code style cleanup

This commit is contained in:
Aaron Po
2026-06-20 13:55:17 -04:00
parent 0c3b0e99e8
commit 5b882ac51c
167 changed files with 3711 additions and 3522 deletions

View File

@@ -9,32 +9,32 @@
<ItemGroup> <ItemGroup>
<PackageReference <PackageReference
Include="Microsoft.AspNetCore.OpenApi" Include="Microsoft.AspNetCore.OpenApi"
Version="9.0.11" Version="9.0.11"
/> />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference <PackageReference
Include="FluentValidation.AspNetCore" Include="FluentValidation.AspNetCore"
Version="11.3.0" Version="11.3.0"
/> />
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.4.1"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Infrastructure\" /> <Folder Include="Infrastructure\"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/>
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj"/>
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" /> <ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj"/>
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" /> <ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj"/>
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj"/>
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,20 +1,20 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json;
using Shared.Contracts;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Shared.Contracts;
namespace API.Core.Authentication; namespace API.Core.Authentication;
/// <summary> /// <summary>
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens /// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme. /// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
/// </summary> /// </summary>
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param> /// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param> /// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param> /// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param> /// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param> /// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
public class JwtAuthenticationHandler( public class JwtAuthenticationHandler(
@@ -26,66 +26,57 @@ public class JwtAuthenticationHandler(
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder) ) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
{ {
/// <summary> /// <summary>
/// Validates the incoming request's bearer JWT access token and produces an authentication result. /// Validates the incoming request's bearer JWT access token and produces an authentication result.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling /// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used /// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
/// when tokens are issued. Authentication fails if the secret is not configured, the /// when tokens are issued. Authentication fails if the secret is not configured, the
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the /// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
/// token fails validation. /// token fails validation.
/// </remarks> /// </remarks>
/// <returns> /// <returns>
/// A successful <see cref="AuthenticateResult"/> containing the validated <see cref="System.Security.Claims.ClaimsPrincipal"/> /// A successful <see cref="AuthenticateResult" /> containing the validated
/// on success, or a failure result describing why authentication could not be completed. /// <see cref="System.Security.Claims.ClaimsPrincipal" />
/// on success, or a failure result describing why authentication could not be completed.
/// </returns> /// </returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
// Use the same access-token secret source as TokenService to avoid mismatched validation. // Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET"); string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret)) secret = configuration["Jwt:SecretKey"];
{
secret = configuration["Jwt:SecretKey"];
}
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured");
{
return AuthenticateResult.Fail("JWT secret is not configured");
}
// Check if Authorization header exists // Check if Authorization header exists
if ( if (
!Request.Headers.TryGetValue( !Request.Headers.TryGetValue(
"Authorization", "Authorization",
out var authHeaderValue out StringValues authHeaderValue
) )
) )
{
return AuthenticateResult.Fail("Authorization header is missing"); return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString(); string authHeader = authHeaderValue.ToString();
if ( if (
!authHeader.StartsWith( !authHeader.StartsWith(
"Bearer ", "Bearer ",
StringComparison.OrdinalIgnoreCase StringComparison.OrdinalIgnoreCase
) )
) )
{
return AuthenticateResult.Fail( return AuthenticateResult.Fail(
"Invalid authorization header format" "Invalid authorization header format"
); );
}
var token = authHeader.Substring("Bearer ".Length).Trim(); string token = authHeader.Substring("Bearer ".Length).Trim();
try try
{ {
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync( ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token, token,
secret secret
); );
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name); AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket); return AuthenticateResult.Success(ticket);
} }
catch (Exception ex) catch (Exception ex)
@@ -97,7 +88,7 @@ public class JwtAuthenticationHandler(
} }
/// <summary> /// <summary>
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied. /// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
/// </summary> /// </summary>
/// <param name="properties">The authentication properties associated with the challenge.</param> /// <param name="properties">The authentication properties associated with the challenge.</param>
/// <returns>A task that completes once the response body has been written.</returns> /// <returns>A task that completes once the response body has been written.</returns>
@@ -106,13 +97,15 @@ public class JwtAuthenticationHandler(
Response.ContentType = "application/json"; Response.ContentType = "application/json";
Response.StatusCode = 401; Response.StatusCode = 401;
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" }; ResponseBody response = new() { Message = "Unauthorized: Invalid or missing authentication token" };
await Response.WriteAsJsonAsync(response); await Response.WriteAsJsonAsync(response);
} }
} }
/// <summary> /// <summary>
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler"/>. /// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
/// </summary> /// </summary>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions"/>.</remarks> /// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { } public class JwtAuthenticationOptions : AuthenticationSchemeOptions
{
}

View File

@@ -1,27 +1,27 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers namespace API.Core.Controllers;
/// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary>
/// <remarks>
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
public class NotFoundController : ControllerBase
{ {
/// <summary> /// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller. /// Returns a generic 404 response for any request that did not match a defined route.
/// </summary> /// </summary>
/// <remarks> /// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>. [HttpGet("404")] //required
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in <c>Program.cs</c>. public IActionResult Handle404()
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
public class NotFoundController : ControllerBase
{ {
/// <summary> return NotFound(new { message = "Route not found." });
/// Returns a generic 404 response for any request that did not match a defined route.
/// </summary>
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
[HttpGet("404")] //required
public IActionResult Handle404()
{
return NotFound(new { message = "Route not found." });
}
} }
} }

View File

@@ -1,12 +1,12 @@
using System.Security.Claims; using System.Security.Claims;
using Shared.Contracts;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace API.Core.Controllers; namespace API.Core.Controllers;
/// <summary> /// <summary>
/// Sample controller demonstrating an endpoint that requires JWT authentication. /// Sample controller demonstrating an endpoint that requires JWT authentication.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
@@ -14,24 +14,24 @@ namespace API.Core.Controllers;
public class ProtectedController : ControllerBase public class ProtectedController : ControllerBase
{ {
/// <summary> /// <summary>
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims. /// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
/// </summary> /// </summary>
/// <returns> /// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> whose payload contains the /// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier"/>) and <c>username</c> /// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
/// (from <see cref="ClaimTypes.Name"/>), either of which may be <c>null</c> if not present in the token. /// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
/// </returns> /// </returns>
[HttpGet] [HttpGet]
public ActionResult<ResponseBody<object>> Get() public ActionResult<ResponseBody<object>> Get()
{ {
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value; string? username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok( return Ok(
new ResponseBody<object> new ResponseBody<object>
{ {
Message = "Protected endpoint accessed successfully", Message = "Protected endpoint accessed successfully",
Payload = new { userId, username }, Payload = new { userId, username }
} }
); );
} }

View File

@@ -1,48 +1,88 @@
// API.Core/Filters/GlobalExceptionFilter.cs // API.Core/Filters/GlobalExceptionFilter.cs
using Shared.Contracts;
using Domain.Exceptions; using Domain.Exceptions;
using FluentValidation;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using Shared.Contracts;
using ValidationException = FluentValidation.ValidationException;
namespace API.Core; namespace API.Core;
/// <summary> /// <summary>
/// MVC exception filter that converts unhandled exceptions raised by controller actions into /// MVC exception filter that converts unhandled exceptions raised by controller actions into
/// consistent JSON error responses with appropriate HTTP status codes. /// consistent JSON error responses with appropriate HTTP status codes.
/// </summary> /// </summary>
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param> /// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
: IExceptionFilter : IExceptionFilter
{ {
/// <summary> /// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result"/> to an appropriate error response, /// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
/// marking the exception as handled. /// marking the exception as handled.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Maps exception types to responses as follows: /// Maps exception types to responses as follows:
/// <list type="bullet"> /// <list type="bullet">
/// <item><description><see cref="FluentValidation.ValidationException"/> → <c>400 Bad Request</c> with per-property validation error messages.</description></item> /// <item>
/// <item><description><see cref="ConflictException"/> → <c>409 Conflict</c> with a <see cref="ResponseBody"/> message.</description></item> /// <description>
/// <item><description><see cref="NotFoundException"/> → <c>404 Not Found</c> with a <see cref="ResponseBody"/> message.</description></item> /// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
/// <item><description><see cref="UnauthorizedException"/> → <c>401 Unauthorized</c> with a <see cref="ResponseBody"/> message.</description></item> /// validation error messages.
/// <item><description><see cref="ForbiddenException"/> → <c>403 Forbidden</c> with a <see cref="ResponseBody"/> message.</description></item> /// </description>
/// <item><description><see cref="SqlException"/> → <c>503 Service Unavailable</c> with a generic database error message.</description></item> /// </item>
/// <item><description><see cref="Domain.Exceptions.ValidationException"/> → <c>400 Bad Request</c> with a <see cref="ResponseBody"/> message.</description></item> /// <item>
/// <item><description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description></item> /// <description>
/// </list> /// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
/// </item>
/// </list>
/// </remarks> /// </remarks>
/// <param name="context">The context for the unhandled exception, including the exception itself and the result to populate.</param> /// <param name="context">
/// The context for the unhandled exception, including the exception itself and the result to
/// populate.
/// </param>
public void OnException(ExceptionContext context) public void OnException(ExceptionContext context)
{ {
logger.LogError(context.Exception, "Unhandled exception occurred"); logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception) switch (context.Exception)
{ {
case FluentValidation.ValidationException fluentValidationException: case ValidationException fluentValidationException:
var errors = fluentValidationException Dictionary<string, string[]> errors = fluentValidationException
.Errors.GroupBy(e => e.PropertyName) .Errors.GroupBy(e => e.PropertyName)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
@@ -60,7 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message } new ResponseBody { Message = ex.Message }
) )
{ {
StatusCode = 409, StatusCode = 409
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
@@ -70,7 +110,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message } new ResponseBody { Message = ex.Message }
) )
{ {
StatusCode = 404, StatusCode = 404
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
@@ -80,7 +120,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message } new ResponseBody { Message = ex.Message }
) )
{ {
StatusCode = 401, StatusCode = 401
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
@@ -90,7 +130,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message } new ResponseBody { Message = ex.Message }
) )
{ {
StatusCode = 403, StatusCode = 403
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
@@ -100,7 +140,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = "A database error occurred." } new ResponseBody { Message = "A database error occurred." }
) )
{ {
StatusCode = 503, StatusCode = 503
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
@@ -110,7 +150,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message } new ResponseBody { Message = ex.Message }
) )
{ {
StatusCode = 400, StatusCode = 400
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
@@ -119,11 +159,11 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
context.Result = new ObjectResult( context.Result = new ObjectResult(
new ResponseBody new ResponseBody
{ {
Message = "An unexpected error occurred", Message = "An unexpected error occurred"
} }
) )
{ {
StatusCode = 500, StatusCode = 500
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;

View File

@@ -14,13 +14,10 @@ using Infrastructure.Jwt;
using Infrastructure.Sql; using Infrastructure.Sql;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
var builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter // Global Exception Filter
builder.Services.AddControllers(options => builder.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
{
options.Filters.Add<GlobalExceptionFilter>();
})
.AddApplicationPart(typeof(BreweryController).Assembly) .AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly) .AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly); .AddApplicationPart(typeof(AuthController).Assembly);
@@ -54,10 +51,7 @@ builder.Services.AddHealthChecks();
// Configure logging for container output // Configure logging for container output
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddConsole(); builder.Logging.AddConsole();
if (!builder.Environment.IsProduction()) if (!builder.Environment.IsProduction()) builder.Logging.AddDebug();
{
builder.Logging.AddDebug();
}
// Configure Dependency Injection ------------------------------------------------------------------------------------- // Configure Dependency Injection -------------------------------------------------------------------------------------
@@ -88,7 +82,7 @@ builder
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
var app = builder.Build(); WebApplication app = builder.Build();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
@@ -106,7 +100,7 @@ app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound"); app.MapFallbackToController("Handle404", "NotFound");
// Graceful shutdown handling // Graceful shutdown handling
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>(); IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() => lifetime.ApplicationStopping.Register(() =>
{ {
app.Logger.LogInformation("Application is shutting down gracefully..."); app.Logger.LogInformation("Application is shutting down gracefully...");

View File

@@ -8,40 +8,40 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
<PackageReference Include="FluentAssertions" Version="6.9.0" /> <PackageReference Include="FluentAssertions" Version="6.9.0"/>
<PackageReference Include="dbup" Version="5.0.41" /> <PackageReference Include="dbup" Version="5.0.41"/>
<!-- Reqnroll core, xUnit adapter and code-behind generator --> <!-- Reqnroll core, xUnit adapter and code-behind generator -->
<PackageReference Include="Reqnroll" Version="3.3.3" /> <PackageReference Include="Reqnroll" Version="3.3.3"/>
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" /> <PackageReference Include="Reqnroll.xUnit" Version="3.3.3"/>
<PackageReference <PackageReference
Include="Reqnroll.Tools.MsBuild.Generation" Include="Reqnroll.Tools.MsBuild.Generation"
Version="3.3.3" Version="3.3.3"
PrivateAssets="all" PrivateAssets="all"
/> />
<!-- ASP.NET Core integration testing --> <!-- ASP.NET Core integration testing -->
<PackageReference <PackageReference
Include="Microsoft.AspNetCore.Mvc.Testing" Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1" Version="9.0.1"
/> />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Ensure feature files are included in the project --> <!-- Ensure feature files are included in the project -->
<None Include="Features\**\*.feature" /> <None Include="Features\**\*.feature"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" /> <ProjectReference Include="..\API.Core\API.Core.csproj"/>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj"/>
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,51 +1,51 @@
Feature: Protected Endpoint Access Token Validation Feature: Protected Endpoint Access Token Validation
As a backend developer As a backend developer
I want protected endpoints to validate access tokens I want protected endpoints to validate access tokens
So that unauthorized requests are rejected So that unauthorized requests are rejected
Scenario: Protected endpoint accepts valid access token Scenario: Protected endpoint accepts valid access token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in And I am logged in
When I submit a request to a protected endpoint with a valid access token When I submit a request to a protected endpoint with a valid access token
Then the response has HTTP status 200 Then the response has HTTP status 200
Scenario: Protected endpoint rejects missing access token Scenario: Protected endpoint rejects missing access token
Given the API is running Given the API is running
When I submit a request to a protected endpoint without an access token When I submit a request to a protected endpoint without an access token
Then the response has HTTP status 401 Then the response has HTTP status 401
Scenario: Protected endpoint rejects invalid access token Scenario: Protected endpoint rejects invalid access token
Given the API is running Given the API is running
When I submit a request to a protected endpoint with an invalid access token When I submit a request to a protected endpoint with an invalid access token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects expired access token Scenario: Protected endpoint rejects expired access token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in with an immediately-expiring access token And I am logged in with an immediately-expiring access token
When I submit a request to a protected endpoint with the expired token When I submit a request to a protected endpoint with the expired token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects token signed with wrong secret Scenario: Protected endpoint rejects token signed with wrong secret
Given the API is running Given the API is running
And I have an access token signed with the wrong secret And I have an access token signed with the wrong secret
When I submit a request to a protected endpoint with the tampered token When I submit a request to a protected endpoint with the tampered token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects refresh token as access token Scenario: Protected endpoint rejects refresh token as access token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in And I am logged in
When I submit a request to a protected endpoint with my refresh token instead of access token When I submit a request to a protected endpoint with my refresh token instead of access token
Then the response has HTTP status 401 Then the response has HTTP status 401
Scenario: Protected endpoint rejects confirmation token as access token Scenario: Protected endpoint rejects confirmation token as access token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token And I have a valid confirmation token
When I submit a request to a protected endpoint with my confirmation token instead of access token When I submit a request to a protected endpoint with my confirmation token instead of access token
Then the response has HTTP status 401 Then the response has HTTP status 401

View File

@@ -1,76 +1,77 @@
Feature: User Account Confirmation Feature: User Account Confirmation
As a newly registered user As a newly registered user
I want to confirm my email address via a validation token I want to confirm my email address via a validation token
So that my account is fully activated So that my account is fully activated
Scenario: Successful confirmation with valid token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
Scenario: Re-confirming an already verified account remains successful Scenario: Successful confirmation with valid token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
And I have a valid access token for my account And I have a valid access token for my account
When I submit a confirmation request with the valid token When I submit a confirmation request with the valid token
And I submit the same confirmation request again Then the response has HTTP status 200
Then the response has HTTP status 200 And the response JSON should have "message" containing "is confirmed"
And the response JSON should have "message" containing "is confirmed"
Scenario: Confirmation fails with invalid token Scenario: Re-confirming an already verified account remains successful
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid confirmation token for my account
When I submit a confirmation request with an invalid token And I have a valid access token for my account
Then the response has HTTP status 401 When I submit a confirmation request with the valid token
And the response JSON should have "message" containing "Invalid token" And I submit the same confirmation request again
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
Scenario: Confirmation fails with expired token Scenario: Confirmation fails with invalid token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have an expired confirmation token for my account And I have a valid access token for my account
And I have a valid access token for my account When I submit a confirmation request with an invalid token
When I submit a confirmation request with the expired token Then the response has HTTP status 401
Then the response has HTTP status 401 And the response JSON should have "message" containing "Invalid token"
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with tampered token (wrong secret) Scenario: Confirmation fails with expired token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a confirmation token signed with the wrong secret And I have an expired confirmation token for my account
And I have a valid access token for my account And I have a valid access token for my account
When I submit a confirmation request with the tampered token When I submit a confirmation request with the expired token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a confirmation token signed with the wrong secret
When I submit a confirmation request with a missing token And I have a valid access token for my account
Then the response has HTTP status 400 When I submit a confirmation request with the tampered token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation endpoint only accepts POST requests Scenario: Confirmation fails when token is missing
Given the API is running Given the API is running
And I have a valid confirmation token And I have registered a new account
When I submit a confirmation request using an invalid HTTP method And I have a valid access token for my account
Then the response has HTTP status 404 When I submit a confirmation request with a missing token
Then the response has HTTP status 400
Scenario: Confirmation fails with malformed token Scenario: Confirmation endpoint only accepts POST requests
Given the API is running Given the API is running
And I have registered a new account And I have a valid confirmation token
And I have a valid access token for my account When I submit a confirmation request using an invalid HTTP method
When I submit a confirmation request with a malformed token Then the response has HTTP status 404
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails without an access token Scenario: Confirmation fails with malformed token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid access token for my account
When I submit a confirmation request with the valid token without an access token When I submit a confirmation request with a malformed token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails without an access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token without an access token
Then the response has HTTP status 401

View File

@@ -3,37 +3,37 @@ As a registered user
I want to log in to my account I want to log in to my account
So that I receive an authentication token to access authenticated routes So that I receive an authentication token to access authenticated routes
Scenario: Successful login with valid credentials Scenario: Successful login with valid credentials
Given the API is running Given the API is running
And I have an existing account And I have an existing account
When I submit a login request with a username and password When I submit a login request with a username and password
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" equal "Logged in successfully." And the response JSON should have "message" equal "Logged in successfully."
And the response JSON should have an access token And the response JSON should have an access token
Scenario: Login fails with invalid credentials Scenario: Login fails with invalid credentials
Given the API is running Given the API is running
And I do not have an existing account And I do not have an existing account
When I submit a login request with a username and password When I submit a login request with a username and password
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" equal "Invalid username or password." And the response JSON should have "message" equal "Invalid username or password."
Scenario: Login fails when required missing username Scenario: Login fails when required missing username
Given the API is running Given the API is running
When I submit a login request with a missing username When I submit a login request with a missing username
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Login fails when required missing password Scenario: Login fails when required missing password
Given the API is running Given the API is running
When I submit a login request with a missing password When I submit a login request with a missing password
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Login fails when both username and password are missing Scenario: Login fails when both username and password are missing
Given the API is running Given the API is running
When I submit a login request with both username and password missing When I submit a login request with both username and password missing
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Login endpoint only accepts POST requests Scenario: Login endpoint only accepts POST requests
Given the API is running Given the API is running
When I submit a login request using a GET request When I submit a login request using a GET request
Then the response has HTTP status 404 Then the response has HTTP status 404

View File

@@ -3,8 +3,8 @@ As a client of the API
I want consistent 404 responses I want consistent 404 responses
So that consumers can gracefully handle missing routes So that consumers can gracefully handle missing routes
Scenario: GET request to an invalid route returns 404 Scenario: GET request to an invalid route returns 404
Given the API is running Given the API is running
When I send an HTTP request "GET" to "/invalid-route" When I send an HTTP request "GET" to "/invalid-route"
Then the response has HTTP status 404 Then the response has HTTP status 404
And the response JSON should have "message" equal "Route not found." And the response JSON should have "message" equal "Route not found."

View File

@@ -1,60 +1,60 @@
Feature: User Registration Feature: User Registration
As a new user As a new user
I want to register an account I want to register an account
So that I can log in and access authenticated routes So that I can log in and access authenticated routes
Scenario: Successful registration with valid details Scenario: Successful registration with valid details
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! | | newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 201 Then the response has HTTP status 201
And the response JSON should have "message" equal "User registered successfully." And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token And the response JSON should have an access token
Scenario: Registration fails with existing username Scenario: Registration fails with existing username
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! | | test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
Scenario: Registration fails with existing email Scenario: Registration fails with existing email
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! | | newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
Scenario: Registration fails with missing required fields Scenario: Registration fails with missing required fields
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! | | | New | User | | | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Registration fails with invalid email format Scenario: Registration fails with invalid email format
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! | | newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Registration fails with weak password Scenario: Registration fails with weak password
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement) Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! | | younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Registration endpoint only accepts POST requests Scenario: Registration endpoint only accepts POST requests
Given the API is running Given the API is running
When I submit a registration request using a GET request When I submit a registration request using a GET request
Then the response has HTTP status 404 Then the response has HTTP status 404

View File

@@ -1,36 +1,36 @@
Feature: Resend Confirmation Email Feature: Resend Confirmation Email
As a user who did not receive the confirmation email As a user who did not receive the confirmation email
I want to request a resend of the confirmation email I want to request a resend of the confirmation email
So that I can obtain a working confirmation link while preventing abuse So that I can obtain a working confirmation link while preventing abuse
Scenario: Legitimate resend for an unconfirmed user Scenario: Legitimate resend for an unconfirmed user
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
When I submit a resend confirmation request for my account When I submit a resend confirmation request for my account
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent" And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user Scenario: Resend is a no-op for an already confirmed user
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
And I have a valid access token for my account And I have a valid access token for my account
And I have confirmed my account And I have confirmed my account
When I submit a resend confirmation request for my account When I submit a resend confirmation request for my account
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent" And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user Scenario: Resend is a no-op for a non-existent user
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
When I submit a resend confirmation request for a non-existent user When I submit a resend confirmation request for a non-existent user
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent" And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend requires authentication Scenario: Resend requires authentication
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
When I submit a resend confirmation request without an access token When I submit a resend confirmation request without an access token
Then the response has HTTP status 401 Then the response has HTTP status 401

View File

@@ -1,39 +1,39 @@
Feature: Token Refresh Feature: Token Refresh
As an authenticated user As an authenticated user
I want to refresh my access token using my refresh token I want to refresh my access token using my refresh token
So that I can maintain my session without logging in again So that I can maintain my session without logging in again
Scenario: Successful token refresh with valid refresh token Scenario: Successful token refresh with valid refresh token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in And I am logged in
When I submit a refresh token request with a valid refresh token When I submit a refresh token request with a valid refresh token
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" equal "Token refreshed successfully." And the response JSON should have "message" equal "Token refreshed successfully."
And the response JSON should have a new access token And the response JSON should have a new access token
And the response JSON should have a new refresh token And the response JSON should have a new refresh token
Scenario: Token refresh fails with invalid refresh token Scenario: Token refresh fails with invalid refresh token
Given the API is running Given the API is running
When I submit a refresh token request with an invalid refresh token When I submit a refresh token request with an invalid refresh token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid" And the response JSON should have "message" containing "Invalid"
Scenario: Token refresh fails with expired refresh token Scenario: Token refresh fails with expired refresh token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in with an immediately-expiring refresh token And I am logged in with an immediately-expiring refresh token
When I submit a refresh token request with the expired refresh token When I submit a refresh token request with the expired refresh token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Token refresh fails when refresh token is missing Scenario: Token refresh fails when refresh token is missing
Given the API is running Given the API is running
When I submit a refresh token request with a missing refresh token When I submit a refresh token request with a missing refresh token
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Token refresh endpoint only accepts POST requests Scenario: Token refresh endpoint only accepts POST requests
Given the API is running Given the API is running
And I have a valid refresh token And I have a valid refresh token
When I submit a refresh token request using a GET request When I submit a refresh token request using a GET request
Then the response has HTTP status 404 Then the response has HTTP status 404

View File

@@ -20,7 +20,7 @@ public class MockEmailDispatcher : IEmailDispatcher
FirstName = firstName, FirstName = firstName,
Email = email, Email = email,
ConfirmationToken = confirmationToken, ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow, SentAt = DateTime.UtcNow
} }
); );
@@ -39,7 +39,7 @@ public class MockEmailDispatcher : IEmailDispatcher
FirstName = firstName, FirstName = firstName,
Email = email, Email = email,
ConfirmationToken = confirmationToken, ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow, SentAt = DateTime.UtcNow
} }
); );

View File

@@ -3,8 +3,8 @@ using Infrastructure.Email;
namespace API.Specs.Mocks; namespace API.Specs.Mocks;
/// <summary> /// <summary>
/// Mock email provider for testing that doesn't actually send emails. /// Mock email provider for testing that doesn't actually send emails.
/// Tracks sent emails for verification in tests if needed. /// Tracks sent emails for verification in tests if needed.
/// </summary> /// </summary>
public class MockEmailProvider : IEmailProvider public class MockEmailProvider : IEmailProvider
{ {
@@ -24,7 +24,7 @@ public class MockEmailProvider : IEmailProvider
Subject = subject, Subject = subject,
Body = body, Body = body,
IsHtml = isHtml, IsHtml = isHtml,
SentAt = DateTime.UtcNow, SentAt = DateTime.UtcNow
} }
); );
@@ -45,7 +45,7 @@ public class MockEmailProvider : IEmailProvider
Subject = subject, Subject = subject,
Body = body, Body = body,
IsHtml = isHtml, IsHtml = isHtml,
SentAt = DateTime.UtcNow, SentAt = DateTime.UtcNow
} }
); );

View File

@@ -1,5 +1,5 @@
using System.Text;
using System.Text.Json; using System.Text.Json;
using API.Specs;
using FluentAssertions; using FluentAssertions;
using Reqnroll; using Reqnroll;
@@ -15,14 +15,11 @@ public class ApiGeneralSteps(ScenarioContext scenario)
private HttpClient GetClient() private HttpClient GetClient()
{ {
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client)) if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client)) return client;
{
return client;
}
var factory = scenario.TryGetValue<TestApiFactory>( TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey, FactoryKey,
out var f out TestApiFactory? f
) )
? f ? f
: new TestApiFactory(); : new TestApiFactory();
@@ -46,19 +43,19 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string jsonBody string jsonBody
) )
{ {
var client = GetClient(); HttpClient client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url) HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
{ {
Content = new StringContent( Content = new StringContent(
jsonBody, jsonBody,
System.Text.Encoding.UTF8, Encoding.UTF8,
"application/json" "application/json"
), )
}; };
var response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
@@ -70,13 +67,13 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string url string url
) )
{ {
var client = GetClient(); HttpClient client = GetClient();
var requestMessage = new HttpRequestMessage( HttpRequestMessage requestMessage = new(
new HttpMethod(method), new HttpMethod(method),
url url
); );
var response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
@@ -86,7 +83,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseStatusCodeShouldBeInt(int expected) public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
((int)response!.StatusCode).Should().Be(expected); ((int)response!.StatusCode).Should().Be(expected);
@@ -96,7 +93,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseHasHttpStatusInt(int expectedCode) public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue("No response was received from the API"); .BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode); ((int)response!.StatusCode).Should().Be(expectedCode);
@@ -109,20 +106,20 @@ public class ApiGeneralSteps(ScenarioContext scenario)
) )
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody) .TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should() .Should()
.BeTrue(); .BeTrue();
using var doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value)) if (!root.TryGetProperty(field, out JsonElement value))
{ {
root.TryGetProperty("payload", out var payloadElem) root.TryGetProperty("payload", out JsonElement payloadElem)
.Should() .Should()
.BeTrue( .BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'", "Expected field '{0}' to be present either at the root or inside 'payload'",
@@ -157,20 +154,20 @@ public class ApiGeneralSteps(ScenarioContext scenario)
) )
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody) .TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should() .Should()
.BeTrue(); .BeTrue();
using var doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value)) if (!root.TryGetProperty(field, out JsonElement value))
{ {
root.TryGetProperty("payload", out var payloadElem) root.TryGetProperty("payload", out JsonElement payloadElem)
.Should() .Should()
.BeTrue( .BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'", "Expected field '{0}' to be present either at the root or inside 'payload'",
@@ -195,7 +192,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
"Expected field '{0}' to be a string", "Expected field '{0}' to be a string",
field field
); );
var actualValue = value.GetString(); string? actualValue = value.GetString();
actualValue actualValue
.Should() .Should()
.Contain( .Contain(

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,37 @@
using System.Collections.Generic;
using API.Specs.Mocks; using API.Specs.Mocks;
using Features.Emails.Services; using Features.Emails.Services;
using Infrastructure.Email; using Infrastructure.Email;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace API.Specs namespace API.Specs;
public class TestApiFactory : WebApplicationFactory<Program>
{ {
public class TestApiFactory : WebApplicationFactory<Program> protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
protected override void ConfigureWebHost(IWebHostBuilder builder) builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{ {
builder.UseEnvironment("Testing"); // Replace the real email provider with mock for testing
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
builder.ConfigureServices(services => if (emailProviderDescriptor != null) services.Remove(emailProviderDescriptor);
{
// Replace the real email provider with mock for testing
var emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
if (emailProviderDescriptor != null) services.AddScoped<IEmailProvider, MockEmailProvider>();
{
services.Remove(emailProviderDescriptor);
}
services.AddScoped<IEmailProvider, MockEmailProvider>(); // Replace the real email dispatcher with mock for testing
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailDispatcher)
);
// Replace the real email dispatcher with mock for testing if (emailDispatcherDescriptor != null) services.Remove(emailDispatcherDescriptor);
var emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailDispatcher)
);
if (emailDispatcherDescriptor != null) services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
{ });
services.Remove(emailDispatcherDescriptor);
}
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
});
}
} }
} }

View File

@@ -1,37 +1,37 @@
<Solution> <Solution>
<Folder Name="/API/"> <Folder Name="/API/">
<Project Path="API/API.Core/API.Core.csproj" /> <Project Path="API/API.Core/API.Core.csproj"/>
<Project Path="API/API.Specs/API.Specs.csproj" /> <Project Path="API/API.Specs/API.Specs.csproj"/>
</Folder> </Folder>
<Folder Name="/Database/"> <Folder Name="/Database/">
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" /> <Project Path="Database/Database.Migrations/Database.Migrations.csproj"/>
<Project Path="Database/Database.Seed/Database.Seed.csproj" /> <Project Path="Database/Database.Seed/Database.Seed.csproj"/>
</Folder> </Folder>
<Folder Name="/Domain/"> <Folder Name="/Domain/">
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" /> <Project Path="Domain/Domain.Entities/Domain.Entities.csproj"/>
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" /> <Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj"/>
</Folder> </Folder>
<Folder Name="/Infrastructure/"> <Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" /> <Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/>
<Project <Project
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" /> Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" /> <Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj"/>
<Project <Project
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" /> Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" /> <Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
</Folder> </Folder>
<Folder Name="/Features/"> <Folder Name="/Features/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" /> <Project Path="Features/Features.Breweries/Features.Breweries.csproj"/>
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" /> <Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/>
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj" /> <Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/>
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj" /> <Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/>
<Project Path="Features/Features.Auth/Features.Auth.csproj" /> <Project Path="Features/Features.Auth/Features.Auth.csproj"/>
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" /> <Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj"/>
<Project Path="Features/Features.Emails/Features.Emails.csproj" /> <Project Path="Features/Features.Emails/Features.Emails.csproj"/>
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" /> <Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj"/>
</Folder> </Folder>
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj"/>
<Project Path="Shared/Shared.Application/Shared.Application.csproj" /> <Project Path="Shared/Shared.Application/Shared.Application.csproj"/>
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -1,23 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Database.Migrations</RootNamespace> <RootNamespace>Database.Migrations</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="dbup" Version="5.0.41" /> <PackageReference Include="dbup" Version="5.0.41"/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" /> <EmbeddedResource Include="scripts/**/*.sql"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\..\.dockerignore"> <Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link> <Link>.dockerignore</Link>
</Content> </Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,50 +1,57 @@
using System.Data; using System.Data;
using System.Reflection; using System.Reflection;
using DbUp; using DbUp;
using DbUp.Engine;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace Database.Migrations; namespace Database.Migrations;
/// <summary> /// <summary>
/// Entry point for the database migration runner. Reads connection details from /// Entry point for the database migration runner. Reads connection details from
/// environment variables, optionally clears and recreates the target database, and /// environment variables, optionally clears and recreates the target database, and
/// applies all SQL migration scripts embedded in this assembly using DbUp. /// applies all SQL migration scripts embedded in this assembly using DbUp.
/// </summary> /// </summary>
public static class Program public static class Program
{ {
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
private static readonly string connectionString = BuildConnectionString();
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary> /// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>, /// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c> /// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
/// environment variables. /// environment variables.
/// </summary> /// </summary>
/// <param name="databaseName"> /// <param name="databaseName">
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the /// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
/// <c>DB_NAME</c> environment variable. /// <c>DB_NAME</c> environment variable.
/// </param> /// </param>
/// <returns>A fully built SQL Server connection string.</returns> /// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when /// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
/// <paramref name="databaseName"/> is <c>null</c>) <c>DB_NAME</c> is not set. /// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
/// </exception> /// </exception>
private static string BuildConnectionString(string? databaseName = null) private static string BuildConnectionString(string? databaseName = null)
{ {
var server = Environment.GetEnvironmentVariable("DB_SERVER") string server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); ?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName string dbName = databaseName
?? Environment.GetEnvironmentVariable("DB_NAME") ?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); ?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER") string user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set"); ?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD") string password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True"; ?? "True";
var builder = new SqlConnectionStringBuilder SqlConnectionStringBuilder builder = new()
{ {
DataSource = server, DataSource = server,
InitialCatalog = dbName, InitialCatalog = dbName,
@@ -57,47 +64,41 @@ public static class Program
return builder.ConnectionString; return builder.ConnectionString;
} }
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
private static readonly string connectionString = BuildConnectionString();
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary> /// <summary>
/// Applies all pending SQL migration scripts embedded in this assembly to the target /// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console. /// database using DbUp, logging progress to the console.
/// </summary> /// </summary>
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns> /// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
private static bool DeployMigrations() private static bool DeployMigrations()
{ {
var upgrader = DeployChanges UpgradeEngine? upgrader = DeployChanges
.To.SqlDatabase(connectionString) .To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole() .LogToConsole()
.Build(); .Build();
var result = upgrader.PerformUpgrade(); DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
return result.Successful; return result.Successful;
} }
/// <summary> /// <summary>
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user /// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
/// mode with rollback to terminate any existing connections. /// mode with rollback to terminate any existing connections.
/// </summary> /// </summary>
/// <returns> /// <returns>
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c> /// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
/// if an error occurred while connecting or dropping the database. /// if an error occurred while connecting or dropping the database.
/// </returns> /// </returns>
private static bool ClearDatabase() private static bool ClearDatabase()
{ {
var myConn = new SqlConnection(masterConnectionString); SqlConnection myConn = new(masterConnectionString);
try try
{ {
myConn.Open(); myConn.Open();
// First, set the database to single user mode to close all connections // First, set the database to single user mode to close all connections
var setModeCommand = new SqlCommand( SqlCommand setModeCommand = new(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " + "IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;", "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn); myConn);
@@ -106,79 +107,75 @@ public static class Program
setModeCommand.ExecuteNonQuery(); setModeCommand.ExecuteNonQuery();
Console.WriteLine("Database set to single user mode."); Console.WriteLine("Database set to single user mode.");
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}"); Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
} }
// Then drop the database // Then drop the database
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn); SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
try try
{ {
dropCommand.ExecuteNonQuery(); dropCommand.ExecuteNonQuery();
Console.WriteLine("Database cleared successfully."); Console.WriteLine("Database cleared successfully.");
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error dropping database: {ex}"); Console.WriteLine($"Error dropping database: {ex}");
return false; return false;
} }
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error clearing database: {ex}"); Console.WriteLine($"Error clearing database: {ex}");
return false; return false;
} }
finally finally
{ {
if (myConn.State == ConnectionState.Open) if (myConn.State == ConnectionState.Open) myConn.Close();
{
myConn.Close();
}
} }
return true; return true;
} }
/// <summary> /// <summary>
/// Creates the <c>Biergarten</c> database on the master connection if it does not /// Creates the <c>Biergarten</c> database on the master connection if it does not
/// already exist. Errors encountered while creating the database are logged but do /// already exist. Errors encountered while creating the database are logged but do
/// not stop execution. /// not stop execution.
/// </summary> /// </summary>
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns> /// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
private static bool CreateDatabaseIfNotExists() private static bool CreateDatabaseIfNotExists()
{ {
var myConn = new SqlConnection(masterConnectionString); SqlConnection myConn = new(masterConnectionString);
const string str = """ const string str = """
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten] CREATE DATABASE [Biergarten]
"""; """;
var myCommand = new SqlCommand(str, myConn); SqlCommand myCommand = new(str, myConn);
try try
{ {
myConn.Open(); myConn.Open();
myCommand.ExecuteNonQuery(); myCommand.ExecuteNonQuery();
Console.WriteLine("Database creation command executed successfully."); Console.WriteLine("Database creation command executed successfully.");
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error creating database: {ex}"); Console.WriteLine($"Error creating database: {ex}");
} }
finally finally
{ {
if (myConn.State == ConnectionState.Open) if (myConn.State == ConnectionState.Open) myConn.Close();
{
myConn.Close();
}
} }
return true; return true;
} }
/// <summary> /// <summary>
/// Migration runner entry point. Optionally clears the existing database when the /// Migration runner entry point. Optionally clears the existing database when the
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the /// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
/// database exists, then deploys all pending migrations. /// database exists, then deploys all pending migrations.
/// </summary> /// </summary>
/// <param name="args">Command-line arguments (unused).</param> /// <param name="args">Command-line arguments (unused).</param>
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns> /// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
@@ -188,7 +185,7 @@ public static class Program
try try
{ {
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE"); string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
if (clearDatabase == "true") if (clearDatabase == "true")
{ {
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database..."); Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
@@ -196,18 +193,16 @@ public static class Program
} }
CreateDatabaseIfNotExists(); CreateDatabaseIfNotExists();
var success = DeployMigrations(); bool success = DeployMigrations();
if (success) if (success)
{ {
Console.WriteLine("Database migrations completed successfully."); Console.WriteLine("Database migrations completed successfully.");
return 0; return 0;
} }
else
{ Console.WriteLine("Database migrations failed.");
Console.WriteLine("Database migrations failed."); return 1;
return 1;
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount
UserAccountID UNIQUEIDENTIFIER UserAccountID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAccountID DEFAULT NEWID(), CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
Username VARCHAR(64) NOT NULL, Username VARCHAR(64) NOT NULL,
FirstName NVARCHAR(128) NOT NULL, FirstName NVARCHAR(128) NOT NULL,
LastName NVARCHAR(128) NOT NULL, LastName NVARCHAR(128) NOT NULL,
Email VARCHAR(128) NOT NULL, Email VARCHAR(128) NOT NULL,
CreatedAt DATETIME NOT NULL CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(), CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME, UpdatedAt DATETIME,
DateOfBirth DATE NOT NULL, DateOfBirth DATE NOT NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_UserAccount CONSTRAINT PK_UserAccount
PRIMARY KEY (UserAccountID), PRIMARY KEY (UserAccountID),
@@ -56,29 +56,30 @@ CREATE TABLE dbo.UserAccount
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
( (
PhotoID UNIQUEIDENTIFIER PhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_PhotoID DEFAULT NEWID(), CONSTRAINT DF_PhotoID DEFAULT NEWID(),
Hyperlink NVARCHAR(256), Hyperlink NVARCHAR(256),
-- storage is handled via filesystem or cloud service -- storage is handled via filesystem or cloud service
UploadedByID UNIQUEIDENTIFIER NOT NULL, UploadedByID UNIQUEIDENTIFIER NOT NULL,
UploadedAt DATETIME NOT NULL UploadedAt DATETIME NOT NULL
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(), CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_Photo CONSTRAINT PK_Photo
PRIMARY KEY (PhotoID), PRIMARY KEY (PhotoID),
CONSTRAINT FK_Photo_UploadedBy CONSTRAINT FK_Photo_UploadedBy
FOREIGN KEY (UploadedByID) FOREIGN KEY (UploadedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION ON DELETE NO ACTION
); );
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID CREATE
NONCLUSTERED INDEX IX_Photo_UploadedByID
ON Photo(UploadedByID); ON Photo(UploadedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -86,31 +87,32 @@ CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
( (
UserAvatarID UNIQUEIDENTIFIER UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(), CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL, PhotoID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID), CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserAccount CONSTRAINT FK_UserAvatar_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_PhotoID CONSTRAINT FK_UserAvatar_PhotoID
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID), REFERENCES Photo (PhotoID),
CONSTRAINT AK_UserAvatar_UserAccountID CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID); ON UserAvatar(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -118,29 +120,30 @@ CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE TABLE UserVerification -- delete verification data when user account is deleted CREATE TABLE UserVerification -- delete verification data when user account is deleted
( (
UserVerificationID UNIQUEIDENTIFIER UserVerificationID UNIQUEIDENTIFIER
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(), CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
VerificationDateTime DATETIME NOT NULL VerificationDateTime DATETIME NOT NULL
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(), CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_UserVerification CONSTRAINT PK_UserVerification
PRIMARY KEY (UserVerificationID), PRIMARY KEY (UserVerificationID),
CONSTRAINT FK_UserVerification_UserAccount CONSTRAINT FK_UserVerification_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT AK_UserVerification_UserAccountID CONSTRAINT AK_UserVerification_UserAccountID
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID); ON UserVerification(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -151,37 +154,39 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
UserCredentialID UNIQUEIDENTIFIER UserCredentialID UNIQUEIDENTIFIER
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(), CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME NOT NULL CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(), CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
Expiry DATETIME NOT NULL Expiry DATETIME NOT NULL
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()), CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
Hash NVARCHAR(256) NOT NULL, Hash NVARCHAR(256) NOT NULL,
-- uses argon2 -- uses argon2
IsRevoked BIT NOT NULL IsRevoked BIT NOT NULL
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0, CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
RevokedAt DATETIME NULL, RevokedAt DATETIME NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_UserCredential CONSTRAINT PK_UserCredential
PRIMARY KEY (UserCredentialID), PRIMARY KEY (UserCredentialID),
CONSTRAINT FK_UserCredential_UserAccount CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID); ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry) ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash); INCLUDE (Hash);
@@ -190,39 +195,42 @@ CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE TABLE UserFollow CREATE TABLE UserFollow
( (
UserFollowID UNIQUEIDENTIFIER UserFollowID UNIQUEIDENTIFIER
CONSTRAINT DF_UserFollowID DEFAULT NEWID(), CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
FollowingID UNIQUEIDENTIFIER NOT NULL, FollowingID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME NOT NULL CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(), CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_UserFollow CONSTRAINT PK_UserFollow
PRIMARY KEY (UserFollowID), PRIMARY KEY (UserFollowID),
CONSTRAINT FK_UserFollow_UserAccount CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID) FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID) CHECK (UserAccountID != FollowingID
); )
);
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID CREATE
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
ON UserFollow(UserAccountID, FollowingID); ON UserFollow(UserAccountID, FollowingID);
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID); ON UserFollow(FollowingID, UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -230,14 +238,14 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE TABLE Country CREATE TABLE Country
( (
CountryID UNIQUEIDENTIFIER CountryID UNIQUEIDENTIFIER
CONSTRAINT DF_CountryID DEFAULT NEWID(), CONSTRAINT DF_CountryID DEFAULT NEWID(),
CountryName NVARCHAR(100) NOT NULL, CountryName NVARCHAR(100) NOT NULL,
ISO3166_1 CHAR(2) NOT NULL, ISO3166_1 CHAR(2) NOT NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_Country CONSTRAINT PK_Country
PRIMARY KEY (CountryID), PRIMARY KEY (CountryID),
@@ -251,17 +259,17 @@ CREATE TABLE Country
CREATE TABLE StateProvince CREATE TABLE StateProvince
( (
StateProvinceID UNIQUEIDENTIFIER StateProvinceID UNIQUEIDENTIFIER
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(), CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
StateProvinceName NVARCHAR(100) NOT NULL, StateProvinceName NVARCHAR(100) NOT NULL,
ISO3166_2 CHAR(6) NOT NULL, ISO3166_2 CHAR(6) NOT NULL,
-- eg 'US-CA' for California, 'CA-ON' for Ontario -- eg 'US-CA' for California, 'CA-ON' for Ontario
CountryID UNIQUEIDENTIFIER NOT NULL, CountryID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_StateProvince CONSTRAINT PK_StateProvince
PRIMARY KEY (StateProvinceID), PRIMARY KEY (StateProvinceID),
@@ -271,10 +279,11 @@ CREATE TABLE StateProvince
CONSTRAINT FK_StateProvince_Country CONSTRAINT FK_StateProvince_Country
FOREIGN KEY (CountryID) FOREIGN KEY (CountryID)
REFERENCES Country(CountryID) REFERENCES Country (CountryID)
); );
CREATE NONCLUSTERED INDEX IX_StateProvince_Country CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID); ON StateProvince(CountryID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -282,24 +291,25 @@ CREATE NONCLUSTERED INDEX IX_StateProvince_Country
CREATE TABLE City CREATE TABLE City
( (
CityID UNIQUEIDENTIFIER CityID UNIQUEIDENTIFIER
CONSTRAINT DF_CityID DEFAULT NEWID(), CONSTRAINT DF_CityID DEFAULT NEWID(),
CityName NVARCHAR(100) NOT NULL, CityName NVARCHAR(100) NOT NULL,
StateProvinceID UNIQUEIDENTIFIER NOT NULL, StateProvinceID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_City CONSTRAINT PK_City
PRIMARY KEY (CityID), PRIMARY KEY (CityID),
CONSTRAINT FK_City_StateProvince CONSTRAINT FK_City_StateProvince
FOREIGN KEY (StateProvinceID) FOREIGN KEY (StateProvinceID)
REFERENCES StateProvince(StateProvinceID) REFERENCES StateProvince (StateProvinceID)
); );
CREATE NONCLUSTERED INDEX IX_City_StateProvince CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID); ON City(StateProvinceID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -310,29 +320,30 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
BreweryPostID UNIQUEIDENTIFIER BreweryPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
BreweryName NVARCHAR(256) NOT NULL, BreweryName NVARCHAR(256) NOT NULL,
PostedByID UNIQUEIDENTIFIER NOT NULL, PostedByID UNIQUEIDENTIFIER NOT NULL,
Description NVARCHAR(512) NOT NULL, Description NVARCHAR(512) NOT NULL,
CreatedAt DATETIME NOT NULL CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(), CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL, UpdatedAt DATETIME NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BreweryPost CONSTRAINT PK_BreweryPost
PRIMARY KEY (BreweryPostID), PRIMARY KEY (BreweryPostID),
CONSTRAINT FK_BreweryPost_UserAccount CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION ON DELETE NO ACTION
); );
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID); ON BreweryPost(PostedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -343,19 +354,19 @@ CREATE TABLE BreweryPostLocation
BreweryPostLocationID UNIQUEIDENTIFIER BreweryPostLocationID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
BreweryPostID UNIQUEIDENTIFIER NOT NULL, BreweryPostID UNIQUEIDENTIFIER NOT NULL,
AddressLine1 NVARCHAR(256) NOT NULL, AddressLine1 NVARCHAR(256) NOT NULL,
AddressLine2 NVARCHAR(256), AddressLine2 NVARCHAR(256),
PostalCode NVARCHAR(20) NOT NULL, PostalCode NVARCHAR(20) NOT NULL,
CityID UNIQUEIDENTIFIER NOT NULL, CityID UNIQUEIDENTIFIER NOT NULL,
Coordinates GEOGRAPHY NULL, Coordinates GEOGRAPHY NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BreweryPostLocation CONSTRAINT PK_BreweryPostLocation
PRIMARY KEY (BreweryPostLocationID), PRIMARY KEY (BreweryPostLocationID),
@@ -365,18 +376,20 @@ CREATE TABLE BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID) REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID) FOREIGN KEY (CityID)
REFERENCES City(CityID) REFERENCES City (CityID)
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID); ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID); ON BreweryPostLocation(CityID);
-- To assess when the time comes: -- To assess when the time comes:
@@ -399,33 +412,35 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
BreweryPostPhotoID UNIQUEIDENTIFIER BreweryPostPhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
BreweryPostID UNIQUEIDENTIFIER NOT NULL, BreweryPostID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL, PhotoID UNIQUEIDENTIFIER NOT NULL,
LinkedAt DATETIME NOT NULL LinkedAt DATETIME NOT NULL
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(), CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BreweryPostPhoto CONSTRAINT PK_BreweryPostPhoto
PRIMARY KEY (BreweryPostPhotoID), PRIMARY KEY (BreweryPostPhotoID),
CONSTRAINT FK_BreweryPostPhoto_BreweryPost CONSTRAINT FK_BreweryPostPhoto_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID) REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostPhoto_Photo CONSTRAINT FK_BreweryPostPhoto_Photo
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID) REFERENCES Photo (PhotoID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID); ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID); ON BreweryPostPhoto(BreweryPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -436,11 +451,11 @@ CREATE TABLE BeerStyle
BeerStyleID UNIQUEIDENTIFIER BeerStyleID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(), CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
StyleName NVARCHAR(100) NOT NULL, StyleName NVARCHAR(100) NOT NULL,
Description NVARCHAR(MAX), Description NVARCHAR(MAX),
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BeerStyle CONSTRAINT PK_BeerStyle
PRIMARY KEY (BeerStyleID), PRIMARY KEY (BeerStyleID),
@@ -454,47 +469,47 @@ CREATE TABLE BeerStyle
CREATE TABLE BeerPost CREATE TABLE BeerPost
( (
BeerPostID UNIQUEIDENTIFIER BeerPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostID DEFAULT NEWID(), CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
Name NVARCHAR(100) NOT NULL, Name NVARCHAR(100) NOT NULL,
Description NVARCHAR(MAX) NOT NULL, Description NVARCHAR(MAX) NOT NULL,
ABV DECIMAL(4,2) NOT NULL, ABV DECIMAL(4, 2) NOT NULL,
-- Alcohol By Volume (typically 0-67%) -- Alcohol By Volume (typically 0-67%)
IBU INT NOT NULL, IBU INT NOT NULL,
-- International Bitterness Units (typically 0-120) -- International Bitterness Units (typically 0-120)
PostedByID UNIQUEIDENTIFIER NOT NULL, PostedByID UNIQUEIDENTIFIER NOT NULL,
BeerStyleID UNIQUEIDENTIFIER NOT NULL, BeerStyleID UNIQUEIDENTIFIER NOT NULL,
BrewedByID UNIQUEIDENTIFIER NOT NULL, BrewedByID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME NOT NULL CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(), CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME, UpdatedAt DATETIME,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BeerPost CONSTRAINT PK_BeerPost
PRIMARY KEY (BeerPostID), PRIMARY KEY (BeerPostID),
CONSTRAINT FK_BeerPost_PostedBy CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID) FOREIGN KEY (BeerStyleID)
REFERENCES BeerStyle(BeerStyleID), REFERENCES BeerStyle (BeerStyleID),
CONSTRAINT FK_BeerPost_Brewery CONSTRAINT FK_BeerPost_Brewery
FOREIGN KEY (BrewedByID) FOREIGN KEY (BrewedByID)
REFERENCES BreweryPost(BreweryPostID), REFERENCES BreweryPost (BreweryPostID),
CONSTRAINT CHK_BeerPost_ABV CONSTRAINT CHK_BeerPost_ABV
CHECK (ABV >= 0 AND ABV <= 67), CHECK (ABV >= 0 AND ABV <= 67),
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
CHECK (IBU >= 0 AND IBU <= 120) CHECK (IBU >= 0 AND IBU <= 120)
); );
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON BeerPost(PostedByID); ON BeerPost(PostedByID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON BeerPost(BeerStyleID); ON BeerPost(BeerStyleID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON BeerPost(BrewedByID); ON BeerPost(BrewedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -520,33 +538,35 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
BeerPostPhotoID UNIQUEIDENTIFIER BeerPostPhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(), CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
BeerPostID UNIQUEIDENTIFIER NOT NULL, BeerPostID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL, PhotoID UNIQUEIDENTIFIER NOT NULL,
LinkedAt DATETIME NOT NULL LinkedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(), CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BeerPostPhoto CONSTRAINT PK_BeerPostPhoto
PRIMARY KEY (BeerPostPhotoID), PRIMARY KEY (BeerPostPhotoID),
CONSTRAINT FK_BeerPostPhoto_BeerPost CONSTRAINT FK_BeerPostPhoto_BeerPost
FOREIGN KEY (BeerPostID) FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID) REFERENCES BeerPost (BeerPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BeerPostPhoto_Photo CONSTRAINT FK_BeerPostPhoto_Photo
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID) REFERENCES Photo (PhotoID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID); ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID); ON BeerPostPhoto(BeerPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -557,39 +577,41 @@ CREATE TABLE BeerPostComment
BeerPostCommentID UNIQUEIDENTIFIER BeerPostCommentID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(), CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
Comment NVARCHAR(250) NOT NULL, Comment NVARCHAR(250) NOT NULL,
BeerPostID UNIQUEIDENTIFIER NOT NULL, BeerPostID UNIQUEIDENTIFIER NOT NULL,
CommentedByID UNIQUEIDENTIFIER NOT NULL, CommentedByID UNIQUEIDENTIFIER NOT NULL,
Rating INT NOT NULL, Rating INT NOT NULL,
CreatedAt DATETIME NOT NULL CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(), CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL, UpdatedAt DATETIME NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID), PRIMARY KEY (BeerPostCommentID),
CONSTRAINT FK_BeerPostComment_BeerPost CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID) FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID), REFERENCES BeerPost (BeerPostID),
CONSTRAINT FK_BeerPostComment_UserAccount CONSTRAINT FK_BeerPostComment_UserAccount
FOREIGN KEY (CommentedByID) FOREIGN KEY (CommentedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT CHK_BeerPostComment_Rating CONSTRAINT CHK_BeerPostComment_Rating
CHECK (Rating BETWEEN 1 AND 5) CHECK (Rating BETWEEN 1 AND 5)
); );
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID); ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID); ON BeerPostComment(CommentedByID);

View File

@@ -1,15 +1,18 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode CREATE
( OR
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
(
@CountryCode NVARCHAR(2) @CountryCode NVARCHAR(2)
) )
RETURNS UNIQUEIDENTIFIER RETURNS UNIQUEIDENTIFIER
AS AS
BEGIN BEGIN
DECLARE @CountryId UNIQUEIDENTIFIER; DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID SELECT @CountryId = CountryID
FROM dbo.Country FROM dbo.Country
WHERE ISO3166_1 = @CountryCode; WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId; RETURN @CountryId;
END; END;

View File

@@ -1,13 +1,16 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode CREATE
( OR
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
(
@StateProvinceCode NVARCHAR(6) @StateProvinceCode NVARCHAR(6)
) )
RETURNS UNIQUEIDENTIFIER RETURNS UNIQUEIDENTIFIER
AS AS
BEGIN BEGIN
DECLARE @StateProvinceId UNIQUEIDENTIFIER; DECLARE
SELECT @StateProvinceId = StateProvinceID @StateProvinceId UNIQUEIDENTIFIER;
FROM dbo.StateProvince SELECT @StateProvinceId = StateProvinceID
WHERE ISO3166_2 = @StateProvinceCode; FROM dbo.StateProvince
RETURN @StateProvinceId; WHERE ISO3166_2 = @StateProvinceCode;
RETURN @StateProvinceId;
END; END;

View File

@@ -1,36 +1,34 @@
CREATE
CREATE OR ALTER PROCEDURE usp_CreateUserAccount OR
( ALTER PROCEDURE usp_CreateUserAccount
(
@UserAccountId UNIQUEIDENTIFIER OUTPUT, @UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64), @Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@DateOfBirth DATETIME, @DateOfBirth DATETIME,
@Email VARCHAR(128) @Email VARCHAR (128)
)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
INSERT INTO UserAccount
(
Username,
FirstName,
LastName,
DateOfBirth,
Email
) )
AS
BEGIN
SET
NOCOUNT ON;
DECLARE
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
INSERT INTO UserAccount
(Username,
FirstName,
LastName,
DateOfBirth,
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES VALUES
( (
@Username, @Username, @FirstName, @LastName, @DateOfBirth, @Email
@FirstName,
@LastName,
@DateOfBirth,
@Email
); );
SELECT @UserAccountId = UserAccountID FROM @Inserted; SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END; END;

View File

@@ -1,20 +1,24 @@
CREATE
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount OR
( ALTER PROCEDURE usp_DeleteUserAccount
(
@UserAccountId UNIQUEIDENTIFIER @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET
NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN BEGIN
RAISERROR('UserAccount with the specified ID does not exist.', 16, RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1); 1);
ROLLBACK TRANSACTION ROLLBACK TRANSACTION
RETURN RETURN
END END
DELETE FROM UserAccount DELETE
WHERE UserAccountId = @UserAccountId; FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END; END;

View File

@@ -1,16 +1,19 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts CREATE
AS OR
ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
Email, Email,
CreatedAt, CreatedAt,
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount; FROM dbo.UserAccount;
END; END;

View File

@@ -1,19 +1,22 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail( CREATE
@Email VARCHAR(128) OR
) ALTER PROCEDURE usp_GetUserAccountByEmail(
AS @Email VARCHAR (128)
)
AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
Email, Email,
CreatedAt, CreatedAt,
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE Email = @Email; WHERE Email = @Email;
END; END;

View File

@@ -1,19 +1,22 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById( CREATE
OR
ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
Email, Email,
CreatedAt, CreatedAt,
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId; WHERE UserAccountID = @UserAccountId;
END END

View File

@@ -1,19 +1,22 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername( CREATE
@Username VARCHAR(64) OR
) ALTER PROCEDURE usp_GetUserAccountByUsername(
AS @Username VARCHAR (64)
)
AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
Email, Email,
CreatedAt, CreatedAt,
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE Username = @Username; WHERE Username = @Username;
END; END;

View File

@@ -1,27 +1,30 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount( CREATE
@Username VARCHAR(64), OR
ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@DateOfBirth DATETIME, @DateOfBirth DATETIME,
@Email VARCHAR(128), @Email VARCHAR (128),
@UserAccountId UNIQUEIDENTIFIER @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET SET
NOCOUNT ON; NOCOUNT ON;
UPDATE UserAccount UPDATE UserAccount
SET Username = @Username, SET Username = @Username,
FirstName = @FirstName, FirstName = @FirstName,
LastName = @LastName, LastName = @LastName,
DateOfBirth = @DateOfBirth, DateOfBirth = @DateOfBirth,
Email = @Email Email = @Email
WHERE UserAccountId = @UserAccountId; WHERE UserAccountId = @UserAccountId;
IF @@ROWCOUNT = 0 IF
BEGIN @@ROWCOUNT = 0
BEGIN
THROW THROW
50001, 'UserAccount with the specified ID does not exist.', 1; 50001, 'UserAccount with the specified ID does not exist.', 1;
END END
END; END;

View File

@@ -1,17 +1,20 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId( CREATE
OR
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT SELECT UserCredentialId,
UserCredentialId, UserAccountId,
UserAccountId, Hash,
Hash, IsRevoked,
IsRevoked, CreatedAt,
CreatedAt, RevokedAt
RevokedAt FROM dbo.UserCredential
FROM dbo.UserCredential WHERE UserAccountId = @UserAccountId
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0; AND IsRevoked = 0;
END; END;

View File

@@ -1,24 +1,30 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential( CREATE
OR
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER @UserAccountId_ UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION; BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_; EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1; THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked -- invalidate all other credentials by setting them to revoked
UPDATE dbo.UserCredential UPDATE dbo.UserCredential
SET IsRevoked = 1, SET IsRevoked = 1,
RevokedAt = GETDATE() RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_ WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1; AND IsRevoked != 1;
COMMIT TRANSACTION; COMMIT TRANSACTION;
END; END;

View File

@@ -1,21 +1,27 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser( CREATE
@Username VARCHAR(64), OR
ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@DateOfBirth DATETIME, @DateOfBirth DATETIME,
@Email VARCHAR(128), @Email VARCHAR (128),
@Hash NVARCHAR(MAX) @Hash NVARCHAR(MAX)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE @UserAccountId_ UNIQUEIDENTIFIER; DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN TRANSACTION; BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT, @UserAccountId = @UserAccountId_ OUTPUT,
@Username = @Username, @Username = @Username,
@FirstName = @FirstName, @FirstName = @FirstName,
@@ -23,20 +29,24 @@ BEGIN
@DateOfBirth = @DateOfBirth, @DateOfBirth = @DateOfBirth,
@Email = @Email; @Email = @Email;
IF @UserAccountId_ IS NULL IF
BEGIN @UserAccountId_ IS NULL
THROW 50000, 'Failed to create user account.', 1; BEGIN
END THROW
50000, 'Failed to create user account.', 1;
INSERT INTO dbo.UserCredential END
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash); INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
IF @@ROWCOUNT = 0 VALUES (@UserAccountId_, @Hash);
BEGIN
THROW 50002, 'Failed to create user credential.', 1; IF
END @@ROWCOUNT = 0
COMMIT TRANSACTION; BEGIN
THROW
SELECT @UserAccountId_ AS UserAccountId; 50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
END END

View File

@@ -1,28 +1,33 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential( CREATE
OR
ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER, @UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX) @Hash NVARCHAR(MAX)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
BEGIN TRANSACTION; SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_ EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
IF @@ROWCOUNT = 0 IF @@ROWCOUNT = 0
THROW 50001, 'User account not found', 1; THROW 50001, 'User account not found', 1;
-- invalidate all other credentials -- set them to revoked -- invalidate all other credentials -- set them to revoked
UPDATE dbo.UserCredential UPDATE dbo.UserCredential
SET IsRevoked = 1, SET IsRevoked = 1,
RevokedAt = GETDATE() RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_; WHERE UserAccountId = @UserAccountId_;
INSERT INTO dbo.UserCredential INSERT INTO dbo.UserCredential
(UserAccountId, Hash) (UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash); VALUES (@UserAccountId_, @Hash);
END; END;

View File

@@ -1,22 +1,29 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER, CREATE
@VerificationDateTime DATETIME = NULL OR
AS ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF @VerificationDateTime IS NULL IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE(); SET @VerificationDateTime = GETDATE();
BEGIN TRANSACTION; BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_; EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1; THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification INSERT INTO dbo.UserVerification
(UserAccountId, VerificationDateTime) (UserAccountId, VerificationDateTime)
VALUES (@UserAccountID_, @VerificationDateTime); VALUES (@UserAccountID_, @VerificationDateTime);
COMMIT TRANSACTION; COMMIT TRANSACTION;
END END

View File

@@ -1,30 +1,40 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity( CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100), @CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6) @StateProvinceCode NVARCHAR(6)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION BEGIN
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode); TRANSACTION
IF @StateProvinceId IS NULL DECLARE
BEGIN @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
THROW 50001, 'State/province does not exist', 1; IF
END @StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF EXISTS (SELECT 1 IF
EXISTS (SELECT 1
FROM dbo.City FROM dbo.City
WHERE CityName = @CityName WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId) AND StateProvinceID = @StateProvinceId)
BEGIN BEGIN
THROW 50002, 'City already exists.', 1; THROW
END 50002, 'City already exists.', 1;
END
INSERT INTO dbo.City INSERT INTO dbo.City
(StateProvinceID, CityName) (StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName); VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION COMMIT TRANSACTION
END; END;

View File

@@ -1,21 +1,26 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry( CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100), @CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2) @ISO3166_1 NVARCHAR(2)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
BEGIN TRANSACTION; SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF EXISTS (SELECT 1 IF
EXISTS (SELECT 1
FROM dbo.Country FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1) WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1; THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country INSERT INTO dbo.Country
(CountryName, ISO3166_1) (CountryName, ISO3166_1)
VALUES VALUES (@CountryName, @ISO3166_1);
(@CountryName, @ISO3166_1); COMMIT TRANSACTION;
COMMIT TRANSACTION;
END; END;

View File

@@ -1,27 +1,34 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince( CREATE
OR
ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100), @StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6), @ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2) @CountryCode NVARCHAR(2)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF EXISTS (SELECT 1 IF
EXISTS (SELECT 1
FROM dbo.StateProvince FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2) WHERE ISO3166_2 = @ISO3166_2)
RETURN; RETURN;
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode); DECLARE
IF @CountryId IS NULL @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
BEGIN IF
THROW 50001, 'Country does not exist', 1; @CountryId IS NULL
BEGIN
THROW
50001, 'Country does not exist', 1;
END END
INSERT INTO dbo.StateProvince INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID) (StateProvinceName, ISO3166_2, CountryID)
VALUES VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
(@StateProvinceName, @ISO3166_2, @CountryId);
END; END;

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery( CREATE
OR
ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256), @BreweryName NVARCHAR(256),
@Description NVARCHAR(512), @Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER, @PostedByID UNIQUEIDENTIFIER,
@@ -7,44 +9,53 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@AddressLine2 NVARCHAR(256) = NULL, @AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20), @PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL @Coordinates GEOGRAPHY = NULL
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1; THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1; THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID) WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1; THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.City FROM dbo.City
WHERE CityID = @CityID) WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1; THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID(); DECLARE
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID(); @NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION; BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID) (BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID); VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates) (BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates); VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION; COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID, SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID; @NewBrewerLocationID AS BreweryPostLocationID;
END END

View File

@@ -1,15 +1,20 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery CREATE
OR
ALTER PROCEDURE dbo.USP_DeleteBrewery
@BreweryPostID UNIQUEIDENTIFIER @BreweryPostID UNIQUEIDENTIFIER
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID) WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1; THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost. -- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE FROM dbo.BreweryPost DELETE
WHERE BreweryPostID = @BreweryPostID; FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END END

View File

@@ -1,28 +1,30 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries( CREATE
OR
ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL, @Limit INT = NULL,
@Offset INT = NULL @Offset INT = NULL
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT bp.BreweryPostID, SELECT bp.BreweryPostID,
bp.PostedByID, bp.PostedByID,
bp.BreweryName, bp.BreweryName,
bp.Description, bp.Description,
bp.CreatedAt, bp.CreatedAt,
bp.UpdatedAt, bp.UpdatedAt,
bp.Timer, bp.Timer,
bpl.BreweryPostLocationID, bpl.BreweryPostLocationID,
bpl.CityID, bpl.CityID,
bpl.AddressLine1, bpl.AddressLine1,
bpl.AddressLine2, bpl.AddressLine2,
bpl.PostalCode, bpl.PostalCode,
bpl.Coordinates bpl.Coordinates
FROM dbo.BreweryPost bp FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END END

View File

@@ -1,9 +1,11 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER CREATE
AS OR
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN BEGIN
SELECT * SELECT *
FROM BreweryPost bp FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID; WHERE bp.BreweryPostID = @BreweryPostID;
END END

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery( CREATE
OR
ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER, @BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256), @BreweryName NVARCHAR(256),
@Description NVARCHAR(512), @Description NVARCHAR(512),
@@ -8,61 +10,70 @@ CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@AddressLine2 NVARCHAR(256) = NULL, @AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL, @PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL @Coordinates GEOGRAPHY = NULL
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1; THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1; THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID) WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1; THROW 50404, 'Brewery not found.', 1;
IF @CityID IS NOT NULL AND NOT EXISTS (SELECT 1 IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City FROM dbo.City
WHERE CityID = @CityID) WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1; THROW 50404, 'City not found.', 1;
BEGIN TRANSACTION; BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName, SET BreweryName = @BreweryName,
Description = @Description, Description = @Description,
UpdatedAt = GETDATE() UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID; WHERE BreweryPostID = @BreweryPostID;
IF @CityID IS NULL IF
BEGIN @CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery. -- No location supplied: clear any existing location for this brewery.
DELETE FROM dbo.BreweryPostLocation DELETE
WHERE BreweryPostID = @BreweryPostID; FROM dbo.BreweryPostLocation
END WHERE BreweryPostID = @BreweryPostID;
ELSE IF EXISTS (SELECT 1 END
ELSE IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID) WHERE BreweryPostID = @BreweryPostID)
BEGIN BEGIN
UPDATE dbo.BreweryPostLocation UPDATE dbo.BreweryPostLocation
SET CityID = @CityID, SET CityID = @CityID,
AddressLine1 = @AddressLine1, AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2, AddressLine2 = @AddressLine2,
PostalCode = @PostalCode, PostalCode = @PostalCode,
Coordinates = @Coordinates Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID; WHERE BreweryPostID = @BreweryPostID;
END END
ELSE ELSE
BEGIN BEGIN
INSERT INTO dbo.BreweryPostLocation INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates) (BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2, VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates); @PostalCode, @Coordinates);
END END
COMMIT TRANSACTION; COMMIT TRANSACTION;
END END

View File

@@ -1,23 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Database.Seed</RootNamespace> <RootNamespace>Database.Seed</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" /> <PackageReference Include="idunno.Password.Generator" Version="1.0.1"/>
<PackageReference <PackageReference
Include="Konscious.Security.Cryptography.Argon2" Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1" Version="1.3.1"
/> />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4"/>
<PackageReference Include="dbup" Version="5.0.41" /> <PackageReference Include="dbup" Version="5.0.41"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -3,15 +3,15 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed; namespace Database.Seed;
/// <summary> /// <summary>
/// Defines a unit of seed data that can be applied to the database. Implementations /// Defines a unit of seed data that can be applied to the database. Implementations
/// should be safe to run against an already-seeded database (e.g. by checking for /// should be safe to run against an already-seeded database (e.g. by checking for
/// existing data before inserting) and may depend on data created by seeders that run /// existing data before inserting) and may depend on data created by seeders that run
/// before them. /// before them.
/// </summary> /// </summary>
internal interface ISeeder internal interface ISeeder
{ {
/// <summary> /// <summary>
/// Inserts this seeder's data into the database using the supplied open connection. /// Inserts this seeder's data into the database using the supplied open connection.
/// </summary> /// </summary>
/// <param name="connection">An open connection to the target database.</param> /// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when seeding is finished.</returns> /// <returns>A task that completes when seeding is finished.</returns>

View File

@@ -4,13 +4,13 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed; namespace Database.Seed;
/// <summary> /// <summary>
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to /// Seeds the location hierarchy (countries, states/provinces, and cities) used to
/// associate other entities (e.g. breweries, users) with a geographic location. /// associate other entities (e.g. breweries, users) with a geographic location.
/// Countries must be seeded before states/provinces, which must be seeded before /// Countries must be seeded before states/provinces, which must be seeded before
/// cities, since each level references its parent by code. The underlying stored /// cities, since each level references its parent by code. The underlying stored
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>, /// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder /// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
/// against an already-seeded database is safe. /// against an already-seeded database is safe.
/// </summary> /// </summary>
internal class LocationSeeder : ISeeder internal class LocationSeeder : ISeeder
{ {
@@ -22,12 +22,12 @@ internal class LocationSeeder : ISeeder
[ [
("Canada", "CA"), ("Canada", "CA"),
("Mexico", "MX"), ("Mexico", "MX"),
("United States", "US"), ("United States", "US")
]; ];
/// <summary> /// <summary>
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code, /// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
/// and the ISO 3166-1 code of its parent country. /// and the ISO 3166-1 code of its parent country.
/// </summary> /// </summary>
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
{ {
@@ -134,12 +134,12 @@ internal class LocationSeeder : ISeeder
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"), ("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
("Yucatán", "MX-YUC", "MX"), ("Yucatán", "MX-YUC", "MX"),
("Zacatecas", "MX-ZAC", "MX"), ("Zacatecas", "MX-ZAC", "MX"),
("Ciudad de México", "MX-CMX", "MX"), ("Ciudad de México", "MX-CMX", "MX")
]; ];
/// <summary> /// <summary>
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its /// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
/// parent state/province. /// parent state/province.
/// </summary> /// </summary>
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } = private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
[ [
@@ -255,38 +255,32 @@ internal class LocationSeeder : ISeeder
("MX-COA", "Saltillo"), ("MX-COA", "Saltillo"),
("MX-BCS", "La Paz"), ("MX-BCS", "La Paz"),
("MX-NAY", "Tepic"), ("MX-NAY", "Tepic"),
("MX-ZAC", "Zacatecas"), ("MX-ZAC", "Zacatecas")
]; ];
/// <summary> /// <summary>
/// Seeds all countries, then states/provinces, then cities, in that order, so that /// Seeds all countries, then states/provinces, then cities, in that order, so that
/// each level's parent reference already exists by the time it is created. /// each level's parent reference already exists by the time it is created.
/// </summary> /// </summary>
/// <param name="connection">An open connection to the target database.</param> /// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when all locations have been seeded.</returns> /// <returns>A task that completes when all locations have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection) public async Task SeedAsync(SqlConnection connection)
{ {
foreach (var (countryName, countryCode) in Countries) foreach ((string countryName, string countryCode) in Countries)
{
await CreateCountryAsync(connection, countryName, countryCode); await CreateCountryAsync(connection, countryName, countryCode);
}
foreach ( foreach (
var (stateProvinceName, stateProvinceCode, countryCode) in States (string stateProvinceName, string stateProvinceCode, string countryCode) in States
) )
{
await CreateStateProvinceAsync( await CreateStateProvinceAsync(
connection, connection,
stateProvinceName, stateProvinceName,
stateProvinceCode, stateProvinceCode,
countryCode countryCode
); );
}
foreach (var (stateProvinceCode, cityName) in Cities) foreach ((string stateProvinceCode, string cityName) in Cities)
{
await CreateCityAsync(connection, cityName, stateProvinceCode); await CreateCityAsync(connection, cityName, stateProvinceCode);
}
} }
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary> /// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
@@ -300,7 +294,7 @@ internal class LocationSeeder : ISeeder
string countryCode string countryCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new(
"dbo.USP_CreateCountry", "dbo.USP_CreateCountry",
connection connection
); );
@@ -324,7 +318,7 @@ internal class LocationSeeder : ISeeder
string countryCode string countryCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new(
"dbo.USP_CreateStateProvince", "dbo.USP_CreateStateProvince",
connection connection
); );
@@ -350,7 +344,7 @@ internal class LocationSeeder : ISeeder
string stateProvinceCode string stateProvinceCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new(
"dbo.USP_CreateCity", "dbo.USP_CreateCity",
connection connection
); );

View File

@@ -1,7 +1,5 @@
using Microsoft.Data.SqlClient; using Database.Seed;
using DbUp; using Microsoft.Data.SqlClient;
using System.Reflection;
using Database.Seed;
// Entry point for the database seeding utility. Connects to the target database // Entry point for the database seeding utility. Connects to the target database
// (retrying on transient failures), then runs each registered ISeeder in order to // (retrying on transient failures), then runs each registered ISeeder in order to
@@ -19,22 +17,22 @@ using Database.Seed;
/// </exception> /// </exception>
string BuildConnectionString() string BuildConnectionString()
{ {
var server = Environment.GetEnvironmentVariable("DB_SERVER") string server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); ?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = Environment.GetEnvironmentVariable("DB_NAME") string dbName = Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); ?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER") string user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set"); ?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD") string password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True"; ?? "True";
var builder = new SqlConnectionStringBuilder SqlConnectionStringBuilder builder = new()
{ {
DataSource = server, DataSource = server,
InitialCatalog = dbName, InitialCatalog = dbName,
@@ -50,7 +48,7 @@ string BuildConnectionString()
try try
{ {
var connectionString = BuildConnectionString(); string connectionString = BuildConnectionString();
Console.WriteLine("Attempting to connect to database..."); Console.WriteLine("Attempting to connect to database...");
@@ -60,7 +58,6 @@ try
int retryDelayMs = 2000; int retryDelayMs = 2000;
for (int attempt = 1; attempt <= maxRetries; attempt++) for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try try
{ {
connection = new SqlConnection(connectionString); connection = new SqlConnection(connectionString);
@@ -76,12 +73,8 @@ try
connection?.Dispose(); connection?.Dispose();
connection = null; connection = null;
} }
}
if (connection == null) if (connection == null) throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
{
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
}
Console.WriteLine("Starting seeding..."); Console.WriteLine("Starting seeding...");
@@ -90,10 +83,10 @@ try
ISeeder[] seeders = ISeeder[] seeders =
[ [
new LocationSeeder(), new LocationSeeder(),
new UserSeeder(), new UserSeeder()
]; ];
foreach (var seeder in seeders) foreach (ISeeder seeder in seeders)
{ {
Console.WriteLine($"Seeding {seeder.GetType().Name}..."); Console.WriteLine($"Seeding {seeder.GetType().Name}...");
await seeder.SeedAsync(connection); await seeder.SeedAsync(connection);

View File

@@ -8,13 +8,13 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed; namespace Database.Seed;
/// <summary> /// <summary>
/// Seeds user accounts, credentials, and verification records. Creates one fixed /// Seeds user accounts, credentials, and verification records. Creates one fixed
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>) /// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
/// for testing, followed by a randomly-generated account for each name in /// for testing, followed by a randomly-generated account for each name in
/// <see cref="SeedNames"/>, each with a random password and date of birth. Skips /// <see cref="SeedNames" />, each with a random password and date of birth. Skips
/// adding a verification record for a user that already has one, so this seeder is /// adding a verification record for a user that already has one, so this seeder is
/// safe to re-run, though re-running will still attempt to register (and may fail on) /// safe to re-run, though re-running will still attempt to register (and may fail on)
/// users that already exist. /// users that already exist.
/// </summary> /// </summary>
internal class UserSeeder : ISeeder internal class UserSeeder : ISeeder
{ {
@@ -123,21 +123,21 @@ internal class UserSeeder : ISeeder
("Zara", "Wilkinson"), ("Zara", "Wilkinson"),
("Zaria", "Gibson"), ("Zaria", "Gibson"),
("Zion", "Watkins"), ("Zion", "Watkins"),
("Zoie", "Armstrong"), ("Zoie", "Armstrong")
]; ];
/// <summary> /// <summary>
/// Registers a fixed test user account followed by one randomly-generated account /// Registers a fixed test user account followed by one randomly-generated account
/// per entry in <see cref="SeedNames"/>, adding a user verification record for each /// per entry in <see cref="SeedNames" />, adding a user verification record for each
/// newly created account that does not already have one. Progress counts are /// newly created account that does not already have one. Progress counts are
/// written to the console on completion. /// written to the console on completion.
/// </summary> /// </summary>
/// <param name="connection">An open connection to the target database.</param> /// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when all users have been seeded.</returns> /// <returns>A task that completes when all users have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection) public async Task SeedAsync(SqlConnection connection)
{ {
var generator = new PasswordGenerator(); PasswordGenerator generator = new();
var rng = new Random(); Random rng = new();
int createdUsers = 0; int createdUsers = 0;
int createdCredentials = 0; int createdCredentials = 0;
int createdVerifications = 0; int createdVerifications = 0;
@@ -147,8 +147,8 @@ internal class UserSeeder : ISeeder
const string firstName = "Test"; const string firstName = "Test";
const string lastName = "User"; const string lastName = "User";
const string email = "test.user@thebiergarten.app"; const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01); DateTime dob = new(1985, 03, 01);
var hash = GeneratePasswordHash("password"); string hash = GeneratePasswordHash("password");
await RegisterUserAsync( await RegisterUserAsync(
connection, connection,
@@ -160,24 +160,24 @@ internal class UserSeeder : ISeeder
hash hash
); );
} }
foreach (var (firstName, lastName) in SeedNames) foreach ((string firstName, string lastName) in SeedNames)
{ {
// prepare user fields // prepare user fields
var username = $"{firstName[0]}.{lastName}"; string username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app"; string email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng); DateTime dob = GenerateDateOfBirth(rng);
// generate a password and hash it // generate a password and hash it
string pwd = generator.Generate( string pwd = generator.Generate(
length: 64, 64,
numberOfDigits: 10, 10,
numberOfSymbols: 10 10
); );
string hash = GeneratePasswordHash(pwd); string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential) // register the user (creates account + credential)
var id = await RegisterUserAsync( Guid id = await RegisterUserAsync(
connection, connection,
username, username,
firstName, firstName,
@@ -203,8 +203,8 @@ internal class UserSeeder : ISeeder
} }
/// <summary> /// <summary>
/// Registers a new user account and its credential by invoking the /// Registers a new user account and its credential by invoking the
/// <c>dbo.USP_RegisterUser</c> stored procedure. /// <c>dbo.USP_RegisterUser</c> stored procedure.
/// </summary> /// </summary>
/// <param name="connection">An open connection to the target database.</param> /// <param name="connection">An open connection to the target database.</param>
/// <param name="username">The unique username for the account.</param> /// <param name="username">The unique username for the account.</param>
@@ -212,8 +212,8 @@ internal class UserSeeder : ISeeder
/// <param name="lastName">The user's last name.</param> /// <param name="lastName">The user's last name.</param>
/// <param name="dateOfBirth">The user's date of birth.</param> /// <param name="dateOfBirth">The user's date of birth.</param>
/// <param name="email">The user's email address.</param> /// <param name="email">The user's email address.</param>
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash"/>.</param> /// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash" />.</param>
/// <returns>The newly created user account's <see cref="Guid"/> identifier.</returns> /// <returns>The newly created user account's <see cref="Guid" /> identifier.</returns>
private static async Task<Guid> RegisterUserAsync( private static async Task<Guid> RegisterUserAsync(
SqlConnection connection, SqlConnection connection,
string username, string username,
@@ -224,7 +224,7 @@ internal class UserSeeder : ISeeder
string hash string hash
) )
{ {
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection); await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
@@ -235,16 +235,16 @@ internal class UserSeeder : ISeeder
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email; command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash; command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return (Guid)result!; return (Guid)result!;
} }
/// <summary> /// <summary>
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte /// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
/// salt, a degree of parallelism matching the available processor count, 64 MB of /// salt, a degree of parallelism matching the available processor count, 64 MB of
/// memory, and 4 iterations. /// memory, and 4 iterations.
/// </summary> /// </summary>
/// <param name="pwd">The plaintext password to hash.</param> /// <param name="pwd">The plaintext password to hash.</param>
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns> /// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
@@ -252,12 +252,12 @@ internal class UserSeeder : ISeeder
{ {
byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd)) Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
{ {
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = 65536, MemorySize = 65536,
Iterations = 4, Iterations = 4
}; };
byte[] hash = argon2.GetBytes(32); byte[] hash = argon2.GetBytes(32);
@@ -278,9 +278,9 @@ internal class UserSeeder : ISeeder
FROM dbo.UserVerification FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId; WHERE UserAccountId = @UserAccountId;
"""; """;
await using var command = new SqlCommand(sql, connection); await using SqlCommand command = new(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId); command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return result is not null; return result is not null;
} }
@@ -293,7 +293,7 @@ internal class UserSeeder : ISeeder
Guid userAccountId Guid userAccountId
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new(
"dbo.USP_CreateUserVerification", "dbo.USP_CreateUserVerification",
connection connection
); );
@@ -304,8 +304,8 @@ internal class UserSeeder : ISeeder
} }
/// <summary> /// <summary>
/// Generates a random date of birth corresponding to an age between 19 and 48 /// Generates a random date of birth corresponding to an age between 19 and 48
/// years (inclusive), with a random day offset within that birth year. /// years (inclusive), with a random day offset within that birth year.
/// </summary> /// </summary>
/// <param name="random">The random number source to use.</param> /// <param name="random">The random number source to use.</param>
/// <returns>A randomly generated date of birth.</returns> /// <returns>A randomly generated date of birth.</returns>

View File

@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Domain</RootNamespace> <RootNamespace>Domain</RootNamespace>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -1,48 +1,50 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table. /// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
/// A user account cannot be deleted while it has an associated brewery post. /// A user account cannot be deleted while it has an associated brewery post.
/// </summary> /// </summary>
public class BreweryPost public class BreweryPost
{ {
/// <summary> /// <summary>
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>. /// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
/// </summary> /// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary> /// <summary>
/// Foreign key referencing the <see cref="UserAccount"/> that authored this post. Maps to <c>PostedByID</c>. /// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
/// </summary> /// </summary>
public Guid PostedById { get; set; } public Guid PostedById { get; set; }
/// <summary> /// <summary>
/// The name of the brewery being posted about. /// The name of the brewery being posted about.
/// </summary> /// </summary>
public string BreweryName { get; set; } = string.Empty; public string BreweryName { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Free-text description of the brewery. /// Free-text description of the brewery.
/// </summary> /// </summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The date and time the post was created. /// The date and time the post was created.
/// </summary> /// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary> /// <summary>
/// The date and time the post was last updated, or <c>null</c> if it has never been updated. /// The date and time the post was last updated, or <c>null</c> if it has never been updated.
/// </summary> /// </summary>
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary> /// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database. /// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
/// <summary> /// <summary>
/// The associated <see cref="BreweryPostLocation"/> for this post, if one has been set. This is a one-to-one navigation property. /// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
/// navigation property.
/// </summary> /// </summary>
public BreweryPostLocation? Location { get; set; } public BreweryPostLocation? Location { get; set; }
} }

View File

@@ -1,48 +1,52 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Represents the physical location of a <see cref="BreweryPost"/>. Maps to the <c>BreweryPostLocation</c> table. /// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is deleted. /// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
/// deleted.
/// </summary> /// </summary>
public class BreweryPostLocation public class BreweryPostLocation
{ {
/// <summary> /// <summary>
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>. /// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
/// </summary> /// </summary>
public Guid BreweryPostLocationId { get; set; } public Guid BreweryPostLocationId { get; set; }
/// <summary> /// <summary>
/// Foreign key referencing the owning <see cref="BreweryPost"/>. Maps to <c>BreweryPostID</c>; unique, enforcing a one-to-one relationship. /// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary> /// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary> /// <summary>
/// The primary street address line. /// The primary street address line.
/// </summary> /// </summary>
public string AddressLine1 { get; set; } = string.Empty; public string AddressLine1 { get; set; } = string.Empty;
/// <summary> /// <summary>
/// An optional secondary address line (e.g., suite or unit number). /// An optional secondary address line (e.g., suite or unit number).
/// </summary> /// </summary>
public string? AddressLine2 { get; set; } public string? AddressLine2 { get; set; }
/// <summary> /// <summary>
/// The postal/ZIP code of the location. /// The postal/ZIP code of the location.
/// </summary> /// </summary>
public string PostalCode { get; set; } = string.Empty; public string PostalCode { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>. /// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
/// </summary> /// </summary>
public Guid CityId { get; set; } public Guid CityId { get; set; }
/// <summary> /// <summary>
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or <c>null</c> if not set. /// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
/// <c>null</c> if not set.
/// </summary> /// </summary>
public byte[]? Coordinates { get; set; } public byte[]? Coordinates { get; set; }
/// <summary> /// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database. /// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,53 +1,55 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity /// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
/// referenced by <see cref="UserCredential"/>, <see cref="UserVerification"/>, brewery posts, and other user-owned data. /// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
/// data.
/// </summary> /// </summary>
public class UserAccount public class UserAccount
{ {
/// <summary> /// <summary>
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>. /// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
/// </summary> /// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
/// <summary> /// <summary>
/// The user's unique username, used for login and display. Enforced unique at the database level. /// The user's unique username, used for login and display. Enforced unique at the database level.
/// </summary> /// </summary>
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The user's first name. /// The user's first name.
/// </summary> /// </summary>
public string FirstName { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The user's last name. /// The user's last name.
/// </summary> /// </summary>
public string LastName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The user's email address. Enforced unique at the database level. /// The user's email address. Enforced unique at the database level.
/// </summary> /// </summary>
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The date and time the account was created. /// The date and time the account was created.
/// </summary> /// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary> /// <summary>
/// The date and time the account was last updated, or <c>null</c> if it has never been updated. /// The date and time the account was last updated, or <c>null</c> if it has never been updated.
/// </summary> /// </summary>
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary> /// <summary>
/// The user's date of birth. /// The user's date of birth.
/// </summary> /// </summary>
public DateTime DateOfBirth { get; set; } public DateTime DateOfBirth { get; set; }
/// <summary> /// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database. /// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,38 +1,40 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount"/>. /// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is deleted. /// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
/// deleted.
/// </summary> /// </summary>
public class UserCredential public class UserCredential
{ {
/// <summary> /// <summary>
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>. /// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
/// </summary> /// </summary>
public Guid UserCredentialId { get; set; } public Guid UserCredentialId { get; set; }
/// <summary> /// <summary>
/// Foreign key referencing the owning <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>. /// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
/// </summary> /// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
/// <summary> /// <summary>
/// The date and time the credential was issued. /// The date and time the credential was issued.
/// </summary> /// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary> /// <summary>
/// The date and time after which the credential is no longer valid. /// The date and time after which the credential is no longer valid.
/// </summary> /// </summary>
public DateTime Expiry { get; set; } public DateTime Expiry { get; set; }
/// <summary> /// <summary>
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted. /// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
/// </summary> /// </summary>
public string Hash { get; set; } = string.Empty; public string Hash { get; set; } = string.Empty;
/// <summary> /// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database. /// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,29 +1,31 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Records that a <see cref="UserAccount"/> has completed verification (e.g., email confirmation). /// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record, /// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
/// and it is deleted automatically when the owning user account is deleted. /// and it is deleted automatically when the owning user account is deleted.
/// </summary> /// </summary>
public class UserVerification public class UserVerification
{ {
/// <summary> /// <summary>
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>. /// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
/// </summary> /// </summary>
public Guid UserVerificationId { get; set; } public Guid UserVerificationId { get; set; }
/// <summary> /// <summary>
/// Foreign key referencing the verified <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>; unique, enforcing a one-to-one relationship. /// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary> /// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
/// <summary> /// <summary>
/// The date and time at which the user account was verified. /// The date and time at which the user account was verified.
/// </summary> /// </summary>
public DateTime VerificationDateTime { get; set; } public DateTime VerificationDateTime { get; set; }
/// <summary> /// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database. /// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,33 +1,33 @@
namespace Domain.Exceptions; namespace Domain.Exceptions;
/// <summary> /// <summary>
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use). /// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
/// Maps to HTTP 409 Conflict. /// Maps to HTTP 409 Conflict.
/// </summary> /// </summary>
public class ConflictException(string message) : Exception(message); public class ConflictException(string message) : Exception(message);
/// <summary> /// <summary>
/// Exception thrown when a requested resource is not found. /// Exception thrown when a requested resource is not found.
/// Maps to HTTP 404 Not Found. /// Maps to HTTP 404 Not Found.
/// </summary> /// </summary>
public class NotFoundException(string message) : Exception(message); public class NotFoundException(string message) : Exception(message);
// Domain.Exceptions/UnauthorizedException.cs // Domain.Exceptions/UnauthorizedException.cs
/// <summary> /// <summary>
/// Exception thrown when authentication fails or is required. /// Exception thrown when authentication fails or is required.
/// Maps to HTTP 401 Unauthorized. /// Maps to HTTP 401 Unauthorized.
/// </summary> /// </summary>
public class UnauthorizedException(string message) : Exception(message); public class UnauthorizedException(string message) : Exception(message);
/// <summary> /// <summary>
/// Exception thrown when a user is authenticated but lacks permission to access a resource. /// Exception thrown when a user is authenticated but lacks permission to access a resource.
/// Maps to HTTP 403 Forbidden. /// Maps to HTTP 403 Forbidden.
/// </summary> /// </summary>
public class ForbiddenException(string message) : Exception(message); public class ForbiddenException(string message) : Exception(message);
/// <summary> /// <summary>
/// Exception thrown when business rule validation fails (distinct from FluentValidation). /// Exception thrown when business rule validation fails (distinct from FluentValidation).
/// Maps to HTTP 400 Bad Request. /// Maps to HTTP 400 Bad Request.
/// </summary> /// </summary>
public class ValidationException(string message) : Exception(message); public class ValidationException(string message) : Exception(message);

View File

@@ -2,10 +2,11 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Commands.ConfirmUser; using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Auth.Tests.Commands; namespace Features.Auth.Tests.Commands;
@@ -13,8 +14,8 @@ namespace Features.Auth.Tests.Commands;
public class ConfirmUserHandlerTests public class ConfirmUserHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly ConfirmUserHandler _handler; private readonly ConfirmUserHandler _handler;
private readonly Mock<ITokenService> _tokenServiceMock;
public ConfirmUserHandlerTests() public ConfirmUserHandlerTests()
{ {
@@ -25,30 +26,30 @@ public class ConfirmUserHandlerTests
private static ValidatedToken MakeValidatedToken(Guid userId, string username) private static ValidatedToken MakeValidatedToken(Guid userId, string username)
{ {
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
return new ValidatedToken(userId, username, principal); return new ValidatedToken(userId, username, principal);
} }
[Fact] [Fact]
public async Task Handle_WithValidConfirmationToken_ConfirmsUser() public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string confirmationToken = "valid-confirmation-token"; const string confirmationToken = "valid-confirmation-token";
var validatedToken = MakeValidatedToken(userId, username); ValidatedToken validatedToken = MakeValidatedToken(userId, username);
var userAccount = new UserAccount { UserAccountId = userId, Username = username }; UserAccount userAccount = new() { UserAccountId = userId, Username = username };
_tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken); _tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken);
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount); _authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
var result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None); ConfirmationPayload result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserAccountId.Should().Be(userId); result.UserAccountId.Should().Be(userId);
@@ -63,7 +64,7 @@ public class ConfirmUserHandlerTests
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token")); .ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
var act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None); Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>(); await act.Should().ThrowAsync<UnauthorizedException>();
} }
@@ -71,7 +72,7 @@ public class ConfirmUserHandlerTests
[Fact] [Fact]
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException() public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "nonexistent"; const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user"; const string confirmationToken = "valid-token-for-nonexistent-user";
@@ -80,7 +81,7 @@ public class ConfirmUserHandlerTests
.ReturnsAsync(MakeValidatedToken(userId, username)); .ReturnsAsync(MakeValidatedToken(userId, username));
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync((UserAccount?)null); _authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None); Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*"); await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*");
} }

View File

@@ -1,7 +1,8 @@
using Domain.Entities; using Domain.Entities;
using FluentAssertions;
using Features.Auth.Commands.RefreshToken; using Features.Auth.Commands.RefreshToken;
using Features.Auth.Dtos;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Auth.Tests.Commands; namespace Features.Auth.Tests.Commands;
@@ -11,16 +12,16 @@ public class RefreshTokenHandlerTests
[Fact] [Fact]
public async Task Handle_MapsTokenServiceResult_ToLoginPayload() public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
{ {
var tokenServiceMock = new Mock<ITokenService>(); Mock<ITokenService> tokenServiceMock = new();
var handler = new RefreshTokenHandler(tokenServiceMock.Object); RefreshTokenHandler handler = new(tokenServiceMock.Object);
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId, Username = "testuser" }; UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
tokenServiceMock tokenServiceMock
.Setup(x => x.RefreshTokenAsync("old-refresh-token")) .Setup(x => x.RefreshTokenAsync("old-refresh-token"))
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token")); .ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
var result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None); LoginPayload result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None);
result.UserAccountId.Should().Be(userId); result.UserAccountId.Should().Be(userId);
result.Username.Should().Be("testuser"); result.Username.Should().Be("testuser");

View File

@@ -1,9 +1,10 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.RegisterUser;
using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using MediatR; using MediatR;
using Moq; using Moq;
@@ -14,10 +15,10 @@ namespace Features.Auth.Tests.Commands;
public class RegisterUserHandlerTests public class RegisterUserHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepoMock; private readonly Mock<IAuthRepository> _authRepoMock;
private readonly RegisterUserHandler _handler;
private readonly Mock<IMediator> _mediatorMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock; private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<IMediator> _mediatorMock;
private readonly RegisterUserHandler _handler;
public RegisterUserHandlerTests() public RegisterUserHandlerTests()
{ {
@@ -34,22 +35,25 @@ public class RegisterUserHandlerTests
); );
} }
private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com") => private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com")
new(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!"); {
return new RegisterUserCommand(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
}
[Fact] [Fact]
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload() public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
{ {
var command = ValidCommand(); RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_password_value"; const string hashedPassword = "hashed_password_value";
var expectedUserId = Guid.NewGuid(); Guid expectedUserId = Guid.NewGuid();
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword); _passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock _authRepoMock
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword)) .Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
command.DateOfBirth, hashedPassword))
.ReturnsAsync(new UserAccount .ReturnsAsync(new UserAccount
{ {
UserAccountId = expectedUserId, UserAccountId = expectedUserId,
@@ -58,14 +62,15 @@ public class RegisterUserHandlerTests
LastName = command.LastName, LastName = command.LastName,
Email = command.Email, Email = command.Email,
DateOfBirth = command.DateOfBirth, DateOfBirth = command.DateOfBirth,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow
}); });
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token"); _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>())).Returns("confirmation-token"); _tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
.Returns("confirmation-token");
var result = await _handler.Handle(command, CancellationToken.None); RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId); result.UserAccountId.Should().Be(expectedUserId);
@@ -83,18 +88,19 @@ public class RegisterUserHandlerTests
[Fact] [Fact]
public async Task Handle_WithExistingUsername_ThrowsConflictException() public async Task Handle_WithExistingUsername_ThrowsConflictException()
{ {
var command = ValidCommand(username: "existinguser"); RegisterUserCommand command = ValidCommand("existinguser");
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "existinguser" }; UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(command, CancellationToken.None); Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists"); await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
_authRepoMock.Verify( _authRepoMock.Verify(
x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()), x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<DateTime>(), It.IsAny<string>()),
Times.Never Times.Never
); );
} }
@@ -102,13 +108,13 @@ public class RegisterUserHandlerTests
[Fact] [Fact]
public async Task Handle_WithExistingEmail_ThrowsConflictException() public async Task Handle_WithExistingEmail_ThrowsConflictException()
{ {
var command = ValidCommand(email: "existing@example.com"); RegisterUserCommand command = ValidCommand(email: "existing@example.com");
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" }; UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser); _authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
var act = async () => await _handler.Handle(command, CancellationToken.None); Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists"); await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
} }
@@ -116,7 +122,7 @@ public class RegisterUserHandlerTests
[Fact] [Fact]
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase() public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
{ {
var command = ValidCommand(); RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_secure_password"; const string hashedPassword = "hashed_secure_password";
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
@@ -124,7 +130,8 @@ public class RegisterUserHandlerTests
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword); _passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock _authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword)) .Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword))
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token"); _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
@@ -134,7 +141,8 @@ public class RegisterUserHandlerTests
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once); _passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
_authRepoMock.Verify( _authRepoMock.Verify(
x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword), x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
command.DateOfBirth, hashedPassword),
Times.Once Times.Once
); );
} }
@@ -142,14 +150,16 @@ public class RegisterUserHandlerTests
[Fact] [Fact]
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent() public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
{ {
var command = ValidCommand(); RegisterUserCommand command = ValidCommand();
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed"); _passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock _authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>())) .Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email }); It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()))
.ReturnsAsync(new UserAccount
{ UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email });
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token"); _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
@@ -157,7 +167,7 @@ public class RegisterUserHandlerTests
.Setup(x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>())) .Setup(x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("smtp down")); .ThrowsAsync(new Exception("smtp down"));
var result = await _handler.Handle(command, CancellationToken.None); RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.ConfirmationEmailSent.Should().BeFalse(); result.ConfirmationEmailSent.Should().BeFalse();
result.AccessToken.Should().Be("access-token"); result.AccessToken.Should().Be("access-token");

View File

@@ -11,20 +11,21 @@ namespace Features.Auth.Tests.Commands;
public class ResendConfirmationEmailHandlerTests public class ResendConfirmationEmailHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock = new(); private readonly Mock<IAuthRepository> _authRepositoryMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
private readonly Mock<IMediator> _mediatorMock = new();
private readonly ResendConfirmationEmailHandler _handler; private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
public ResendConfirmationEmailHandlerTests() public ResendConfirmationEmailHandlerTests()
{ {
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _mediatorMock.Object); _handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object,
_mediatorMock.Object);
} }
[Fact] [Fact]
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified() public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" }; UserAccount user = new() { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false); _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
@@ -43,7 +44,7 @@ public class ResendConfirmationEmailHandlerTests
[Fact] [Fact]
public async Task Handle_DoesNothing_WhenUserDoesNotExist() public async Task Handle_DoesNothing_WhenUserDoesNotExist()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null); _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
@@ -57,8 +58,8 @@ public class ResendConfirmationEmailHandlerTests
[Fact] [Fact]
public async Task Handle_DoesNothing_WhenUserAlreadyVerified() public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId }; UserAccount user = new() { UserAccountId = userId };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true); _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);

View File

@@ -1,26 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>Features.Auth.Tests</RootNamespace> <RootNamespace>Features.Auth.Tests</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72"/>
<PackageReference Include="FluentAssertions" Version="6.9.0" /> <PackageReference Include="FluentAssertions" Version="6.9.0"/>
<PackageReference Include="DbMocker" Version="1.26.0" /> <PackageReference Include="DbMocker" Version="1.26.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" /> <ProjectReference Include="..\Features.Auth\Features.Auth.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,9 +1,10 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions; using Features.Auth.Dtos;
using Features.Auth.Queries.Login; using Features.Auth.Queries.Login;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Moq; using Moq;
@@ -12,9 +13,9 @@ namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests public class LoginHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepoMock; private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock; private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly LoginHandler _handler;
public LoginHandlerTests() public LoginHandlerTests()
{ {
@@ -28,24 +29,24 @@ public class LoginHandlerTests
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername() public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{ {
const string username = "CogitoErgoSum"; const string username = "CogitoErgoSum";
var userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount UserAccount userAccount = new()
{ {
UserAccountId = userAccountId, UserAccountId = userAccountId,
Username = username, Username = username,
FirstName = "René", FirstName = "René",
LastName = "Descartes", LastName = "Descartes",
Email = "r.descartes@example.com", Email = "r.descartes@example.com",
DateOfBirth = new DateTime(1596, 03, 31), DateOfBirth = new DateTime(1596, 03, 31)
}; };
var userCredential = new UserCredential UserCredential userCredential = new()
{ {
UserCredentialId = Guid.NewGuid(), UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId, UserAccountId = userAccountId,
Hash = "some-hash", Hash = "some-hash",
Expiry = DateTime.MaxValue, Expiry = DateTime.MaxValue
}; };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
@@ -54,7 +55,7 @@ public class LoginHandlerTests
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token"); _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
var result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None); LoginPayload result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None);
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserAccountId.Should().Be(userAccountId); result.UserAccountId.Should().Be(userAccountId);
@@ -69,7 +70,7 @@ public class LoginHandlerTests
const string username = "de_beauvoir"; const string username = "de_beauvoir";
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>(); await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never); _authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never);
@@ -79,13 +80,14 @@ public class LoginHandlerTests
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException() public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{ {
const string username = "BRussell"; const string username = "BRussell";
var userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username }; UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync((UserCredential?)null); _authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync((UserCredential?)null);
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password."); await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never); _passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -95,15 +97,15 @@ public class LoginHandlerTests
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException() public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{ {
const string username = "RCarnap"; const string username = "RCarnap";
var userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username }; UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
var userCredential = new UserCredential { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" }; UserCredential userCredential = new() { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential); _authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false); _passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false);
var act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None); Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password."); await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
} }

View File

@@ -1,19 +1,22 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using FluentAssertions; using Domain.Entities;
using Features.Auth.Repository; using Features.Auth.Repository;
using FluentAssertions;
namespace Features.Auth.Tests.Repository; namespace Features.Auth.Tests.Repository;
public class AuthRepositoryTests public class AuthRepositoryTests
{ {
private static AuthRepository CreateRepo(MockDbConnection conn) => private static AuthRepository CreateRepo(MockDbConnection conn)
new(new TestConnectionFactory(conn)); {
return new AuthRepository(new TestConnectionFactory(conn));
}
[Fact] [Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount() public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{ {
var expectedUserId = Guid.NewGuid(); Guid expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser") conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsScalar(expectedUserId); .ReturnsScalar(expectedUserId);
@@ -46,14 +49,14 @@ public class AuthRepositoryTests
) )
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync( UserAccount result = await repo.RegisterUserAsync(
username: "testuser", "testuser",
firstName: "Test", "Test",
lastName: "User", "User",
email: "test@example.com", "test@example.com",
dateOfBirth: new DateTime(1990, 1, 1), new DateTime(1990, 1, 1),
passwordHash: "hashedpassword123" "hashedpassword123"
); );
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -68,8 +71,8 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists() public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable( .ReturnsTable(
@@ -98,8 +101,8 @@ public class AuthRepositoryTests
) )
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com"); UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId); result!.UserAccountId.Should().Be(userId);
@@ -112,13 +115,13 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists() public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{ {
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("nonexistent@example.com"); UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull(); result.Should().BeNull();
} }
@@ -126,8 +129,8 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists() public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername" cmd.CommandText == "usp_GetUserAccountByUsername"
@@ -158,8 +161,8 @@ public class AuthRepositoryTests
) )
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser"); UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId); result!.UserAccountId.Should().Be(userId);
@@ -170,15 +173,15 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists() public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{ {
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername" cmd.CommandText == "usp_GetUserAccountByUsername"
) )
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("nonexistent"); UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull(); result.Should().BeNull();
} }
@@ -186,9 +189,9 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists() public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var credentialId = Guid.NewGuid(); Guid credentialId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId" cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
@@ -211,8 +214,8 @@ public class AuthRepositoryTests
) )
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId); result!.UserCredentialId.Should().Be(credentialId);
@@ -223,16 +226,16 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists() public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId" cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
) )
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull(); result.Should().BeNull();
} }
@@ -240,17 +243,17 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully() public async Task RotateCredentialAsync_ExecutesSuccessfully()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var newPasswordHash = "new_hashed_password"; string newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential") conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1); .ReturnsScalar(1);
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
// Should not throw // Should not throw
var act = async () => Func<Task> act = async () =>
await repo.RotateCredentialAsync(userId, newPasswordHash); await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync(); await act.Should().NotThrowAsync();
} }

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{ {
private readonly DbConnection _conn = conn; private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn; public DbConnection CreateConnection()
{
return _conn;
}
} }

View File

@@ -2,9 +2,9 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Moq; using Moq;
@@ -12,152 +12,153 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceRefreshTests public class TokenServiceRefreshTests
{ {
private readonly Mock<ITokenInfrastructure> _tokenInfraMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService; private readonly TokenService _tokenService;
public TokenServiceRefreshTests() public TokenServiceRefreshTests()
{ {
_tokenInfraMock = new Mock<ITokenInfrastructure>(); _tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>(); _authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens // Set environment variables for tokens
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService( _tokenService = new TokenService(
_tokenInfraMock.Object, _tokenInfraMock.Object,
_authRepositoryMock.Object _authRepositoryMock.Object
); );
} }
[Fact] [Fact]
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens() public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string refreshToken = "valid-refresh-token"; const string refreshToken = "valid-refresh-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
var userAccount = new UserAccount UserAccount userAccount = new()
{
UserAccountId = userId,
Username = username,
FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
};
// Mock the validation of refresh token
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
// Mock the generation of new tokens
_tokenInfraMock
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
_authRepositoryMock
.Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync(userAccount);
// Act
var result = await _tokenService.RefreshTokenAsync(refreshToken);
// Assert
result.Should().NotBeNull();
result.UserAccount.UserAccountId.Should().Be(userId);
result.UserAccount.Username.Should().Be(username);
result.AccessToken.Should().NotBeEmpty();
result.RefreshToken.Should().NotBeEmpty();
_authRepositoryMock.Verify(
x => x.GetUserByIdAsync(userId),
Times.Once
);
// Verify tokens were generated (called twice - once for access, once for refresh)
_tokenInfraMock.Verify(
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
Times.Exactly(2)
);
}
[Fact]
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string invalidToken = "invalid-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.RefreshTokenAsync(invalidToken)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string expiredToken = "expired-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.RefreshTokenAsync(expiredToken)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{
// Arrange
var userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), UserAccountId = userId,
new(JwtRegisteredClaimNames.UniqueName, username), Username = username,
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1)
}; };
var claimsIdentity = new ClaimsIdentity(claims); // Mock the validation of refresh token
var principal = new ClaimsPrincipal(claimsIdentity); _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
_tokenInfraMock // Mock the generation of new tokens
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>())) _tokenInfraMock
.ReturnsAsync(principal); .Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
_authRepositoryMock _authRepositoryMock
.Setup(x => x.GetUserByIdAsync(userId)) .Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync((UserAccount?)null); .ReturnsAsync(userAccount);
// Act & Assert // Act
await FluentActions.Invoking(async () => RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
await _tokenService.RefreshTokenAsync(refreshToken)
).Should().ThrowAsync<UnauthorizedException>() // Assert
.WithMessage("*User account not found*"); result.Should().NotBeNull();
} result.UserAccount.UserAccountId.Should().Be(userId);
result.UserAccount.Username.Should().Be(username);
result.AccessToken.Should().NotBeEmpty();
result.RefreshToken.Should().NotBeEmpty();
_authRepositoryMock.Verify(
x => x.GetUserByIdAsync(userId),
Times.Once
);
// Verify tokens were generated (called twice - once for access, once for refresh)
_tokenInfraMock.Verify(
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
Times.Exactly(2)
);
}
[Fact]
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string invalidToken = "invalid-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.RefreshTokenAsync(invalidToken)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string expiredToken = "expired-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.RefreshTokenAsync(expiredToken)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
_authRepositoryMock
.Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync((UserAccount?)null);
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.RefreshTokenAsync(refreshToken)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
}
} }

View File

@@ -1,10 +1,9 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Moq; using Moq;
@@ -12,272 +11,273 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceValidationTests public class TokenServiceValidationTests
{ {
private readonly Mock<ITokenInfrastructure> _tokenInfraMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService; private readonly TokenService _tokenService;
public TokenServiceValidationTests() public TokenServiceValidationTests()
{ {
_tokenInfraMock = new Mock<ITokenInfrastructure>(); _tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>(); _authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens // Set environment variables for tokens
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService( _tokenService = new TokenService(
_tokenInfraMock.Object, _tokenInfraMock.Object,
_authRepositoryMock.Object _authRepositoryMock.Object
); );
} }
[Fact] [Fact]
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken() public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string token = "valid-access-token"; const string token = "valid-access-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
var result = ValidatedToken result =
await _tokenService.ValidateAccessTokenAsync(token); await _tokenService.ValidateAccessTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserId.Should().Be(userId); result.UserId.Should().Be(userId);
result.Username.Should().Be(username); result.Username.Should().Be(username);
result.Principal.Should().NotBeNull(); result.Principal.Should().NotBeNull();
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString()); result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
} }
[Fact] [Fact]
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken() public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string token = "valid-refresh-token"; const string token = "valid-refresh-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
var result = ValidatedToken result =
await _tokenService.ValidateRefreshTokenAsync(token); await _tokenService.ValidateRefreshTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserId.Should().Be(userId); result.UserId.Should().Be(userId);
result.Username.Should().Be(username); result.Username.Should().Be(username);
} }
[Fact] [Fact]
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken() public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string token = "valid-confirmation-token"; const string token = "valid-confirmation-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
var result = ValidatedToken result =
await _tokenService.ValidateConfirmationTokenAsync(token); await _tokenService.ValidateConfirmationTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserId.Should().Be(userId); result.UserId.Should().Be(userId);
result.Username.Should().Be(username); result.Username.Should().Be(username);
} }
[Fact] [Fact]
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException() public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string token = "invalid-token"; const string token = "invalid-token";
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token) await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException() public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string token = "expired-token"; const string token = "expired-token";
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException(
"Token has expired" "Token has expired"
)); ));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token) await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException() public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string username = "testuser"; const string username = "testuser";
const string token = "token-without-user-id"; const string token = "token-without-user-id";
// Claims without Sub (user ID) // Claims without Sub (user ID)
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token) await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>() ).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*"); .WithMessage("*missing required claims*");
} }
[Fact] [Fact]
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException() public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string token = "token-without-username"; const string token = "token-without-username";
// Claims without UniqueName (username) // Claims without UniqueName (username)
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token) await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>() ).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*"); .WithMessage("*missing required claims*");
} }
[Fact] [Fact]
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException() public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string username = "testuser"; const string username = "testuser";
const string token = "token-with-malformed-user-id"; const string token = "token-with-malformed-user-id";
// Claims with invalid GUID format // Claims with invalid GUID format
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"), new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token) await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>() ).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*"); .WithMessage("*malformed user ID*");
} }
[Fact] [Fact]
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException() public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string token = "invalid-refresh-token"; const string token = "invalid-refresh-token";
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _tokenService.ValidateRefreshTokenAsync(token) await _tokenService.ValidateRefreshTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException() public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string token = "invalid-confirmation-token"; const string token = "invalid-confirmation-token";
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _tokenService.ValidateConfirmationTokenAsync(token) await _tokenService.ValidateConfirmationTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
} }

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.ConfirmUser;
/// <summary> /// <summary>
/// Validates a confirmation token and confirms the corresponding user account. /// Validates a confirmation token and confirms the corresponding user account.
/// </summary> /// </summary>
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param> /// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>; public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
@@ -7,8 +8,8 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.ConfirmUser;
/// <summary> /// <summary>
/// Handles <see cref="ConfirmUserCommand"/> by validating the confirmation token and marking the /// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed. /// corresponding user account as confirmed.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param> /// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param> /// <param name="tokenService">Service used to validate the confirmation token.</param>
@@ -18,18 +19,15 @@ public class ConfirmUserHandler(
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload> ) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{ {
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found. /// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
/// </exception> /// </exception>
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken) public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken)
{ {
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token); ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
var user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId); UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
if (user == null) if (user == null) throw new UnauthorizedException("User account not found");
{
throw new UnauthorizedException("User account not found");
}
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow); return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
} }

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary> /// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the /// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
/// request body of <c>POST /api/auth/refresh</c>. /// request body of <c>POST /api/auth/refresh</c>.
/// </summary> /// </summary>
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>; public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;

View File

@@ -5,8 +5,8 @@ using MediatR;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary> /// <summary>
/// Handles <see cref="RefreshTokenCommand"/> by validating the refresh token and issuing a new /// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair. /// access/refresh token pair.
/// </summary> /// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param> /// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService) public class RefreshTokenHandler(ITokenService tokenService)
@@ -14,7 +14,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
{ {
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken) public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{ {
var result = await tokenService.RefreshTokenAsync(request.RefreshToken); RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken); return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken,
result.AccessToken);
} }
} }

View File

@@ -3,12 +3,12 @@ using FluentValidation;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary> /// <summary>
/// Validates <see cref="RefreshTokenCommand"/> instances before they are processed. /// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary> /// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand> public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{ {
/// <summary> /// <summary>
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken"/> to be non-empty. /// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
/// </summary> /// </summary>
public RefreshTokenValidator() public RefreshTokenValidator()
{ {

View File

@@ -4,14 +4,20 @@ using MediatR;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary> /// <summary>
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>. /// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
/// </summary> /// </summary>
/// <param name="Username">The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens.</param> /// <param name="Username">
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
/// underscores, and hyphens.
/// </param>
/// <param name="FirstName">The user's first name; up to 128 characters.</param> /// <param name="FirstName">The user's first name; up to 128 characters.</param>
/// <param name="LastName">The user's last name; up to 128 characters.</param> /// <param name="LastName">The user's last name; up to 128 characters.</param>
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param> /// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param> /// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
/// <param name="Password">The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character.</param> /// <param name="Password">
/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a
/// lowercase letter, a number, and a special character.
/// </param>
public record RegisterUserCommand( public record RegisterUserCommand(
string Username, string Username,
string FirstName, string FirstName,

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
@@ -9,9 +10,9 @@ using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary> /// <summary>
/// Handles <see cref="RegisterUserCommand"/>: validates uniqueness, hashes the password, persists the /// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration /// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails. /// confirmation email via Features.Emails.
/// </summary> /// </summary>
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param> /// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param> /// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
@@ -25,15 +26,15 @@ public class RegisterUserHandler(
) : IRequestHandler<RegisterUserCommand, RegistrationPayload> ) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
{ {
/// <exception cref="ConflictException"> /// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address. /// Thrown when an existing account already has the same username or email address.
/// </exception> /// </exception>
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken) public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
{ {
await ValidateUserDoesNotExist(request.Username, request.Email); await ValidateUserDoesNotExist(request.Username, request.Email);
var hashed = passwordInfrastructure.Hash(request.Password); string hashed = passwordInfrastructure.Hash(request.Password);
var createdUser = await authRepo.RegisterUserAsync( UserAccount createdUser = await authRepo.RegisterUserAsync(
request.Username, request.Username,
request.FirstName, request.FirstName,
request.LastName, request.LastName,
@@ -42,16 +43,15 @@ public class RegisterUserHandler(
hashed hashed
); );
var accessToken = tokenService.GenerateAccessToken(createdUser); string accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser); string refreshToken = tokenService.GenerateRefreshToken(createdUser);
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser); string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
{ return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty,
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false); false);
}
var emailSent = false; bool emailSent = false;
try try
{ {
await mediator.Send( await mediator.Send(
@@ -67,20 +67,19 @@ public class RegisterUserHandler(
// ignored // ignored
} }
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent); return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken,
emailSent);
} }
/// <exception cref="ConflictException"> /// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address. /// Thrown when an existing account already has the same username or email address.
/// </exception> /// </exception>
private async Task ValidateUserDoesNotExist(string username, string email) private async Task ValidateUserDoesNotExist(string username, string email)
{ {
var existingUsername = await authRepo.GetUserByUsernameAsync(username); UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
var existingEmail = await authRepo.GetUserByEmailAsync(email); UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null) if (existingUsername != null || existingEmail != null)
{
throw new ConflictException("Username or email already exists"); throw new ConflictException("Username or email already exists");
}
} }
} }

View File

@@ -3,13 +3,13 @@ using FluentValidation;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary> /// <summary>
/// Validates <see cref="RegisterUserCommand"/> instances before they are processed. /// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary> /// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand> public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{ {
/// <summary> /// <summary>
/// Configures validation rules for username format and length, first/last name length, email format and /// 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. /// length, minimum age based on date of birth, and password strength requirements.
/// </summary> /// </summary>
public RegisterUserValidator() public RegisterUserValidator()
{ {

View File

@@ -3,7 +3,7 @@ using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary> /// <summary>
/// Resends the account confirmation email to a user, generating a fresh confirmation token. /// Resends the account confirmation email to a user, generating a fresh confirmation token.
/// </summary> /// </summary>
/// <param name="UserId">The unique identifier of the user requesting the resend.</param> /// <param name="UserId">The unique identifier of the user requesting the resend.</param>
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using MediatR; using MediatR;
@@ -6,15 +7,15 @@ using Shared.Application.Emails;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary> /// <summary>
/// Handles <see cref="ResendConfirmationEmailCommand"/> by generating a fresh confirmation token and /// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails. /// sending it via Features.Emails.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up the user and check verification status.</param> /// <param name="authRepository">Repository used to look up the user and check verification status.</param>
/// <param name="tokenService">Service used to generate the confirmation token.</param> /// <param name="tokenService">Service used to generate the confirmation token.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param> /// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
/// <remarks> /// <remarks>
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration) /// 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. /// or if the user's account is already verified.
/// </remarks> /// </remarks>
public class ResendConfirmationEmailHandler( public class ResendConfirmationEmailHandler(
IAuthRepository authRepository, IAuthRepository authRepository,
@@ -24,18 +25,12 @@ public class ResendConfirmationEmailHandler(
{ {
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken) public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
{ {
var user = await authRepository.GetUserByIdAsync(request.UserId); UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null) if (user == null) return; // Silent return to prevent user enumeration
{
return; // Silent return to prevent user enumeration
}
if (await authRepository.IsUserVerifiedAsync(request.UserId)) if (await authRepository.IsUserVerifiedAsync(request.UserId)) return; // Already confirmed, no-op
{
return; // Already confirmed, no-op
}
var confirmationToken = tokenService.GenerateConfirmationToken(user); string confirmationToken = tokenService.GenerateConfirmationToken(user);
await mediator.Send( await mediator.Send(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken), new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken cancellationToken

View File

@@ -9,121 +9,120 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Contracts; using Shared.Contracts;
namespace Features.Auth.Controllers namespace Features.Auth.Controllers;
/// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// </summary>
/// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
/// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{ {
/// <summary> /// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh. /// Registers a new user account.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most /// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token. /// username, issued refresh/access tokens, and whether a confirmation email was sent.
/// </remarks> /// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param> /// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
[ApiController] /// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
[Route("api/[controller]")] [AllowAnonymous]
[Authorize(AuthenticationSchemes = "JWT")] [HttpPost("register")]
public class AuthController(IMediator mediator) : ControllerBase public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
[FromBody] RegisterUserCommand command
)
{ {
/// <summary> RegistrationPayload payload = await mediator.Send(command);
/// Registers a new user account. return Created("/", new ResponseBody<RegistrationPayload>
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
/// </remarks>
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="RegistrationPayload"/>.</returns>
[AllowAnonymous]
[HttpPost("register")]
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
[FromBody] RegisterUserCommand command
)
{ {
var payload = await mediator.Send(command); Message = "User registered successfully.",
return Created("/", new ResponseBody<RegistrationPayload> Payload = payload
{ });
Message = "User registered successfully.", }
Payload = payload,
});
}
/// <summary> /// <summary>
/// Authenticates a user with a username and password. /// Authenticates a user with a username and password.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID, /// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and a newly issued refresh/access token pair. /// username, and a newly issued refresh/access token pair.
/// </remarks> /// </remarks>
/// <param name="query">The login credentials (username and password).</param> /// <param name="query">The login credentials (username and password).</param>
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns> /// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query) public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
{
LoginPayload payload = await mediator.Send(query);
return Ok(new ResponseBody<LoginPayload>
{ {
var payload = await mediator.Send(query); Message = "Logged in successfully.",
return Ok(new ResponseBody<LoginPayload> Payload = payload
{ });
Message = "Logged in successfully.", }
Payload = payload,
});
}
/// <summary> /// <summary>
/// Confirms a user account using a confirmation token. /// Confirms a user account using a confirmation token.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed /// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
/// user's ID and the confirmation timestamp. /// user's ID and the confirmation timestamp.
/// </remarks> /// </remarks>
/// <param name="token">The confirmation token supplied via the confirmation email link.</param> /// <param name="token">The confirmation token supplied via the confirmation email link.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="ConfirmationPayload"/>.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")] [HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token) public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
{
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
return Ok(new ResponseBody<ConfirmationPayload>
{ {
var payload = await mediator.Send(new ConfirmUserCommand(token)); Message = "User with ID " + payload.UserAccountId + " is confirmed.",
return Ok(new ResponseBody<ConfirmationPayload> Payload = payload
{ });
Message = "User with ID " + payload.UserAccountId + " is confirmed.", }
Payload = payload,
});
}
/// <summary> /// <summary>
/// Resends the account confirmation email for the specified user. /// Resends the account confirmation email for the specified user.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c>. /// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks> /// </remarks>
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param> /// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the email was resent.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
[HttpPost("confirm/resend")] [HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId) public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{ {
await mediator.Send(new ResendConfirmationEmailCommand(userId)); await mediator.Send(new ResendConfirmationEmailCommand(userId));
return Ok(new ResponseBody { Message = "confirmation email has been resent" }); return Ok(new ResponseBody { Message = "confirmation email has been resent" });
} }
/// <summary> /// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair. /// Exchanges a valid refresh token for a new access/refresh token pair.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID, /// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and the newly issued refresh/access token pair. /// username, and the newly issued refresh/access token pair.
/// </remarks> /// </remarks>
/// <param name="command">The request containing the refresh token to exchange.</param> /// <param name="command">The request containing the refresh token to exchange.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh( public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
[FromBody] RefreshTokenCommand command [FromBody] RefreshTokenCommand command
) )
{
LoginPayload payload = await mediator.Send(command);
return Ok(new ResponseBody<LoginPayload>
{ {
var payload = await mediator.Send(command); Message = "Token refreshed successfully.",
return Ok(new ResponseBody<LoginPayload> Payload = payload
{ });
Message = "Token refreshed successfully.",
Payload = payload,
});
}
} }
} }

View File

@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Auth.DependencyInjection; namespace Features.Auth.DependencyInjection;
/// <summary> /// <summary>
/// Registers the services owned by the Auth feature slice. /// Registers the services owned by the Auth feature slice.
/// </summary> /// </summary>
public static class FeaturesAuthServiceCollectionExtensions public static class FeaturesAuthServiceCollectionExtensions
{ {

View File

@@ -1,7 +1,7 @@
namespace Features.Auth.Dtos; namespace Features.Auth.Dtos;
/// <summary> /// <summary>
/// Payload returned to the client after a successful login or token refresh. /// Payload returned to the client after a successful login or token refresh.
/// </summary> /// </summary>
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param> /// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
/// <param name="Username">The username of the authenticated user account.</param> /// <param name="Username">The username of the authenticated user account.</param>
@@ -15,7 +15,7 @@ public record LoginPayload(
); );
/// <summary> /// <summary>
/// Payload returned to the client after a successful registration. /// Payload returned to the client after a successful registration.
/// </summary> /// </summary>
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param> /// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
/// <param name="Username">The username of the newly created user account.</param> /// <param name="Username">The username of the newly created user account.</param>
@@ -31,7 +31,7 @@ public record RegistrationPayload(
); );
/// <summary> /// <summary>
/// Payload returned to the client after a user account's email has been confirmed. /// Payload returned to the client after a user account's email has been confirmed.
/// </summary> /// </summary>
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param> /// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param> /// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>

View File

@@ -1,22 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Features.Auth</RootNamespace> <RootNamespace>Features.Auth</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj"/>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
@@ -8,10 +9,13 @@ using MediatR;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Queries.Login;
/// <summary> /// <summary>
/// Handles <see cref="LoginQuery"/> by verifying credentials and issuing access/refresh tokens. /// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
/// </summary> /// </summary>
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param> /// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to verify a plain-text password against a stored hash.</param> /// <param name="passwordInfrastructure">
/// Infrastructure component used to verify a plain-text password against a stored
/// hash.
/// </param>
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param> /// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
public class LoginHandler( public class LoginHandler(
IAuthRepository authRepo, IAuthRepository authRepo,
@@ -20,25 +24,25 @@ public class LoginHandler(
) : IRequestHandler<LoginQuery, LoginPayload> ) : IRequestHandler<LoginQuery, LoginPayload>
{ {
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when the username does not match any account, the account has no active credential, /// 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. /// or the supplied password does not match the stored hash.
/// </exception> /// </exception>
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken) public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
{ {
var user = UserAccount user =
await authRepo.GetUserByUsernameAsync(request.Username) await authRepo.GetUserByUsernameAsync(request.Username)
?? throw new UnauthorizedException("Invalid username or password."); ?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords // @todo handle expired passwords
var activeCred = UserCredential activeCred =
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId) await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
?? throw new UnauthorizedException("Invalid username or password."); ?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash)) if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password."); throw new UnauthorizedException("Invalid username or password.");
var accessToken = tokenService.GenerateAccessToken(user); string accessToken = tokenService.GenerateAccessToken(user);
var refreshToken = tokenService.GenerateRefreshToken(user); string refreshToken = tokenService.GenerateRefreshToken(user);
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken); return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
} }

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Queries.Login;
/// <summary> /// <summary>
/// Authenticates a user using their username and password and issues new tokens. Bound directly /// Authenticates a user using their username and password and issues new tokens. Bound directly
/// from the request body of <c>POST /api/auth/login</c>. /// from the request body of <c>POST /api/auth/login</c>.
/// </summary> /// </summary>
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>; public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;

View File

@@ -3,13 +3,13 @@ using FluentValidation;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Queries.Login;
/// <summary> /// <summary>
/// Validates <see cref="LoginQuery"/> instances before they are processed. /// Validates <see cref="LoginQuery" /> instances before they are processed.
/// </summary> /// </summary>
public class LoginValidator : AbstractValidator<LoginQuery> public class LoginValidator : AbstractValidator<LoginQuery>
{ {
/// <summary> /// <summary>
/// Configures validation rules requiring both <see cref="LoginQuery.Username"/> and /// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
/// <see cref="LoginQuery.Password"/> to be non-empty. /// <see cref="LoginQuery.Password" /> to be non-empty.
/// </summary> /// </summary>
public LoginValidator() public LoginValidator()
{ {

View File

@@ -7,23 +7,23 @@ using Microsoft.Data.SqlClient;
namespace Features.Auth.Repository; namespace Features.Auth.Repository;
/// <summary> /// <summary>
/// ADO.NET-based implementation of <see cref="IAuthRepository"/> backed by SQL Server stored procedures, /// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification. /// handling user registration, credential lookup/rotation, and account verification.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param> /// <param name="connectionFactory">The factory used to create database connections.</param>
public class AuthRepository(ISqlConnectionFactory connectionFactory) public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory), : Repository<UserAccount>(connectionFactory),
IAuthRepository IAuthRepository
{ {
/// <summary> /// <summary>
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored /// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
/// procedure, then fetches and returns the newly created user. /// procedure, then fetches and returns the newly created user.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively: /// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
/// it may be returned as a <see cref="Guid"/>, a parseable <see cref="string"/>, or a 16-byte array. /// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
/// If the result cannot be interpreted, <see cref="Guid.Empty"/> is used, which will cause the /// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail. /// subsequent lookup to fail.
/// </remarks> /// </remarks>
/// <param name="username">Unique username for the user</param> /// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param> /// <param name="firstName">User's first name</param>
@@ -34,7 +34,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <returns>The newly created UserAccount with generated ID</returns> /// <returns>The newly created UserAccount with generated ID</returns>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception> /// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount> RegisterUserAsync( public async Task<UserAccount> RegisterUserAsync(
string username, string username,
string firstName, string firstName,
string lastName, string lastName,
@@ -43,8 +43,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
string passwordHash string passwordHash
) )
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser"; command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
@@ -56,89 +56,82 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@DateOfBirth", dateOfBirth); AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash); AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
Guid userAccountId = Guid.Empty; Guid userAccountId = Guid.Empty;
if (result != null && result != DBNull.Value) if (result != null && result != DBNull.Value)
{ {
if (result is Guid g) if (result is Guid g)
{
userAccountId = g; userAccountId = g;
} else if (result is string s && Guid.TryParse(s, out Guid parsed))
else if (result is string s && Guid.TryParse(s, out var parsed))
{
userAccountId = parsed; userAccountId = parsed;
}
else if (result is byte[] bytes && bytes.Length == 16) else if (result is byte[] bytes && bytes.Length == 16)
{
userAccountId = new Guid(bytes); userAccountId = new Guid(bytes);
}
else else
{
// Fallback: try to convert and parse string representation // Fallback: try to convert and parse string representation
try try
{ {
var str = result.ToString(); string? str = result.ToString();
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p)) if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
userAccountId = p; userAccountId = p;
} }
catch catch
{ {
userAccountId = Guid.Empty; userAccountId = Guid.Empty;
} }
}
} }
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user."); return await GetUserByIdAsync(userAccountId) ??
throw new Exception("Failed to retrieve newly registered user.");
} }
/// <summary> /// <summary>
/// Retrieves a user account by email address (typically used for login) using the /// Retrieves a user account by email address (typically used for login) using the
/// <c>usp_GetUserAccountByEmail</c> stored procedure. /// <c>usp_GetUserAccountByEmail</c> stored procedure.
/// </summary> /// </summary>
/// <param name="email">Email address to search for</param> /// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync( public async Task<UserAccount?> GetUserByEmailAsync(
string email string email
) )
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail"; command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email); AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <summary> /// <summary>
/// Retrieves a user account by username (typically used for login) using the /// Retrieves a user account by username (typically used for login) using the
/// <c>usp_GetUserAccountByUsername</c> stored procedure. /// <c>usp_GetUserAccountByUsername</c> stored procedure.
/// </summary> /// </summary>
/// <param name="username">Username to search for</param> /// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync( public async Task<UserAccount?> GetUserByUsernameAsync(
string username string username
) )
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername"; command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username); AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <summary> /// <summary>
/// Retrieves the active (non-revoked) credential for a user account using the /// Retrieves the active (non-revoked) credential for a user account using the
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure. /// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns> /// <returns>Active UserCredential if found, null otherwise</returns>
@@ -147,20 +140,20 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
Guid userAccountId Guid userAccountId
) )
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId); AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null; return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
} }
/// <summary> /// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one, /// Rotates a user's credential by invalidating all existing credentials and creating a new one,
/// using the <c>USP_RotateUserCredential</c> stored procedure. /// using the <c>USP_RotateUserCredential</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param> /// <param name="newPasswordHash">New hashed password</param>
@@ -170,8 +163,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
string newPasswordHash string newPasswordHash
) )
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential"; command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
@@ -182,53 +175,50 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
} }
/// <summary> /// <summary>
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure. /// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync( public async Task<UserAccount?> GetUserByIdAsync(
Guid userAccountId Guid userAccountId
) )
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById"; command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId); AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <summary> /// <summary>
/// Marks a user account as confirmed by creating a verification record via the /// Marks a user account as confirmed by creating a verification record via the
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a /// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user /// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed. /// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param> /// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if the user account does not exist.</returns> /// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails for a reason other than a duplicate verification record.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync( /// Thrown when the database command fails for a reason other than
/// a duplicate verification record.
/// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync(
Guid userAccountId Guid userAccountId
) )
{ {
var user = await GetUserByIdAsync(userAccountId); UserAccount? user = await GetUserByIdAsync(userAccountId);
if (user == null) if (user == null) return null;
{
return null;
}
// Idempotency: if already verified, treat as successful confirmation. // Idempotency: if already verified, treat as successful confirmation.
if (await IsUserVerifiedAsync(userAccountId)) if (await IsUserVerifiedAsync(userAccountId)) return user;
{
return user;
}
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification"; command.CommandText = "USP_CreateUserVerification";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
@@ -248,29 +238,29 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
} }
/// <summary> /// <summary>
/// Checks whether a user account has been verified by querying the /// Checks whether a user account has been verified by querying the
/// <c>dbo.UserVerification</c> table for a matching record. /// <c>dbo.UserVerification</c> table for a matching record.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns> /// <returns>True if the user has a verification record, false otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId) public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = command.CommandText =
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID"; "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
command.CommandType = CommandType.Text; command.CommandType = CommandType.Text;
AddParameter(command, "@UserAccountID", userAccountId); AddParameter(command, "@UserAccountID", userAccountId);
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return result != null && result != DBNull.Value; return result != null && result != DBNull.Value;
} }
/// <summary> /// <summary>
/// Determines whether a <see cref="SqlException"/> represents a duplicate key violation /// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert. /// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
/// </summary> /// </summary>
/// <param name="ex">The SQL exception to inspect.</param> /// <param name="ex">The SQL exception to inspect.</param>
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns> /// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
@@ -282,15 +272,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <summary> /// <summary>
/// Maps a data reader row to a UserAccount entity. /// Maps a data reader row to a UserAccount entity.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param> /// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount"/> instance.</returns> /// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override Domain.Entities.UserAccount MapToEntity( protected override UserAccount MapToEntity(
DbDataReader reader DbDataReader reader
) )
{ {
return new Domain.Entities.UserAccount return new UserAccount
{ {
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")), Username = reader.GetString(reader.GetOrdinal("Username")),
@@ -304,33 +294,33 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null ? null
: (byte[])reader["Timer"], : (byte[])reader["Timer"]
}; };
} }
/// <summary> /// <summary>
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if /// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
/// present in the reader's schema, allowing this method to support result sets that omit it. /// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param> /// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="UserCredential"/> instance.</returns> /// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
private static UserCredential MapToCredentialEntity(DbDataReader reader) private static UserCredential MapToCredentialEntity(DbDataReader reader)
{ {
var entity = new UserCredential UserCredential entity = new()
{ {
UserCredentialId = reader.GetGuid( UserCredentialId = reader.GetGuid(
reader.GetOrdinal("UserCredentialId") reader.GetOrdinal("UserCredentialId")
), ),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")), Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
}; };
// Optional columns // Optional columns
var hasTimer = bool hasTimer =
reader reader
.GetSchemaTable() .GetSchemaTable()
?.Rows.Cast<System.Data.DataRow>() ?.Rows.Cast<DataRow>()
.Any(r => .Any(r =>
string.Equals( string.Equals(
r["ColumnName"]?.ToString(), r["ColumnName"]?.ToString(),
@@ -340,29 +330,27 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
) ?? false; ) ?? false;
if (hasTimer) if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null ? null
: (byte[])reader["Timer"]; : (byte[])reader["Timer"];
}
return entity; return entity;
} }
/// <summary> /// <summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to /// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value"/>. /// <see cref="DBNull.Value" />.
/// </summary> /// </summary>
/// <param name="command">The command to add the parameter to.</param> /// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param> /// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param> /// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter( private static void AddParameter(
DbCommand command, DbCommand command,
string name, string name,
object? value object? value
) )
{ {
var p = command.CreateParameter(); DbParameter p = command.CreateParameter();
p.ParameterName = name; p.ParameterName = name;
p.Value = value ?? DBNull.Value; p.Value = value ?? DBNull.Value;
command.Parameters.Add(p); command.Parameters.Add(p);

View File

@@ -3,13 +3,13 @@ using Domain.Entities;
namespace Features.Auth.Repository; namespace Features.Auth.Repository;
/// <summary> /// <summary>
/// Repository for authentication-related database operations including user registration and credential management. /// Repository for authentication-related database operations including user registration and credential management.
/// </summary> /// </summary>
public interface IAuthRepository public interface IAuthRepository
{ {
/// <summary> /// <summary>
/// Registers a new user with account details and initial credential. /// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser /// Uses stored procedure: USP_RegisterUser
/// </summary> /// </summary>
/// <param name="username">Unique username for the user</param> /// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param> /// <param name="firstName">User's first name</param>
@@ -18,7 +18,7 @@ public interface IAuthRepository
/// <param name="dateOfBirth">User's date of birth</param> /// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param> /// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns> /// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Entities.UserAccount> RegisterUserAsync( Task<UserAccount> RegisterUserAsync(
string username, string username,
string firstName, string firstName,
string lastName, string lastName,
@@ -28,24 +28,24 @@ public interface IAuthRepository
); );
/// <summary> /// <summary>
/// Retrieves a user account by email address (typically used for login). /// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail /// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary> /// </summary>
/// <param name="email">Email address to search for</param> /// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(string email); Task<UserAccount?> GetUserByEmailAsync(string email);
/// <summary> /// <summary>
/// Retrieves a user account by username (typically used for login). /// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername /// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary> /// </summary>
/// <param name="username">Username to search for</param> /// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(string username); Task<UserAccount?> GetUserByUsernameAsync(string username);
/// <summary> /// <summary>
/// Retrieves the active (non-revoked) credential for a user account. /// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId /// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns> /// <returns>Active UserCredential if found, null otherwise</returns>
@@ -54,29 +54,29 @@ public interface IAuthRepository
); );
/// <summary> /// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one. /// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential /// Uses stored procedure: USP_RotateUserCredential
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param> /// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
/// <summary> /// <summary>
/// Marks a user account as confirmed. /// Marks a user account as confirmed.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param> /// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns> /// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId); Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
/// <summary> /// <summary>
/// Retrieves a user account by ID. /// Retrieves a user account by ID.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId); Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary> /// <summary>
/// Checks whether a user account has been verified. /// Checks whether a user account has been verified.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns> /// <returns>True if the user has a verification record, false otherwise</returns>

View File

@@ -4,28 +4,30 @@ using Domain.Entities;
namespace Features.Auth.Services; namespace Features.Auth.Services;
/// <summary> /// <summary>
/// Identifies the kind of token being generated or validated. /// Identifies the kind of token being generated or validated.
/// </summary> /// </summary>
public enum TokenType public enum TokenType
{ {
/// <summary>A short-lived token used to authorize API requests.</summary> /// <summary>A short-lived token used to authorize API requests.</summary>
AccessToken, AccessToken,
/// <summary>A long-lived token used to obtain new access tokens.</summary> /// <summary>A long-lived token used to obtain new access tokens.</summary>
RefreshToken, RefreshToken,
/// <summary>A short-lived token used to confirm a user's email/account.</summary> /// <summary>A short-lived token used to confirm a user's email/account.</summary>
ConfirmationToken, ConfirmationToken
} }
/// <summary> /// <summary>
/// Represents the result of successfully validating a token. /// Represents the result of successfully validating a token.
/// </summary> /// </summary>
/// <param name="UserId">The unique identifier of the user the token belongs to.</param> /// <param name="UserId">The unique identifier of the user the token belongs to.</param>
/// <param name="Username">The username extracted from the token's claims.</param> /// <param name="Username">The username extracted from the token's claims.</param>
/// <param name="Principal">The <see cref="ClaimsPrincipal"/> produced from the validated token.</param> /// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
/// <summary> /// <summary>
/// Represents the result of refreshing a user's session. /// Represents the result of refreshing a user's session.
/// </summary> /// </summary>
/// <param name="UserAccount">The user account associated with the refreshed session.</param> /// <param name="UserAccount">The user account associated with the refreshed session.</param>
/// <param name="RefreshToken">The newly issued refresh token.</param> /// <param name="RefreshToken">The newly issued refresh token.</param>
@@ -37,77 +39,79 @@ public record RefreshTokenResult(
); );
/// <summary> /// <summary>
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService"/>. /// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
/// </summary> /// </summary>
public static class TokenServiceExpirationHours public static class TokenServiceExpirationHours
{ {
/// <summary>The expiration window, in hours, for access tokens.</summary> /// <summary>The expiration window, in hours, for access tokens.</summary>
public const double AccessTokenHours = 1; public const double AccessTokenHours = 1;
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary> /// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
public const double RefreshTokenHours = 504; // 21 days public const double RefreshTokenHours = 504; // 21 days
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary> /// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
public const double ConfirmationTokenHours = 0.5; // 30 minutes public const double ConfirmationTokenHours = 0.5; // 30 minutes
} }
/// <summary> /// <summary>
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation. /// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
/// </summary> /// </summary>
public interface ITokenService public interface ITokenService
{ {
/// <summary> /// <summary>
/// Generates a new access token for the given user. /// Generates a new access token for the given user.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns> /// <returns>The signed access token string.</returns>
string GenerateAccessToken(UserAccount user); string GenerateAccessToken(UserAccount user);
/// <summary> /// <summary>
/// Generates a new refresh token for the given user. /// Generates a new refresh token for the given user.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns> /// <returns>The signed refresh token string.</returns>
string GenerateRefreshToken(UserAccount user); string GenerateRefreshToken(UserAccount user);
/// <summary> /// <summary>
/// Generates a new confirmation token for the given user. /// Generates a new confirmation token for the given user.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns> /// <returns>The signed confirmation token string.</returns>
string GenerateConfirmationToken(UserAccount user); string GenerateConfirmationToken(UserAccount user);
/// <summary> /// <summary>
/// Generates a token of the type specified by <typeparamref name="T"/>, which must be <see cref="TokenType"/>. /// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />.
/// </summary> /// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam> /// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns> /// <returns>The signed token string corresponding to the requested token type.</returns>
string GenerateToken<T>(UserAccount user) where T : struct, Enum; string GenerateToken<T>(UserAccount user) where T : struct, Enum;
/// <summary> /// <summary>
/// Validates an access token. /// Validates an access token.
/// </summary> /// </summary>
/// <param name="token">The access token string to validate.</param> /// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateAccessTokenAsync(string token); Task<ValidatedToken> ValidateAccessTokenAsync(string token);
/// <summary> /// <summary>
/// Validates a refresh token. /// Validates a refresh token.
/// </summary> /// </summary>
/// <param name="token">The refresh token string to validate.</param> /// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateRefreshTokenAsync(string token); Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
/// <summary> /// <summary>
/// Validates a confirmation token. /// Validates a confirmation token.
/// </summary> /// </summary>
/// <param name="token">The confirmation token string to validate.</param> /// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token); Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
/// <summary> /// <summary>
/// Validates a refresh token and issues a new access/refresh token pair for the associated user. /// Validates a refresh token and issues a new access/refresh token pair for the associated user.
/// </summary> /// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param> /// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns> /// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString); Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
} }

View File

@@ -1,5 +1,5 @@
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Repository; using Features.Auth.Repository;
@@ -8,27 +8,26 @@ using Infrastructure.Jwt;
namespace Features.Auth.Services; namespace Features.Auth.Services;
/// <summary> /// <summary>
/// Default implementation of <see cref="ITokenService"/> that generates and validates JWTs /// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
/// for access, refresh, and confirmation flows using secrets read from environment variables. /// for access, refresh, and confirmation flows using secrets read from environment variables.
/// </summary> /// </summary>
public class TokenService : ITokenService public class TokenService : ITokenService
{ {
private readonly ITokenInfrastructure _tokenInfrastructure;
private readonly IAuthRepository _authRepository;
private readonly string _accessTokenSecret; private readonly string _accessTokenSecret;
private readonly string _refreshTokenSecret; private readonly IAuthRepository _authRepository;
private readonly string _confirmationTokenSecret; private readonly string _confirmationTokenSecret;
private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure;
/// <summary> /// <summary>
/// Initializes a new instance of <see cref="TokenService"/>, loading the access, refresh, and /// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
/// confirmation token signing secrets from environment variables. /// confirmation token signing secrets from environment variables.
/// </summary> /// </summary>
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param> /// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param> /// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or /// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set. /// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
/// </exception> /// </exception>
public TokenService( public TokenService(
ITokenInfrastructure tokenInfrastructure, ITokenInfrastructure tokenInfrastructure,
@@ -39,139 +38,170 @@ public class TokenService : ITokenService
_authRepository = authRepository; _authRepository = authRepository;
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET") _accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set"); ?? throw new InvalidOperationException(
"ACCESS_TOKEN_SECRET environment variable is not set");
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET") _refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set"); ?? throw new InvalidOperationException(
"REFRESH_TOKEN_SECRET environment variable is not set");
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET") _confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set"); ?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set");
} }
/// <summary> /// <summary>
/// Generates an access token for the given user, signed with the access token secret and /// Generates an access token for the given user, signed with the access token secret and
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours"/> hours. /// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns> /// <returns>The signed access token string.</returns>
public string GenerateAccessToken(UserAccount user) public string GenerateAccessToken(UserAccount user)
{ {
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret); return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
} }
/// <summary> /// <summary>
/// Generates a refresh token for the given user, signed with the refresh token secret and /// Generates a refresh token for the given user, signed with the refresh token secret and
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours"/> hours. /// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns> /// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user) public string GenerateRefreshToken(UserAccount user)
{ {
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret); return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
} }
/// <summary> /// <summary>
/// Generates a confirmation token for the given user, signed with the confirmation token secret and /// Generates a confirmation token for the given user, signed with the confirmation token secret and
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours"/> hours. /// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns> /// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user) public string GenerateConfirmationToken(UserAccount user)
{ {
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret); return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
} }
/// <summary> /// <summary>
/// Generates a token of the kind specified by <typeparamref name="T"/>, dispatching to /// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken"/>, <see cref="GenerateRefreshToken"/>, or /// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken"/> based on the corresponding <see cref="TokenType"/> value. /// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
/// </summary> /// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam> /// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns> /// <returns>The signed token string corresponding to the requested token type.</returns>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when <typeparamref name="T"/> is not <see cref="TokenType"/> or does not resolve to a known token type. /// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
/// </exception> /// </exception>
public string GenerateToken<T>(UserAccount user) where T : struct, Enum public string GenerateToken<T>(UserAccount user) where T : struct, Enum
{ {
if (typeof(T) != typeof(TokenType)) if (typeof(T) != typeof(TokenType))
throw new InvalidOperationException("Invalid token type"); throw new InvalidOperationException("Invalid token type");
var tokenTypeName = typeof(T).Name; string tokenTypeName = typeof(T).Name;
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed)) if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out object? parsed))
throw new InvalidOperationException("Invalid token type"); throw new InvalidOperationException("Invalid token type");
var tokenType = (TokenType)parsed; TokenType tokenType = (TokenType)parsed;
return tokenType switch return tokenType switch
{ {
TokenType.AccessToken => GenerateAccessToken(user), TokenType.AccessToken => GenerateAccessToken(user),
TokenType.RefreshToken => GenerateRefreshToken(user), TokenType.RefreshToken => GenerateRefreshToken(user),
TokenType.ConfirmationToken => GenerateConfirmationToken(user), TokenType.ConfirmationToken => GenerateConfirmationToken(user),
_ => throw new InvalidOperationException("Invalid token type"), _ => throw new InvalidOperationException("Invalid token type")
}; };
} }
/// <summary> /// <summary>
/// Validates an access token against the access token secret. /// Validates an access token against the access token secret.
/// </summary> /// </summary>
/// <param name="token">The access token string to validate.</param> /// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token) public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); {
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
}
/// <summary> /// <summary>
/// Validates a refresh token against the refresh token secret. /// Validates a refresh token against the refresh token secret.
/// </summary> /// </summary>
/// <param name="token">The refresh token string to validate.</param> /// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token) public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); {
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
}
/// <summary> /// <summary>
/// Validates a confirmation token against the confirmation token secret. /// Validates a confirmation token against the confirmation token secret.
/// </summary> /// </summary>
/// <param name="token">The confirmation token string to validate.</param> /// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token) public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); {
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
}
/// <summary> /// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens: /// Validates the given refresh token, looks up the associated user, and issues a fresh
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims. /// access/refresh token pair.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception>
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
{
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
if (user == null)
throw new UnauthorizedException("User account not found");
string newAccess = GenerateAccessToken(user);
string newRefresh = GenerateRefreshToken(user);
return new RefreshTokenResult(user, newRefresh, newAccess);
}
/// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary> /// </summary>
/// <param name="token">The token string to validate.</param> /// <param name="token">The token string to validate.</param>
/// <param name="secret">The secret key to validate the token's signature against.</param> /// <param name="secret">The secret key to validate the token's signature against.</param>
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param> /// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid"/>, /// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
/// </exception> /// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType) private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
{ {
try try
{ {
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret); ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value; string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim)) if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims"); throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
if (!Guid.TryParse(userIdClaim, out var userId)) if (!Guid.TryParse(userIdClaim, out Guid userId))
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID"); throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
return new ValidatedToken(userId, usernameClaim, principal); return new ValidatedToken(userId, usernameClaim, principal);
@@ -185,26 +215,4 @@ public class TokenService : ITokenService
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}"); throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
} }
} }
/// <summary>
/// Validates the given refresh token, looks up the associated user, and issues a fresh
/// access/refresh token pair.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception>
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
{
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
if (user == null)
throw new UnauthorizedException("User account not found");
var newAccess = GenerateAccessToken(user);
var newRefresh = GenerateRefreshToken(user);
return new RefreshTokenResult(user, newRefresh, newAccess);
}
} }

View File

@@ -1,38 +1,41 @@
using Domain.Entities; using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Commands.CreateBrewery; using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.Dtos;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Breweries.Tests.Commands; namespace Features.Breweries.Tests.Commands;
public class CreateBreweryHandlerTests public class CreateBreweryHandlerTests
{ {
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly CreateBreweryHandler _handler; private readonly CreateBreweryHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public CreateBreweryHandlerTests() public CreateBreweryHandlerTests()
{ {
_handler = new CreateBreweryHandler(_repoMock.Object); _handler = new CreateBreweryHandler(_repoMock.Object);
} }
private static CreateBreweryLocation ValidLocation() => private static CreateBreweryLocation ValidLocation()
new( {
CityId: Guid.NewGuid(), return new CreateBreweryLocation(
AddressLine1: "123 Main St", Guid.NewGuid(),
AddressLine2: null, "123 Main St",
PostalCode: "12345", null,
Coordinates: null "12345",
null
); );
}
[Fact] [Fact]
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt() public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
{ {
var command = new CreateBreweryCommand( CreateBreweryCommand command = new(
PostedById: Guid.NewGuid(), Guid.NewGuid(),
BreweryName: "MyBrew", "MyBrew",
Description: "Desc", "Desc",
Location: ValidLocation() ValidLocation()
); );
BreweryPost? persisted = null; BreweryPost? persisted = null;
@@ -41,9 +44,9 @@ public class CreateBreweryHandlerTests
.Callback<BreweryPost>(b => persisted = b) .Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var before = DateTime.UtcNow; DateTime before = DateTime.UtcNow;
var result = await _handler.Handle(command, CancellationToken.None); BreweryDto result = await _handler.Handle(command, CancellationToken.None);
var after = DateTime.UtcNow; DateTime after = DateTime.UtcNow;
persisted.Should().NotBeNull(); persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().NotBe(Guid.Empty); persisted!.BreweryPostId.Should().NotBe(Guid.Empty);

View File

@@ -9,9 +9,9 @@ public class DeleteBreweryHandlerTests
[Fact] [Fact]
public async Task Handle_DelegatesToRepository() public async Task Handle_DelegatesToRepository()
{ {
var repoMock = new Mock<IBreweryRepository>(); Mock<IBreweryRepository> repoMock = new();
var handler = new DeleteBreweryHandler(repoMock.Object); DeleteBreweryHandler handler = new(repoMock.Object);
var id = Guid.NewGuid(); Guid id = Guid.NewGuid();
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask); repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None); await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);

View File

@@ -1,15 +1,15 @@
using Domain.Entities; using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Commands.UpdateBrewery; using Features.Breweries.Commands.UpdateBrewery;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Breweries.Tests.Commands; namespace Features.Breweries.Tests.Commands;
public class UpdateBreweryHandlerTests public class UpdateBreweryHandlerTests
{ {
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly UpdateBreweryHandler _handler; private readonly UpdateBreweryHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public UpdateBreweryHandlerTests() public UpdateBreweryHandlerTests()
{ {
@@ -19,12 +19,12 @@ public class UpdateBreweryHandlerTests
[Fact] [Fact]
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt() public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
{ {
var command = new UpdateBreweryCommand( UpdateBreweryCommand command = new(
BreweryPostId: Guid.NewGuid(), Guid.NewGuid(),
PostedById: Guid.NewGuid(), Guid.NewGuid(),
BreweryName: "Renamed", "Renamed",
Description: "New description", "New description",
Location: null null
); );
BreweryPost? persisted = null; BreweryPost? persisted = null;
@@ -33,9 +33,9 @@ public class UpdateBreweryHandlerTests
.Callback<BreweryPost>(b => persisted = b) .Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var before = DateTime.UtcNow; DateTime before = DateTime.UtcNow;
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
var after = DateTime.UtcNow; DateTime after = DateTime.UtcNow;
persisted.Should().NotBeNull(); persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().Be(command.BreweryPostId); persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
@@ -49,12 +49,12 @@ public class UpdateBreweryHandlerTests
[Fact] [Fact]
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull() public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
{ {
var command = new UpdateBreweryCommand( UpdateBreweryCommand command = new(
BreweryPostId: Guid.NewGuid(), Guid.NewGuid(),
PostedById: Guid.NewGuid(), Guid.NewGuid(),
BreweryName: "Name", "Name",
Description: "Description", "Description",
Location: null null
); );
BreweryPost? persisted = null; BreweryPost? persisted = null;
@@ -71,20 +71,20 @@ public class UpdateBreweryHandlerTests
[Fact] [Fact]
public async Task Handle_SetsLocation_WhenCommandLocationProvided() public async Task Handle_SetsLocation_WhenCommandLocationProvided()
{ {
var locationCommand = new UpdateBreweryLocation( UpdateBreweryLocation locationCommand = new(
BreweryPostLocationId: Guid.NewGuid(), Guid.NewGuid(),
CityId: Guid.NewGuid(), Guid.NewGuid(),
AddressLine1: "456 Oak Ave", "456 Oak Ave",
AddressLine2: "Suite 2", "Suite 2",
PostalCode: "54321", "54321",
Coordinates: null null
); );
var command = new UpdateBreweryCommand( UpdateBreweryCommand command = new(
BreweryPostId: Guid.NewGuid(), Guid.NewGuid(),
PostedById: Guid.NewGuid(), Guid.NewGuid(),
BreweryName: "Name", "Name",
Description: "Description", "Description",
Location: locationCommand locationCommand
); );
BreweryPost? persisted = null; BreweryPost? persisted = null;

View File

@@ -1,26 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>Features.Breweries.Tests</RootNamespace> <RootNamespace>Features.Breweries.Tests</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72"/>
<PackageReference Include="FluentAssertions" Version="6.9.0" /> <PackageReference Include="FluentAssertions" Version="6.9.0"/>
<PackageReference Include="DbMocker" Version="1.26.0" /> <PackageReference Include="DbMocker" Version="1.26.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,15 +1,16 @@
using Domain.Entities; using Domain.Entities;
using FluentAssertions; using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetAllBreweries; using Features.Breweries.Queries.GetAllBreweries;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Breweries.Tests.Queries; namespace Features.Breweries.Tests.Queries;
public class GetAllBreweriesHandlerTests public class GetAllBreweriesHandlerTests
{ {
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly GetAllBreweriesHandler _handler; private readonly GetAllBreweriesHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public GetAllBreweriesHandlerTests() public GetAllBreweriesHandlerTests()
{ {
@@ -22,7 +23,7 @@ public class GetAllBreweriesHandlerTests
_repoMock.Setup(r => r.GetAllAsync(10, 5)) _repoMock.Setup(r => r.GetAllAsync(10, 5))
.ReturnsAsync(Array.Empty<BreweryPost>()); .ReturnsAsync(Array.Empty<BreweryPost>());
var result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None); IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
result.Should().BeEmpty(); result.Should().BeEmpty();
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once); _repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
@@ -31,15 +32,15 @@ public class GetAllBreweriesHandlerTests
[Fact] [Fact]
public async Task Handle_ReturnsAllBreweries_FromRepository() public async Task Handle_ReturnsAllBreweries_FromRepository()
{ {
var breweries = new[] BreweryPost[] breweries = new[]
{ {
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" }, new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }, new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }
}; };
_repoMock.Setup(r => r.GetAllAsync(null, null)) _repoMock.Setup(r => r.GetAllAsync(null, null))
.ReturnsAsync(breweries); .ReturnsAsync(breweries);
var result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None); IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId)); result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
} }

View File

@@ -1,15 +1,16 @@
using Domain.Entities; using Domain.Entities;
using FluentAssertions; using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetBreweryById; using Features.Breweries.Queries.GetBreweryById;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Breweries.Tests.Queries; namespace Features.Breweries.Tests.Queries;
public class GetBreweryByIdHandlerTests public class GetBreweryByIdHandlerTests
{ {
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly GetBreweryByIdHandler _handler; private readonly GetBreweryByIdHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public GetBreweryByIdHandlerTests() public GetBreweryByIdHandlerTests()
{ {
@@ -19,11 +20,11 @@ public class GetBreweryByIdHandlerTests
[Fact] [Fact]
public async Task Handle_ReturnsBrewery_WhenFound() public async Task Handle_ReturnsBrewery_WhenFound()
{ {
var brewery = new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" }; BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId)) _repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
.ReturnsAsync(brewery); .ReturnsAsync(brewery);
var result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None); BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.BreweryPostId.Should().Be(brewery.BreweryPostId); result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
@@ -32,11 +33,11 @@ public class GetBreweryByIdHandlerTests
[Fact] [Fact]
public async Task Handle_ReturnsNull_WhenNotFound() public async Task Handle_ReturnsNull_WhenNotFound()
{ {
var id = Guid.NewGuid(); Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id)) _repoMock.Setup(r => r.GetByIdAsync(id))
.ReturnsAsync((BreweryPost?)null); .ReturnsAsync((BreweryPost?)null);
var result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None); BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
result.Should().BeNull(); result.Should().BeNull();
} }

View File

@@ -1,25 +1,27 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Features.Breweries.Repository;
using Domain.Entities; using Domain.Entities;
using Features.Breweries.Repository;
using FluentAssertions;
namespace Features.Breweries.Tests.Repository; namespace Features.Breweries.Tests.Repository;
public class BreweryRepositoryTests public class BreweryRepositoryTests
{ {
private static BreweryRepository CreateRepo(MockDbConnection conn) => private static BreweryRepository CreateRepo(MockDbConnection conn)
new(new TestConnectionFactory(conn)); {
return new BreweryRepository(new TestConnectionFactory(conn));
}
[Fact] [Fact]
public async Task GetByIdAsync_ReturnsBrewery_WhenExists() public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
{ {
var breweryId = Guid.NewGuid(); Guid breweryId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
// Repository calls the stored procedure // Repository calls the stored procedure
const string getByIdSql = "USP_GetBreweryById"; const string getByIdSql = "USP_GetBreweryById";
var locationId = Guid.NewGuid(); Guid locationId = Guid.NewGuid();
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql) conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
.ReturnsTable( .ReturnsTable(
@@ -56,8 +58,8 @@ public class BreweryRepositoryTests
) )
); );
var repo = CreateRepo(conn); BreweryRepository repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(breweryId); BreweryPost? result = await repo.GetByIdAsync(breweryId);
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.BreweryPostId.Should().Be(breweryId); result!.BreweryPostId.Should().Be(breweryId);
result.Location.Should().NotBeNull(); result.Location.Should().NotBeNull();
@@ -67,23 +69,22 @@ public class BreweryRepositoryTests
[Fact] [Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotExists() public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
{ {
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById") conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); BreweryRepository repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(Guid.NewGuid()); BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
result.Should().BeNull(); result.Should().BeNull();
} }
[Fact] [Fact]
public async Task CreateAsync_ExecutesSuccessfully() public async Task CreateAsync_ExecutesSuccessfully()
{ {
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery") conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
.ReturnsScalar(1); .ReturnsScalar(1);
var repo = CreateRepo(conn); BreweryRepository repo = CreateRepo(conn);
var brewery = new BreweryPost BreweryPost brewery = new()
{ {
BreweryPostId = Guid.NewGuid(), BreweryPostId = Guid.NewGuid(),
PostedById = Guid.NewGuid(), PostedById = Guid.NewGuid(),
@@ -101,7 +102,7 @@ public class BreweryRepositoryTests
}; };
// Should not throw // Should not throw
var act = async () => await repo.CreateAsync(brewery); Func<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().NotThrowAsync(); await act.Should().NotThrowAsync();
} }
} }

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{ {
private readonly DbConnection _conn = conn; private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn; public DbConnection CreateConnection()
{
return _conn;
}
} }

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary> /// <summary>
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand"/>. /// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand" />.
/// </summary> /// </summary>
public record CreateBreweryLocation( public record CreateBreweryLocation(
Guid CityId, Guid CityId,
@@ -15,7 +15,7 @@ public record CreateBreweryLocation(
); );
/// <summary> /// <summary>
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>. /// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>.
/// </summary> /// </summary>
public record CreateBreweryCommand( public record CreateBreweryCommand(
Guid PostedById, Guid PostedById,

View File

@@ -6,21 +6,21 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary> /// <summary>
/// Handles <see cref="CreateBreweryCommand"/> by persisting a new brewery post and its location. /// Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post and its location.
/// </summary> /// </summary>
/// <param name="repository">Repository used to persist the new brewery post.</param> /// <param name="repository">Repository used to persist the new brewery post.</param>
public class CreateBreweryHandler(IBreweryRepository repository) public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto> : IRequestHandler<CreateBreweryCommand, BreweryDto>
{ {
/// <summary> /// <summary>
/// Creates a new brewery post, generating new identifiers for the post and its location. /// Creates a new brewery post, generating new identifiers for the post and its location.
/// </summary> /// </summary>
/// <param name="request">The details of the brewery post to create.</param> /// <param name="request">The details of the brewery post to create.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param> /// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The newly created brewery post.</returns> /// <returns>The newly created brewery post.</returns>
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
{ {
var entity = new BreweryPost BreweryPost entity = new()
{ {
BreweryPostId = Guid.NewGuid(), BreweryPostId = Guid.NewGuid(),
PostedById = request.PostedById, PostedById = request.PostedById,
@@ -34,8 +34,8 @@ public class CreateBreweryHandler(IBreweryRepository repository)
AddressLine1 = request.Location.AddressLine1, AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2, AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode, PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates, Coordinates = request.Location.Coordinates
}, }
}; };
await repository.CreateAsync(entity); await repository.CreateAsync(entity);

View File

@@ -3,15 +3,15 @@ using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary> /// <summary>
/// Validates <see cref="CreateBreweryCommand"/> instances before they are processed. /// Validates <see cref="CreateBreweryCommand" /> instances before they are processed.
/// </summary> /// </summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand> public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{ {
/// <summary> /// <summary>
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById"/>, /// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById" />,
/// <see cref="CreateBreweryCommand.BreweryName"/>, <see cref="CreateBreweryCommand.Description"/>, and /// <see cref="CreateBreweryCommand.BreweryName" />, <see cref="CreateBreweryCommand.Description" />, and
/// <see cref="CreateBreweryCommand.Location"/> to be present, with length limits on the name, description, /// <see cref="CreateBreweryCommand.Location" /> to be present, with length limits on the name, description,
/// address line 1, and postal code fields. /// address line 1, and postal code fields.
/// </summary> /// </summary>
public CreateBreweryValidator() public CreateBreweryValidator()
{ {

View File

@@ -3,6 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary> /// <summary>
/// Deletes a brewery post by its unique identifier. /// Deletes a brewery post by its unique identifier.
/// </summary> /// </summary>
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest; public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;

Some files were not shown because too many files have changed in this diff Show More