code style cleanup

This commit is contained in:
Aaron Po
2026-06-20 13:55:17 -04:00
parent 07aedcb866
commit 254431928f
167 changed files with 3711 additions and 3522 deletions

View File

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

View File

@@ -1,20 +1,20 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using Shared.Contracts;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Shared.Contracts;
namespace API.Core.Authentication;
/// <summary>
/// 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.
/// 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.
/// </summary>
/// <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="encoder">The URL encoder 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="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>
public class JwtAuthenticationHandler(
@@ -26,66 +26,57 @@ public class JwtAuthenticationHandler(
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
{
/// <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>
/// <remarks>
/// 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
/// 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
/// token fails validation.
/// 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
/// 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
/// token fails validation.
/// </remarks>
/// <returns>
/// A successful <see cref="AuthenticateResult"/> containing the validated <see cref="System.Security.Claims.ClaimsPrincipal"/>
/// on success, or a failure result describing why authentication could not be completed.
/// A successful <see cref="AuthenticateResult" /> containing the validated
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
/// on success, or a failure result describing why authentication could not be completed.
/// </returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration["Jwt:SecretKey"];
}
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret)) secret = configuration["Jwt:SecretKey"];
if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured");
}
if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured");
// Check if Authorization header exists
if (
!Request.Headers.TryGetValue(
"Authorization",
out var authHeaderValue
out StringValues authHeaderValue
)
)
{
return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString();
string authHeader = authHeaderValue.ToString();
if (
!authHeader.StartsWith(
"Bearer ",
StringComparison.OrdinalIgnoreCase
)
)
{
return AuthenticateResult.Fail(
"Invalid authorization header format"
);
}
var token = authHeader.Substring("Bearer ".Length).Trim();
string token = authHeader.Substring("Bearer ".Length).Trim();
try
{
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token,
secret
);
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
@@ -97,7 +88,7 @@ public class JwtAuthenticationHandler(
}
/// <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>
/// <param name="properties">The authentication properties associated with the challenge.</param>
/// <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.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);
}
}
/// <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>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions"/>.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions
{
}

View File

@@ -1,27 +1,27 @@
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>
/// 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>
/// <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
/// <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()
{
/// <summary>
/// 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." });
}
return NotFound(new { message = "Route not found." });
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using Shared.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace API.Core.Controllers;
/// <summary>
/// Sample controller demonstrating an endpoint that requires JWT authentication.
/// Sample controller demonstrating an endpoint that requires JWT authentication.
/// </summary>
[ApiController]
[Route("api/[controller]")]
@@ -14,25 +14,25 @@ namespace API.Core.Controllers;
public class ProtectedController : ControllerBase
{
/// <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>
/// <returns>
/// 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>
/// (from <see cref="ClaimTypes.Name"/>), either of which may be <c>null</c> if not present in the token.
/// 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>
/// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
/// </returns>
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value;
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok(
new ResponseBody<object>
{
Message = "Protected endpoint accessed successfully",
Payload = new { userId, username },
Payload = new { userId, username }
}
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,76 +1,77 @@
Feature: User Account Confirmation
As a newly registered user
I want to confirm my email address via a validation token
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"
As a newly registered user
I want to confirm my email address via a validation token
So that my account is fully activated
Scenario: Re-confirming an already verified account remains successful
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
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: 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: Confirmation fails with invalid token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with an invalid token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Re-confirming an already verified account remains successful
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
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
Given the API is running
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
When I submit a confirmation request with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with invalid token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with an invalid token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running
And I have registered a new account
And I have a confirmation token signed with the wrong secret
And I have a valid access token for my account
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 fails with expired token
Given the API is running
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
When I submit a confirmation request with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a missing token
Then the response has HTTP status 400
Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running
And I have registered a new account
And I have a confirmation token signed with the wrong secret
And I have a valid access token for my account
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
Given the API is running
And I have a valid confirmation token
When I submit a confirmation request using an invalid HTTP method
Then the response has HTTP status 404
Scenario: Confirmation fails when token is missing
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a missing token
Then the response has HTTP status 400
Scenario: Confirmation fails with malformed token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a malformed 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
Given the API is running
And I have a valid confirmation token
When I submit a confirmation request using an invalid HTTP method
Then the response has HTTP status 404
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
Scenario: Confirmation fails with malformed token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a malformed token
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
So that I receive an authentication token to access authenticated routes
Scenario: Successful login with valid credentials
Given the API is running
And I have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 200
And the response JSON should have "message" equal "Logged in successfully."
And the response JSON should have an access token
Scenario: Successful login with valid credentials
Given the API is running
And I have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 200
And the response JSON should have "message" equal "Logged in successfully."
And the response JSON should have an access token
Scenario: Login fails with invalid credentials
Given the API is running
And I do not have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 401
And the response JSON should have "message" equal "Invalid username or password."
Scenario: Login fails with invalid credentials
Given the API is running
And I do not have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 401
And the response JSON should have "message" equal "Invalid username or password."
Scenario: Login fails when required missing username
Given the API is running
When I submit a login request with a missing username
Then the response has HTTP status 400
Scenario: Login fails when required missing username
Given the API is running
When I submit a login request with a missing username
Then the response has HTTP status 400
Scenario: Login fails when required missing password
Given the API is running
When I submit a login request with a missing password
Then the response has HTTP status 400
Scenario: Login fails when required missing password
Given the API is running
When I submit a login request with a missing password
Then the response has HTTP status 400
Scenario: Login fails when both username and password are missing
Given the API is running
When I submit a login request with both username and password missing
Then the response has HTTP status 400
Scenario: Login fails when both username and password are missing
Given the API is running
When I submit a login request with both username and password missing
Then the response has HTTP status 400
Scenario: Login endpoint only accepts POST requests
Given the API is running
When I submit a login request using a GET request
Then the response has HTTP status 404
Scenario: Login endpoint only accepts POST requests
Given the API is running
When I submit a login request using a GET request
Then the response has HTTP status 404

View File

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

View File

@@ -1,60 +1,60 @@
Feature: User Registration
As a new user
I want to register an account
So that I can log in and access authenticated routes
As a new user
I want to register an account
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
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 201
And the response JSON should have "message" equal "User registered successfully."
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
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
Then the response has HTTP status 409
Scenario: Registration fails with existing email
Scenario: Registration fails with existing email
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
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
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! |
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
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Then the response has HTTP status 400
Scenario: Registration fails with weak password
Scenario: Registration fails with weak password
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
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
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
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
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
As a user who did not receive 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
As a user who did not receive 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
Scenario: Legitimate resend for an unconfirmed user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Legitimate resend for an unconfirmed user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user
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
And I have confirmed my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user
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
And I have confirmed my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for a non-existent user
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for a non-existent user
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend requires authentication
Given the API is running
And I have registered a new account
When I submit a resend confirmation request without an access token
Then the response has HTTP status 401
Scenario: Resend requires authentication
Given the API is running
And I have registered a new account
When I submit a resend confirmation request without an access token
Then the response has HTTP status 401

View File

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

View File

@@ -20,7 +20,7 @@ public class MockEmailDispatcher : IEmailDispatcher
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
SentAt = DateTime.UtcNow
}
);
@@ -39,7 +39,7 @@ public class MockEmailDispatcher : IEmailDispatcher
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
SentAt = DateTime.UtcNow
}
);
@@ -67,4 +67,4 @@ public class MockEmailDispatcher : IEmailDispatcher
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}
}
}

View File

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

View File

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

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 Features.Emails.Services;
using Infrastructure.Email;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
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 =>
{
// Replace the real email provider with mock for testing
var emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
if (emailProviderDescriptor != null) services.Remove(emailProviderDescriptor);
if (emailProviderDescriptor != null)
{
services.Remove(emailProviderDescriptor);
}
services.AddScoped<IEmailProvider, MockEmailProvider>();
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
var emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailDispatcher)
);
if (emailDispatcherDescriptor != null) services.Remove(emailDispatcherDescriptor);
if (emailDispatcherDescriptor != null)
{
services.Remove(emailDispatcherDescriptor);
}
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
});
}
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
});
}
}
}

View File

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

View File

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

View File

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

View File

@@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount
UserAccountID UNIQUEIDENTIFIER
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(),
UpdatedAt DATETIME,
UpdatedAt DATETIME,
DateOfBirth DATE NOT NULL,
DateOfBirth DATE NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserAccount
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
(
PhotoID UNIQUEIDENTIFIER
PhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
Hyperlink NVARCHAR(256),
Hyperlink NVARCHAR(256),
-- storage is handled via filesystem or cloud service
UploadedByID UNIQUEIDENTIFIER NOT NULL,
UploadedAt DATETIME NOT NULL
UploadedAt DATETIME NOT NULL
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_Photo
PRIMARY KEY (PhotoID),
CONSTRAINT FK_Photo_UploadedBy
FOREIGN KEY (UploadedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
CREATE
NONCLUSTERED INDEX IX_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
(
UserAvatarID UNIQUEIDENTIFIER
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE,
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_PhotoID
FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID),
REFERENCES Photo (PhotoID),
CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);
----------------------------------------------------------------------------
@@ -118,29 +120,30 @@ CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE TABLE UserVerification -- delete verification data when user account is deleted
(
UserVerificationID UNIQUEIDENTIFIER
UserVerificationID UNIQUEIDENTIFIER
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserVerification
PRIMARY KEY (UserVerificationID),
CONSTRAINT FK_UserVerification_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE,
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE,
CONSTRAINT AK_UserVerification_UserAccountID
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID);
----------------------------------------------------------------------------
@@ -151,37 +154,39 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
UserCredentialID UNIQUEIDENTIFIER
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(),
Expiry DATETIME NOT NULL
Expiry DATETIME NOT NULL
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
Hash NVARCHAR(256) NOT NULL,
Hash NVARCHAR(256) NOT NULL,
-- uses argon2
IsRevoked BIT NOT NULL
IsRevoked BIT NOT NULL
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
RevokedAt DATETIME NULL,
RevokedAt DATETIME NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserCredential
PRIMARY KEY (UserCredentialID),
CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash);
@@ -190,39 +195,42 @@ CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE TABLE UserFollow
(
UserFollowID UNIQUEIDENTIFIER
UserFollowID UNIQUEIDENTIFIER
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserFollow
PRIMARY KEY (UserFollowID),
CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
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);
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID);
----------------------------------------------------------------------------
@@ -230,14 +238,14 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE TABLE Country
(
CountryID UNIQUEIDENTIFIER
CountryID UNIQUEIDENTIFIER
CONSTRAINT DF_CountryID DEFAULT NEWID(),
CountryName NVARCHAR(100) NOT NULL,
ISO3166_1 CHAR(2) NOT NULL,
ISO3166_1 CHAR(2) NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_Country
PRIMARY KEY (CountryID),
@@ -251,17 +259,17 @@ CREATE TABLE Country
CREATE TABLE StateProvince
(
StateProvinceID UNIQUEIDENTIFIER
StateProvinceID UNIQUEIDENTIFIER
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
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
CountryID UNIQUEIDENTIFIER NOT NULL,
CountryID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_StateProvince
PRIMARY KEY (StateProvinceID),
@@ -271,10 +279,11 @@ CREATE TABLE StateProvince
CONSTRAINT FK_StateProvince_Country
FOREIGN KEY (CountryID)
REFERENCES Country(CountryID)
REFERENCES Country (CountryID)
);
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID);
----------------------------------------------------------------------------
@@ -282,24 +291,25 @@ CREATE NONCLUSTERED INDEX IX_StateProvince_Country
CREATE TABLE City
(
CityID UNIQUEIDENTIFIER
CityID UNIQUEIDENTIFIER
CONSTRAINT DF_CityID DEFAULT NEWID(),
CityName NVARCHAR(100) NOT NULL,
CityName NVARCHAR(100) NOT NULL,
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_City
PRIMARY KEY (CityID),
CONSTRAINT FK_City_StateProvince
FOREIGN KEY (StateProvinceID)
REFERENCES StateProvince(StateProvinceID)
REFERENCES StateProvince (StateProvinceID)
);
CREATE NONCLUSTERED INDEX IX_City_StateProvince
CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID);
----------------------------------------------------------------------------
@@ -310,29 +320,30 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
BreweryPostID UNIQUEIDENTIFIER
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(),
UpdatedAt DATETIME NULL,
UpdatedAt DATETIME NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BreweryPost
PRIMARY KEY (BreweryPostID),
CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID);
----------------------------------------------------------------------------
@@ -343,19 +354,19 @@ CREATE TABLE BreweryPostLocation
BreweryPostLocationID UNIQUEIDENTIFIER
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
PRIMARY KEY (BreweryPostLocationID),
@@ -365,18 +376,20 @@ CREATE TABLE BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID)
ON DELETE CASCADE,
REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID)
REFERENCES City(CityID)
REFERENCES City (CityID)
);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID);
-- 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
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BreweryPostPhoto
PRIMARY KEY (BreweryPostPhotoID),
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID)
ON DELETE CASCADE,
REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostPhoto_Photo
FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID)
ON DELETE CASCADE
REFERENCES Photo (PhotoID)
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID);
----------------------------------------------------------------------------
@@ -436,11 +451,11 @@ CREATE TABLE BeerStyle
BeerStyleID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
StyleName NVARCHAR(100) NOT NULL,
StyleName NVARCHAR(100) NOT NULL,
Description NVARCHAR(MAX),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerStyle
PRIMARY KEY (BeerStyleID),
@@ -454,47 +469,47 @@ CREATE TABLE BeerStyle
CREATE TABLE BeerPost
(
BeerPostID UNIQUEIDENTIFIER
BeerPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
Name NVARCHAR(100) NOT NULL,
Name NVARCHAR(100) 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%)
IBU INT NOT NULL,
IBU INT NOT NULL,
-- International Bitterness Units (typically 0-120)
PostedByID UNIQUEIDENTIFIER NOT NULL,
PostedByID 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(),
UpdatedAt DATETIME,
UpdatedAt DATETIME,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerPost
PRIMARY KEY (BeerPostID),
CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID)
REFERENCES BeerStyle(BeerStyleID),
REFERENCES BeerStyle (BeerStyleID),
CONSTRAINT FK_BeerPost_Brewery
FOREIGN KEY (BrewedByID)
REFERENCES BreweryPost(BreweryPostID),
REFERENCES BreweryPost (BreweryPostID),
CONSTRAINT CHK_BeerPost_ABV
CHECK (ABV >= 0 AND ABV <= 67),
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
CHECK (IBU >= 0 AND IBU <= 120)
);
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON BeerPost(PostedByID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON BeerPost(BeerStyleID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON BeerPost(BrewedByID);
----------------------------------------------------------------------------
@@ -520,33 +538,35 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
BeerPostPhotoID UNIQUEIDENTIFIER
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerPostPhoto
PRIMARY KEY (BeerPostPhotoID),
CONSTRAINT FK_BeerPostPhoto_BeerPost
FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID)
ON DELETE CASCADE,
REFERENCES BeerPost (BeerPostID)
ON DELETE CASCADE,
CONSTRAINT FK_BeerPostPhoto_Photo
FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID)
ON DELETE CASCADE
REFERENCES Photo (PhotoID)
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID);
----------------------------------------------------------------------------
@@ -557,39 +577,41 @@ CREATE TABLE BeerPostComment
BeerPostCommentID UNIQUEIDENTIFIER
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(),
UpdatedAt DATETIME NULL,
UpdatedAt DATETIME NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID),
CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID),
REFERENCES BeerPost (BeerPostID),
CONSTRAINT FK_BeerPostComment_UserAccount
FOREIGN KEY (CommentedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CHK_BeerPostComment_Rating
CHECK (Rating BETWEEN 1 AND 5)
);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
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)
)
RETURNS UNIQUEIDENTIFIER
AS
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE @CountryId UNIQUEIDENTIFIER;
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId;
RETURN @CountryId;
END;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,15 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// 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
/// existing data before inserting) and may depend on data created by seeders that run
/// before them.
/// 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
/// existing data before inserting) and may depend on data created by seeders that run
/// before them.
/// </summary>
internal interface ISeeder
{
/// <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>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when seeding is finished.</returns>

View File

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

View File

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

View File

@@ -8,13 +8,13 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// Seeds user accounts, credentials, and verification records. Creates one fixed
/// "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
/// <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
/// safe to re-run, though re-running will still attempt to register (and may fail on)
/// users that already exist.
/// Seeds user accounts, credentials, and verification records. Creates one fixed
/// "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
/// <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
/// safe to re-run, though re-running will still attempt to register (and may fail on)
/// users that already exist.
/// </summary>
internal class UserSeeder : ISeeder
{
@@ -123,21 +123,21 @@ internal class UserSeeder : ISeeder
("Zara", "Wilkinson"),
("Zaria", "Gibson"),
("Zion", "Watkins"),
("Zoie", "Armstrong"),
("Zoie", "Armstrong")
];
/// <summary>
/// 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
/// newly created account that does not already have one. Progress counts are
/// written to the console on completion.
/// 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
/// newly created account that does not already have one. Progress counts are
/// written to the console on completion.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when all users have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection)
{
var generator = new PasswordGenerator();
var rng = new Random();
PasswordGenerator generator = new();
Random rng = new();
int createdUsers = 0;
int createdCredentials = 0;
int createdVerifications = 0;
@@ -147,8 +147,8 @@ internal class UserSeeder : ISeeder
const string firstName = "Test";
const string lastName = "User";
const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01);
var hash = GeneratePasswordHash("password");
DateTime dob = new(1985, 03, 01);
string hash = GeneratePasswordHash("password");
await RegisterUserAsync(
connection,
@@ -160,24 +160,24 @@ internal class UserSeeder : ISeeder
hash
);
}
foreach (var (firstName, lastName) in SeedNames)
foreach ((string firstName, string lastName) in SeedNames)
{
// prepare user fields
var username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng);
string username = $"{firstName[0]}.{lastName}";
string email = $"{firstName}.{lastName}@thebiergarten.app";
DateTime dob = GenerateDateOfBirth(rng);
// generate a password and hash it
string pwd = generator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
64,
10,
10
);
string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential)
var id = await RegisterUserAsync(
Guid id = await RegisterUserAsync(
connection,
username,
firstName,
@@ -203,8 +203,8 @@ internal class UserSeeder : ISeeder
}
/// <summary>
/// Registers a new user account and its credential by invoking the
/// <c>dbo.USP_RegisterUser</c> stored procedure.
/// Registers a new user account and its credential by invoking the
/// <c>dbo.USP_RegisterUser</c> stored procedure.
/// </summary>
/// <param name="connection">An open connection to the target database.</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="dateOfBirth">The user's date of birth.</param>
/// <param name="email">The user's email address.</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>
/// <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>
private static async Task<Guid> RegisterUserAsync(
SqlConnection connection,
string username,
@@ -224,7 +224,7 @@ internal class UserSeeder : ISeeder
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;
@@ -235,16 +235,16 @@ internal class UserSeeder : ISeeder
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return (Guid)result!;
}
/// <summary>
/// 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
/// memory, and 4 iterations.
/// 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
/// memory, and 4 iterations.
/// </summary>
/// <param name="pwd">The plaintext password to hash.</param>
/// <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);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = 65536,
Iterations = 4,
Iterations = 4
};
byte[] hash = argon2.GetBytes(32);
@@ -278,9 +278,9 @@ internal class UserSeeder : ISeeder
FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId;
""";
await using var command = new SqlCommand(sql, connection);
await using SqlCommand command = new(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return result is not null;
}
@@ -293,7 +293,7 @@ internal class UserSeeder : ISeeder
Guid userAccountId
)
{
await using var command = new SqlCommand(
await using SqlCommand command = new(
"dbo.USP_CreateUserVerification",
connection
);
@@ -304,8 +304,8 @@ internal class UserSeeder : ISeeder
}
/// <summary>
/// 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.
/// 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.
/// </summary>
/// <param name="random">The random number source to use.</param>
/// <returns>A randomly generated date of birth.</returns>
@@ -316,4 +316,4 @@ internal class UserSeeder : ISeeder
int offsetDays = random.Next(0, 365);
return baseDate.AddDays(-offsetDays);
}
}
}

View File

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

View File

@@ -1,48 +1,50 @@
namespace Domain.Entities;
/// <summary>
/// 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.
/// 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.
/// </summary>
public class BreweryPost
{
/// <summary>
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
/// </summary>
public Guid BreweryPostId { get; set; }
/// <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>
public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery being posted about.
/// The name of the brewery being posted about.
/// </summary>
public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// Free-text description of the brewery.
/// Free-text description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time the post was created.
/// The date and time the post was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <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>
public DateTime? UpdatedAt { get; set; }
/// <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>
public byte[]? Timer { get; set; }
/// <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>
public BreweryPostLocation? Location { get; set; }
}
}

View File

@@ -1,48 +1,52 @@
namespace Domain.Entities;
/// <summary>
/// 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.
/// 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.
/// </summary>
public class BreweryPostLocation
{
/// <summary>
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
/// </summary>
public Guid BreweryPostLocationId { get; set; }
/// <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>
public Guid BreweryPostId { get; set; }
/// <summary>
/// The primary street address line.
/// The primary street address line.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty;
/// <summary>
/// An optional secondary address line (e.g., suite or unit number).
/// An optional secondary address line (e.g., suite or unit number).
/// </summary>
public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the location.
/// The postal/ZIP code of the location.
/// </summary>
public string PostalCode { get; set; } = string.Empty;
/// <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>
public Guid CityId { get; set; }
/// <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>
public byte[]? Coordinates { get; set; }
/// <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>
public byte[]? Timer { get; set; }
}
}

View File

@@ -1,53 +1,55 @@
namespace Domain.Entities;
/// <summary>
/// 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.
/// 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.
/// </summary>
public class UserAccount
{
/// <summary>
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; }
/// <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>
public string Username { get; set; } = string.Empty;
/// <summary>
/// The user's first name.
/// The user's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty;
/// <summary>
/// The user's last name.
/// The user's last name.
/// </summary>
public string LastName { get; set; } = string.Empty;
/// <summary>
/// The user's email address. Enforced unique at the database level.
/// The user's email address. Enforced unique at the database level.
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// The date and time the account was created.
/// The date and time the account was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <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>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// The user's date of birth.
/// The user's date of birth.
/// </summary>
public DateTime DateOfBirth { get; set; }
/// <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>
public byte[]? Timer { get; set; }
}
}

View File

@@ -1,38 +1,40 @@
namespace Domain.Entities;
/// <summary>
/// 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.
/// 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.
/// </summary>
public class UserCredential
{
/// <summary>
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
/// </summary>
public Guid UserCredentialId { get; set; }
/// <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>
public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time the credential was issued.
/// The date and time the credential was issued.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <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>
public DateTime Expiry { get; set; }
/// <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>
public string Hash { get; set; } = string.Empty;
/// <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>
public byte[]? Timer { get; set; }
}
}

View File

@@ -1,29 +1,31 @@
namespace Domain.Entities;
/// <summary>
/// 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,
/// and it is deleted automatically when the owning user account is deleted.
/// 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,
/// and it is deleted automatically when the owning user account is deleted.
/// </summary>
public class UserVerification
{
/// <summary>
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
/// </summary>
public Guid UserVerificationId { get; set; }
/// <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>
public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time at which the user account was verified.
/// The date and time at which the user account was verified.
/// </summary>
public DateTime VerificationDateTime { get; set; }
/// <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>
public byte[]? Timer { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using Moq;
@@ -12,9 +13,9 @@ namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly LoginHandler _handler;
public LoginHandlerTests()
{
@@ -28,24 +29,24 @@ public class LoginHandlerTests
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{
const string username = "CogitoErgoSum";
var userAccountId = Guid.NewGuid();
Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount
UserAccount userAccount = new()
{
UserAccountId = userAccountId,
Username = username,
FirstName = "René",
LastName = "Descartes",
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(),
UserAccountId = userAccountId,
Hash = "some-hash",
Expiry = DateTime.MaxValue,
Expiry = DateTime.MaxValue
};
_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.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.UserAccountId.Should().Be(userAccountId);
@@ -69,7 +70,7 @@ public class LoginHandlerTests
const string username = "de_beauvoir";
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never);
@@ -79,13 +80,14 @@ public class LoginHandlerTests
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{
const string username = "BRussell";
var userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
_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.");
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -95,16 +97,16 @@ public class LoginHandlerTests
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{
const string username = "RCarnap";
var userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
var userCredential = new UserCredential { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
UserCredential userCredential = new() { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<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.");
}
}
}

View File

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

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
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 Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
@@ -12,152 +12,153 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceRefreshTests
{
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly TokenService _tokenService;
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceRefreshTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
public TokenServiceRefreshTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens
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("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
// Set environment variables for tokens
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("CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
);
}
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
);
}
[Fact]
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
{
// Arrange
var userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
[Fact]
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
var userAccount = new UserAccount
{
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>
UserAccount userAccount = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
UserAccountId = userId,
Username = username,
FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1)
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
// Mock the validation of refresh token
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
_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?)null);
_authRepositoryMock
.Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync(userAccount);
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.RefreshTokenAsync(refreshToken)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
}
}
// Act
RefreshTokenResult 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
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.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
@@ -12,272 +11,273 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceValidationTests
{
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly TokenService _tokenService;
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceValidationTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
public TokenServiceValidationTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens
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("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
// Set environment variables for tokens
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("CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
);
}
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
);
}
[Fact]
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
var userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-access-token";
[Fact]
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-access-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
var result =
await _tokenService.ValidateAccessTokenAsync(token);
// Act
ValidatedToken result =
await _tokenService.ValidateAccessTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
result.Principal.Should().NotBeNull();
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
}
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
result.Principal.Should().NotBeNull();
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
}
[Fact]
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
var userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-refresh-token";
[Fact]
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-refresh-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
var result =
await _tokenService.ValidateRefreshTokenAsync(token);
// Act
ValidatedToken result =
await _tokenService.ValidateRefreshTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
[Fact]
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
var userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-confirmation-token";
[Fact]
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-confirmation-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
var result =
await _tokenService.ValidateConfirmationTokenAsync(token);
// Act
ValidatedToken result =
await _tokenService.ValidateConfirmationTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
[Fact]
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-token";
[Fact]
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "expired-token";
[Fact]
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "expired-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException(
"Token has expired"
));
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException(
"Token has expired"
));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-without-user-id";
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-without-user-id";
// Claims without Sub (user ID)
var claims = new List<Claim>
// Claims without Sub (user ID)
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{
// Arrange
var userId = Guid.NewGuid();
const string token = "token-without-username";
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{
// Arrange
Guid userId = Guid.NewGuid();
const string token = "token-without-username";
// Claims without UniqueName (username)
var claims = new List<Claim>
// Claims without UniqueName (username)
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-with-malformed-user-id";
[Fact]
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-with-malformed-user-id";
// Claims with invalid GUID format
var claims = new List<Claim>
// Claims with invalid GUID format
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*");
}
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateAccessTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*");
}
[Fact]
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-refresh-token";
[Fact]
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateRefreshTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateRefreshTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-confirmation-token";
[Fact]
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-confirmation-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateConfirmationTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
}
// Act & Assert
await FluentActions.Invoking(async () =>
await _tokenService.ValidateConfirmationTokenAsync(token)
).Should().ThrowAsync<UnauthorizedException>();
}
}

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser;
/// <summary>
/// Validates a confirmation token and confirms the corresponding user account.
/// Validates a confirmation token and confirms the corresponding user account.
/// </summary>
/// <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 Features.Auth.Dtos;
using Features.Auth.Repository;
@@ -7,8 +8,8 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser;
/// <summary>
/// Handles <see cref="ConfirmUserCommand"/> by validating the confirmation token and marking the
/// corresponding user account as confirmed.
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed.
/// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param>
@@ -18,19 +19,16 @@ public class ConfirmUserHandler(
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{
/// <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>
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)
{
throw new UnauthorizedException("User account not found");
}
if (user == null) throw new UnauthorizedException("User account not found");
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
}
}
}

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// 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>.
/// 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>.
/// </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;
/// <summary>
/// Handles <see cref="RefreshTokenCommand"/> by validating the refresh token and issuing a new
/// access/refresh token pair.
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair.
/// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService)
@@ -14,7 +14,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
{
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
var result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken);
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
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;
/// <summary>
/// Validates <see cref="RefreshTokenCommand"/> instances before they are processed.
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{
/// <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>
public RefreshTokenValidator()
{
@@ -16,4 +16,4 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
.NotEmpty()
.WithMessage("Refresh token is required");
}
}
}

View File

@@ -4,14 +4,20 @@ using MediatR;
namespace Features.Auth.Commands.RegisterUser;
/// <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>
/// <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="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="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(
string Username,
string FirstName,
@@ -19,4 +25,4 @@ public record RegisterUserCommand(
string Email,
DateTime DateOfBirth,
string Password
) : IRequest<RegistrationPayload>;
) : IRequest<RegistrationPayload>;

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
@@ -9,9 +10,9 @@ using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Handles <see cref="RegisterUserCommand"/>: validates uniqueness, hashes the password, persists the
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails.
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails.
/// </summary>
/// <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>
@@ -25,15 +26,15 @@ public class RegisterUserHandler(
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
{
/// <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>
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
{
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.FirstName,
request.LastName,
@@ -42,16 +43,15 @@ public class RegisterUserHandler(
hashed
);
var accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
string accessToken = tokenService.GenerateAccessToken(createdUser);
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
{
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false);
}
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty,
false);
var emailSent = false;
bool emailSent = false;
try
{
await mediator.Send(
@@ -67,20 +67,19 @@ public class RegisterUserHandler(
// ignored
}
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent);
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken,
emailSent);
}
/// <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>
private async Task ValidateUserDoesNotExist(string username, string email)
{
var existingUsername = await authRepo.GetUserByUsernameAsync(username);
var existingEmail = await authRepo.GetUserByEmailAsync(email);
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null)
{
throw new ConflictException("Username or email already exists");
}
}
}
}

View File

@@ -3,13 +3,13 @@ using FluentValidation;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Validates <see cref="RegisterUserCommand"/> instances before they are processed.
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{
/// <summary>
/// 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.
/// 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.
/// </summary>
public RegisterUserValidator()
{
@@ -65,4 +65,4 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
"Password must contain at least one special character"
);
}
}
}

View File

@@ -3,7 +3,7 @@ using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <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>
/// <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.Services;
using MediatR;
@@ -6,15 +7,15 @@ using Shared.Application.Emails;
namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary>
/// Handles <see cref="ResendConfirmationEmailCommand"/> by generating a fresh confirmation token and
/// sending it via Features.Emails.
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails.
/// </summary>
/// <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="mediator">Used to send the cross-slice command that triggers the email.</param>
/// <remarks>
/// 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.
/// 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.
/// </remarks>
public class ResendConfirmationEmailHandler(
IAuthRepository authRepository,
@@ -24,21 +25,15 @@ public class ResendConfirmationEmailHandler(
{
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
{
var user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null)
{
return; // Silent return to prevent user enumeration
}
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null) return; // Silent return to prevent user enumeration
if (await authRepository.IsUserVerifiedAsync(request.UserId))
{
return; // Already confirmed, no-op
}
if (await authRepository.IsUserVerifiedAsync(request.UserId)) return; // Already confirmed, no-op
var confirmationToken = tokenService.GenerateConfirmationToken(user);
string confirmationToken = tokenService.GenerateConfirmationToken(user);
await mediator.Send(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken
);
}
}
}

View File

@@ -9,121 +9,120 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
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>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// Registers a new user account.
/// </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.
/// 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="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
/// <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
)
{
/// <summary>
/// Registers a new user account.
/// </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
)
RegistrationPayload payload = await mediator.Send(command);
return Created("/", new ResponseBody<RegistrationPayload>
{
var payload = await mediator.Send(command);
return Created("/", new ResponseBody<RegistrationPayload>
{
Message = "User registered successfully.",
Payload = payload,
});
}
/// <summary>
/// Authenticates a user with a username and password.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <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>
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
{
var payload = await mediator.Send(query);
return Ok(new ResponseBody<LoginPayload>
{
Message = "Logged in successfully.",
Payload = payload,
});
}
/// <summary>
/// Confirms a user account using a confirmation token.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
/// user's ID and the confirmation timestamp.
/// </remarks>
/// <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>
[HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
{
var payload = await mediator.Send(new ConfirmUserCommand(token));
return Ok(new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
Payload = payload,
});
}
/// <summary>
/// Resends the account confirmation email for the specified user.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks>
/// <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>
[HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{
await mediator.Send(new ResendConfirmationEmailCommand(userId));
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
}
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <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>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
[FromBody] RefreshTokenCommand command
)
{
var payload = await mediator.Send(command);
return Ok(new ResponseBody<LoginPayload>
{
Message = "Token refreshed successfully.",
Payload = payload,
});
}
Message = "User registered successfully.",
Payload = payload
});
}
}
/// <summary>
/// Authenticates a user with a username and password.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <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>
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
{
LoginPayload payload = await mediator.Send(query);
return Ok(new ResponseBody<LoginPayload>
{
Message = "Logged in successfully.",
Payload = payload
});
}
/// <summary>
/// Confirms a user account using a confirmation token.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
/// user's ID and the confirmation timestamp.
/// </remarks>
/// <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>
[HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
{
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
return Ok(new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
Payload = payload
});
}
/// <summary>
/// Resends the account confirmation email for the specified user.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks>
/// <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>
[HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{
await mediator.Send(new ResendConfirmationEmailCommand(userId));
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
}
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <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>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
[FromBody] RefreshTokenCommand command
)
{
LoginPayload payload = await mediator.Send(command);
return Ok(new ResponseBody<LoginPayload>
{
Message = "Token refreshed successfully.",
Payload = payload
});
}
}

View File

@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Auth.DependencyInjection;
/// <summary>
/// Registers the services owned by the Auth feature slice.
/// Registers the services owned by the Auth feature slice.
/// </summary>
public static class FeaturesAuthServiceCollectionExtensions
{
@@ -17,4 +17,4 @@ public static class FeaturesAuthServiceCollectionExtensions
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
return services;
}
}
}

View File

@@ -1,7 +1,7 @@
namespace Features.Auth.Dtos;
/// <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>
/// <param name="UserAccountId">The unique identifier 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>
/// Payload returned to the client after a successful registration.
/// Payload returned to the client after a successful registration.
/// </summary>
/// <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>
@@ -31,8 +31,8 @@ public record RegistrationPayload(
);
/// <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>
/// <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>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

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

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
@@ -8,10 +9,13 @@ using MediatR;
namespace Features.Auth.Queries.Login;
/// <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>
/// <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>
public class LoginHandler(
IAuthRepository authRepo,
@@ -20,26 +24,26 @@ public class LoginHandler(
) : IRequestHandler<LoginQuery, LoginPayload>
{
/// <exception cref="UnauthorizedException">
/// 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.
/// 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.
/// </exception>
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
{
var user =
UserAccount user =
await authRepo.GetUserByUsernameAsync(request.Username)
?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords
var activeCred =
UserCredential activeCred =
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password.");
var accessToken = tokenService.GenerateAccessToken(user);
var refreshToken = tokenService.GenerateRefreshToken(user);
string accessToken = tokenService.GenerateAccessToken(user);
string refreshToken = tokenService.GenerateRefreshToken(user);
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
}
}
}

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Queries.Login;
/// <summary>
/// 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>.
/// 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>.
/// </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;
/// <summary>
/// Validates <see cref="LoginQuery"/> instances before they are processed.
/// Validates <see cref="LoginQuery" /> instances before they are processed.
/// </summary>
public class LoginValidator : AbstractValidator<LoginQuery>
{
/// <summary>
/// Configures validation rules requiring both <see cref="LoginQuery.Username"/> and
/// <see cref="LoginQuery.Password"/> to be non-empty.
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
/// <see cref="LoginQuery.Password" /> to be non-empty.
/// </summary>
public LoginValidator()
{
@@ -17,4 +17,4 @@ public class LoginValidator : AbstractValidator<LoginQuery>
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
}
}
}

View File

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

View File

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

View File

@@ -4,28 +4,30 @@ using Domain.Entities;
namespace Features.Auth.Services;
/// <summary>
/// Identifies the kind of token being generated or validated.
/// Identifies the kind of token being generated or validated.
/// </summary>
public enum TokenType
{
/// <summary>A short-lived token used to authorize API requests.</summary>
AccessToken,
/// <summary>A long-lived token used to obtain new access tokens.</summary>
RefreshToken,
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
ConfirmationToken,
ConfirmationToken
}
/// <summary>
/// Represents the result of successfully validating a token.
/// Represents the result of successfully validating a token.
/// </summary>
/// <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="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);
/// <summary>
/// Represents the result of refreshing a user's session.
/// Represents the result of refreshing a user's session.
/// </summary>
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
/// <param name="RefreshToken">The newly issued refresh token.</param>
@@ -37,77 +39,79 @@ public record RefreshTokenResult(
);
/// <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>
public static class TokenServiceExpirationHours
{
/// <summary>The expiration window, in hours, for access tokens.</summary>
public const double AccessTokenHours = 1;
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
public const double RefreshTokenHours = 504; // 21 days
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
public const double ConfirmationTokenHours = 0.5; // 30 minutes
}
/// <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>
public interface ITokenService
{
/// <summary>
/// Generates a new access token for the given user.
/// Generates a new access token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
string GenerateAccessToken(UserAccount user);
/// <summary>
/// Generates a new refresh token for the given user.
/// Generates a new refresh token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
string GenerateRefreshToken(UserAccount user);
/// <summary>
/// Generates a new confirmation token for the given user.
/// Generates a new confirmation token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
string GenerateConfirmationToken(UserAccount user);
/// <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>
/// <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>
/// <returns>The signed token string corresponding to the requested token type.</returns>
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
/// <summary>
/// Validates an access token.
/// Validates an access token.
/// </summary>
/// <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);
/// <summary>
/// Validates a refresh token.
/// Validates a refresh token.
/// </summary>
/// <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);
/// <summary>
/// Validates a confirmation token.
/// Validates a confirmation token.
/// </summary>
/// <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);
/// <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>
/// <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);
}
}

View File

@@ -1,5 +1,5 @@
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Repository;
@@ -8,27 +8,26 @@ using Infrastructure.Jwt;
namespace Features.Auth.Services;
/// <summary>
/// Default implementation of <see cref="ITokenService"/> that generates and validates JWTs
/// for access, refresh, and confirmation flows using secrets read from environment variables.
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
/// for access, refresh, and confirmation flows using secrets read from environment variables.
/// </summary>
public class TokenService : ITokenService
{
private readonly ITokenInfrastructure _tokenInfrastructure;
private readonly IAuthRepository _authRepository;
private readonly string _accessTokenSecret;
private readonly string _refreshTokenSecret;
private readonly IAuthRepository _authRepository;
private readonly string _confirmationTokenSecret;
private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure;
/// <summary>
/// Initializes a new instance of <see cref="TokenService"/>, loading the access, refresh, and
/// confirmation token signing secrets from environment variables.
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
/// confirmation token signing secrets from environment variables.
/// </summary>
/// <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>
/// <exception cref="InvalidOperationException">
/// 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.
/// 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.
/// </exception>
public TokenService(
ITokenInfrastructure tokenInfrastructure,
@@ -39,139 +38,170 @@ public class TokenService : ITokenService
_authRepository = authRepository;
_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")
?? 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")
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set");
}
/// <summary>
/// Generates an access token for the given user, signed with the access token secret and
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours"/> hours.
/// Generates an access token for the given user, signed with the access token secret and
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
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);
}
/// <summary>
/// Generates a refresh token for the given user, signed with the refresh token secret and
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours"/> hours.
/// Generates a refresh token for the given user, signed with the refresh token secret and
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
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);
}
/// <summary>
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours"/> hours.
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
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);
}
/// <summary>
/// Generates a token of the kind specified by <typeparamref name="T"/>, dispatching to
/// <see cref="GenerateAccessToken"/>, <see cref="GenerateRefreshToken"/>, or
/// <see cref="GenerateConfirmationToken"/> based on the corresponding <see cref="TokenType"/> value.
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
/// </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>
/// <returns>The signed token string corresponding to the requested token type.</returns>
/// <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>
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
{
if (typeof(T) != typeof(TokenType))
throw new InvalidOperationException("Invalid token type");
var tokenTypeName = typeof(T).Name;
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
string tokenTypeName = typeof(T).Name;
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out object? parsed))
throw new InvalidOperationException("Invalid token type");
var tokenType = (TokenType)parsed;
TokenType tokenType = (TokenType)parsed;
return tokenType switch
{
TokenType.AccessToken => GenerateAccessToken(user),
TokenType.RefreshToken => GenerateRefreshToken(user),
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
_ => throw new InvalidOperationException("Invalid token type"),
_ => throw new InvalidOperationException("Invalid token type")
};
}
/// <summary>
/// Validates an access token against the access token secret.
/// Validates an access token against the access token secret.
/// </summary>
/// <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">
/// 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>
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
{
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
}
/// <summary>
/// Validates a refresh token against the refresh token secret.
/// Validates a refresh token against the refresh token secret.
/// </summary>
/// <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">
/// 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>
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
{
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
}
/// <summary>
/// Validates a confirmation token against the confirmation token secret.
/// Validates a confirmation token against the confirmation token secret.
/// </summary>
/// <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">
/// 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>
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
{
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
}
/// <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.
/// 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)
{
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>
/// <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="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">
/// 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).
/// 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).
/// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
{
try
{
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
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");
return new ValidatedToken(userId, usernameClaim, principal);
@@ -185,26 +215,4 @@ public class TokenService : ITokenService
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 FluentAssertions;
using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Commands;
public class CreateBreweryHandlerTests
{
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly CreateBreweryHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public CreateBreweryHandlerTests()
{
_handler = new CreateBreweryHandler(_repoMock.Object);
}
private static CreateBreweryLocation ValidLocation() =>
new(
CityId: Guid.NewGuid(),
AddressLine1: "123 Main St",
AddressLine2: null,
PostalCode: "12345",
Coordinates: null
private static CreateBreweryLocation ValidLocation()
{
return new CreateBreweryLocation(
Guid.NewGuid(),
"123 Main St",
null,
"12345",
null
);
}
[Fact]
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
{
var command = new CreateBreweryCommand(
PostedById: Guid.NewGuid(),
BreweryName: "MyBrew",
Description: "Desc",
Location: ValidLocation()
CreateBreweryCommand command = new(
Guid.NewGuid(),
"MyBrew",
"Desc",
ValidLocation()
);
BreweryPost? persisted = null;
@@ -41,9 +44,9 @@ public class CreateBreweryHandlerTests
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
var before = DateTime.UtcNow;
var result = await _handler.Handle(command, CancellationToken.None);
var after = DateTime.UtcNow;
DateTime before = DateTime.UtcNow;
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
DateTime after = DateTime.UtcNow;
persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);
@@ -65,4 +68,4 @@ public class CreateBreweryHandlerTests
result.BreweryName.Should().Be("MyBrew");
result.Location.Should().NotBeNull();
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetAllBreweries;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Queries;
public class GetAllBreweriesHandlerTests
{
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly GetAllBreweriesHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public GetAllBreweriesHandlerTests()
{
@@ -22,7 +23,7 @@ public class GetAllBreweriesHandlerTests
_repoMock.Setup(r => r.GetAllAsync(10, 5))
.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();
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
@@ -31,16 +32,16 @@ public class GetAllBreweriesHandlerTests
[Fact]
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 = "B" },
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }
};
_repoMock.Setup(r => r.GetAllAsync(null, null))
.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));
}
}
}

View File

@@ -1,15 +1,16 @@
using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetBreweryById;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Queries;
public class GetBreweryByIdHandlerTests
{
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly GetBreweryByIdHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public GetBreweryByIdHandlerTests()
{
@@ -19,11 +20,11 @@ public class GetBreweryByIdHandlerTests
[Fact]
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))
.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!.BreweryPostId.Should().Be(brewery.BreweryPostId);
@@ -32,12 +33,12 @@ public class GetBreweryByIdHandlerTests
[Fact]
public async Task Handle_ReturnsNull_WhenNotFound()
{
var id = Guid.NewGuid();
Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id))
.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();
}
}
}

View File

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

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
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;
/// <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>
public record CreateBreweryLocation(
Guid CityId,
@@ -15,11 +15,11 @@ public record CreateBreweryLocation(
);
/// <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>
public record CreateBreweryCommand(
Guid PostedById,
string BreweryName,
string Description,
CreateBreweryLocation Location
) : IRequest<BreweryDto>;
) : IRequest<BreweryDto>;

View File

@@ -6,21 +6,21 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery;
/// <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>
/// <param name="repository">Repository used to persist the new brewery post.</param>
public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto>
{
/// <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>
/// <param name="request">The details of the brewery post to create.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The newly created brewery post.</returns>
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
{
var entity = new BreweryPost
BreweryPost entity = new()
{
BreweryPostId = Guid.NewGuid(),
PostedById = request.PostedById,
@@ -34,11 +34,11 @@ public class CreateBreweryHandler(IBreweryRepository repository)
AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates,
},
Coordinates = request.Location.Coordinates
}
};
await repository.CreateAsync(entity);
return entity.ToDto();
}
}
}

View File

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

View File

@@ -3,6 +3,6 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>
/// Deletes a brewery post by its unique identifier.
/// Deletes a brewery post by its unique identifier.
/// </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