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: