From a1a63b11bb9afe2dd9de3e2ea62e14495af7e1f6 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 20 Jun 2026 00:59:18 -0400 Subject: [PATCH] Migrate Auth and Emails to vertical slices (Features.Auth, Features.Emails) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth and Emails land together since Auth's registration/resend flows need Emails to exist first. Service.Auth's four services (Register, Login, Confirmation, Token) collapse into MediatR commands/queries in Features.Auth; TokenService stays as a slice-internal service since multiple handlers call it. Auth no longer references Emails directly — it sends SendRegistrationEmailCommand/SendResendConfirmationEmailCommand (defined in Shared.Application) which Features.Emails handles, so neither slice has a project reference on the other. Service.Emails' IEmailService becomes Features.Emails' IEmailDispatcher, simplified to take (firstName, email, token) instead of a full UserAccount since that's all it ever used. API.Specs' TestApiFactory/MockEmailService are updated to swap in the relocated interface. Also deletes Infrastructure.Repository now that Breweries, UserManagement, and Auth have all moved their repos out of it, and replaces the two now-dead docker-compose test services (repository.tests, service.auth.tests, both pointing at deleted Dockerfiles) with a single unit.tests service that runs every Features.*.Tests project via Dockerfile.tests. --- web/backend/API/API.Core/API.Core.csproj | 6 +- .../API/API.Core/Contracts/Auth/Login.cs | 37 -- .../API.Core/Contracts/Auth/RefreshToken.cs | 31 -- .../API.Core/Controllers/AuthController.cs | 173 ---------- web/backend/API/API.Core/Dockerfile | 9 +- web/backend/API/API.Core/Program.cs | 30 +- web/backend/API/API.Specs/API.Specs.csproj | 1 + web/backend/API/API.Specs/Dockerfile | 29 +- ...EmailService.cs => MockEmailDispatcher.cs} | 23 +- web/backend/API/API.Specs/TestApiFactory.cs | 14 +- web/backend/Core.slnx | 12 +- .../Database.Seed/Database.Seed.csproj | 2 - web/backend/Dockerfile.tests | 15 + .../Commands/ConfirmUserHandlerTests.cs | 87 +++++ .../Commands/RefreshTokenHandlerTests.cs | 30 ++ .../Commands/RegisterUserHandlerTests.cs | 165 +++++++++ .../ResendConfirmationEmailHandlerTests.cs | 73 ++++ .../Features.Auth.Tests.csproj | 26 ++ .../Queries/LoginHandlerTests.cs | 110 ++++++ .../Repository/AuthRepositoryTests.cs} | 7 +- .../Repository}/TestConnectionFactory.cs | 2 +- .../Services/TokenServiceRefreshTests.cs} | 9 +- .../Services/TokenServiceValidationTests.cs} | 9 +- .../ConfirmUser/ConfirmUserCommand.cs | 10 + .../ConfirmUser/ConfirmUserHandler.cs | 36 ++ .../RefreshToken/RefreshTokenCommand.cs | 10 + .../RefreshToken/RefreshTokenHandler.cs | 20 ++ .../RefreshToken/RefreshTokenValidator.cs | 19 ++ .../RegisterUser/RegisterUserCommand.cs | 22 ++ .../RegisterUser/RegisterUserHandler.cs | 86 +++++ .../RegisterUser/RegisterUserValidator.cs} | 27 +- .../ResendConfirmationEmailCommand.cs | 9 + .../ResendConfirmationEmailHandler.cs | 44 +++ .../Controllers/AuthController.cs | 129 +++++++ ...FeaturesAuthServiceCollectionExtensions.cs | 20 ++ .../Features.Auth/Dtos/AuthPayloads.cs} | 5 +- .../Features.Auth/Features.Auth.csproj | 22 ++ .../Queries/Login/LoginHandler.cs | 45 +++ .../Features.Auth/Queries/Login/LoginQuery.cs | 10 + .../Queries/Login/LoginValidator.cs | 20 ++ .../Repository}/AuthRepository.cs | 4 +- .../Repository}/IAuthRepository.cs | 2 +- .../Features.Auth/Services/ITokenService.cs | 113 ++++++ .../Features.Auth/Services/TokenService.cs} | 113 +----- .../Features.Breweries.csproj | 2 +- .../SendRegistrationEmailHandlerTests.cs | 24 ++ ...SendResendConfirmationEmailHandlerTests.cs | 24 ++ .../Features.Emails.Tests.csproj | 25 ++ .../SendRegistrationEmailHandler.cs | 17 + .../SendResendConfirmationEmailHandler.cs | 17 + ...aturesEmailsServiceCollectionExtensions.cs | 20 ++ .../Features.Emails/Features.Emails.csproj | 14 + .../Services/EmailDispatcher.cs | 73 ++++ .../Services/IEmailDispatcher.cs | 25 ++ .../Dockerfile | 17 - .../Infrastructure.Repository.Tests.csproj | 27 -- .../Infrastructure.Repository.csproj | 24 -- .../ConfirmationService.test.cs | 159 --------- .../Service/Service.Auth.Tests/Dockerfile | 21 -- .../Service.Auth.Tests/LoginService.test.cs | 254 -------------- .../RegisterService.test.cs | 322 ------------------ .../Service.Auth.Tests.csproj | 28 -- .../Service.Auth/ConfirmationService.cs | 78 ----- .../Service.Auth/IConfirmationService.cs | 32 -- .../Service/Service.Auth/ILoginService.cs | 29 -- .../Service/Service.Auth/IRegisterService.cs | 83 ----- .../Service/Service.Auth/LoginService.cs | 57 ---- .../Service/Service.Auth/RegisterService.cs | 120 ------- .../Service/Service.Auth/Service.Auth.csproj | 22 -- .../Service/Service.Emails/EmailService.cs | 114 ------- .../Service.Emails/Service.Emails.csproj | 15 - web/docker-compose.test.yaml | 28 +- 72 files changed, 1456 insertions(+), 1881 deletions(-) delete mode 100644 web/backend/API/API.Core/Contracts/Auth/Login.cs delete mode 100644 web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs delete mode 100644 web/backend/API/API.Core/Controllers/AuthController.cs rename web/backend/API/API.Specs/Mocks/{MockEmailService.cs => MockEmailDispatcher.cs} (70%) create mode 100644 web/backend/Dockerfile.tests create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj create mode 100644 web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs rename web/backend/{Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs => Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs} (98%) rename web/backend/{Infrastructure/Infrastructure.Repository.Tests/Database => Features/Features.Auth.Tests/Repository}/TestConnectionFactory.cs (82%) rename web/backend/{Service/Service.Auth.Tests/TokenServiceRefresh.test.cs => Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs} (96%) rename web/backend/{Service/Service.Auth.Tests/TokenServiceValidation.test.cs => Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs} (98%) create mode 100644 web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs create mode 100644 web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs create mode 100644 web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs create mode 100644 web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs rename web/backend/{API/API.Core/Contracts/Auth/Register.cs => Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs} (63%) create mode 100644 web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs create mode 100644 web/backend/Features/Features.Auth/Controllers/AuthController.cs create mode 100644 web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs rename web/backend/{API/API.Core/Contracts/Auth/AuthDTO.cs => Features/Features.Auth/Dtos/AuthPayloads.cs} (94%) create mode 100644 web/backend/Features/Features.Auth/Features.Auth.csproj create mode 100644 web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs create mode 100644 web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs create mode 100644 web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs rename web/backend/{Infrastructure/Infrastructure.Repository/Auth => Features/Features.Auth/Repository}/AuthRepository.cs (99%) rename web/backend/{Infrastructure/Infrastructure.Repository/Auth => Features/Features.Auth/Repository}/IAuthRepository.cs (98%) create mode 100644 web/backend/Features/Features.Auth/Services/ITokenService.cs rename web/backend/{Service/Service.Auth/ITokenService.cs => Features/Features.Auth/Services/TokenService.cs} (68%) create mode 100644 web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs create mode 100644 web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs create mode 100644 web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj create mode 100644 web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs create mode 100644 web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs create mode 100644 web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs create mode 100644 web/backend/Features/Features.Emails/Features.Emails.csproj create mode 100644 web/backend/Features/Features.Emails/Services/EmailDispatcher.cs create mode 100644 web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs delete mode 100644 web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile delete mode 100644 web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj delete mode 100644 web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj delete mode 100644 web/backend/Service/Service.Auth.Tests/ConfirmationService.test.cs delete mode 100644 web/backend/Service/Service.Auth.Tests/Dockerfile delete mode 100644 web/backend/Service/Service.Auth.Tests/LoginService.test.cs delete mode 100644 web/backend/Service/Service.Auth.Tests/RegisterService.test.cs delete mode 100644 web/backend/Service/Service.Auth.Tests/Service.Auth.Tests.csproj delete mode 100644 web/backend/Service/Service.Auth/ConfirmationService.cs delete mode 100644 web/backend/Service/Service.Auth/IConfirmationService.cs delete mode 100644 web/backend/Service/Service.Auth/ILoginService.cs delete mode 100644 web/backend/Service/Service.Auth/IRegisterService.cs delete mode 100644 web/backend/Service/Service.Auth/LoginService.cs delete mode 100644 web/backend/Service/Service.Auth/RegisterService.cs delete mode 100644 web/backend/Service/Service.Auth/Service.Auth.csproj delete mode 100644 web/backend/Service/Service.Emails/EmailService.cs delete mode 100644 web/backend/Service/Service.Emails/Service.Emails.csproj diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index b82cf27..6c2b5ae 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -27,14 +27,12 @@ - - - - + + diff --git a/web/backend/API/API.Core/Contracts/Auth/Login.cs b/web/backend/API/API.Core/Contracts/Auth/Login.cs deleted file mode 100644 index ce64c9a..0000000 --- a/web/backend/API/API.Core/Contracts/Auth/Login.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Shared.Contracts; -using FluentValidation; - -namespace API.Core.Contracts.Auth; - -/// -/// Request body for the login endpoint, containing the credentials used to authenticate a user. -/// -public record LoginRequest -{ - /// - /// The username of the account attempting to log in. - /// - public string Username { get; init; } = default!; - - /// - /// The plaintext password of the account attempting to log in. - /// - public string Password { get; init; } = default!; -} - -/// -/// Validates instances before they are processed by the login endpoint. -/// -public class LoginRequestValidator : AbstractValidator -{ - /// - /// Configures validation rules requiring both and - /// to be non-empty. - /// - public LoginRequestValidator() - { - RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required"); - - RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required"); - } -} diff --git a/web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs b/web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs deleted file mode 100644 index f2705cc..0000000 --- a/web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs +++ /dev/null @@ -1,31 +0,0 @@ -using FluentValidation; - -namespace API.Core.Contracts.Auth; - -/// -/// Request body for the token refresh endpoint, containing the refresh token used to obtain a new access token. -/// -public record RefreshTokenRequest -{ - /// - /// The refresh token previously issued to the client during login, registration, or a prior refresh. - /// - public string RefreshToken { get; init; } = default!; -} - -/// -/// Validates instances before they are processed by the refresh endpoint. -/// -public class RefreshTokenRequestValidator - : AbstractValidator -{ - /// - /// Configures a validation rule requiring to be non-empty. - /// - public RefreshTokenRequestValidator() - { - RuleFor(x => x.RefreshToken) - .NotEmpty() - .WithMessage("Refresh token is required"); - } -} diff --git a/web/backend/API/API.Core/Controllers/AuthController.cs b/web/backend/API/API.Core/Controllers/AuthController.cs deleted file mode 100644 index 58d7fdb..0000000 --- a/web/backend/API/API.Core/Controllers/AuthController.cs +++ /dev/null @@ -1,173 +0,0 @@ -using API.Core.Contracts.Auth; -using Shared.Contracts; -using Domain.Entities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Service.Auth; - -namespace API.Core.Controllers -{ - /// - /// Handles user authentication concerns: registration, login, email confirmation, and token refresh. - /// - /// - /// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default, but most - /// actions opt out via [AllowAnonymous] since they are entry points used before a caller holds a token. - /// - /// Service used to register new user accounts. - /// Service used to authenticate existing user accounts. - /// Service used to confirm user accounts and resend confirmation emails. - /// Service used to refresh JWT access/refresh token pairs. - [ApiController] - [Route("api/[controller]")] - [Authorize(AuthenticationSchemes = "JWT")] - public class AuthController( - IRegisterService registerService, - ILoginService loginService, - IConfirmationService confirmationService, - ITokenService tokenService - ) : ControllerBase - { - /// - /// Registers a new user account. - /// - /// - /// Allows anonymous access. On success, responds with 201 Created containing the new user's ID, - /// username, issued refresh/access tokens, and whether a confirmation email was sent. - /// - /// The registration details, including username, name, email, date of birth, and password. - /// A 201 Created result wrapping a of . - [AllowAnonymous] - [HttpPost("register")] - public async Task> Register( - [FromBody] RegisterRequest req - ) - { - var rtn = await registerService.RegisterAsync( - new UserAccount - { - UserAccountId = Guid.Empty, - Username = req.Username, - FirstName = req.FirstName, - LastName = req.LastName, - Email = req.Email, - DateOfBirth = req.DateOfBirth, - }, - req.Password - ); - - var response = new ResponseBody - { - Message = "User registered successfully.", - Payload = new RegistrationPayload( - rtn.UserAccount.UserAccountId, - rtn.UserAccount.Username, - rtn.RefreshToken, - rtn.AccessToken, - rtn.EmailSent - ), - }; - return Created("/", response); - } - - /// - /// Authenticates a user with a username and password. - /// - /// - /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, - /// username, and a newly issued refresh/access token pair. - /// - /// The login credentials (username and password). - /// An 200 OK result wrapping a of . - [AllowAnonymous] - [HttpPost("login")] - public async Task Login([FromBody] LoginRequest req) - { - var rtn = await loginService.LoginAsync(req.Username, req.Password); - - return Ok( - new ResponseBody - { - Message = "Logged in successfully.", - Payload = new LoginPayload( - rtn.UserAccount.UserAccountId, - rtn.UserAccount.Username, - rtn.RefreshToken, - rtn.AccessToken - ), - } - ); - } - - /// - /// Confirms a user account using a confirmation token. - /// - /// - /// Requires JWT authentication. On success, responds with 200 OK containing the confirmed - /// user's ID and the confirmation timestamp. - /// - /// The confirmation token supplied via the confirmation email link. - /// A 200 OK result wrapping a of . - [HttpPost("confirm")] - public async Task Confirm([FromQuery] string token) - { - var rtn = await confirmationService.ConfirmUserAsync(token); - return Ok( - new ResponseBody - { - Message = "User with ID " + rtn.UserId + " is confirmed.", - Payload = new ConfirmationPayload( - rtn.UserId, - rtn.ConfirmedAt - ), - } - ); - } - - /// - /// Resends the account confirmation email for the specified user. - /// - /// - /// Requires JWT authentication. On success, responds with 200 OK. - /// - /// The unique identifier of the user account to resend the confirmation email for. - /// A 200 OK result wrapping a confirming the email was resent. - [HttpPost("confirm/resend")] - public async Task ResendConfirmation([FromQuery] Guid userId) - { - await confirmationService.ResendConfirmationEmailAsync(userId); - return Ok(new ResponseBody { Message = "confirmation email has been resent" }); - } - - /// - /// Exchanges a valid refresh token for a new access/refresh token pair. - /// - /// - /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, - /// username, and the newly issued refresh/access token pair. - /// - /// The request containing the refresh token to exchange. - /// A 200 OK result wrapping a of . - [AllowAnonymous] - [HttpPost("refresh")] - public async Task Refresh( - [FromBody] RefreshTokenRequest req - ) - { - var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken); - - return Ok( - new ResponseBody - { - Message = "Token refreshed successfully.", - Payload = new LoginPayload( - rtn.UserAccount.UserAccountId, - rtn.UserAccount.Username, - rtn.RefreshToken, - rtn.AccessToken - ), - } - ); - } - } -} diff --git a/web/backend/API/API.Core/Dockerfile b/web/backend/API/API.Core/Dockerfile index a657919..7567388 100644 --- a/web/backend/API/API.Core/Dockerfile +++ b/web/backend/API/API.Core/Dockerfile @@ -21,20 +21,19 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"] +COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"] +COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] +COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] +COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] -COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] -COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"] COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"] -COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"] -COPY ["Service/Service.Auth.Tests/Service.Auth.Tests.csproj", "Service/Service.Auth.Tests/"] -COPY ["Service/Service.Emails/Service.Emails.csproj", "Service/Service.Emails/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] RUN dotnet restore "Core.slnx" diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 14cc423..fabaa40 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -1,19 +1,17 @@ using API.Core; using API.Core.Authentication; +using Features.Auth.Controllers; +using Features.Auth.DependencyInjection; using Features.Breweries.Controllers; using Features.Breweries.DependencyInjection; +using Features.Emails.DependencyInjection; +using Features.Emails.Services; using Features.UserManagement.Controllers; using Features.UserManagement.DependencyInjection; using FluentValidation; using FluentValidation.AspNetCore; -using Infrastructure.Email; -using Infrastructure.Email.Templates.Rendering; using Infrastructure.Jwt; -using Infrastructure.PasswordHashing; -using Infrastructure.Repository.Auth; using Infrastructure.Sql; -using Service.Auth; -using Service.Emails; using Shared.Application.Behaviors; var builder = WebApplication.CreateBuilder(args); @@ -24,7 +22,8 @@ builder.Services.AddControllers(options => options.Filters.Add(); }) .AddApplicationPart(typeof(BreweryController).Assembly) - .AddApplicationPart(typeof(UserController).Assembly); + .AddApplicationPart(typeof(UserController).Assembly) + .AddApplicationPart(typeof(AuthController).Assembly); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -34,6 +33,7 @@ builder.Services.AddOpenApi(); builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly); +builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); builder.Services.AddFluentValidationAutoValidation(); // Add MediatR. Each Features.* slice's assembly is registered here as it's introduced; @@ -43,6 +43,8 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining(); cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly); + cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); + cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); @@ -64,20 +66,14 @@ builder.Services.AddSingleton< DefaultSqlConnectionFactory >(); -builder.Services.AddScoped(); builder.Services.AddFeaturesBreweries(); builder.Services.AddFeaturesUserManagement(); +builder.Services.AddFeaturesAuth(); +builder.Services.AddFeaturesEmails(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - +// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because +// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly. builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); // Register the exception filter builder.Services.AddScoped(); diff --git a/web/backend/API/API.Specs/API.Specs.csproj b/web/backend/API/API.Specs/API.Specs.csproj index 705f4cb..e986b35 100644 --- a/web/backend/API/API.Specs/API.Specs.csproj +++ b/web/backend/API/API.Specs/API.Specs.csproj @@ -42,5 +42,6 @@ + diff --git a/web/backend/API/API.Specs/Dockerfile b/web/backend/API/API.Specs/Dockerfile index 1f3c815..f1556b9 100644 --- a/web/backend/API/API.Specs/Dockerfile +++ b/web/backend/API/API.Specs/Dockerfile @@ -1,17 +1,32 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src + +# See API.Core/Dockerfile for why this copies every project file (with real relative paths) and +# restores against Core.slnx rather than hand-listing API.Specs' transitive dependencies. +COPY ["Core.slnx", "./"] COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] -COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] -COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] -COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] +COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"] +COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] +COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] +COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"] +COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"] +COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"] +COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] +COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] +COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] +COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] +COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] +COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] +COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] +COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] -COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] -COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"] -COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"] -RUN dotnet restore "API/API.Specs/API.Specs.csproj" +COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"] +COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] +COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] +RUN dotnet restore "Core.slnx" COPY . . WORKDIR "/src/API/API.Specs" RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build diff --git a/web/backend/API/API.Specs/Mocks/MockEmailService.cs b/web/backend/API/API.Specs/Mocks/MockEmailDispatcher.cs similarity index 70% rename from web/backend/API/API.Specs/Mocks/MockEmailService.cs rename to web/backend/API/API.Specs/Mocks/MockEmailDispatcher.cs index 0e9f0b2..643aaa6 100644 --- a/web/backend/API/API.Specs/Mocks/MockEmailService.cs +++ b/web/backend/API/API.Specs/Mocks/MockEmailDispatcher.cs @@ -1,23 +1,24 @@ -using Domain.Entities; -using Service.Emails; +using Features.Emails.Services; namespace API.Specs.Mocks; -public class MockEmailService : IEmailService +public class MockEmailDispatcher : IEmailDispatcher { public List SentRegistrationEmails { get; } = new(); public List SentResendConfirmationEmails { get; } = new(); public Task SendRegistrationEmailAsync( - UserAccount createdUser, + string firstName, + string email, string confirmationToken ) { SentRegistrationEmails.Add( new RegistrationEmail { - UserAccount = createdUser, + FirstName = firstName, + Email = email, ConfirmationToken = confirmationToken, SentAt = DateTime.UtcNow, } @@ -27,14 +28,16 @@ public class MockEmailService : IEmailService } public Task SendResendConfirmationEmailAsync( - UserAccount user, + string firstName, + string email, string confirmationToken ) { SentResendConfirmationEmails.Add( new ResendConfirmationEmail { - UserAccount = user, + FirstName = firstName, + Email = email, ConfirmationToken = confirmationToken, SentAt = DateTime.UtcNow, } @@ -51,14 +54,16 @@ public class MockEmailService : IEmailService public class RegistrationEmail { - public UserAccount UserAccount { get; init; } = null!; + public string FirstName { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; public string ConfirmationToken { get; init; } = string.Empty; public DateTime SentAt { get; init; } } public class ResendConfirmationEmail { - public UserAccount UserAccount { get; init; } = null!; + public string FirstName { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; public string ConfirmationToken { get; init; } = string.Empty; public DateTime SentAt { get; init; } } diff --git a/web/backend/API/API.Specs/TestApiFactory.cs b/web/backend/API/API.Specs/TestApiFactory.cs index 2b32445..2b2046d 100644 --- a/web/backend/API/API.Specs/TestApiFactory.cs +++ b/web/backend/API/API.Specs/TestApiFactory.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using API.Specs.Mocks; +using Features.Emails.Services; using Infrastructure.Email; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Service.Emails; namespace API.Specs { @@ -29,17 +29,17 @@ namespace API.Specs services.AddScoped(); - // Replace the real email service with mock for testing - var emailServiceDescriptor = services.SingleOrDefault(d => - d.ServiceType == typeof(IEmailService) + // Replace the real email dispatcher with mock for testing + var emailDispatcherDescriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(IEmailDispatcher) ); - if (emailServiceDescriptor != null) + if (emailDispatcherDescriptor != null) { - services.Remove(emailServiceDescriptor); + services.Remove(emailDispatcherDescriptor); } - services.AddScoped(); + services.AddScoped(); }); } } diff --git a/web/backend/Core.slnx b/web/backend/Core.slnx index 384490e..c3a2f2d 100644 --- a/web/backend/Core.slnx +++ b/web/backend/Core.slnx @@ -18,9 +18,6 @@ - - @@ -28,11 +25,10 @@ - - - - - + + + + diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index 984411c..27f356a 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -19,7 +19,5 @@ - diff --git a/web/backend/Dockerfile.tests b/web/backend/Dockerfile.tests new file mode 100644 index 0000000..81b4f21 --- /dev/null +++ b/web/backend/Dockerfile.tests @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY . . +RUN dotnet restore "Core.slnx" +RUN dotnet build "Core.slnx" -c $BUILD_CONFIGURATION + +# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is +# excluded here since it's a separate BDD/integration suite, run by its own compose service +# against the live, seeded database. +FROM build AS final +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +RUN mkdir -p /app/test-results +ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"] diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs new file mode 100644 index 0000000..9c54b8a --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs @@ -0,0 +1,87 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Features.Auth.Commands.ConfirmUser; +using Features.Auth.Repository; +using Features.Auth.Services; +using Moq; + +namespace Features.Auth.Tests.Commands; + +public class ConfirmUserHandlerTests +{ + private readonly Mock _authRepositoryMock; + private readonly Mock _tokenServiceMock; + private readonly ConfirmUserHandler _handler; + + public ConfirmUserHandlerTests() + { + _authRepositoryMock = new Mock(); + _tokenServiceMock = new Mock(); + _handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object); + } + + private static ValidatedToken MakeValidatedToken(Guid userId, string username) + { + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId.ToString()), + new(JwtRegisteredClaimNames.UniqueName, username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + return new ValidatedToken(userId, username, principal); + } + + [Fact] + public async Task Handle_WithValidConfirmationToken_ConfirmsUser() + { + var userId = Guid.NewGuid(); + const string username = "testuser"; + const string confirmationToken = "valid-confirmation-token"; + + var validatedToken = MakeValidatedToken(userId, username); + var userAccount = new UserAccount { UserAccountId = userId, Username = username }; + + _tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken); + _authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount); + + var result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None); + + result.Should().NotBeNull(); + result.UserAccountId.Should().Be(userId); + result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Handle_WithInvalidConfirmationToken_ThrowsUnauthorizedException() + { + const string invalidToken = "invalid-confirmation-token"; + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) + .ThrowsAsync(new UnauthorizedException("Invalid confirmation token")); + + var act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException() + { + var userId = Guid.NewGuid(); + const string username = "nonexistent"; + const string confirmationToken = "valid-token-for-nonexistent-user"; + + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) + .ReturnsAsync(MakeValidatedToken(userId, username)); + _authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync((UserAccount?)null); + + var act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*User account not found*"); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs new file mode 100644 index 0000000..35b9e07 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs @@ -0,0 +1,30 @@ +using Domain.Entities; +using FluentAssertions; +using Features.Auth.Commands.RefreshToken; +using Features.Auth.Services; +using Moq; + +namespace Features.Auth.Tests.Commands; + +public class RefreshTokenHandlerTests +{ + [Fact] + public async Task Handle_MapsTokenServiceResult_ToLoginPayload() + { + var tokenServiceMock = new Mock(); + var handler = new RefreshTokenHandler(tokenServiceMock.Object); + var userId = Guid.NewGuid(); + var user = new UserAccount { UserAccountId = userId, Username = "testuser" }; + + tokenServiceMock + .Setup(x => x.RefreshTokenAsync("old-refresh-token")) + .ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token")); + + var result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None); + + result.UserAccountId.Should().Be(userId); + result.Username.Should().Be("testuser"); + result.RefreshToken.Should().Be("new-refresh-token"); + result.AccessToken.Should().Be("new-access-token"); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs new file mode 100644 index 0000000..d1e6cd4 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs @@ -0,0 +1,165 @@ +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Features.Auth.Commands.RegisterUser; +using Features.Auth.Repository; +using Features.Auth.Services; +using Infrastructure.PasswordHashing; +using MediatR; +using Moq; +using Shared.Application.Emails; + +namespace Features.Auth.Tests.Commands; + +public class RegisterUserHandlerTests +{ + private readonly Mock _authRepoMock; + private readonly Mock _passwordInfraMock; + private readonly Mock _tokenServiceMock; + private readonly Mock _mediatorMock; + private readonly RegisterUserHandler _handler; + + public RegisterUserHandlerTests() + { + _authRepoMock = new Mock(); + _passwordInfraMock = new Mock(); + _tokenServiceMock = new Mock(); + _mediatorMock = new Mock(); + + _handler = new RegisterUserHandler( + _authRepoMock.Object, + _passwordInfraMock.Object, + _tokenServiceMock.Object, + _mediatorMock.Object + ); + } + + private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com") => + new(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!"); + + [Fact] + public async Task Handle_WithValidData_CreatesUserAndReturnsPayload() + { + var command = ValidCommand(); + const string hashedPassword = "hashed_password_value"; + var expectedUserId = Guid.NewGuid(); + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null); + _authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null); + _passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword); + + _authRepoMock + .Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword)) + .ReturnsAsync(new UserAccount + { + UserAccountId = expectedUserId, + Username = command.Username, + FirstName = command.FirstName, + LastName = command.LastName, + Email = command.Email, + DateOfBirth = command.DateOfBirth, + CreatedAt = DateTime.UtcNow, + }); + + _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny())).Returns("access-token"); + _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny())).Returns("refresh-token"); + _tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny())).Returns("confirmation-token"); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.Should().NotBeNull(); + result.UserAccountId.Should().Be(expectedUserId); + result.Username.Should().Be(command.Username); + result.AccessToken.Should().Be("access-token"); + result.RefreshToken.Should().Be("refresh-token"); + result.ConfirmationEmailSent.Should().BeTrue(); + + _mediatorMock.Verify( + x => x.Send(It.IsAny(), It.IsAny()), + Times.Once + ); + } + + [Fact] + public async Task Handle_WithExistingUsername_ThrowsConflictException() + { + var command = ValidCommand(username: "existinguser"); + var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "existinguser" }; + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser); + _authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null); + + var act = async () => await _handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("Username or email already exists"); + + _authRepoMock.Verify( + x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task Handle_WithExistingEmail_ThrowsConflictException() + { + var command = ValidCommand(email: "existing@example.com"); + var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" }; + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null); + _authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser); + + var act = async () => await _handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("Username or email already exists"); + } + + [Fact] + public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase() + { + var command = ValidCommand(); + const string hashedPassword = "hashed_secure_password"; + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny())).ReturnsAsync((UserAccount?)null); + _authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny())).ReturnsAsync((UserAccount?)null); + _passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword); + + _authRepoMock + .Setup(x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), hashedPassword)) + .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); + + _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny())).Returns("access-token"); + _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny())).Returns("refresh-token"); + + await _handler.Handle(command, CancellationToken.None); + + _passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once); + _authRepoMock.Verify( + x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword), + Times.Once + ); + } + + [Fact] + public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent() + { + var command = ValidCommand(); + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny())).ReturnsAsync((UserAccount?)null); + _authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny())).ReturnsAsync((UserAccount?)null); + _passwordInfraMock.Setup(x => x.Hash(It.IsAny())).Returns("hashed"); + _authRepoMock + .Setup(x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email }); + + _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny())).Returns("access-token"); + _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny())).Returns("refresh-token"); + _mediatorMock + .Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("smtp down")); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.ConfirmationEmailSent.Should().BeFalse(); + result.AccessToken.Should().Be("access-token"); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs new file mode 100644 index 0000000..31e63f7 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs @@ -0,0 +1,73 @@ +using Domain.Entities; +using Features.Auth.Commands.ResendConfirmationEmail; +using Features.Auth.Repository; +using Features.Auth.Services; +using MediatR; +using Moq; +using Shared.Application.Emails; + +namespace Features.Auth.Tests.Commands; + +public class ResendConfirmationEmailHandlerTests +{ + private readonly Mock _authRepositoryMock = new(); + private readonly Mock _tokenServiceMock = new(); + private readonly Mock _mediatorMock = new(); + private readonly ResendConfirmationEmailHandler _handler; + + public ResendConfirmationEmailHandlerTests() + { + _handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _mediatorMock.Object); + } + + [Fact] + public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified() + { + var userId = Guid.NewGuid(); + var user = new UserAccount { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" }; + + _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); + _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false); + _tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user)).Returns("fresh-token"); + + await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); + + _mediatorMock.Verify( + x => x.Send(It.Is(c => + c.FirstName == "Aaron" && c.Email == "aaron@example.com" && c.ConfirmationToken == "fresh-token" + ), It.IsAny()), + Times.Once + ); + } + + [Fact] + public async Task Handle_DoesNothing_WhenUserDoesNotExist() + { + var userId = Guid.NewGuid(); + _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null); + + await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); + + _mediatorMock.Verify( + x => x.Send(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task Handle_DoesNothing_WhenUserAlreadyVerified() + { + var userId = Guid.NewGuid(); + var user = new UserAccount { UserAccountId = userId }; + + _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); + _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true); + + await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); + + _mediatorMock.Verify( + x => x.Send(It.IsAny(), It.IsAny()), + Times.Never + ); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj new file mode 100644 index 0000000..b102ed2 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + enable + enable + false + Features.Auth.Tests + + + + + + + + + + + + + + + + + + + diff --git a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs new file mode 100644 index 0000000..80b91d4 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs @@ -0,0 +1,110 @@ +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Features.Auth.Queries.Login; +using Features.Auth.Repository; +using Features.Auth.Services; +using Infrastructure.PasswordHashing; +using Moq; + +namespace Features.Auth.Tests.Queries; + +public class LoginHandlerTests +{ + private readonly Mock _authRepoMock; + private readonly Mock _passwordInfraMock; + private readonly Mock _tokenServiceMock; + private readonly LoginHandler _handler; + + public LoginHandlerTests() + { + _authRepoMock = new Mock(); + _passwordInfraMock = new Mock(); + _tokenServiceMock = new Mock(); + _handler = new LoginHandler(_authRepoMock.Object, _passwordInfraMock.Object, _tokenServiceMock.Object); + } + + [Fact] + public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername() + { + const string username = "CogitoErgoSum"; + var userAccountId = Guid.NewGuid(); + + var userAccount = new UserAccount + { + UserAccountId = userAccountId, + Username = username, + FirstName = "René", + LastName = "Descartes", + Email = "r.descartes@example.com", + DateOfBirth = new DateTime(1596, 03, 31), + }; + + var userCredential = new UserCredential + { + UserCredentialId = Guid.NewGuid(), + UserAccountId = userAccountId, + Hash = "some-hash", + Expiry = DateTime.MaxValue, + }; + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); + _authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential); + _passwordInfraMock.Setup(x => x.Verify(It.IsAny(), It.IsAny())).Returns(true); + _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny())).Returns("access-token"); + _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny())).Returns("refresh-token"); + + var result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None); + + result.Should().NotBeNull(); + result.UserAccountId.Should().Be(userAccountId); + result.Username.Should().Be(username); + result.AccessToken.Should().Be("access-token"); + result.RefreshToken.Should().Be("refresh-token"); + } + + [Fact] + public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException() + { + const string username = "de_beauvoir"; + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null); + + var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); + + await act.Should().ThrowAsync(); + _authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException() + { + const string username = "BRussell"; + var userAccountId = Guid.NewGuid(); + var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username }; + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); + _authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync((UserCredential?)null); + + var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("Invalid username or password."); + _passwordInfraMock.Verify(x => x.Verify(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException() + { + const string username = "RCarnap"; + var userAccountId = Guid.NewGuid(); + var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username }; + var userCredential = new UserCredential { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" }; + + _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); + _authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential); + _passwordInfraMock.Setup(x => x.Verify(It.IsAny(), It.IsAny())).Returns(false); + + var act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("Invalid username or password."); + } +} diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs similarity index 98% rename from web/backend/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs rename to web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs index e067b4f..9b8446f 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Auth/AuthRepository.test.cs +++ b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs @@ -1,11 +1,10 @@ using Apps72.Dev.Data.DbMocker; using FluentAssertions; -using Infrastructure.Repository.Auth; -using Infrastructure.Repository.Tests.Database; +using Features.Auth.Repository; -namespace Infrastructure.Repository.Tests.Auth; +namespace Features.Auth.Tests.Repository; -public class AuthRepositoryTest +public class AuthRepositoryTests { private static AuthRepository CreateRepo(MockDbConnection conn) => new(new TestConnectionFactory(conn)); diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs b/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs similarity index 82% rename from web/backend/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs rename to web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs index d43dcda..e48d7cf 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Database/TestConnectionFactory.cs +++ b/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs @@ -1,7 +1,7 @@ using System.Data.Common; using Infrastructure.Sql; -namespace Infrastructure.Repository.Tests.Database; +namespace Features.Auth.Tests.Repository; internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory { diff --git a/web/backend/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs similarity index 96% rename from web/backend/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs rename to web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs index a2634de..66b2c64 100644 --- a/web/backend/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs @@ -3,19 +3,20 @@ using System.Security.Claims; using Domain.Entities; using Domain.Exceptions; using FluentAssertions; +using Features.Auth.Repository; +using Features.Auth.Services; using Infrastructure.Jwt; -using Infrastructure.Repository.Auth; using Moq; -namespace Service.Auth.Tests; +namespace Features.Auth.Tests.Services; -public class TokenServiceRefreshTest +public class TokenServiceRefreshTests { private readonly Mock _tokenInfraMock; private readonly Mock _authRepositoryMock; private readonly TokenService _tokenService; - public TokenServiceRefreshTest() + public TokenServiceRefreshTests() { _tokenInfraMock = new Mock(); _authRepositoryMock = new Mock(); diff --git a/web/backend/Service/Service.Auth.Tests/TokenServiceValidation.test.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs similarity index 98% rename from web/backend/Service/Service.Auth.Tests/TokenServiceValidation.test.cs rename to web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs index 1c729c8..44b33a6 100644 --- a/web/backend/Service/Service.Auth.Tests/TokenServiceValidation.test.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs @@ -3,19 +3,20 @@ using System.Security.Claims; using Domain.Entities; using Domain.Exceptions; using FluentAssertions; +using Features.Auth.Repository; +using Features.Auth.Services; using Infrastructure.Jwt; -using Infrastructure.Repository.Auth; using Moq; -namespace Service.Auth.Tests; +namespace Features.Auth.Tests.Services; -public class TokenServiceValidationTest +public class TokenServiceValidationTests { private readonly Mock _tokenInfraMock; private readonly Mock _authRepositoryMock; private readonly TokenService _tokenService; - public TokenServiceValidationTest() + public TokenServiceValidationTests() { _tokenInfraMock = new Mock(); _authRepositoryMock = new Mock(); diff --git a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs new file mode 100644 index 0000000..4265e75 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs @@ -0,0 +1,10 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Commands.ConfirmUser; + +/// +/// Validates a confirmation token and confirms the corresponding user account. +/// +/// The confirmation token issued to the user, typically delivered via email. +public record ConfirmUserCommand(string Token) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs new file mode 100644 index 0000000..d986c03 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs @@ -0,0 +1,36 @@ +using Domain.Exceptions; +using Features.Auth.Dtos; +using Features.Auth.Repository; +using Features.Auth.Services; +using MediatR; + +namespace Features.Auth.Commands.ConfirmUser; + +/// +/// Handles by validating the confirmation token and marking the +/// corresponding user account as confirmed. +/// +/// Repository used to look up and confirm user accounts. +/// Service used to validate the confirmation token. +public class ConfirmUserHandler( + IAuthRepository authRepository, + ITokenService tokenService +) : IRequestHandler +{ + /// + /// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found. + /// + public async Task Handle(ConfirmUserCommand request, CancellationToken cancellationToken) + { + var validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token); + + var user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId); + + if (user == null) + { + throw new UnauthorizedException("User account not found"); + } + + return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 0000000..c30c056 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,10 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Commands.RefreshToken; + +/// +/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the +/// request body of POST /api/auth/refresh. +/// +public record RefreshTokenCommand(string RefreshToken) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs new file mode 100644 index 0000000..7cf3ecb --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs @@ -0,0 +1,20 @@ +using Features.Auth.Dtos; +using Features.Auth.Services; +using MediatR; + +namespace Features.Auth.Commands.RefreshToken; + +/// +/// Handles by validating the refresh token and issuing a new +/// access/refresh token pair. +/// +/// Service used to validate and exchange the refresh token. +public class RefreshTokenHandler(ITokenService tokenService) + : IRequestHandler +{ + public async Task Handle(RefreshTokenCommand request, CancellationToken cancellationToken) + { + var result = await tokenService.RefreshTokenAsync(request.RefreshToken); + return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs new file mode 100644 index 0000000..ff68178 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace Features.Auth.Commands.RefreshToken; + +/// +/// Validates instances before they are processed. +/// +public class RefreshTokenValidator : AbstractValidator +{ + /// + /// Configures a validation rule requiring to be non-empty. + /// + public RefreshTokenValidator() + { + RuleFor(x => x.RefreshToken) + .NotEmpty() + .WithMessage("Refresh token is required"); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs new file mode 100644 index 0000000..4306a54 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs @@ -0,0 +1,22 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Commands.RegisterUser; + +/// +/// Registers a new user account. Bound directly from the request body of POST /api/auth/register. +/// +/// The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens. +/// The user's first name; up to 128 characters. +/// The user's last name; up to 128 characters. +/// The user's email address; up to 128 characters and must be a valid email format. +/// The user's date of birth; the user must be at least 19 years old. +/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character. +public record RegisterUserCommand( + string Username, + string FirstName, + string LastName, + string Email, + DateTime DateOfBirth, + string Password +) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs new file mode 100644 index 0000000..4bd7e4c --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -0,0 +1,86 @@ +using Domain.Exceptions; +using Features.Auth.Dtos; +using Features.Auth.Repository; +using Features.Auth.Services; +using Infrastructure.PasswordHashing; +using MediatR; +using Shared.Application.Emails; + +namespace Features.Auth.Commands.RegisterUser; + +/// +/// Handles : validates uniqueness, hashes the password, persists the +/// account, issues access/refresh/confirmation tokens, and attempts to send the registration +/// confirmation email via Features.Emails. +/// +/// Repository used to check for existing users and persist the new account. +/// Infrastructure component used to hash the user's plain-text password. +/// Service used to generate access, refresh, and confirmation tokens. +/// Used to send the cross-slice command that triggers the confirmation email. +public class RegisterUserHandler( + IAuthRepository authRepo, + IPasswordInfrastructure passwordInfrastructure, + ITokenService tokenService, + IMediator mediator +) : IRequestHandler +{ + /// + /// Thrown when an existing account already has the same username or email address. + /// + public async Task Handle(RegisterUserCommand request, CancellationToken cancellationToken) + { + await ValidateUserDoesNotExist(request.Username, request.Email); + + var hashed = passwordInfrastructure.Hash(request.Password); + + var createdUser = await authRepo.RegisterUserAsync( + request.Username, + request.FirstName, + request.LastName, + request.Email, + request.DateOfBirth, + hashed + ); + + var accessToken = tokenService.GenerateAccessToken(createdUser); + var refreshToken = tokenService.GenerateRefreshToken(createdUser); + var confirmationToken = tokenService.GenerateConfirmationToken(createdUser); + + if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) + { + return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false); + } + + var emailSent = false; + try + { + await mediator.Send( + new SendRegistrationEmailCommand(createdUser.FirstName, createdUser.Email, confirmationToken), + cancellationToken + ); + emailSent = true; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync(ex.Message); + Console.WriteLine("Could not send email."); + // ignored + } + + return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent); + } + + /// + /// Thrown when an existing account already has the same username or email address. + /// + private async Task ValidateUserDoesNotExist(string username, string email) + { + var existingUsername = await authRepo.GetUserByUsernameAsync(username); + var existingEmail = await authRepo.GetUserByEmailAsync(email); + + if (existingUsername != null || existingEmail != null) + { + throw new ConflictException("Username or email already exists"); + } + } +} diff --git a/web/backend/API/API.Core/Contracts/Auth/Register.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs similarity index 63% rename from web/backend/API/API.Core/Contracts/Auth/Register.cs rename to web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs index a1407a2..66a0ee3 100644 --- a/web/backend/API/API.Core/Contracts/Auth/Register.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs @@ -1,36 +1,17 @@ -using Shared.Contracts; using FluentValidation; -namespace API.Core.Contracts.Auth; +namespace Features.Auth.Commands.RegisterUser; /// -/// Request body for the registration endpoint, containing the details needed to create a new user account. +/// Validates instances before they are processed. /// -/// The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens. -/// The user's first name; up to 128 characters. -/// The user's last name; up to 128 characters. -/// The user's email address; up to 128 characters and must be a valid email format. -/// The user's date of birth; the user must be at least 19 years old. -/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character. -public record RegisterRequest( - string Username, - string FirstName, - string LastName, - string Email, - DateTime DateOfBirth, - string Password -); - -/// -/// Validates instances before they are processed by the registration endpoint. -/// -public class RegisterRequestValidator : AbstractValidator +public class RegisterUserValidator : AbstractValidator { /// /// Configures validation rules for username format and length, first/last name length, email format and /// length, minimum age based on date of birth, and password strength requirements. /// - public RegisterRequestValidator() + public RegisterUserValidator() { RuleFor(x => x.Username) .NotEmpty() diff --git a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs new file mode 100644 index 0000000..6b85591 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace Features.Auth.Commands.ResendConfirmationEmail; + +/// +/// Resends the account confirmation email to a user, generating a fresh confirmation token. +/// +/// The unique identifier of the user requesting the resend. +public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs new file mode 100644 index 0000000..01340b6 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs @@ -0,0 +1,44 @@ +using Features.Auth.Repository; +using Features.Auth.Services; +using MediatR; +using Shared.Application.Emails; + +namespace Features.Auth.Commands.ResendConfirmationEmail; + +/// +/// Handles by generating a fresh confirmation token and +/// sending it via Features.Emails. +/// +/// Repository used to look up the user and check verification status. +/// Service used to generate the confirmation token. +/// Used to send the cross-slice command that triggers the email. +/// +/// Returns silently without sending an email if the user does not exist (to prevent user enumeration) +/// or if the user's account is already verified. +/// +public class ResendConfirmationEmailHandler( + IAuthRepository authRepository, + ITokenService tokenService, + IMediator mediator +) : IRequestHandler +{ + public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken) + { + var user = await authRepository.GetUserByIdAsync(request.UserId); + if (user == null) + { + return; // Silent return to prevent user enumeration + } + + if (await authRepository.IsUserVerifiedAsync(request.UserId)) + { + return; // Already confirmed, no-op + } + + var confirmationToken = tokenService.GenerateConfirmationToken(user); + await mediator.Send( + new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken), + cancellationToken + ); + } +} diff --git a/web/backend/Features/Features.Auth/Controllers/AuthController.cs b/web/backend/Features/Features.Auth/Controllers/AuthController.cs new file mode 100644 index 0000000..fcb7bdb --- /dev/null +++ b/web/backend/Features/Features.Auth/Controllers/AuthController.cs @@ -0,0 +1,129 @@ +using Features.Auth.Commands.ConfirmUser; +using Features.Auth.Commands.RefreshToken; +using Features.Auth.Commands.RegisterUser; +using Features.Auth.Commands.ResendConfirmationEmail; +using Features.Auth.Dtos; +using Features.Auth.Queries.Login; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Shared.Contracts; + +namespace Features.Auth.Controllers +{ + /// + /// Handles user authentication concerns: registration, login, email confirmation, and token refresh. + /// + /// + /// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default, but most + /// actions opt out via [AllowAnonymous] since they are entry points used before a caller holds a token. + /// + /// Used to dispatch auth commands and queries to their handlers. + [ApiController] + [Route("api/[controller]")] + [Authorize(AuthenticationSchemes = "JWT")] + public class AuthController(IMediator mediator) : ControllerBase + { + /// + /// Registers a new user account. + /// + /// + /// Allows anonymous access. On success, responds with 201 Created containing the new user's ID, + /// username, issued refresh/access tokens, and whether a confirmation email was sent. + /// + /// The registration details, including username, name, email, date of birth, and password. + /// A 201 Created result wrapping a of . + [AllowAnonymous] + [HttpPost("register")] + public async Task>> Register( + [FromBody] RegisterUserCommand command + ) + { + var payload = await mediator.Send(command); + return Created("/", new ResponseBody + { + Message = "User registered successfully.", + Payload = payload, + }); + } + + /// + /// Authenticates a user with a username and password. + /// + /// + /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, + /// username, and a newly issued refresh/access token pair. + /// + /// The login credentials (username and password). + /// An 200 OK result wrapping a of . + [AllowAnonymous] + [HttpPost("login")] + public async Task>> Login([FromBody] LoginQuery query) + { + var payload = await mediator.Send(query); + return Ok(new ResponseBody + { + Message = "Logged in successfully.", + Payload = payload, + }); + } + + /// + /// Confirms a user account using a confirmation token. + /// + /// + /// Requires JWT authentication. On success, responds with 200 OK containing the confirmed + /// user's ID and the confirmation timestamp. + /// + /// The confirmation token supplied via the confirmation email link. + /// A 200 OK result wrapping a of . + [HttpPost("confirm")] + public async Task>> Confirm([FromQuery] string token) + { + var payload = await mediator.Send(new ConfirmUserCommand(token)); + return Ok(new ResponseBody + { + Message = "User with ID " + payload.UserAccountId + " is confirmed.", + Payload = payload, + }); + } + + /// + /// Resends the account confirmation email for the specified user. + /// + /// + /// Requires JWT authentication. On success, responds with 200 OK. + /// + /// The unique identifier of the user account to resend the confirmation email for. + /// A 200 OK result wrapping a confirming the email was resent. + [HttpPost("confirm/resend")] + public async Task> ResendConfirmation([FromQuery] Guid userId) + { + await mediator.Send(new ResendConfirmationEmailCommand(userId)); + return Ok(new ResponseBody { Message = "confirmation email has been resent" }); + } + + /// + /// Exchanges a valid refresh token for a new access/refresh token pair. + /// + /// + /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, + /// username, and the newly issued refresh/access token pair. + /// + /// The request containing the refresh token to exchange. + /// A 200 OK result wrapping a of . + [AllowAnonymous] + [HttpPost("refresh")] + public async Task>> Refresh( + [FromBody] RefreshTokenCommand command + ) + { + var payload = await mediator.Send(command); + return Ok(new ResponseBody + { + Message = "Token refreshed successfully.", + Payload = payload, + }); + } + } +} diff --git a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs new file mode 100644 index 0000000..3718ad4 --- /dev/null +++ b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Features.Auth.Repository; +using Features.Auth.Services; +using Infrastructure.PasswordHashing; +using Microsoft.Extensions.DependencyInjection; + +namespace Features.Auth.DependencyInjection; + +/// +/// Registers the services owned by the Auth feature slice. +/// +public static class FeaturesAuthServiceCollectionExtensions +{ + public static IServiceCollection AddFeaturesAuth(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs b/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs similarity index 94% rename from web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs rename to web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs index aa41e65..f79f28e 100644 --- a/web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs +++ b/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs @@ -1,7 +1,4 @@ -using Domain.Entities; -using Org.BouncyCastle.Asn1.Cms; - -namespace API.Core.Contracts.Auth; +namespace Features.Auth.Dtos; /// /// Payload returned to the client after a successful login or token refresh. diff --git a/web/backend/Features/Features.Auth/Features.Auth.csproj b/web/backend/Features/Features.Auth/Features.Auth.csproj new file mode 100644 index 0000000..c928b6d --- /dev/null +++ b/web/backend/Features/Features.Auth/Features.Auth.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + Features.Auth + + + + + + + + + + + + + + + + diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs b/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs new file mode 100644 index 0000000..ddece25 --- /dev/null +++ b/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs @@ -0,0 +1,45 @@ +using Domain.Exceptions; +using Features.Auth.Dtos; +using Features.Auth.Repository; +using Features.Auth.Services; +using Infrastructure.PasswordHashing; +using MediatR; + +namespace Features.Auth.Queries.Login; + +/// +/// Handles by verifying credentials and issuing access/refresh tokens. +/// +/// Repository used to look up the user account and its active credential. +/// Infrastructure component used to verify a plain-text password against a stored hash. +/// Service used to generate access and refresh tokens for the authenticated user. +public class LoginHandler( + IAuthRepository authRepo, + IPasswordInfrastructure passwordInfrastructure, + ITokenService tokenService +) : IRequestHandler +{ + /// + /// Thrown when the username does not match any account, the account has no active credential, + /// or the supplied password does not match the stored hash. + /// + public async Task Handle(LoginQuery request, CancellationToken cancellationToken) + { + var user = + await authRepo.GetUserByUsernameAsync(request.Username) + ?? throw new UnauthorizedException("Invalid username or password."); + + // @todo handle expired passwords + var activeCred = + await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId) + ?? throw new UnauthorizedException("Invalid username or password."); + + if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash)) + throw new UnauthorizedException("Invalid username or password."); + + var accessToken = tokenService.GenerateAccessToken(user); + var refreshToken = tokenService.GenerateRefreshToken(user); + + return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken); + } +} diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs b/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs new file mode 100644 index 0000000..d291aa0 --- /dev/null +++ b/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs @@ -0,0 +1,10 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Queries.Login; + +/// +/// Authenticates a user using their username and password and issues new tokens. Bound directly +/// from the request body of POST /api/auth/login. +/// +public record LoginQuery(string Username, string Password) : IRequest; diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs b/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs new file mode 100644 index 0000000..7a45333 --- /dev/null +++ b/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace Features.Auth.Queries.Login; + +/// +/// Validates instances before they are processed. +/// +public class LoginValidator : AbstractValidator +{ + /// + /// Configures validation rules requiring both and + /// to be non-empty. + /// + public LoginValidator() + { + RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required"); + + RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required"); + } +} diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs similarity index 99% rename from web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs rename to web/backend/Features/Features.Auth/Repository/AuthRepository.cs index 92ce775..523dfe3 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs @@ -4,7 +4,7 @@ using Domain.Entities; using Infrastructure.Sql; using Microsoft.Data.SqlClient; -namespace Infrastructure.Repository.Auth; +namespace Features.Auth.Repository; /// /// ADO.NET-based implementation of backed by SQL Server stored procedures, @@ -12,7 +12,7 @@ namespace Infrastructure.Repository.Auth; /// /// The factory used to create database connections. public class AuthRepository(ISqlConnectionFactory connectionFactory) - : Repository(connectionFactory), + : Infrastructure.Sql.Repository(connectionFactory), IAuthRepository { /// diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs similarity index 98% rename from web/backend/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs rename to web/backend/Features/Features.Auth/Repository/IAuthRepository.cs index 27ad3f8..b98932e 100644 --- a/web/backend/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs @@ -1,6 +1,6 @@ using Domain.Entities; -namespace Infrastructure.Repository.Auth; +namespace Features.Auth.Repository; /// /// Repository for authentication-related database operations including user registration and credential management. diff --git a/web/backend/Features/Features.Auth/Services/ITokenService.cs b/web/backend/Features/Features.Auth/Services/ITokenService.cs new file mode 100644 index 0000000..4c0cefa --- /dev/null +++ b/web/backend/Features/Features.Auth/Services/ITokenService.cs @@ -0,0 +1,113 @@ +using System.Security.Claims; +using Domain.Entities; + +namespace Features.Auth.Services; + +/// +/// Identifies the kind of token being generated or validated. +/// +public enum TokenType +{ + /// A short-lived token used to authorize API requests. + AccessToken, + /// A long-lived token used to obtain new access tokens. + RefreshToken, + /// A short-lived token used to confirm a user's email/account. + ConfirmationToken, +} + +/// +/// Represents the result of successfully validating a token. +/// +/// The unique identifier of the user the token belongs to. +/// The username extracted from the token's claims. +/// The produced from the validated token. +public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); + +/// +/// Represents the result of refreshing a user's session. +/// +/// The user account associated with the refreshed session. +/// The newly issued refresh token. +/// The newly issued access token. +public record RefreshTokenResult( + UserAccount UserAccount, + string RefreshToken, + string AccessToken +); + +/// +/// Defines the expiration windows, in hours, for each type of token issued by . +/// +public static class TokenServiceExpirationHours +{ + /// The expiration window, in hours, for access tokens. + public const double AccessTokenHours = 1; + /// The expiration window, in hours, for refresh tokens (21 days). + public const double RefreshTokenHours = 504; // 21 days + /// The expiration window, in hours, for confirmation tokens (30 minutes). + public const double ConfirmationTokenHours = 0.5; // 30 minutes +} + +/// +/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation. +/// +public interface ITokenService +{ + /// + /// Generates a new access token for the given user. + /// + /// The user account to generate the token for. + /// The signed access token string. + string GenerateAccessToken(UserAccount user); + + /// + /// Generates a new refresh token for the given user. + /// + /// The user account to generate the token for. + /// The signed refresh token string. + string GenerateRefreshToken(UserAccount user); + + /// + /// Generates a new confirmation token for the given user. + /// + /// The user account to generate the token for. + /// The signed confirmation token string. + string GenerateConfirmationToken(UserAccount user); + + /// + /// Generates a token of the type specified by , which must be . + /// + /// The enum type identifying which kind of token to generate. Must be . + /// The user account to generate the token for. + /// The signed token string corresponding to the requested token type. + string GenerateToken(UserAccount user) where T : struct, Enum; + + /// + /// Validates an access token. + /// + /// The access token string to validate. + /// A describing the token's claims. + Task ValidateAccessTokenAsync(string token); + + /// + /// Validates a refresh token. + /// + /// The refresh token string to validate. + /// A describing the token's claims. + Task ValidateRefreshTokenAsync(string token); + + /// + /// Validates a confirmation token. + /// + /// The confirmation token string to validate. + /// A describing the token's claims. + Task ValidateConfirmationTokenAsync(string token); + + /// + /// Validates a refresh token and issues a new access/refresh token pair for the associated user. + /// + /// The refresh token string to validate and exchange. + /// A containing the user and newly issued tokens. + Task RefreshTokenAsync(string refreshTokenString); +} diff --git a/web/backend/Service/Service.Auth/ITokenService.cs b/web/backend/Features/Features.Auth/Services/TokenService.cs similarity index 68% rename from web/backend/Service/Service.Auth/ITokenService.cs rename to web/backend/Features/Features.Auth/Services/TokenService.cs index 2401301..d6b78fe 100644 --- a/web/backend/Service/Service.Auth/ITokenService.cs +++ b/web/backend/Features/Features.Auth/Services/TokenService.cs @@ -2,119 +2,10 @@ using System.Security.Claims; using System.IdentityModel.Tokens.Jwt; using Domain.Entities; using Domain.Exceptions; +using Features.Auth.Repository; using Infrastructure.Jwt; -using Infrastructure.Repository.Auth; -namespace Service.Auth; - -/// -/// Identifies the kind of token being generated or validated. -/// -public enum TokenType -{ - /// A short-lived token used to authorize API requests. - AccessToken, - /// A long-lived token used to obtain new access tokens. - RefreshToken, - /// A short-lived token used to confirm a user's email/account. - ConfirmationToken, -} - -/// -/// Represents the result of successfully validating a token. -/// -/// The unique identifier of the user the token belongs to. -/// The username extracted from the token's claims. -/// The produced from the validated token. -public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); - -/// -/// Represents the result of refreshing a user's session. -/// -/// The user account associated with the refreshed session. -/// The newly issued refresh token. -/// The newly issued access token. -public record RefreshTokenResult( - UserAccount UserAccount, - string RefreshToken, - string AccessToken -); - -/// -/// Defines the expiration windows, in hours, for each type of token issued by . -/// -public static class TokenServiceExpirationHours -{ - /// The expiration window, in hours, for access tokens. - public const double AccessTokenHours = 1; - /// The expiration window, in hours, for refresh tokens (21 days). - public const double RefreshTokenHours = 504; // 21 days - /// The expiration window, in hours, for confirmation tokens (30 minutes). - public const double ConfirmationTokenHours = 0.5; // 30 minutes -} - -/// -/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation. -/// -public interface ITokenService -{ - /// - /// Generates a new access token for the given user. - /// - /// The user account to generate the token for. - /// The signed access token string. - string GenerateAccessToken(UserAccount user); - - /// - /// Generates a new refresh token for the given user. - /// - /// The user account to generate the token for. - /// The signed refresh token string. - string GenerateRefreshToken(UserAccount user); - - /// - /// Generates a new confirmation token for the given user. - /// - /// The user account to generate the token for. - /// The signed confirmation token string. - string GenerateConfirmationToken(UserAccount user); - - /// - /// Generates a token of the type specified by , which must be . - /// - /// The enum type identifying which kind of token to generate. Must be . - /// The user account to generate the token for. - /// The signed token string corresponding to the requested token type. - string GenerateToken(UserAccount user) where T : struct, Enum; - - /// - /// Validates an access token. - /// - /// The access token string to validate. - /// A describing the token's claims. - Task ValidateAccessTokenAsync(string token); - - /// - /// Validates a refresh token. - /// - /// The refresh token string to validate. - /// A describing the token's claims. - Task ValidateRefreshTokenAsync(string token); - - /// - /// Validates a confirmation token. - /// - /// The confirmation token string to validate. - /// A describing the token's claims. - Task ValidateConfirmationTokenAsync(string token); - - /// - /// Validates a refresh token and issues a new access/refresh token pair for the associated user. - /// - /// The refresh token string to validate and exchange. - /// A containing the user and newly issued tokens. - Task RefreshTokenAsync(string refreshTokenString); -} +namespace Features.Auth.Services; /// /// Default implementation of that generates and validates JWTs diff --git a/web/backend/Features/Features.Breweries/Features.Breweries.csproj b/web/backend/Features/Features.Breweries/Features.Breweries.csproj index 0c5faed..015aa3e 100644 --- a/web/backend/Features/Features.Breweries/Features.Breweries.csproj +++ b/web/backend/Features/Features.Breweries/Features.Breweries.csproj @@ -12,7 +12,7 @@ - + diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs new file mode 100644 index 0000000..1a61c17 --- /dev/null +++ b/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs @@ -0,0 +1,24 @@ +using Features.Emails.Commands.SendRegistrationEmail; +using Features.Emails.Services; +using Moq; +using Shared.Application.Emails; + +namespace Features.Emails.Tests.Commands; + +public class SendRegistrationEmailHandlerTests +{ + [Fact] + public async Task Handle_DelegatesToEmailDispatcher() + { + var dispatcherMock = new Mock(); + var handler = new SendRegistrationEmailHandler(dispatcherMock.Object); + var command = new SendRegistrationEmailCommand("Aaron", "aaron@example.com", "token-123"); + + await handler.Handle(command, CancellationToken.None); + + dispatcherMock.Verify( + d => d.SendRegistrationEmailAsync("Aaron", "aaron@example.com", "token-123"), + Times.Once + ); + } +} diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs new file mode 100644 index 0000000..7922e49 --- /dev/null +++ b/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs @@ -0,0 +1,24 @@ +using Features.Emails.Commands.SendResendConfirmationEmail; +using Features.Emails.Services; +using Moq; +using Shared.Application.Emails; + +namespace Features.Emails.Tests.Commands; + +public class SendResendConfirmationEmailHandlerTests +{ + [Fact] + public async Task Handle_DelegatesToEmailDispatcher() + { + var dispatcherMock = new Mock(); + var handler = new SendResendConfirmationEmailHandler(dispatcherMock.Object); + var command = new SendResendConfirmationEmailCommand("Aaron", "aaron@example.com", "token-456"); + + await handler.Handle(command, CancellationToken.None); + + dispatcherMock.Verify( + d => d.SendResendConfirmationEmailAsync("Aaron", "aaron@example.com", "token-456"), + Times.Once + ); + } +} diff --git a/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj b/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj new file mode 100644 index 0000000..f9e2036 --- /dev/null +++ b/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + false + Features.Emails.Tests + + + + + + + + + + + + + + + + + + diff --git a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs new file mode 100644 index 0000000..d536dfa --- /dev/null +++ b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs @@ -0,0 +1,17 @@ +using Features.Emails.Services; +using MediatR; +using Shared.Application.Emails; + +namespace Features.Emails.Commands.SendRegistrationEmail; + +/// +/// Handles , the cross-slice command sent by Features.Auth +/// after a new user registers. +/// +/// Dispatcher used to render and send the email. +public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher) + : IRequestHandler +{ + public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken) => + emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken); +} diff --git a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs new file mode 100644 index 0000000..4d5fcb1 --- /dev/null +++ b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs @@ -0,0 +1,17 @@ +using Features.Emails.Services; +using MediatR; +using Shared.Application.Emails; + +namespace Features.Emails.Commands.SendResendConfirmationEmail; + +/// +/// Handles , the cross-slice command sent by Features.Auth +/// when a user requests a fresh confirmation link. +/// +/// Dispatcher used to render and send the email. +public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher) + : IRequestHandler +{ + public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken) => + emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken); +} diff --git a/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs b/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs new file mode 100644 index 0000000..08aeabe --- /dev/null +++ b/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Features.Emails.Services; +using Infrastructure.Email; +using Infrastructure.Email.Templates.Rendering; +using Microsoft.Extensions.DependencyInjection; + +namespace Features.Emails.DependencyInjection; + +/// +/// Registers the services owned by the Emails feature slice. +/// +public static class FeaturesEmailsServiceCollectionExtensions +{ + public static IServiceCollection AddFeaturesEmails(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/web/backend/Features/Features.Emails/Features.Emails.csproj b/web/backend/Features/Features.Emails/Features.Emails.csproj new file mode 100644 index 0000000..cb5bd5a --- /dev/null +++ b/web/backend/Features/Features.Emails/Features.Emails.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + Features.Emails + + + + + + + + diff --git a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs new file mode 100644 index 0000000..8d40ce1 --- /dev/null +++ b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs @@ -0,0 +1,73 @@ +using Infrastructure.Email; +using Infrastructure.Email.Templates.Rendering; + +namespace Features.Emails.Services; + +/// +/// Default implementation of that renders email templates and dispatches +/// them via an . +/// +/// Provider used to deliver the rendered emails. +/// Provider used to render HTML email bodies from templates. +public class EmailDispatcher( + IEmailProvider emailProvider, + IEmailTemplateProvider emailTemplateProvider +) : IEmailDispatcher +{ + /// + /// The base URL of the website, used to build confirmation links. Read from the + /// WEBSITE_BASE_URL environment variable. + /// + /// + /// Thrown at type initialization time when the WEBSITE_BASE_URL environment variable is not set. + /// + private static readonly string WebsiteBaseUrl = + Environment.GetEnvironmentVariable("WEBSITE_BASE_URL") + ?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set"); + + /// + /// Builds a confirmation link from the given token, renders the registration welcome email template, + /// and sends it to the newly created user. + /// + public async Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken) + { + var confirmationLink = + $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; + + var emailHtml = + await emailTemplateProvider.RenderUserRegisteredEmailAsync( + firstName, + confirmationLink + ); + + await emailProvider.SendAsync( + email, + "Welcome to The Biergarten App!", + emailHtml, + isHtml: true + ); + } + + /// + /// Builds a confirmation link from the given token, renders the resend-confirmation email template, + /// and sends it to the user. + /// + public async Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken) + { + var confirmationLink = + $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; + + var emailHtml = + await emailTemplateProvider.RenderResendConfirmationEmailAsync( + firstName, + confirmationLink + ); + + await emailProvider.SendAsync( + email, + "Confirm Your Email - The Biergarten App", + emailHtml, + isHtml: true + ); + } +} diff --git a/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs new file mode 100644 index 0000000..62daa50 --- /dev/null +++ b/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs @@ -0,0 +1,25 @@ +namespace Features.Emails.Services; + +/// +/// Defines operations for sending account-related emails, such as registration and confirmation resend emails. +/// +public interface IEmailDispatcher +{ + /// + /// Sends a welcome email containing an account confirmation link to a newly registered user. + /// + /// The recipient's first name, used to personalize the email. + /// The recipient's email address. + /// The confirmation token to embed in the confirmation link. + /// A task that completes once the email has been sent. + Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken); + + /// + /// Sends an email containing a fresh account confirmation link to a user who requested a resend. + /// + /// The recipient's first name, used to personalize the email. + /// The recipient's email address. + /// The confirmation token to embed in the confirmation link. + /// A task that completes once the email has been sent. + Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken); +} diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile deleted file mode 100644 index 435afb5..0000000 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] -COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"] -COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] -COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"] -COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"] -RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj" -COPY . . -WORKDIR "/src/Infrastructure/Infrastructure.Repository.Tests" -RUN dotnet build "./Infrastructure.Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build - -FROM build AS final -RUN mkdir -p /app/test-results/repository-tests -WORKDIR /src/Infrastructure/Infrastructure.Repository.Tests -ENTRYPOINT ["dotnet", "test", "./Infrastructure.Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests/results.trx"] diff --git a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj b/web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj deleted file mode 100644 index ad54e15..0000000 --- a/web/backend/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - net10.0 - enable - enable - false - Infrastructure.Repository.Tests - - - - - - - - - - - - - - - - - - - - diff --git a/web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj b/web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj deleted file mode 100644 index df21703..0000000 --- a/web/backend/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - net10.0 - enable - enable - Infrastructure.Repository - - - - - - - - - - - - diff --git a/web/backend/Service/Service.Auth.Tests/ConfirmationService.test.cs b/web/backend/Service/Service.Auth.Tests/ConfirmationService.test.cs deleted file mode 100644 index e04c293..0000000 --- a/web/backend/Service/Service.Auth.Tests/ConfirmationService.test.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using Domain.Entities; -using Domain.Exceptions; -using FluentAssertions; -using Infrastructure.Repository.Auth; -using Moq; -using Service.Emails; - -namespace Service.Auth.Tests; - -public class ConfirmationServiceTest -{ - private readonly Mock _authRepositoryMock; - private readonly Mock _tokenServiceMock; - private readonly Mock _emailServiceMock; - private readonly ConfirmationService _confirmationService; - - public ConfirmationServiceTest() - { - _authRepositoryMock = new Mock(); - _tokenServiceMock = new Mock(); - _emailServiceMock = new Mock(); - - _confirmationService = new ConfirmationService( - _authRepositoryMock.Object, - _tokenServiceMock.Object, - _emailServiceMock.Object - ); - } - - [Fact] - public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser() - { - // Arrange - var userId = Guid.NewGuid(); - const string username = "testuser"; - const string confirmationToken = "valid-confirmation-token"; - - var claims = new List - { - new(JwtRegisteredClaimNames.Sub, userId.ToString()), - new(JwtRegisteredClaimNames.UniqueName, username), - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - }; - - var claimsIdentity = new ClaimsIdentity(claims); - var principal = new ClaimsPrincipal(claimsIdentity); - - var validatedToken = new ValidatedToken(userId, username, principal); - var userAccount = new UserAccount - { - UserAccountId = userId, - Username = username, - FirstName = "Test", - LastName = "User", - Email = "test@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; - - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) - .ReturnsAsync(validatedToken); - - _authRepositoryMock - .Setup(x => x.ConfirmUserAccountAsync(userId)) - .ReturnsAsync(userAccount); - - // Act - var result = - await _confirmationService.ConfirmUserAsync(confirmationToken); - - // Assert - result.Should().NotBeNull(); - result.UserId.Should().Be(userId); - result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); - - _tokenServiceMock.Verify( - x => x.ValidateConfirmationTokenAsync(confirmationToken), - Times.Once - ); - - _authRepositoryMock.Verify( - x => x.ConfirmUserAccountAsync(userId), - Times.Once - ); - } - - [Fact] - public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException() - { - // Arrange - const string invalidToken = "invalid-confirmation-token"; - - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) - .ThrowsAsync(new UnauthorizedException( - "Invalid confirmation token" - )); - - // Act & Assert - await FluentActions.Invoking(async () => - await _confirmationService.ConfirmUserAsync(invalidToken) - ).Should().ThrowAsync(); - } - - [Fact] - public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException() - { - // Arrange - const string expiredToken = "expired-confirmation-token"; - - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(expiredToken)) - .ThrowsAsync(new UnauthorizedException( - "Confirmation token has expired" - )); - - // Act & Assert - await FluentActions.Invoking(async () => - await _confirmationService.ConfirmUserAsync(expiredToken) - ).Should().ThrowAsync(); - } - - [Fact] - public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException() - { - // Arrange - var userId = Guid.NewGuid(); - const string username = "nonexistent"; - const string confirmationToken = "valid-token-for-nonexistent-user"; - - var claims = new List - { - new(JwtRegisteredClaimNames.Sub, userId.ToString()), - new(JwtRegisteredClaimNames.UniqueName, username), - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - }; - - var claimsIdentity = new ClaimsIdentity(claims); - var principal = new ClaimsPrincipal(claimsIdentity); - - var validatedToken = new ValidatedToken(userId, username, principal); - - _tokenServiceMock - .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) - .ReturnsAsync(validatedToken); - - _authRepositoryMock - .Setup(x => x.ConfirmUserAccountAsync(userId)) - .ReturnsAsync((UserAccount?)null); - - // Act & Assert - await FluentActions.Invoking(async () => - await _confirmationService.ConfirmUserAsync(confirmationToken) - ).Should().ThrowAsync() - .WithMessage("*User account not found*"); - } -} diff --git a/web/backend/Service/Service.Auth.Tests/Dockerfile b/web/backend/Service/Service.Auth.Tests/Dockerfile deleted file mode 100644 index 5ab1feb..0000000 --- a/web/backend/Service/Service.Auth.Tests/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] -COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] -COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] -COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] -COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] -COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] -COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] -COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"] -COPY ["Service/Service.Auth.Tests/Service.Auth.Tests.csproj", "Service/Service.Auth.Tests/"] -RUN dotnet restore "Service/Service.Auth.Tests/Service.Auth.Tests.csproj" -COPY . . -WORKDIR "/src/Service/Service.Auth.Tests" -RUN dotnet build "./Service.Auth.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build - -FROM build AS final -RUN mkdir -p /app/test-results/service-auth-tests -WORKDIR /src/Service/Service.Auth.Tests -ENTRYPOINT ["dotnet", "test", "./Service.Auth.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/service-auth-tests/results.trx"] diff --git a/web/backend/Service/Service.Auth.Tests/LoginService.test.cs b/web/backend/Service/Service.Auth.Tests/LoginService.test.cs deleted file mode 100644 index 12f2e20..0000000 --- a/web/backend/Service/Service.Auth.Tests/LoginService.test.cs +++ /dev/null @@ -1,254 +0,0 @@ -using Domain.Entities; -using Domain.Exceptions; -using FluentAssertions; -using Infrastructure.PasswordHashing; -using Infrastructure.Repository.Auth; -using Moq; - -namespace Service.Auth.Tests; - -public class LoginServiceTest -{ - private readonly Mock _authRepoMock; - private readonly Mock _passwordInfraMock; - private readonly Mock _tokenServiceMock; - private readonly LoginService _loginService; - - public LoginServiceTest() - { - _authRepoMock = new Mock(); - _passwordInfraMock = new Mock(); - _tokenServiceMock = new Mock(); - _loginService = new LoginService( - _authRepoMock.Object, - _passwordInfraMock.Object, - _tokenServiceMock.Object - ); - } - - // Happy path: login returns the user account with the same username -- successful login - [Fact] - public async Task LoginAsync_WithValidData_ReturnsUserAccountWithMatchingUsername() - { - // Arrange - const string username = "CogitoErgoSum"; - var userAccountId = Guid.NewGuid(); - - UserAccount userAccount = new() - { - UserAccountId = userAccountId, - Username = username, - FirstName = "René", - LastName = "Descartes", - Email = "r.descartes@example.com", - DateOfBirth = new DateTime(1596, 03, 31), - }; - - UserCredential userCredential = new() - { - UserCredentialId = Guid.NewGuid(), - UserAccountId = userAccountId, - Hash = "some-hash", - Expiry = DateTime.MaxValue, - }; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(username)) - .ReturnsAsync(userAccount); - - _authRepoMock - .Setup(x => - x.GetActiveCredentialByUserAccountIdAsync(userAccountId) - ) - .ReturnsAsync(userCredential); - - _passwordInfraMock - .Setup(x => x.Verify(It.IsAny(), It.IsAny())) - .Returns(true); - - _tokenServiceMock - .Setup(x => x.GenerateAccessToken(It.IsAny())) - .Returns("access-token"); - - _tokenServiceMock - .Setup(x => x.GenerateRefreshToken(It.IsAny())) - .Returns("refresh-token"); - - // Act - var result = await _loginService.LoginAsync( - username, - It.IsAny() - ); - - // Assert - result.Should().NotBeNull(); - result.UserAccount.UserAccountId.Should().Be(userAccountId); - result.UserAccount.Username.Should().Be(username); - result.AccessToken.Should().Be("access-token"); - result.RefreshToken.Should().Be("refresh-token"); - - _authRepoMock.Verify( - x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), - Times.Once - ); - - _authRepoMock.Verify( - x => x.GetUserByUsernameAsync(username), - Times.Once - ); - - _passwordInfraMock.Verify( - x => x.Verify(It.IsAny(), It.IsAny()), - Times.Once - ); - } - - [Fact] - public async Task LoginAsync_WithUnregisteredUsername_ThrowsUnauthorizedException() - { - // Arrange - const string username = "de_beauvoir"; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(username)) - .ReturnsAsync((UserAccount?)null); - - // Act - var act = async () => - await _loginService.LoginAsync(username, It.IsAny()); - - // Assert - await act.Should().ThrowAsync(); - - _authRepoMock.Verify( - x => x.GetUserByUsernameAsync(username), - Times.Once - ); - - _authRepoMock.Verify( - x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny()), - Times.Never - ); - - _passwordInfraMock.Verify( - x => x.Verify(It.IsAny(), It.IsAny()), - Times.Never - ); - } - - [Fact] - public async Task LoginAsync_WithNoActiveCredential_ThrowsUnauthorizedException() - { - // Arrange - const string username = "BRussell"; - var userAccountId = Guid.NewGuid(); - - UserAccount userAccount = new() - { - UserAccountId = userAccountId, - Username = username, - FirstName = "Bertrand", - LastName = "Russell", - Email = "b.russell@example.co.uk", - DateOfBirth = new DateTime(1872, 05, 18), - }; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(username)) - .ReturnsAsync(userAccount); - - _authRepoMock - .Setup(x => - x.GetActiveCredentialByUserAccountIdAsync(userAccountId) - ) - .ReturnsAsync((UserCredential?)null); - - // Act - var act = async () => - await _loginService.LoginAsync(username, It.IsAny()); - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Invalid username or password."); - - _authRepoMock.Verify( - x => x.GetUserByUsernameAsync(username), - Times.Once - ); - - _authRepoMock.Verify( - x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), - Times.Once - ); - - _passwordInfraMock.Verify( - x => x.Verify(It.IsAny(), It.IsAny()), - Times.Never - ); - } - - [Fact] - public async Task LoginAsync_WithIncorrectPassword_ThrowsUnauthorizedException() - { - // Arrange - const string username = "RCarnap"; - var userAccountId = Guid.NewGuid(); - - UserAccount userAccount = new() - { - UserAccountId = userAccountId, - Username = username, - FirstName = "Rudolf", - LastName = "Carnap", - Email = "r.carnap@example.de", - DateOfBirth = new DateTime(1891, 05, 18), - }; - - UserCredential userCredential = new() - { - UserCredentialId = Guid.NewGuid(), - UserAccountId = userAccountId, - Hash = "hashed-password", - Expiry = DateTime.MaxValue, - }; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(username)) - .ReturnsAsync(userAccount); - - _authRepoMock - .Setup(x => - x.GetActiveCredentialByUserAccountIdAsync(userAccountId) - ) - .ReturnsAsync(userCredential); - - _passwordInfraMock - .Setup(x => x.Verify(It.IsAny(), It.IsAny())) - .Returns(false); - - // Act - var act = async () => - await _loginService.LoginAsync(username, It.IsAny()); - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Invalid username or password."); - - _authRepoMock.Verify( - x => x.GetUserByUsernameAsync(username), - Times.Once - ); - - _authRepoMock.Verify( - x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId), - Times.Once - ); - - _passwordInfraMock.Verify( - x => x.Verify(It.IsAny(), It.IsAny()), - Times.Once - ); - } -} diff --git a/web/backend/Service/Service.Auth.Tests/RegisterService.test.cs b/web/backend/Service/Service.Auth.Tests/RegisterService.test.cs deleted file mode 100644 index 3f71644..0000000 --- a/web/backend/Service/Service.Auth.Tests/RegisterService.test.cs +++ /dev/null @@ -1,322 +0,0 @@ -using Domain.Entities; -using Domain.Exceptions; -using FluentAssertions; -using Infrastructure.PasswordHashing; -using Infrastructure.Repository.Auth; -using Moq; -using Service.Emails; - -namespace Service.Auth.Tests; - -public class RegisterServiceTest -{ - private readonly Mock _authRepoMock; - private readonly Mock _passwordInfraMock; - private readonly Mock _tokenServiceMock; - private readonly Mock _emailServiceMock; // todo handle email related test cases here - private readonly RegisterService _registerService; - - public RegisterServiceTest() - { - _authRepoMock = new Mock(); - _passwordInfraMock = new Mock(); - _tokenServiceMock = new Mock(); - _emailServiceMock = new Mock(); - - _registerService = new RegisterService( - _authRepoMock.Object, - _passwordInfraMock.Object, - _tokenServiceMock.Object, - _emailServiceMock.Object - ); - } - - [Fact] - public async Task RegisterAsync_WithValidData_CreatesUserAndReturnsAuthServiceReturn() - { - // Arrange - var userAccount = new UserAccount - { - Username = "newuser", - FirstName = "John", - LastName = "Doe", - Email = "john.doe@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; - - const string password = "SecurePassword123!"; - const string hashedPassword = "hashed_password_value"; - var expectedUserId = Guid.NewGuid(); - - // Mock: No existing user - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(userAccount.Username)) - .ReturnsAsync((UserAccount?)null); - - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(userAccount.Email)) - .ReturnsAsync((UserAccount?)null); - - // Mock: Password hashing - _passwordInfraMock - .Setup(x => x.Hash(password)) - .Returns(hashedPassword); - - // Mock: User registration - _authRepoMock - .Setup(x => - x.RegisterUserAsync( - userAccount.Username, - userAccount.FirstName, - userAccount.LastName, - userAccount.Email, - userAccount.DateOfBirth, - hashedPassword - ) - ) - .ReturnsAsync( - new UserAccount - { - UserAccountId = expectedUserId, - Username = userAccount.Username, - FirstName = userAccount.FirstName, - LastName = userAccount.LastName, - Email = userAccount.Email, - DateOfBirth = userAccount.DateOfBirth, - CreatedAt = DateTime.UtcNow, - } - ); - - // Mock: Token generation - _tokenServiceMock - .Setup(x => x.GenerateAccessToken(It.IsAny())) - .Returns("access-token"); - - _tokenServiceMock - .Setup(x => x.GenerateRefreshToken(It.IsAny())) - .Returns("refresh-token"); - - // Act - var result = await _registerService.RegisterAsync( - userAccount, - password - ); - - // Assert - result.Should().NotBeNull(); - result.UserAccount.UserAccountId.Should().Be(expectedUserId); - result.UserAccount.Username.Should().Be(userAccount.Username); - result.UserAccount.Email.Should().Be(userAccount.Email); - result.AccessToken.Should().Be("access-token"); - result.RefreshToken.Should().Be("refresh-token"); - - // Verify all mocks were called as expected - _authRepoMock.Verify( - x => x.GetUserByUsernameAsync(userAccount.Username), - Times.Once - ); - _authRepoMock.Verify( - x => x.GetUserByEmailAsync(userAccount.Email), - Times.Once - ); - _passwordInfraMock.Verify(x => x.Hash(password), Times.Once); - _authRepoMock.Verify( - x => - x.RegisterUserAsync( - userAccount.Username, - userAccount.FirstName, - userAccount.LastName, - userAccount.Email, - userAccount.DateOfBirth, - hashedPassword - ), - Times.Once - ); - _emailServiceMock.Verify( - x => - x.SendRegistrationEmailAsync( - It.IsAny(), - It.IsAny() - ), - Times.Once - ); - } - - [Fact] - public async Task RegisterAsync_WithExistingUsername_ThrowsConflictException() - { - // Arrange - var userAccount = new UserAccount - { - Username = "existinguser", - FirstName = "Jane", - LastName = "Smith", - Email = "jane.smith@example.com", - DateOfBirth = new DateTime(1995, 5, 15), - }; - var password = "Password123!"; - - var existingUser = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "existinguser", - FirstName = "Existing", - LastName = "User", - Email = "existing@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(userAccount.Username)) - .ReturnsAsync(existingUser); - - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(userAccount.Email)) - .ReturnsAsync((UserAccount?)null); - - // Act - var act = async () => - await _registerService.RegisterAsync(userAccount, password); - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Username or email already exists"); - - // Verify that registration was never called - _authRepoMock.Verify( - x => - x.RegisterUserAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ), - Times.Never - ); - } - - [Fact] - public async Task RegisterAsync_WithExistingEmail_ThrowsConflictException() - { - // Arrange - var userAccount = new UserAccount - { - Username = "newuser", - FirstName = "Jane", - LastName = "Smith", - Email = "existing@example.com", - DateOfBirth = new DateTime(1995, 5, 15), - }; - var password = "Password123!"; - - var existingUser = new UserAccount - { - UserAccountId = Guid.NewGuid(), - Username = "otheruser", - FirstName = "Existing", - LastName = "User", - Email = "existing@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(userAccount.Username)) - .ReturnsAsync((UserAccount?)null); - - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(userAccount.Email)) - .ReturnsAsync(existingUser); - - // Act - var act = async () => - await _registerService.RegisterAsync(userAccount, password); - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Username or email already exists"); - - _authRepoMock.Verify( - x => - x.RegisterUserAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ), - Times.Never - ); - } - - [Fact] - public async Task RegisterAsync_PasswordIsHashed_BeforeStoringInDatabase() - { - // Arrange - var userAccount = new UserAccount - { - Username = "secureuser", - FirstName = "Secure", - LastName = "User", - Email = "secure@example.com", - DateOfBirth = new DateTime(1990, 1, 1), - }; - var plainPassword = "PlainPassword123!"; - var hashedPassword = "hashed_secure_password"; - - _authRepoMock - .Setup(x => x.GetUserByUsernameAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - - _authRepoMock - .Setup(x => x.GetUserByEmailAsync(It.IsAny())) - .ReturnsAsync((UserAccount?)null); - - _passwordInfraMock - .Setup(x => x.Hash(plainPassword)) - .Returns(hashedPassword); - - _authRepoMock - .Setup(x => - x.RegisterUserAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - hashedPassword - ) - ) - .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); - - _tokenServiceMock - .Setup(x => x.GenerateAccessToken(It.IsAny())) - .Returns("access-token"); - - _tokenServiceMock - .Setup(x => x.GenerateRefreshToken(It.IsAny())) - .Returns("refresh-token"); - - // Act - await _registerService.RegisterAsync(userAccount, plainPassword); - - // Assert - _passwordInfraMock.Verify(x => x.Hash(plainPassword), Times.Once); - _authRepoMock.Verify( - x => - x.RegisterUserAsync( - userAccount.Username, - userAccount.FirstName, - userAccount.LastName, - userAccount.Email, - userAccount.DateOfBirth, - hashedPassword - ), // Verify hashed password is used - Times.Once - ); - } -} diff --git a/web/backend/Service/Service.Auth.Tests/Service.Auth.Tests.csproj b/web/backend/Service/Service.Auth.Tests/Service.Auth.Tests.csproj deleted file mode 100644 index 75f637d..0000000 --- a/web/backend/Service/Service.Auth.Tests/Service.Auth.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - net10.0 - enable - enable - false - Service.Auth.Tests - Linux - - - - - - - - - - - - - - - - - - - - diff --git a/web/backend/Service/Service.Auth/ConfirmationService.cs b/web/backend/Service/Service.Auth/ConfirmationService.cs deleted file mode 100644 index ca82b42..0000000 --- a/web/backend/Service/Service.Auth/ConfirmationService.cs +++ /dev/null @@ -1,78 +0,0 @@ - -using Domain.Exceptions; -using Infrastructure.Repository.Auth; -using Service.Emails; - -namespace Service.Auth; - -/// -/// Handles confirmation of newly registered user accounts and resending of confirmation emails. -/// -/// Repository used to look up and confirm user accounts. -/// Service used to generate and validate confirmation tokens. -/// Service used to send confirmation-related emails. -public class ConfirmationService( - IAuthRepository authRepository, - ITokenService tokenService, - IEmailService emailService -) : IConfirmationService -{ - /// - /// Validates a confirmation token and marks the corresponding user account as confirmed. - /// - /// The confirmation token issued to the user, typically delivered via email. - /// - /// A containing the UTC timestamp of confirmation and the confirmed user's ID. - /// - /// - /// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found. - /// - public async Task ConfirmUserAsync( - string confirmationToken - ) - { - var validatedToken = await tokenService.ValidateConfirmationTokenAsync( - confirmationToken - ); - - var user = await authRepository.ConfirmUserAccountAsync( - validatedToken.UserId - ); - - if (user == null) - { - throw new UnauthorizedException("User account not found"); - } - - return new ConfirmationServiceReturn( - DateTime.UtcNow, - user.UserAccountId - ); - } - - /// - /// Resends the account confirmation email to a user, generating a fresh confirmation token. - /// - /// The unique identifier of the user requesting the resend. - /// A task that completes once the operation has finished. - /// - /// Returns silently without sending an email if the user does not exist (to prevent user enumeration) - /// or if the user's account is already verified. - /// - public async Task ResendConfirmationEmailAsync(Guid userId) - { - var user = await authRepository.GetUserByIdAsync(userId); - if (user == null) - { - return; // Silent return to prevent user enumeration - } - - if (await authRepository.IsUserVerifiedAsync(userId)) - { - return; // Already confirmed, no-op - } - - var confirmationToken = tokenService.GenerateConfirmationToken(user); - await emailService.SendResendConfirmationEmailAsync(user, confirmationToken); - } -} diff --git a/web/backend/Service/Service.Auth/IConfirmationService.cs b/web/backend/Service/Service.Auth/IConfirmationService.cs deleted file mode 100644 index 832ddb7..0000000 --- a/web/backend/Service/Service.Auth/IConfirmationService.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Domain.Exceptions; -using Infrastructure.Repository.Auth; - -namespace Service.Auth; - -/// -/// Represents the result of successfully confirming a user account. -/// -/// The UTC date and time at which the account was confirmed. -/// The unique identifier of the confirmed user. -public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId); - -/// -/// Defines operations for confirming user accounts and resending confirmation emails. -/// -public interface IConfirmationService -{ - /// - /// Validates a confirmation token and confirms the corresponding user account. - /// - /// The confirmation token issued to the user. - /// A describing the confirmed account. - Task ConfirmUserAsync(string confirmationToken); - - /// - /// Resends the account confirmation email for the specified user. - /// - /// The unique identifier of the user requesting the resend. - /// A task that completes once the operation has finished. - Task ResendConfirmationEmailAsync(Guid userId); - -} diff --git a/web/backend/Service/Service.Auth/ILoginService.cs b/web/backend/Service/Service.Auth/ILoginService.cs deleted file mode 100644 index 51fa7bb..0000000 --- a/web/backend/Service/Service.Auth/ILoginService.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Domain.Entities; - -namespace Service.Auth; - -/// -/// Represents the result of a successful login attempt. -/// -/// The authenticated user's account. -/// The issued refresh token. -/// The issued access token. -public record LoginServiceReturn( - UserAccount UserAccount, - string RefreshToken, - string AccessToken -); - -/// -/// Defines the operation for authenticating a user with a username and password. -/// -public interface ILoginService -{ - /// - /// Authenticates a user using their username and password and issues new tokens. - /// - /// The username of the account to authenticate. - /// The plain-text password to verify against the stored credential. - /// A containing the authenticated user and issued tokens. - Task LoginAsync(string username, string password); -} diff --git a/web/backend/Service/Service.Auth/IRegisterService.cs b/web/backend/Service/Service.Auth/IRegisterService.cs deleted file mode 100644 index 6eae342..0000000 --- a/web/backend/Service/Service.Auth/IRegisterService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Domain.Entities; - -namespace Service.Auth; - -/// -/// Represents the result of a user registration attempt. -/// -public record RegisterServiceReturn -{ - /// - /// Gets a value indicating whether tokens were issued for the newly created account. - /// false when token generation failed, in which case and - /// are empty. - /// - public bool IsAuthenticated { get; init; } = false; - - /// - /// Gets a value indicating whether the registration confirmation email was sent successfully. - /// - public bool EmailSent { get; init; } = false; - - /// - /// Gets the user account that was created. - /// - public UserAccount UserAccount { get; init; } - - /// - /// Gets the issued access token, or an empty string if authentication was not completed. - /// - public string AccessToken { get; init; } = string.Empty; - - /// - /// Gets the issued refresh token, or an empty string if authentication was not completed. - /// - public string RefreshToken { get; init; } = string.Empty; - - /// - /// Initializes a new instance representing a successful registration with issued tokens. - /// - /// The newly created user account. - /// The issued access token. - /// The issued refresh token. - /// Whether the confirmation email was sent successfully. - public RegisterServiceReturn( - UserAccount userAccount, - string accessToken, - string refreshToken, - bool emailSent - ) - { - IsAuthenticated = true; - EmailSent = emailSent; - UserAccount = userAccount; - AccessToken = accessToken; - RefreshToken = refreshToken; - } - - /// - /// Initializes a new instance representing a registration where tokens could not be issued. - /// - /// The newly created user account. - public RegisterServiceReturn(UserAccount userAccount) - { - UserAccount = userAccount; - } -} - -/// -/// Defines the operation for registering a new user account. -/// -public interface IRegisterService -{ - /// - /// Registers a new user account with the given password. - /// - /// The user account details to register. - /// The plain-text password to hash and store for the new account. - /// A describing the outcome of the registration. - Task RegisterAsync( - UserAccount userAccount, - string password - ); -} \ No newline at end of file diff --git a/web/backend/Service/Service.Auth/LoginService.cs b/web/backend/Service/Service.Auth/LoginService.cs deleted file mode 100644 index c03848e..0000000 --- a/web/backend/Service/Service.Auth/LoginService.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Domain.Entities; -using Domain.Exceptions; -using Infrastructure.PasswordHashing; -using Infrastructure.Repository.Auth; - -namespace Service.Auth; - - -/// -/// Handles authenticating users by verifying their credentials and issuing access/refresh tokens. -/// -/// Repository used to look up user accounts and their active credentials. -/// Infrastructure component used to verify a plain-text password against a stored hash. -/// Service used to generate access and refresh tokens for an authenticated user. -public class LoginService( - IAuthRepository authRepo, - IPasswordInfrastructure passwordInfrastructure, - ITokenService tokenService -) : ILoginService -{ - /// - /// Authenticates a user by username and password, issuing a new access and refresh token on success. - /// - /// The username of the account to authenticate. - /// The plain-text password to verify against the stored credential. - /// A containing the authenticated user and issued tokens. - /// - /// Thrown when the username does not match any account, the account has no active credential, - /// or the supplied password does not match the stored hash. - /// - public async Task LoginAsync( - string username, - string password - ) - { - // Attempt lookup by username - // the user was not found - var user = - await authRepo.GetUserByUsernameAsync(username) - ?? throw new UnauthorizedException("Invalid username or password."); - - // @todo handle expired passwords - var activeCred = - await authRepo.GetActiveCredentialByUserAccountIdAsync( - user.UserAccountId - ) - ?? throw new UnauthorizedException("Invalid username or password."); - - if (!passwordInfrastructure.Verify(password, activeCred.Hash)) - throw new UnauthorizedException("Invalid username or password."); - - string accessToken = tokenService.GenerateAccessToken(user); - string refreshToken = tokenService.GenerateRefreshToken(user); - - return new LoginServiceReturn(user, refreshToken, accessToken); - } -} diff --git a/web/backend/Service/Service.Auth/RegisterService.cs b/web/backend/Service/Service.Auth/RegisterService.cs deleted file mode 100644 index b54dd2f..0000000 --- a/web/backend/Service/Service.Auth/RegisterService.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Domain.Entities; -using Domain.Exceptions; -using Infrastructure.Email; -using Infrastructure.Email.Templates.Rendering; -using Infrastructure.PasswordHashing; -using Infrastructure.Repository.Auth; -using Microsoft.Extensions.Logging; -using Service.Emails; - -namespace Service.Auth; - -/// -/// Handles registration of new user accounts, including uniqueness validation, password hashing, -/// token issuance, and sending the registration confirmation email. -/// -/// Repository used to check for existing users and persist the new account. -/// Infrastructure component used to hash the user's plain-text password. -/// Service used to generate access, refresh, and confirmation tokens. -/// Service used to send the registration confirmation email. -public class RegisterService( - IAuthRepository authRepo, - IPasswordInfrastructure passwordInfrastructure, - ITokenService tokenService, - IEmailService emailService -) : IRegisterService -{ - /// - /// Verifies that no existing user account has the same username or email as the given account. - /// - /// The candidate user account whose username and email should be checked for uniqueness. - /// - /// Thrown when an existing account already has the same username or email address. - /// - private async Task ValidateUserDoesNotExist(UserAccount userAccount) - { - // Check if user already exists - var existingUsername = await authRepo.GetUserByUsernameAsync( - userAccount.Username - ); - var existingEmail = await authRepo.GetUserByEmailAsync( - userAccount.Email - ); - - if (existingUsername != null || existingEmail != null) - { - throw new ConflictException("Username or email already exists"); - } - } - - /// - /// Registers a new user account: validates uniqueness, hashes the password, persists the account, - /// issues access/refresh/confirmation tokens, and attempts to send the registration confirmation email. - /// - /// The user account details to register. - /// The plain-text password to hash and store for the new account. - /// - /// A describing the outcome. If token generation fails, the - /// returned value has set to false and no tokens. - /// Otherwise tokens are populated and reflects whether the - /// confirmation email was sent successfully (failures to send the email are swallowed and do not fail registration). - /// - /// - /// Thrown when an existing account already has the same username or email address. - /// - public async Task RegisterAsync( - UserAccount userAccount, - string password - ) - { - await ValidateUserDoesNotExist(userAccount); - // password hashing - var hashed = passwordInfrastructure.Hash(password); - - // Register user with hashed password and get the created user with generated ID - var createdUser = await authRepo.RegisterUserAsync( - userAccount.Username, - userAccount.FirstName, - userAccount.LastName, - userAccount.Email, - userAccount.DateOfBirth, - hashed - ); - - var accessToken = tokenService.GenerateAccessToken(createdUser); - var refreshToken = tokenService.GenerateRefreshToken(createdUser); - var confirmationToken = tokenService.GenerateConfirmationToken(createdUser); - - if ( - string.IsNullOrEmpty(accessToken) - || string.IsNullOrEmpty(refreshToken) - ) - { - return new RegisterServiceReturn(createdUser); - } - - bool emailSent = false; - try - { - // send confirmation email - await emailService.SendRegistrationEmailAsync( - createdUser, confirmationToken - ); - - emailSent = true; - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync(ex.Message); - Console.WriteLine("Could not send email."); - // ignored - } - - return new RegisterServiceReturn( - createdUser, - accessToken, - refreshToken, - emailSent - ); - } -} \ No newline at end of file diff --git a/web/backend/Service/Service.Auth/Service.Auth.csproj b/web/backend/Service/Service.Auth/Service.Auth.csproj deleted file mode 100644 index 29b1f80..0000000 --- a/web/backend/Service/Service.Auth/Service.Auth.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - - - - - - - diff --git a/web/backend/Service/Service.Emails/EmailService.cs b/web/backend/Service/Service.Emails/EmailService.cs deleted file mode 100644 index ded54a0..0000000 --- a/web/backend/Service/Service.Emails/EmailService.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Domain.Entities; -using Infrastructure.Email; -using Infrastructure.Email.Templates.Rendering; - -namespace Service.Emails; - -/// -/// Defines operations for sending account-related emails, such as registration and confirmation resend emails. -/// -public interface IEmailService -{ - /// - /// Sends a welcome email containing an account confirmation link to a newly registered user. - /// - /// The newly created user account to email. - /// The confirmation token to embed in the confirmation link. - /// A task that completes once the email has been sent. - public Task SendRegistrationEmailAsync( - UserAccount createdUser, - string confirmationToken - ); - - /// - /// Sends an email containing a fresh account confirmation link to a user who requested a resend. - /// - /// The user account to email. - /// The confirmation token to embed in the confirmation link. - /// A task that completes once the email has been sent. - public Task SendResendConfirmationEmailAsync( - UserAccount user, - string confirmationToken - ); -} - -/// -/// Default implementation of that renders email templates and dispatches -/// them via an . -/// -/// Provider used to deliver the rendered emails. -/// Provider used to render HTML email bodies from templates. -public class EmailService( - IEmailProvider emailProvider, - IEmailTemplateProvider emailTemplateProvider -) : IEmailService -{ - /// - /// The base URL of the website, used to build confirmation links. Read from the - /// WEBSITE_BASE_URL environment variable. - /// - /// - /// Thrown at type initialization time when the WEBSITE_BASE_URL environment variable is not set. - /// - private static readonly string WebsiteBaseUrl = - Environment.GetEnvironmentVariable("WEBSITE_BASE_URL") - ?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set"); - - /// - /// Builds a confirmation link from the given token, renders the registration welcome email template, - /// and sends it to the newly created user. - /// - /// The newly created user account to email. - /// The confirmation token to embed in the confirmation link. - /// A task that completes once the email has been sent. - public async Task SendRegistrationEmailAsync( - UserAccount createdUser, - string confirmationToken - ) - { - var confirmationLink = - $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; - - var emailHtml = - await emailTemplateProvider.RenderUserRegisteredEmailAsync( - createdUser.FirstName, - confirmationLink - ); - - await emailProvider.SendAsync( - createdUser.Email, - "Welcome to The Biergarten App!", - emailHtml, - isHtml: true - ); - } - - /// - /// Builds a confirmation link from the given token, renders the resend-confirmation email template, - /// and sends it to the user. - /// - /// The user account to email. - /// The confirmation token to embed in the confirmation link. - /// A task that completes once the email has been sent. - public async Task SendResendConfirmationEmailAsync( - UserAccount user, - string confirmationToken - ) - { - var confirmationLink = - $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; - - var emailHtml = - await emailTemplateProvider.RenderResendConfirmationEmailAsync( - user.FirstName, - confirmationLink - ); - - await emailProvider.SendAsync( - user.Email, - "Confirm Your Email - The Biergarten App", - emailHtml, - isHtml: true - ); - } -} diff --git a/web/backend/Service/Service.Emails/Service.Emails.csproj b/web/backend/Service/Service.Emails/Service.Emails.csproj deleted file mode 100644 index df009f9..0000000 --- a/web/backend/Service/Service.Emails/Service.Emails.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - - diff --git a/web/docker-compose.test.yaml b/web/docker-compose.test.yaml index 4ca37c1..74a95db 100644 --- a/web/docker-compose.test.yaml +++ b/web/docker-compose.test.yaml @@ -99,36 +99,16 @@ services: networks: - testnet - repository.tests: + unit.tests: env_file: ".env.test" - image: repository.tests - container_name: test-env-repository-tests + image: unit.tests + container_name: test-env-unit-tests depends_on: database.seed: condition: service_completed_successfully build: context: ./backend - dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile - args: - BUILD_CONFIGURATION: Release - environment: - DOTNET_RUNNING_IN_CONTAINER: "true" - volumes: - - ./test-results:/app/test-results - restart: "no" - networks: - - testnet - - service.auth.tests: - env_file: ".env.test" - image: service.auth.tests - container_name: test-env-service-auth-tests - depends_on: - database.seed: - condition: service_completed_successfully - build: - context: ./backend - dockerfile: Service/Service.Auth.Tests/Dockerfile + dockerfile: Dockerfile.tests args: BUILD_CONFIGURATION: Release environment: