diff --git a/.dockerignore b/.dockerignore index fe1152b..fd0f72c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ **/.classpath **/.dockerignore -**/.env +.env **/.git **/.gitignore **/.project @@ -15,7 +15,7 @@ **/bin **/charts **/docker-compose* -**/Dockerfile* +Dockerfile **/node_modules **/npm-debug.log **/obj @@ -27,4 +27,5 @@ README.md !.git/HEAD !.git/config !.git/packed-refs -!.git/refs/heads/** \ No newline at end of file +!.git/refs/heads/** +!docker-compose.dcproj \ No newline at end of file diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 77defab..63b6206 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -25,11 +25,11 @@ jobs: with: dotnet-version: 8.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore Hello.sln - name: Build - run: dotnet build --no-restore + run: dotnet build Hello.sln --no-restore - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test Hello.sln --no-build --verbosity normal docker: runs-on: ubuntu-latest needs: [build] @@ -58,6 +58,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . + file: ./core/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index cee6411..0000000 --- a/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -USER app -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 - -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY core/api.core.csproj ./core/ -COPY emails/api.emails.csproj ./emails/ -COPY files/api.files.csproj ./files/ -COPY tests/api.tests.csproj ./tests/ -COPY Hello.sln ./ -RUN dotnet restore - -COPY . . -WORKDIR /src -RUN dotnet build -c $BUILD_CONFIGURATION -o /app/build - -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false - -FROM base AS runtime -WORKDIR /app -COPY --chown=app --from=publish /app/publish . -ENTRYPOINT ["dotnet", "api.core.dll"] \ No newline at end of file diff --git a/Hello.sln b/Hello.sln index 612455e..7364b28 100644 --- a/Hello.sln +++ b/Hello.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34330.188 +# Visual Studio Version 18 +VisualStudioVersion = 18.10.12217.157 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "api.core", "core\api.core.csproj", "{5136ED6D-92F3-4B1E-8E93-3E5FBAA66607}" EndProject @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "api.emails", "emails\api.em EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "api.files", "files\api.files.csproj", "{F2425268-5799-42FF-9778-5A1BB7B9DE31}" EndProject +Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{81DDED9D-158B-E303-5F62-77A2896D2A5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {F2425268-5799-42FF-9778-5A1BB7B9DE31}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2425268-5799-42FF-9778-5A1BB7B9DE31}.Release|Any CPU.ActiveCfg = Release|Any CPU {F2425268-5799-42FF-9778-5A1BB7B9DE31}.Release|Any CPU.Build.0 = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 45d0b02..8f33cda 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,48 @@ This API allows to interface the database and handles business functions ## Installation Different ways can be used to spin up a backend Hello API. -### Create the DB -First of, you need to create a postgreSQL DB if it's not already done. +### Creating the DataBase -> ⚠️ If your database is already created and running, you can skip this step and directly go to [Run the image](#run-the-image). - -Access this [repository](https://github.com/ApplETS/Hello-Database-Setup) to get started! +The Docker Compose will create a local instance. If you already have a PostgreSQL instance running on your machine, make sure to deactivate it before booting up the Compose, as it will claim the same port. ### Installation introduction -There is two ways you can install the API: -- Using a docker image: [Docker Installation](#-docker-installation) +There are multiple ways you can install the API: +- Using the docker compose file: [Docker Compose](#-docker-compose) + - Depending on the running parameter you can modify the existing code. +- Using a docker image: [Docker Installation](#-docker-installation) (Deprecated) - This method doesn't allows you to modify easily the existing code and is more suited for a local server when there is only front-end development to be done. -- Using the classic approach with Visual Studio: [Local Installation](#-local-installation) +- Using Compose with Visual Studio: [Integrated Compose with Visual Studio](#-compose-with-visual-studio) + +### 🐳 Docker Compose +After cloning this repository, open it via a terminal or an IDE. +```bash +cd path/to/repo +# With an SSH Key +git clone git@github.com:ApplETS/Backend-Hello.git +# Without an ssh key +git clone https://github.com/ApplETS/Backend-Hello.git +``` +You'll need to setup the environment variables in the `.env` file. +Simply copy and paste the `.env.template` file, rename it to `.env` and fill it with the correct values. + +Or, in the same directory as this README, run this command and fill it with the correct values: +```bash +cp core/.env.template .env +``` +In the same directory as this README, run this the docker compose command: +```bash +docker compose up -d +``` + +After running the docker compose file, the api should be accessible via: +```bash +# API +http://localhost:8081/api +# SWAGGER +http://localhost:8081/swagger/index.html +``` +> ⚠️ Don't forget to rebuild the app after modifying the source code, use the command docker compose up -d --build ### 🐳 Docker Installation Start by pulling the image from docker hub @@ -34,31 +63,34 @@ Simply copy and paste the `.env.template` file, rename it to `.env` and fill it Then, run the image on your local machine ```bash -docker run --env-file .env --restart always -d -p 8080:8080 -v :/app/volume:rw --name ps-api ghcr.io/applets/backend-hello: +docker compose --env-file ./core/.env up -d ``` -You can navigate to `http://localhost:8080/swagger` to check if the API is running correctly! +You can navigate to `http://localhost:8081/swagger` to check if the API is running correctly! -### 👷 Local Installation +### 👷 Compose with Visual Studio You will need a couple of things to get started developing and maintaining Hello-Backend. +- [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install). Should be installed with Docker. - Docker (suppose to be already installed at this point) - [Git](https://git-scm.com/downloads) -- Visual Studio 2022 or above version. +- Visual Studio 2022 or above version. (Visual Studio 2026 does not include .NET 8 by default) - Make sure to check the ASP.NET and web development component image -- .NET8 (which is supposed to come package with VS2022, but in case it's not.) +- .NET 8 (Does come with VS2022, but not VS2026) + +NB: Compose is guaranteed to work with a Visual Studio 2026 instance. VS2022 might not work as straightforward. #### Setup 1. Open Bash, navigate to somewhere you like: ```bash cd path/to/repo ``` -2. Clone the repo using an [ssh key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) or without: +2. Clone the repo using an [ssh key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) or by HTTPS: ```bash # With an SSH Key git clone git@github.com:ApplETS/Backend-Hello.git -# Without an ssh key +# With HTTPS git clone https://github.com/ApplETS/Backend-Hello.git ``` 3. Open the .sln in Visual Studio 2022 @@ -66,10 +98,8 @@ git clone https://github.com/ApplETS/Backend-Hello.git #### Running the app using Visual Studio -To run the app make sure to check Docker to run it using docker: - -image +To run the app, make sure to select the docker-compose startup project: image -Then click on ![image](https://github.com/ApplETS/Backend-Hello/assets/25663435/de874f36-47e0-4e9f-8c8f-9e7e21a67d6c) to run the API. +Then click on ![Docker Compose](https://github.com/ApplETS/Backend-Hello/assets/25663435/de874f36-47e0-4e9f-8c8f-9e7e21a67d6c) to run the API. -You can navigate to http://localhost:8080 where you're app is running. +You can navigate to http://localhost:8081/swagger where the app is running. diff --git a/core/.env.template b/core/.env.template index 8724981..fb1f928 100644 --- a/core/.env.template +++ b/core/.env.template @@ -1,11 +1,10 @@ # Environment Setup - ASPNETCORE_HTTPS_PORT=8081 ASPNETCORE_HTTP_PORT=8080 ASPNETCORE_ENVIRONMENT=Development +CONNECTION_STRING=Host=db;Port=5432;Username=postgres;Password=test123;Database=ps; -CONNECTION_STRING=User ID=postgres;Password=test123;Host=host.docker.internal;Port=5432;Database=ps; - +# Optional for dev EMAIL_SERVER=smtp.gmail.com EMAIL_PORT=587 EMAIL_USERNAME= @@ -18,14 +17,23 @@ REDIS_CONNECTION_STRING=host.docker.internal:6379,password=eYVX7EwVmmxKPCDmwMtyK CONTAINER_DIR=/app/volume CDN_URL=http://localhost:6464 +# Communicate with one of the ADMIN to get these information. OPENID_ISSUER=https://example.com OPENID_BASE_URL=[Insert your URL here] OPENID_CLIENT_ID=[Insert your client id here] OPENID_CLIENT_SECRET=[Insert your client secret here] -FRONTEND_BASE_URL=http://localhost:3000 +FRONTEND_BASE_URL=http://localhost:8080 RATE_LIMIT_TIME_WINDOW_SECONDS=10 RATE_LIMIT_MAX_REQUESTS=4 ADMIN_ACCESS_API_KEY= + +# The Database services environmental variable +# Default value, if changed check the connection_string +POSTGRES_USER=postgres +POSTGRES_PASSWORD=test123 +POSTGRES_DB=postgres +# Directory of CDN +HOST_CDN_DIR=[Insert your CDN directory here] \ No newline at end of file diff --git a/core/Data/Entities/User.cs b/core/Data/Entities/User.cs index 3886b7f..3effce5 100644 --- a/core/Data/Entities/User.cs +++ b/core/Data/Entities/User.cs @@ -67,7 +67,7 @@ public class User [Flags] public enum UserRole { - Admin = 0b10000000, - Moderator = 0b01000000, - Organizer = 0b00100000 + Admin = 0x1, + Moderator = 0x2, + Organizer = 0x4 } \ No newline at end of file diff --git a/core/Dockerfile b/core/Dockerfile index d0241c0..194c88f 100644 --- a/core/Dockerfile +++ b/core/Dockerfile @@ -1,29 +1,27 @@ FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -USER app +USER $APP_UID WORKDIR /app -EXPOSE 8080 EXPOSE 8081 +EXPOSE 8082 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src -COPY ../core/api.core.csproj ./core/ -COPY ../emails/api.emails.csproj ./emails/ -COPY ../files/api.files.csproj ./files/ -COPY ../tests/api.tests.csproj ./tests/ -COPY ../Hello.sln ./ -RUN dotnet restore +COPY ["core/api.core.csproj", "core/"] +COPY ["emails/api.emails.csproj", "emails/"] +COPY ["files/api.files.csproj", "files/"] +RUN dotnet restore "./core/api.core.csproj" -COPY ../ . -WORKDIR /src -RUN dotnet build -c $BUILD_CONFIGURATION -o /app/build +COPY . . +WORKDIR "/src/core" +RUN dotnet build "./api.core.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Release -RUN dotnet publish -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet publish "./api.core.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false -FROM base AS runtime +FROM base AS final WORKDIR /app -COPY --chown=app --from=publish /app/publish . +COPY --chown=$APP_UID --from=publish /app/publish . RUN mkdir -p /app/volume # create volume directory to store images -ENTRYPOINT ["dotnet", "api.core.dll"] +ENTRYPOINT ["dotnet", "api.core.dll"] \ No newline at end of file diff --git a/core/Extensions/DependencyInjectionExtension.cs b/core/Extensions/DependencyInjectionExtension.cs index a3e52ed..0a65e17 100644 --- a/core/Extensions/DependencyInjectionExtension.cs +++ b/core/Extensions/DependencyInjectionExtension.cs @@ -41,6 +41,7 @@ public static IServiceCollection AddDependencyInjection(this IServiceCollection services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Utils services.AddTransient(); diff --git a/core/Program.cs b/core/Program.cs index b8bb114..4ad40ab 100644 --- a/core/Program.cs +++ b/core/Program.cs @@ -29,7 +29,16 @@ builder.Configuration.AddEnvironmentVariables(); builder.Services.AddDbContext(opt => opt.UseNpgsql(connectionString)); - +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowFrontend", + policy => + { + policy.WithOrigins(Environment.GetEnvironmentVariable("FRONTEND_BASE_URL")!) // Put your frontend URL here + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); var key = Encoding.ASCII.GetBytes(Environment.GetEnvironmentVariable("OPENID_CLIENT_SECRET") ?? ""); builder.Services @@ -70,7 +79,7 @@ ValidAudience = Environment.GetEnvironmentVariable("OPENID_CLIENT_ID") }; // Ensure HTTPS is used (should be true in production) - options.RequireHttpsMetadata = true; + options.RequireHttpsMetadata = false; }); //.AddOpenIdConnect(options => // { @@ -181,11 +190,11 @@ app.UseSwagger(); app.UseSwaggerUI(); +app.UseCors("AllowFrontend"); app.UseExceptionMiddleware(); // app.UseHttpsRedirection(); - app.UseAuthentication(); app.UseAuthorization(); diff --git a/core/Properties/launchSettings.json b/core/Properties/launchSettings.json index bbea7fc..4940e1e 100644 --- a/core/Properties/launchSettings.json +++ b/core/Properties/launchSettings.json @@ -8,7 +8,7 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, - "applicationUrl": "http://localhost:5010" + "applicationUrl": "http://localhost:8081" }, "https": { "commandName": "Project", @@ -35,8 +35,8 @@ "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", "publishAllPorts": true, "useSSL": true, - "httpPort": 8080, - "sslPort": 8081, + "httpPort": 8081, + "sslPort": 8082, "DockerfileRunArguments": "--rm --env-file .env" } }, diff --git a/core/Services/Abstractions/IIdentityProviderService.cs b/core/Services/Abstractions/IIdentityProviderService.cs new file mode 100644 index 0000000..a587baf --- /dev/null +++ b/core/Services/Abstractions/IIdentityProviderService.cs @@ -0,0 +1,26 @@ +namespace api.core.Services.Abstractions; + +/// +/// Interface to communicate with any ID Provider +/// +public interface IIdentityProviderService +{ + /// + /// Fetches User's information from the ID Provider + /// + /// Header used to call the endpoint + /// If result is null, UserInfo endpoint cannot be accessed + UserInfoDto? GetUserInfo(string accessHeader); +} + +public class UserInfoDto +{ + public string Sub { get; set; } = null!; + public string Email { get; set; } = null!; + public bool EmailVerified { get; set; } + public string Name { get; set; } = null!; + public string GivenName { get; set; } = null!; + public string PreferedUsername { get; set; } = null!; + public string Nickname { get; set; } = null!; + public List Groups { get; set; } = null!; +} \ No newline at end of file diff --git a/core/Services/Abstractions/IUserService.cs b/core/Services/Abstractions/IUserService.cs index c487b3e..f2246b9 100644 --- a/core/Services/Abstractions/IUserService.cs +++ b/core/Services/Abstractions/IUserService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.Tracing; +using System.Diagnostics.Tracing; using api.core.Data.Enums; using api.core.Data.requests; diff --git a/core/Services/AuthentikService.cs b/core/Services/AuthentikService.cs new file mode 100644 index 0000000..efa4639 --- /dev/null +++ b/core/Services/AuthentikService.cs @@ -0,0 +1,30 @@ +using api.core.Services.Abstractions; + +using System.Text.Json; + +namespace api.core.Services; + +/// +/// Service used to communicate with Authentik ID Provider +/// +public class AuthentikService : IIdentityProviderService +{ + public UserInfoDto? GetUserInfo(string accessHeader) + { + using HttpClient client = new HttpClient(); + client.BaseAddress = new Uri(Environment.GetEnvironmentVariable("OPENID_BASE_URL")!); + + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "userinfo/"); + request.Headers.Add("Authorization", accessHeader); + + // TODO : Changer pour un comportement asynchrone + var response = client.SendAsync(request).Result; + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return response.Content.ReadFromJsonAsync(new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }).Result; + } +} diff --git a/core/Services/UserService.cs b/core/Services/UserService.cs index 7b56b99..50eae10 100644 --- a/core/Services/UserService.cs +++ b/core/Services/UserService.cs @@ -1,4 +1,4 @@ -using api.core.data.entities; +using api.core.data.entities; using api.core.Data.Exceptions; using api.core.Data.Enums; using api.core.Data.requests; @@ -12,6 +12,7 @@ using api.core.Misc; using api.core.Repositories.Abstractions; using api.core.Data.Entities; +using api.core.Services.Abstractions; namespace api.core.Services; @@ -21,7 +22,8 @@ public class UserService( ITagRepository tagRepository, IActivityAreaRepository activityAreaRepository, IImageService imageService, - IJwtUtils jwtUtils) : IUserService + IJwtUtils jwtUtils, + IIdentityProviderService identityProvider) : IUserService { private const string AVATAR_FILE_NAME = "avatar.webp"; @@ -57,16 +59,11 @@ public UserResponseDTO AddOrganizer(string id, UserCreateDTO organizerDto) public UserResponseDTO GetUser(string authHeader) { string userId = jwtUtils.GetUserIdFromAuthHeader(authHeader); - UserResponseDTO? userRes = null; - var organizer = userRepository.GetOrganizer(userId); - if (organizer != null) - userRes = UserResponseDTO.Map(organizer!); + var user = userRepository.Get(userId); - var moderator = userRepository.GetModerator(userId); - if (moderator != null) - userRes = UserResponseDTO.Map(moderator!); - - if (userRes == null) throw new Exception("No users associated with this ID"); + UserResponseDTO? userRes = user == null ? + AddUser(authHeader, userId) : + UserResponseDTO.Map(user); var fields = tagRepository.GetInterestFieldsForOrganizer(userId); userRes.FieldsOfInterests = fields; @@ -166,4 +163,31 @@ public string UpdateUserAvatar(string id, IFormFile avatarFile) var url = fileShareService.FileGetDownloadUri($"{userId}/{AVATAR_FILE_NAME}"); return url.ToString(); } + + /// + /// Adds a new user by calling the ID Provider endpoint for the users' information + /// + /// The header used for calling de ID Provider's endpoint + /// User to add + /// Formatted DTO of User's information + private UserResponseDTO AddUser(string authHeader, string userId) + { + UserInfoDto? userInfo = identityProvider.GetUserInfo(authHeader); + + if (userInfo == null) + { + throw new Exception("No users associated with this ID"); + } + + User addedUser = userRepository.Add(new User + { + Email = userInfo.Email, + Id = userId, + ProfileDescription = userInfo.GivenName, + Organization = string.Empty, + IsActive = true + }); + + return UserResponseDTO.Map(addedUser); + } } diff --git a/core/api.core.csproj b/core/api.core.csproj index d02811e..1b6d978 100644 --- a/core/api.core.csproj +++ b/core/api.core.csproj @@ -12,6 +12,7 @@ false false 1591 + ..\docker-compose.dcproj diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..5edd358 --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,16 @@ + + + + 2.1 + Linux + 81dded9d-158b-e303-5f62-77a2896d2a5a + LaunchBrowser + {Scheme}://localhost:{ServicePort}/swagger + core + ./core/.env + + + + + + \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 9c6fa6a..848ada1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,42 +1,46 @@ -version: '3.9' +# Make sure the .env file is ready and has the correct values services: - backend: - image: ghcr.io/applets/backend-hello:main - restart: always - ports: - - 8080:8080 - env_file: - - core/.env - volumes: - - ./backend-data:/app/volume:rw adminer: image: adminer restart: always ports: - - 8090:8080 + - "8090:8080" + extra_hosts: + - "host.docker.internal:host-gateway" db: image: postgres restart: always ports: - - 5432:5432 - environment: - - POSTGRES_PASSWORD=test123 + - "5432:5432" # Check if we want to expose this port to the host env_file: - - core/.env + - ./core/.env redis: image: redis:7.2.4 restart: always ports: - - '63790:6379' + - "6379:6379" command: redis-server --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81 - volumes: + volumes: - cache:/data cdn: image: nginx:1.25.3 restart: always ports: - - 6464:80 + - "6464:80" + env_file: + - ./core/.env + volumes: + - ${HOST_CDN_DIR}:/usr/share/nginx/html:Z + api.core: + image: apicore + build: + context: . + dockerfile: core/Dockerfile + ports: + - "8081:8080" + - "8082:8081" + env_file: + - ./core/.env volumes: cache: driver: local - \ No newline at end of file diff --git a/launchSettings.json b/launchSettings.json new file mode 100644 index 0000000..2ecf213 --- /dev/null +++ b/launchSettings.json @@ -0,0 +1,15 @@ +{ + "profiles": { + "Docker Compose": { + "commandName": "DockerCompose", + "commandVersion": "1.0", + "serviceActions": { + "adminer": "StartWithoutDebugging", + "cdn": "StartWithoutDebugging", + "api.core": "StartDebugging", + "db": "StartWithoutDebugging", + "redis": "StartWithoutDebugging" + } + } + } +} \ No newline at end of file diff --git a/readme_assets/compose-vs-startup.png b/readme_assets/compose-vs-startup.png new file mode 100644 index 0000000..20004e1 Binary files /dev/null and b/readme_assets/compose-vs-startup.png differ diff --git a/tests/Tests/Services/UserServiceTests.cs b/tests/Tests/Services/UserServiceTests.cs index 0e7ce00..79676f7 100644 --- a/tests/Tests/Services/UserServiceTests.cs +++ b/tests/Tests/Services/UserServiceTests.cs @@ -15,6 +15,7 @@ using api.core.Repositories.Abstractions; using Microsoft.IdentityModel.JsonWebTokens; using api.core.Misc; +using api.core.Services.Abstractions; namespace api.tests.Tests.Services; public class UserServiceTests @@ -25,6 +26,7 @@ public class UserServiceTests private readonly Mock _fileShareServiceMock; private readonly Mock _imageServiceMock; private readonly Mock _jwtUtilsMock; + private readonly Mock _providerServiceMock; private readonly UserService _userService; public UserServiceTests() @@ -35,6 +37,7 @@ public UserServiceTests() _fileShareServiceMock = new Mock(); _imageServiceMock = new Mock(); _jwtUtilsMock = new Mock(); + _providerServiceMock = new Mock(); _fileShareServiceMock.Setup(service => service.FileGetDownloadUri(It.IsAny())).Returns(new Uri("http://example.com/avatar.webp")); _userService = new UserService( @@ -43,7 +46,8 @@ public UserServiceTests() _tagRepositoryMock.Object, _activityAreaRepositoryMock.Object, _imageServiceMock.Object, - _jwtUtilsMock.Object); + _jwtUtilsMock.Object, + _providerServiceMock.Object); } [Fact] @@ -112,7 +116,7 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenOrganizerIsFoundById() Role = UserRole.Organizer }; - _userRepositoryMock.Setup(repo => repo.GetOrganizer(organizerId)).Returns(organizer); + _userRepositoryMock.Setup(repo => repo.Get(organizerId)).Returns(organizer); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(organizerId)).Returns(organizerId); // Act @@ -123,7 +127,7 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenOrganizerIsFoundById() result.Id.Should().Be(organizerId); result.Email.Should().Be(organizer.Email); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(organizerId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(organizerId), Times.Once); } [Fact] @@ -141,8 +145,7 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenModeratorIsFoundById() }; _jwtUtilsMock.Setup(jwtUtil => jwtUtil.GetUserIdFromAuthHeader(moderatorId)).Returns(moderatorId); - _userRepositoryMock.Setup(repo => repo.GetOrganizer(moderatorId)).Returns((User?)null); // Simulate no organizer found - _userRepositoryMock.Setup(repo => repo.GetModerator(moderatorId)).Returns(moderator); // Simulate moderator found + _userRepositoryMock.Setup(repo => repo.Get(moderatorId)).Returns(moderator); // Simulate moderator found // Act var result = _userService.GetUser(moderatorId); @@ -152,29 +155,76 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenModeratorIsFoundById() result.Id.Should().Be(moderatorId); result.Email.Should().Be(moderator.Email); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(moderatorId), Times.Once); - _userRepositoryMock.Verify(repo => repo.GetModerator(moderatorId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(moderatorId), Times.Once); _jwtUtilsMock.Verify(jwtUtil => jwtUtil.GetUserIdFromAuthHeader(moderatorId), Times.Once); } [Fact] - public void GetUser_ShouldThrowException_WhenNoUserIsAssociatedWithProvidedId() + public void GetUser_ShouldReturnUserResponseDTO_WhenNewUserIsFoundById() + { + // Arrange + var userId = "userId"; + var user = new User + { + Id = userId, + Email = "jane.doe@example.com", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + var userInfo = new UserInfoDto + { + Email = "jane.doe@example.com", + EmailVerified = true, + GivenName = "Jane Doe", + Name = "Jane Doe", + Nickname = "jane.doe", + PreferedUsername = "jane.doe", + Sub = userId + }; + + _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(userId)).Returns(userId); + _userRepositoryMock.Setup(repo => repo.Get(userId)).Returns(null as User); + _providerServiceMock.Setup(provider => provider.GetUserInfo(userId)).Returns(userInfo); + _userRepositoryMock.Setup(repo => + repo.Add(It.Is(u => u.Id == userInfo.Sub && u.Email == userInfo.Email && u.ProfileDescription == userInfo.GivenName))) + .Returns(user); + + // Act + var result = _userService.GetUser(userId); + + // Assert + result.Should().NotBeNull(); + result.Type.Should().BeEmpty(); + result.Id.Should().Be(user.Id); + result.Email.Should().Be(user.Email); + + _jwtUtilsMock.Verify(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(userId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(userId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Add(It.IsAny()), Times.Once); + _providerServiceMock.Verify(provider => provider.GetUserInfo(userId), Times.Once); + } + + [Fact] + public void GetUser_ShouldThrowException_WhenNoUserIsAssociatedWithProvidedIdNorValid() { // Arrange var userId = "nobody"; // Setup both organizer and moderator repositories to return null, simulating that no user is found with the provided ID - _userRepositoryMock.Setup(repo => repo.GetOrganizer(userId)).Returns(null as User); - _userRepositoryMock.Setup(repo => repo.GetModerator(userId)).Returns(null as User); + _userRepositoryMock.Setup(repo => repo.Get(userId)).Returns(null as User); _jwtUtilsMock.Setup(jwtUtil => jwtUtil.GetUserIdFromAuthHeader(userId)).Returns(userId); + // Setup the provider call as if the call was invalid (no connection or invalid token) + _providerServiceMock.Setup(provider => provider.GetUserInfo(userId)).Returns(null as UserInfoDto); + // Act Action act = () => _userService.GetUser(userId); // Assert act.Should().Throw().WithMessage("No users associated with this ID"); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(userId), Times.Once); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(userId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(userId), Times.Once); + _providerServiceMock.Verify(provider => provider.GetUserInfo(userId), Times.Once); } @@ -209,7 +259,7 @@ public void UpdateUser_ShouldReturnTrue_WhenOrganizerIsUpdatedSuccessfully() }; _activityAreaRepositoryMock.Setup(repo => repo.Get(actAreaIdModified)).Returns(activity); // Simulate activity area found - _userRepositoryMock.Setup(repo => repo.GetOrganizer(organizerId)).Returns(existingOrganizer); + _userRepositoryMock.Setup(repo => repo.Get(organizerId)).Returns(existingOrganizer); _userRepositoryMock.Setup(repo => repo.Update(organizerId, It.IsAny())).Returns(true); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(organizerId)).Returns(organizerId); @@ -250,7 +300,7 @@ public void UpdateUser_ShouldThrow_WhenActivityAreaIsNotFoundInTheList() UpdatedAt = DateTime.UtcNow }; _activityAreaRepositoryMock.Setup(repo => repo.Get(badActAreaIdModified)).Returns(null as ActivityArea); // Simulate activity area not found - _userRepositoryMock.Setup(repo => repo.GetOrganizer(organizerId)).Returns(existingOrganizer); + _userRepositoryMock.Setup(repo => repo.Get(organizerId)).Returns(existingOrganizer); _userRepositoryMock.Setup(repo => repo.Update(organizerId, It.IsAny())).Returns(true); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(organizerId)).Returns(organizerId); @@ -279,7 +329,7 @@ public void UpdateUser_ShouldReturnTrue_WhenModeratorIsUpdatedSuccessfully() Role = UserRole.Moderator }; - _userRepositoryMock.Setup(repo => repo.GetModerator(moderatorId)).Returns(existingModerator); // Simulate moderator found + _userRepositoryMock.Setup(repo => repo.Get(moderatorId)).Returns(existingModerator); // Simulate moderator found _userRepositoryMock.Setup(repo => repo.Update(moderatorId, It.IsAny())).Returns(true); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(moderatorId)).Returns(moderatorId);