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: