diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index 6c2b5ae..6e9825d 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -9,32 +9,32 @@ - + - + - + - - - - - - - - - - + + + + + + + + + + diff --git a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs index 90c5756..3833c64 100644 --- a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs +++ b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -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; /// -/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens -/// supplied in the Authorization header against the JWT authentication scheme. +/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens +/// supplied in the Authorization header against the JWT authentication scheme. /// /// The monitored options for the JWT authentication scheme. -/// The logger factory used by the base . -/// The URL encoder used by the base . +/// The logger factory used by the base . +/// The URL encoder used by the base . /// The infrastructure service used to validate JWT access tokens. /// The application configuration, used as a fallback source for the JWT secret key. public class JwtAuthenticationHandler( @@ -26,66 +26,57 @@ public class JwtAuthenticationHandler( ) : AuthenticationHandler(options, logger, encoder) { /// - /// 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. /// /// - /// The signing secret is resolved first from the ACCESS_TOKEN_SECRET environment variable, falling - /// back to the Jwt:SecretKey configuration value, to stay consistent with the secret source used - /// when tokens are issued. Authentication fails if the secret is not configured, the - /// Authorization header is missing, the header does not use the Bearer scheme, or the - /// token fails validation. + /// The signing secret is resolved first from the ACCESS_TOKEN_SECRET environment variable, falling + /// back to the Jwt:SecretKey configuration value, to stay consistent with the secret source used + /// when tokens are issued. Authentication fails if the secret is not configured, the + /// Authorization header is missing, the header does not use the Bearer scheme, or the + /// token fails validation. /// /// - /// A successful containing the validated - /// on success, or a failure result describing why authentication could not be completed. + /// A successful containing the validated + /// + /// on success, or a failure result describing why authentication could not be completed. /// protected override async Task 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( } /// - /// 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. /// /// The authentication properties associated with the challenge. /// A task that completes once the response body has been written. @@ -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); } } /// -/// Options for the JWT authentication scheme handled by . +/// Options for the JWT authentication scheme handled by . /// -/// No additional options are defined beyond those provided by . -public class JwtAuthenticationOptions : AuthenticationSchemeOptions { } +/// No additional options are defined beyond those provided by . +public class JwtAuthenticationOptions : AuthenticationSchemeOptions +{ +} \ No newline at end of file diff --git a/web/backend/API/API.Core/Controllers/NotFoundController.cs b/web/backend/API/API.Core/Controllers/NotFoundController.cs index ca6b1fa..1f3a31e 100644 --- a/web/backend/API/API.Core/Controllers/NotFoundController.cs +++ b/web/backend/API/API.Core/Controllers/NotFoundController.cs @@ -1,27 +1,27 @@ using Microsoft.AspNetCore.Mvc; -namespace API.Core.Controllers +namespace API.Core.Controllers; + +/// +/// Handles requests that do not match any other route, used as the application's fallback controller. +/// +/// +/// Excluded from API explorer/Swagger output via [ApiExplorerSettings(IgnoreApi = true)]. +/// Wired up as the fallback target via app.MapFallbackToController("Handle404", "NotFound") in +/// Program.cs. +/// +[ApiController] +[ApiExplorerSettings(IgnoreApi = true)] +[Route("error")] // required +public class NotFoundController : ControllerBase { /// - /// 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. /// - /// - /// Excluded from API explorer/Swagger output via [ApiExplorerSettings(IgnoreApi = true)]. - /// Wired up as the fallback target via app.MapFallbackToController("Handle404", "NotFound") in Program.cs. - /// - [ApiController] - [ApiExplorerSettings(IgnoreApi = true)] - [Route("error")] // required - public class NotFoundController : ControllerBase + /// A 404 Not Found result with a JSON body containing a "Route not found." message. + [HttpGet("404")] //required + public IActionResult Handle404() { - /// - /// Returns a generic 404 response for any request that did not match a defined route. - /// - /// A 404 Not Found result with a JSON body containing a "Route not found." message. - [HttpGet("404")] //required - public IActionResult Handle404() - { - return NotFound(new { message = "Route not found." }); - } + return NotFound(new { message = "Route not found." }); } -} +} \ No newline at end of file diff --git a/web/backend/API/API.Core/Controllers/ProtectedController.cs b/web/backend/API/API.Core/Controllers/ProtectedController.cs index 4f1be4f..4efa192 100644 --- a/web/backend/API/API.Core/Controllers/ProtectedController.cs +++ b/web/backend/API/API.Core/Controllers/ProtectedController.cs @@ -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; /// -/// Sample controller demonstrating an endpoint that requires JWT authentication. +/// Sample controller demonstrating an endpoint that requires JWT authentication. /// [ApiController] [Route("api/[controller]")] @@ -14,25 +14,25 @@ namespace API.Core.Controllers; public class ProtectedController : ControllerBase { /// - /// 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. /// /// - /// A 200 OK result wrapping a whose payload contains the - /// caller's userId (from ) and username - /// (from ), either of which may be null if not present in the token. + /// A 200 OK result wrapping a whose payload contains the + /// caller's userId (from ) and username + /// (from ), either of which may be null if not present in the token. /// [HttpGet] public ActionResult> 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 { Message = "Protected endpoint accessed successfully", - Payload = new { userId, username }, + Payload = new { userId, username } } ); } -} +} \ No newline at end of file diff --git a/web/backend/API/API.Core/GlobalException.cs b/web/backend/API/API.Core/GlobalException.cs index 4af77ad..78e8325 100644 --- a/web/backend/API/API.Core/GlobalException.cs +++ b/web/backend/API/API.Core/GlobalException.cs @@ -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; /// -/// 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. /// /// Logger used to record unhandled exceptions before they are translated into a response. public class GlobalExceptionFilter(ILogger logger) : IExceptionFilter { /// - /// Logs the exception and sets to an appropriate error response, - /// marking the exception as handled. + /// Logs the exception and sets to an appropriate error response, + /// marking the exception as handled. /// /// - /// Maps exception types to responses as follows: - /// - /// 400 Bad Request with per-property validation error messages. - /// 409 Conflict with a message. - /// 404 Not Found with a message. - /// 401 Unauthorized with a message. - /// 403 Forbidden with a message. - /// 503 Service Unavailable with a generic database error message. - /// 400 Bad Request with a message. - /// Any other exception → 500 Internal Server Error with a generic error message. - /// + /// Maps exception types to responses as follows: + /// + /// + /// + /// 400 Bad Request with per-property + /// validation error messages. + /// + /// + /// + /// + /// 409 Conflict with a + /// message. + /// + /// + /// + /// + /// 404 Not Found with a + /// message. + /// + /// + /// + /// + /// 401 Unauthorized with a + /// message. + /// + /// + /// + /// + /// 403 Forbidden with a + /// message. + /// + /// + /// + /// + /// 503 Service Unavailable with a generic database error + /// message. + /// + /// + /// + /// + /// 400 Bad Request with a + /// message. + /// + /// + /// + /// Any other exception → 500 Internal Server Error with a generic error message. + /// + /// /// - /// The context for the unhandled exception, including the exception itself and the result to populate. + /// + /// The context for the unhandled exception, including the exception itself and the result to + /// populate. + /// 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 errors = fluentValidationException .Errors.GroupBy(e => e.PropertyName) .ToDictionary( g => g.Key, @@ -60,7 +100,7 @@ public class GlobalExceptionFilter(ILogger logger) new ResponseBody { Message = ex.Message } ) { - StatusCode = 409, + StatusCode = 409 }; context.ExceptionHandled = true; break; @@ -70,7 +110,7 @@ public class GlobalExceptionFilter(ILogger logger) new ResponseBody { Message = ex.Message } ) { - StatusCode = 404, + StatusCode = 404 }; context.ExceptionHandled = true; break; @@ -80,7 +120,7 @@ public class GlobalExceptionFilter(ILogger logger) new ResponseBody { Message = ex.Message } ) { - StatusCode = 401, + StatusCode = 401 }; context.ExceptionHandled = true; break; @@ -90,7 +130,7 @@ public class GlobalExceptionFilter(ILogger logger) new ResponseBody { Message = ex.Message } ) { - StatusCode = 403, + StatusCode = 403 }; context.ExceptionHandled = true; break; @@ -100,7 +140,7 @@ public class GlobalExceptionFilter(ILogger logger) new ResponseBody { Message = "A database error occurred." } ) { - StatusCode = 503, + StatusCode = 503 }; context.ExceptionHandled = true; break; @@ -110,7 +150,7 @@ public class GlobalExceptionFilter(ILogger logger) new ResponseBody { Message = ex.Message } ) { - StatusCode = 400, + StatusCode = 400 }; context.ExceptionHandled = true; break; @@ -119,14 +159,14 @@ public class GlobalExceptionFilter(ILogger 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; } } -} +} \ No newline at end of file diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index fabaa40..1bc9b48 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -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(); -}) +builder.Services.AddControllers(options => { options.Filters.Add(); }) .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 lifetime = app.Services.GetRequiredService(); lifetime.ApplicationStopping.Register(() => { app.Logger.LogInformation("Application is shutting down gracefully..."); }); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/web/backend/API/API.Specs/API.Specs.csproj b/web/backend/API/API.Specs/API.Specs.csproj index e986b35..8aeff0a 100644 --- a/web/backend/API/API.Specs/API.Specs.csproj +++ b/web/backend/API/API.Specs/API.Specs.csproj @@ -8,40 +8,40 @@ - - - - - + + + + + - - + + - + - + - - - + + + diff --git a/web/backend/API/API.Specs/Features/AccessTokenValidation.feature b/web/backend/API/API.Specs/Features/AccessTokenValidation.feature index e52e1bb..1a20e66 100644 --- a/web/backend/API/API.Specs/Features/AccessTokenValidation.feature +++ b/web/backend/API/API.Specs/Features/AccessTokenValidation.feature @@ -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 \ No newline at end of file diff --git a/web/backend/API/API.Specs/Features/Confirmation.feature b/web/backend/API/API.Specs/Features/Confirmation.feature index 0657aab..9cd8ff0 100644 --- a/web/backend/API/API.Specs/Features/Confirmation.feature +++ b/web/backend/API/API.Specs/Features/Confirmation.feature @@ -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 \ No newline at end of file diff --git a/web/backend/API/API.Specs/Features/Login.feature b/web/backend/API/API.Specs/Features/Login.feature index 0adad13..4fc5826 100644 --- a/web/backend/API/API.Specs/Features/Login.feature +++ b/web/backend/API/API.Specs/Features/Login.feature @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/web/backend/API/API.Specs/Features/NotFound.feature b/web/backend/API/API.Specs/Features/NotFound.feature index fcfc041..1e5e1cb 100644 --- a/web/backend/API/API.Specs/Features/NotFound.feature +++ b/web/backend/API/API.Specs/Features/NotFound.feature @@ -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." \ No newline at end of file +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." \ No newline at end of file diff --git a/web/backend/API/API.Specs/Features/Registration.feature b/web/backend/API/API.Specs/Features/Registration.feature index 0ff53c3..edc3367 100644 --- a/web/backend/API/API.Specs/Features/Registration.feature +++ b/web/backend/API/API.Specs/Features/Registration.feature @@ -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 \ No newline at end of file diff --git a/web/backend/API/API.Specs/Features/ResendConfirmation.feature b/web/backend/API/API.Specs/Features/ResendConfirmation.feature index 9cf414c..9eb407f 100644 --- a/web/backend/API/API.Specs/Features/ResendConfirmation.feature +++ b/web/backend/API/API.Specs/Features/ResendConfirmation.feature @@ -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 \ No newline at end of file diff --git a/web/backend/API/API.Specs/Features/TokenRefresh.feature b/web/backend/API/API.Specs/Features/TokenRefresh.feature index c63bc55..fa0fa84 100644 --- a/web/backend/API/API.Specs/Features/TokenRefresh.feature +++ b/web/backend/API/API.Specs/Features/TokenRefresh.feature @@ -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 \ No newline at end of file diff --git a/web/backend/API/API.Specs/Mocks/MockEmailDispatcher.cs b/web/backend/API/API.Specs/Mocks/MockEmailDispatcher.cs index 643aaa6..e3277d2 100644 --- a/web/backend/API/API.Specs/Mocks/MockEmailDispatcher.cs +++ b/web/backend/API/API.Specs/Mocks/MockEmailDispatcher.cs @@ -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; } } -} +} \ No newline at end of file diff --git a/web/backend/API/API.Specs/Mocks/MockEmailProvider.cs b/web/backend/API/API.Specs/Mocks/MockEmailProvider.cs index 0c8b113..ba5cf65 100644 --- a/web/backend/API/API.Specs/Mocks/MockEmailProvider.cs +++ b/web/backend/API/API.Specs/Mocks/MockEmailProvider.cs @@ -3,8 +3,8 @@ using Infrastructure.Email; namespace API.Specs.Mocks; /// -/// 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. /// 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; } } -} +} \ No newline at end of file diff --git a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs index 44273f4..65d5940 100644 --- a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs +++ b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs @@ -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(ClientKey, out var client)) - { - return client; - } + if (scenario.TryGetValue(ClientKey, out HttpClient? client)) return client; - var factory = scenario.TryGetValue( + TestApiFactory? factory = scenario.TryGetValue( 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(ResponseKey, out var response) + .TryGetValue(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(ResponseKey, out var response) + .TryGetValue(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(ResponseKey, out var response) + .TryGetValue(ResponseKey, out HttpResponseMessage? response) .Should() .BeTrue(); scenario - .TryGetValue(ResponseBodyKey, out var responseBody) + .TryGetValue(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(ResponseKey, out var response) + .TryGetValue(ResponseKey, out HttpResponseMessage? response) .Should() .BeTrue(); scenario - .TryGetValue(ResponseBodyKey, out var responseBody) + .TryGetValue(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 ); } -} +} \ No newline at end of file diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index e80dcb1..489d42f 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -1,5 +1,5 @@ +using System.Text; using System.Text.Json; -using API.Specs; using FluentAssertions; using Infrastructure.Jwt; using Reqnroll; @@ -21,14 +21,11 @@ public class AuthSteps(ScenarioContext scenario) private HttpClient GetClient() { - if (scenario.TryGetValue(ClientKey, out var client)) - { - return client; - } + if (scenario.TryGetValue(ClientKey, out HttpClient? client)) return client; - var factory = scenario.TryGetValue( + TestApiFactory? factory = scenario.TryGetValue( FactoryKey, - out var f + out TestApiFactory? f ) ? f : new TestApiFactory(); @@ -42,9 +39,9 @@ public class AuthSteps(ScenarioContext scenario) private static string GetRequiredEnvVar(string name) { return Environment.GetEnvironmentVariable(name) - ?? throw new InvalidOperationException( - $"{name} environment variable is not set" - ); + ?? throw new InvalidOperationException( + $"{name} environment variable is not set" + ); } private static string GenerateJwtToken( @@ -54,7 +51,7 @@ public class AuthSteps(ScenarioContext scenario) DateTime expiry ) { - var infra = new JwtInfrastructure(); + JwtInfrastructure infra = new(); return infra.GenerateJwt(userId, username, expiry, secret); } @@ -69,12 +66,12 @@ public class AuthSteps(ScenarioContext scenario) private static string ParseRegisteredUsername(JsonElement root) { return root - .GetProperty("payload") - .GetProperty("username") - .GetString() - ?? throw new InvalidOperationException( - "username missing from registration payload" - ); + .GetProperty("payload") + .GetProperty("username") + .GetString() + ?? throw new InvalidOperationException( + "username missing from registration payload" + ); } private static string ParseTokenFromPayload( @@ -84,15 +81,13 @@ public class AuthSteps(ScenarioContext scenario) ) { if ( - payload.TryGetProperty(camelCaseName, out var tokenElem) + payload.TryGetProperty(camelCaseName, out JsonElement tokenElem) || payload.TryGetProperty(pascalCaseName, out tokenElem) ) - { return tokenElem.GetString() - ?? throw new InvalidOperationException( - $"{camelCaseName} is null" - ); - } + ?? throw new InvalidOperationException( + $"{camelCaseName} is null" + ); throw new InvalidOperationException( $"Could not find token field '{camelCaseName}' in payload" @@ -114,30 +109,30 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a login request with a username and password")] public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() { - var client = GetClient(); - var (username, password) = scenario.TryGetValue<( + HttpClient client = GetClient(); + (string username, string password) = scenario.TryGetValue<( string username, string password - )>(TestUserKey, out var user) + )>(TestUserKey, out (string username, string password) user) ? user : ("test.user", "password"); - var body = JsonSerializer.Serialize(new { username, password }); + string body = JsonSerializer.Serialize(new { username, password }); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/login" ) { Content = new StringContent( body, - 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; @@ -146,23 +141,23 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a login request with a missing username")] public async Task WhenISubmitALoginRequestWithAMissingUsername() { - var client = GetClient(); - var body = JsonSerializer.Serialize(new { password = "test" }); + HttpClient client = GetClient(); + string body = JsonSerializer.Serialize(new { password = "test" }); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/login" ) { Content = new StringContent( body, - 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; @@ -171,23 +166,23 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a login request with a missing password")] public async Task WhenISubmitALoginRequestWithAMissingPassword() { - var client = GetClient(); - var body = JsonSerializer.Serialize(new { username = "test" }); + HttpClient client = GetClient(); + string body = JsonSerializer.Serialize(new { username = "test" }); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/login" ) { Content = new StringContent( body, - 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; @@ -196,21 +191,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a login request with both username and password missing")] public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing() { - var client = GetClient(); - var requestMessage = new HttpRequestMessage( + HttpClient client = GetClient(); + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/login" ) { Content = new StringContent( "{}", - 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; @@ -220,28 +215,26 @@ public class AuthSteps(ScenarioContext scenario) public void ThenTheResponseJsonShouldHaveAnAccessToken() { scenario - .TryGetValue(ResponseKey, out var response) + .TryGetValue(ResponseKey, out HttpResponseMessage? response) .Should() .BeTrue(); scenario - .TryGetValue(ResponseBodyKey, out var responseBody) + .TryGetValue(ResponseBodyKey, out string? responseBody) .Should() .BeTrue(); - var doc = JsonDocument.Parse(responseBody!); - var root = doc.RootElement; + JsonDocument doc = JsonDocument.Parse(responseBody!); + JsonElement root = doc.RootElement; JsonElement tokenElem = default; - var hasToken = false; + bool hasToken = false; if ( - root.TryGetProperty("payload", out var payloadElem) + root.TryGetProperty("payload", out JsonElement payloadElem) && payloadElem.ValueKind == JsonValueKind.Object ) - { hasToken = payloadElem.TryGetProperty("accessToken", out tokenElem) || payloadElem.TryGetProperty("AccessToken", out tokenElem); - } hasToken .Should() @@ -249,29 +242,29 @@ public class AuthSteps(ScenarioContext scenario) "Expected an access token either at the root or inside 'payload'" ); - var token = tokenElem.GetString(); + string? token = tokenElem.GetString(); token.Should().NotBeNullOrEmpty(); } [When("I submit a login request using a GET request")] public async Task WhenISubmitALoginRequestUsingAgetRequest() { - var client = GetClient(); + HttpClient client = GetClient(); // testing GET - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/auth/login" ) { Content = new StringContent( "{}", - 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; @@ -280,33 +273,27 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a registration request with values:")] public async Task WhenISubmitARegistrationRequestWithValues(Table table) { - var client = GetClient(); - var row = table.Rows[0]; + HttpClient client = GetClient(); + DataTableRow? row = table.Rows[0]; - var username = row["Username"] ?? ""; - var firstName = row["FirstName"] ?? ""; - var lastName = row["LastName"] ?? ""; - var email = row["Email"] ?? ""; - var dateOfBirth = row["DateOfBirth"] ?? ""; + string username = row["Username"] ?? ""; + string firstName = row["FirstName"] ?? ""; + string lastName = row["LastName"] ?? ""; + string email = row["Email"] ?? ""; + string dateOfBirth = row["DateOfBirth"] ?? ""; - if (dateOfBirth == "{underage_date}") - { - dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd"); - } + if (dateOfBirth == "{underage_date}") dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd"); // Keep default registration fixture values unique across repeated runs. if (email == "newuser@example.com") { - var suffix = Guid.NewGuid().ToString("N")[..8]; + string suffix = Guid.NewGuid().ToString("N")[..8]; email = $"newuser-{suffix}@example.com"; - if (username == "newuser") - { - username = $"newuser-{suffix}"; - } + if (username == "newuser") username = $"newuser-{suffix}"; } - var password = row["Password"]; + string? password = row["Password"]; var registrationData = new { @@ -315,25 +302,25 @@ public class AuthSteps(ScenarioContext scenario) lastName, email, dateOfBirth, - password, + password }; - var body = JsonSerializer.Serialize(registrationData); + string body = JsonSerializer.Serialize(registrationData); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/register" ) { Content = new StringContent( body, - 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; @@ -342,21 +329,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a registration request using a GET request")] public async Task WhenISubmitARegistrationRequestUsingAGetRequest() { - var client = GetClient(); - var requestMessage = new HttpRequestMessage( + HttpClient client = GetClient(); + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/auth/register" ) { Content = new StringContent( "{}", - 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; @@ -365,8 +352,8 @@ public class AuthSteps(ScenarioContext scenario) [Given("I have registered a new account")] public async Task GivenIHaveRegisteredANewAccount() { - var client = GetClient(); - var suffix = Guid.NewGuid().ToString("N")[..8]; + HttpClient client = GetClient(); + string suffix = Guid.NewGuid().ToString("N")[..8]; var registrationData = new { username = $"newuser-{suffix}", @@ -374,29 +361,29 @@ public class AuthSteps(ScenarioContext scenario) lastName = "User", email = $"newuser-{suffix}@example.com", dateOfBirth = "1990-01-01", - password = "Password1!", + password = "Password1!" }; - var body = JsonSerializer.Serialize(registrationData); - var requestMessage = new HttpRequestMessage( + string body = JsonSerializer.Serialize(registrationData); + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/register" ) { Content = new StringContent( body, - 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; - using var doc = JsonDocument.Parse(responseBody); - var root = doc.RootElement; + using JsonDocument doc = JsonDocument.Parse(responseBody); + JsonElement root = doc.RootElement; scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root); scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root); } @@ -404,43 +391,39 @@ public class AuthSteps(ScenarioContext scenario) [Given("I am logged in")] public async Task GivenIAmLoggedIn() { - var client = GetClient(); + HttpClient client = GetClient(); var loginData = new { username = "test.user", password = "password" }; - var body = JsonSerializer.Serialize(loginData); + string body = JsonSerializer.Serialize(loginData); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/login" ) { Content = new StringContent( body, - 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(); - var doc = JsonDocument.Parse(responseBody); - var root = doc.RootElement; - if (root.TryGetProperty("payload", out var payloadElem)) + JsonDocument doc = JsonDocument.Parse(responseBody); + JsonElement root = doc.RootElement; + if (root.TryGetProperty("payload", out JsonElement payloadElem)) { if ( - payloadElem.TryGetProperty("accessToken", out var tokenElem) + payloadElem.TryGetProperty("accessToken", out JsonElement tokenElem) || payloadElem.TryGetProperty("AccessToken", out tokenElem) ) - { scenario["accessToken"] = tokenElem.GetString(); - } if ( - payloadElem.TryGetProperty("refreshToken", out var refreshElem) + payloadElem.TryGetProperty("refreshToken", out JsonElement refreshElem) || payloadElem.TryGetProperty("RefreshToken", out refreshElem) ) - { scenario["refreshToken"] = refreshElem.GetString(); - } } } @@ -460,21 +443,21 @@ public class AuthSteps(ScenarioContext scenario) [Given("I have a valid access token for my account")] public void GivenIHaveAValidAccessTokenForMyAccount() { - var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : throw new InvalidOperationException( "registered user ID not found in scenario" ); - var username = scenario.TryGetValue( + string? username = scenario.TryGetValue( RegisteredUsernameKey, - out var user + out string? user ) ? user : throw new InvalidOperationException( "registered username not found in scenario" ); - var secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET"); + string secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET"); scenario["accessToken"] = GenerateJwtToken( userId, username, @@ -486,21 +469,21 @@ public class AuthSteps(ScenarioContext scenario) [Given("I have a valid confirmation token for my account")] public void GivenIHaveAValidConfirmationTokenForMyAccount() { - var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : throw new InvalidOperationException( "registered user ID not found in scenario" ); - var username = scenario.TryGetValue( + string? username = scenario.TryGetValue( RegisteredUsernameKey, - out var user + out string? user ) ? user : throw new InvalidOperationException( "registered username not found in scenario" ); - var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); + string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); scenario["confirmationToken"] = GenerateJwtToken( userId, username, @@ -512,21 +495,21 @@ public class AuthSteps(ScenarioContext scenario) [Given("I have an expired confirmation token for my account")] public void GivenIHaveAnExpiredConfirmationTokenForMyAccount() { - var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : throw new InvalidOperationException( "registered user ID not found in scenario" ); - var username = scenario.TryGetValue( + string? username = scenario.TryGetValue( RegisteredUsernameKey, - out var user + out string? user ) ? user : throw new InvalidOperationException( "registered username not found in scenario" ); - var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); + string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); scenario["confirmationToken"] = GenerateJwtToken( userId, username, @@ -538,14 +521,14 @@ public class AuthSteps(ScenarioContext scenario) [Given("I have a confirmation token signed with the wrong secret")] public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret() { - var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : throw new InvalidOperationException( "registered user ID not found in scenario" ); - var username = scenario.TryGetValue( + string? username = scenario.TryGetValue( RegisteredUsernameKey, - out var user + out string? user ) ? user : throw new InvalidOperationException( @@ -567,21 +550,21 @@ public class AuthSteps(ScenarioContext scenario) )] public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken() { - var client = GetClient(); - var token = scenario.TryGetValue("accessToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("accessToken", out string? t) ? t : "invalid-token"; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/protected" ) { - Headers = { { "Authorization", $"Bearer {token}" } }, + Headers = { { "Authorization", $"Bearer {token}" } } }; - 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; } @@ -591,17 +574,17 @@ public class AuthSteps(ScenarioContext scenario) )] public async Task WhenISubmitARequestToAProtectedEndpointWithAnInvalidAccessToken() { - var client = GetClient(); - var requestMessage = new HttpRequestMessage( + HttpClient client = GetClient(); + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/protected" ) { - Headers = { { "Authorization", "Bearer invalid-token-format" } }, + Headers = { { "Authorization", "Bearer invalid-token-format" } } }; - 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; } @@ -609,23 +592,23 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request with the valid token")] public async Task WhenISubmitAConfirmationRequestWithTheValidToken() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : "valid-token"; - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -633,23 +616,23 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit the same confirmation request again")] public async Task WhenISubmitTheSameConfirmationRequestAgain() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : "valid-token"; - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -657,21 +640,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request with a malformed token")] public async Task WhenISubmitAConfirmationRequestWithAMalformedToken() { - var client = GetClient(); + HttpClient client = GetClient(); const string token = "malformed-token-not-jwt"; - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -679,35 +662,31 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a refresh token request with a valid refresh token")] public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken() { - var client = GetClient(); - if (scenario.TryGetValue("accessToken", out var oldAccessToken)) - { + HttpClient client = GetClient(); + if (scenario.TryGetValue("accessToken", out string? oldAccessToken)) scenario[PreviousAccessTokenKey] = oldAccessToken; - } - if (scenario.TryGetValue("refreshToken", out var oldRefreshToken)) - { + if (scenario.TryGetValue("refreshToken", out string? oldRefreshToken)) scenario[PreviousRefreshTokenKey] = oldRefreshToken; - } - var token = scenario.TryGetValue("refreshToken", out var t) + string? token = scenario.TryGetValue("refreshToken", out string? t) ? t : "valid-refresh-token"; - var body = JsonSerializer.Serialize(new { refreshToken = token }); + string body = JsonSerializer.Serialize(new { refreshToken = token }); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/refresh" ) { Content = new StringContent( body, - 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; } @@ -715,25 +694,25 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a refresh token request with an invalid refresh token")] public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken() { - var client = GetClient(); - var body = JsonSerializer.Serialize( + HttpClient client = GetClient(); + string body = JsonSerializer.Serialize( new { refreshToken = "invalid-refresh-token" } ); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/refresh" ) { Content = new StringContent( body, - 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; } @@ -741,26 +720,26 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a refresh token request with the expired refresh token")] public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken() { - var client = GetClient(); + HttpClient client = GetClient(); // Use an expired token - var body = JsonSerializer.Serialize( + string body = JsonSerializer.Serialize( new { refreshToken = "expired-refresh-token" } ); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/refresh" ) { Content = new StringContent( body, - 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; } @@ -768,23 +747,23 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a refresh token request with a missing refresh token")] public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken() { - var client = GetClient(); - var body = JsonSerializer.Serialize(new { }); + HttpClient client = GetClient(); + string body = JsonSerializer.Serialize(new { }); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, "/api/auth/refresh" ) { Content = new StringContent( body, - 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; } @@ -792,21 +771,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a refresh token request using a GET request")] public async Task WhenISubmitARefreshTokenRequestUsingAGETRequest() { - var client = GetClient(); - var requestMessage = new HttpRequestMessage( + HttpClient client = GetClient(); + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/auth/refresh" ) { Content = new StringContent( "{}", - 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; } @@ -815,14 +794,14 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a request to a protected endpoint without an access token")] public async Task WhenISubmitARequestToAProtectedEndpointWithoutAnAccessToken() { - var client = GetClient(); - var requestMessage = new HttpRequestMessage( + HttpClient client = GetClient(); + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/protected" ); - 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; } @@ -846,21 +825,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a request to a protected endpoint with the expired token")] public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken() { - var client = GetClient(); - var token = scenario.TryGetValue("accessToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("accessToken", out string? t) ? t : "expired-token"; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/protected" ) { - Headers = { { "Authorization", $"Bearer {token}" } }, + Headers = { { "Authorization", $"Bearer {token}" } } }; - 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; } @@ -868,21 +847,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a request to a protected endpoint with the tampered token")] public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken() { - var client = GetClient(); - var token = scenario.TryGetValue("accessToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("accessToken", out string? t) ? t : "tampered-token"; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/protected" ) { - Headers = { { "Authorization", $"Bearer {token}" } }, + Headers = { { "Authorization", $"Bearer {token}" } } }; - 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; } @@ -892,21 +871,21 @@ public class AuthSteps(ScenarioContext scenario) )] public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken() { - var client = GetClient(); - var token = scenario.TryGetValue("refreshToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("refreshToken", out string? t) ? t : "refresh-token"; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/protected" ) { - Headers = { { "Authorization", $"Bearer {token}" } }, + Headers = { { "Authorization", $"Bearer {token}" } } }; - 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; } @@ -920,23 +899,23 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request with the expired token")] public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : "expired-confirmation-token"; - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -944,23 +923,23 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request with the tampered token")] public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : "tampered-confirmation-token"; - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -968,17 +947,17 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request with a missing token")] public async Task WhenISubmitAConfirmationRequestWithAMissingToken() { - var client = GetClient(); - var accessToken = scenario.TryGetValue("accessToken", out var at) + HttpClient client = GetClient(); + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm"); + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/confirm"); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -986,18 +965,18 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request using an invalid HTTP method")] public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : "valid-confirmation-token"; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Get, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); - 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; } @@ -1007,21 +986,21 @@ public class AuthSteps(ScenarioContext scenario) )] public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : "confirmation-token"; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Get, "/api/protected" ) { - Headers = { { "Authorization", $"Bearer {token}" } }, + Headers = { { "Authorization", $"Bearer {token}" } } }; - 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; } @@ -1029,21 +1008,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request with an invalid token")] public async Task WhenISubmitAConfirmationRequestWithAnInvalidToken() { - var client = GetClient(); + HttpClient client = GetClient(); const string token = "invalid-confirmation-token"; - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -1051,18 +1030,18 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a confirmation request with the valid token without an access token")] public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : "valid-token"; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); - 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; } @@ -1071,13 +1050,13 @@ public class AuthSteps(ScenarioContext scenario) public void ThenTheResponseJsonShouldHaveANewAccessToken() { scenario - .TryGetValue(ResponseBodyKey, out var responseBody) + .TryGetValue(ResponseBodyKey, out string? responseBody) .Should() .BeTrue(); - using var doc = JsonDocument.Parse(responseBody!); - var payload = doc.RootElement.GetProperty("payload"); - var accessToken = ParseTokenFromPayload( + using JsonDocument doc = JsonDocument.Parse(responseBody!); + JsonElement payload = doc.RootElement.GetProperty("payload"); + string accessToken = ParseTokenFromPayload( payload, "accessToken", "AccessToken" @@ -1088,25 +1067,23 @@ public class AuthSteps(ScenarioContext scenario) if ( scenario.TryGetValue( PreviousAccessTokenKey, - out var previousAccessToken + out string? previousAccessToken ) ) - { accessToken.Should().NotBe(previousAccessToken); - } } [Then("the response JSON should have a new refresh token")] public void ThenTheResponseJsonShouldHaveANewRefreshToken() { scenario - .TryGetValue(ResponseBodyKey, out var responseBody) + .TryGetValue(ResponseBodyKey, out string? responseBody) .Should() .BeTrue(); - using var doc = JsonDocument.Parse(responseBody!); - var payload = doc.RootElement.GetProperty("payload"); - var refreshToken = ParseTokenFromPayload( + using JsonDocument doc = JsonDocument.Parse(responseBody!); + JsonElement payload = doc.RootElement.GetProperty("payload"); + string refreshToken = ParseTokenFromPayload( payload, "refreshToken", "RefreshToken" @@ -1117,56 +1094,54 @@ public class AuthSteps(ScenarioContext scenario) if ( scenario.TryGetValue( PreviousRefreshTokenKey, - out var previousRefreshToken + out string? previousRefreshToken ) ) - { refreshToken.Should().NotBe(previousRefreshToken); - } } [Given("I have confirmed my account")] public async Task GivenIHaveConfirmedMyAccount() { - var client = GetClient(); - var token = scenario.TryGetValue("confirmationToken", out var t) + HttpClient client = GetClient(); + string? token = scenario.TryGetValue("confirmationToken", out string? t) ? t : throw new InvalidOperationException("confirmation token not found"); - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm?token={Uri.EscapeDataString(token)}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - var response = await client.SendAsync(requestMessage); + HttpResponseMessage response = await client.SendAsync(requestMessage); response.EnsureSuccessStatusCode(); } [When("I submit a resend confirmation request for my account")] public async Task WhenISubmitAResendConfirmationRequestForMyAccount() { - var client = GetClient(); - var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + HttpClient client = GetClient(); + Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : throw new InvalidOperationException("registered user ID not found"); - var accessToken = scenario.TryGetValue("accessToken", out var at) + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm/resend?userId={userId}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -1174,21 +1149,21 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a resend confirmation request for a non-existent user")] public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser() { - var client = GetClient(); - var fakeUserId = Guid.NewGuid(); - var accessToken = scenario.TryGetValue("accessToken", out var at) + HttpClient client = GetClient(); + Guid fakeUserId = Guid.NewGuid(); + string? accessToken = scenario.TryGetValue("accessToken", out string? at) ? at : string.Empty; - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm/resend?userId={fakeUserId}" ); if (!string.IsNullOrEmpty(accessToken)) requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); - 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; } @@ -1196,19 +1171,19 @@ public class AuthSteps(ScenarioContext scenario) [When("I submit a resend confirmation request without an access token")] public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken() { - var client = GetClient(); - var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + HttpClient client = GetClient(); + Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : Guid.NewGuid(); - var requestMessage = new HttpRequestMessage( + HttpRequestMessage requestMessage = new( HttpMethod.Post, $"/api/auth/confirm/resend?userId={userId}" ); - 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; } -} +} \ No newline at end of file diff --git a/web/backend/API/API.Specs/TestApiFactory.cs b/web/backend/API/API.Specs/TestApiFactory.cs index 2b2046d..b01dc65 100644 --- a/web/backend/API/API.Specs/TestApiFactory.cs +++ b/web/backend/API/API.Specs/TestApiFactory.cs @@ -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 { - public class TestApiFactory : WebApplicationFactory + 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(); - services.AddScoped(); + // 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(); - }); - } + services.AddScoped(); + }); } -} +} \ No newline at end of file diff --git a/web/backend/Core.slnx b/web/backend/Core.slnx index c3a2f2d..7c93c7a 100644 --- a/web/backend/Core.slnx +++ b/web/backend/Core.slnx @@ -1,37 +1,37 @@ - - + + - - + + - - + + - + - + Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/> + - + Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/> + - - - - - - - - + + + + + + + + - - + + diff --git a/web/backend/Database/Database.Migrations/Database.Migrations.csproj b/web/backend/Database/Database.Migrations/Database.Migrations.csproj index ab0b5d7..fced2a1 100644 --- a/web/backend/Database/Database.Migrations/Database.Migrations.csproj +++ b/web/backend/Database/Database.Migrations/Database.Migrations.csproj @@ -1,23 +1,23 @@  - - Exe - net10.0 - enable - enable - Database.Migrations - Linux - + + Exe + net10.0 + enable + enable + Database.Migrations + Linux + - - - - - - - - - - .dockerignore - - + + + + + + + + + + .dockerignore + + diff --git a/web/backend/Database/Database.Migrations/Program.cs b/web/backend/Database/Database.Migrations/Program.cs index ed48d08..b4afe9c 100644 --- a/web/backend/Database/Database.Migrations/Program.cs +++ b/web/backend/Database/Database.Migrations/Program.cs @@ -1,50 +1,57 @@ using System.Data; using System.Reflection; using DbUp; +using DbUp.Engine; using Microsoft.Data.SqlClient; namespace Database.Migrations; /// -/// 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. /// public static class Program { + /// The connection string for the target application database (DB_NAME). + private static readonly string connectionString = BuildConnectionString(); + + /// The connection string for the master database, used for create/drop operations. + private static readonly string masterConnectionString = BuildConnectionString("master"); + /// - /// Builds a SQL Server connection string from the DB_SERVER, DB_NAME, - /// DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE - /// environment variables. + /// Builds a SQL Server connection string from the DB_SERVER, DB_NAME, + /// DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE + /// environment variables. /// /// - /// The database (initial catalog) to connect to. When null, falls back to the - /// DB_NAME environment variable. + /// The database (initial catalog) to connect to. When null, falls back to the + /// DB_NAME environment variable. /// /// A fully built SQL Server connection string. /// - /// Thrown when DB_SERVER, DB_USER, DB_PASSWORD, or (when - /// is null) DB_NAME is not set. + /// Thrown when DB_SERVER, DB_USER, DB_PASSWORD, or (when + /// is null) DB_NAME is not set. /// 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; } - /// The connection string for the target application database (DB_NAME). - private static readonly string connectionString = BuildConnectionString(); - - /// The connection string for the master database, used for create/drop operations. - private static readonly string masterConnectionString = BuildConnectionString("master"); - /// - /// 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. /// /// true if the upgrade completed successfully; otherwise false. 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; } /// - /// Drops the Biergarten database if it exists, first forcing it into single-user - /// mode with rollback to terminate any existing connections. + /// Drops the Biergarten database if it exists, first forcing it into single-user + /// mode with rollback to terminate any existing connections. /// /// - /// true if the database was dropped (or did not exist) without error; false - /// if an error occurred while connecting or dropping the database. + /// true if the database was dropped (or did not exist) without error; false + /// if an error occurred while connecting or dropping the database. /// 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; } /// - /// Creates the Biergarten 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 Biergarten database on the master connection if it does not + /// already exist. Errors encountered while creating the database are logged but do + /// not stop execution. /// /// true always; this method does not propagate database errors as a failure result. 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; } /// - /// Migration runner entry point. Optionally clears the existing database when the - /// CLEAR_DATABASE environment variable is set to "true", ensures the - /// database exists, then deploys all pending migrations. + /// Migration runner entry point. Optionally clears the existing database when the + /// CLEAR_DATABASE environment variable is set to "true", ensures the + /// database exists, then deploys all pending migrations. /// /// Command-line arguments (unused). /// 0 if migrations completed successfully; 1 if they failed or an error occurred. @@ -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; } } -} +} \ No newline at end of file diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql index af6d79c..f3ffb1a 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql @@ -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); diff --git a/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetCountryIdByCode.sql b/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetCountryIdByCode.sql index e341a35..d8a4414 100644 --- a/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetCountryIdByCode.sql +++ b/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetCountryIdByCode.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetStateProvinceIdByCode.sql b/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetStateProvinceIdByCode.sql index 1f4bc20..f07c30d 100644 --- a/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetStateProvinceIdByCode.sql +++ b/web/backend/Database/Database.Migrations/scripts/02-functions/UDF_GetStateProvinceIdByCode.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql index 80f71d5..9de882b 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_CreateUserAccount.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql index 7ea5d3e..86be614 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_DeleteUserAccount.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql index 233acf5..2e6a6f0 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetAllUserAccounts.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql index 5e6fc2e..786de35 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByEmail.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql index 7113807..510a79f 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountById.sql @@ -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 diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql index 96fcca9..572ac35 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_GetUserAccountByUsername.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql index e0355b8..413d923 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/01-UserAccount/USP_UpdateUserAccount.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql index bc385e9..f387e92 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_GetUserCredentialByUserAccountId.sql @@ -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; \ No newline at end of file diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql index 08c7d53..c74118e 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_InvalidateUserCredential.sql @@ -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; \ No newline at end of file diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql index d09a7e0..a85f8e4 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RegisterUser.sql @@ -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 diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql index d4d4e60..e00317a 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/02-Auth/USP_RotateUserCredential.sql @@ -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; \ No newline at end of file diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql index e2fbe68..23a9e78 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/03-UserVerification/USP_AddUserVerification.sql @@ -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 diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCity.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCity.sql index 0de7c52..ef82d90 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCity.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCity.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCountry.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCountry.sql index c548872..acad245 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCountry.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateCountry.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateStateProvince.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateStateProvince.sql index de1e369..a850ac8 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateStateProvince.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/04-Location/USP_CreateStateProvince.sql @@ -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; diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql index 3683dc7..bfb29f8 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_CreateBrewery.sql @@ -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 diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_DeleteBrewery.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_DeleteBrewery.sql index 30d9baf..4b83179 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_DeleteBrewery.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_DeleteBrewery.sql @@ -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 diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetAllBreweries.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetAllBreweries.sql index 5933a25..4f92e20 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetAllBreweries.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetAllBreweries.sql @@ -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 diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql index 25425ef..730adf3 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_GetBreweryById.sql @@ -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 \ No newline at end of file diff --git a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_UpdateBrewery.sql b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_UpdateBrewery.sql index dcadaa6..cd7d43d 100644 --- a/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_UpdateBrewery.sql +++ b/web/backend/Database/Database.Migrations/scripts/03-crud/05-Breweries/USP_UpdateBrewery.sql @@ -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 diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index 27f356a..45c69e9 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -1,23 +1,23 @@  - - Exe - net10.0 - enable - enable - Database.Seed - + + Exe + net10.0 + enable + enable + Database.Seed + - - - - - - + + + + + + - - - + + + diff --git a/web/backend/Database/Database.Seed/ISeeder.cs b/web/backend/Database/Database.Seed/ISeeder.cs index 07918bd..94e1a83 100644 --- a/web/backend/Database/Database.Seed/ISeeder.cs +++ b/web/backend/Database/Database.Seed/ISeeder.cs @@ -3,15 +3,15 @@ using Microsoft.Data.SqlClient; namespace Database.Seed; /// -/// 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. /// internal interface ISeeder { /// - /// 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. /// /// An open connection to the target database. /// A task that completes when seeding is finished. diff --git a/web/backend/Database/Database.Seed/LocationSeeder.cs b/web/backend/Database/Database.Seed/LocationSeeder.cs index 61df53a..b3b971c 100644 --- a/web/backend/Database/Database.Seed/LocationSeeder.cs +++ b/web/backend/Database/Database.Seed/LocationSeeder.cs @@ -4,13 +4,13 @@ using Microsoft.Data.SqlClient; namespace Database.Seed; /// -/// 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 (USP_CreateCountry, USP_CreateStateProvince, -/// USP_CreateCity) 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 (USP_CreateCountry, USP_CreateStateProvince, +/// USP_CreateCity) are expected to be idempotent, so re-running this seeder +/// against an already-seeded database is safe. /// internal class LocationSeeder : ISeeder { @@ -22,12 +22,12 @@ internal class LocationSeeder : ISeeder [ ("Canada", "CA"), ("Mexico", "MX"), - ("United States", "US"), + ("United States", "US") ]; /// - /// 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. /// 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") ]; /// - /// 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. /// 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") ]; /// - /// 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. /// /// An open connection to the target database. /// A task that completes when all locations have been seeded. 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); - } } /// Creates a single country by invoking the dbo.USP_CreateCountry stored procedure. @@ -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(); } -} +} \ No newline at end of file diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index cdb4652..47d2cd5 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -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; /// 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); diff --git a/web/backend/Database/Database.Seed/UserSeeder.cs b/web/backend/Database/Database.Seed/UserSeeder.cs index 28d4efe..32b2c98 100644 --- a/web/backend/Database/Database.Seed/UserSeeder.cs +++ b/web/backend/Database/Database.Seed/UserSeeder.cs @@ -8,13 +8,13 @@ using Microsoft.Data.SqlClient; namespace Database.Seed; /// -/// Seeds user accounts, credentials, and verification records. Creates one fixed -/// "Test User" account (test.user@thebiergarten.app / password "password") -/// for testing, followed by a randomly-generated account for each name in -/// , 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 (test.user@thebiergarten.app / password "password") +/// for testing, followed by a randomly-generated account for each name in +/// , 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. /// internal class UserSeeder : ISeeder { @@ -123,21 +123,21 @@ internal class UserSeeder : ISeeder ("Zara", "Wilkinson"), ("Zaria", "Gibson"), ("Zion", "Watkins"), - ("Zoie", "Armstrong"), + ("Zoie", "Armstrong") ]; /// - /// Registers a fixed test user account followed by one randomly-generated account - /// per entry in , 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 , 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. /// /// An open connection to the target database. /// A task that completes when all users have been seeded. 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 } /// - /// Registers a new user account and its credential by invoking the - /// dbo.USP_RegisterUser stored procedure. + /// Registers a new user account and its credential by invoking the + /// dbo.USP_RegisterUser stored procedure. /// /// An open connection to the target database. /// The unique username for the account. @@ -212,8 +212,8 @@ internal class UserSeeder : ISeeder /// The user's last name. /// The user's date of birth. /// The user's email address. - /// The salted password hash, as produced by . - /// The newly created user account's identifier. + /// The salted password hash, as produced by . + /// The newly created user account's identifier. private static async Task 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!; } /// - /// 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. /// /// The plaintext password to hash. /// A string containing the base64-encoded salt and hash, separated by a colon. @@ -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 } /// - /// 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. /// /// The random number source to use. /// A randomly generated date of birth. @@ -316,4 +316,4 @@ internal class UserSeeder : ISeeder int offsetDays = random.Next(0, 365); return baseDate.AddDays(-offsetDays); } -} +} \ No newline at end of file diff --git a/web/backend/Domain/Domain.Entities/Domain.Entities.csproj b/web/backend/Domain/Domain.Entities/Domain.Entities.csproj index 424ac3a..976a018 100644 --- a/web/backend/Domain/Domain.Entities/Domain.Entities.csproj +++ b/web/backend/Domain/Domain.Entities/Domain.Entities.csproj @@ -1,8 +1,8 @@ - - net10.0 - enable - enable - Domain - + + net10.0 + enable + enable + Domain + diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs index 36d5299..0594a9b 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -1,48 +1,50 @@ namespace Domain.Entities; /// -/// Represents a user-submitted post about a brewery. Maps to the BreweryPost 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 BreweryPost table. +/// A user account cannot be deleted while it has an associated brewery post. /// public class BreweryPost { /// - /// Primary key identifying this brewery post. Maps to BreweryPostID. + /// Primary key identifying this brewery post. Maps to BreweryPostID. /// public Guid BreweryPostId { get; set; } /// - /// Foreign key referencing the that authored this post. Maps to PostedByID. + /// Foreign key referencing the that authored this post. Maps to PostedByID. /// public Guid PostedById { get; set; } /// - /// The name of the brewery being posted about. + /// The name of the brewery being posted about. /// public string BreweryName { get; set; } = string.Empty; /// - /// Free-text description of the brewery. + /// Free-text description of the brewery. /// public string Description { get; set; } = string.Empty; /// - /// The date and time the post was created. + /// The date and time the post was created. /// public DateTime CreatedAt { get; set; } /// - /// The date and time the post was last updated, or null if it has never been updated. + /// The date and time the post was last updated, or null if it has never been updated. /// public DateTime? UpdatedAt { get; set; } /// - /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has + /// been read from the database. /// public byte[]? Timer { get; set; } /// - /// The associated for this post, if one has been set. This is a one-to-one navigation property. + /// The associated for this post, if one has been set. This is a one-to-one + /// navigation property. /// public BreweryPostLocation? Location { get; set; } -} +} \ No newline at end of file diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs index 8da3311..c59917d 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -1,48 +1,52 @@ namespace Domain.Entities; /// -/// Represents the physical location of a . Maps to the BreweryPostLocation 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 . Maps to the BreweryPostLocation table. +/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is +/// deleted. /// public class BreweryPostLocation { /// - /// Primary key identifying this location. Maps to BreweryPostLocationID. + /// Primary key identifying this location. Maps to BreweryPostLocationID. /// public Guid BreweryPostLocationId { get; set; } /// - /// Foreign key referencing the owning . Maps to BreweryPostID; unique, enforcing a one-to-one relationship. + /// Foreign key referencing the owning . Maps to BreweryPostID; unique, enforcing a + /// one-to-one relationship. /// public Guid BreweryPostId { get; set; } /// - /// The primary street address line. + /// The primary street address line. /// public string AddressLine1 { get; set; } = string.Empty; /// - /// An optional secondary address line (e.g., suite or unit number). + /// An optional secondary address line (e.g., suite or unit number). /// public string? AddressLine2 { get; set; } /// - /// The postal/ZIP code of the location. + /// The postal/ZIP code of the location. /// public string PostalCode { get; set; } = string.Empty; /// - /// Foreign key referencing the city in which the brewery is located. Maps to CityID. + /// Foreign key referencing the city in which the brewery is located. Maps to CityID. /// public Guid CityId { get; set; } /// - /// Serialized SQL Server GEOGRAPHY value representing the geographic coordinates of the location, or null if not set. + /// Serialized SQL Server GEOGRAPHY value representing the geographic coordinates of the location, or + /// null if not set. /// public byte[]? Coordinates { get; set; } /// - /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has + /// been read from the database. /// public byte[]? Timer { get; set; } -} +} \ No newline at end of file diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs index 4c63f58..acc5090 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs @@ -1,53 +1,55 @@ namespace Domain.Entities; /// -/// Represents a registered user of the application. Maps to the UserAccount table, the root entity -/// referenced by , , brewery posts, and other user-owned data. +/// Represents a registered user of the application. Maps to the UserAccount table, the root entity +/// referenced by , , brewery posts, and other user-owned +/// data. /// public class UserAccount { /// - /// Primary key identifying this user account. Maps to UserAccountID. + /// Primary key identifying this user account. Maps to UserAccountID. /// public Guid UserAccountId { get; set; } /// - /// 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. /// public string Username { get; set; } = string.Empty; /// - /// The user's first name. + /// The user's first name. /// public string FirstName { get; set; } = string.Empty; /// - /// The user's last name. + /// The user's last name. /// public string LastName { get; set; } = string.Empty; /// - /// The user's email address. Enforced unique at the database level. + /// The user's email address. Enforced unique at the database level. /// public string Email { get; set; } = string.Empty; /// - /// The date and time the account was created. + /// The date and time the account was created. /// public DateTime CreatedAt { get; set; } /// - /// The date and time the account was last updated, or null if it has never been updated. + /// The date and time the account was last updated, or null if it has never been updated. /// public DateTime? UpdatedAt { get; set; } /// - /// The user's date of birth. + /// The user's date of birth. /// public DateTime DateOfBirth { get; set; } /// - /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has + /// been read from the database. /// public byte[]? Timer { get; set; } -} +} \ No newline at end of file diff --git a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs index 9f1b337..4be575e 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserCredential.cs @@ -1,38 +1,40 @@ namespace Domain.Entities; /// -/// Represents an issued authentication credential (refresh token) for a . -/// Maps to the UserCredential table. Credentials are deleted automatically when the owning user account is deleted. +/// Represents an issued authentication credential (refresh token) for a . +/// Maps to the UserCredential table. Credentials are deleted automatically when the owning user account is +/// deleted. /// public class UserCredential { /// - /// Primary key identifying this credential. Maps to UserCredentialID. + /// Primary key identifying this credential. Maps to UserCredentialID. /// public Guid UserCredentialId { get; set; } /// - /// Foreign key referencing the owning . Maps to UserAccountID. + /// Foreign key referencing the owning . Maps to UserAccountID. /// public Guid UserAccountId { get; set; } /// - /// The date and time the credential was issued. + /// The date and time the credential was issued. /// public DateTime CreatedAt { get; set; } /// - /// The date and time after which the credential is no longer valid. + /// The date and time after which the credential is no longer valid. /// public DateTime Expiry { get; set; } /// - /// 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. /// public string Hash { get; set; } = string.Empty; /// - /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has + /// been read from the database. /// public byte[]? Timer { get; set; } -} +} \ No newline at end of file diff --git a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs index 132b773..abd5e0e 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserVerification.cs @@ -1,29 +1,31 @@ namespace Domain.Entities; /// -/// Records that a has completed verification (e.g., email confirmation). -/// Maps to the UserVerification 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 has completed verification (e.g., email confirmation). +/// Maps to the UserVerification table. A user account has at most one verification record, +/// and it is deleted automatically when the owning user account is deleted. /// public class UserVerification { /// - /// Primary key identifying this verification record. Maps to UserVerificationID. + /// Primary key identifying this verification record. Maps to UserVerificationID. /// public Guid UserVerificationId { get; set; } /// - /// Foreign key referencing the verified . Maps to UserAccountID; unique, enforcing a one-to-one relationship. + /// Foreign key referencing the verified . Maps to UserAccountID; unique, enforcing a + /// one-to-one relationship. /// public Guid UserAccountId { get; set; } /// - /// The date and time at which the user account was verified. + /// The date and time at which the user account was verified. /// public DateTime VerificationDateTime { get; set; } /// - /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has been read from the database. + /// SQL Server ROWVERSION concurrency token used to detect concurrent updates. null until the row has + /// been read from the database. /// public byte[]? Timer { get; set; } -} +} \ No newline at end of file diff --git a/web/backend/Domain/Domain.Exceptions/Exceptions.cs b/web/backend/Domain/Domain.Exceptions/Exceptions.cs index 209880c..cf6964b 100644 --- a/web/backend/Domain/Domain.Exceptions/Exceptions.cs +++ b/web/backend/Domain/Domain.Exceptions/Exceptions.cs @@ -1,33 +1,33 @@ namespace Domain.Exceptions; /// -/// 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. /// public class ConflictException(string message) : Exception(message); /// -/// 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. /// public class NotFoundException(string message) : Exception(message); // Domain.Exceptions/UnauthorizedException.cs /// -/// 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. /// public class UnauthorizedException(string message) : Exception(message); /// -/// 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. /// public class ForbiddenException(string message) : Exception(message); /// -/// 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. /// public class ValidationException(string message) : Exception(message); \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs index 9c54b8a..b6988a4 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/ConfirmUserHandlerTests.cs @@ -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 _authRepositoryMock; - private readonly Mock _tokenServiceMock; private readonly ConfirmUserHandler _handler; + private readonly Mock _tokenServiceMock; public ConfirmUserHandlerTests() { @@ -25,30 +26,30 @@ public class ConfirmUserHandlerTests private static ValidatedToken MakeValidatedToken(Guid userId, string username) { - var claims = new List + List 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> act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None); await act.Should().ThrowAsync(); } @@ -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> act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None); await act.Should().ThrowAsync().WithMessage("*User account not found*"); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs index 35b9e07..610a23f 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/RefreshTokenHandlerTests.cs @@ -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(); - var handler = new RefreshTokenHandler(tokenServiceMock.Object); - var userId = Guid.NewGuid(); - var user = new UserAccount { UserAccountId = userId, Username = "testuser" }; + Mock 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"); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs index d1e6cd4..c24bfbc 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs @@ -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 _authRepoMock; + private readonly RegisterUserHandler _handler; + private readonly Mock _mediatorMock; private readonly Mock _passwordInfraMock; private readonly Mock _tokenServiceMock; - private readonly Mock _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())).Returns("access-token"); _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny())).Returns("refresh-token"); - _tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny())).Returns("confirmation-token"); + _tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny())) + .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> act = async () => await _handler.Handle(command, CancellationToken.None); await act.Should().ThrowAsync().WithMessage("Username or email already exists"); _authRepoMock.Verify( - x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), 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> act = async () => await _handler.Handle(command, CancellationToken.None); await act.Should().ThrowAsync().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())).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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), hashedPassword)) + .Setup(x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), hashedPassword)) .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny())).Returns("access-token"); @@ -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())).ReturnsAsync((UserAccount?)null); _authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny())).ReturnsAsync((UserAccount?)null); _passwordInfraMock.Setup(x => x.Hash(It.IsAny())).Returns("hashed"); _authRepoMock - .Setup(x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email }); + .Setup(x => x.RegisterUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UserAccount + { UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email }); _tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny())).Returns("access-token"); _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny())).Returns("refresh-token"); @@ -157,9 +167,9 @@ public class RegisterUserHandlerTests .Setup(x => x.Send(It.IsAny(), It.IsAny())) .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"); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs index 31e63f7..50289cd 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/ResendConfirmationEmailHandlerTests.cs @@ -11,20 +11,21 @@ namespace Features.Auth.Tests.Commands; public class ResendConfirmationEmailHandlerTests { private readonly Mock _authRepositoryMock = new(); - private readonly Mock _tokenServiceMock = new(); - private readonly Mock _mediatorMock = new(); private readonly ResendConfirmationEmailHandler _handler; + private readonly Mock _mediatorMock = new(); + private readonly Mock _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 ); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj index b102ed2..c788b58 100644 --- a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj +++ b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj @@ -1,26 +1,26 @@ - - net10.0 - enable - enable - false - Features.Auth.Tests - + + net10.0 + enable + enable + false + Features.Auth.Tests + - - - - - - - - + + + + + + + + - - - + + + - - - + + + diff --git a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs index 80b91d4..64d6e01 100644 --- a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs @@ -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 _authRepoMock; + private readonly LoginHandler _handler; private readonly Mock _passwordInfraMock; private readonly Mock _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())).Returns("access-token"); _tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny())).Returns("refresh-token"); - var result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None); + 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> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); await act.Should().ThrowAsync(); _authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny()), 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> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); await act.Should().ThrowAsync().WithMessage("Invalid username or password."); _passwordInfraMock.Verify(x => x.Verify(It.IsAny(), It.IsAny()), Times.Never); @@ -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(), It.IsAny())).Returns(false); - var act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None); + Func> act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None); await act.Should().ThrowAsync().WithMessage("Invalid username or password."); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs index 9b8446f..b1e133c 100644 --- a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs @@ -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 act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash); await act.Should().NotThrowAsync(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs index e48d7cf..a20e082 100644 --- a/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs +++ b/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs @@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory { private readonly DbConnection _conn = conn; - public DbConnection CreateConnection() => _conn; -} + public DbConnection CreateConnection() + { + return _conn; + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs index 66b2c64..ac29471 100644 --- a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs @@ -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 _tokenInfraMock; - private readonly Mock _authRepositoryMock; - private readonly TokenService _tokenService; + private readonly Mock _authRepositoryMock; + private readonly Mock _tokenInfraMock; + private readonly TokenService _tokenService; - public TokenServiceRefreshTests() - { - _tokenInfraMock = new Mock(); - _authRepositoryMock = new Mock(); + public TokenServiceRefreshTests() + { + _tokenInfraMock = new Mock(); + _authRepositoryMock = new Mock(); - // 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 + List 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())) - .ReturnsAsync(principal); - - // Mock the generation of new tokens - _tokenInfraMock - .Setup(x => x.GenerateJwt(userId, username, It.IsAny(), It.IsAny())) - .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(), It.IsAny(), It.IsAny(), It.IsAny()), - 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())) - .ThrowsAsync(new UnauthorizedException("Invalid refresh token")); - - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.RefreshTokenAsync(invalidToken) - ).Should().ThrowAsync(); - } - - [Fact] - public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException() - { - // Arrange - const string expiredToken = "expired-refresh-token"; - - _tokenInfraMock - .Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny())) - .ThrowsAsync(new UnauthorizedException("Refresh token has expired")); - - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.RefreshTokenAsync(expiredToken) - ).Should().ThrowAsync(); - } - - [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 + 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())) + .ReturnsAsync(principal); - _tokenInfraMock - .Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny())) - .ReturnsAsync(principal); + // Mock the generation of new tokens + _tokenInfraMock + .Setup(x => x.GenerateJwt(userId, username, It.IsAny(), It.IsAny())) + .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() - .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(), It.IsAny(), It.IsAny(), It.IsAny()), + 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())) + .ThrowsAsync(new UnauthorizedException("Invalid refresh token")); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.RefreshTokenAsync(invalidToken) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException() + { + // Arrange + const string expiredToken = "expired-refresh-token"; + + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Refresh token has expired")); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.RefreshTokenAsync(expiredToken) + ).Should().ThrowAsync(); + } + + [Fact] + public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException() + { + // Arrange + Guid userId = Guid.NewGuid(); + const string username = "testuser"; + const string refreshToken = "valid-refresh-token"; + + List 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())) + .ReturnsAsync(principal); + + _authRepositoryMock + .Setup(x => x.GetUserByIdAsync(userId)) + .ReturnsAsync((UserAccount?)null); + + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.RefreshTokenAsync(refreshToken) + ).Should().ThrowAsync() + .WithMessage("*User account not found*"); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs index 44b33a6..f3550cf 100644 --- a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceValidationTests.cs @@ -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 _tokenInfraMock; - private readonly Mock _authRepositoryMock; - private readonly TokenService _tokenService; + private readonly Mock _authRepositoryMock; + private readonly Mock _tokenInfraMock; + private readonly TokenService _tokenService; - public TokenServiceValidationTests() - { - _tokenInfraMock = new Mock(); - _authRepositoryMock = new Mock(); + public TokenServiceValidationTests() + { + _tokenInfraMock = new Mock(); + _authRepositoryMock = new Mock(); - // 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 + List 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())) - .ReturnsAsync(principal); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .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 + List 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())) - .ReturnsAsync(principal); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .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 + List 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())) - .ReturnsAsync(principal); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .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())) - .ThrowsAsync(new UnauthorizedException("Invalid token")); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Invalid token")); - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.ValidateAccessTokenAsync(token) - ).Should().ThrowAsync(); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync(); + } - [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())) - .ThrowsAsync(new UnauthorizedException( - "Token has expired" - )); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException( + "Token has expired" + )); - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.ValidateAccessTokenAsync(token) - ).Should().ThrowAsync(); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync(); + } - [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 + // Claims without Sub (user ID) + List 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())) - .ReturnsAsync(principal); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.ValidateAccessTokenAsync(token) - ).Should().ThrowAsync() - .WithMessage("*missing required claims*"); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync() + .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 + // Claims without UniqueName (username) + List 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())) - .ReturnsAsync(principal); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.ValidateAccessTokenAsync(token) - ).Should().ThrowAsync() - .WithMessage("*missing required claims*"); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync() + .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 + // Claims with invalid GUID format + List 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())) - .ReturnsAsync(principal); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ReturnsAsync(principal); - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.ValidateAccessTokenAsync(token) - ).Should().ThrowAsync() - .WithMessage("*malformed user ID*"); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateAccessTokenAsync(token) + ).Should().ThrowAsync() + .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())) - .ThrowsAsync(new UnauthorizedException("Invalid token")); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Invalid token")); - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.ValidateRefreshTokenAsync(token) - ).Should().ThrowAsync(); - } + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateRefreshTokenAsync(token) + ).Should().ThrowAsync(); + } - [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())) - .ThrowsAsync(new UnauthorizedException("Invalid token")); + _tokenInfraMock + .Setup(x => x.ValidateJwtAsync(token, It.IsAny())) + .ThrowsAsync(new UnauthorizedException("Invalid token")); - // Act & Assert - await FluentActions.Invoking(async () => - await _tokenService.ValidateConfirmationTokenAsync(token) - ).Should().ThrowAsync(); - } -} + // Act & Assert + await FluentActions.Invoking(async () => + await _tokenService.ValidateConfirmationTokenAsync(token) + ).Should().ThrowAsync(); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs index 4265e75..bb80868 100644 --- a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs +++ b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserCommand.cs @@ -4,7 +4,7 @@ using MediatR; namespace Features.Auth.Commands.ConfirmUser; /// -/// Validates a confirmation token and confirms the corresponding user account. +/// Validates a confirmation token and confirms the corresponding user account. /// /// The confirmation token issued to the user, typically delivered via email. -public record ConfirmUserCommand(string Token) : IRequest; +public record ConfirmUserCommand(string Token) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs index d986c03..979698d 100644 --- a/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs @@ -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; /// -/// Handles by validating the confirmation token and marking the -/// corresponding user account as confirmed. +/// Handles by validating the confirmation token and marking the +/// corresponding user account as confirmed. /// /// Repository used to look up and confirm user accounts. /// Service used to validate the confirmation token. @@ -18,19 +19,16 @@ public class ConfirmUserHandler( ) : IRequestHandler { /// - /// 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. /// public async Task 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); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs index c30c056..ed8709e 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs @@ -4,7 +4,7 @@ using MediatR; namespace Features.Auth.Commands.RefreshToken; /// -/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the -/// request body of POST /api/auth/refresh. +/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the +/// request body of POST /api/auth/refresh. /// -public record RefreshTokenCommand(string RefreshToken) : IRequest; +public record RefreshTokenCommand(string RefreshToken) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs index 7cf3ecb..977e739 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs @@ -5,8 +5,8 @@ using MediatR; namespace Features.Auth.Commands.RefreshToken; /// -/// Handles by validating the refresh token and issuing a new -/// access/refresh token pair. +/// Handles by validating the refresh token and issuing a new +/// access/refresh token pair. /// /// Service used to validate and exchange the refresh token. public class RefreshTokenHandler(ITokenService tokenService) @@ -14,7 +14,8 @@ public class RefreshTokenHandler(ITokenService tokenService) { public async Task Handle(RefreshTokenCommand request, CancellationToken cancellationToken) { - var result = await tokenService.RefreshTokenAsync(request.RefreshToken); - return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken); + RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken); + return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, + result.AccessToken); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs index ff68178..24070f5 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenValidator.cs @@ -3,12 +3,12 @@ using FluentValidation; namespace Features.Auth.Commands.RefreshToken; /// -/// Validates instances before they are processed. +/// Validates instances before they are processed. /// public class RefreshTokenValidator : AbstractValidator { /// - /// Configures a validation rule requiring to be non-empty. + /// Configures a validation rule requiring to be non-empty. /// public RefreshTokenValidator() { @@ -16,4 +16,4 @@ public class RefreshTokenValidator : AbstractValidator .NotEmpty() .WithMessage("Refresh token is required"); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs index 4306a54..ab2965a 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserCommand.cs @@ -4,14 +4,20 @@ using MediatR; namespace Features.Auth.Commands.RegisterUser; /// -/// Registers a new user account. Bound directly from the request body of POST /api/auth/register. +/// Registers a new user account. Bound directly from the request body of POST /api/auth/register. /// -/// The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens. +/// +/// The desired username; must be 3-64 characters and contain only letters, numbers, dots, +/// underscores, and hyphens. +/// /// The user's first name; up to 128 characters. /// The user's last name; up to 128 characters. /// The user's email address; up to 128 characters and must be a valid email format. /// The user's date of birth; the user must be at least 19 years old. -/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character. +/// +/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a +/// lowercase letter, a number, and a special character. +/// public record RegisterUserCommand( string Username, string FirstName, @@ -19,4 +25,4 @@ public record RegisterUserCommand( string Email, DateTime DateOfBirth, string Password -) : IRequest; +) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs index 4bd7e4c..39915bd 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -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; /// -/// Handles : validates uniqueness, hashes the password, persists the -/// account, issues access/refresh/confirmation tokens, and attempts to send the registration -/// confirmation email via Features.Emails. +/// Handles : validates uniqueness, hashes the password, persists the +/// account, issues access/refresh/confirmation tokens, and attempts to send the registration +/// confirmation email via Features.Emails. /// /// Repository used to check for existing users and persist the new account. /// Infrastructure component used to hash the user's plain-text password. @@ -25,15 +26,15 @@ public class RegisterUserHandler( ) : IRequestHandler { /// - /// 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. /// public async Task 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); } /// - /// 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. /// 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"); - } } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs index 66a0ee3..bca2aaa 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserValidator.cs @@ -3,13 +3,13 @@ using FluentValidation; namespace Features.Auth.Commands.RegisterUser; /// -/// Validates instances before they are processed. +/// Validates instances before they are processed. /// public class RegisterUserValidator : AbstractValidator { /// - /// Configures validation rules for username format and length, first/last name length, email format and - /// length, minimum age based on date of birth, and password strength requirements. + /// Configures validation rules for username format and length, first/last name length, email format and + /// length, minimum age based on date of birth, and password strength requirements. /// public RegisterUserValidator() { @@ -65,4 +65,4 @@ public class RegisterUserValidator : AbstractValidator "Password must contain at least one special character" ); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs index 6b85591..66c4fbf 100644 --- a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs +++ b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailCommand.cs @@ -3,7 +3,7 @@ using MediatR; namespace Features.Auth.Commands.ResendConfirmationEmail; /// -/// 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. /// /// The unique identifier of the user requesting the resend. -public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; +public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs index 01340b6..5049260 100644 --- a/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs @@ -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; /// -/// Handles by generating a fresh confirmation token and -/// sending it via Features.Emails. +/// Handles by generating a fresh confirmation token and +/// sending it via Features.Emails. /// /// Repository used to look up the user and check verification status. /// Service used to generate the confirmation token. /// Used to send the cross-slice command that triggers the email. /// -/// Returns silently without sending an email if the user does not exist (to prevent user enumeration) -/// or if the user's account is already verified. +/// Returns silently without sending an email if the user does not exist (to prevent user enumeration) +/// or if the user's account is already verified. /// public class ResendConfirmationEmailHandler( IAuthRepository authRepository, @@ -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 ); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Controllers/AuthController.cs b/web/backend/Features/Features.Auth/Controllers/AuthController.cs index fcb7bdb..12fe44a 100644 --- a/web/backend/Features/Features.Auth/Controllers/AuthController.cs +++ b/web/backend/Features/Features.Auth/Controllers/AuthController.cs @@ -9,121 +9,120 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Shared.Contracts; -namespace Features.Auth.Controllers +namespace Features.Auth.Controllers; + +/// +/// Handles user authentication concerns: registration, login, email confirmation, and token refresh. +/// +/// +/// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default, but most +/// actions opt out via [AllowAnonymous] since they are entry points used before a caller holds a token. +/// +/// Used to dispatch auth commands and queries to their handlers. +[ApiController] +[Route("api/[controller]")] +[Authorize(AuthenticationSchemes = "JWT")] +public class AuthController(IMediator mediator) : ControllerBase { /// - /// Handles user authentication concerns: registration, login, email confirmation, and token refresh. + /// Registers a new user account. /// /// - /// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default, but most - /// actions opt out via [AllowAnonymous] since they are entry points used before a caller holds a token. + /// Allows anonymous access. On success, responds with 201 Created containing the new user's ID, + /// username, issued refresh/access tokens, and whether a confirmation email was sent. /// - /// Used to dispatch auth commands and queries to their handlers. - [ApiController] - [Route("api/[controller]")] - [Authorize(AuthenticationSchemes = "JWT")] - public class AuthController(IMediator mediator) : ControllerBase + /// The registration details, including username, name, email, date of birth, and password. + /// A 201 Created result wrapping a of . + [AllowAnonymous] + [HttpPost("register")] + public async Task>> Register( + [FromBody] RegisterUserCommand command + ) { - /// - /// Registers a new user account. - /// - /// - /// Allows anonymous access. On success, responds with 201 Created containing the new user's ID, - /// username, issued refresh/access tokens, and whether a confirmation email was sent. - /// - /// The registration details, including username, name, email, date of birth, and password. - /// A 201 Created result wrapping a of . - [AllowAnonymous] - [HttpPost("register")] - public async Task>> Register( - [FromBody] RegisterUserCommand command - ) + RegistrationPayload payload = await mediator.Send(command); + return Created("/", new ResponseBody { - var payload = await mediator.Send(command); - return Created("/", new ResponseBody - { - Message = "User registered successfully.", - Payload = payload, - }); - } - - /// - /// Authenticates a user with a username and password. - /// - /// - /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, - /// username, and a newly issued refresh/access token pair. - /// - /// The login credentials (username and password). - /// An 200 OK result wrapping a of . - [AllowAnonymous] - [HttpPost("login")] - public async Task>> Login([FromBody] LoginQuery query) - { - var payload = await mediator.Send(query); - return Ok(new ResponseBody - { - Message = "Logged in successfully.", - Payload = payload, - }); - } - - /// - /// Confirms a user account using a confirmation token. - /// - /// - /// Requires JWT authentication. On success, responds with 200 OK containing the confirmed - /// user's ID and the confirmation timestamp. - /// - /// The confirmation token supplied via the confirmation email link. - /// A 200 OK result wrapping a of . - [HttpPost("confirm")] - public async Task>> Confirm([FromQuery] string token) - { - var payload = await mediator.Send(new ConfirmUserCommand(token)); - return Ok(new ResponseBody - { - Message = "User with ID " + payload.UserAccountId + " is confirmed.", - Payload = payload, - }); - } - - /// - /// Resends the account confirmation email for the specified user. - /// - /// - /// Requires JWT authentication. On success, responds with 200 OK. - /// - /// The unique identifier of the user account to resend the confirmation email for. - /// A 200 OK result wrapping a confirming the email was resent. - [HttpPost("confirm/resend")] - public async Task> ResendConfirmation([FromQuery] Guid userId) - { - await mediator.Send(new ResendConfirmationEmailCommand(userId)); - return Ok(new ResponseBody { Message = "confirmation email has been resent" }); - } - - /// - /// Exchanges a valid refresh token for a new access/refresh token pair. - /// - /// - /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, - /// username, and the newly issued refresh/access token pair. - /// - /// The request containing the refresh token to exchange. - /// A 200 OK result wrapping a of . - [AllowAnonymous] - [HttpPost("refresh")] - public async Task>> Refresh( - [FromBody] RefreshTokenCommand command - ) - { - var payload = await mediator.Send(command); - return Ok(new ResponseBody - { - Message = "Token refreshed successfully.", - Payload = payload, - }); - } + Message = "User registered successfully.", + Payload = payload + }); } -} + + /// + /// Authenticates a user with a username and password. + /// + /// + /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, + /// username, and a newly issued refresh/access token pair. + /// + /// The login credentials (username and password). + /// An 200 OK result wrapping a of . + [AllowAnonymous] + [HttpPost("login")] + public async Task>> Login([FromBody] LoginQuery query) + { + LoginPayload payload = await mediator.Send(query); + return Ok(new ResponseBody + { + Message = "Logged in successfully.", + Payload = payload + }); + } + + /// + /// Confirms a user account using a confirmation token. + /// + /// + /// Requires JWT authentication. On success, responds with 200 OK containing the confirmed + /// user's ID and the confirmation timestamp. + /// + /// The confirmation token supplied via the confirmation email link. + /// A 200 OK result wrapping a of . + [HttpPost("confirm")] + public async Task>> Confirm([FromQuery] string token) + { + ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token)); + return Ok(new ResponseBody + { + Message = "User with ID " + payload.UserAccountId + " is confirmed.", + Payload = payload + }); + } + + /// + /// Resends the account confirmation email for the specified user. + /// + /// + /// Requires JWT authentication. On success, responds with 200 OK. + /// + /// The unique identifier of the user account to resend the confirmation email for. + /// A 200 OK result wrapping a confirming the email was resent. + [HttpPost("confirm/resend")] + public async Task> ResendConfirmation([FromQuery] Guid userId) + { + await mediator.Send(new ResendConfirmationEmailCommand(userId)); + return Ok(new ResponseBody { Message = "confirmation email has been resent" }); + } + + /// + /// Exchanges a valid refresh token for a new access/refresh token pair. + /// + /// + /// Allows anonymous access. On success, responds with 200 OK containing the user's ID, + /// username, and the newly issued refresh/access token pair. + /// + /// The request containing the refresh token to exchange. + /// A 200 OK result wrapping a of . + [AllowAnonymous] + [HttpPost("refresh")] + public async Task>> Refresh( + [FromBody] RefreshTokenCommand command + ) + { + LoginPayload payload = await mediator.Send(command); + return Ok(new ResponseBody + { + Message = "Token refreshed successfully.", + Payload = payload + }); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs index 3718ad4..16c844e 100644 --- a/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Auth/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Auth.DependencyInjection; /// -/// Registers the services owned by the Auth feature slice. +/// Registers the services owned by the Auth feature slice. /// public static class FeaturesAuthServiceCollectionExtensions { @@ -17,4 +17,4 @@ public static class FeaturesAuthServiceCollectionExtensions services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs b/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs index f79f28e..0b1bab4 100644 --- a/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs +++ b/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs @@ -1,7 +1,7 @@ namespace Features.Auth.Dtos; /// -/// Payload returned to the client after a successful login or token refresh. +/// Payload returned to the client after a successful login or token refresh. /// /// The unique identifier of the authenticated user account. /// The username of the authenticated user account. @@ -15,7 +15,7 @@ public record LoginPayload( ); /// -/// Payload returned to the client after a successful registration. +/// Payload returned to the client after a successful registration. /// /// The unique identifier of the newly created user account. /// The username of the newly created user account. @@ -31,8 +31,8 @@ public record RegistrationPayload( ); /// -/// 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. /// /// The unique identifier of the user account that was confirmed. /// The date and time at which the account was confirmed. -public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); +public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Features.Auth.csproj b/web/backend/Features/Features.Auth/Features.Auth.csproj index c928b6d..7adcdc9 100644 --- a/web/backend/Features/Features.Auth/Features.Auth.csproj +++ b/web/backend/Features/Features.Auth/Features.Auth.csproj @@ -1,22 +1,22 @@ - - net10.0 - enable - enable - Features.Auth - + + net10.0 + enable + enable + Features.Auth + - - - + + + - - - - - - - - - + + + + + + + + + diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs b/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs index ddece25..6306d97 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs +++ b/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs @@ -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; /// -/// Handles by verifying credentials and issuing access/refresh tokens. +/// Handles by verifying credentials and issuing access/refresh tokens. /// /// Repository used to look up the user account and its active credential. -/// Infrastructure component used to verify a plain-text password against a stored hash. +/// +/// Infrastructure component used to verify a plain-text password against a stored +/// hash. +/// /// Service used to generate access and refresh tokens for the authenticated user. public class LoginHandler( IAuthRepository authRepo, @@ -20,26 +24,26 @@ public class LoginHandler( ) : IRequestHandler { /// - /// Thrown when the username does not match any account, the account has no active credential, - /// or the supplied password does not match the stored hash. + /// Thrown when the username does not match any account, the account has no active credential, + /// or the supplied password does not match the stored hash. /// public async Task Handle(LoginQuery request, CancellationToken cancellationToken) { - var user = + 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); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs b/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs index d291aa0..3ae72cb 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs +++ b/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs @@ -4,7 +4,7 @@ using MediatR; namespace Features.Auth.Queries.Login; /// -/// Authenticates a user using their username and password and issues new tokens. Bound directly -/// from the request body of POST /api/auth/login. +/// Authenticates a user using their username and password and issues new tokens. Bound directly +/// from the request body of POST /api/auth/login. /// -public record LoginQuery(string Username, string Password) : IRequest; +public record LoginQuery(string Username, string Password) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs b/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs index 7a45333..6b60c07 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs +++ b/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs @@ -3,13 +3,13 @@ using FluentValidation; namespace Features.Auth.Queries.Login; /// -/// Validates instances before they are processed. +/// Validates instances before they are processed. /// public class LoginValidator : AbstractValidator { /// - /// Configures validation rules requiring both and - /// to be non-empty. + /// Configures validation rules requiring both and + /// to be non-empty. /// public LoginValidator() { @@ -17,4 +17,4 @@ public class LoginValidator : AbstractValidator RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required"); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs index 523dfe3..7f4eb6a 100644 --- a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs @@ -7,23 +7,23 @@ using Microsoft.Data.SqlClient; namespace Features.Auth.Repository; /// -/// ADO.NET-based implementation of backed by SQL Server stored procedures, -/// handling user registration, credential lookup/rotation, and account verification. +/// ADO.NET-based implementation of backed by SQL Server stored procedures, +/// handling user registration, credential lookup/rotation, and account verification. /// /// The factory used to create database connections. public class AuthRepository(ISqlConnectionFactory connectionFactory) - : Infrastructure.Sql.Repository(connectionFactory), + : Repository(connectionFactory), IAuthRepository { /// - /// Registers a new user account and initial credential using the USP_RegisterUser stored - /// procedure, then fetches and returns the newly created user. + /// Registers a new user account and initial credential using the USP_RegisterUser stored + /// procedure, then fetches and returns the newly created user. /// /// - /// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively: - /// it may be returned as a , a parseable , or a 16-byte array. - /// If the result cannot be interpreted, 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 , a parseable , or a 16-byte array. + /// If the result cannot be interpreted, is used, which will cause the + /// subsequent lookup to fail. /// /// Unique username for the user /// User's first name @@ -34,7 +34,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) /// The newly created UserAccount with generated ID /// Thrown when the newly registered user cannot be retrieved after registration. /// Thrown when the database command fails. - public async Task RegisterUserAsync( + public async Task 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."); } /// - /// Retrieves a user account by email address (typically used for login) using the - /// usp_GetUserAccountByEmail stored procedure. + /// Retrieves a user account by email address (typically used for login) using the + /// usp_GetUserAccountByEmail stored procedure. /// /// Email address to search for /// UserAccount if found, null otherwise /// Thrown when the database command fails. - public async Task GetUserByEmailAsync( + public async Task 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; } /// - /// Retrieves a user account by username (typically used for login) using the - /// usp_GetUserAccountByUsername stored procedure. + /// Retrieves a user account by username (typically used for login) using the + /// usp_GetUserAccountByUsername stored procedure. /// /// Username to search for /// UserAccount if found, null otherwise /// Thrown when the database command fails. - public async Task GetUserByUsernameAsync( + public async Task 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; } /// - /// Retrieves the active (non-revoked) credential for a user account using the - /// USP_GetActiveUserCredentialByUserAccountId stored procedure. + /// Retrieves the active (non-revoked) credential for a user account using the + /// USP_GetActiveUserCredentialByUserAccountId stored procedure. /// /// ID of the user account /// Active UserCredential if found, null otherwise @@ -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; } /// - /// Rotates a user's credential by invalidating all existing credentials and creating a new one, - /// using the USP_RotateUserCredential stored procedure. + /// Rotates a user's credential by invalidating all existing credentials and creating a new one, + /// using the USP_RotateUserCredential stored procedure. /// /// ID of the user account /// New hashed password @@ -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) } /// - /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. + /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. /// /// ID of the user account /// UserAccount if found, null otherwise /// Thrown when the database command fails. - public async Task GetUserByIdAsync( + public async Task 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; } /// - /// Marks a user account as confirmed by creating a verification record via the - /// USP_CreateUserVerification 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 + /// USP_CreateUserVerification 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. /// /// ID of the user account to confirm - /// The confirmed , or null if the user account does not exist. - /// Thrown when the database command fails for a reason other than a duplicate verification record. - public async Task ConfirmUserAccountAsync( + /// The confirmed , or null if the user account does not exist. + /// + /// Thrown when the database command fails for a reason other than + /// a duplicate verification record. + /// + public async Task 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) } /// - /// Checks whether a user account has been verified by querying the - /// dbo.UserVerification table for a matching record. + /// Checks whether a user account has been verified by querying the + /// dbo.UserVerification table for a matching record. /// /// ID of the user account /// True if the user has a verification record, false otherwise /// Thrown when the database command fails. public async Task 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; } /// - /// Determines whether a represents a duplicate key violation - /// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert. + /// Determines whether a represents a duplicate key violation + /// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert. /// /// The SQL exception to inspect. /// True if the exception represents a duplicate key violation, false otherwise. @@ -282,15 +272,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) /// - /// Maps a data reader row to a UserAccount entity. + /// Maps a data reader row to a UserAccount entity. /// /// The data reader positioned on the row to map. - /// The mapped instance. - protected override Domain.Entities.UserAccount MapToEntity( + /// The mapped instance. + 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"] }; } /// - /// Maps a data reader row to a UserCredential entity. The Timer 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 Timer column is mapped only if + /// present in the reader's schema, allowing this method to support result sets that omit it. /// /// The data reader positioned on the row to map. - /// The mapped instance. + /// The mapped instance. 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() + ?.Rows.Cast() .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; } /// - /// Helper method to add a parameter to a database command, converting null values to - /// . + /// Helper method to add a parameter to a database command, converting null values to + /// . /// /// The command to add the parameter to. /// The parameter name (including any prefix, e.g. "@Username"). - /// The parameter value, or null to bind . + /// The parameter value, or null to bind . 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); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs index b98932e..5c791b7 100644 --- a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs @@ -3,13 +3,13 @@ using Domain.Entities; namespace Features.Auth.Repository; /// -/// Repository for authentication-related database operations including user registration and credential management. +/// Repository for authentication-related database operations including user registration and credential management. /// public interface IAuthRepository { /// - /// 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 /// /// Unique username for the user /// User's first name @@ -18,7 +18,7 @@ public interface IAuthRepository /// User's date of birth /// Hashed password /// The newly created UserAccount with generated ID - Task RegisterUserAsync( + Task RegisterUserAsync( string username, string firstName, string lastName, @@ -28,24 +28,24 @@ public interface IAuthRepository ); /// - /// 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 /// /// Email address to search for /// UserAccount if found, null otherwise - Task GetUserByEmailAsync(string email); + Task GetUserByEmailAsync(string email); /// - /// 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 /// /// Username to search for /// UserAccount if found, null otherwise - Task GetUserByUsernameAsync(string username); + Task GetUserByUsernameAsync(string username); /// - /// 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 /// /// ID of the user account /// Active UserCredential if found, null otherwise @@ -54,31 +54,31 @@ public interface IAuthRepository ); /// - /// 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 /// /// ID of the user account /// New hashed password Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); /// - /// Marks a user account as confirmed. + /// Marks a user account as confirmed. /// /// ID of the user account to confirm /// The confirmed UserAccount entity, or null if the user account does not exist - Task ConfirmUserAccountAsync(Guid userAccountId); + Task ConfirmUserAccountAsync(Guid userAccountId); /// - /// Retrieves a user account by ID. + /// Retrieves a user account by ID. /// /// ID of the user account /// UserAccount if found, null otherwise - Task GetUserByIdAsync(Guid userAccountId); + Task GetUserByIdAsync(Guid userAccountId); /// - /// Checks whether a user account has been verified. + /// Checks whether a user account has been verified. /// /// ID of the user account /// True if the user has a verification record, false otherwise Task IsUserVerifiedAsync(Guid userAccountId); -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Services/ITokenService.cs b/web/backend/Features/Features.Auth/Services/ITokenService.cs index 4c0cefa..09b34ab 100644 --- a/web/backend/Features/Features.Auth/Services/ITokenService.cs +++ b/web/backend/Features/Features.Auth/Services/ITokenService.cs @@ -4,28 +4,30 @@ using Domain.Entities; namespace Features.Auth.Services; /// -/// Identifies the kind of token being generated or validated. +/// Identifies the kind of token being generated or validated. /// public enum TokenType { /// A short-lived token used to authorize API requests. AccessToken, + /// A long-lived token used to obtain new access tokens. RefreshToken, + /// A short-lived token used to confirm a user's email/account. - ConfirmationToken, + ConfirmationToken } /// -/// Represents the result of successfully validating a token. +/// Represents the result of successfully validating a token. /// /// The unique identifier of the user the token belongs to. /// The username extracted from the token's claims. -/// The produced from the validated token. +/// The produced from the validated token. public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); /// -/// Represents the result of refreshing a user's session. +/// Represents the result of refreshing a user's session. /// /// The user account associated with the refreshed session. /// The newly issued refresh token. @@ -37,77 +39,79 @@ public record RefreshTokenResult( ); /// -/// Defines the expiration windows, in hours, for each type of token issued by . +/// Defines the expiration windows, in hours, for each type of token issued by . /// public static class TokenServiceExpirationHours { /// The expiration window, in hours, for access tokens. public const double AccessTokenHours = 1; + /// The expiration window, in hours, for refresh tokens (21 days). public const double RefreshTokenHours = 504; // 21 days + /// The expiration window, in hours, for confirmation tokens (30 minutes). public const double ConfirmationTokenHours = 0.5; // 30 minutes } /// -/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation. +/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation. /// public interface ITokenService { /// - /// Generates a new access token for the given user. + /// Generates a new access token for the given user. /// /// The user account to generate the token for. /// The signed access token string. string GenerateAccessToken(UserAccount user); /// - /// Generates a new refresh token for the given user. + /// Generates a new refresh token for the given user. /// /// The user account to generate the token for. /// The signed refresh token string. string GenerateRefreshToken(UserAccount user); /// - /// Generates a new confirmation token for the given user. + /// Generates a new confirmation token for the given user. /// /// The user account to generate the token for. /// The signed confirmation token string. string GenerateConfirmationToken(UserAccount user); /// - /// Generates a token of the type specified by , which must be . + /// Generates a token of the type specified by , which must be . /// - /// The enum type identifying which kind of token to generate. Must be . + /// The enum type identifying which kind of token to generate. Must be . /// The user account to generate the token for. /// The signed token string corresponding to the requested token type. string GenerateToken(UserAccount user) where T : struct, Enum; /// - /// Validates an access token. + /// Validates an access token. /// /// The access token string to validate. - /// A describing the token's claims. + /// A describing the token's claims. Task ValidateAccessTokenAsync(string token); /// - /// Validates a refresh token. + /// Validates a refresh token. /// /// The refresh token string to validate. - /// A describing the token's claims. + /// A describing the token's claims. Task ValidateRefreshTokenAsync(string token); /// - /// Validates a confirmation token. + /// Validates a confirmation token. /// /// The confirmation token string to validate. - /// A describing the token's claims. + /// A describing the token's claims. Task ValidateConfirmationTokenAsync(string token); /// - /// Validates a refresh token and issues a new access/refresh token pair for the associated user. + /// Validates a refresh token and issues a new access/refresh token pair for the associated user. /// /// The refresh token string to validate and exchange. - /// A containing the user and newly issued tokens. + /// A containing the user and newly issued tokens. Task RefreshTokenAsync(string refreshTokenString); -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Auth/Services/TokenService.cs b/web/backend/Features/Features.Auth/Services/TokenService.cs index d6b78fe..e9b4a34 100644 --- a/web/backend/Features/Features.Auth/Services/TokenService.cs +++ b/web/backend/Features/Features.Auth/Services/TokenService.cs @@ -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; /// -/// Default implementation of that generates and validates JWTs -/// for access, refresh, and confirmation flows using secrets read from environment variables. +/// Default implementation of that generates and validates JWTs +/// for access, refresh, and confirmation flows using secrets read from environment variables. /// 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; /// - /// Initializes a new instance of , loading the access, refresh, and - /// confirmation token signing secrets from environment variables. + /// Initializes a new instance of , loading the access, refresh, and + /// confirmation token signing secrets from environment variables. /// /// The infrastructure component used to generate and validate JWTs. /// Repository used to look up user accounts during token refresh. /// - /// Thrown when any of the ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, or - /// CONFIRMATION_TOKEN_SECRET environment variables are not set. + /// Thrown when any of the ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, or + /// CONFIRMATION_TOKEN_SECRET environment variables are not set. /// 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"); } /// - /// Generates an access token for the given user, signed with the access token secret and - /// expiring after hours. + /// Generates an access token for the given user, signed with the access token secret and + /// expiring after hours. /// /// The user account to generate the token for. /// The signed access token string. 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); } /// - /// Generates a refresh token for the given user, signed with the refresh token secret and - /// expiring after hours. + /// Generates a refresh token for the given user, signed with the refresh token secret and + /// expiring after hours. /// /// The user account to generate the token for. /// The signed refresh token string. 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); } /// - /// Generates a confirmation token for the given user, signed with the confirmation token secret and - /// expiring after hours. + /// Generates a confirmation token for the given user, signed with the confirmation token secret and + /// expiring after hours. /// /// The user account to generate the token for. /// The signed confirmation token string. 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); } /// - /// Generates a token of the kind specified by , dispatching to - /// , , or - /// based on the corresponding value. + /// Generates a token of the kind specified by , dispatching to + /// , , or + /// based on the corresponding value. /// - /// The enum type identifying which kind of token to generate. Must be . + /// The enum type identifying which kind of token to generate. Must be . /// The user account to generate the token for. /// The signed token string corresponding to the requested token type. /// - /// Thrown when is not or does not resolve to a known token type. + /// Thrown when is not or does not resolve to a known token type. /// public string GenerateToken(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") }; } /// - /// Validates an access token against the access token secret. + /// Validates an access token against the access token secret. /// /// The access token string to validate. - /// A describing the token's claims. + /// A describing the token's claims. /// - /// 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. /// public async Task ValidateAccessTokenAsync(string token) - => await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); + { + return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); + } /// - /// Validates a refresh token against the refresh token secret. + /// Validates a refresh token against the refresh token secret. /// /// The refresh token string to validate. - /// A describing the token's claims. + /// A describing the token's claims. /// - /// 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. /// public async Task ValidateRefreshTokenAsync(string token) - => await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); + { + return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); + } /// - /// Validates a confirmation token against the confirmation token secret. + /// Validates a confirmation token against the confirmation token secret. /// /// The confirmation token string to validate. - /// A describing the token's claims. + /// A describing the token's claims. /// - /// 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. /// public async Task ValidateConfirmationTokenAsync(string token) - => await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); + { + return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation"); + } /// - /// 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. + /// + /// The refresh token string to validate and exchange. + /// A containing the user and newly issued tokens. + /// + /// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists. + /// + public async Task 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); + } + + /// + /// 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. /// /// The token string to validate. /// The secret key to validate the token's signature against. /// A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages. - /// A describing the token's claims. + /// A describing the token's claims. /// - /// Thrown when required claims are missing, the user ID claim is not a valid , - /// 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 , + /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). /// private async Task 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}"); } } - - /// - /// Validates the given refresh token, looks up the associated user, and issues a fresh - /// access/refresh token pair. - /// - /// The refresh token string to validate and exchange. - /// A containing the user and newly issued tokens. - /// - /// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists. - /// - public async Task 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); - } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs index a632d11..6bbf7b4 100644 --- a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryHandlerTests.cs @@ -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 _repoMock = new(); private readonly CreateBreweryHandler _handler; + private readonly Mock _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(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(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs index 1c7106c..243ca81 100644 --- a/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Commands/DeleteBreweryHandlerTests.cs @@ -9,13 +9,13 @@ public class DeleteBreweryHandlerTests [Fact] public async Task Handle_DelegatesToRepository() { - var repoMock = new Mock(); - var handler = new DeleteBreweryHandler(repoMock.Object); - var id = Guid.NewGuid(); + Mock 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); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs index 797c1a3..0299612 100644 --- a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryHandlerTests.cs @@ -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 _repoMock = new(); private readonly UpdateBreweryHandler _handler; + private readonly Mock _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(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); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj index debe2e0..7ddeb83 100644 --- a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj +++ b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj @@ -1,26 +1,26 @@ - - net10.0 - enable - enable - false - Features.Breweries.Tests - + + net10.0 + enable + enable + false + Features.Breweries.Tests + - - - - - - - - + + + + + + + + - - - + + + - - - + + + diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs index 9cd96fc..5a0478d 100644 --- a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweriesHandlerTests.cs @@ -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 _repoMock = new(); private readonly GetAllBreweriesHandler _handler; + private readonly Mock _repoMock = new(); public GetAllBreweriesHandlerTests() { @@ -22,7 +23,7 @@ public class GetAllBreweriesHandlerTests _repoMock.Setup(r => r.GetAllAsync(10, 5)) .ReturnsAsync(Array.Empty()); - var result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None); + IEnumerable 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 result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None); result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId)); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs index 0c98295..be453ef 100644 --- a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryByIdHandlerTests.cs @@ -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 _repoMock = new(); private readonly GetBreweryByIdHandler _handler; + private readonly Mock _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(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs index aefb999..5739564 100644 --- a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs +++ b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs @@ -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 act = async () => await repo.CreateAsync(brewery); await act.Should().NotThrowAsync(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs index 69c6c1a..9ebb3fd 100644 --- a/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs +++ b/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs @@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory { private readonly DbConnection _conn = conn; - public DbConnection CreateConnection() => _conn; -} + public DbConnection CreateConnection() + { + return _conn; + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs index 9a61928..97e7cd8 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryCommand.cs @@ -4,7 +4,7 @@ using MediatR; namespace Features.Breweries.Commands.CreateBrewery; /// -/// Location data required to create a new brewery post, supplied as part of . +/// Location data required to create a new brewery post, supplied as part of . /// public record CreateBreweryLocation( Guid CityId, @@ -15,11 +15,11 @@ public record CreateBreweryLocation( ); /// -/// Creates a new brewery post. Bound directly from the request body of POST /api/brewery. +/// Creates a new brewery post. Bound directly from the request body of POST /api/brewery. /// public record CreateBreweryCommand( Guid PostedById, string BreweryName, string Description, CreateBreweryLocation Location -) : IRequest; +) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs index 5eea5ec..26073a8 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryHandler.cs @@ -6,21 +6,21 @@ using MediatR; namespace Features.Breweries.Commands.CreateBrewery; /// -/// Handles by persisting a new brewery post and its location. +/// Handles by persisting a new brewery post and its location. /// /// Repository used to persist the new brewery post. public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler { /// - /// 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. /// /// The details of the brewery post to create. /// A token to observe for cancellation requests. /// The newly created brewery post. public async Task 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(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs index bb1c2e6..3d70d2d 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs @@ -3,15 +3,15 @@ using FluentValidation; namespace Features.Breweries.Commands.CreateBrewery; /// -/// Validates instances before they are processed. +/// Validates instances before they are processed. /// public class CreateBreweryValidator : AbstractValidator { /// - /// Configures validation rules requiring , - /// , , and - /// to be present, with length limits on the name, description, - /// address line 1, and postal code fields. + /// Configures validation rules requiring , + /// , , and + /// to be present, with length limits on the name, description, + /// address line 1, and postal code fields. /// public CreateBreweryValidator() { @@ -56,4 +56,4 @@ public class CreateBreweryValidator : AbstractValidator .When(x => x.Location is not null) .WithMessage("Postal code cannot exceed 20 characters."); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs index 360de7c..da74352 100644 --- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryCommand.cs @@ -3,6 +3,6 @@ using MediatR; namespace Features.Breweries.Commands.DeleteBrewery; /// -/// Deletes a brewery post by its unique identifier. +/// Deletes a brewery post by its unique identifier. /// -public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest; +public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs index 44675f5..41eabde 100644 --- a/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/DeleteBrewery/DeleteBreweryHandler.cs @@ -4,12 +4,14 @@ using MediatR; namespace Features.Breweries.Commands.DeleteBrewery; /// -/// Handles by deleting the matching brewery post. +/// Handles by deleting the matching brewery post. /// /// Repository used to delete the brewery post. public class DeleteBreweryHandler(IBreweryRepository repository) : IRequestHandler { - public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) => - repository.DeleteAsync(request.BreweryPostId); -} + public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) + { + return repository.DeleteAsync(request.BreweryPostId); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs index b4126b2..47ac0c1 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryCommand.cs @@ -4,7 +4,7 @@ using MediatR; namespace Features.Breweries.Commands.UpdateBrewery; /// -/// Location data for an existing brewery post, supplied as part of . +/// Location data for an existing brewery post, supplied as part of . /// public record UpdateBreweryLocation( Guid BreweryPostLocationId, @@ -16,8 +16,8 @@ public record UpdateBreweryLocation( ); /// -/// Updates an existing brewery post. Bound directly from the request body of PUT /api/brewery/{id}. -/// A null clears the brewery's location. +/// Updates an existing brewery post. Bound directly from the request body of PUT /api/brewery/{id}. +/// A null clears the brewery's location. /// public record UpdateBreweryCommand( Guid BreweryPostId, @@ -25,4 +25,4 @@ public record UpdateBreweryCommand( string BreweryName, string Description, UpdateBreweryLocation? Location -) : IRequest; +) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs index 8cac769..9462bd3 100644 --- a/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs +++ b/web/backend/Features/Features.Breweries/Commands/UpdateBrewery/UpdateBreweryHandler.cs @@ -6,41 +6,43 @@ using MediatR; namespace Features.Breweries.Commands.UpdateBrewery; /// -/// Handles by persisting changes to an existing brewery post. +/// Handles by persisting changes to an existing brewery post. /// /// Repository used to persist the updated brewery post. public class UpdateBreweryHandler(IBreweryRepository repository) : IRequestHandler { /// - /// Updates an existing brewery post. If has no Location, - /// the brewery's location is cleared. + /// Updates an existing brewery post. If has no Location, + /// the brewery's location is cleared. /// /// The updated details of the brewery post. /// A token to observe for cancellation requests. /// The updated brewery post. public async Task Handle(UpdateBreweryCommand request, CancellationToken cancellationToken) { - var entity = new BreweryPost + BreweryPost entity = new() { BreweryPostId = request.BreweryPostId, PostedById = request.PostedById, BreweryName = request.BreweryName, Description = request.Description, UpdatedAt = DateTime.UtcNow, - Location = request.Location is null ? null : new BreweryPostLocation - { - BreweryPostLocationId = request.Location.BreweryPostLocationId, - BreweryPostId = request.BreweryPostId, - CityId = request.Location.CityId, - AddressLine1 = request.Location.AddressLine1, - AddressLine2 = request.Location.AddressLine2, - PostalCode = request.Location.PostalCode, - Coordinates = request.Location.Coordinates, - }, + Location = request.Location is null + ? null + : new BreweryPostLocation + { + BreweryPostLocationId = request.Location.BreweryPostLocationId, + BreweryPostId = request.BreweryPostId, + CityId = request.Location.CityId, + AddressLine1 = request.Location.AddressLine1, + AddressLine2 = request.Location.AddressLine2, + PostalCode = request.Location.PostalCode, + Coordinates = request.Location.Coordinates + } }; await repository.UpdateAsync(entity); return entity.ToDto(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs index db5ecdc..bdff8fc 100644 --- a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs +++ b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs @@ -12,11 +12,11 @@ using Shared.Contracts; namespace Features.Breweries.Controllers; /// -/// Provides CRUD endpoints for managing brewery posts. +/// Provides CRUD endpoints for managing brewery posts. /// /// -/// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default; read endpoints -/// ( and ) opt out via [AllowAnonymous]. +/// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default; read endpoints +/// ( and ) opt out via [AllowAnonymous]. /// /// Used to dispatch brewery commands and queries to their handlers. [ApiController] @@ -25,75 +25,84 @@ namespace Features.Breweries.Controllers; public class BreweryController(IMediator mediator) : ControllerBase { /// - /// Retrieves a single brewery post by its unique identifier. + /// Retrieves a single brewery post by its unique identifier. /// /// Allows anonymous access. /// The unique identifier of the brewery post to retrieve. /// - /// A 200 OK result wrapping a of if found; - /// otherwise a 404 Not Found result wrapping a error message. + /// A 200 OK result wrapping a of if found; + /// otherwise a 404 Not Found result wrapping a error message. /// [AllowAnonymous] [HttpGet("{id:guid}")] public async Task>> GetById(Guid id) { - var brewery = await mediator.Send(new GetBreweryByIdQuery(id)); + BreweryDto? brewery = await mediator.Send(new GetBreweryByIdQuery(id)); if (brewery is null) return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." }); return Ok(new ResponseBody { Message = "Brewery retrieved successfully.", - Payload = brewery, + Payload = brewery }); } /// - /// Retrieves a paginated list of brewery posts. + /// Retrieves a paginated list of brewery posts. /// /// Allows anonymous access. /// The maximum number of brewery posts to return, or null for no limit. /// The number of brewery posts to skip before returning results, or null for no offset. - /// A 200 OK result wrapping a of a collection of . + /// A 200 OK result wrapping a of a collection of . [AllowAnonymous] [HttpGet] public async Task>>> GetAll( [FromQuery] int? limit, [FromQuery] int? offset) { - var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset)); + IEnumerable breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset)); return Ok(new ResponseBody> { Message = "Breweries retrieved successfully.", - Payload = breweries, + Payload = breweries }); } /// - /// Creates a new brewery post. + /// Creates a new brewery post. /// /// The brewery details to create, including the posting user, name, description, and location. - /// A 201 Created result wrapping a of the newly created . + /// + /// A 201 Created result wrapping a of the newly created + /// . + /// [HttpPost] public async Task>> Create([FromBody] CreateBreweryCommand command) { - var brewery = await mediator.Send(command); + BreweryDto brewery = await mediator.Send(command); return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody { Message = "Brewery created successfully.", - Payload = brewery, + Payload = brewery }); } /// - /// Updates an existing brewery post. + /// Updates an existing brewery post. /// - /// The unique identifier of the brewery post from the route, which must match 's ID. - /// The updated brewery details, including the posting user, name, description, and optional location. + /// + /// The unique identifier of the brewery post from the route, which must match + /// 's ID. + /// + /// + /// The updated brewery details, including the posting user, name, description, and optional + /// location. + /// /// - /// A 200 OK result wrapping a of the updated if the - /// update succeeds; otherwise a 400 Bad Request result wrapping a error message - /// when the route ID does not match the payload ID. + /// A 200 OK result wrapping a of the updated if the + /// update succeeds; otherwise a 400 Bad Request result wrapping a error message + /// when the route ID does not match the payload ID. /// [HttpPut("{id:guid}")] public async Task>> Update(Guid id, [FromBody] UpdateBreweryCommand command) @@ -101,23 +110,23 @@ public class BreweryController(IMediator mediator) : ControllerBase if (command.BreweryPostId != id) return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." }); - var brewery = await mediator.Send(command); + BreweryDto brewery = await mediator.Send(command); return Ok(new ResponseBody { Message = "Brewery updated successfully.", - Payload = brewery, + Payload = brewery }); } /// - /// Deletes a brewery post. + /// Deletes a brewery post. /// /// The unique identifier of the brewery post to delete. - /// A 200 OK result wrapping a confirming the deletion. + /// A 200 OK result wrapping a confirming the deletion. [HttpDelete("{id:guid}")] public async Task> Delete(Guid id) { await mediator.Send(new DeleteBreweryCommand(id)); return Ok(new ResponseBody { Message = "Brewery deleted successfully." }); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs index 6fc01db..957a7e6 100644 --- a/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Breweries/DependencyInjection/FeaturesBreweriesServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Breweries.DependencyInjection; /// -/// Registers the services owned by the Breweries feature slice. +/// Registers the services owned by the Breweries feature slice. /// public static class FeaturesBreweriesServiceCollectionExtensions { @@ -13,4 +13,4 @@ public static class FeaturesBreweriesServiceCollectionExtensions services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index c05c48d..4186773 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -1,89 +1,89 @@ namespace Features.Breweries.Dtos; /// -/// Represents the location details of an existing brewery, as returned in . +/// Represents the location details of an existing brewery, as returned in . /// public class BreweryLocationDto { /// - /// The unique identifier of the brewery's location record. + /// The unique identifier of the brewery's location record. /// public Guid BreweryPostLocationId { get; set; } /// - /// The unique identifier of the brewery post that this location belongs to. + /// The unique identifier of the brewery post that this location belongs to. /// public Guid BreweryPostId { get; set; } /// - /// The unique identifier of the city in which the brewery is located. + /// The unique identifier of the city in which the brewery is located. /// public Guid CityId { get; set; } /// - /// The primary street address line of the brewery. + /// The primary street address line of the brewery. /// public string AddressLine1 { get; set; } = string.Empty; /// - /// An optional secondary address line (e.g. suite or unit number). + /// An optional secondary address line (e.g. suite or unit number). /// public string? AddressLine2 { get; set; } /// - /// The postal/ZIP code of the brewery's address. + /// The postal/ZIP code of the brewery's address. /// public string PostalCode { get; set; } = string.Empty; /// - /// The optional geographic coordinates of the brewery, in a raw binary representation. + /// The optional geographic coordinates of the brewery, in a raw binary representation. /// public byte[]? Coordinates { get; set; } } /// -/// Represents a brewery post as returned by the brewery endpoints, including its metadata and -/// optional location. +/// Represents a brewery post as returned by the brewery endpoints, including its metadata and +/// optional location. /// public class BreweryDto { /// - /// The unique identifier of the brewery post. + /// The unique identifier of the brewery post. /// public Guid BreweryPostId { get; set; } /// - /// The unique identifier of the user account that created the brewery post. + /// The unique identifier of the user account that created the brewery post. /// public Guid PostedById { get; set; } /// - /// The name of the brewery. + /// The name of the brewery. /// public string BreweryName { get; set; } = string.Empty; /// - /// A description of the brewery. + /// A description of the brewery. /// public string Description { get; set; } = string.Empty; /// - /// The date and time at which the brewery post was created. + /// The date and time at which the brewery post was created. /// public DateTime CreatedAt { get; set; } /// - /// The date and time at which the brewery post was last updated, or null if it has never been updated. + /// The date and time at which the brewery post was last updated, or null if it has never been updated. /// public DateTime? UpdatedAt { get; set; } /// - /// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post. + /// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post. /// public byte[]? Timer { get; set; } /// - /// The location details of the brewery, or null if no location is associated with it. + /// The location details of the brewery, or null if no location is associated with it. /// public BreweryLocationDto? Location { get; set; } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index 90db7a3..baeef35 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -3,34 +3,39 @@ using Domain.Entities; namespace Features.Breweries.Dtos; /// -/// Maps domain entities to their wire representation. +/// Maps domain entities to their wire representation. /// public static class BreweryDtoMapper { /// - /// Maps a domain entity to its representation, - /// including its location, if present. + /// Maps a domain entity to its representation, + /// including its location, if present. /// /// The brewery post entity to map. - /// The mapped . - public static BreweryDto ToDto(this BreweryPost brewery) => new() + /// The mapped . + public static BreweryDto ToDto(this BreweryPost brewery) { - BreweryPostId = brewery.BreweryPostId, - PostedById = brewery.PostedById, - BreweryName = brewery.BreweryName, - Description = brewery.Description, - CreatedAt = brewery.CreatedAt, - UpdatedAt = brewery.UpdatedAt, - Timer = brewery.Timer, - Location = brewery.Location is null ? null : new BreweryLocationDto + return new BreweryDto { - BreweryPostLocationId = brewery.Location.BreweryPostLocationId, - BreweryPostId = brewery.Location.BreweryPostId, - CityId = brewery.Location.CityId, - AddressLine1 = brewery.Location.AddressLine1, - AddressLine2 = brewery.Location.AddressLine2, - PostalCode = brewery.Location.PostalCode, - Coordinates = brewery.Location.Coordinates, - }, - }; -} + BreweryPostId = brewery.BreweryPostId, + PostedById = brewery.PostedById, + BreweryName = brewery.BreweryName, + Description = brewery.Description, + CreatedAt = brewery.CreatedAt, + UpdatedAt = brewery.UpdatedAt, + Timer = brewery.Timer, + Location = brewery.Location is null + ? null + : new BreweryLocationDto + { + BreweryPostLocationId = brewery.Location.BreweryPostLocationId, + BreweryPostId = brewery.Location.BreweryPostId, + CityId = brewery.Location.CityId, + AddressLine1 = brewery.Location.AddressLine1, + AddressLine2 = brewery.Location.AddressLine2, + PostalCode = brewery.Location.PostalCode, + Coordinates = brewery.Location.Coordinates + } + }; + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Features.Breweries.csproj b/web/backend/Features/Features.Breweries/Features.Breweries.csproj index 015aa3e..d83006a 100644 --- a/web/backend/Features/Features.Breweries/Features.Breweries.csproj +++ b/web/backend/Features/Features.Breweries/Features.Breweries.csproj @@ -1,19 +1,19 @@ - - net10.0 - enable - enable - Features.Breweries - + + net10.0 + enable + enable + Features.Breweries + - - - + + + - - - - - - + + + + + + diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs index e758625..314d7a9 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesHandler.cs @@ -1,3 +1,4 @@ +using Domain.Entities; using Features.Breweries.Dtos; using Features.Breweries.Repository; using MediatR; @@ -5,7 +6,7 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweries; /// -/// Handles by retrieving a paginated list of brewery posts. +/// Handles by retrieving a paginated list of brewery posts. /// /// Repository used to query brewery post data. public class GetAllBreweriesHandler(IBreweryRepository repository) @@ -13,7 +14,7 @@ public class GetAllBreweriesHandler(IBreweryRepository repository) { public async Task> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken) { - var breweries = await repository.GetAllAsync(request.Limit, request.Offset); + IEnumerable breweries = await repository.GetAllAsync(request.Limit, request.Offset); return breweries.Select(b => b.ToDto()); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs index d8caea2..ef3c437 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweries/GetAllBreweriesQuery.cs @@ -4,6 +4,6 @@ using MediatR; namespace Features.Breweries.Queries.GetAllBreweries; /// -/// Retrieves a paginated list of brewery posts. +/// Retrieves a paginated list of brewery posts. /// -public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest>; +public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest>; \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs index efbd3c5..0ad63b9 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdHandler.cs @@ -1,3 +1,4 @@ +using Domain.Entities; using Features.Breweries.Dtos; using Features.Breweries.Repository; using MediatR; @@ -5,7 +6,7 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryById; /// -/// Handles by looking up the matching brewery post. +/// Handles by looking up the matching brewery post. /// /// Repository used to query brewery post data. public class GetBreweryByIdHandler(IBreweryRepository repository) @@ -13,7 +14,7 @@ public class GetBreweryByIdHandler(IBreweryRepository repository) { public async Task Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken) { - var brewery = await repository.GetByIdAsync(request.BreweryPostId); + BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId); return brewery?.ToDto(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs index c753991..7c259a1 100644 --- a/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryById/GetBreweryByIdQuery.cs @@ -4,6 +4,6 @@ using MediatR; namespace Features.Breweries.Queries.GetBreweryById; /// -/// Retrieves a single brewery post by its unique identifier. +/// Retrieves a single brewery post by its unique identifier. /// -public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest; +public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index e8753e3..71523e2 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -1,3 +1,4 @@ +using System.Data; using System.Data.Common; using Domain.Entities; using Infrastructure.Sql; @@ -5,51 +6,48 @@ using Infrastructure.Sql; namespace Features.Breweries.Repository; /// -/// ADO.NET-based implementation of backed by SQL Server stored -/// procedures. +/// ADO.NET-based implementation of backed by SQL Server stored +/// procedures. /// /// The factory used to create database connections. public class BreweryRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IBreweryRepository { /// - /// Retrieves a brewery post by ID using the USP_GetBreweryById stored procedure. + /// Retrieves a brewery post by ID using the USP_GetBreweryById stored procedure. /// /// The unique identifier of the brewery post. - /// The matching , or null if not found. + /// The matching , or null if not found. /// Thrown when the database command fails. public async Task GetByIdAsync(Guid id) { - await using var connection = await CreateConnection(); - await using var command = connection.CreateCommand(); - command.CommandType = System.Data.CommandType.StoredProcedure; + await using DbConnection connection = await CreateConnection(); + await using DbCommand command = connection.CreateCommand(); + command.CommandType = CommandType.StoredProcedure; command.CommandText = "USP_GetBreweryById"; AddParameter(command, "@BreweryPostID", id); - await using var reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) - { - return MapToEntity(reader); - } + await using DbDataReader reader = await command.ExecuteReaderAsync(); + if (await reader.ReadAsync()) return MapToEntity(reader); return null; } /// - /// Retrieves all brewery posts, optionally paginated, using the USP_GetAllBreweries - /// stored procedure. The @Limit and @Offset parameters are only added when their - /// corresponding argument has a value. + /// Retrieves all brewery posts, optionally paginated, using the USP_GetAllBreweries + /// stored procedure. The @Limit and @Offset parameters are only added when their + /// corresponding argument has a value. /// /// The maximum number of records to return, or null for no limit. /// The number of records to skip, or null for no offset. - /// The collection of matching records. + /// The collection of matching records. /// Thrown when the database command fails. public async Task> GetAllAsync(int? limit, int? offset) { - 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_GetAllBreweries"; - command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandType = CommandType.StoredProcedure; if (limit.HasValue) AddParameter(command, "@Limit", limit.Value); @@ -57,30 +55,27 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) if (offset.HasValue) AddParameter(command, "@Offset", offset.Value); - await using var reader = await command.ExecuteReaderAsync(); - var breweries = new List(); + await using DbDataReader reader = await command.ExecuteReaderAsync(); + List breweries = new(); - while (await reader.ReadAsync()) - { - breweries.Add(MapToEntity(reader)); - } + while (await reader.ReadAsync()) breweries.Add(MapToEntity(reader)); return breweries; } /// - /// Updates a brewery post's name and description, and upserts or clears its location, using the - /// USP_UpdateBrewery stored procedure. When .Location is - /// null, any existing location for the brewery is removed. + /// Updates a brewery post's name and description, and upserts or clears its location, using the + /// USP_UpdateBrewery stored procedure. When .Location is + /// null, any existing location for the brewery is removed. /// /// The brewery post containing updated values. /// Thrown when the database command fails. public async Task UpdateAsync(BreweryPost brewery) { - 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_UpdateBrewery"; - command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@BreweryPostID", brewery.BreweryPostId); AddParameter(command, "@BreweryName", brewery.BreweryName); @@ -96,40 +91,37 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) } /// - /// Deletes a brewery post by ID using the USP_DeleteBrewery stored procedure. Its location - /// and photos are removed via cascading foreign keys. + /// Deletes a brewery post by ID using the USP_DeleteBrewery stored procedure. Its location + /// and photos are removed via cascading foreign keys. /// /// The unique identifier of the brewery post to delete. /// Thrown when the database command fails. public async Task DeleteAsync(Guid id) { - 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_DeleteBrewery"; - command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandType = CommandType.StoredProcedure; AddParameter(command, "@BreweryPostID", id); await command.ExecuteNonQueryAsync(); } /// - /// Creates a new brewery post and its location using the USP_CreateBrewery stored procedure. + /// Creates a new brewery post and its location using the USP_CreateBrewery stored procedure. /// /// The brewery post to create. Must have a non-null Location. - /// Thrown when .Location is null. + /// Thrown when .Location is null. /// Thrown when the database command fails. public async Task CreateAsync(BreweryPost brewery) { - 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_CreateBrewery"; - command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandType = CommandType.StoredProcedure; - if (brewery.Location is null) - { - throw new ArgumentException("Location must be provided when creating a brewery."); - } + if (brewery.Location is null) throw new ArgumentException("Location must be provided when creating a brewery."); AddParameter(command, "@BreweryName", brewery.BreweryName); AddParameter(command, "@Description", brewery.Description); @@ -140,27 +132,26 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) AddParameter(command, "@PostalCode", brewery.Location?.PostalCode); AddParameter(command, "@Coordinates", brewery.Location?.Coordinates); await command.ExecuteNonQueryAsync(); - } /// - /// Maps the current row of a data reader to a entity, including its - /// rowversion Timer field and, if location columns are present in the result set, its - /// associated . + /// Maps the current row of a data reader to a entity, including its + /// rowversion Timer field and, if location columns are present in the result set, its + /// associated . /// /// The data reader positioned on the row to map. - /// The mapped instance. + /// The mapped instance. protected override BreweryPost MapToEntity(DbDataReader reader) { - var brewery = new BreweryPost(); + BreweryPost brewery = new(); - var ordBreweryPostId = reader.GetOrdinal("BreweryPostId"); - var ordPostedById = reader.GetOrdinal("PostedById"); - var ordBreweryName = reader.GetOrdinal("BreweryName"); - var ordDescription = reader.GetOrdinal("Description"); - var ordCreatedAt = reader.GetOrdinal("CreatedAt"); - var ordUpdatedAt = reader.GetOrdinal("UpdatedAt"); - var ordTimer = reader.GetOrdinal("Timer"); + int ordBreweryPostId = reader.GetOrdinal("BreweryPostId"); + int ordPostedById = reader.GetOrdinal("PostedById"); + int ordBreweryName = reader.GetOrdinal("BreweryName"); + int ordDescription = reader.GetOrdinal("Description"); + int ordCreatedAt = reader.GetOrdinal("CreatedAt"); + int ordUpdatedAt = reader.GetOrdinal("UpdatedAt"); + int ordTimer = reader.GetOrdinal("Timer"); brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId); brewery.PostedById = reader.GetGuid(ordPostedById); @@ -172,39 +163,39 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) // Read timer (varbinary/rowversion) robustly if (reader.IsDBNull(ordTimer)) - { brewery.Timer = null; - } else - { try { brewery.Timer = reader.GetFieldValue(ordTimer); } catch { - var length = reader.GetBytes(ordTimer, 0, null, 0, 0); - var buffer = new byte[length]; + long length = reader.GetBytes(ordTimer, 0, null, 0, 0); + byte[] buffer = new byte[length]; reader.GetBytes(ordTimer, 0, buffer, 0, (int)length); brewery.Timer = buffer; } - } // Map BreweryPostLocation if columns are present try { - var ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); + int ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); if (!reader.IsDBNull(ordLocationId)) { - var location = new BreweryPostLocation + BreweryPostLocation location = new() { BreweryPostLocationId = reader.GetGuid(ordLocationId), BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")), CityId = reader.GetGuid(reader.GetOrdinal("CityId")), AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")), - AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) ? null : reader.GetString(reader.GetOrdinal("AddressLine2")), + AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) + ? null + : reader.GetString(reader.GetOrdinal("AddressLine2")), PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")), - Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) ? null : reader.GetFieldValue(reader.GetOrdinal("Coordinates")) + Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) + ? null + : reader.GetFieldValue(reader.GetOrdinal("Coordinates")) }; brewery.Location = location; } @@ -218,21 +209,21 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) } /// - /// Helper method to add a parameter to a database command, converting null values to - /// . + /// Helper method to add a parameter to a database command, converting null values to + /// . /// /// The command to add the parameter to. /// The parameter name (including any prefix, e.g. "@BreweryName"). - /// The parameter value, or null to bind . + /// The parameter value, or null to bind . 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); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs index 8563190..d0a0972 100644 --- a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs @@ -3,41 +3,41 @@ using Domain.Entities; namespace Features.Breweries.Repository; /// -/// Repository for CRUD operations on brewery post records. +/// Repository for CRUD operations on brewery post records. /// public interface IBreweryRepository { /// - /// Retrieves a brewery post by its unique identifier. + /// Retrieves a brewery post by its unique identifier. /// /// The unique identifier of the brewery post. - /// The matching , or null if not found. + /// The matching , or null if not found. Task GetByIdAsync(Guid id); /// - /// Retrieves all brewery posts, optionally paginated. + /// Retrieves all brewery posts, optionally paginated. /// /// The maximum number of records to return, or null for no limit. /// The number of records to skip, or null for no offset. - /// The collection of matching records. + /// The collection of matching records. Task> GetAllAsync(int? limit, int? offset); /// - /// Updates an existing brewery post. + /// Updates an existing brewery post. /// /// The brewery post containing updated values. Task UpdateAsync(BreweryPost brewery); /// - /// Deletes a brewery post by its unique identifier. + /// Deletes a brewery post by its unique identifier. /// /// The unique identifier of the brewery post to delete. Task DeleteAsync(Guid id); /// - /// Creates a new brewery post, including its location details. + /// Creates a new brewery post, including its location details. /// /// The brewery post to create. Must have a non-null Location. - /// Thrown when has no Location. + /// Thrown when has no Location. Task CreateAsync(BreweryPost brewery); -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs index 1a61c17..4e91e62 100644 --- a/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs +++ b/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs @@ -10,9 +10,9 @@ public class SendRegistrationEmailHandlerTests [Fact] public async Task Handle_DelegatesToEmailDispatcher() { - var dispatcherMock = new Mock(); - var handler = new SendRegistrationEmailHandler(dispatcherMock.Object); - var command = new SendRegistrationEmailCommand("Aaron", "aaron@example.com", "token-123"); + Mock dispatcherMock = new(); + SendRegistrationEmailHandler handler = new(dispatcherMock.Object); + SendRegistrationEmailCommand command = new("Aaron", "aaron@example.com", "token-123"); await handler.Handle(command, CancellationToken.None); @@ -21,4 +21,4 @@ public class SendRegistrationEmailHandlerTests Times.Once ); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs index 7922e49..1563013 100644 --- a/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs +++ b/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs @@ -10,9 +10,9 @@ public class SendResendConfirmationEmailHandlerTests [Fact] public async Task Handle_DelegatesToEmailDispatcher() { - var dispatcherMock = new Mock(); - var handler = new SendResendConfirmationEmailHandler(dispatcherMock.Object); - var command = new SendResendConfirmationEmailCommand("Aaron", "aaron@example.com", "token-456"); + Mock dispatcherMock = new(); + SendResendConfirmationEmailHandler handler = new(dispatcherMock.Object); + SendResendConfirmationEmailCommand command = new("Aaron", "aaron@example.com", "token-456"); await handler.Handle(command, CancellationToken.None); @@ -21,4 +21,4 @@ public class SendResendConfirmationEmailHandlerTests Times.Once ); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj b/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj index f9e2036..3234d95 100644 --- a/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj +++ b/web/backend/Features/Features.Emails.Tests/Features.Emails.Tests.csproj @@ -1,25 +1,25 @@ - - net10.0 - enable - enable - false - Features.Emails.Tests - + + net10.0 + enable + enable + false + Features.Emails.Tests + - - - - - - - + + + + + + + - - - + + + - - - + + + diff --git a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs index d536dfa..b840a45 100644 --- a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs +++ b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs @@ -5,13 +5,15 @@ using Shared.Application.Emails; namespace Features.Emails.Commands.SendRegistrationEmail; /// -/// Handles , the cross-slice command sent by Features.Auth -/// after a new user registers. +/// Handles , the cross-slice command sent by Features.Auth +/// after a new user registers. /// /// Dispatcher used to render and send the email. public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher) : IRequestHandler { - public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken) => - emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken); -} + public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken) + { + return emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs index 4d5fcb1..d11f1d0 100644 --- a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs +++ b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs @@ -5,13 +5,16 @@ using Shared.Application.Emails; namespace Features.Emails.Commands.SendResendConfirmationEmail; /// -/// Handles , the cross-slice command sent by Features.Auth -/// when a user requests a fresh confirmation link. +/// Handles , the cross-slice command sent by Features.Auth +/// when a user requests a fresh confirmation link. /// /// Dispatcher used to render and send the email. public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher) : IRequestHandler { - public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken) => - emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken); -} + public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken) + { + return emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email, + request.ConfirmationToken); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs b/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs index 08aeabe..f5ac6be 100644 --- a/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Emails/DependencyInjection/FeaturesEmailsServiceCollectionExtensions.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.Emails.DependencyInjection; /// -/// Registers the services owned by the Emails feature slice. +/// Registers the services owned by the Emails feature slice. /// public static class FeaturesEmailsServiceCollectionExtensions { @@ -17,4 +17,4 @@ public static class FeaturesEmailsServiceCollectionExtensions services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Emails/Features.Emails.csproj b/web/backend/Features/Features.Emails/Features.Emails.csproj index cb5bd5a..cf71399 100644 --- a/web/backend/Features/Features.Emails/Features.Emails.csproj +++ b/web/backend/Features/Features.Emails/Features.Emails.csproj @@ -1,14 +1,14 @@ - - net10.0 - enable - enable - Features.Emails - + + net10.0 + enable + enable + Features.Emails + - - - - - + + + + + diff --git a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs index 8d40ce1..54ce972 100644 --- a/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs +++ b/web/backend/Features/Features.Emails/Services/EmailDispatcher.cs @@ -4,8 +4,8 @@ using Infrastructure.Email.Templates.Rendering; namespace Features.Emails.Services; /// -/// Default implementation of that renders email templates and dispatches -/// them via an . +/// Default implementation of that renders email templates and dispatches +/// them via an . /// /// Provider used to deliver the rendered emails. /// Provider used to render HTML email bodies from templates. @@ -15,26 +15,26 @@ public class EmailDispatcher( ) : IEmailDispatcher { /// - /// The base URL of the website, used to build confirmation links. Read from the - /// WEBSITE_BASE_URL environment variable. + /// The base URL of the website, used to build confirmation links. Read from the + /// WEBSITE_BASE_URL environment variable. /// /// - /// Thrown at type initialization time when the WEBSITE_BASE_URL environment variable is not set. + /// Thrown at type initialization time when the WEBSITE_BASE_URL environment variable is not set. /// private static readonly string WebsiteBaseUrl = Environment.GetEnvironmentVariable("WEBSITE_BASE_URL") ?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set"); /// - /// Builds a confirmation link from the given token, renders the registration welcome email template, - /// and sends it to the newly created user. + /// Builds a confirmation link from the given token, renders the registration welcome email template, + /// and sends it to the newly created user. /// public async Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken) { - var confirmationLink = + string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; - var emailHtml = + string emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync( firstName, confirmationLink @@ -44,20 +44,20 @@ public class EmailDispatcher( email, "Welcome to The Biergarten App!", emailHtml, - isHtml: true + true ); } /// - /// Builds a confirmation link from the given token, renders the resend-confirmation email template, - /// and sends it to the user. + /// Builds a confirmation link from the given token, renders the resend-confirmation email template, + /// and sends it to the user. /// public async Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken) { - var confirmationLink = + string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; - var emailHtml = + string emailHtml = await emailTemplateProvider.RenderResendConfirmationEmailAsync( firstName, confirmationLink @@ -67,7 +67,7 @@ public class EmailDispatcher( email, "Confirm Your Email - The Biergarten App", emailHtml, - isHtml: true + true ); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs b/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs index 62daa50..7abe1b5 100644 --- a/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs +++ b/web/backend/Features/Features.Emails/Services/IEmailDispatcher.cs @@ -1,12 +1,12 @@ namespace Features.Emails.Services; /// -/// Defines operations for sending account-related emails, such as registration and confirmation resend emails. +/// Defines operations for sending account-related emails, such as registration and confirmation resend emails. /// public interface IEmailDispatcher { /// - /// Sends a welcome email containing an account confirmation link to a newly registered user. + /// Sends a welcome email containing an account confirmation link to a newly registered user. /// /// The recipient's first name, used to personalize the email. /// The recipient's email address. @@ -15,11 +15,11 @@ public interface IEmailDispatcher Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken); /// - /// Sends an email containing a fresh account confirmation link to a user who requested a resend. + /// Sends an email containing a fresh account confirmation link to a user who requested a resend. /// /// The recipient's first name, used to personalize the email. /// The recipient's email address. /// The confirmation token to embed in the confirmation link. /// A task that completes once the email has been sent. Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken); -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs b/web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs index f972896..66ce813 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs +++ b/web/backend/Features/Features.UserManagement.Tests/Commands/UpdateUserHandlerTests.cs @@ -10,13 +10,13 @@ public class UpdateUserHandlerTests [Fact] public async Task Handle_DelegatesToRepository() { - var repoMock = new Mock(); - var handler = new UpdateUserHandler(repoMock.Object); - var user = new UserAccount { UserAccountId = Guid.NewGuid() }; + Mock repoMock = new(); + UpdateUserHandler handler = new(repoMock.Object); + UserAccount user = new() { UserAccountId = Guid.NewGuid() }; repoMock.Setup(r => r.UpdateAsync(user)).Returns(Task.CompletedTask); await handler.Handle(new UpdateUserCommand(user), CancellationToken.None); repoMock.Verify(r => r.UpdateAsync(user), Times.Once); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj b/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj index 621ef73..3813b43 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj +++ b/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj @@ -1,26 +1,26 @@ - - net10.0 - enable - enable - false - Features.UserManagement.Tests - + + net10.0 + enable + enable + false + Features.UserManagement.Tests + - - - - - - - - + + + + + + + + - - - + + + - - - + + + diff --git a/web/backend/Features/Features.UserManagement.Tests/Queries/GetAllUsersHandlerTests.cs b/web/backend/Features/Features.UserManagement.Tests/Queries/GetAllUsersHandlerTests.cs index d5d3014..9505515 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Queries/GetAllUsersHandlerTests.cs +++ b/web/backend/Features/Features.UserManagement.Tests/Queries/GetAllUsersHandlerTests.cs @@ -1,7 +1,7 @@ using Domain.Entities; -using FluentAssertions; using Features.UserManagement.Queries.GetAllUsers; using Features.UserManagement.Repository; +using FluentAssertions; using Moq; namespace Features.UserManagement.Tests.Queries; @@ -11,13 +11,13 @@ public class GetAllUsersHandlerTests [Fact] public async Task Handle_PassesLimitAndOffset_ToRepository() { - var repoMock = new Mock(); - var handler = new GetAllUsersHandler(repoMock.Object); + Mock repoMock = new(); + GetAllUsersHandler handler = new(repoMock.Object); repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty()); - var result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None); + IEnumerable result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None); result.Should().BeEmpty(); repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement.Tests/Queries/GetUserByIdHandlerTests.cs b/web/backend/Features/Features.UserManagement.Tests/Queries/GetUserByIdHandlerTests.cs index 2136c88..23ca061 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Queries/GetUserByIdHandlerTests.cs +++ b/web/backend/Features/Features.UserManagement.Tests/Queries/GetUserByIdHandlerTests.cs @@ -1,16 +1,16 @@ using Domain.Entities; using Domain.Exceptions; -using FluentAssertions; using Features.UserManagement.Queries.GetUserById; using Features.UserManagement.Repository; +using FluentAssertions; using Moq; namespace Features.UserManagement.Tests.Queries; public class GetUserByIdHandlerTests { - private readonly Mock _repoMock = new(); private readonly GetUserByIdHandler _handler; + private readonly Mock _repoMock = new(); public GetUserByIdHandlerTests() { @@ -20,10 +20,10 @@ public class GetUserByIdHandlerTests [Fact] public async Task Handle_ReturnsUser_WhenFound() { - var user = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "test" }; + UserAccount user = new() { UserAccountId = Guid.NewGuid(), Username = "test" }; _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); - var result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None); + UserAccount result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None); result.Should().Be(user); } @@ -31,11 +31,11 @@ public class GetUserByIdHandlerTests [Fact] public async Task Handle_Throws_WhenNotFound() { - var id = Guid.NewGuid(); + Guid id = Guid.NewGuid(); _repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null); - var act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None); + Func> act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None); await act.Should().ThrowAsync(); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs index 1266eb3..6454219 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs +++ b/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs @@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory { private readonly DbConnection _conn = conn; - public DbConnection CreateConnection() => _conn; -} + public DbConnection CreateConnection() + { + return _conn; + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs b/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs index ba393ba..d38bc33 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs +++ b/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs @@ -1,18 +1,21 @@ using Apps72.Dev.Data.DbMocker; -using FluentAssertions; +using Domain.Entities; using Features.UserManagement.Repository; +using FluentAssertions; namespace Features.UserManagement.Tests.Repository; public class UserAccountRepositoryTests { - private static UserAccountRepository CreateRepo(MockDbConnection conn) => - new(new TestConnectionFactory(conn)); + private static UserAccountRepository CreateRepo(MockDbConnection conn) + { + return new UserAccountRepository(new TestConnectionFactory(conn)); + } [Fact] public async Task GetByIdAsync_ReturnsRow_Mapped() { - var conn = new MockDbConnection(); + MockDbConnection conn = new(); conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById") .ReturnsTable( MockTable @@ -40,8 +43,8 @@ public class UserAccountRepositoryTests ) ); - var repo = CreateRepo(conn); - var result = await repo.GetByIdAsync( + UserAccountRepository repo = CreateRepo(conn); + UserAccount? result = await repo.GetByIdAsync( Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") ); @@ -53,7 +56,7 @@ public class UserAccountRepositoryTests [Fact] public async Task GetAllAsync_ReturnsMultipleRows() { - var conn = new MockDbConnection(); + MockDbConnection conn = new(); conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts") .ReturnsTable( MockTable @@ -92,19 +95,19 @@ public class UserAccountRepositoryTests ) ); - var repo = CreateRepo(conn); - var results = (await repo.GetAllAsync(null, null)).ToList(); + UserAccountRepository repo = CreateRepo(conn); + List results = (await repo.GetAllAsync(null, null)).ToList(); results.Should().HaveCount(2); results .Select(r => r.Username) .Should() - .BeEquivalentTo(new[] { "a", "b" }); + .BeEquivalentTo("a", "b"); } [Fact] public async Task GetByUsername_ReturnsRow() { - var conn = new MockDbConnection(); + MockDbConnection conn = new(); conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername" ) @@ -134,8 +137,8 @@ public class UserAccountRepositoryTests ) ); - var repo = CreateRepo(conn); - var result = await repo.GetByUsernameAsync("lookupuser"); + UserAccountRepository repo = CreateRepo(conn); + UserAccount? result = await repo.GetByUsernameAsync("lookupuser"); result.Should().NotBeNull(); result!.Email.Should().Be("lookup@example.com"); } @@ -143,7 +146,7 @@ public class UserAccountRepositoryTests [Fact] public async Task GetByEmail_ReturnsRow() { - var conn = new MockDbConnection(); + MockDbConnection conn = new(); conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") .ReturnsTable( MockTable @@ -171,9 +174,9 @@ public class UserAccountRepositoryTests ) ); - var repo = CreateRepo(conn); - var result = await repo.GetByEmailAsync("byemail@example.com"); + UserAccountRepository repo = CreateRepo(conn); + UserAccount? result = await repo.GetByEmailAsync("byemail@example.com"); result.Should().NotBeNull(); result!.Username.Should().Be("byemail"); } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs index 05b6775..4cc4b85 100644 --- a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs +++ b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserCommand.cs @@ -4,7 +4,7 @@ using MediatR; namespace Features.UserManagement.Commands.UpdateUser; /// -/// Updates an existing user account. Not currently exposed via any HTTP route, carried forward -/// from IUserService.UpdateAsync as-is. +/// Updates an existing user account. Not currently exposed via any HTTP route, carried forward +/// from IUserService.UpdateAsync as-is. /// -public record UpdateUserCommand(UserAccount UserAccount) : IRequest; +public record UpdateUserCommand(UserAccount UserAccount) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs index e1666bd..a6188b0 100644 --- a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs +++ b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs @@ -4,12 +4,14 @@ using MediatR; namespace Features.UserManagement.Commands.UpdateUser; /// -/// Handles by persisting changes to an existing user account. +/// Handles by persisting changes to an existing user account. /// /// Repository used to persist the updated user account. public class UpdateUserHandler(IUserAccountRepository repository) : IRequestHandler { - public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) => - repository.UpdateAsync(request.UserAccount); -} + public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) + { + return repository.UpdateAsync(request.UserAccount); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Controllers/UserController.cs b/web/backend/Features/Features.UserManagement/Controllers/UserController.cs index cd499ce..017834f 100644 --- a/web/backend/Features/Features.UserManagement/Controllers/UserController.cs +++ b/web/backend/Features/Features.UserManagement/Controllers/UserController.cs @@ -4,42 +4,41 @@ using Features.UserManagement.Queries.GetUserById; using MediatR; using Microsoft.AspNetCore.Mvc; -namespace Features.UserManagement.Controllers +namespace Features.UserManagement.Controllers; + +/// +/// Provides read-only endpoints for retrieving user accounts. +/// +/// Used to dispatch user queries to their handlers. +[ApiController] +[Route("api/[controller]")] +public class UserController(IMediator mediator) : ControllerBase { /// - /// Provides read-only endpoints for retrieving user accounts. + /// Retrieves a paginated list of user accounts. /// - /// Used to dispatch user queries to their handlers. - [ApiController] - [Route("api/[controller]")] - public class UserController(IMediator mediator) : ControllerBase + /// The maximum number of user accounts to return, or null for no limit. + /// The number of user accounts to skip before returning results, or null for no offset. + /// A 200 OK result containing the collection of entities. + [HttpGet] + public async Task>> GetAll( + [FromQuery] int? limit, + [FromQuery] int? offset + ) { - /// - /// Retrieves a paginated list of user accounts. - /// - /// The maximum number of user accounts to return, or null for no limit. - /// The number of user accounts to skip before returning results, or null for no offset. - /// A 200 OK result containing the collection of entities. - [HttpGet] - public async Task>> GetAll( - [FromQuery] int? limit, - [FromQuery] int? offset - ) - { - var users = await mediator.Send(new GetAllUsersQuery(limit, offset)); - return Ok(users); - } - - /// - /// Retrieves a single user account by its unique identifier. - /// - /// The unique identifier of the user account to retrieve. - /// A 200 OK result containing the matching . - [HttpGet("{id:guid}")] - public async Task> GetById(Guid id) - { - var user = await mediator.Send(new GetUserByIdQuery(id)); - return Ok(user); - } + IEnumerable users = await mediator.Send(new GetAllUsersQuery(limit, offset)); + return Ok(users); } -} + + /// + /// Retrieves a single user account by its unique identifier. + /// + /// The unique identifier of the user account to retrieve. + /// A 200 OK result containing the matching . + [HttpGet("{id:guid}")] + public async Task> GetById(Guid id) + { + UserAccount user = await mediator.Send(new GetUserByIdQuery(id)); + return Ok(user); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs b/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs index 2d09f36..aaf9e97 100644 --- a/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.UserManagement/DependencyInjection/FeaturesUserManagementServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Features.UserManagement.DependencyInjection; /// -/// Registers the services owned by the UserManagement feature slice. +/// Registers the services owned by the UserManagement feature slice. /// public static class FeaturesUserManagementServiceCollectionExtensions { @@ -13,4 +13,4 @@ public static class FeaturesUserManagementServiceCollectionExtensions services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj b/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj index aa9eb8b..b43da23 100644 --- a/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj +++ b/web/backend/Features/Features.UserManagement/Features.UserManagement.csproj @@ -1,20 +1,20 @@ - - net10.0 - enable - enable - Features.UserManagement - + + net10.0 + enable + enable + Features.UserManagement + - - - + + + - - - - - - - + + + + + + + diff --git a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs index 682fd39..88e654b 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs @@ -5,12 +5,14 @@ using MediatR; namespace Features.UserManagement.Queries.GetAllUsers; /// -/// Handles by retrieving a paginated list of user accounts. +/// Handles by retrieving a paginated list of user accounts. /// /// Repository used to query user account data. public class GetAllUsersHandler(IUserAccountRepository repository) : IRequestHandler> { - public Task> Handle(GetAllUsersQuery request, CancellationToken cancellationToken) => - repository.GetAllAsync(request.Limit, request.Offset); -} + public Task> Handle(GetAllUsersQuery request, CancellationToken cancellationToken) + { + return repository.GetAllAsync(request.Limit, request.Offset); + } +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs index dab6b3b..abac3a7 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersQuery.cs @@ -4,6 +4,6 @@ using MediatR; namespace Features.UserManagement.Queries.GetAllUsers; /// -/// Retrieves a paginated list of user accounts. +/// Retrieves a paginated list of user accounts. /// -public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest>; +public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest>; \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs index 1fe54a3..5c3e170 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs @@ -6,7 +6,7 @@ using MediatR; namespace Features.UserManagement.Queries.GetUserById; /// -/// Handles by looking up the matching user account. +/// Handles by looking up the matching user account. /// /// Repository used to query user account data. public class GetUserByIdHandler(IUserAccountRepository repository) @@ -15,9 +15,9 @@ public class GetUserByIdHandler(IUserAccountRepository repository) /// Thrown when no user account exists with the given ID. public async Task Handle(GetUserByIdQuery request, CancellationToken cancellationToken) { - var user = await repository.GetByIdAsync(request.UserAccountId); + UserAccount? user = await repository.GetByIdAsync(request.UserAccountId); if (user is null) throw new NotFoundException($"User with ID {request.UserAccountId} not found"); return user; } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs index b047bc4..9f88e6f 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs @@ -4,6 +4,6 @@ using MediatR; namespace Features.UserManagement.Queries.GetUserById; /// -/// Retrieves a single user account by its unique identifier. +/// Retrieves a single user account by its unique identifier. /// -public record GetUserByIdQuery(Guid UserAccountId) : IRequest; +public record GetUserByIdQuery(Guid UserAccountId) : IRequest; \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs index 6854cce..449d9ae 100644 --- a/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/IUserAccountRepository.cs @@ -1,51 +1,53 @@ +using Domain.Entities; + namespace Features.UserManagement.Repository; /// -/// Repository for CRUD operations on user account records. +/// Repository for CRUD operations on user account records. /// public interface IUserAccountRepository { /// - /// Retrieves a user account by its unique identifier. + /// Retrieves a user account by its unique identifier. /// /// The unique identifier of the user account. - /// The matching , or null if not found. - Task GetByIdAsync(Guid id); + /// The matching , or null if not found. + Task GetByIdAsync(Guid id); /// - /// Retrieves all user accounts, optionally paginated. + /// Retrieves all user accounts, optionally paginated. /// /// The maximum number of records to return, or null for no limit. /// The number of records to skip, or null for no offset. - /// The collection of matching records. - Task> GetAllAsync( + /// The collection of matching records. + Task> GetAllAsync( int? limit, int? offset ); /// - /// Updates an existing user account's details. + /// Updates an existing user account's details. /// /// The user account containing updated values. Must have a valid UserAccountId. - Task UpdateAsync(Domain.Entities.UserAccount userAccount); + Task UpdateAsync(UserAccount userAccount); /// - /// Deletes a user account by its unique identifier. + /// Deletes a user account by its unique identifier. /// /// The unique identifier of the user account to delete. Task DeleteAsync(Guid id); /// - /// Retrieves a user account by username. + /// Retrieves a user account by username. /// /// The username to search for. - /// The matching , or null if not found. - Task GetByUsernameAsync(string username); + /// The matching , or null if not found. + Task GetByUsernameAsync(string username); /// - /// Retrieves a user account by email address. + /// Retrieves a user account by email address. /// /// The email address to search for. - /// The matching , or null if not found. - Task GetByEmailAsync(string email); -} + /// The matching , or null if not found. + Task GetByEmailAsync(string email); +} \ No newline at end of file diff --git a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs index 41ec7f1..210f38e 100644 --- a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs @@ -1,53 +1,54 @@ using System.Data; using System.Data.Common; +using Domain.Entities; using Infrastructure.Sql; namespace Features.UserManagement.Repository; /// -/// ADO.NET-based implementation of backed by SQL Server -/// stored procedures. +/// ADO.NET-based implementation of backed by SQL Server +/// stored procedures. /// /// The factory used to create database connections. public class UserAccountRepository(ISqlConnectionFactory connectionFactory) - : Infrastructure.Sql.Repository(connectionFactory), + : Repository(connectionFactory), IUserAccountRepository { /// - /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. + /// Retrieves a user account by ID using the usp_GetUserAccountById stored procedure. /// /// The unique identifier of the user account. - /// The matching , or null if not found. + /// The matching , or null if not found. /// Thrown when the database command fails. - public async Task GetByIdAsync(Guid id) + public async Task GetByIdAsync(Guid id) { - 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", id); - await using var reader = await command.ExecuteReaderAsync(); + await using DbDataReader reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? MapToEntity(reader) : null; } /// - /// Retrieves all user accounts, optionally paginated, using the usp_GetAllUserAccounts - /// stored procedure. The @Limit and @Offset parameters are only added when their - /// corresponding argument has a value. + /// Retrieves all user accounts, optionally paginated, using the usp_GetAllUserAccounts + /// stored procedure. The @Limit and @Offset parameters are only added when their + /// corresponding argument has a value. /// /// The maximum number of records to return, or null for no limit. /// The number of records to skip, or null for no offset. - /// The collection of matching records. + /// The collection of matching records. /// Thrown when the database command fails. - public async Task> GetAllAsync( + public async Task> GetAllAsync( int? limit, int? offset ) { - 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_GetAllUserAccounts"; command.CommandType = CommandType.StoredProcedure; @@ -57,27 +58,24 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) if (offset.HasValue) AddParameter(command, "@Offset", offset.Value); - await using var reader = await command.ExecuteReaderAsync(); - var users = new List(); + await using DbDataReader reader = await command.ExecuteReaderAsync(); + List users = new(); - while (await reader.ReadAsync()) - { - users.Add(MapToEntity(reader)); - } + while (await reader.ReadAsync()) users.Add(MapToEntity(reader)); return users; } /// - /// Updates a user account's username, first name, last name, email, and date of birth using - /// the usp_UpdateUserAccount stored procedure. + /// Updates a user account's username, first name, last name, email, and date of birth using + /// the usp_UpdateUserAccount stored procedure. /// /// The user account containing updated values. Must have a valid UserAccountId. /// Thrown when the database command fails. - public async Task UpdateAsync(Domain.Entities.UserAccount userAccount) + public async Task UpdateAsync(UserAccount userAccount) { - 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_UpdateUserAccount"; command.CommandType = CommandType.StoredProcedure; @@ -92,14 +90,14 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) } /// - /// Deletes a user account by ID using the usp_DeleteUserAccount stored procedure. + /// Deletes a user account by ID using the usp_DeleteUserAccount stored procedure. /// /// The unique identifier of the user account to delete. /// Thrown when the database command fails. public async Task DeleteAsync(Guid id) { - 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_DeleteUserAccount"; command.CommandType = CommandType.StoredProcedure; @@ -108,57 +106,57 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) } /// - /// Retrieves a user account by username using the usp_GetUserAccountByUsername stored procedure. + /// Retrieves a user account by username using the usp_GetUserAccountByUsername stored procedure. /// /// The username to search for. - /// The matching , or null if not found. + /// The matching , or null if not found. /// Thrown when the database command fails. - public async Task GetByUsernameAsync( + public async Task GetByUsernameAsync( 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; } /// - /// Retrieves a user account by email address using the usp_GetUserAccountByEmail stored procedure. + /// Retrieves a user account by email address using the usp_GetUserAccountByEmail stored procedure. /// /// The email address to search for. - /// The matching , or null if not found. + /// The matching , or null if not found. /// Thrown when the database command fails. - public async Task GetByEmailAsync( + public async Task GetByEmailAsync( 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; } /// - /// Maps the current row of a data reader to a entity. + /// Maps the current row of a data reader to a entity. /// /// The data reader positioned on the row to map. - /// The mapped instance. - protected override Domain.Entities.UserAccount MapToEntity( + /// The mapped instance. + 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")), @@ -172,26 +170,26 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null - : (byte[])reader["Timer"], + : (byte[])reader["Timer"] }; } /// - /// Helper method to add a parameter to a database command, converting null values to - /// . + /// Helper method to add a parameter to a database command, converting null values to + /// . /// /// The command to add the parameter to. /// The parameter name (including any prefix, e.g. "@Username"). - /// The parameter value, or null to bind . + /// The parameter value, or null to bind . 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); } -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Footer.razor b/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Footer.razor index 52aa354..8f7f60a 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Footer.razor +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Footer.razor @@ -13,6 +13,5 @@ @code { - [Parameter] - public string? FooterText { get; set; } + [Parameter] public string? FooterText { get; set; } } diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Header.razor b/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Header.razor index 3e82013..02b67cf 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Header.razor +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Components/Header.razor @@ -2,8 +2,8 @@ diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj b/web/backend/Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj index 4dee74a..c52e3a8 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj @@ -1,26 +1,26 @@ - - net10.0 - enable - enable - + + net10.0 + enable + enable + - - - + + + - - - - - + + + + + diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor b/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor index 42db0ae..62e5b58 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/ResendConfirmation.razor @@ -1,5 +1,4 @@ @using Infrastructure.Email.Templates.Components - @@ -11,107 +10,113 @@ Resend Confirmation - The Biergarten App + + -
- -
- - +
+ + - -
+ + -
+
-
- - + + + - - - + + + - - + - - - + + + - - - + + + - -
-

- New Confirmation Link -

-
+

+ New Confirmation Link +

+
-

- Hi @Username, you requested another email confirmation - link. - Use the button below to verify your account. -

-
+

+ Hi @Username, you requested another email confirmation + link. + Use the button below to verify your account. +

+
- - - + - + + + + + + Confirm Email Again + + + + +
- - - +
+ + + - -
+ -
-
+
-

- This replacement link expires in 24 hours. -

-
+

+ This replacement link expires in 24 hours. +

+
-

- If you did not request this, you can safely ignore this email. -

-
+

+ If you did not request this, you can safely ignore this email. +

+
- -
+ +
+ + + + @code { - [Parameter] - public string Username { get; set; } = string.Empty; + [Parameter] public string Username { get; set; } = string.Empty; - [Parameter] - public string ConfirmationLink { get; set; } = string.Empty; + [Parameter] public string ConfirmationLink { get; set; } = string.Empty; } diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/UserRegistration.razor b/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/UserRegistration.razor index a96d5b0..7d80c3e 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/UserRegistration.razor +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Mail/UserRegistration.razor @@ -1,5 +1,4 @@ @using Infrastructure.Email.Templates.Components - @@ -11,108 +10,117 @@ Welcome to The Biergarten App! + + - - - - + +
- - - + +
+ +
+ + + - -
+ +
- -
- - + + + + - - - - + + + + - - - + - - - - + + + + - - -
-

- Welcome Aboard! -

-
+

+ Welcome Aboard! +

+
-

- Hi @Username, we're excited to have you join our - community of beer enthusiasts! -

-
+

+ Hi @Username, we're excited to have you join our + community of beer enthusiasts! +

+
- - - + - + + + + + + Confirm Your Email + + + + +
- - - + +
+ + + - -
+ -
-
+
-

- This confirmation link expires in 24 hours. -

-
+

+ This confirmation link expires in 24 hours. +

+
- + + +
+ +
@code { - [Parameter] - public string Username { get; set; } = string.Empty; + [Parameter] public string Username { get; set; } = string.Empty; - [Parameter] - public string ConfirmationLink { get; set; } = string.Empty; + [Parameter] public string ConfirmationLink { get; set; } = string.Empty; } diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs index 821df27..14281a3 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/EmailTemplateProvider.cs @@ -1,22 +1,26 @@ using Infrastructure.Email.Templates.Mail; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; using Microsoft.Extensions.Logging; namespace Infrastructure.Email.Templates.Rendering; /// -/// Service for rendering Razor email templates to HTML using HtmlRenderer. +/// Service for rendering Razor email templates to HTML using HtmlRenderer. /// -/// The service provider used to resolve dependencies for the Razor component rendering pipeline. -/// The logger factory passed to the used to render components. +/// +/// The service provider used to resolve dependencies for the Razor component rendering +/// pipeline. +/// +/// The logger factory passed to the used to render components. public class EmailTemplateProvider( IServiceProvider serviceProvider, ILoggerFactory loggerFactory ) : IEmailTemplateProvider { /// - /// Renders the UserRegisteredEmail template with the specified parameters. + /// Renders the UserRegisteredEmail template with the specified parameters. /// /// The username to include in the email /// The email confirmation link @@ -26,17 +30,17 @@ public class EmailTemplateProvider( string confirmationLink ) { - var parameters = new Dictionary + Dictionary parameters = new() { { nameof(UserRegistration.Username), username }, - { nameof(UserRegistration.ConfirmationLink), confirmationLink }, + { nameof(UserRegistration.ConfirmationLink), confirmationLink } }; return await RenderComponentAsync(parameters); } /// - /// Renders the ResendConfirmation template with the specified parameters. + /// Renders the ResendConfirmation template with the specified parameters. /// /// The username to include in the email /// The new confirmation link @@ -46,19 +50,19 @@ public class EmailTemplateProvider( string confirmationLink ) { - var parameters = new Dictionary + Dictionary parameters = new() { { nameof(ResendConfirmation.Username), username }, - { nameof(ResendConfirmation.ConfirmationLink), confirmationLink }, + { nameof(ResendConfirmation.ConfirmationLink), confirmationLink } }; return await RenderComponentAsync(parameters); } /// - /// Generic method to render any Razor component to HTML. - /// Creates a scoped , dispatches the render onto its renderer thread, - /// and returns the resulting HTML string. + /// Generic method to render any Razor component to HTML. + /// Creates a scoped , dispatches the render onto its renderer thread, + /// and returns the resulting HTML string. /// /// The type of the Razor component to render. /// A dictionary of parameter names and values to pass to the component. @@ -68,15 +72,15 @@ public class EmailTemplateProvider( ) where TComponent : IComponent { - await using var htmlRenderer = new HtmlRenderer( + await using HtmlRenderer htmlRenderer = new( serviceProvider, loggerFactory ); - var html = await htmlRenderer.Dispatcher.InvokeAsync(async () => + string html = await htmlRenderer.Dispatcher.InvokeAsync(async () => { - var parameterView = ParameterView.FromDictionary(parameters); - var output = await htmlRenderer.RenderComponentAsync( + ParameterView parameterView = ParameterView.FromDictionary(parameters); + HtmlRootComponent output = await htmlRenderer.RenderComponentAsync( parameterView ); @@ -85,4 +89,4 @@ public class EmailTemplateProvider( return html; } -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs index 2125025..77d3df0 100644 --- a/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email.Templates/Rendering/IEmailTemplateProvider.cs @@ -1,12 +1,12 @@ namespace Infrastructure.Email.Templates.Rendering; /// -/// Service for rendering Razor email templates to HTML. +/// Service for rendering Razor email templates to HTML. /// public interface IEmailTemplateProvider { /// - /// Renders the UserRegisteredEmail template with the specified parameters. + /// Renders the UserRegisteredEmail template with the specified parameters. /// /// The username to include in the email /// The email confirmation link @@ -17,7 +17,7 @@ public interface IEmailTemplateProvider ); /// - /// Renders the ResendConfirmation template with the specified parameters. + /// Renders the ResendConfirmation template with the specified parameters. /// /// The username to include in the email /// The new confirmation link @@ -26,4 +26,4 @@ public interface IEmailTemplateProvider string username, string confirmationLink ); -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs b/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs index 0a17b5d..7efd462 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email/IEmailProvider.cs @@ -1,12 +1,12 @@ namespace Infrastructure.Email; /// -/// Service for sending emails via SMTP. +/// Service for sending emails via SMTP. /// public interface IEmailProvider { /// - /// Sends an email to a single recipient. + /// Sends an email to a single recipient. /// /// Recipient email address /// Email subject line @@ -15,7 +15,7 @@ public interface IEmailProvider Task SendAsync(string to, string subject, string body, bool isHtml = true); /// - /// Sends an email to multiple recipients. + /// Sends an email to multiple recipients. /// /// List of recipient email addresses /// Email subject line @@ -27,4 +27,4 @@ public interface IEmailProvider string body, bool isHtml = true ); -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj b/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj index 717486f..f95550c 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj +++ b/web/backend/Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj @@ -1,12 +1,12 @@  - - net10.0 - enable - enable - Infrastructure.Email - + + net10.0 + enable + enable + Infrastructure.Email + - - - + + + diff --git a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs index 4b5aa94..706fca8 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs @@ -5,37 +5,54 @@ using MimeKit; namespace Infrastructure.Email; /// -/// SMTP email service implementation using MailKit. -/// Configured via environment variables. +/// SMTP email service implementation using MailKit. +/// Configured via environment variables. /// public class SmtpEmailProvider : IEmailProvider { - private readonly string _host; - private readonly int _port; - private readonly string? _username; - private readonly string? _password; - private readonly bool _useSsl; private readonly string _fromEmail; private readonly string _fromName; + private readonly string _host; + private readonly string? _password; + private readonly int _port; + private readonly string? _username; + private readonly bool _useSsl; /// - /// Initializes a new instance of , reading SMTP configuration - /// from environment variables. + /// Initializes a new instance of , reading SMTP configuration + /// from environment variables. /// /// - /// Reads the following environment variables: - /// - /// SMTP_HOST (required) - the SMTP server hostname. - /// SMTP_PORT (optional, default "587") - the SMTP server port, must be a valid integer. - /// SMTP_USERNAME (optional) - the username used for authentication. - /// SMTP_PASSWORD (optional) - the password used for authentication. - /// SMTP_USE_SSL (optional, default "true") - whether to use StartTls when connecting. - /// SMTP_FROM_EMAIL (required) - the email address used as the sender. - /// SMTP_FROM_NAME (optional, default "The Biergarten") - the display name used as the sender. - /// + /// Reads the following environment variables: + /// + /// + /// SMTP_HOST (required) - the SMTP server hostname. + /// + /// + /// SMTP_PORT (optional, default "587") - the SMTP server port, must be a valid integer. + /// + /// + /// SMTP_USERNAME (optional) - the username used for authentication. + /// + /// + /// SMTP_PASSWORD (optional) - the password used for authentication. + /// + /// + /// SMTP_USE_SSL (optional, default "true") - whether to use StartTls when connecting. + /// + /// + /// SMTP_FROM_EMAIL (required) - the email address used as the sender. + /// + /// + /// + /// SMTP_FROM_NAME (optional, default "The Biergarten") - the display name used as the + /// sender. + /// + /// + /// /// /// - /// Thrown when SMTP_HOST or SMTP_FROM_EMAIL is not set, or when SMTP_PORT is not a valid integer. + /// Thrown when SMTP_HOST or SMTP_FROM_EMAIL is not set, or when SMTP_PORT is not a valid integer. /// public SmtpEmailProvider() { @@ -45,19 +62,17 @@ public class SmtpEmailProvider : IEmailProvider "SMTP_HOST environment variable is not set" ); - var portString = + string portString = Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587"; if (!int.TryParse(portString, out _port)) - { throw new InvalidOperationException( $"SMTP_PORT '{portString}' is not a valid integer" ); - } _username = Environment.GetEnvironmentVariable("SMTP_USERNAME"); _password = Environment.GetEnvironmentVariable("SMTP_PASSWORD"); - var useSslString = + string useSslString = Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true"; _useSsl = bool.Parse(useSslString); @@ -73,7 +88,7 @@ public class SmtpEmailProvider : IEmailProvider } /// - /// Sends an email to a single recipient by delegating to the multi-recipient overload. + /// Sends an email to a single recipient by delegating to the multi-recipient overload. /// /// Recipient email address /// Email subject line @@ -91,15 +106,18 @@ public class SmtpEmailProvider : IEmailProvider } /// - /// Sends an email to multiple recipients using MailKit's . - /// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates - /// if credentials were configured, sends the message, and disconnects. + /// Sends an email to multiple recipients using MailKit's . + /// Connects using StartTls when SSL is enabled (or no encryption otherwise), authenticates + /// if credentials were configured, sends the message, and disconnects. /// /// List of recipient email addresses /// Email subject line /// Email body (HTML or plain text) /// Whether the body is HTML (default: true) - /// Thrown when connecting, authenticating, or sending via SMTP fails. The original exception is included as the inner exception. + /// + /// Thrown when connecting, authenticating, or sending via SMTP fails. The + /// original exception is included as the inner exception. + /// public async Task SendAsync( IEnumerable to, string subject, @@ -107,34 +125,27 @@ public class SmtpEmailProvider : IEmailProvider bool isHtml = true ) { - var message = new MimeMessage(); + MimeMessage message = new(); message.From.Add(new MailboxAddress(_fromName, _fromEmail)); - foreach (var recipient in to) - { - message.To.Add(MailboxAddress.Parse(recipient)); - } + foreach (string recipient in to) message.To.Add(MailboxAddress.Parse(recipient)); message.Subject = subject; - var bodyBuilder = new BodyBuilder(); + BodyBuilder bodyBuilder = new(); if (isHtml) - { bodyBuilder.HtmlBody = body; - } else - { bodyBuilder.TextBody = body; - } message.Body = bodyBuilder.ToMessageBody(); - using var client = new SmtpClient(); + using SmtpClient client = new(); try { // Determine the SecureSocketOptions based on SSL setting - var secureSocketOptions = _useSsl + SecureSocketOptions secureSocketOptions = _useSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None; @@ -145,9 +156,7 @@ public class SmtpEmailProvider : IEmailProvider !string.IsNullOrEmpty(_username) && !string.IsNullOrEmpty(_password) ) - { await client.AuthenticateAsync(_username, _password); - } await client.SendAsync(message); await client.DisconnectAsync(true); @@ -160,4 +169,4 @@ public class SmtpEmailProvider : IEmailProvider ); } } -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs index fca9d97..7fbf240 100644 --- a/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs @@ -3,12 +3,12 @@ using System.Security.Claims; namespace Infrastructure.Jwt; /// -/// Service for generating and validating JSON Web Tokens (JWTs) used for authentication. +/// Service for generating and validating JSON Web Tokens (JWTs) used for authentication. /// public interface ITokenInfrastructure { /// - /// Generates a signed JWT for the given user. + /// Generates a signed JWT for the given user. /// /// The unique identifier of the user the token is issued for. /// The username of the user, included as a claim. @@ -23,11 +23,14 @@ public interface ITokenInfrastructure ); /// - /// Validates a JWT and returns the resulting claims principal. + /// Validates a JWT and returns the resulting claims principal. /// /// The JWT string to validate. /// The symmetric secret used to verify the token's signature. - /// A representing the validated token's claims. - /// Thrown when the token is invalid, expired, or fails validation. + /// A representing the validated token's claims. + /// + /// Thrown when the token is invalid, expired, or fails + /// validation. + /// Task ValidateJwtAsync(string token, string secret); } \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj b/web/backend/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj index 59caedd..3823c8d 100644 --- a/web/backend/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj +++ b/web/backend/Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj @@ -1,23 +1,23 @@ - - net10.0 - enable - enable - Infrastructure.Jwt - + + net10.0 + enable + enable + Infrastructure.Jwt + - - - - + + + + - - - + + + diff --git a/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs index 3a6a75f..165197b 100644 --- a/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs @@ -1,24 +1,24 @@ using System.Security.Claims; using System.Text; +using Domain.Exceptions; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; -using Domain.Exceptions; namespace Infrastructure.Jwt; /// -/// Generates and validates HMAC-SHA256 signed JWTs using . +/// Generates and validates HMAC-SHA256 signed JWTs using . /// public class JwtInfrastructure : ITokenInfrastructure { /// - /// Generates a signed JWT containing the user's ID, username, issued-at time, expiry time, - /// and a unique token identifier (JTI), signed using HMAC-SHA256 with the provided secret. + /// Generates a signed JWT containing the user's ID, username, issued-at time, expiry time, + /// and a unique token identifier (JTI), signed using HMAC-SHA256 with the provided secret. /// /// - /// Sets the following registered claims: sub (userId), unique_name (username), - /// iat (current UTC time), exp (expiry), and jti (a newly generated GUID). + /// Sets the following registered claims: sub (userId), unique_name (username), + /// iat (current UTC time), exp (expiry), and jti (a newly generated GUID). /// /// The unique identifier of the user the token is issued for. /// The username of the user, included as a claim. @@ -32,31 +32,31 @@ public class JwtInfrastructure : ITokenInfrastructure string secret ) { - var handler = new JsonWebTokenHandler(); - var key = Encoding.UTF8.GetBytes(secret); - var claims = new List + JsonWebTokenHandler handler = new(); + byte[] key = Encoding.UTF8.GetBytes(secret); + List claims = new() { - new(JwtRegisteredClaimNames.Sub, userId.ToString()), - new(JwtRegisteredClaimNames.UniqueName, username), - new( + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(JwtRegisteredClaimNames.UniqueName, username), + new Claim( JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() ), - new( + new Claim( JwtRegisteredClaimNames.Exp, new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString() ), - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; - var tokenDescriptor = new SecurityTokenDescriptor + SecurityTokenDescriptor tokenDescriptor = new() { Subject = new ClaimsIdentity(claims), Expires = expiry, SigningCredentials = new SigningCredentials( new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256 - ), + ) }; return handler.CreateToken(tokenDescriptor); @@ -64,39 +64,39 @@ public class JwtInfrastructure : ITokenInfrastructure /// - /// Validates a JWT's signature and lifetime (issuer and audience validation are disabled), - /// using the provided secret as the HMAC-SHA256 symmetric signing key. + /// Validates a JWT's signature and lifetime (issuer and audience validation are disabled), + /// using the provided secret as the HMAC-SHA256 symmetric signing key. /// /// The JWT string to validate. /// The symmetric secret used to verify the token's signature (encoded as UTF-8 bytes). - /// A wrapping the validated token's claims identity. + /// A wrapping the validated token's claims identity. /// - /// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation - /// (including when validation itself throws, e.g. due to a malformed token or signature mismatch). + /// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation + /// (including when validation itself throws, e.g. due to a malformed token or signature mismatch). /// public async Task ValidateJwtAsync( string token, string secret ) { - var handler = new JsonWebTokenHandler(); - var keyBytes = Encoding.UTF8.GetBytes( + JsonWebTokenHandler handler = new(); + byte[] keyBytes = Encoding.UTF8.GetBytes( secret ); - var parameters = new TokenValidationParameters + TokenValidationParameters parameters = new() { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, - IssuerSigningKey = new SymmetricSecurityKey(keyBytes), + IssuerSigningKey = new SymmetricSecurityKey(keyBytes) }; try { - var result = await handler.ValidateTokenAsync(token, parameters); + TokenValidationResult? result = await handler.ValidateTokenAsync(token, parameters); if (!result.IsValid || result.ClaimsIdentity == null) throw new UnauthorizedAccessException(); - + return new ClaimsPrincipal(result.ClaimsIdentity); } catch (Exception e) diff --git a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs index f65632a..9f4ba50 100644 --- a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs @@ -5,7 +5,7 @@ using Konscious.Security.Cryptography; namespace Infrastructure.PasswordHashing; /// -/// Hashes and verifies passwords using the Argon2id algorithm via Konscious.Security.Cryptography. +/// Hashes and verifies passwords using the Argon2id algorithm via Konscious.Security.Cryptography. /// public class Argon2Infrastructure : IPasswordInfrastructure { @@ -15,67 +15,67 @@ public class Argon2Infrastructure : IPasswordInfrastructure private const int ArgonMemoryKb = 65536; // 64MB /// - /// Hashes a plaintext password using Argon2id with a newly generated 128-bit cryptographically - /// random salt, 4 iterations, 64MB of memory, and a degree of parallelism equal to the number of - /// available processors (minimum 1). + /// Hashes a plaintext password using Argon2id with a newly generated 128-bit cryptographically + /// random salt, 4 iterations, 64MB of memory, and a degree of parallelism equal to the number of + /// available processors (minimum 1). /// /// The plaintext password to hash. /// - /// A string of the form "{base64Salt}:{base64Hash}" containing the salt and the resulting - /// 256-bit hash, suitable for storage and later verification. + /// A string of the form "{base64Salt}:{base64Hash}" containing the salt and the resulting + /// 256-bit hash, suitable for storage and later verification. /// public string Hash(string password) { - var salt = RandomNumberGenerator.GetBytes(SaltSize); - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); + Argon2id argon2 = new(Encoding.UTF8.GetBytes(password)) { Salt = salt, DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), MemorySize = ArgonMemoryKb, - Iterations = ArgonIterations, + Iterations = ArgonIterations }; - var hash = argon2.GetBytes(HashSize); + byte[] hash = argon2.GetBytes(HashSize); return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; } /// - /// Verifies a plaintext password against a stored salt/hash string by recomputing the Argon2id - /// hash with the extracted salt and comparing it to the stored hash using a fixed-time comparison - /// to mitigate timing attacks. + /// Verifies a plaintext password against a stored salt/hash string by recomputing the Argon2id + /// hash with the extracted salt and comparing it to the stored hash using a fixed-time comparison + /// to mitigate timing attacks. /// /// The plaintext password to verify. /// - /// The stored string of the form "{base64Salt}:{base64Hash}" previously produced by . + /// The stored string of the form "{base64Salt}:{base64Hash}" previously produced by . /// /// - /// true if the password matches the stored hash; false if it does not match, the stored - /// string is malformed (e.g. not in the expected two-part format, or not valid base64), or any other - /// error occurs while verifying. + /// true if the password matches the stored hash; false if it does not match, the stored + /// string is malformed (e.g. not in the expected two-part format, or not valid base64), or any other + /// error occurs while verifying. /// public bool Verify(string password, string stored) { try { - var parts = stored.Split( + string[] parts = stored.Split( ':', StringSplitOptions.RemoveEmptyEntries ); if (parts.Length != 2) return false; - var salt = Convert.FromBase64String(parts[0]); - var expected = Convert.FromBase64String(parts[1]); + byte[] salt = Convert.FromBase64String(parts[0]); + byte[] expected = Convert.FromBase64String(parts[1]); - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + Argon2id argon2 = new(Encoding.UTF8.GetBytes(password)) { Salt = salt, DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), MemorySize = ArgonMemoryKb, - Iterations = ArgonIterations, + Iterations = ArgonIterations }; - var actual = argon2.GetBytes(expected.Length); + byte[] actual = argon2.GetBytes(expected.Length); return CryptographicOperations.FixedTimeEquals(actual, expected); } catch @@ -83,4 +83,4 @@ public class Argon2Infrastructure : IPasswordInfrastructure return false; } } -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs index 7b26103..3b04e21 100644 --- a/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.PasswordHashing/IPasswordInfrastructure.cs @@ -1,22 +1,22 @@ namespace Infrastructure.PasswordHashing; /// -/// Service for hashing and verifying user passwords. +/// Service for hashing and verifying user passwords. /// public interface IPasswordInfrastructure { /// - /// Hashes a plaintext password, generating a new random salt. + /// Hashes a plaintext password, generating a new random salt. /// /// The plaintext password to hash. /// A string encoding both the salt and the resulting hash, suitable for storage. public string Hash(string password); /// - /// Verifies a plaintext password against a previously stored hash. + /// Verifies a plaintext password against a previously stored hash. /// /// The plaintext password to verify. - /// The stored salt/hash string previously produced by . + /// The stored salt/hash string previously produced by . /// true if the password matches the stored hash; otherwise false. public bool Verify(string password, string stored); -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj index 97afa1f..4438159 100644 --- a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj +++ b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj @@ -1,15 +1,15 @@ - - net10.0 - enable - enable - Infrastructure.PasswordHashing - + + net10.0 + enable + enable + Infrastructure.PasswordHashing + - - - + + + diff --git a/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs index 503ab8a..fff740e 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs @@ -5,8 +5,8 @@ using Microsoft.Extensions.Configuration; namespace Infrastructure.Sql; /// -/// Default implementation that creates SQL Server connections, -/// resolving the connection string from environment variables or application configuration. +/// Default implementation that creates SQL Server connections, +/// resolving the connection string from environment variables or application configuration. /// /// The application configuration, used as a fallback source for the connection string. public class DefaultSqlConnectionFactory(IConfiguration configuration) @@ -17,26 +17,32 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) ); /// - /// Resolves the SQL Server connection string, preferring (in order): the DB_CONNECTION_STRING - /// environment variable, a connection string built from individual DB_* environment variables - /// via , and finally the "Default" - /// connection string from . + /// Creates a new, unopened using the resolved connection string. + /// + /// A new instance. + public DbConnection CreateConnection() + { + return new SqlConnection(_connectionString); + } + + /// + /// Resolves the SQL Server connection string, preferring (in order): the DB_CONNECTION_STRING + /// environment variable, a connection string built from individual DB_* environment variables + /// via , and finally the "Default" + /// connection string from . /// /// The application configuration to fall back to. /// The resolved SQL Server connection string. /// - /// Thrown when no connection string can be resolved from any of the supported sources. + /// Thrown when no connection string can be resolved from any of the supported sources. /// private static string GetConnectionString(IConfiguration configuration) { // Check for full connection string first - var fullConnectionString = Environment.GetEnvironmentVariable( + string? fullConnectionString = Environment.GetEnvironmentVariable( "DB_CONNECTION_STRING" ); - if (!string.IsNullOrEmpty(fullConnectionString)) - { - return fullConnectionString; - } + if (!string.IsNullOrEmpty(fullConnectionString)) return fullConnectionString; // Try to build from individual environment variables (preferred method for Docker) try @@ -46,24 +52,12 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) catch (InvalidOperationException) { // Fall back to configuration-based connection string if env vars are not set - var connString = configuration.GetConnectionString("Default"); - if (!string.IsNullOrEmpty(connString)) - { - return connString; - } + string? connString = configuration.GetConnectionString("Default"); + if (!string.IsNullOrEmpty(connString)) return connString; throw new InvalidOperationException( "Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default." ); } } - - /// - /// Creates a new, unopened using the resolved connection string. - /// - /// A new instance. - public DbConnection CreateConnection() - { - return new SqlConnection(_connectionString); - } -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs index fc2d112..78c26d6 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/ISqlConnectionFactory.cs @@ -3,13 +3,13 @@ using System.Data.Common; namespace Infrastructure.Sql; /// -/// Factory for creating database connections used by repositories. +/// Factory for creating database connections used by repositories. /// public interface ISqlConnectionFactory { /// - /// Creates a new, unopened database connection. + /// Creates a new, unopened database connection. /// - /// A new instance. + /// A new instance. DbConnection CreateConnection(); -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj b/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj index 78ce87b..b9ea4fa 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj +++ b/web/backend/Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj @@ -1,20 +1,20 @@ - - net10.0 - enable - enable - Infrastructure.Sql - - - - - - - + + net10.0 + enable + enable + Infrastructure.Sql + + + + + + + diff --git a/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs b/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs index 872dcd1..6691682 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/Repository.cs @@ -3,8 +3,8 @@ using System.Data.Common; namespace Infrastructure.Sql; /// -/// Base class for ADO.NET-based repositories, providing shared connection creation and -/// entity-mapping infrastructure for derived repository implementations. +/// Base class for ADO.NET-based repositories, providing shared connection creation and +/// entity-mapping infrastructure for derived repository implementations. /// /// The entity type managed by the repository. /// The factory used to create database connections. @@ -12,21 +12,21 @@ public abstract class Repository(ISqlConnectionFactory connectionFactory) where T : class { /// - /// Creates and opens a new database connection using the configured . + /// Creates and opens a new database connection using the configured . /// - /// An open ready for use. + /// An open ready for use. /// Thrown when the connection cannot be opened. protected async Task CreateConnection() { - var connection = connectionFactory.CreateConnection(); + DbConnection connection = connectionFactory.CreateConnection(); await connection.OpenAsync(); return connection; } /// - /// Maps the current row of a data reader to an instance of . + /// Maps the current row of a data reader to an instance of . /// /// The data reader positioned on the row to map. /// The mapped entity instance. protected abstract T MapToEntity(DbDataReader reader); -} +} \ No newline at end of file diff --git a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs index a38e401..4ccdc1f 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs @@ -3,62 +3,62 @@ using Microsoft.Data.SqlClient; namespace Infrastructure.Sql; /// -/// Helper for building SQL Server connection strings from environment variables. +/// Helper for building SQL Server connection strings from environment variables. /// public static class SqlConnectionStringHelper { /// - /// Builds a SQL Server connection string from environment variables. - /// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE. + /// Builds a SQL Server connection string from environment variables. + /// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE. /// /// Optional override for the database name. If null, uses DB_NAME env var. /// A properly formatted SQL Server connection string. public static string BuildConnectionString(string? databaseName = null) { - var server = + string server = Environment.GetEnvironmentVariable("DB_SERVER") ?? throw new InvalidOperationException( "DB_SERVER environment variable is not set" ); - var dbName = + string dbName = databaseName ?? Environment.GetEnvironmentVariable("DB_NAME") ?? throw new InvalidOperationException( "DB_NAME environment variable is not set" ); - var user = + string user = Environment.GetEnvironmentVariable("DB_USER") ?? throw new InvalidOperationException( "DB_USER environment variable is not set" ); - var password = + string password = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? throw new InvalidOperationException( "DB_PASSWORD environment variable is not set" ); - var builder = new SqlConnectionStringBuilder + SqlConnectionStringBuilder builder = new() { DataSource = server, InitialCatalog = dbName, UserID = user, Password = password, TrustServerCertificate = true, - Encrypt = true, + Encrypt = true }; return builder.ConnectionString; } /// - /// Builds a connection string to the master database using environment variables. + /// Builds a connection string to the master database using environment variables. /// /// A connection string for the master database. public static string BuildMasterConnectionString() { return BuildConnectionString("master"); } -} +} \ No newline at end of file diff --git a/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs b/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs index 305d057..8720f9e 100644 --- a/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs +++ b/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs @@ -1,12 +1,13 @@ using FluentValidation; +using FluentValidation.Results; using MediatR; namespace Shared.Application.Behaviors; /// -/// MediatR pipeline behavior that runs all registered FluentValidation validators for a -/// request before invoking its handler, short-circuiting with a -/// when any validator reports failures. +/// MediatR pipeline behavior that runs all registered FluentValidation validators for a +/// request before invoking its handler, short-circuiting with a +/// when any validator reports failures. /// public class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior @@ -18,27 +19,21 @@ public class ValidationBehavior(IEnumerable(request); + ValidationContext context = new(request); - var failures = ( - await Task.WhenAll( - validators.Select(v => v.ValidateAsync(context, cancellationToken)) + List failures = ( + await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken)) + ) ) - ) .SelectMany(result => result.Errors) .Where(failure => failure != null) .ToList(); - if (failures.Count > 0) - { - throw new ValidationException(failures); - } + if (failures.Count > 0) throw new ValidationException(failures); return await next(); } -} +} \ No newline at end of file diff --git a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs index 8a6c86a..2d29726 100644 --- a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs +++ b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs @@ -3,11 +3,11 @@ using MediatR; namespace Shared.Application.Emails; /// -/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a -/// registration confirmation email, without either slice taking a project reference on the other. +/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a +/// registration confirmation email, without either slice taking a project reference on the other. /// public record SendRegistrationEmailCommand( string FirstName, string Email, string ConfirmationToken -) : IRequest; +) : IRequest; \ No newline at end of file diff --git a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs index e4d2ba4..7783f3d 100644 --- a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs +++ b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs @@ -3,11 +3,11 @@ using MediatR; namespace Shared.Application.Emails; /// -/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a -/// fresh confirmation-link email, without either slice taking a project reference on the other. +/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a +/// fresh confirmation-link email, without either slice taking a project reference on the other. /// public record SendResendConfirmationEmailCommand( string FirstName, string Email, string ConfirmationToken -) : IRequest; +) : IRequest; \ No newline at end of file diff --git a/web/backend/Shared/Shared.Application/Shared.Application.csproj b/web/backend/Shared/Shared.Application/Shared.Application.csproj index e03dc4e..519d781 100644 --- a/web/backend/Shared/Shared.Application/Shared.Application.csproj +++ b/web/backend/Shared/Shared.Application/Shared.Application.csproj @@ -1,13 +1,13 @@ - - net10.0 - enable - enable - Shared.Application - + + net10.0 + enable + enable + Shared.Application + - - - - + + + + diff --git a/web/backend/Shared/Shared.Contracts/ResponseBody.cs b/web/backend/Shared/Shared.Contracts/ResponseBody.cs index 080aca2..c8522b2 100644 --- a/web/backend/Shared/Shared.Contracts/ResponseBody.cs +++ b/web/backend/Shared/Shared.Contracts/ResponseBody.cs @@ -1,29 +1,29 @@ namespace Shared.Contracts; /// -/// Generic envelope used to wrap API responses that carry a data payload alongside a human-readable message. +/// Generic envelope used to wrap API responses that carry a data payload alongside a human-readable message. /// /// The type of the data payload returned in the response. public record ResponseBody { /// - /// A human-readable message describing the outcome of the request. + /// A human-readable message describing the outcome of the request. /// public required string Message { get; init; } /// - /// The data payload associated with the response. + /// The data payload associated with the response. /// public required T Payload { get; init; } } /// -/// Envelope used to wrap API responses that carry only a human-readable message and no data payload. +/// Envelope used to wrap API responses that carry only a human-readable message and no data payload. /// public record ResponseBody { /// - /// A human-readable message describing the outcome of the request. + /// A human-readable message describing the outcome of the request. /// public required string Message { get; init; } -} +} \ No newline at end of file diff --git a/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj b/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj index 7401600..3b0aefc 100644 --- a/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj +++ b/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj @@ -1,8 +1,8 @@ - - net10.0 - enable - enable - Shared.Contracts - + + net10.0 + enable + enable + Shared.Contracts +