mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
code style cleanup
This commit is contained in:
@@ -9,32 +9,32 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="Microsoft.AspNetCore.OpenApi"
|
Include="Microsoft.AspNetCore.OpenApi"
|
||||||
Version="9.0.11"
|
Version="9.0.11"
|
||||||
/>
|
/>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="FluentValidation.AspNetCore"
|
Include="FluentValidation.AspNetCore"
|
||||||
Version="11.3.0"
|
Version="11.3.0"
|
||||||
/>
|
/>
|
||||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
<PackageReference Include="MediatR" Version="12.4.1"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Infrastructure\" />
|
<Folder Include="Infrastructure\"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/>
|
||||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj"/>
|
||||||
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
|
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj"/>
|
||||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj"/>
|
||||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj"/>
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
|
||||||
using Shared.Contracts;
|
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Shared.Contracts;
|
||||||
|
|
||||||
namespace API.Core.Authentication;
|
namespace API.Core.Authentication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
|
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
|
||||||
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
|
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
|
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
|
||||||
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param>
|
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
|
||||||
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param>
|
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
|
||||||
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
|
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
|
||||||
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
|
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
|
||||||
public class JwtAuthenticationHandler(
|
public class JwtAuthenticationHandler(
|
||||||
@@ -26,66 +26,57 @@ public class JwtAuthenticationHandler(
|
|||||||
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
||||||
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
||||||
/// when tokens are issued. Authentication fails if the secret is not configured, the
|
/// when tokens are issued. Authentication fails if the secret is not configured, the
|
||||||
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
|
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
|
||||||
/// token fails validation.
|
/// token fails validation.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// A successful <see cref="AuthenticateResult"/> containing the validated <see cref="System.Security.Claims.ClaimsPrincipal"/>
|
/// A successful <see cref="AuthenticateResult" /> containing the validated
|
||||||
/// on success, or a failure result describing why authentication could not be completed.
|
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
|
||||||
|
/// on success, or a failure result describing why authentication could not be completed.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||||
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
if (string.IsNullOrWhiteSpace(secret)) secret = configuration["Jwt:SecretKey"];
|
||||||
{
|
|
||||||
secret = configuration["Jwt:SecretKey"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured");
|
||||||
{
|
|
||||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Authorization header exists
|
// Check if Authorization header exists
|
||||||
if (
|
if (
|
||||||
!Request.Headers.TryGetValue(
|
!Request.Headers.TryGetValue(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
out var authHeaderValue
|
out StringValues authHeaderValue
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
|
||||||
return AuthenticateResult.Fail("Authorization header is missing");
|
return AuthenticateResult.Fail("Authorization header is missing");
|
||||||
}
|
|
||||||
|
|
||||||
var authHeader = authHeaderValue.ToString();
|
string authHeader = authHeaderValue.ToString();
|
||||||
if (
|
if (
|
||||||
!authHeader.StartsWith(
|
!authHeader.StartsWith(
|
||||||
"Bearer ",
|
"Bearer ",
|
||||||
StringComparison.OrdinalIgnoreCase
|
StringComparison.OrdinalIgnoreCase
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
|
||||||
return AuthenticateResult.Fail(
|
return AuthenticateResult.Fail(
|
||||||
"Invalid authorization header format"
|
"Invalid authorization header format"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
string token = authHeader.Substring("Bearer ".Length).Trim();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||||
token,
|
token,
|
||||||
secret
|
secret
|
||||||
);
|
);
|
||||||
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
|
||||||
return AuthenticateResult.Success(ticket);
|
return AuthenticateResult.Success(ticket);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -97,7 +88,7 @@ public class JwtAuthenticationHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
|
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="properties">The authentication properties associated with the challenge.</param>
|
/// <param name="properties">The authentication properties associated with the challenge.</param>
|
||||||
/// <returns>A task that completes once the response body has been written.</returns>
|
/// <returns>A task that completes once the response body has been written.</returns>
|
||||||
@@ -106,13 +97,15 @@ public class JwtAuthenticationHandler(
|
|||||||
Response.ContentType = "application/json";
|
Response.ContentType = "application/json";
|
||||||
Response.StatusCode = 401;
|
Response.StatusCode = 401;
|
||||||
|
|
||||||
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
ResponseBody response = new() { Message = "Unauthorized: Invalid or missing authentication token" };
|
||||||
await Response.WriteAsJsonAsync(response);
|
await Response.WriteAsJsonAsync(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler"/>.
|
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions"/>.</remarks>
|
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
|
||||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
public class JwtAuthenticationOptions : AuthenticationSchemeOptions
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace API.Core.Controllers
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
|
||||||
|
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
|
||||||
|
/// <c>Program.cs</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[ApiController]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
|
[Route("error")] // required
|
||||||
|
public class NotFoundController : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
/// Returns a generic 404 response for any request that did not match a defined route.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
|
||||||
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
|
[HttpGet("404")] //required
|
||||||
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in <c>Program.cs</c>.
|
public IActionResult Handle404()
|
||||||
/// </remarks>
|
|
||||||
[ApiController]
|
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
|
||||||
[Route("error")] // required
|
|
||||||
public class NotFoundController : ControllerBase
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
return NotFound(new { message = "Route not found." });
|
||||||
/// Returns a generic 404 response for any request that did not match a defined route.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
|
|
||||||
[HttpGet("404")] //required
|
|
||||||
public IActionResult Handle404()
|
|
||||||
{
|
|
||||||
return NotFound(new { message = "Route not found." });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Shared.Contracts;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Shared.Contracts;
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sample controller demonstrating an endpoint that requires JWT authentication.
|
/// Sample controller demonstrating an endpoint that requires JWT authentication.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
@@ -14,25 +14,25 @@ namespace API.Core.Controllers;
|
|||||||
public class ProtectedController : ControllerBase
|
public class ProtectedController : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
|
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> whose payload contains the
|
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
|
||||||
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier"/>) and <c>username</c>
|
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
|
||||||
/// (from <see cref="ClaimTypes.Name"/>), either of which may be <c>null</c> if not present in the token.
|
/// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<ResponseBody<object>> Get()
|
public ActionResult<ResponseBody<object>> Get()
|
||||||
{
|
{
|
||||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<object>
|
new ResponseBody<object>
|
||||||
{
|
{
|
||||||
Message = "Protected endpoint accessed successfully",
|
Message = "Protected endpoint accessed successfully",
|
||||||
Payload = new { userId, username },
|
Payload = new { userId, username }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,88 @@
|
|||||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||||
|
|
||||||
using Shared.Contracts;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentValidation;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Shared.Contracts;
|
||||||
|
using ValidationException = FluentValidation.ValidationException;
|
||||||
|
|
||||||
namespace API.Core;
|
namespace API.Core;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
||||||
/// consistent JSON error responses with appropriate HTTP status codes.
|
/// consistent JSON error responses with appropriate HTTP status codes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
|
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
|
||||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||||
: IExceptionFilter
|
: IExceptionFilter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logs the exception and sets <see cref="ExceptionContext.Result"/> to an appropriate error response,
|
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
|
||||||
/// marking the exception as handled.
|
/// marking the exception as handled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Maps exception types to responses as follows:
|
/// Maps exception types to responses as follows:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><description><see cref="FluentValidation.ValidationException"/> → <c>400 Bad Request</c> with per-property validation error messages.</description></item>
|
/// <item>
|
||||||
/// <item><description><see cref="ConflictException"/> → <c>409 Conflict</c> with a <see cref="ResponseBody"/> message.</description></item>
|
/// <description>
|
||||||
/// <item><description><see cref="NotFoundException"/> → <c>404 Not Found</c> with a <see cref="ResponseBody"/> message.</description></item>
|
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
|
||||||
/// <item><description><see cref="UnauthorizedException"/> → <c>401 Unauthorized</c> with a <see cref="ResponseBody"/> message.</description></item>
|
/// validation error messages.
|
||||||
/// <item><description><see cref="ForbiddenException"/> → <c>403 Forbidden</c> with a <see cref="ResponseBody"/> message.</description></item>
|
/// </description>
|
||||||
/// <item><description><see cref="SqlException"/> → <c>503 Service Unavailable</c> with a generic database error message.</description></item>
|
/// </item>
|
||||||
/// <item><description><see cref="Domain.Exceptions.ValidationException"/> → <c>400 Bad Request</c> with a <see cref="ResponseBody"/> message.</description></item>
|
/// <item>
|
||||||
/// <item><description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description></item>
|
/// <description>
|
||||||
/// </list>
|
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
|
||||||
|
/// <see cref="ResponseBody" /> message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
|
||||||
|
/// <see cref="ResponseBody" /> message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="context">The context for the unhandled exception, including the exception itself and the result to populate.</param>
|
/// <param name="context">
|
||||||
|
/// The context for the unhandled exception, including the exception itself and the result to
|
||||||
|
/// populate.
|
||||||
|
/// </param>
|
||||||
public void OnException(ExceptionContext context)
|
public void OnException(ExceptionContext context)
|
||||||
{
|
{
|
||||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||||
|
|
||||||
switch (context.Exception)
|
switch (context.Exception)
|
||||||
{
|
{
|
||||||
case FluentValidation.ValidationException fluentValidationException:
|
case ValidationException fluentValidationException:
|
||||||
var errors = fluentValidationException
|
Dictionary<string, string[]> errors = fluentValidationException
|
||||||
.Errors.GroupBy(e => e.PropertyName)
|
.Errors.GroupBy(e => e.PropertyName)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
@@ -60,7 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
new ResponseBody { Message = ex.Message }
|
new ResponseBody { Message = ex.Message }
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 409,
|
StatusCode = 409
|
||||||
};
|
};
|
||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
@@ -70,7 +110,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
new ResponseBody { Message = ex.Message }
|
new ResponseBody { Message = ex.Message }
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 404,
|
StatusCode = 404
|
||||||
};
|
};
|
||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
@@ -80,7 +120,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
new ResponseBody { Message = ex.Message }
|
new ResponseBody { Message = ex.Message }
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 401,
|
StatusCode = 401
|
||||||
};
|
};
|
||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
@@ -90,7 +130,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
new ResponseBody { Message = ex.Message }
|
new ResponseBody { Message = ex.Message }
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 403,
|
StatusCode = 403
|
||||||
};
|
};
|
||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
@@ -100,7 +140,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
new ResponseBody { Message = "A database error occurred." }
|
new ResponseBody { Message = "A database error occurred." }
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 503,
|
StatusCode = 503
|
||||||
};
|
};
|
||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
@@ -110,7 +150,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
new ResponseBody { Message = ex.Message }
|
new ResponseBody { Message = ex.Message }
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 400,
|
StatusCode = 400
|
||||||
};
|
};
|
||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
@@ -119,14 +159,14 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(
|
||||||
new ResponseBody
|
new ResponseBody
|
||||||
{
|
{
|
||||||
Message = "An unexpected error occurred",
|
Message = "An unexpected error occurred"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 500,
|
StatusCode = 500
|
||||||
};
|
};
|
||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,13 +14,10 @@ using Infrastructure.Jwt;
|
|||||||
using Infrastructure.Sql;
|
using Infrastructure.Sql;
|
||||||
using Shared.Application.Behaviors;
|
using Shared.Application.Behaviors;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Global Exception Filter
|
// Global Exception Filter
|
||||||
builder.Services.AddControllers(options =>
|
builder.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
|
||||||
{
|
|
||||||
options.Filters.Add<GlobalExceptionFilter>();
|
|
||||||
})
|
|
||||||
.AddApplicationPart(typeof(BreweryController).Assembly)
|
.AddApplicationPart(typeof(BreweryController).Assembly)
|
||||||
.AddApplicationPart(typeof(UserController).Assembly)
|
.AddApplicationPart(typeof(UserController).Assembly)
|
||||||
.AddApplicationPart(typeof(AuthController).Assembly);
|
.AddApplicationPart(typeof(AuthController).Assembly);
|
||||||
@@ -54,10 +51,7 @@ builder.Services.AddHealthChecks();
|
|||||||
// Configure logging for container output
|
// Configure logging for container output
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddConsole();
|
builder.Logging.AddConsole();
|
||||||
if (!builder.Environment.IsProduction())
|
if (!builder.Environment.IsProduction()) builder.Logging.AddDebug();
|
||||||
{
|
|
||||||
builder.Logging.AddDebug();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -88,7 +82,7 @@ builder
|
|||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
@@ -106,10 +100,10 @@ app.MapControllers();
|
|||||||
app.MapFallbackToController("Handle404", "NotFound");
|
app.MapFallbackToController("Handle404", "NotFound");
|
||||||
|
|
||||||
// Graceful shutdown handling
|
// Graceful shutdown handling
|
||||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||||
lifetime.ApplicationStopping.Register(() =>
|
lifetime.ApplicationStopping.Register(() =>
|
||||||
{
|
{
|
||||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -8,40 +8,40 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="6.9.0"/>
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41"/>
|
||||||
|
|
||||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
<PackageReference Include="Reqnroll" Version="3.3.3"/>
|
||||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3"/>
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="Reqnroll.Tools.MsBuild.Generation"
|
Include="Reqnroll.Tools.MsBuild.Generation"
|
||||||
Version="3.3.3"
|
Version="3.3.3"
|
||||||
PrivateAssets="all"
|
PrivateAssets="all"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ASP.NET Core integration testing -->
|
<!-- ASP.NET Core integration testing -->
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||||
Version="9.0.1"
|
Version="9.0.1"
|
||||||
/>
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Ensure feature files are included in the project -->
|
<!-- Ensure feature files are included in the project -->
|
||||||
<None Include="Features\**\*.feature" />
|
<None Include="Features\**\*.feature"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
<ProjectReference Include="..\API.Core\API.Core.csproj"/>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj"/>
|
||||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
Feature: Protected Endpoint Access Token Validation
|
Feature: Protected Endpoint Access Token Validation
|
||||||
As a backend developer
|
As a backend developer
|
||||||
I want protected endpoints to validate access tokens
|
I want protected endpoints to validate access tokens
|
||||||
So that unauthorized requests are rejected
|
So that unauthorized requests are rejected
|
||||||
|
|
||||||
Scenario: Protected endpoint accepts valid access token
|
Scenario: Protected endpoint accepts valid access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in
|
And I am logged in
|
||||||
When I submit a request to a protected endpoint with a valid access token
|
When I submit a request to a protected endpoint with a valid access token
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects missing access token
|
Scenario: Protected endpoint rejects missing access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a request to a protected endpoint without an access token
|
When I submit a request to a protected endpoint without an access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects invalid access token
|
Scenario: Protected endpoint rejects invalid access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a request to a protected endpoint with an invalid access token
|
When I submit a request to a protected endpoint with an invalid access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Unauthorized"
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects expired access token
|
Scenario: Protected endpoint rejects expired access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in with an immediately-expiring access token
|
And I am logged in with an immediately-expiring access token
|
||||||
When I submit a request to a protected endpoint with the expired token
|
When I submit a request to a protected endpoint with the expired token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Unauthorized"
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects token signed with wrong secret
|
Scenario: Protected endpoint rejects token signed with wrong secret
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an access token signed with the wrong secret
|
And I have an access token signed with the wrong secret
|
||||||
When I submit a request to a protected endpoint with the tampered token
|
When I submit a request to a protected endpoint with the tampered token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Unauthorized"
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects refresh token as access token
|
Scenario: Protected endpoint rejects refresh token as access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in
|
And I am logged in
|
||||||
When I submit a request to a protected endpoint with my refresh token instead of access token
|
When I submit a request to a protected endpoint with my refresh token instead of access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects confirmation token as access token
|
Scenario: Protected endpoint rejects confirmation token as access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token
|
And I have a valid confirmation token
|
||||||
When I submit a request to a protected endpoint with my confirmation token instead of access token
|
When I submit a request to a protected endpoint with my confirmation token instead of access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
@@ -1,76 +1,77 @@
|
|||||||
Feature: User Account Confirmation
|
Feature: User Account Confirmation
|
||||||
As a newly registered user
|
As a newly registered user
|
||||||
I want to confirm my email address via a validation token
|
I want to confirm my email address via a validation token
|
||||||
So that my account is fully activated
|
So that my account is fully activated
|
||||||
Scenario: Successful confirmation with valid token
|
|
||||||
Given the API is running
|
|
||||||
And I have registered a new account
|
|
||||||
And I have a valid confirmation token for my account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
When I submit a confirmation request with the valid token
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And the response JSON should have "message" containing "is confirmed"
|
|
||||||
|
|
||||||
Scenario: Re-confirming an already verified account remains successful
|
Scenario: Successful confirmation with valid token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token for my account
|
And I have a valid confirmation token for my account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a confirmation request with the valid token
|
When I submit a confirmation request with the valid token
|
||||||
And I submit the same confirmation request again
|
Then the response has HTTP status 200
|
||||||
Then the response has HTTP status 200
|
And the response JSON should have "message" containing "is confirmed"
|
||||||
And the response JSON should have "message" containing "is confirmed"
|
|
||||||
|
|
||||||
Scenario: Confirmation fails with invalid token
|
Scenario: Re-confirming an already verified account remains successful
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a valid confirmation token for my account
|
||||||
When I submit a confirmation request with an invalid token
|
And I have a valid access token for my account
|
||||||
Then the response has HTTP status 401
|
When I submit a confirmation request with the valid token
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
And I submit the same confirmation request again
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "is confirmed"
|
||||||
|
|
||||||
Scenario: Confirmation fails with expired token
|
Scenario: Confirmation fails with invalid token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have an expired confirmation token for my account
|
And I have a valid access token for my account
|
||||||
And I have a valid access token for my account
|
When I submit a confirmation request with an invalid token
|
||||||
When I submit a confirmation request with the expired token
|
Then the response has HTTP status 401
|
||||||
Then the response has HTTP status 401
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
|
||||||
|
|
||||||
Scenario: Confirmation fails with tampered token (wrong secret)
|
Scenario: Confirmation fails with expired token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a confirmation token signed with the wrong secret
|
And I have an expired confirmation token for my account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a confirmation request with the tampered token
|
When I submit a confirmation request with the expired token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
Scenario: Confirmation fails when token is missing
|
Scenario: Confirmation fails with tampered token (wrong secret)
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a confirmation token signed with the wrong secret
|
||||||
When I submit a confirmation request with a missing token
|
And I have a valid access token for my account
|
||||||
Then the response has HTTP status 400
|
When I submit a confirmation request with the tampered token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
Scenario: Confirmation endpoint only accepts POST requests
|
Scenario: Confirmation fails when token is missing
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have a valid confirmation token
|
And I have registered a new account
|
||||||
When I submit a confirmation request using an invalid HTTP method
|
And I have a valid access token for my account
|
||||||
Then the response has HTTP status 404
|
When I submit a confirmation request with a missing token
|
||||||
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Confirmation fails with malformed token
|
Scenario: Confirmation endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have a valid confirmation token
|
||||||
And I have a valid access token for my account
|
When I submit a confirmation request using an invalid HTTP method
|
||||||
When I submit a confirmation request with a malformed token
|
Then the response has HTTP status 404
|
||||||
Then the response has HTTP status 401
|
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
|
||||||
|
|
||||||
Scenario: Confirmation fails without an access token
|
Scenario: Confirmation fails with malformed token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a confirmation request with the valid token without an access token
|
When I submit a confirmation request with a malformed token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
|
Scenario: Confirmation fails without an access token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token for my account
|
||||||
|
When I submit a confirmation request with the valid token without an access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
@@ -3,37 +3,37 @@ As a registered user
|
|||||||
I want to log in to my account
|
I want to log in to my account
|
||||||
So that I receive an authentication token to access authenticated routes
|
So that I receive an authentication token to access authenticated routes
|
||||||
|
|
||||||
Scenario: Successful login with valid credentials
|
Scenario: Successful login with valid credentials
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
When I submit a login request with a username and password
|
When I submit a login request with a username and password
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" equal "Logged in successfully."
|
And the response JSON should have "message" equal "Logged in successfully."
|
||||||
And the response JSON should have an access token
|
And the response JSON should have an access token
|
||||||
|
|
||||||
Scenario: Login fails with invalid credentials
|
Scenario: Login fails with invalid credentials
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I do not have an existing account
|
And I do not have an existing account
|
||||||
When I submit a login request with a username and password
|
When I submit a login request with a username and password
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" equal "Invalid username or password."
|
And the response JSON should have "message" equal "Invalid username or password."
|
||||||
|
|
||||||
Scenario: Login fails when required missing username
|
Scenario: Login fails when required missing username
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request with a missing username
|
When I submit a login request with a missing username
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Login fails when required missing password
|
Scenario: Login fails when required missing password
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request with a missing password
|
When I submit a login request with a missing password
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Login fails when both username and password are missing
|
Scenario: Login fails when both username and password are missing
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request with both username and password missing
|
When I submit a login request with both username and password missing
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Login endpoint only accepts POST requests
|
Scenario: Login endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request using a GET request
|
When I submit a login request using a GET request
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
@@ -3,8 +3,8 @@ As a client of the API
|
|||||||
I want consistent 404 responses
|
I want consistent 404 responses
|
||||||
So that consumers can gracefully handle missing routes
|
So that consumers can gracefully handle missing routes
|
||||||
|
|
||||||
Scenario: GET request to an invalid route returns 404
|
Scenario: GET request to an invalid route returns 404
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I send an HTTP request "GET" to "/invalid-route"
|
When I send an HTTP request "GET" to "/invalid-route"
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
And the response JSON should have "message" equal "Route not found."
|
And the response JSON should have "message" equal "Route not found."
|
||||||
@@ -1,60 +1,60 @@
|
|||||||
Feature: User Registration
|
Feature: User Registration
|
||||||
As a new user
|
As a new user
|
||||||
I want to register an account
|
I want to register an account
|
||||||
So that I can log in and access authenticated routes
|
So that I can log in and access authenticated routes
|
||||||
|
|
||||||
Scenario: Successful registration with valid details
|
Scenario: Successful registration with valid details
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
|
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
|
||||||
Then the response has HTTP status 201
|
Then the response has HTTP status 201
|
||||||
And the response JSON should have "message" equal "User registered successfully."
|
And the response JSON should have "message" equal "User registered successfully."
|
||||||
And the response JSON should have an access token
|
And the response JSON should have an access token
|
||||||
|
|
||||||
Scenario: Registration fails with existing username
|
Scenario: Registration fails with existing username
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
||||||
Then the response has HTTP status 409
|
Then the response has HTTP status 409
|
||||||
|
|
||||||
Scenario: Registration fails with existing email
|
Scenario: Registration fails with existing email
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
||||||
Then the response has HTTP status 409
|
Then the response has HTTP status 409
|
||||||
|
|
||||||
Scenario: Registration fails with missing required fields
|
Scenario: Registration fails with missing required fields
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| | New | User | | | Password1! |
|
| | New | User | | | Password1! |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Registration fails with invalid email format
|
Scenario: Registration fails with invalid email format
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
|
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Registration fails with weak password
|
Scenario: Registration fails with weak password
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
|
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
|
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
|
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Registration endpoint only accepts POST requests
|
Scenario: Registration endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request using a GET request
|
When I submit a registration request using a GET request
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
@@ -1,36 +1,36 @@
|
|||||||
Feature: Resend Confirmation Email
|
Feature: Resend Confirmation Email
|
||||||
As a user who did not receive the confirmation email
|
As a user who did not receive the confirmation email
|
||||||
I want to request a resend of the confirmation email
|
I want to request a resend of the confirmation email
|
||||||
So that I can obtain a working confirmation link while preventing abuse
|
So that I can obtain a working confirmation link while preventing abuse
|
||||||
|
|
||||||
Scenario: Legitimate resend for an unconfirmed user
|
Scenario: Legitimate resend for an unconfirmed user
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a resend confirmation request for my account
|
When I submit a resend confirmation request for my account
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
Scenario: Resend is a no-op for an already confirmed user
|
Scenario: Resend is a no-op for an already confirmed user
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token for my account
|
And I have a valid confirmation token for my account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
And I have confirmed my account
|
And I have confirmed my account
|
||||||
When I submit a resend confirmation request for my account
|
When I submit a resend confirmation request for my account
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
Scenario: Resend is a no-op for a non-existent user
|
Scenario: Resend is a no-op for a non-existent user
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a resend confirmation request for a non-existent user
|
When I submit a resend confirmation request for a non-existent user
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
Scenario: Resend requires authentication
|
Scenario: Resend requires authentication
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
When I submit a resend confirmation request without an access token
|
When I submit a resend confirmation request without an access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
@@ -1,39 +1,39 @@
|
|||||||
Feature: Token Refresh
|
Feature: Token Refresh
|
||||||
As an authenticated user
|
As an authenticated user
|
||||||
I want to refresh my access token using my refresh token
|
I want to refresh my access token using my refresh token
|
||||||
So that I can maintain my session without logging in again
|
So that I can maintain my session without logging in again
|
||||||
|
|
||||||
Scenario: Successful token refresh with valid refresh token
|
Scenario: Successful token refresh with valid refresh token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in
|
And I am logged in
|
||||||
When I submit a refresh token request with a valid refresh token
|
When I submit a refresh token request with a valid refresh token
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" equal "Token refreshed successfully."
|
And the response JSON should have "message" equal "Token refreshed successfully."
|
||||||
And the response JSON should have a new access token
|
And the response JSON should have a new access token
|
||||||
And the response JSON should have a new refresh token
|
And the response JSON should have a new refresh token
|
||||||
|
|
||||||
Scenario: Token refresh fails with invalid refresh token
|
Scenario: Token refresh fails with invalid refresh token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a refresh token request with an invalid refresh token
|
When I submit a refresh token request with an invalid refresh token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Invalid"
|
And the response JSON should have "message" containing "Invalid"
|
||||||
|
|
||||||
Scenario: Token refresh fails with expired refresh token
|
Scenario: Token refresh fails with expired refresh token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in with an immediately-expiring refresh token
|
And I am logged in with an immediately-expiring refresh token
|
||||||
When I submit a refresh token request with the expired refresh token
|
When I submit a refresh token request with the expired refresh token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
Scenario: Token refresh fails when refresh token is missing
|
Scenario: Token refresh fails when refresh token is missing
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a refresh token request with a missing refresh token
|
When I submit a refresh token request with a missing refresh token
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Token refresh endpoint only accepts POST requests
|
Scenario: Token refresh endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have a valid refresh token
|
And I have a valid refresh token
|
||||||
When I submit a refresh token request using a GET request
|
When I submit a refresh token request using a GET request
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
@@ -20,7 +20,7 @@ public class MockEmailDispatcher : IEmailDispatcher
|
|||||||
FirstName = firstName,
|
FirstName = firstName,
|
||||||
Email = email,
|
Email = email,
|
||||||
ConfirmationToken = confirmationToken,
|
ConfirmationToken = confirmationToken,
|
||||||
SentAt = DateTime.UtcNow,
|
SentAt = DateTime.UtcNow
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ public class MockEmailDispatcher : IEmailDispatcher
|
|||||||
FirstName = firstName,
|
FirstName = firstName,
|
||||||
Email = email,
|
Email = email,
|
||||||
ConfirmationToken = confirmationToken,
|
ConfirmationToken = confirmationToken,
|
||||||
SentAt = DateTime.UtcNow,
|
SentAt = DateTime.UtcNow
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,4 +67,4 @@ public class MockEmailDispatcher : IEmailDispatcher
|
|||||||
public string ConfirmationToken { get; init; } = string.Empty;
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
public DateTime SentAt { get; init; }
|
public DateTime SentAt { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ using Infrastructure.Email;
|
|||||||
namespace API.Specs.Mocks;
|
namespace API.Specs.Mocks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mock email provider for testing that doesn't actually send emails.
|
/// Mock email provider for testing that doesn't actually send emails.
|
||||||
/// Tracks sent emails for verification in tests if needed.
|
/// Tracks sent emails for verification in tests if needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MockEmailProvider : IEmailProvider
|
public class MockEmailProvider : IEmailProvider
|
||||||
{
|
{
|
||||||
@@ -24,7 +24,7 @@ public class MockEmailProvider : IEmailProvider
|
|||||||
Subject = subject,
|
Subject = subject,
|
||||||
Body = body,
|
Body = body,
|
||||||
IsHtml = isHtml,
|
IsHtml = isHtml,
|
||||||
SentAt = DateTime.UtcNow,
|
SentAt = DateTime.UtcNow
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ public class MockEmailProvider : IEmailProvider
|
|||||||
Subject = subject,
|
Subject = subject,
|
||||||
Body = body,
|
Body = body,
|
||||||
IsHtml = isHtml,
|
IsHtml = isHtml,
|
||||||
SentAt = DateTime.UtcNow,
|
SentAt = DateTime.UtcNow
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,4 +65,4 @@ public class MockEmailProvider : IEmailProvider
|
|||||||
public bool IsHtml { get; init; }
|
public bool IsHtml { get; init; }
|
||||||
public DateTime SentAt { get; init; }
|
public DateTime SentAt { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using API.Specs;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Reqnroll;
|
using Reqnroll;
|
||||||
|
|
||||||
@@ -15,14 +15,11 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
private HttpClient GetClient()
|
private HttpClient GetClient()
|
||||||
{
|
{
|
||||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
|
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client)) return client;
|
||||||
{
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
var factory = scenario.TryGetValue<TestApiFactory>(
|
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
|
||||||
FactoryKey,
|
FactoryKey,
|
||||||
out var f
|
out TestApiFactory? f
|
||||||
)
|
)
|
||||||
? f
|
? f
|
||||||
: new TestApiFactory();
|
: new TestApiFactory();
|
||||||
@@ -46,19 +43,19 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
string jsonBody
|
string jsonBody
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var client = GetClient();
|
HttpClient client = GetClient();
|
||||||
|
|
||||||
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
|
HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
|
||||||
{
|
{
|
||||||
Content = new StringContent(
|
Content = new StringContent(
|
||||||
jsonBody,
|
jsonBody,
|
||||||
System.Text.Encoding.UTF8,
|
Encoding.UTF8,
|
||||||
"application/json"
|
"application/json"
|
||||||
),
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
var responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
@@ -70,13 +67,13 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
string url
|
string url
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var client = GetClient();
|
HttpClient client = GetClient();
|
||||||
var requestMessage = new HttpRequestMessage(
|
HttpRequestMessage requestMessage = new(
|
||||||
new HttpMethod(method),
|
new HttpMethod(method),
|
||||||
url
|
url
|
||||||
);
|
);
|
||||||
var response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
var responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
@@ -86,7 +83,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
|
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
((int)response!.StatusCode).Should().Be(expected);
|
((int)response!.StatusCode).Should().Be(expected);
|
||||||
@@ -96,7 +93,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
|
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue("No response was received from the API");
|
.BeTrue("No response was received from the API");
|
||||||
((int)response!.StatusCode).Should().Be(expectedCode);
|
((int)response!.StatusCode).Should().Be(expectedCode);
|
||||||
@@ -109,20 +106,20 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(responseBody!);
|
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
var root = doc.RootElement;
|
JsonElement root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty(field, out var value))
|
if (!root.TryGetProperty(field, out JsonElement value))
|
||||||
{
|
{
|
||||||
root.TryGetProperty("payload", out var payloadElem)
|
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue(
|
.BeTrue(
|
||||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||||
@@ -157,20 +154,20 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(responseBody!);
|
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
var root = doc.RootElement;
|
JsonElement root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty(field, out var value))
|
if (!root.TryGetProperty(field, out JsonElement value))
|
||||||
{
|
{
|
||||||
root.TryGetProperty("payload", out var payloadElem)
|
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue(
|
.BeTrue(
|
||||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||||
@@ -195,7 +192,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
"Expected field '{0}' to be a string",
|
"Expected field '{0}' to be a string",
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
var actualValue = value.GetString();
|
string? actualValue = value.GetString();
|
||||||
actualValue
|
actualValue
|
||||||
.Should()
|
.Should()
|
||||||
.Contain(
|
.Contain(
|
||||||
@@ -206,4 +203,4 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
actualValue
|
actualValue
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,37 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using API.Specs.Mocks;
|
using API.Specs.Mocks;
|
||||||
using Features.Emails.Services;
|
using Features.Emails.Services;
|
||||||
using Infrastructure.Email;
|
using Infrastructure.Email;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace API.Specs
|
namespace API.Specs;
|
||||||
|
|
||||||
|
public class TestApiFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
public class TestApiFactory : WebApplicationFactory<Program>
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
builder.UseEnvironment("Testing");
|
// Replace the real email provider with mock for testing
|
||||||
|
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
|
||||||
|
d.ServiceType == typeof(IEmailProvider)
|
||||||
|
);
|
||||||
|
|
||||||
builder.ConfigureServices(services =>
|
if (emailProviderDescriptor != null) services.Remove(emailProviderDescriptor);
|
||||||
{
|
|
||||||
// Replace the real email provider with mock for testing
|
|
||||||
var emailProviderDescriptor = services.SingleOrDefault(d =>
|
|
||||||
d.ServiceType == typeof(IEmailProvider)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (emailProviderDescriptor != null)
|
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
||||||
{
|
|
||||||
services.Remove(emailProviderDescriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
// Replace the real email dispatcher with mock for testing
|
||||||
|
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
||||||
|
d.ServiceType == typeof(IEmailDispatcher)
|
||||||
|
);
|
||||||
|
|
||||||
// Replace the real email dispatcher with mock for testing
|
if (emailDispatcherDescriptor != null) services.Remove(emailDispatcherDescriptor);
|
||||||
var emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
|
||||||
d.ServiceType == typeof(IEmailDispatcher)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (emailDispatcherDescriptor != null)
|
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
||||||
{
|
});
|
||||||
services.Remove(emailDispatcherDescriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,37 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/API/">
|
<Folder Name="/API/">
|
||||||
<Project Path="API/API.Core/API.Core.csproj" />
|
<Project Path="API/API.Core/API.Core.csproj"/>
|
||||||
<Project Path="API/API.Specs/API.Specs.csproj" />
|
<Project Path="API/API.Specs/API.Specs.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Database/">
|
<Folder Name="/Database/">
|
||||||
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
|
<Project Path="Database/Database.Migrations/Database.Migrations.csproj"/>
|
||||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
<Project Path="Database/Database.Seed/Database.Seed.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Domain/">
|
<Folder Name="/Domain/">
|
||||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj"/>
|
||||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Infrastructure/">
|
<Folder Name="/Infrastructure/">
|
||||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/>
|
||||||
<Project
|
<Project
|
||||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
|
||||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj"/>
|
||||||
<Project
|
<Project
|
||||||
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
|
||||||
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Features/">
|
<Folder Name="/Features/">
|
||||||
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" />
|
<Project Path="Features/Features.Breweries/Features.Breweries.csproj"/>
|
||||||
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" />
|
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/>
|
||||||
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj" />
|
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/>
|
||||||
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj" />
|
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/>
|
||||||
<Project Path="Features/Features.Auth/Features.Auth.csproj" />
|
<Project Path="Features/Features.Auth/Features.Auth.csproj"/>
|
||||||
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
|
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj"/>
|
||||||
<Project Path="Features/Features.Emails/Features.Emails.csproj" />
|
<Project Path="Features/Features.Emails/Features.Emails.csproj"/>
|
||||||
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
|
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Shared/">
|
<Folder Name="/Shared/">
|
||||||
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
|
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj"/>
|
||||||
<Project Path="Shared/Shared.Application/Shared.Application.csproj" />
|
<Project Path="Shared/Shared.Application/Shared.Application.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Database.Migrations</RootNamespace>
|
<RootNamespace>Database.Migrations</RootNamespace>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41"/>
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="scripts/**/*.sql" />
|
<EmbeddedResource Include="scripts/**/*.sql"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="..\..\.dockerignore">
|
<Content Include="..\..\.dockerignore">
|
||||||
<Link>.dockerignore</Link>
|
<Link>.dockerignore</Link>
|
||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,50 +1,57 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DbUp;
|
using DbUp;
|
||||||
|
using DbUp.Engine;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
namespace Database.Migrations;
|
namespace Database.Migrations;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for the database migration runner. Reads connection details from
|
/// Entry point for the database migration runner. Reads connection details from
|
||||||
/// environment variables, optionally clears and recreates the target database, and
|
/// environment variables, optionally clears and recreates the target database, and
|
||||||
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
|
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
||||||
|
private static readonly string connectionString = BuildConnectionString();
|
||||||
|
|
||||||
|
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
|
||||||
|
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||||
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||||
/// environment variables.
|
/// environment variables.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="databaseName">
|
/// <param name="databaseName">
|
||||||
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
|
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
|
||||||
/// <c>DB_NAME</c> environment variable.
|
/// <c>DB_NAME</c> environment variable.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>A fully built SQL Server connection string.</returns>
|
/// <returns>A fully built SQL Server connection string.</returns>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
|
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
|
||||||
/// <paramref name="databaseName"/> is <c>null</c>) <c>DB_NAME</c> is not set.
|
/// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
private static string BuildConnectionString(string? databaseName = null)
|
private static string BuildConnectionString(string? databaseName = null)
|
||||||
{
|
{
|
||||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
string server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||||
|
|
||||||
var dbName = databaseName
|
string dbName = databaseName
|
||||||
?? Environment.GetEnvironmentVariable("DB_NAME")
|
?? Environment.GetEnvironmentVariable("DB_NAME")
|
||||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||||
|
|
||||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
string user = Environment.GetEnvironmentVariable("DB_USER")
|
||||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||||
|
|
||||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
string password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||||
|
|
||||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
||||||
?? "True";
|
?? "True";
|
||||||
|
|
||||||
var builder = new SqlConnectionStringBuilder
|
SqlConnectionStringBuilder builder = new()
|
||||||
{
|
{
|
||||||
DataSource = server,
|
DataSource = server,
|
||||||
InitialCatalog = dbName,
|
InitialCatalog = dbName,
|
||||||
@@ -57,47 +64,41 @@ public static class Program
|
|||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
|
||||||
private static readonly string connectionString = BuildConnectionString();
|
|
||||||
|
|
||||||
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
|
|
||||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
||||||
/// database using DbUp, logging progress to the console.
|
/// database using DbUp, logging progress to the console.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
||||||
private static bool DeployMigrations()
|
private static bool DeployMigrations()
|
||||||
{
|
{
|
||||||
var upgrader = DeployChanges
|
UpgradeEngine? upgrader = DeployChanges
|
||||||
.To.SqlDatabase(connectionString)
|
.To.SqlDatabase(connectionString)
|
||||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||||
.LogToConsole()
|
.LogToConsole()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var result = upgrader.PerformUpgrade();
|
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
|
||||||
return result.Successful;
|
return result.Successful;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
|
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
|
||||||
/// mode with rollback to terminate any existing connections.
|
/// mode with rollback to terminate any existing connections.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
|
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
|
||||||
/// if an error occurred while connecting or dropping the database.
|
/// if an error occurred while connecting or dropping the database.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
private static bool ClearDatabase()
|
private static bool ClearDatabase()
|
||||||
{
|
{
|
||||||
var myConn = new SqlConnection(masterConnectionString);
|
SqlConnection myConn = new(masterConnectionString);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
myConn.Open();
|
myConn.Open();
|
||||||
|
|
||||||
// First, set the database to single user mode to close all connections
|
// First, set the database to single user mode to close all connections
|
||||||
var setModeCommand = new SqlCommand(
|
SqlCommand setModeCommand = new(
|
||||||
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
|
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
|
||||||
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
||||||
myConn);
|
myConn);
|
||||||
@@ -106,79 +107,75 @@ public static class Program
|
|||||||
setModeCommand.ExecuteNonQuery();
|
setModeCommand.ExecuteNonQuery();
|
||||||
Console.WriteLine("Database set to single user mode.");
|
Console.WriteLine("Database set to single user mode.");
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then drop the database
|
// Then drop the database
|
||||||
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
dropCommand.ExecuteNonQuery();
|
dropCommand.ExecuteNonQuery();
|
||||||
Console.WriteLine("Database cleared successfully.");
|
Console.WriteLine("Database cleared successfully.");
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error dropping database: {ex}");
|
Console.WriteLine($"Error dropping database: {ex}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error clearing database: {ex}");
|
Console.WriteLine($"Error clearing database: {ex}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (myConn.State == ConnectionState.Open)
|
if (myConn.State == ConnectionState.Open) myConn.Close();
|
||||||
{
|
|
||||||
myConn.Close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates the <c>Biergarten</c> database on the master connection if it does not
|
/// Creates the <c>Biergarten</c> database on the master connection if it does not
|
||||||
/// already exist. Errors encountered while creating the database are logged but do
|
/// already exist. Errors encountered while creating the database are logged but do
|
||||||
/// not stop execution.
|
/// not stop execution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
|
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
|
||||||
private static bool CreateDatabaseIfNotExists()
|
private static bool CreateDatabaseIfNotExists()
|
||||||
{
|
{
|
||||||
var myConn = new SqlConnection(masterConnectionString);
|
SqlConnection myConn = new(masterConnectionString);
|
||||||
|
|
||||||
const string str = """
|
const string str = """
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
|
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
|
||||||
CREATE DATABASE [Biergarten]
|
CREATE DATABASE [Biergarten]
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var myCommand = new SqlCommand(str, myConn);
|
SqlCommand myCommand = new(str, myConn);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
myConn.Open();
|
myConn.Open();
|
||||||
myCommand.ExecuteNonQuery();
|
myCommand.ExecuteNonQuery();
|
||||||
Console.WriteLine("Database creation command executed successfully.");
|
Console.WriteLine("Database creation command executed successfully.");
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error creating database: {ex}");
|
Console.WriteLine($"Error creating database: {ex}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (myConn.State == ConnectionState.Open)
|
if (myConn.State == ConnectionState.Open) myConn.Close();
|
||||||
{
|
|
||||||
myConn.Close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Migration runner entry point. Optionally clears the existing database when the
|
/// Migration runner entry point. Optionally clears the existing database when the
|
||||||
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
|
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
|
||||||
/// database exists, then deploys all pending migrations.
|
/// database exists, then deploys all pending migrations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="args">Command-line arguments (unused).</param>
|
/// <param name="args">Command-line arguments (unused).</param>
|
||||||
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
|
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
|
||||||
@@ -188,7 +185,7 @@ public static class Program
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
||||||
if (clearDatabase == "true")
|
if (clearDatabase == "true")
|
||||||
{
|
{
|
||||||
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
|
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
|
||||||
@@ -196,18 +193,16 @@ public static class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
CreateDatabaseIfNotExists();
|
CreateDatabaseIfNotExists();
|
||||||
var success = DeployMigrations();
|
bool success = DeployMigrations();
|
||||||
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Database migrations completed successfully.");
|
Console.WriteLine("Database migrations completed successfully.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
Console.WriteLine("Database migrations failed.");
|
||||||
Console.WriteLine("Database migrations failed.");
|
return 1;
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -216,4 +211,4 @@ public static class Program
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount
|
|||||||
UserAccountID UNIQUEIDENTIFIER
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||||
|
|
||||||
Username VARCHAR(64) NOT NULL,
|
Username VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
FirstName NVARCHAR(128) NOT NULL,
|
FirstName NVARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
LastName NVARCHAR(128) NOT NULL,
|
LastName NVARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
Email VARCHAR(128) NOT NULL,
|
Email VARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
DateOfBirth DATE NOT NULL,
|
DateOfBirth DATE NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserAccount
|
CONSTRAINT PK_UserAccount
|
||||||
PRIMARY KEY (UserAccountID),
|
PRIMARY KEY (UserAccountID),
|
||||||
@@ -56,29 +56,30 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
||||||
(
|
(
|
||||||
PhotoID UNIQUEIDENTIFIER
|
PhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
Hyperlink NVARCHAR(256),
|
Hyperlink NVARCHAR(256),
|
||||||
-- storage is handled via filesystem or cloud service
|
-- storage is handled via filesystem or cloud service
|
||||||
|
|
||||||
UploadedByID UNIQUEIDENTIFIER NOT NULL,
|
UploadedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
UploadedAt DATETIME NOT NULL
|
UploadedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_Photo
|
CONSTRAINT PK_Photo
|
||||||
PRIMARY KEY (PhotoID),
|
PRIMARY KEY (PhotoID),
|
||||||
|
|
||||||
CONSTRAINT FK_Photo_UploadedBy
|
CONSTRAINT FK_Photo_UploadedBy
|
||||||
FOREIGN KEY (UploadedByID)
|
FOREIGN KEY (UploadedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||||
ON Photo(UploadedByID);
|
ON Photo(UploadedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -86,31 +87,32 @@ CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
|
|||||||
|
|
||||||
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||||
(
|
(
|
||||||
UserAvatarID UNIQUEIDENTIFIER
|
UserAvatarID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserAvatar_UserAccount
|
CONSTRAINT FK_UserAvatar_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_UserAvatar_PhotoID
|
CONSTRAINT FK_UserAvatar_PhotoID
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo(PhotoID),
|
REFERENCES Photo (PhotoID),
|
||||||
|
|
||||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||||
UNIQUE (UserAccountID)
|
UNIQUE (UserAccountID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||||
ON UserAvatar(UserAccountID);
|
ON UserAvatar(UserAccountID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -118,29 +120,30 @@ CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
|||||||
|
|
||||||
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
||||||
(
|
(
|
||||||
UserVerificationID UNIQUEIDENTIFIER
|
UserVerificationID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
|
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
VerificationDateTime DATETIME NOT NULL
|
VerificationDateTime DATETIME NOT NULL
|
||||||
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserVerification
|
CONSTRAINT PK_UserVerification
|
||||||
PRIMARY KEY (UserVerificationID),
|
PRIMARY KEY (UserVerificationID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserVerification_UserAccount
|
CONSTRAINT FK_UserVerification_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT AK_UserVerification_UserAccountID
|
CONSTRAINT AK_UserVerification_UserAccountID
|
||||||
UNIQUE (UserAccountID)
|
UNIQUE (UserAccountID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||||
ON UserVerification(UserAccountID);
|
ON UserVerification(UserAccountID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -151,37 +154,39 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
UserCredentialID UNIQUEIDENTIFIER
|
UserCredentialID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
|
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Expiry DATETIME NOT NULL
|
Expiry DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||||
|
|
||||||
Hash NVARCHAR(256) NOT NULL,
|
Hash NVARCHAR(256) NOT NULL,
|
||||||
-- uses argon2
|
-- uses argon2
|
||||||
|
|
||||||
IsRevoked BIT NOT NULL
|
IsRevoked BIT NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
|
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
|
||||||
|
|
||||||
RevokedAt DATETIME NULL,
|
RevokedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserCredential
|
CONSTRAINT PK_UserCredential
|
||||||
PRIMARY KEY (UserCredentialID),
|
PRIMARY KEY (UserCredentialID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserCredential_UserAccount
|
CONSTRAINT FK_UserCredential_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||||
ON UserCredential(UserAccountID);
|
ON UserCredential(UserAccountID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||||
INCLUDE (Hash);
|
INCLUDE (Hash);
|
||||||
|
|
||||||
@@ -190,39 +195,42 @@ CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
|||||||
|
|
||||||
CREATE TABLE UserFollow
|
CREATE TABLE UserFollow
|
||||||
(
|
(
|
||||||
UserFollowID UNIQUEIDENTIFIER
|
UserFollowID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserFollow
|
CONSTRAINT PK_UserFollow
|
||||||
PRIMARY KEY (UserFollowID),
|
PRIMARY KEY (UserFollowID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccount
|
CONSTRAINT FK_UserFollow_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||||
FOREIGN KEY (FollowingID)
|
FOREIGN KEY (FollowingID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CK_CannotFollowOwnAccount
|
CONSTRAINT CK_CannotFollowOwnAccount
|
||||||
CHECK (UserAccountID != FollowingID)
|
CHECK (UserAccountID != FollowingID
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||||
ON UserFollow(UserAccountID, FollowingID);
|
ON UserFollow(UserAccountID, FollowingID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||||
ON UserFollow(FollowingID, UserAccountID);
|
ON UserFollow(FollowingID, UserAccountID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -230,14 +238,14 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
|||||||
|
|
||||||
CREATE TABLE Country
|
CREATE TABLE Country
|
||||||
(
|
(
|
||||||
CountryID UNIQUEIDENTIFIER
|
CountryID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
||||||
|
|
||||||
CountryName NVARCHAR(100) NOT NULL,
|
CountryName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
ISO3166_1 CHAR(2) NOT NULL,
|
ISO3166_1 CHAR(2) NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_Country
|
CONSTRAINT PK_Country
|
||||||
PRIMARY KEY (CountryID),
|
PRIMARY KEY (CountryID),
|
||||||
@@ -251,17 +259,17 @@ CREATE TABLE Country
|
|||||||
|
|
||||||
CREATE TABLE StateProvince
|
CREATE TABLE StateProvince
|
||||||
(
|
(
|
||||||
StateProvinceID UNIQUEIDENTIFIER
|
StateProvinceID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
|
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
|
||||||
|
|
||||||
StateProvinceName NVARCHAR(100) NOT NULL,
|
StateProvinceName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
ISO3166_2 CHAR(6) NOT NULL,
|
ISO3166_2 CHAR(6) NOT NULL,
|
||||||
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
||||||
|
|
||||||
CountryID UNIQUEIDENTIFIER NOT NULL,
|
CountryID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_StateProvince
|
CONSTRAINT PK_StateProvince
|
||||||
PRIMARY KEY (StateProvinceID),
|
PRIMARY KEY (StateProvinceID),
|
||||||
@@ -271,10 +279,11 @@ CREATE TABLE StateProvince
|
|||||||
|
|
||||||
CONSTRAINT FK_StateProvince_Country
|
CONSTRAINT FK_StateProvince_Country
|
||||||
FOREIGN KEY (CountryID)
|
FOREIGN KEY (CountryID)
|
||||||
REFERENCES Country(CountryID)
|
REFERENCES Country (CountryID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_StateProvince_Country
|
||||||
ON StateProvince(CountryID);
|
ON StateProvince(CountryID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -282,24 +291,25 @@ CREATE NONCLUSTERED INDEX IX_StateProvince_Country
|
|||||||
|
|
||||||
CREATE TABLE City
|
CREATE TABLE City
|
||||||
(
|
(
|
||||||
CityID UNIQUEIDENTIFIER
|
CityID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
||||||
|
|
||||||
CityName NVARCHAR(100) NOT NULL,
|
CityName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
|
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_City
|
CONSTRAINT PK_City
|
||||||
PRIMARY KEY (CityID),
|
PRIMARY KEY (CityID),
|
||||||
|
|
||||||
CONSTRAINT FK_City_StateProvince
|
CONSTRAINT FK_City_StateProvince
|
||||||
FOREIGN KEY (StateProvinceID)
|
FOREIGN KEY (StateProvinceID)
|
||||||
REFERENCES StateProvince(StateProvinceID)
|
REFERENCES StateProvince (StateProvinceID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_City_StateProvince
|
||||||
ON City(StateProvinceID);
|
ON City(StateProvinceID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -310,29 +320,30 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
BreweryPostID UNIQUEIDENTIFIER
|
BreweryPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryName NVARCHAR(256) NOT NULL,
|
BreweryName NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(512) NOT NULL,
|
Description NVARCHAR(512) NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME NULL,
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPost
|
CONSTRAINT PK_BreweryPost
|
||||||
PRIMARY KEY (BreweryPostID),
|
PRIMARY KEY (BreweryPostID),
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPost_UserAccount
|
CONSTRAINT FK_BreweryPost_UserAccount
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||||
ON BreweryPost(PostedByID);
|
ON BreweryPost(PostedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -343,19 +354,19 @@ CREATE TABLE BreweryPostLocation
|
|||||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
AddressLine1 NVARCHAR(256) NOT NULL,
|
AddressLine1 NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
AddressLine2 NVARCHAR(256),
|
AddressLine2 NVARCHAR(256),
|
||||||
|
|
||||||
PostalCode NVARCHAR(20) NOT NULL,
|
PostalCode NVARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Coordinates GEOGRAPHY NULL,
|
Coordinates GEOGRAPHY NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPostLocation
|
CONSTRAINT PK_BreweryPostLocation
|
||||||
PRIMARY KEY (BreweryPostLocationID),
|
PRIMARY KEY (BreweryPostLocationID),
|
||||||
@@ -365,18 +376,20 @@ CREATE TABLE BreweryPostLocation
|
|||||||
|
|
||||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost(BreweryPostID)
|
REFERENCES BreweryPost (BreweryPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostLocation_City
|
CONSTRAINT FK_BreweryPostLocation_City
|
||||||
FOREIGN KEY (CityID)
|
FOREIGN KEY (CityID)
|
||||||
REFERENCES City(CityID)
|
REFERENCES City (CityID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||||
ON BreweryPostLocation(BreweryPostID);
|
ON BreweryPostLocation(BreweryPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||||
ON BreweryPostLocation(CityID);
|
ON BreweryPostLocation(CityID);
|
||||||
|
|
||||||
-- To assess when the time comes:
|
-- To assess when the time comes:
|
||||||
@@ -399,33 +412,35 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
|||||||
BreweryPostPhotoID UNIQUEIDENTIFIER
|
BreweryPostPhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
LinkedAt DATETIME NOT NULL
|
LinkedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPostPhoto
|
CONSTRAINT PK_BreweryPostPhoto
|
||||||
PRIMARY KEY (BreweryPostPhotoID),
|
PRIMARY KEY (BreweryPostPhotoID),
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
|
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost(BreweryPostID)
|
REFERENCES BreweryPost (BreweryPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostPhoto_Photo
|
CONSTRAINT FK_BreweryPostPhoto_Photo
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo(PhotoID)
|
REFERENCES Photo (PhotoID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -436,11 +451,11 @@ CREATE TABLE BeerStyle
|
|||||||
BeerStyleID UNIQUEIDENTIFIER
|
BeerStyleID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
||||||
|
|
||||||
StyleName NVARCHAR(100) NOT NULL,
|
StyleName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(MAX),
|
Description NVARCHAR(MAX),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerStyle
|
CONSTRAINT PK_BeerStyle
|
||||||
PRIMARY KEY (BeerStyleID),
|
PRIMARY KEY (BeerStyleID),
|
||||||
@@ -454,47 +469,47 @@ CREATE TABLE BeerStyle
|
|||||||
|
|
||||||
CREATE TABLE BeerPost
|
CREATE TABLE BeerPost
|
||||||
(
|
(
|
||||||
BeerPostID UNIQUEIDENTIFIER
|
BeerPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
Name NVARCHAR(100) NOT NULL,
|
Name NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(MAX) NOT NULL,
|
Description NVARCHAR(MAX) NOT NULL,
|
||||||
|
|
||||||
ABV DECIMAL(4,2) NOT NULL,
|
ABV DECIMAL(4, 2) NOT NULL,
|
||||||
-- Alcohol By Volume (typically 0-67%)
|
-- Alcohol By Volume (typically 0-67%)
|
||||||
|
|
||||||
IBU INT NOT NULL,
|
IBU INT NOT NULL,
|
||||||
-- International Bitterness Units (typically 0-120)
|
-- International Bitterness Units (typically 0-120)
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
|
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
BrewedByID UNIQUEIDENTIFIER NOT NULL,
|
BrewedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPost
|
CONSTRAINT PK_BeerPost
|
||||||
PRIMARY KEY (BeerPostID),
|
PRIMARY KEY (BeerPostID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_PostedBy
|
CONSTRAINT FK_BeerPost_PostedBy
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_BeerStyle
|
CONSTRAINT FK_BeerPost_BeerStyle
|
||||||
FOREIGN KEY (BeerStyleID)
|
FOREIGN KEY (BeerStyleID)
|
||||||
REFERENCES BeerStyle(BeerStyleID),
|
REFERENCES BeerStyle (BeerStyleID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_Brewery
|
CONSTRAINT FK_BeerPost_Brewery
|
||||||
FOREIGN KEY (BrewedByID)
|
FOREIGN KEY (BrewedByID)
|
||||||
REFERENCES BreweryPost(BreweryPostID),
|
REFERENCES BreweryPost (BreweryPostID),
|
||||||
|
|
||||||
CONSTRAINT CHK_BeerPost_ABV
|
CONSTRAINT CHK_BeerPost_ABV
|
||||||
CHECK (ABV >= 0 AND ABV <= 67),
|
CHECK (ABV >= 0 AND ABV <= 67),
|
||||||
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
|
|||||||
CHECK (IBU >= 0 AND IBU <= 120)
|
CHECK (IBU >= 0 AND IBU <= 120)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||||
ON BeerPost(PostedByID);
|
ON BeerPost(PostedByID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||||
ON BeerPost(BeerStyleID);
|
ON BeerPost(BeerStyleID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||||
ON BeerPost(BrewedByID);
|
ON BeerPost(BrewedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -520,33 +538,35 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
|||||||
BeerPostPhotoID UNIQUEIDENTIFIER
|
BeerPostPhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
LinkedAt DATETIME NOT NULL
|
LinkedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostPhoto
|
CONSTRAINT PK_BeerPostPhoto
|
||||||
PRIMARY KEY (BeerPostPhotoID),
|
PRIMARY KEY (BeerPostPhotoID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostPhoto_BeerPost
|
CONSTRAINT FK_BeerPostPhoto_BeerPost
|
||||||
FOREIGN KEY (BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
REFERENCES BeerPost(BeerPostID)
|
REFERENCES BeerPost (BeerPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostPhoto_Photo
|
CONSTRAINT FK_BeerPostPhoto_Photo
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo(PhotoID)
|
REFERENCES Photo (PhotoID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -557,39 +577,41 @@ CREATE TABLE BeerPostComment
|
|||||||
BeerPostCommentID UNIQUEIDENTIFIER
|
BeerPostCommentID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
||||||
|
|
||||||
Comment NVARCHAR(250) NOT NULL,
|
Comment NVARCHAR(250) NOT NULL,
|
||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Rating INT NOT NULL,
|
Rating INT NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME NULL,
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostComment
|
CONSTRAINT PK_BeerPostComment
|
||||||
PRIMARY KEY (BeerPostCommentID),
|
PRIMARY KEY (BeerPostCommentID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||||
FOREIGN KEY (BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
REFERENCES BeerPost(BeerPostID),
|
REFERENCES BeerPost (BeerPostID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_UserAccount
|
CONSTRAINT FK_BeerPostComment_UserAccount
|
||||||
FOREIGN KEY (CommentedByID)
|
FOREIGN KEY (CommentedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CHK_BeerPostComment_Rating
|
CONSTRAINT CHK_BeerPostComment_Rating
|
||||||
CHECK (Rating BETWEEN 1 AND 5)
|
CHECK (Rating BETWEEN 1 AND 5)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||||
ON BeerPostComment(BeerPostID);
|
ON BeerPostComment(BeerPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||||
ON BeerPostComment(CommentedByID);
|
ON BeerPostComment(CommentedByID);
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
CREATE
|
||||||
(
|
OR
|
||||||
|
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
||||||
|
(
|
||||||
@CountryCode NVARCHAR(2)
|
@CountryCode NVARCHAR(2)
|
||||||
)
|
)
|
||||||
RETURNS UNIQUEIDENTIFIER
|
RETURNS UNIQUEIDENTIFIER
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @CountryId UNIQUEIDENTIFIER;
|
DECLARE
|
||||||
|
@CountryId UNIQUEIDENTIFIER;
|
||||||
|
|
||||||
SELECT @CountryId = CountryID
|
SELECT @CountryId = CountryID
|
||||||
FROM dbo.Country
|
FROM dbo.Country
|
||||||
WHERE ISO3166_1 = @CountryCode;
|
WHERE ISO3166_1 = @CountryCode;
|
||||||
|
|
||||||
RETURN @CountryId;
|
RETURN @CountryId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
CREATE
|
||||||
(
|
OR
|
||||||
|
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
||||||
|
(
|
||||||
@StateProvinceCode NVARCHAR(6)
|
@StateProvinceCode NVARCHAR(6)
|
||||||
)
|
)
|
||||||
RETURNS UNIQUEIDENTIFIER
|
RETURNS UNIQUEIDENTIFIER
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER;
|
DECLARE
|
||||||
SELECT @StateProvinceId = StateProvinceID
|
@StateProvinceId UNIQUEIDENTIFIER;
|
||||||
FROM dbo.StateProvince
|
SELECT @StateProvinceId = StateProvinceID
|
||||||
WHERE ISO3166_2 = @StateProvinceCode;
|
FROM dbo.StateProvince
|
||||||
RETURN @StateProvinceId;
|
WHERE ISO3166_2 = @StateProvinceCode;
|
||||||
|
RETURN @StateProvinceId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
|
CREATE
|
||||||
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
|
OR
|
||||||
(
|
ALTER PROCEDURE usp_CreateUserAccount
|
||||||
|
(
|
||||||
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
||||||
@Username VARCHAR(64),
|
@Username VARCHAR (64),
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR(128)
|
@Email VARCHAR (128)
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
|
|
||||||
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
|
||||||
|
|
||||||
INSERT INTO UserAccount
|
|
||||||
(
|
|
||||||
Username,
|
|
||||||
FirstName,
|
|
||||||
LastName,
|
|
||||||
DateOfBirth,
|
|
||||||
Email
|
|
||||||
)
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
||||||
|
|
||||||
|
INSERT INTO UserAccount
|
||||||
|
(Username,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
DateOfBirth,
|
||||||
|
Email)
|
||||||
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
||||||
VALUES
|
VALUES
|
||||||
(
|
(
|
||||||
@Username,
|
@Username, @FirstName, @LastName, @DateOfBirth, @Email
|
||||||
@FirstName,
|
|
||||||
@LastName,
|
|
||||||
@DateOfBirth,
|
|
||||||
@Email
|
|
||||||
);
|
);
|
||||||
|
|
||||||
SELECT @UserAccountId = UserAccountID FROM @Inserted;
|
SELECT @UserAccountId = UserAccountID
|
||||||
|
FROM @Inserted;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
|
CREATE
|
||||||
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
|
OR
|
||||||
(
|
ALTER PROCEDURE usp_DeleteUserAccount
|
||||||
|
(
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON
|
SET
|
||||||
|
NOCOUNT ON
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISERROR('UserAccount with the specified ID does not exist.', 16,
|
RAISERROR
|
||||||
|
('UserAccount with the specified ID does not exist.', 16,
|
||||||
1);
|
1);
|
||||||
ROLLBACK TRANSACTION
|
ROLLBACK TRANSACTION
|
||||||
RETURN
|
RETURN
|
||||||
END
|
END
|
||||||
|
|
||||||
DELETE FROM UserAccount
|
DELETE
|
||||||
WHERE UserAccountId = @UserAccountId;
|
FROM UserAccount
|
||||||
|
WHERE UserAccountId = @UserAccountId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
CREATE
|
||||||
AS
|
OR
|
||||||
|
ALTER PROCEDURE usp_GetAllUserAccounts
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount;
|
FROM dbo.UserAccount;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
|
CREATE
|
||||||
@Email VARCHAR(128)
|
OR
|
||||||
)
|
ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||||
AS
|
@Email VARCHAR (128)
|
||||||
|
)
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE Email = @Email;
|
WHERE Email = @Email;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE USP_GetUserAccountById(
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE UserAccountID = @UserAccountId;
|
WHERE UserAccountID = @UserAccountId;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
|
CREATE
|
||||||
@Username VARCHAR(64)
|
OR
|
||||||
)
|
ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||||
AS
|
@Username VARCHAR (64)
|
||||||
|
)
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE Username = @Username;
|
WHERE Username = @Username;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
|
CREATE
|
||||||
@Username VARCHAR(64),
|
OR
|
||||||
|
ALTER PROCEDURE usp_UpdateUserAccount(
|
||||||
|
@Username VARCHAR (64),
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR(128),
|
@Email VARCHAR (128),
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
UPDATE UserAccount
|
UPDATE UserAccount
|
||||||
SET Username = @Username,
|
SET Username = @Username,
|
||||||
FirstName = @FirstName,
|
FirstName = @FirstName,
|
||||||
LastName = @LastName,
|
LastName = @LastName,
|
||||||
DateOfBirth = @DateOfBirth,
|
DateOfBirth = @DateOfBirth,
|
||||||
Email = @Email
|
Email = @Email
|
||||||
WHERE UserAccountId = @UserAccountId;
|
WHERE UserAccountId = @UserAccountId;
|
||||||
|
|
||||||
IF @@ROWCOUNT = 0
|
IF
|
||||||
BEGIN
|
@@ROWCOUNT = 0
|
||||||
|
BEGIN
|
||||||
THROW
|
THROW
|
||||||
50001, 'UserAccount with the specified ID does not exist.', 1;
|
50001, 'UserAccount with the specified ID does not exist.', 1;
|
||||||
END
|
END
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT
|
SELECT UserCredentialId,
|
||||||
UserCredentialId,
|
UserAccountId,
|
||||||
UserAccountId,
|
Hash,
|
||||||
Hash,
|
IsRevoked,
|
||||||
IsRevoked,
|
CreatedAt,
|
||||||
CreatedAt,
|
RevokedAt
|
||||||
RevokedAt
|
FROM dbo.UserCredential
|
||||||
FROM dbo.UserCredential
|
WHERE UserAccountId = @UserAccountId
|
||||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0;
|
AND IsRevoked = 0;
|
||||||
END;
|
END;
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||||
@UserAccountId_ UNIQUEIDENTIFIER
|
@UserAccountId_ UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
||||||
IF @@ROWCOUNT = 0
|
IF
|
||||||
|
@@ROWCOUNT = 0
|
||||||
THROW 50001, 'User account not found', 1;
|
THROW 50001, 'User account not found', 1;
|
||||||
|
|
||||||
-- invalidate all other credentials by setting them to revoked
|
-- invalidate all other credentials by setting them to revoked
|
||||||
UPDATE dbo.UserCredential
|
UPDATE dbo.UserCredential
|
||||||
SET IsRevoked = 1,
|
SET IsRevoked = 1,
|
||||||
RevokedAt = GETDATE()
|
RevokedAt = GETDATE()
|
||||||
WHERE UserAccountId = @UserAccountId_
|
WHERE UserAccountId = @UserAccountId_
|
||||||
AND IsRevoked != 1;
|
AND IsRevoked != 1;
|
||||||
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END;
|
END;
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
|
CREATE
|
||||||
@Username VARCHAR(64),
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||||
|
@Username VARCHAR (64),
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR(128),
|
@Email VARCHAR (128),
|
||||||
@Hash NVARCHAR(MAX)
|
@Hash NVARCHAR(MAX)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
|
DECLARE
|
||||||
|
@UserAccountId_ UNIQUEIDENTIFIER;
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC usp_CreateUserAccount
|
EXEC usp_CreateUserAccount
|
||||||
@UserAccountId = @UserAccountId_ OUTPUT,
|
@UserAccountId = @UserAccountId_ OUTPUT,
|
||||||
@Username = @Username,
|
@Username = @Username,
|
||||||
@FirstName = @FirstName,
|
@FirstName = @FirstName,
|
||||||
@@ -23,20 +29,24 @@ BEGIN
|
|||||||
@DateOfBirth = @DateOfBirth,
|
@DateOfBirth = @DateOfBirth,
|
||||||
@Email = @Email;
|
@Email = @Email;
|
||||||
|
|
||||||
IF @UserAccountId_ IS NULL
|
IF
|
||||||
BEGIN
|
@UserAccountId_ IS NULL
|
||||||
THROW 50000, 'Failed to create user account.', 1;
|
BEGIN
|
||||||
END
|
THROW
|
||||||
|
50000, 'Failed to create user account.', 1;
|
||||||
INSERT INTO dbo.UserCredential
|
END
|
||||||
(UserAccountId, Hash)
|
|
||||||
VALUES (@UserAccountId_, @Hash);
|
INSERT INTO dbo.UserCredential
|
||||||
|
(UserAccountId, Hash)
|
||||||
IF @@ROWCOUNT = 0
|
VALUES (@UserAccountId_, @Hash);
|
||||||
BEGIN
|
|
||||||
THROW 50002, 'Failed to create user credential.', 1;
|
IF
|
||||||
END
|
@@ROWCOUNT = 0
|
||||||
COMMIT TRANSACTION;
|
BEGIN
|
||||||
|
THROW
|
||||||
SELECT @UserAccountId_ AS UserAccountId;
|
50002, 'Failed to create user credential.', 1;
|
||||||
|
END
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
|
SELECT @UserAccountId_ AS UserAccountId;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||||
@UserAccountId_ UNIQUEIDENTIFIER,
|
@UserAccountId_ UNIQUEIDENTIFIER,
|
||||||
@Hash NVARCHAR(MAX)
|
@Hash NVARCHAR(MAX)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
BEGIN TRANSACTION;
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
||||||
|
|
||||||
IF @@ROWCOUNT = 0
|
IF @@ROWCOUNT = 0
|
||||||
THROW 50001, 'User account not found', 1;
|
THROW 50001, 'User account not found', 1;
|
||||||
|
|
||||||
|
|
||||||
-- invalidate all other credentials -- set them to revoked
|
-- invalidate all other credentials -- set them to revoked
|
||||||
UPDATE dbo.UserCredential
|
UPDATE dbo.UserCredential
|
||||||
SET IsRevoked = 1,
|
SET IsRevoked = 1,
|
||||||
RevokedAt = GETDATE()
|
RevokedAt = GETDATE()
|
||||||
WHERE UserAccountId = @UserAccountId_;
|
WHERE UserAccountId = @UserAccountId_;
|
||||||
|
|
||||||
INSERT INTO dbo.UserCredential
|
INSERT INTO dbo.UserCredential
|
||||||
(UserAccountId, Hash)
|
(UserAccountId, Hash)
|
||||||
VALUES (@UserAccountId_, @Hash);
|
VALUES (@UserAccountId_, @Hash);
|
||||||
|
|
||||||
|
|
||||||
END;
|
END;
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
CREATE
|
||||||
@VerificationDateTime DATETIME = NULL
|
OR
|
||||||
AS
|
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||||
|
@VerificationDateTime DATETIME = NULL
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF @VerificationDateTime IS NULL
|
IF
|
||||||
|
@VerificationDateTime IS NULL
|
||||||
SET @VerificationDateTime = GETDATE();
|
SET @VerificationDateTime = GETDATE();
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
||||||
IF @@ROWCOUNT = 0
|
IF
|
||||||
|
@@ROWCOUNT = 0
|
||||||
THROW 50001, 'Could not find a user with that id', 1;
|
THROW 50001, 'Could not find a user with that id', 1;
|
||||||
|
|
||||||
INSERT INTO dbo.UserVerification
|
INSERT INTO dbo.UserVerification
|
||||||
(UserAccountId, VerificationDateTime)
|
(UserAccountId, VerificationDateTime)
|
||||||
VALUES (@UserAccountID_, @VerificationDateTime);
|
VALUES (@UserAccountID_, @VerificationDateTime);
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateCity(
|
||||||
@CityName NVARCHAR(100),
|
@CityName NVARCHAR(100),
|
||||||
@StateProvinceCode NVARCHAR(6)
|
@StateProvinceCode NVARCHAR(6)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
BEGIN TRANSACTION
|
BEGIN
|
||||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
TRANSACTION
|
||||||
IF @StateProvinceId IS NULL
|
DECLARE
|
||||||
BEGIN
|
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
||||||
THROW 50001, 'State/province does not exist', 1;
|
IF
|
||||||
END
|
@StateProvinceId IS NULL
|
||||||
|
BEGIN
|
||||||
|
THROW
|
||||||
|
50001, 'State/province does not exist', 1;
|
||||||
|
END
|
||||||
|
|
||||||
IF EXISTS (SELECT 1
|
IF
|
||||||
|
EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityName = @CityName
|
WHERE CityName = @CityName
|
||||||
AND StateProvinceID = @StateProvinceId)
|
AND StateProvinceID = @StateProvinceId)
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|
||||||
THROW 50002, 'City already exists.', 1;
|
THROW
|
||||||
END
|
50002, 'City already exists.', 1;
|
||||||
|
END
|
||||||
|
|
||||||
INSERT INTO dbo.City
|
INSERT INTO dbo.City
|
||||||
(StateProvinceID, CityName)
|
(StateProvinceID, CityName)
|
||||||
VALUES (@StateProvinceId, @CityName);
|
VALUES (@StateProvinceId, @CityName);
|
||||||
COMMIT TRANSACTION
|
COMMIT TRANSACTION
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||||
@CountryName NVARCHAR(100),
|
@CountryName NVARCHAR(100),
|
||||||
@ISO3166_1 NVARCHAR(2)
|
@ISO3166_1 NVARCHAR(2)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
BEGIN TRANSACTION;
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
IF EXISTS (SELECT 1
|
IF
|
||||||
|
EXISTS (SELECT 1
|
||||||
FROM dbo.Country
|
FROM dbo.Country
|
||||||
WHERE ISO3166_1 = @ISO3166_1)
|
WHERE ISO3166_1 = @ISO3166_1)
|
||||||
THROW 50001, 'Country already exists', 1;
|
THROW 50001, 'Country already exists', 1;
|
||||||
|
|
||||||
INSERT INTO dbo.Country
|
INSERT INTO dbo.Country
|
||||||
(CountryName, ISO3166_1)
|
(CountryName, ISO3166_1)
|
||||||
VALUES
|
VALUES (@CountryName, @ISO3166_1);
|
||||||
(@CountryName, @ISO3166_1);
|
COMMIT TRANSACTION;
|
||||||
COMMIT TRANSACTION;
|
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||||
@StateProvinceName NVARCHAR(100),
|
@StateProvinceName NVARCHAR(100),
|
||||||
@ISO3166_2 NVARCHAR(6),
|
@ISO3166_2 NVARCHAR(6),
|
||||||
@CountryCode NVARCHAR(2)
|
@CountryCode NVARCHAR(2)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF EXISTS (SELECT 1
|
IF
|
||||||
|
EXISTS (SELECT 1
|
||||||
FROM dbo.StateProvince
|
FROM dbo.StateProvince
|
||||||
WHERE ISO3166_2 = @ISO3166_2)
|
WHERE ISO3166_2 = @ISO3166_2)
|
||||||
RETURN;
|
RETURN;
|
||||||
|
|
||||||
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
DECLARE
|
||||||
IF @CountryId IS NULL
|
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
||||||
BEGIN
|
IF
|
||||||
THROW 50001, 'Country does not exist', 1;
|
@CountryId IS NULL
|
||||||
|
BEGIN
|
||||||
|
THROW
|
||||||
|
50001, 'Country does not exist', 1;
|
||||||
|
|
||||||
END
|
END
|
||||||
|
|
||||||
INSERT INTO dbo.StateProvince
|
INSERT INTO dbo.StateProvince
|
||||||
(StateProvinceName, ISO3166_2, CountryID)
|
(StateProvinceName, ISO3166_2, CountryID)
|
||||||
VALUES
|
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
|
||||||
(@StateProvinceName, @ISO3166_2, @CountryId);
|
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||||
@BreweryName NVARCHAR(256),
|
@BreweryName NVARCHAR(256),
|
||||||
@Description NVARCHAR(512),
|
@Description NVARCHAR(512),
|
||||||
@PostedByID UNIQUEIDENTIFIER,
|
@PostedByID UNIQUEIDENTIFIER,
|
||||||
@@ -7,44 +9,53 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
|||||||
@AddressLine2 NVARCHAR(256) = NULL,
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
@PostalCode NVARCHAR(20),
|
@PostalCode NVARCHAR(20),
|
||||||
@Coordinates GEOGRAPHY = NULL
|
@Coordinates GEOGRAPHY = NULL
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF @BreweryName IS NULL
|
IF
|
||||||
|
@BreweryName IS NULL
|
||||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
IF @Description IS NULL
|
IF
|
||||||
|
@Description IS NULL
|
||||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE UserAccountID = @PostedByID)
|
WHERE UserAccountID = @PostedByID)
|
||||||
THROW 50404, 'User not found.', 1;
|
THROW 50404, 'User not found.', 1;
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityID = @CityID)
|
WHERE CityID = @CityID)
|
||||||
THROW 50404, 'City not found.', 1;
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
DECLARE
|
||||||
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||||
|
DECLARE
|
||||||
|
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPost
|
INSERT INTO dbo.BreweryPost
|
||||||
(BreweryPostID, BreweryName, Description, PostedByID)
|
(BreweryPostID, BreweryName, Description, PostedByID)
|
||||||
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPostLocation
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
SELECT @NewBreweryID AS BreweryPostID,
|
SELECT @NewBreweryID AS BreweryPostID,
|
||||||
@NewBrewerLocationID AS BreweryPostLocationID;
|
@NewBrewerLocationID AS BreweryPostLocationID;
|
||||||
|
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_DeleteBrewery
|
||||||
@BreweryPostID UNIQUEIDENTIFIER
|
@BreweryPostID UNIQUEIDENTIFIER
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.BreweryPost
|
FROM dbo.BreweryPost
|
||||||
WHERE BreweryPostID = @BreweryPostID)
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
THROW 50404, 'Brewery not found.', 1;
|
THROW 50404, 'Brewery not found.', 1;
|
||||||
|
|
||||||
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
|
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
|
||||||
DELETE FROM dbo.BreweryPost
|
DELETE
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
FROM dbo.BreweryPost
|
||||||
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
||||||
@Limit INT = NULL,
|
@Limit INT = NULL,
|
||||||
@Offset INT = NULL
|
@Offset INT = NULL
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT bp.BreweryPostID,
|
SELECT bp.BreweryPostID,
|
||||||
bp.PostedByID,
|
bp.PostedByID,
|
||||||
bp.BreweryName,
|
bp.BreweryName,
|
||||||
bp.Description,
|
bp.Description,
|
||||||
bp.CreatedAt,
|
bp.CreatedAt,
|
||||||
bp.UpdatedAt,
|
bp.UpdatedAt,
|
||||||
bp.Timer,
|
bp.Timer,
|
||||||
bpl.BreweryPostLocationID,
|
bpl.BreweryPostLocationID,
|
||||||
bpl.CityID,
|
bpl.CityID,
|
||||||
bpl.AddressLine1,
|
bpl.AddressLine1,
|
||||||
bpl.AddressLine2,
|
bpl.AddressLine2,
|
||||||
bpl.PostalCode,
|
bpl.PostalCode,
|
||||||
bpl.Coordinates
|
bpl.Coordinates
|
||||||
FROM dbo.BreweryPost bp
|
FROM dbo.BreweryPost bp
|
||||||
LEFT JOIN dbo.BreweryPostLocation bpl
|
LEFT JOIN dbo.BreweryPostLocation bpl
|
||||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
ORDER BY bp.CreatedAt DESC
|
ORDER BY bp.CreatedAt DESC
|
||||||
OFFSET ISNULL(@Offset, 0) ROWS
|
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
|
||||||
FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
|
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
CREATE
|
||||||
AS
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM BreweryPost bp
|
FROM BreweryPost bp
|
||||||
INNER JOIN BreweryPostLocation bpl
|
INNER JOIN BreweryPostLocation bpl
|
||||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
WHERE bp.BreweryPostID = @BreweryPostID;
|
WHERE bp.BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||||
@BreweryPostID UNIQUEIDENTIFIER,
|
@BreweryPostID UNIQUEIDENTIFIER,
|
||||||
@BreweryName NVARCHAR(256),
|
@BreweryName NVARCHAR(256),
|
||||||
@Description NVARCHAR(512),
|
@Description NVARCHAR(512),
|
||||||
@@ -8,61 +10,70 @@ CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
|||||||
@AddressLine2 NVARCHAR(256) = NULL,
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
@PostalCode NVARCHAR(20) = NULL,
|
@PostalCode NVARCHAR(20) = NULL,
|
||||||
@Coordinates GEOGRAPHY = NULL
|
@Coordinates GEOGRAPHY = NULL
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF @BreweryName IS NULL
|
IF
|
||||||
|
@BreweryName IS NULL
|
||||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
IF @Description IS NULL
|
IF
|
||||||
|
@Description IS NULL
|
||||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.BreweryPost
|
FROM dbo.BreweryPost
|
||||||
WHERE BreweryPostID = @BreweryPostID)
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
THROW 50404, 'Brewery not found.', 1;
|
THROW 50404, 'Brewery not found.', 1;
|
||||||
|
|
||||||
IF @CityID IS NOT NULL AND NOT EXISTS (SELECT 1
|
IF
|
||||||
|
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityID = @CityID)
|
WHERE CityID = @CityID)
|
||||||
THROW 50404, 'City not found.', 1;
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
UPDATE dbo.BreweryPost
|
UPDATE dbo.BreweryPost
|
||||||
SET BreweryName = @BreweryName,
|
SET BreweryName = @BreweryName,
|
||||||
Description = @Description,
|
Description = @Description,
|
||||||
UpdatedAt = GETDATE()
|
UpdatedAt = GETDATE()
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
|
|
||||||
IF @CityID IS NULL
|
IF
|
||||||
BEGIN
|
@CityID IS NULL
|
||||||
|
BEGIN
|
||||||
-- No location supplied: clear any existing location for this brewery.
|
-- No location supplied: clear any existing location for this brewery.
|
||||||
DELETE FROM dbo.BreweryPostLocation
|
DELETE
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
FROM dbo.BreweryPostLocation
|
||||||
END
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
ELSE IF EXISTS (SELECT 1
|
END
|
||||||
|
ELSE IF EXISTS (SELECT 1
|
||||||
FROM dbo.BreweryPostLocation
|
FROM dbo.BreweryPostLocation
|
||||||
WHERE BreweryPostID = @BreweryPostID)
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
BEGIN
|
BEGIN
|
||||||
UPDATE dbo.BreweryPostLocation
|
UPDATE dbo.BreweryPostLocation
|
||||||
SET CityID = @CityID,
|
SET CityID = @CityID,
|
||||||
AddressLine1 = @AddressLine1,
|
AddressLine1 = @AddressLine1,
|
||||||
AddressLine2 = @AddressLine2,
|
AddressLine2 = @AddressLine2,
|
||||||
PostalCode = @PostalCode,
|
PostalCode = @PostalCode,
|
||||||
Coordinates = @Coordinates
|
Coordinates = @Coordinates
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
ELSE
|
ELSE
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO dbo.BreweryPostLocation
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
|
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
|
||||||
@PostalCode, @Coordinates);
|
@PostalCode, @Coordinates);
|
||||||
END
|
END
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Database.Seed</RootNamespace>
|
<RootNamespace>Database.Seed</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
<PackageReference Include="idunno.Password.Generator" Version="1.0.1"/>
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="Konscious.Security.Cryptography.Argon2"
|
Include="Konscious.Security.Cryptography.Argon2"
|
||||||
Version="1.3.1"
|
Version="1.3.1"
|
||||||
/>
|
/>
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4"/>
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ using Microsoft.Data.SqlClient;
|
|||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines a unit of seed data that can be applied to the database. Implementations
|
/// Defines a unit of seed data that can be applied to the database. Implementations
|
||||||
/// should be safe to run against an already-seeded database (e.g. by checking for
|
/// should be safe to run against an already-seeded database (e.g. by checking for
|
||||||
/// existing data before inserting) and may depend on data created by seeders that run
|
/// existing data before inserting) and may depend on data created by seeders that run
|
||||||
/// before them.
|
/// before them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal interface ISeeder
|
internal interface ISeeder
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inserts this seeder's data into the database using the supplied open connection.
|
/// Inserts this seeder's data into the database using the supplied open connection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
/// <returns>A task that completes when seeding is finished.</returns>
|
/// <returns>A task that completes when seeding is finished.</returns>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ using Microsoft.Data.SqlClient;
|
|||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to
|
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to
|
||||||
/// associate other entities (e.g. breweries, users) with a geographic location.
|
/// associate other entities (e.g. breweries, users) with a geographic location.
|
||||||
/// Countries must be seeded before states/provinces, which must be seeded before
|
/// Countries must be seeded before states/provinces, which must be seeded before
|
||||||
/// cities, since each level references its parent by code. The underlying stored
|
/// cities, since each level references its parent by code. The underlying stored
|
||||||
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
|
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
|
||||||
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
|
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
|
||||||
/// against an already-seeded database is safe.
|
/// against an already-seeded database is safe.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class LocationSeeder : ISeeder
|
internal class LocationSeeder : ISeeder
|
||||||
{
|
{
|
||||||
@@ -22,12 +22,12 @@ internal class LocationSeeder : ISeeder
|
|||||||
[
|
[
|
||||||
("Canada", "CA"),
|
("Canada", "CA"),
|
||||||
("Mexico", "MX"),
|
("Mexico", "MX"),
|
||||||
("United States", "US"),
|
("United States", "US")
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
|
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
|
||||||
/// and the ISO 3166-1 code of its parent country.
|
/// and the ISO 3166-1 code of its parent country.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
||||||
{
|
{
|
||||||
@@ -134,12 +134,12 @@ internal class LocationSeeder : ISeeder
|
|||||||
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
|
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
|
||||||
("Yucatán", "MX-YUC", "MX"),
|
("Yucatán", "MX-YUC", "MX"),
|
||||||
("Zacatecas", "MX-ZAC", "MX"),
|
("Zacatecas", "MX-ZAC", "MX"),
|
||||||
("Ciudad de México", "MX-CMX", "MX"),
|
("Ciudad de México", "MX-CMX", "MX")
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
|
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
|
||||||
/// parent state/province.
|
/// parent state/province.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
||||||
[
|
[
|
||||||
@@ -255,38 +255,32 @@ internal class LocationSeeder : ISeeder
|
|||||||
("MX-COA", "Saltillo"),
|
("MX-COA", "Saltillo"),
|
||||||
("MX-BCS", "La Paz"),
|
("MX-BCS", "La Paz"),
|
||||||
("MX-NAY", "Tepic"),
|
("MX-NAY", "Tepic"),
|
||||||
("MX-ZAC", "Zacatecas"),
|
("MX-ZAC", "Zacatecas")
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds all countries, then states/provinces, then cities, in that order, so that
|
/// Seeds all countries, then states/provinces, then cities, in that order, so that
|
||||||
/// each level's parent reference already exists by the time it is created.
|
/// each level's parent reference already exists by the time it is created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
/// <returns>A task that completes when all locations have been seeded.</returns>
|
/// <returns>A task that completes when all locations have been seeded.</returns>
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
public async Task SeedAsync(SqlConnection connection)
|
||||||
{
|
{
|
||||||
foreach (var (countryName, countryCode) in Countries)
|
foreach ((string countryName, string countryCode) in Countries)
|
||||||
{
|
|
||||||
await CreateCountryAsync(connection, countryName, countryCode);
|
await CreateCountryAsync(connection, countryName, countryCode);
|
||||||
}
|
|
||||||
|
|
||||||
foreach (
|
foreach (
|
||||||
var (stateProvinceName, stateProvinceCode, countryCode) in States
|
(string stateProvinceName, string stateProvinceCode, string countryCode) in States
|
||||||
)
|
)
|
||||||
{
|
|
||||||
await CreateStateProvinceAsync(
|
await CreateStateProvinceAsync(
|
||||||
connection,
|
connection,
|
||||||
stateProvinceName,
|
stateProvinceName,
|
||||||
stateProvinceCode,
|
stateProvinceCode,
|
||||||
countryCode
|
countryCode
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var (stateProvinceCode, cityName) in Cities)
|
foreach ((string stateProvinceCode, string cityName) in Cities)
|
||||||
{
|
|
||||||
await CreateCityAsync(connection, cityName, stateProvinceCode);
|
await CreateCityAsync(connection, cityName, stateProvinceCode);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
|
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
|
||||||
@@ -300,7 +294,7 @@ internal class LocationSeeder : ISeeder
|
|||||||
string countryCode
|
string countryCode
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new(
|
||||||
"dbo.USP_CreateCountry",
|
"dbo.USP_CreateCountry",
|
||||||
connection
|
connection
|
||||||
);
|
);
|
||||||
@@ -324,7 +318,7 @@ internal class LocationSeeder : ISeeder
|
|||||||
string countryCode
|
string countryCode
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new(
|
||||||
"dbo.USP_CreateStateProvince",
|
"dbo.USP_CreateStateProvince",
|
||||||
connection
|
connection
|
||||||
);
|
);
|
||||||
@@ -350,7 +344,7 @@ internal class LocationSeeder : ISeeder
|
|||||||
string stateProvinceCode
|
string stateProvinceCode
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new(
|
||||||
"dbo.USP_CreateCity",
|
"dbo.USP_CreateCity",
|
||||||
connection
|
connection
|
||||||
);
|
);
|
||||||
@@ -363,4 +357,4 @@ internal class LocationSeeder : ISeeder
|
|||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
using Database.Seed;
|
||||||
using DbUp;
|
using Microsoft.Data.SqlClient;
|
||||||
using System.Reflection;
|
|
||||||
using Database.Seed;
|
|
||||||
|
|
||||||
// Entry point for the database seeding utility. Connects to the target database
|
// Entry point for the database seeding utility. Connects to the target database
|
||||||
// (retrying on transient failures), then runs each registered ISeeder in order to
|
// (retrying on transient failures), then runs each registered ISeeder in order to
|
||||||
@@ -19,22 +17,22 @@ using Database.Seed;
|
|||||||
/// </exception>
|
/// </exception>
|
||||||
string BuildConnectionString()
|
string BuildConnectionString()
|
||||||
{
|
{
|
||||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
string server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||||
|
|
||||||
var dbName = Environment.GetEnvironmentVariable("DB_NAME")
|
string dbName = Environment.GetEnvironmentVariable("DB_NAME")
|
||||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||||
|
|
||||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
string user = Environment.GetEnvironmentVariable("DB_USER")
|
||||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||||
|
|
||||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
string password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||||
|
|
||||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
||||||
?? "True";
|
?? "True";
|
||||||
|
|
||||||
var builder = new SqlConnectionStringBuilder
|
SqlConnectionStringBuilder builder = new()
|
||||||
{
|
{
|
||||||
DataSource = server,
|
DataSource = server,
|
||||||
InitialCatalog = dbName,
|
InitialCatalog = dbName,
|
||||||
@@ -50,7 +48,7 @@ string BuildConnectionString()
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var connectionString = BuildConnectionString();
|
string connectionString = BuildConnectionString();
|
||||||
|
|
||||||
Console.WriteLine("Attempting to connect to database...");
|
Console.WriteLine("Attempting to connect to database...");
|
||||||
|
|
||||||
@@ -60,7 +58,6 @@ try
|
|||||||
int retryDelayMs = 2000;
|
int retryDelayMs = 2000;
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
connection = new SqlConnection(connectionString);
|
connection = new SqlConnection(connectionString);
|
||||||
@@ -76,12 +73,8 @@ try
|
|||||||
connection?.Dispose();
|
connection?.Dispose();
|
||||||
connection = null;
|
connection = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (connection == null)
|
if (connection == null) throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
|
||||||
{
|
|
||||||
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Starting seeding...");
|
Console.WriteLine("Starting seeding...");
|
||||||
|
|
||||||
@@ -90,10 +83,10 @@ try
|
|||||||
ISeeder[] seeders =
|
ISeeder[] seeders =
|
||||||
[
|
[
|
||||||
new LocationSeeder(),
|
new LocationSeeder(),
|
||||||
new UserSeeder(),
|
new UserSeeder()
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (var seeder in seeders)
|
foreach (ISeeder seeder in seeders)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
|
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
|
||||||
await seeder.SeedAsync(connection);
|
await seeder.SeedAsync(connection);
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ using Microsoft.Data.SqlClient;
|
|||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds user accounts, credentials, and verification records. Creates one fixed
|
/// Seeds user accounts, credentials, and verification records. Creates one fixed
|
||||||
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
|
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
|
||||||
/// for testing, followed by a randomly-generated account for each name in
|
/// for testing, followed by a randomly-generated account for each name in
|
||||||
/// <see cref="SeedNames"/>, each with a random password and date of birth. Skips
|
/// <see cref="SeedNames" />, each with a random password and date of birth. Skips
|
||||||
/// adding a verification record for a user that already has one, so this seeder is
|
/// adding a verification record for a user that already has one, so this seeder is
|
||||||
/// safe to re-run, though re-running will still attempt to register (and may fail on)
|
/// safe to re-run, though re-running will still attempt to register (and may fail on)
|
||||||
/// users that already exist.
|
/// users that already exist.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class UserSeeder : ISeeder
|
internal class UserSeeder : ISeeder
|
||||||
{
|
{
|
||||||
@@ -123,21 +123,21 @@ internal class UserSeeder : ISeeder
|
|||||||
("Zara", "Wilkinson"),
|
("Zara", "Wilkinson"),
|
||||||
("Zaria", "Gibson"),
|
("Zaria", "Gibson"),
|
||||||
("Zion", "Watkins"),
|
("Zion", "Watkins"),
|
||||||
("Zoie", "Armstrong"),
|
("Zoie", "Armstrong")
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a fixed test user account followed by one randomly-generated account
|
/// Registers a fixed test user account followed by one randomly-generated account
|
||||||
/// per entry in <see cref="SeedNames"/>, adding a user verification record for each
|
/// per entry in <see cref="SeedNames" />, adding a user verification record for each
|
||||||
/// newly created account that does not already have one. Progress counts are
|
/// newly created account that does not already have one. Progress counts are
|
||||||
/// written to the console on completion.
|
/// written to the console on completion.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
/// <returns>A task that completes when all users have been seeded.</returns>
|
/// <returns>A task that completes when all users have been seeded.</returns>
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
public async Task SeedAsync(SqlConnection connection)
|
||||||
{
|
{
|
||||||
var generator = new PasswordGenerator();
|
PasswordGenerator generator = new();
|
||||||
var rng = new Random();
|
Random rng = new();
|
||||||
int createdUsers = 0;
|
int createdUsers = 0;
|
||||||
int createdCredentials = 0;
|
int createdCredentials = 0;
|
||||||
int createdVerifications = 0;
|
int createdVerifications = 0;
|
||||||
@@ -147,8 +147,8 @@ internal class UserSeeder : ISeeder
|
|||||||
const string firstName = "Test";
|
const string firstName = "Test";
|
||||||
const string lastName = "User";
|
const string lastName = "User";
|
||||||
const string email = "test.user@thebiergarten.app";
|
const string email = "test.user@thebiergarten.app";
|
||||||
var dob = new DateTime(1985, 03, 01);
|
DateTime dob = new(1985, 03, 01);
|
||||||
var hash = GeneratePasswordHash("password");
|
string hash = GeneratePasswordHash("password");
|
||||||
|
|
||||||
await RegisterUserAsync(
|
await RegisterUserAsync(
|
||||||
connection,
|
connection,
|
||||||
@@ -160,24 +160,24 @@ internal class UserSeeder : ISeeder
|
|||||||
hash
|
hash
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
foreach (var (firstName, lastName) in SeedNames)
|
foreach ((string firstName, string lastName) in SeedNames)
|
||||||
{
|
{
|
||||||
// prepare user fields
|
// prepare user fields
|
||||||
var username = $"{firstName[0]}.{lastName}";
|
string username = $"{firstName[0]}.{lastName}";
|
||||||
var email = $"{firstName}.{lastName}@thebiergarten.app";
|
string email = $"{firstName}.{lastName}@thebiergarten.app";
|
||||||
var dob = GenerateDateOfBirth(rng);
|
DateTime dob = GenerateDateOfBirth(rng);
|
||||||
|
|
||||||
// generate a password and hash it
|
// generate a password and hash it
|
||||||
string pwd = generator.Generate(
|
string pwd = generator.Generate(
|
||||||
length: 64,
|
64,
|
||||||
numberOfDigits: 10,
|
10,
|
||||||
numberOfSymbols: 10
|
10
|
||||||
);
|
);
|
||||||
string hash = GeneratePasswordHash(pwd);
|
string hash = GeneratePasswordHash(pwd);
|
||||||
|
|
||||||
|
|
||||||
// register the user (creates account + credential)
|
// register the user (creates account + credential)
|
||||||
var id = await RegisterUserAsync(
|
Guid id = await RegisterUserAsync(
|
||||||
connection,
|
connection,
|
||||||
username,
|
username,
|
||||||
firstName,
|
firstName,
|
||||||
@@ -203,8 +203,8 @@ internal class UserSeeder : ISeeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a new user account and its credential by invoking the
|
/// Registers a new user account and its credential by invoking the
|
||||||
/// <c>dbo.USP_RegisterUser</c> stored procedure.
|
/// <c>dbo.USP_RegisterUser</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
/// <param name="username">The unique username for the account.</param>
|
/// <param name="username">The unique username for the account.</param>
|
||||||
@@ -212,8 +212,8 @@ internal class UserSeeder : ISeeder
|
|||||||
/// <param name="lastName">The user's last name.</param>
|
/// <param name="lastName">The user's last name.</param>
|
||||||
/// <param name="dateOfBirth">The user's date of birth.</param>
|
/// <param name="dateOfBirth">The user's date of birth.</param>
|
||||||
/// <param name="email">The user's email address.</param>
|
/// <param name="email">The user's email address.</param>
|
||||||
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash"/>.</param>
|
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash" />.</param>
|
||||||
/// <returns>The newly created user account's <see cref="Guid"/> identifier.</returns>
|
/// <returns>The newly created user account's <see cref="Guid" /> identifier.</returns>
|
||||||
private static async Task<Guid> RegisterUserAsync(
|
private static async Task<Guid> RegisterUserAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string username,
|
string username,
|
||||||
@@ -224,7 +224,7 @@ internal class UserSeeder : ISeeder
|
|||||||
string hash
|
string hash
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
|
await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
|
|
||||||
@@ -235,16 +235,16 @@ internal class UserSeeder : ISeeder
|
|||||||
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
|
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
|
||||||
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
|
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
|
||||||
|
|
||||||
var result = await command.ExecuteScalarAsync();
|
object? result = await command.ExecuteScalarAsync();
|
||||||
|
|
||||||
|
|
||||||
return (Guid)result!;
|
return (Guid)result!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
|
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
|
||||||
/// salt, a degree of parallelism matching the available processor count, 64 MB of
|
/// salt, a degree of parallelism matching the available processor count, 64 MB of
|
||||||
/// memory, and 4 iterations.
|
/// memory, and 4 iterations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pwd">The plaintext password to hash.</param>
|
/// <param name="pwd">The plaintext password to hash.</param>
|
||||||
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
|
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
|
||||||
@@ -252,12 +252,12 @@ internal class UserSeeder : ISeeder
|
|||||||
{
|
{
|
||||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
|
||||||
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
|
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
|
||||||
{
|
{
|
||||||
Salt = salt,
|
Salt = salt,
|
||||||
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
||||||
MemorySize = 65536,
|
MemorySize = 65536,
|
||||||
Iterations = 4,
|
Iterations = 4
|
||||||
};
|
};
|
||||||
|
|
||||||
byte[] hash = argon2.GetBytes(32);
|
byte[] hash = argon2.GetBytes(32);
|
||||||
@@ -278,9 +278,9 @@ internal class UserSeeder : ISeeder
|
|||||||
FROM dbo.UserVerification
|
FROM dbo.UserVerification
|
||||||
WHERE UserAccountId = @UserAccountId;
|
WHERE UserAccountId = @UserAccountId;
|
||||||
""";
|
""";
|
||||||
await using var command = new SqlCommand(sql, connection);
|
await using SqlCommand command = new(sql, connection);
|
||||||
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
|
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
|
||||||
var result = await command.ExecuteScalarAsync();
|
object? result = await command.ExecuteScalarAsync();
|
||||||
return result is not null;
|
return result is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ internal class UserSeeder : ISeeder
|
|||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new(
|
||||||
"dbo.USP_CreateUserVerification",
|
"dbo.USP_CreateUserVerification",
|
||||||
connection
|
connection
|
||||||
);
|
);
|
||||||
@@ -304,8 +304,8 @@ internal class UserSeeder : ISeeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a random date of birth corresponding to an age between 19 and 48
|
/// Generates a random date of birth corresponding to an age between 19 and 48
|
||||||
/// years (inclusive), with a random day offset within that birth year.
|
/// years (inclusive), with a random day offset within that birth year.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="random">The random number source to use.</param>
|
/// <param name="random">The random number source to use.</param>
|
||||||
/// <returns>A randomly generated date of birth.</returns>
|
/// <returns>A randomly generated date of birth.</returns>
|
||||||
@@ -316,4 +316,4 @@ internal class UserSeeder : ISeeder
|
|||||||
int offsetDays = random.Next(0, 365);
|
int offsetDays = random.Next(0, 365);
|
||||||
return baseDate.AddDays(-offsetDays);
|
return baseDate.AddDays(-offsetDays);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Domain</RootNamespace>
|
<RootNamespace>Domain</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,48 +1,50 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
|
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
|
||||||
/// A user account cannot be deleted while it has an associated brewery post.
|
/// A user account cannot be deleted while it has an associated brewery post.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BreweryPost
|
public class BreweryPost
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
|
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Foreign key referencing the <see cref="UserAccount"/> that authored this post. Maps to <c>PostedByID</c>.
|
/// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The name of the brewery being posted about.
|
/// The name of the brewery being posted about.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Free-text description of the brewery.
|
/// Free-text description of the brewery.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The date and time the post was created.
|
/// The date and time the post was created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
|
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The associated <see cref="BreweryPostLocation"/> for this post, if one has been set. This is a one-to-one navigation property.
|
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
|
||||||
|
/// navigation property.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BreweryPostLocation? Location { get; set; }
|
public BreweryPostLocation? Location { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,52 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the physical location of a <see cref="BreweryPost"/>. Maps to the <c>BreweryPostLocation</c> table.
|
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
|
||||||
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is deleted.
|
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
|
||||||
|
/// deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BreweryPostLocation
|
public class BreweryPostLocation
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
|
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Foreign key referencing the owning <see cref="BreweryPost"/>. Maps to <c>BreweryPostID</c>; unique, enforcing a one-to-one relationship.
|
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
|
||||||
|
/// one-to-one relationship.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The primary street address line.
|
/// The primary street address line.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An optional secondary address line (e.g., suite or unit number).
|
/// An optional secondary address line (e.g., suite or unit number).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? AddressLine2 { get; set; }
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The postal/ZIP code of the location.
|
/// The postal/ZIP code of the location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
|
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid CityId { get; set; }
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or <c>null</c> if not set.
|
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
|
||||||
|
/// <c>null</c> if not set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,53 +1,55 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
|
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
|
||||||
/// referenced by <see cref="UserCredential"/>, <see cref="UserVerification"/>, brewery posts, and other user-owned data.
|
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
|
||||||
|
/// data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserAccount
|
public class UserAccount
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
|
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user's unique username, used for login and display. Enforced unique at the database level.
|
/// The user's unique username, used for login and display. Enforced unique at the database level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user's first name.
|
/// The user's first name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string FirstName { get; set; } = string.Empty;
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user's last name.
|
/// The user's last name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string LastName { get; set; } = string.Empty;
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user's email address. Enforced unique at the database level.
|
/// The user's email address. Enforced unique at the database level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The date and time the account was created.
|
/// The date and time the account was created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
|
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user's date of birth.
|
/// The user's date of birth.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime DateOfBirth { get; set; }
|
public DateTime DateOfBirth { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,40 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount"/>.
|
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
|
||||||
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is deleted.
|
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
|
||||||
|
/// deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserCredential
|
public class UserCredential
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
|
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid UserCredentialId { get; set; }
|
public Guid UserCredentialId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Foreign key referencing the owning <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>.
|
/// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The date and time the credential was issued.
|
/// The date and time the credential was issued.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The date and time after which the credential is no longer valid.
|
/// The date and time after which the credential is no longer valid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime Expiry { get; set; }
|
public DateTime Expiry { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
|
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Hash { get; set; } = string.Empty;
|
public string Hash { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,31 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records that a <see cref="UserAccount"/> has completed verification (e.g., email confirmation).
|
/// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
|
||||||
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
|
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
|
||||||
/// and it is deleted automatically when the owning user account is deleted.
|
/// and it is deleted automatically when the owning user account is deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserVerification
|
public class UserVerification
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
|
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid UserVerificationId { get; set; }
|
public Guid UserVerificationId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Foreign key referencing the verified <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>; unique, enforcing a one-to-one relationship.
|
/// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
|
||||||
|
/// one-to-one relationship.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The date and time at which the user account was verified.
|
/// The date and time at which the user account was verified.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime VerificationDateTime { get; set; }
|
public DateTime VerificationDateTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
namespace Domain.Exceptions;
|
namespace Domain.Exceptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
||||||
/// Maps to HTTP 409 Conflict.
|
/// Maps to HTTP 409 Conflict.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConflictException(string message) : Exception(message);
|
public class ConflictException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a requested resource is not found.
|
/// Exception thrown when a requested resource is not found.
|
||||||
/// Maps to HTTP 404 Not Found.
|
/// Maps to HTTP 404 Not Found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NotFoundException(string message) : Exception(message);
|
public class NotFoundException(string message) : Exception(message);
|
||||||
|
|
||||||
// Domain.Exceptions/UnauthorizedException.cs
|
// Domain.Exceptions/UnauthorizedException.cs
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when authentication fails or is required.
|
/// Exception thrown when authentication fails or is required.
|
||||||
/// Maps to HTTP 401 Unauthorized.
|
/// Maps to HTTP 401 Unauthorized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UnauthorizedException(string message) : Exception(message);
|
public class UnauthorizedException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
||||||
/// Maps to HTTP 403 Forbidden.
|
/// Maps to HTTP 403 Forbidden.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ForbiddenException(string message) : Exception(message);
|
public class ForbiddenException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
||||||
/// Maps to HTTP 400 Bad Request.
|
/// Maps to HTTP 400 Bad Request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ValidationException(string message) : Exception(message);
|
public class ValidationException(string message) : Exception(message);
|
||||||
@@ -2,10 +2,11 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Auth.Commands.ConfirmUser;
|
using Features.Auth.Commands.ConfirmUser;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Commands;
|
namespace Features.Auth.Tests.Commands;
|
||||||
@@ -13,8 +14,8 @@ namespace Features.Auth.Tests.Commands;
|
|||||||
public class ConfirmUserHandlerTests
|
public class ConfirmUserHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
|
||||||
private readonly ConfirmUserHandler _handler;
|
private readonly ConfirmUserHandler _handler;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
|
||||||
public ConfirmUserHandlerTests()
|
public ConfirmUserHandlerTests()
|
||||||
{
|
{
|
||||||
@@ -25,30 +26,30 @@ public class ConfirmUserHandlerTests
|
|||||||
|
|
||||||
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
|
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
|
||||||
return new ValidatedToken(userId, username, principal);
|
return new ValidatedToken(userId, username, principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
|
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string confirmationToken = "valid-confirmation-token";
|
const string confirmationToken = "valid-confirmation-token";
|
||||||
|
|
||||||
var validatedToken = MakeValidatedToken(userId, username);
|
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
||||||
var userAccount = new UserAccount { UserAccountId = userId, Username = username };
|
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
|
||||||
|
|
||||||
_tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken);
|
_tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken);
|
||||||
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
|
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
|
||||||
|
|
||||||
var result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
ConfirmationPayload result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserAccountId.Should().Be(userId);
|
result.UserAccountId.Should().Be(userId);
|
||||||
@@ -63,7 +64,7 @@ public class ConfirmUserHandlerTests
|
|||||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||||
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
|
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
|
||||||
|
|
||||||
var act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,7 @@ public class ConfirmUserHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
|
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
const string username = "nonexistent";
|
const string username = "nonexistent";
|
||||||
const string confirmationToken = "valid-token-for-nonexistent-user";
|
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||||
|
|
||||||
@@ -80,8 +81,8 @@ public class ConfirmUserHandlerTests
|
|||||||
.ReturnsAsync(MakeValidatedToken(userId, username));
|
.ReturnsAsync(MakeValidatedToken(userId, username));
|
||||||
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync((UserAccount?)null);
|
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
var act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*");
|
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Auth.Commands.RefreshToken;
|
using Features.Auth.Commands.RefreshToken;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Commands;
|
namespace Features.Auth.Tests.Commands;
|
||||||
@@ -11,20 +12,20 @@ public class RefreshTokenHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
|
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
|
||||||
{
|
{
|
||||||
var tokenServiceMock = new Mock<ITokenService>();
|
Mock<ITokenService> tokenServiceMock = new();
|
||||||
var handler = new RefreshTokenHandler(tokenServiceMock.Object);
|
RefreshTokenHandler handler = new(tokenServiceMock.Object);
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var user = new UserAccount { UserAccountId = userId, Username = "testuser" };
|
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
|
||||||
|
|
||||||
tokenServiceMock
|
tokenServiceMock
|
||||||
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
||||||
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
|
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
|
||||||
|
|
||||||
var result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None);
|
LoginPayload result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None);
|
||||||
|
|
||||||
result.UserAccountId.Should().Be(userId);
|
result.UserAccountId.Should().Be(userId);
|
||||||
result.Username.Should().Be("testuser");
|
result.Username.Should().Be("testuser");
|
||||||
result.RefreshToken.Should().Be("new-refresh-token");
|
result.RefreshToken.Should().Be("new-refresh-token");
|
||||||
result.AccessToken.Should().Be("new-access-token");
|
result.AccessToken.Should().Be("new-access-token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Auth.Commands.RegisterUser;
|
using Features.Auth.Commands.RegisterUser;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.PasswordHashing;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Moq;
|
using Moq;
|
||||||
@@ -14,10 +15,10 @@ namespace Features.Auth.Tests.Commands;
|
|||||||
public class RegisterUserHandlerTests
|
public class RegisterUserHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||||
|
private readonly RegisterUserHandler _handler;
|
||||||
|
private readonly Mock<IMediator> _mediatorMock;
|
||||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
private readonly Mock<IMediator> _mediatorMock;
|
|
||||||
private readonly RegisterUserHandler _handler;
|
|
||||||
|
|
||||||
public RegisterUserHandlerTests()
|
public RegisterUserHandlerTests()
|
||||||
{
|
{
|
||||||
@@ -34,22 +35,25 @@ public class RegisterUserHandlerTests
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com") =>
|
private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com")
|
||||||
new(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
|
{
|
||||||
|
return new RegisterUserCommand(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
||||||
{
|
{
|
||||||
var command = ValidCommand();
|
RegisterUserCommand command = ValidCommand();
|
||||||
const string hashedPassword = "hashed_password_value";
|
const string hashedPassword = "hashed_password_value";
|
||||||
var expectedUserId = Guid.NewGuid();
|
Guid expectedUserId = Guid.NewGuid();
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
||||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
||||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||||
|
|
||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword))
|
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
|
||||||
|
command.DateOfBirth, hashedPassword))
|
||||||
.ReturnsAsync(new UserAccount
|
.ReturnsAsync(new UserAccount
|
||||||
{
|
{
|
||||||
UserAccountId = expectedUserId,
|
UserAccountId = expectedUserId,
|
||||||
@@ -58,14 +62,15 @@ public class RegisterUserHandlerTests
|
|||||||
LastName = command.LastName,
|
LastName = command.LastName,
|
||||||
Email = command.Email,
|
Email = command.Email,
|
||||||
DateOfBirth = command.DateOfBirth,
|
DateOfBirth = command.DateOfBirth,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||||
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>())).Returns("confirmation-token");
|
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("confirmation-token");
|
||||||
|
|
||||||
var result = await _handler.Handle(command, CancellationToken.None);
|
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserAccountId.Should().Be(expectedUserId);
|
result.UserAccountId.Should().Be(expectedUserId);
|
||||||
@@ -83,18 +88,19 @@ public class RegisterUserHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
||||||
{
|
{
|
||||||
var command = ValidCommand(username: "existinguser");
|
RegisterUserCommand command = ValidCommand("existinguser");
|
||||||
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
|
UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser);
|
||||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
var act = async () => await _handler.Handle(command, CancellationToken.None);
|
Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
||||||
|
|
||||||
_authRepoMock.Verify(
|
_authRepoMock.Verify(
|
||||||
x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||||
Times.Never
|
Times.Never
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,13 +108,13 @@ public class RegisterUserHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
||||||
{
|
{
|
||||||
var command = ValidCommand(email: "existing@example.com");
|
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
||||||
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" };
|
UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" };
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
||||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
|
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
|
||||||
|
|
||||||
var act = async () => await _handler.Handle(command, CancellationToken.None);
|
Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
||||||
}
|
}
|
||||||
@@ -116,7 +122,7 @@ public class RegisterUserHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
||||||
{
|
{
|
||||||
var command = ValidCommand();
|
RegisterUserCommand command = ValidCommand();
|
||||||
const string hashedPassword = "hashed_secure_password";
|
const string hashedPassword = "hashed_secure_password";
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||||
@@ -124,7 +130,8 @@ public class RegisterUserHandlerTests
|
|||||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||||
|
|
||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword))
|
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword))
|
||||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||||
|
|
||||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||||
@@ -134,7 +141,8 @@ public class RegisterUserHandlerTests
|
|||||||
|
|
||||||
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
|
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
|
||||||
_authRepoMock.Verify(
|
_authRepoMock.Verify(
|
||||||
x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword),
|
x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
|
||||||
|
command.DateOfBirth, hashedPassword),
|
||||||
Times.Once
|
Times.Once
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -142,14 +150,16 @@ public class RegisterUserHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
|
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
|
||||||
{
|
{
|
||||||
var command = ValidCommand();
|
RegisterUserCommand command = ValidCommand();
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()))
|
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email });
|
It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(new UserAccount
|
||||||
|
{ UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email });
|
||||||
|
|
||||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||||
@@ -157,9 +167,9 @@ public class RegisterUserHandlerTests
|
|||||||
.Setup(x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()))
|
.Setup(x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new Exception("smtp down"));
|
.ThrowsAsync(new Exception("smtp down"));
|
||||||
|
|
||||||
var result = await _handler.Handle(command, CancellationToken.None);
|
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
result.ConfirmationEmailSent.Should().BeFalse();
|
result.ConfirmationEmailSent.Should().BeFalse();
|
||||||
result.AccessToken.Should().Be("access-token");
|
result.AccessToken.Should().Be("access-token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,20 +11,21 @@ namespace Features.Auth.Tests.Commands;
|
|||||||
public class ResendConfirmationEmailHandlerTests
|
public class ResendConfirmationEmailHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
|
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
|
||||||
private readonly Mock<IMediator> _mediatorMock = new();
|
|
||||||
private readonly ResendConfirmationEmailHandler _handler;
|
private readonly ResendConfirmationEmailHandler _handler;
|
||||||
|
private readonly Mock<IMediator> _mediatorMock = new();
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
||||||
|
|
||||||
public ResendConfirmationEmailHandlerTests()
|
public ResendConfirmationEmailHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _mediatorMock.Object);
|
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object,
|
||||||
|
_mediatorMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var user = new UserAccount { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" };
|
UserAccount user = new() { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" };
|
||||||
|
|
||||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
|
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
|
||||||
@@ -43,7 +44,7 @@ public class ResendConfirmationEmailHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||||
@@ -57,8 +58,8 @@ public class ResendConfirmationEmailHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var user = new UserAccount { UserAccountId = userId };
|
UserAccount user = new() { UserAccountId = userId };
|
||||||
|
|
||||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
|
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
|
||||||
@@ -70,4 +71,4 @@ public class ResendConfirmationEmailHandlerTests
|
|||||||
Times.Never
|
Times.Never
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<RootNamespace>Features.Auth.Tests</RootNamespace>
|
<RootNamespace>Features.Auth.Tests</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="6.9.0"/>
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
<PackageReference Include="DbMocker" Version="1.26.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
|
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentAssertions;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Queries.Login;
|
using Features.Auth.Queries.Login;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.PasswordHashing;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
@@ -12,9 +13,9 @@ namespace Features.Auth.Tests.Queries;
|
|||||||
public class LoginHandlerTests
|
public class LoginHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||||
|
private readonly LoginHandler _handler;
|
||||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
private readonly LoginHandler _handler;
|
|
||||||
|
|
||||||
public LoginHandlerTests()
|
public LoginHandlerTests()
|
||||||
{
|
{
|
||||||
@@ -28,24 +29,24 @@ public class LoginHandlerTests
|
|||||||
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
||||||
{
|
{
|
||||||
const string username = "CogitoErgoSum";
|
const string username = "CogitoErgoSum";
|
||||||
var userAccountId = Guid.NewGuid();
|
Guid userAccountId = Guid.NewGuid();
|
||||||
|
|
||||||
var userAccount = new UserAccount
|
UserAccount userAccount = new()
|
||||||
{
|
{
|
||||||
UserAccountId = userAccountId,
|
UserAccountId = userAccountId,
|
||||||
Username = username,
|
Username = username,
|
||||||
FirstName = "René",
|
FirstName = "René",
|
||||||
LastName = "Descartes",
|
LastName = "Descartes",
|
||||||
Email = "r.descartes@example.com",
|
Email = "r.descartes@example.com",
|
||||||
DateOfBirth = new DateTime(1596, 03, 31),
|
DateOfBirth = new DateTime(1596, 03, 31)
|
||||||
};
|
};
|
||||||
|
|
||||||
var userCredential = new UserCredential
|
UserCredential userCredential = new()
|
||||||
{
|
{
|
||||||
UserCredentialId = Guid.NewGuid(),
|
UserCredentialId = Guid.NewGuid(),
|
||||||
UserAccountId = userAccountId,
|
UserAccountId = userAccountId,
|
||||||
Hash = "some-hash",
|
Hash = "some-hash",
|
||||||
Expiry = DateTime.MaxValue,
|
Expiry = DateTime.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||||
@@ -54,7 +55,7 @@ public class LoginHandlerTests
|
|||||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||||
|
|
||||||
var result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None);
|
LoginPayload result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserAccountId.Should().Be(userAccountId);
|
result.UserAccountId.Should().Be(userAccountId);
|
||||||
@@ -69,7 +70,7 @@ public class LoginHandlerTests
|
|||||||
const string username = "de_beauvoir";
|
const string username = "de_beauvoir";
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||||
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never);
|
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never);
|
||||||
@@ -79,13 +80,14 @@ public class LoginHandlerTests
|
|||||||
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
const string username = "BRussell";
|
const string username = "BRussell";
|
||||||
var userAccountId = Guid.NewGuid();
|
Guid userAccountId = Guid.NewGuid();
|
||||||
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
|
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync((UserCredential?)null);
|
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||||
|
.ReturnsAsync((UserCredential?)null);
|
||||||
|
|
||||||
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
||||||
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||||
@@ -95,16 +97,16 @@ public class LoginHandlerTests
|
|||||||
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
const string username = "RCarnap";
|
const string username = "RCarnap";
|
||||||
var userAccountId = Guid.NewGuid();
|
Guid userAccountId = Guid.NewGuid();
|
||||||
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
|
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||||
var userCredential = new UserCredential { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
|
UserCredential userCredential = new() { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
|
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
|
||||||
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false);
|
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false);
|
||||||
|
|
||||||
var act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
|
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
using Apps72.Dev.Data.DbMocker;
|
||||||
using FluentAssertions;
|
using Domain.Entities;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Repository;
|
namespace Features.Auth.Tests.Repository;
|
||||||
|
|
||||||
public class AuthRepositoryTests
|
public class AuthRepositoryTests
|
||||||
{
|
{
|
||||||
private static AuthRepository CreateRepo(MockDbConnection conn) =>
|
private static AuthRepository CreateRepo(MockDbConnection conn)
|
||||||
new(new TestConnectionFactory(conn));
|
{
|
||||||
|
return new AuthRepository(new TestConnectionFactory(conn));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
|
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
|
||||||
{
|
{
|
||||||
var expectedUserId = Guid.NewGuid();
|
Guid expectedUserId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
||||||
.ReturnsScalar(expectedUserId);
|
.ReturnsScalar(expectedUserId);
|
||||||
@@ -46,14 +49,14 @@ public class AuthRepositoryTests
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.RegisterUserAsync(
|
UserAccount result = await repo.RegisterUserAsync(
|
||||||
username: "testuser",
|
"testuser",
|
||||||
firstName: "Test",
|
"Test",
|
||||||
lastName: "User",
|
"User",
|
||||||
email: "test@example.com",
|
"test@example.com",
|
||||||
dateOfBirth: new DateTime(1990, 1, 1),
|
new DateTime(1990, 1, 1),
|
||||||
passwordHash: "hashedpassword123"
|
"hashedpassword123"
|
||||||
);
|
);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
@@ -68,8 +71,8 @@ public class AuthRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
|
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||||
.ReturnsTable(
|
.ReturnsTable(
|
||||||
@@ -98,8 +101,8 @@ public class AuthRepositoryTests
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.UserAccountId.Should().Be(userId);
|
result!.UserAccountId.Should().Be(userId);
|
||||||
@@ -112,13 +115,13 @@ public class AuthRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
|
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
|
||||||
{
|
{
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||||
.ReturnsTable(MockTable.Empty());
|
.ReturnsTable(MockTable.Empty());
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
@@ -126,8 +129,8 @@ public class AuthRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
|
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd =>
|
||||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||||
@@ -158,8 +161,8 @@ public class AuthRepositoryTests
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByUsernameAsync("usernameuser");
|
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.UserAccountId.Should().Be(userId);
|
result!.UserAccountId.Should().Be(userId);
|
||||||
@@ -170,15 +173,15 @@ public class AuthRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
|
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
|
||||||
{
|
{
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd =>
|
||||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||||
)
|
)
|
||||||
.ReturnsTable(MockTable.Empty());
|
.ReturnsTable(MockTable.Empty());
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByUsernameAsync("nonexistent");
|
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
@@ -186,9 +189,9 @@ public class AuthRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
|
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var credentialId = Guid.NewGuid();
|
Guid credentialId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd =>
|
||||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||||
@@ -211,8 +214,8 @@ public class AuthRepositoryTests
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.UserCredentialId.Should().Be(credentialId);
|
result!.UserCredentialId.Should().Be(credentialId);
|
||||||
@@ -223,16 +226,16 @@ public class AuthRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
|
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd =>
|
||||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||||
)
|
)
|
||||||
.ReturnsTable(MockTable.Empty());
|
.ReturnsTable(MockTable.Empty());
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
@@ -240,18 +243,18 @@ public class AuthRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RotateCredentialAsync_ExecutesSuccessfully()
|
public async Task RotateCredentialAsync_ExecutesSuccessfully()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var newPasswordHash = "new_hashed_password";
|
string newPasswordHash = "new_hashed_password";
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
|
||||||
.ReturnsScalar(1);
|
.ReturnsScalar(1);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
|
|
||||||
// Should not throw
|
// Should not throw
|
||||||
var act = async () =>
|
Func<Task> act = async () =>
|
||||||
await repo.RotateCredentialAsync(userId, newPasswordHash);
|
await repo.RotateCredentialAsync(userId, newPasswordHash);
|
||||||
await act.Should().NotThrowAsync();
|
await act.Should().NotThrowAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
|||||||
{
|
{
|
||||||
private readonly DbConnection _conn = conn;
|
private readonly DbConnection _conn = conn;
|
||||||
|
|
||||||
public DbConnection CreateConnection() => _conn;
|
public DbConnection CreateConnection()
|
||||||
}
|
{
|
||||||
|
return _conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
@@ -12,152 +12,153 @@ namespace Features.Auth.Tests.Services;
|
|||||||
|
|
||||||
public class TokenServiceRefreshTests
|
public class TokenServiceRefreshTests
|
||||||
{
|
{
|
||||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
private readonly TokenService _tokenService;
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
public TokenServiceRefreshTests()
|
public TokenServiceRefreshTests()
|
||||||
{
|
{
|
||||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
// Set environment variables for tokens
|
// Set environment variables for tokens
|
||||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
|
||||||
|
"test-confirmation-secret-that-is-very-long-1234567890");
|
||||||
|
|
||||||
_tokenService = new TokenService(
|
_tokenService = new TokenService(
|
||||||
_tokenInfraMock.Object,
|
_tokenInfraMock.Object,
|
||||||
_authRepositoryMock.Object
|
_authRepositoryMock.Object
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string refreshToken = "valid-refresh-token";
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
var userAccount = new UserAccount
|
UserAccount userAccount = new()
|
||||||
{
|
|
||||||
UserAccountId = userId,
|
|
||||||
Username = username,
|
|
||||||
FirstName = "Test",
|
|
||||||
LastName = "User",
|
|
||||||
Email = "test@example.com",
|
|
||||||
DateOfBirth = new DateTime(1990, 1, 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the validation of refresh token
|
|
||||||
_tokenInfraMock
|
|
||||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
|
||||||
.ReturnsAsync(principal);
|
|
||||||
|
|
||||||
// Mock the generation of new tokens
|
|
||||||
_tokenInfraMock
|
|
||||||
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
|
||||||
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
|
|
||||||
|
|
||||||
_authRepositoryMock
|
|
||||||
.Setup(x => x.GetUserByIdAsync(userId))
|
|
||||||
.ReturnsAsync(userAccount);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _tokenService.RefreshTokenAsync(refreshToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result.UserAccount.UserAccountId.Should().Be(userId);
|
|
||||||
result.UserAccount.Username.Should().Be(username);
|
|
||||||
result.AccessToken.Should().NotBeEmpty();
|
|
||||||
result.RefreshToken.Should().NotBeEmpty();
|
|
||||||
|
|
||||||
_authRepositoryMock.Verify(
|
|
||||||
x => x.GetUserByIdAsync(userId),
|
|
||||||
Times.Once
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify tokens were generated (called twice - once for access, once for refresh)
|
|
||||||
_tokenInfraMock.Verify(
|
|
||||||
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
|
||||||
Times.Exactly(2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
const string invalidToken = "invalid-refresh-token";
|
|
||||||
|
|
||||||
_tokenInfraMock
|
|
||||||
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
|
||||||
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await FluentActions.Invoking(async () =>
|
|
||||||
await _tokenService.RefreshTokenAsync(invalidToken)
|
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
const string expiredToken = "expired-refresh-token";
|
|
||||||
|
|
||||||
_tokenInfraMock
|
|
||||||
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
|
||||||
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await FluentActions.Invoking(async () =>
|
|
||||||
await _tokenService.RefreshTokenAsync(expiredToken)
|
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userId = Guid.NewGuid();
|
|
||||||
const string username = "testuser";
|
|
||||||
const string refreshToken = "valid-refresh-token";
|
|
||||||
|
|
||||||
var claims = new List<Claim>
|
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
UserAccountId = userId,
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
Username = username,
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
FirstName = "Test",
|
||||||
|
LastName = "User",
|
||||||
|
Email = "test@example.com",
|
||||||
|
DateOfBirth = new DateTime(1990, 1, 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
// Mock the validation of refresh token
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
_tokenInfraMock
|
// Mock the generation of new tokens
|
||||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
_tokenInfraMock
|
||||||
.ReturnsAsync(principal);
|
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||||
|
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
|
||||||
|
|
||||||
_authRepositoryMock
|
_authRepositoryMock
|
||||||
.Setup(x => x.GetUserByIdAsync(userId))
|
.Setup(x => x.GetUserByIdAsync(userId))
|
||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync(userAccount);
|
||||||
|
|
||||||
// Act & Assert
|
// Act
|
||||||
await FluentActions.Invoking(async () =>
|
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||||
await _tokenService.RefreshTokenAsync(refreshToken)
|
|
||||||
).Should().ThrowAsync<UnauthorizedException>()
|
// Assert
|
||||||
.WithMessage("*User account not found*");
|
result.Should().NotBeNull();
|
||||||
}
|
result.UserAccount.UserAccountId.Should().Be(userId);
|
||||||
}
|
result.UserAccount.Username.Should().Be(username);
|
||||||
|
result.AccessToken.Should().NotBeEmpty();
|
||||||
|
result.RefreshToken.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
_authRepositoryMock.Verify(
|
||||||
|
x => x.GetUserByIdAsync(userId),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||||
|
_tokenInfraMock.Verify(
|
||||||
|
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||||
|
Times.Exactly(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string invalidToken = "invalid-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string expiredToken = "expired-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.GetUserByIdAsync(userId))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Domain.Entities;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
@@ -12,272 +11,273 @@ namespace Features.Auth.Tests.Services;
|
|||||||
|
|
||||||
public class TokenServiceValidationTests
|
public class TokenServiceValidationTests
|
||||||
{
|
{
|
||||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
private readonly TokenService _tokenService;
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
public TokenServiceValidationTests()
|
public TokenServiceValidationTests()
|
||||||
{
|
{
|
||||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
// Set environment variables for tokens
|
// Set environment variables for tokens
|
||||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
|
||||||
|
"test-confirmation-secret-that-is-very-long-1234567890");
|
||||||
|
|
||||||
_tokenService = new TokenService(
|
_tokenService = new TokenService(
|
||||||
_tokenInfraMock.Object,
|
_tokenInfraMock.Object,
|
||||||
_authRepositoryMock.Object
|
_authRepositoryMock.Object
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string token = "valid-access-token";
|
const string token = "valid-access-token";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result =
|
ValidatedToken result =
|
||||||
await _tokenService.ValidateAccessTokenAsync(token);
|
await _tokenService.ValidateAccessTokenAsync(token);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserId.Should().Be(userId);
|
result.UserId.Should().Be(userId);
|
||||||
result.Username.Should().Be(username);
|
result.Username.Should().Be(username);
|
||||||
result.Principal.Should().NotBeNull();
|
result.Principal.Should().NotBeNull();
|
||||||
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
|
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string token = "valid-refresh-token";
|
const string token = "valid-refresh-token";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result =
|
ValidatedToken result =
|
||||||
await _tokenService.ValidateRefreshTokenAsync(token);
|
await _tokenService.ValidateRefreshTokenAsync(token);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserId.Should().Be(userId);
|
result.UserId.Should().Be(userId);
|
||||||
result.Username.Should().Be(username);
|
result.Username.Should().Be(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string token = "valid-confirmation-token";
|
const string token = "valid-confirmation-token";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result =
|
ValidatedToken result =
|
||||||
await _tokenService.ValidateConfirmationTokenAsync(token);
|
await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserId.Should().Be(userId);
|
result.UserId.Should().Be(userId);
|
||||||
result.Username.Should().Be(username);
|
result.Username.Should().Be(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string token = "invalid-token";
|
const string token = "invalid-token";
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _tokenService.ValidateAccessTokenAsync(token)
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string token = "expired-token";
|
const string token = "expired-token";
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ThrowsAsync(new UnauthorizedException(
|
.ThrowsAsync(new UnauthorizedException(
|
||||||
"Token has expired"
|
"Token has expired"
|
||||||
));
|
));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _tokenService.ValidateAccessTokenAsync(token)
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string token = "token-without-user-id";
|
const string token = "token-without-user-id";
|
||||||
|
|
||||||
// Claims without Sub (user ID)
|
// Claims without Sub (user ID)
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _tokenService.ValidateAccessTokenAsync(token)
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
).Should().ThrowAsync<UnauthorizedException>()
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
.WithMessage("*missing required claims*");
|
.WithMessage("*missing required claims*");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
const string token = "token-without-username";
|
const string token = "token-without-username";
|
||||||
|
|
||||||
// Claims without UniqueName (username)
|
// Claims without UniqueName (username)
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _tokenService.ValidateAccessTokenAsync(token)
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
).Should().ThrowAsync<UnauthorizedException>()
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
.WithMessage("*missing required claims*");
|
.WithMessage("*missing required claims*");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string username = "testuser";
|
const string username = "testuser";
|
||||||
const string token = "token-with-malformed-user-id";
|
const string token = "token-with-malformed-user-id";
|
||||||
|
|
||||||
// Claims with invalid GUID format
|
// Claims with invalid GUID format
|
||||||
var claims = new List<Claim>
|
List<Claim> claims = new()
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _tokenService.ValidateAccessTokenAsync(token)
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
).Should().ThrowAsync<UnauthorizedException>()
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
.WithMessage("*malformed user ID*");
|
.WithMessage("*malformed user ID*");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string token = "invalid-refresh-token";
|
const string token = "invalid-refresh-token";
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _tokenService.ValidateRefreshTokenAsync(token)
|
await _tokenService.ValidateRefreshTokenAsync(token)
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
const string token = "invalid-confirmation-token";
|
const string token = "invalid-confirmation-token";
|
||||||
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions.Invoking(async () =>
|
await FluentActions.Invoking(async () =>
|
||||||
await _tokenService.ValidateConfirmationTokenAsync(token)
|
await _tokenService.ValidateConfirmationTokenAsync(token)
|
||||||
).Should().ThrowAsync<UnauthorizedException>();
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ using MediatR;
|
|||||||
namespace Features.Auth.Commands.ConfirmUser;
|
namespace Features.Auth.Commands.ConfirmUser;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a confirmation token and confirms the corresponding user account.
|
/// Validates a confirmation token and confirms the corresponding user account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
|
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
|
||||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
@@ -7,8 +8,8 @@ using MediatR;
|
|||||||
namespace Features.Auth.Commands.ConfirmUser;
|
namespace Features.Auth.Commands.ConfirmUser;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="ConfirmUserCommand"/> by validating the confirmation token and marking the
|
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||||
/// corresponding user account as confirmed.
|
/// corresponding user account as confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
||||||
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
||||||
@@ -18,19 +19,16 @@ public class ConfirmUserHandler(
|
|||||||
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||||
{
|
{
|
||||||
/// <exception cref="UnauthorizedException">
|
/// <exception cref="UnauthorizedException">
|
||||||
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken)
|
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
|
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
|
||||||
|
|
||||||
var user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null) throw new UnauthorizedException("User account not found");
|
||||||
{
|
|
||||||
throw new UnauthorizedException("User account not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ using MediatR;
|
|||||||
namespace Features.Auth.Commands.RefreshToken;
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||||
/// request body of <c>POST /api/auth/refresh</c>.
|
/// request body of <c>POST /api/auth/refresh</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||||
@@ -5,8 +5,8 @@ using MediatR;
|
|||||||
namespace Features.Auth.Commands.RefreshToken;
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="RefreshTokenCommand"/> by validating the refresh token and issuing a new
|
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
|
||||||
/// access/refresh token pair.
|
/// access/refresh token pair.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
|
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
|
||||||
public class RefreshTokenHandler(ITokenService tokenService)
|
public class RefreshTokenHandler(ITokenService tokenService)
|
||||||
@@ -14,7 +14,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
|
|||||||
{
|
{
|
||||||
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||||
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken);
|
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken,
|
||||||
|
result.AccessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,12 @@ using FluentValidation;
|
|||||||
namespace Features.Auth.Commands.RefreshToken;
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates <see cref="RefreshTokenCommand"/> instances before they are processed.
|
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken"/> to be non-empty.
|
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RefreshTokenValidator()
|
public RefreshTokenValidator()
|
||||||
{
|
{
|
||||||
@@ -16,4 +16,4 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
|||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.WithMessage("Refresh token is required");
|
.WithMessage("Refresh token is required");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,14 +4,20 @@ using MediatR;
|
|||||||
namespace Features.Auth.Commands.RegisterUser;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
|
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Username">The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens.</param>
|
/// <param name="Username">
|
||||||
|
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
|
||||||
|
/// underscores, and hyphens.
|
||||||
|
/// </param>
|
||||||
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
|
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
|
||||||
/// <param name="LastName">The user's last name; up to 128 characters.</param>
|
/// <param name="LastName">The user's last name; up to 128 characters.</param>
|
||||||
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
|
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
|
||||||
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
||||||
/// <param name="Password">The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character.</param>
|
/// <param name="Password">
|
||||||
|
/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a
|
||||||
|
/// lowercase letter, a number, and a special character.
|
||||||
|
/// </param>
|
||||||
public record RegisterUserCommand(
|
public record RegisterUserCommand(
|
||||||
string Username,
|
string Username,
|
||||||
string FirstName,
|
string FirstName,
|
||||||
@@ -19,4 +25,4 @@ public record RegisterUserCommand(
|
|||||||
string Email,
|
string Email,
|
||||||
DateTime DateOfBirth,
|
DateTime DateOfBirth,
|
||||||
string Password
|
string Password
|
||||||
) : IRequest<RegistrationPayload>;
|
) : IRequest<RegistrationPayload>;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
@@ -9,9 +10,9 @@ using Shared.Application.Emails;
|
|||||||
namespace Features.Auth.Commands.RegisterUser;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="RegisterUserCommand"/>: validates uniqueness, hashes the password, persists the
|
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
|
||||||
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
||||||
/// confirmation email via Features.Emails.
|
/// confirmation email via Features.Emails.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
|
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
|
||||||
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
|
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
|
||||||
@@ -25,15 +26,15 @@ public class RegisterUserHandler(
|
|||||||
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
||||||
{
|
{
|
||||||
/// <exception cref="ConflictException">
|
/// <exception cref="ConflictException">
|
||||||
/// Thrown when an existing account already has the same username or email address.
|
/// Thrown when an existing account already has the same username or email address.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await ValidateUserDoesNotExist(request.Username, request.Email);
|
await ValidateUserDoesNotExist(request.Username, request.Email);
|
||||||
|
|
||||||
var hashed = passwordInfrastructure.Hash(request.Password);
|
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||||
|
|
||||||
var createdUser = await authRepo.RegisterUserAsync(
|
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||||
request.Username,
|
request.Username,
|
||||||
request.FirstName,
|
request.FirstName,
|
||||||
request.LastName,
|
request.LastName,
|
||||||
@@ -42,16 +43,15 @@ public class RegisterUserHandler(
|
|||||||
hashed
|
hashed
|
||||||
);
|
);
|
||||||
|
|
||||||
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||||
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||||
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||||
{
|
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty,
|
||||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false);
|
false);
|
||||||
}
|
|
||||||
|
|
||||||
var emailSent = false;
|
bool emailSent = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await mediator.Send(
|
await mediator.Send(
|
||||||
@@ -67,20 +67,19 @@ public class RegisterUserHandler(
|
|||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent);
|
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken,
|
||||||
|
emailSent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="ConflictException">
|
/// <exception cref="ConflictException">
|
||||||
/// Thrown when an existing account already has the same username or email address.
|
/// Thrown when an existing account already has the same username or email address.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
private async Task ValidateUserDoesNotExist(string username, string email)
|
private async Task ValidateUserDoesNotExist(string username, string email)
|
||||||
{
|
{
|
||||||
var existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||||
var existingEmail = await authRepo.GetUserByEmailAsync(email);
|
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||||
|
|
||||||
if (existingUsername != null || existingEmail != null)
|
if (existingUsername != null || existingEmail != null)
|
||||||
{
|
|
||||||
throw new ConflictException("Username or email already exists");
|
throw new ConflictException("Username or email already exists");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,13 @@ using FluentValidation;
|
|||||||
namespace Features.Auth.Commands.RegisterUser;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates <see cref="RegisterUserCommand"/> instances before they are processed.
|
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures validation rules for username format and length, first/last name length, email format and
|
/// Configures validation rules for username format and length, first/last name length, email format and
|
||||||
/// length, minimum age based on date of birth, and password strength requirements.
|
/// length, minimum age based on date of birth, and password strength requirements.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RegisterUserValidator()
|
public RegisterUserValidator()
|
||||||
{
|
{
|
||||||
@@ -65,4 +65,4 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
|||||||
"Password must contain at least one special character"
|
"Password must contain at least one special character"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ using MediatR;
|
|||||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
|
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
|
||||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Domain.Entities;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -6,15 +7,15 @@ using Shared.Application.Emails;
|
|||||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="ResendConfirmationEmailCommand"/> by generating a fresh confirmation token and
|
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
|
||||||
/// sending it via Features.Emails.
|
/// sending it via Features.Emails.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
|
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
|
||||||
/// <param name="tokenService">Service used to generate the confirmation token.</param>
|
/// <param name="tokenService">Service used to generate the confirmation token.</param>
|
||||||
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
|
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
||||||
/// or if the user's account is already verified.
|
/// or if the user's account is already verified.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class ResendConfirmationEmailHandler(
|
public class ResendConfirmationEmailHandler(
|
||||||
IAuthRepository authRepository,
|
IAuthRepository authRepository,
|
||||||
@@ -24,21 +25,15 @@ public class ResendConfirmationEmailHandler(
|
|||||||
{
|
{
|
||||||
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
|
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await authRepository.GetUserByIdAsync(request.UserId);
|
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
|
||||||
if (user == null)
|
if (user == null) return; // Silent return to prevent user enumeration
|
||||||
{
|
|
||||||
return; // Silent return to prevent user enumeration
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await authRepository.IsUserVerifiedAsync(request.UserId))
|
if (await authRepository.IsUserVerifiedAsync(request.UserId)) return; // Already confirmed, no-op
|
||||||
{
|
|
||||||
return; // Already confirmed, no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
string confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||||
await mediator.Send(
|
await mediator.Send(
|
||||||
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,121 +9,120 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Shared.Contracts;
|
using Shared.Contracts;
|
||||||
|
|
||||||
namespace Features.Auth.Controllers
|
namespace Features.Auth.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
||||||
|
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
|
public class AuthController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
|
/// Registers a new user account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
|
||||||
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
|
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
|
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
|
||||||
[ApiController]
|
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
|
||||||
[Route("api/[controller]")]
|
[AllowAnonymous]
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[HttpPost("register")]
|
||||||
public class AuthController(IMediator mediator) : ControllerBase
|
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
||||||
|
[FromBody] RegisterUserCommand command
|
||||||
|
)
|
||||||
{
|
{
|
||||||
/// <summary>
|
RegistrationPayload payload = await mediator.Send(command);
|
||||||
/// Registers a new user account.
|
return Created("/", new ResponseBody<RegistrationPayload>
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
|
|
||||||
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
|
|
||||||
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="RegistrationPayload"/>.</returns>
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpPost("register")]
|
|
||||||
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
|
||||||
[FromBody] RegisterUserCommand command
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var payload = await mediator.Send(command);
|
Message = "User registered successfully.",
|
||||||
return Created("/", new ResponseBody<RegistrationPayload>
|
Payload = payload
|
||||||
{
|
});
|
||||||
Message = "User registered successfully.",
|
|
||||||
Payload = payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Authenticates a user with a username and password.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
|
||||||
/// username, and a newly issued refresh/access token pair.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="query">The login credentials (username and password).</param>
|
|
||||||
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns>
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpPost("login")]
|
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
|
||||||
{
|
|
||||||
var payload = await mediator.Send(query);
|
|
||||||
return Ok(new ResponseBody<LoginPayload>
|
|
||||||
{
|
|
||||||
Message = "Logged in successfully.",
|
|
||||||
Payload = payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Confirms a user account using a confirmation token.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
|
|
||||||
/// user's ID and the confirmation timestamp.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="ConfirmationPayload"/>.</returns>
|
|
||||||
[HttpPost("confirm")]
|
|
||||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
|
|
||||||
{
|
|
||||||
var payload = await mediator.Send(new ConfirmUserCommand(token));
|
|
||||||
return Ok(new ResponseBody<ConfirmationPayload>
|
|
||||||
{
|
|
||||||
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
|
|
||||||
Payload = payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resends the account confirmation email for the specified user.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the email was resent.</returns>
|
|
||||||
[HttpPost("confirm/resend")]
|
|
||||||
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
|
||||||
{
|
|
||||||
await mediator.Send(new ResendConfirmationEmailCommand(userId));
|
|
||||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exchanges a valid refresh token for a new access/refresh token pair.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
|
||||||
/// username, and the newly issued refresh/access token pair.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="command">The request containing the refresh token to exchange.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns>
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpPost("refresh")]
|
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
|
||||||
[FromBody] RefreshTokenCommand command
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var payload = await mediator.Send(command);
|
|
||||||
return Ok(new ResponseBody<LoginPayload>
|
|
||||||
{
|
|
||||||
Message = "Token refreshed successfully.",
|
|
||||||
Payload = payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a user with a username and password.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||||
|
/// username, and a newly issued refresh/access token pair.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="query">The login credentials (username and password).</param>
|
||||||
|
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
||||||
|
{
|
||||||
|
LoginPayload payload = await mediator.Send(query);
|
||||||
|
return Ok(new ResponseBody<LoginPayload>
|
||||||
|
{
|
||||||
|
Message = "Logged in successfully.",
|
||||||
|
Payload = payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms a user account using a confirmation token.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
|
||||||
|
/// user's ID and the confirmation timestamp.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
|
||||||
|
[HttpPost("confirm")]
|
||||||
|
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
|
||||||
|
{
|
||||||
|
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
|
||||||
|
return Ok(new ResponseBody<ConfirmationPayload>
|
||||||
|
{
|
||||||
|
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
|
||||||
|
Payload = payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resends the account confirmation email for the specified user.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
|
||||||
|
[HttpPost("confirm/resend")]
|
||||||
|
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ResendConfirmationEmailCommand(userId));
|
||||||
|
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges a valid refresh token for a new access/refresh token pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
||||||
|
/// username, and the newly issued refresh/access token pair.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="command">The request containing the refresh token to exchange.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||||
|
[FromBody] RefreshTokenCommand command
|
||||||
|
)
|
||||||
|
{
|
||||||
|
LoginPayload payload = await mediator.Send(command);
|
||||||
|
return Ok(new ResponseBody<LoginPayload>
|
||||||
|
{
|
||||||
|
Message = "Token refreshed successfully.",
|
||||||
|
Payload = payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
namespace Features.Auth.DependencyInjection;
|
namespace Features.Auth.DependencyInjection;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers the services owned by the Auth feature slice.
|
/// Registers the services owned by the Auth feature slice.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FeaturesAuthServiceCollectionExtensions
|
public static class FeaturesAuthServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
@@ -17,4 +17,4 @@ public static class FeaturesAuthServiceCollectionExtensions
|
|||||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace Features.Auth.Dtos;
|
namespace Features.Auth.Dtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Payload returned to the client after a successful login or token refresh.
|
/// Payload returned to the client after a successful login or token refresh.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
|
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
|
||||||
/// <param name="Username">The username of the authenticated user account.</param>
|
/// <param name="Username">The username of the authenticated user account.</param>
|
||||||
@@ -15,7 +15,7 @@ public record LoginPayload(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Payload returned to the client after a successful registration.
|
/// Payload returned to the client after a successful registration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
|
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
|
||||||
/// <param name="Username">The username of the newly created user account.</param>
|
/// <param name="Username">The username of the newly created user account.</param>
|
||||||
@@ -31,8 +31,8 @@ public record RegistrationPayload(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Payload returned to the client after a user account's email has been confirmed.
|
/// Payload returned to the client after a user account's email has been confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
|
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
|
||||||
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
|
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
|
||||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Features.Auth</RootNamespace>
|
<RootNamespace>Features.Auth</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj"/>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
@@ -8,10 +9,13 @@ using MediatR;
|
|||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Queries.Login;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="LoginQuery"/> by verifying credentials and issuing access/refresh tokens.
|
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
|
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
|
||||||
/// <param name="passwordInfrastructure">Infrastructure component used to verify a plain-text password against a stored hash.</param>
|
/// <param name="passwordInfrastructure">
|
||||||
|
/// Infrastructure component used to verify a plain-text password against a stored
|
||||||
|
/// hash.
|
||||||
|
/// </param>
|
||||||
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
|
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
|
||||||
public class LoginHandler(
|
public class LoginHandler(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
@@ -20,26 +24,26 @@ public class LoginHandler(
|
|||||||
) : IRequestHandler<LoginQuery, LoginPayload>
|
) : IRequestHandler<LoginQuery, LoginPayload>
|
||||||
{
|
{
|
||||||
/// <exception cref="UnauthorizedException">
|
/// <exception cref="UnauthorizedException">
|
||||||
/// Thrown when the username does not match any account, the account has no active credential,
|
/// Thrown when the username does not match any account, the account has no active credential,
|
||||||
/// or the supplied password does not match the stored hash.
|
/// or the supplied password does not match the stored hash.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
|
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user =
|
UserAccount user =
|
||||||
await authRepo.GetUserByUsernameAsync(request.Username)
|
await authRepo.GetUserByUsernameAsync(request.Username)
|
||||||
?? throw new UnauthorizedException("Invalid username or password.");
|
?? throw new UnauthorizedException("Invalid username or password.");
|
||||||
|
|
||||||
// @todo handle expired passwords
|
// @todo handle expired passwords
|
||||||
var activeCred =
|
UserCredential activeCred =
|
||||||
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
|
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
|
||||||
?? throw new UnauthorizedException("Invalid username or password.");
|
?? throw new UnauthorizedException("Invalid username or password.");
|
||||||
|
|
||||||
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
|
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
|
||||||
throw new UnauthorizedException("Invalid username or password.");
|
throw new UnauthorizedException("Invalid username or password.");
|
||||||
|
|
||||||
var accessToken = tokenService.GenerateAccessToken(user);
|
string accessToken = tokenService.GenerateAccessToken(user);
|
||||||
var refreshToken = tokenService.GenerateRefreshToken(user);
|
string refreshToken = tokenService.GenerateRefreshToken(user);
|
||||||
|
|
||||||
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
|
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ using MediatR;
|
|||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Queries.Login;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authenticates a user using their username and password and issues new tokens. Bound directly
|
/// Authenticates a user using their username and password and issues new tokens. Bound directly
|
||||||
/// from the request body of <c>POST /api/auth/login</c>.
|
/// from the request body of <c>POST /api/auth/login</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
||||||
@@ -3,13 +3,13 @@ using FluentValidation;
|
|||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Queries.Login;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates <see cref="LoginQuery"/> instances before they are processed.
|
/// Validates <see cref="LoginQuery" /> instances before they are processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LoginValidator : AbstractValidator<LoginQuery>
|
public class LoginValidator : AbstractValidator<LoginQuery>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures validation rules requiring both <see cref="LoginQuery.Username"/> and
|
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
|
||||||
/// <see cref="LoginQuery.Password"/> to be non-empty.
|
/// <see cref="LoginQuery.Password" /> to be non-empty.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LoginValidator()
|
public LoginValidator()
|
||||||
{
|
{
|
||||||
@@ -17,4 +17,4 @@ public class LoginValidator : AbstractValidator<LoginQuery>
|
|||||||
|
|
||||||
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,23 +7,23 @@ using Microsoft.Data.SqlClient;
|
|||||||
namespace Features.Auth.Repository;
|
namespace Features.Auth.Repository;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ADO.NET-based implementation of <see cref="IAuthRepository"/> backed by SQL Server stored procedures,
|
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
|
||||||
/// handling user registration, credential lookup/rotation, and account verification.
|
/// handling user registration, credential lookup/rotation, and account verification.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory),
|
: Repository<UserAccount>(connectionFactory),
|
||||||
IAuthRepository
|
IAuthRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
|
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
|
||||||
/// procedure, then fetches and returns the newly created user.
|
/// procedure, then fetches and returns the newly created user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
|
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
|
||||||
/// it may be returned as a <see cref="Guid"/>, a parseable <see cref="string"/>, or a 16-byte array.
|
/// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
|
||||||
/// If the result cannot be interpreted, <see cref="Guid.Empty"/> is used, which will cause the
|
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
|
||||||
/// subsequent lookup to fail.
|
/// subsequent lookup to fail.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="username">Unique username for the user</param>
|
/// <param name="username">Unique username for the user</param>
|
||||||
/// <param name="firstName">User's first name</param>
|
/// <param name="firstName">User's first name</param>
|
||||||
@@ -34,7 +34,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
public async Task<UserAccount> RegisterUserAsync(
|
||||||
string username,
|
string username,
|
||||||
string firstName,
|
string firstName,
|
||||||
string lastName,
|
string lastName,
|
||||||
@@ -43,8 +43,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
string passwordHash
|
string passwordHash
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
|
|
||||||
command.CommandText = "USP_RegisterUser";
|
command.CommandText = "USP_RegisterUser";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
@@ -56,89 +56,82 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
AddParameter(command, "@DateOfBirth", dateOfBirth);
|
AddParameter(command, "@DateOfBirth", dateOfBirth);
|
||||||
AddParameter(command, "@Hash", passwordHash);
|
AddParameter(command, "@Hash", passwordHash);
|
||||||
|
|
||||||
var result = await command.ExecuteScalarAsync();
|
object? result = await command.ExecuteScalarAsync();
|
||||||
|
|
||||||
Guid userAccountId = Guid.Empty;
|
Guid userAccountId = Guid.Empty;
|
||||||
if (result != null && result != DBNull.Value)
|
if (result != null && result != DBNull.Value)
|
||||||
{
|
{
|
||||||
if (result is Guid g)
|
if (result is Guid g)
|
||||||
{
|
|
||||||
userAccountId = g;
|
userAccountId = g;
|
||||||
}
|
else if (result is string s && Guid.TryParse(s, out Guid parsed))
|
||||||
else if (result is string s && Guid.TryParse(s, out var parsed))
|
|
||||||
{
|
|
||||||
userAccountId = parsed;
|
userAccountId = parsed;
|
||||||
}
|
|
||||||
else if (result is byte[] bytes && bytes.Length == 16)
|
else if (result is byte[] bytes && bytes.Length == 16)
|
||||||
{
|
|
||||||
userAccountId = new Guid(bytes);
|
userAccountId = new Guid(bytes);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
// Fallback: try to convert and parse string representation
|
// Fallback: try to convert and parse string representation
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var str = result.ToString();
|
string? str = result.ToString();
|
||||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p))
|
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
|
||||||
userAccountId = p;
|
userAccountId = p;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
userAccountId = Guid.Empty;
|
userAccountId = Guid.Empty;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
return await GetUserByIdAsync(userAccountId) ??
|
||||||
|
throw new Exception("Failed to retrieve newly registered user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a user account by email address (typically used for login) using the
|
/// Retrieves a user account by email address (typically used for login) using the
|
||||||
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
|
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="email">Email address to search for</param>
|
/// <param name="email">Email address to search for</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
public async Task<UserAccount?> GetUserByEmailAsync(
|
||||||
string email
|
string email
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
command.CommandText = "usp_GetUserAccountByEmail";
|
command.CommandText = "usp_GetUserAccountByEmail";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
AddParameter(command, "@Email", email);
|
AddParameter(command, "@Email", email);
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync();
|
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a user account by username (typically used for login) using the
|
/// Retrieves a user account by username (typically used for login) using the
|
||||||
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
|
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="username">Username to search for</param>
|
/// <param name="username">Username to search for</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
public async Task<UserAccount?> GetUserByUsernameAsync(
|
||||||
string username
|
string username
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
command.CommandText = "usp_GetUserAccountByUsername";
|
command.CommandText = "usp_GetUserAccountByUsername";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
AddParameter(command, "@Username", username);
|
AddParameter(command, "@Username", username);
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync();
|
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the active (non-revoked) credential for a user account using the
|
/// Retrieves the active (non-revoked) credential for a user account using the
|
||||||
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||||
@@ -147,20 +140,20 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
AddParameter(command, "@UserAccountId", userAccountId);
|
AddParameter(command, "@UserAccountId", userAccountId);
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync();
|
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||||
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
||||||
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <param name="newPasswordHash">New hashed password</param>
|
/// <param name="newPasswordHash">New hashed password</param>
|
||||||
@@ -170,8 +163,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
string newPasswordHash
|
string newPasswordHash
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
command.CommandText = "USP_RotateUserCredential";
|
command.CommandText = "USP_RotateUserCredential";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
@@ -182,53 +175,50 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
public async Task<UserAccount?> GetUserByIdAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
command.CommandText = "usp_GetUserAccountById";
|
command.CommandText = "usp_GetUserAccountById";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
AddParameter(command, "@UserAccountId", userAccountId);
|
AddParameter(command, "@UserAccountId", userAccountId);
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync();
|
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks a user account as confirmed by creating a verification record via the
|
/// Marks a user account as confirmed by creating a verification record via the
|
||||||
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
|
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
|
||||||
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
||||||
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||||
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount"/>, or <c>null</c> if the user account does not exist.</returns>
|
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails for a reason other than a duplicate verification record.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||||
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
/// Thrown when the database command fails for a reason other than
|
||||||
|
/// a duplicate verification record.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<UserAccount?> ConfirmUserAccountAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var user = await GetUserByIdAsync(userAccountId);
|
UserAccount? user = await GetUserByIdAsync(userAccountId);
|
||||||
if (user == null)
|
if (user == null) return null;
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotency: if already verified, treat as successful confirmation.
|
// Idempotency: if already verified, treat as successful confirmation.
|
||||||
if (await IsUserVerifiedAsync(userAccountId))
|
if (await IsUserVerifiedAsync(userAccountId)) return user;
|
||||||
{
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
command.CommandText = "USP_CreateUserVerification";
|
command.CommandText = "USP_CreateUserVerification";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
@@ -248,29 +238,29 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether a user account has been verified by querying the
|
/// Checks whether a user account has been verified by querying the
|
||||||
/// <c>dbo.UserVerification</c> table for a matching record.
|
/// <c>dbo.UserVerification</c> table for a matching record.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
command.CommandText =
|
command.CommandText =
|
||||||
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
||||||
command.CommandType = CommandType.Text;
|
command.CommandType = CommandType.Text;
|
||||||
|
|
||||||
AddParameter(command, "@UserAccountID", userAccountId);
|
AddParameter(command, "@UserAccountID", userAccountId);
|
||||||
|
|
||||||
var result = await command.ExecuteScalarAsync();
|
object? result = await command.ExecuteScalarAsync();
|
||||||
return result != null && result != DBNull.Value;
|
return result != null && result != DBNull.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether a <see cref="SqlException"/> represents a duplicate key violation
|
/// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
|
||||||
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
|
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ex">The SQL exception to inspect.</param>
|
/// <param name="ex">The SQL exception to inspect.</param>
|
||||||
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
|
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
|
||||||
@@ -282,15 +272,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserAccount entity.
|
/// Maps a data reader row to a UserAccount entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||||
/// <returns>The mapped <see cref="Domain.Entities.UserAccount"/> instance.</returns>
|
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
|
||||||
protected override Domain.Entities.UserAccount MapToEntity(
|
protected override UserAccount MapToEntity(
|
||||||
DbDataReader reader
|
DbDataReader reader
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return new Domain.Entities.UserAccount
|
return new UserAccount
|
||||||
{
|
{
|
||||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||||
Username = reader.GetString(reader.GetOrdinal("Username")),
|
Username = reader.GetString(reader.GetOrdinal("Username")),
|
||||||
@@ -304,33 +294,33 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
||||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||||
? null
|
? null
|
||||||
: (byte[])reader["Timer"],
|
: (byte[])reader["Timer"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
||||||
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||||
/// <returns>The mapped <see cref="UserCredential"/> instance.</returns>
|
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
|
||||||
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
||||||
{
|
{
|
||||||
var entity = new UserCredential
|
UserCredential entity = new()
|
||||||
{
|
{
|
||||||
UserCredentialId = reader.GetGuid(
|
UserCredentialId = reader.GetGuid(
|
||||||
reader.GetOrdinal("UserCredentialId")
|
reader.GetOrdinal("UserCredentialId")
|
||||||
),
|
),
|
||||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||||
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
||||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional columns
|
// Optional columns
|
||||||
var hasTimer =
|
bool hasTimer =
|
||||||
reader
|
reader
|
||||||
.GetSchemaTable()
|
.GetSchemaTable()
|
||||||
?.Rows.Cast<System.Data.DataRow>()
|
?.Rows.Cast<DataRow>()
|
||||||
.Any(r =>
|
.Any(r =>
|
||||||
string.Equals(
|
string.Equals(
|
||||||
r["ColumnName"]?.ToString(),
|
r["ColumnName"]?.ToString(),
|
||||||
@@ -340,31 +330,29 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
) ?? false;
|
) ?? false;
|
||||||
|
|
||||||
if (hasTimer)
|
if (hasTimer)
|
||||||
{
|
|
||||||
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||||
? null
|
? null
|
||||||
: (byte[])reader["Timer"];
|
: (byte[])reader["Timer"];
|
||||||
}
|
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
||||||
/// <see cref="DBNull.Value"/>.
|
/// <see cref="DBNull.Value" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command">The command to add the parameter to.</param>
|
/// <param name="command">The command to add the parameter to.</param>
|
||||||
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
||||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param>
|
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
||||||
private static void AddParameter(
|
private static void AddParameter(
|
||||||
DbCommand command,
|
DbCommand command,
|
||||||
string name,
|
string name,
|
||||||
object? value
|
object? value
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var p = command.CreateParameter();
|
DbParameter p = command.CreateParameter();
|
||||||
p.ParameterName = name;
|
p.ParameterName = name;
|
||||||
p.Value = value ?? DBNull.Value;
|
p.Value = value ?? DBNull.Value;
|
||||||
command.Parameters.Add(p);
|
command.Parameters.Add(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,13 @@ using Domain.Entities;
|
|||||||
namespace Features.Auth.Repository;
|
namespace Features.Auth.Repository;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repository for authentication-related database operations including user registration and credential management.
|
/// Repository for authentication-related database operations including user registration and credential management.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAuthRepository
|
public interface IAuthRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a new user with account details and initial credential.
|
/// Registers a new user with account details and initial credential.
|
||||||
/// Uses stored procedure: USP_RegisterUser
|
/// Uses stored procedure: USP_RegisterUser
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="username">Unique username for the user</param>
|
/// <param name="username">Unique username for the user</param>
|
||||||
/// <param name="firstName">User's first name</param>
|
/// <param name="firstName">User's first name</param>
|
||||||
@@ -18,7 +18,7 @@ public interface IAuthRepository
|
|||||||
/// <param name="dateOfBirth">User's date of birth</param>
|
/// <param name="dateOfBirth">User's date of birth</param>
|
||||||
/// <param name="passwordHash">Hashed password</param>
|
/// <param name="passwordHash">Hashed password</param>
|
||||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||||
Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
Task<UserAccount> RegisterUserAsync(
|
||||||
string username,
|
string username,
|
||||||
string firstName,
|
string firstName,
|
||||||
string lastName,
|
string lastName,
|
||||||
@@ -28,24 +28,24 @@ public interface IAuthRepository
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a user account by email address (typically used for login).
|
/// Retrieves a user account by email address (typically used for login).
|
||||||
/// Uses stored procedure: usp_GetUserAccountByEmail
|
/// Uses stored procedure: usp_GetUserAccountByEmail
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="email">Email address to search for</param>
|
/// <param name="email">Email address to search for</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(string email);
|
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a user account by username (typically used for login).
|
/// Retrieves a user account by username (typically used for login).
|
||||||
/// Uses stored procedure: usp_GetUserAccountByUsername
|
/// Uses stored procedure: usp_GetUserAccountByUsername
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="username">Username to search for</param>
|
/// <param name="username">Username to search for</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(string username);
|
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the active (non-revoked) credential for a user account.
|
/// Retrieves the active (non-revoked) credential for a user account.
|
||||||
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
|
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||||
@@ -54,31 +54,31 @@ public interface IAuthRepository
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
||||||
/// Uses stored procedure: USP_RotateUserCredential
|
/// Uses stored procedure: USP_RotateUserCredential
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <param name="newPasswordHash">New hashed password</param>
|
/// <param name="newPasswordHash">New hashed password</param>
|
||||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks a user account as confirmed.
|
/// Marks a user account as confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||||
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
|
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
|
||||||
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a user account by ID.
|
/// Retrieves a user account by ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether a user account has been verified.
|
/// Checks whether a user account has been verified.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
@@ -4,28 +4,30 @@ using Domain.Entities;
|
|||||||
namespace Features.Auth.Services;
|
namespace Features.Auth.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Identifies the kind of token being generated or validated.
|
/// Identifies the kind of token being generated or validated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum TokenType
|
public enum TokenType
|
||||||
{
|
{
|
||||||
/// <summary>A short-lived token used to authorize API requests.</summary>
|
/// <summary>A short-lived token used to authorize API requests.</summary>
|
||||||
AccessToken,
|
AccessToken,
|
||||||
|
|
||||||
/// <summary>A long-lived token used to obtain new access tokens.</summary>
|
/// <summary>A long-lived token used to obtain new access tokens.</summary>
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
|
|
||||||
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
||||||
ConfirmationToken,
|
ConfirmationToken
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the result of successfully validating a token.
|
/// Represents the result of successfully validating a token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
|
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
|
||||||
/// <param name="Username">The username extracted from the token's claims.</param>
|
/// <param name="Username">The username extracted from the token's claims.</param>
|
||||||
/// <param name="Principal">The <see cref="ClaimsPrincipal"/> produced from the validated token.</param>
|
/// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
|
||||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the result of refreshing a user's session.
|
/// Represents the result of refreshing a user's session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
|
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
|
||||||
/// <param name="RefreshToken">The newly issued refresh token.</param>
|
/// <param name="RefreshToken">The newly issued refresh token.</param>
|
||||||
@@ -37,77 +39,79 @@ public record RefreshTokenResult(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService"/>.
|
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TokenServiceExpirationHours
|
public static class TokenServiceExpirationHours
|
||||||
{
|
{
|
||||||
/// <summary>The expiration window, in hours, for access tokens.</summary>
|
/// <summary>The expiration window, in hours, for access tokens.</summary>
|
||||||
public const double AccessTokenHours = 1;
|
public const double AccessTokenHours = 1;
|
||||||
|
|
||||||
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
||||||
public const double RefreshTokenHours = 504; // 21 days
|
public const double RefreshTokenHours = 504; // 21 days
|
||||||
|
|
||||||
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
||||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a new access token for the given user.
|
/// Generates a new access token for the given user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed access token string.</returns>
|
/// <returns>The signed access token string.</returns>
|
||||||
string GenerateAccessToken(UserAccount user);
|
string GenerateAccessToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a new refresh token for the given user.
|
/// Generates a new refresh token for the given user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed refresh token string.</returns>
|
/// <returns>The signed refresh token string.</returns>
|
||||||
string GenerateRefreshToken(UserAccount user);
|
string GenerateRefreshToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a new confirmation token for the given user.
|
/// Generates a new confirmation token for the given user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed confirmation token string.</returns>
|
/// <returns>The signed confirmation token string.</returns>
|
||||||
string GenerateConfirmationToken(UserAccount user);
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a token of the type specified by <typeparamref name="T"/>, which must be <see cref="TokenType"/>.
|
/// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||||
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates an access token.
|
/// Validates an access token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The access token string to validate.</param>
|
/// <param name="token">The access token string to validate.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a refresh token.
|
/// Validates a refresh token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The refresh token string to validate.</param>
|
/// <param name="token">The refresh token string to validate.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a confirmation token.
|
/// Validates a confirmation token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The confirmation token string to validate.</param>
|
/// <param name="token">The confirmation token string to validate.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
|
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||||
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
|
||||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
@@ -8,27 +8,26 @@ using Infrastructure.Jwt;
|
|||||||
namespace Features.Auth.Services;
|
namespace Features.Auth.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default implementation of <see cref="ITokenService"/> that generates and validates JWTs
|
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
|
||||||
/// for access, refresh, and confirmation flows using secrets read from environment variables.
|
/// for access, refresh, and confirmation flows using secrets read from environment variables.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TokenService : ITokenService
|
public class TokenService : ITokenService
|
||||||
{
|
{
|
||||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
|
||||||
private readonly IAuthRepository _authRepository;
|
|
||||||
|
|
||||||
private readonly string _accessTokenSecret;
|
private readonly string _accessTokenSecret;
|
||||||
private readonly string _refreshTokenSecret;
|
private readonly IAuthRepository _authRepository;
|
||||||
private readonly string _confirmationTokenSecret;
|
private readonly string _confirmationTokenSecret;
|
||||||
|
private readonly string _refreshTokenSecret;
|
||||||
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of <see cref="TokenService"/>, loading the access, refresh, and
|
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
|
||||||
/// confirmation token signing secrets from environment variables.
|
/// confirmation token signing secrets from environment variables.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
|
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
|
||||||
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
|
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public TokenService(
|
public TokenService(
|
||||||
ITokenInfrastructure tokenInfrastructure,
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
@@ -39,139 +38,170 @@ public class TokenService : ITokenService
|
|||||||
_authRepository = authRepository;
|
_authRepository = authRepository;
|
||||||
|
|
||||||
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||||
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
?? throw new InvalidOperationException(
|
||||||
|
"ACCESS_TOKEN_SECRET environment variable is not set");
|
||||||
|
|
||||||
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||||
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
?? throw new InvalidOperationException(
|
||||||
|
"REFRESH_TOKEN_SECRET environment variable is not set");
|
||||||
|
|
||||||
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||||
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
?? throw new InvalidOperationException(
|
||||||
|
"CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates an access token for the given user, signed with the access token secret and
|
/// Generates an access token for the given user, signed with the access token secret and
|
||||||
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours"/> hours.
|
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed access token string.</returns>
|
/// <returns>The signed access token string.</returns>
|
||||||
public string GenerateAccessToken(UserAccount user)
|
public string GenerateAccessToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a refresh token for the given user, signed with the refresh token secret and
|
/// Generates a refresh token for the given user, signed with the refresh token secret and
|
||||||
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours"/> hours.
|
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed refresh token string.</returns>
|
/// <returns>The signed refresh token string.</returns>
|
||||||
public string GenerateRefreshToken(UserAccount user)
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
|
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
|
||||||
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours"/> hours.
|
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed confirmation token string.</returns>
|
/// <returns>The signed confirmation token string.</returns>
|
||||||
public string GenerateConfirmationToken(UserAccount user)
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a token of the kind specified by <typeparamref name="T"/>, dispatching to
|
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
|
||||||
/// <see cref="GenerateAccessToken"/>, <see cref="GenerateRefreshToken"/>, or
|
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
|
||||||
/// <see cref="GenerateConfirmationToken"/> based on the corresponding <see cref="TokenType"/> value.
|
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when <typeparamref name="T"/> is not <see cref="TokenType"/> or does not resolve to a known token type.
|
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||||
{
|
{
|
||||||
if (typeof(T) != typeof(TokenType))
|
if (typeof(T) != typeof(TokenType))
|
||||||
throw new InvalidOperationException("Invalid token type");
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
var tokenTypeName = typeof(T).Name;
|
string tokenTypeName = typeof(T).Name;
|
||||||
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out object? parsed))
|
||||||
throw new InvalidOperationException("Invalid token type");
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
var tokenType = (TokenType)parsed;
|
TokenType tokenType = (TokenType)parsed;
|
||||||
return tokenType switch
|
return tokenType switch
|
||||||
{
|
{
|
||||||
TokenType.AccessToken => GenerateAccessToken(user),
|
TokenType.AccessToken => GenerateAccessToken(user),
|
||||||
TokenType.RefreshToken => GenerateRefreshToken(user),
|
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||||
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||||
_ => throw new InvalidOperationException("Invalid token type"),
|
_ => throw new InvalidOperationException("Invalid token type")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates an access token against the access token secret.
|
/// Validates an access token against the access token secret.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The access token string to validate.</param>
|
/// <param name="token">The access token string to validate.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||||
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
{
|
||||||
|
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a refresh token against the refresh token secret.
|
/// Validates a refresh token against the refresh token secret.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The refresh token string to validate.</param>
|
/// <param name="token">The refresh token string to validate.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||||
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
{
|
||||||
|
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a confirmation token against the confirmation token secret.
|
/// Validates a confirmation token against the confirmation token secret.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The confirmation token string to validate.</param>
|
/// <param name="token">The confirmation token string to validate.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||||
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
{
|
||||||
|
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
/// Validates the given refresh token, looks up the associated user, and issues a fresh
|
||||||
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
/// access/refresh token pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||||
|
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
|
{
|
||||||
|
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
|
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||||
|
if (user == null)
|
||||||
|
throw new UnauthorizedException("User account not found");
|
||||||
|
|
||||||
|
string newAccess = GenerateAccessToken(user);
|
||||||
|
string newRefresh = GenerateRefreshToken(user);
|
||||||
|
|
||||||
|
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||||
|
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The token string to validate.</param>
|
/// <param name="token">The token string to validate.</param>
|
||||||
/// <param name="secret">The secret key to validate the token's signature against.</param>
|
/// <param name="secret">The secret key to validate the token's signature against.</param>
|
||||||
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid"/>,
|
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
||||||
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||||
/// </exception>
|
/// </exception>
|
||||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||||
|
|
||||||
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||||
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
||||||
|
|
||||||
if (!Guid.TryParse(userIdClaim, out var userId))
|
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||||
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||||
|
|
||||||
return new ValidatedToken(userId, usernameClaim, principal);
|
return new ValidatedToken(userId, usernameClaim, principal);
|
||||||
@@ -185,26 +215,4 @@ public class TokenService : ITokenService
|
|||||||
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/// <summary>
|
|
||||||
/// Validates the given refresh token, looks up the associated user, and issues a fresh
|
|
||||||
/// access/refresh token pair.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
|
||||||
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
|
||||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
|
||||||
{
|
|
||||||
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
|
||||||
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
|
||||||
if (user == null)
|
|
||||||
throw new UnauthorizedException("User account not found");
|
|
||||||
|
|
||||||
var newAccess = GenerateAccessToken(user);
|
|
||||||
var newRefresh = GenerateRefreshToken(user);
|
|
||||||
|
|
||||||
return new RefreshTokenResult(user, newRefresh, newAccess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,41 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Breweries.Commands.CreateBrewery;
|
using Features.Breweries.Commands.CreateBrewery;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Commands;
|
namespace Features.Breweries.Tests.Commands;
|
||||||
|
|
||||||
public class CreateBreweryHandlerTests
|
public class CreateBreweryHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
|
||||||
private readonly CreateBreweryHandler _handler;
|
private readonly CreateBreweryHandler _handler;
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
public CreateBreweryHandlerTests()
|
public CreateBreweryHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new CreateBreweryHandler(_repoMock.Object);
|
_handler = new CreateBreweryHandler(_repoMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreateBreweryLocation ValidLocation() =>
|
private static CreateBreweryLocation ValidLocation()
|
||||||
new(
|
{
|
||||||
CityId: Guid.NewGuid(),
|
return new CreateBreweryLocation(
|
||||||
AddressLine1: "123 Main St",
|
Guid.NewGuid(),
|
||||||
AddressLine2: null,
|
"123 Main St",
|
||||||
PostalCode: "12345",
|
null,
|
||||||
Coordinates: null
|
"12345",
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
|
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
|
||||||
{
|
{
|
||||||
var command = new CreateBreweryCommand(
|
CreateBreweryCommand command = new(
|
||||||
PostedById: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
BreweryName: "MyBrew",
|
"MyBrew",
|
||||||
Description: "Desc",
|
"Desc",
|
||||||
Location: ValidLocation()
|
ValidLocation()
|
||||||
);
|
);
|
||||||
|
|
||||||
BreweryPost? persisted = null;
|
BreweryPost? persisted = null;
|
||||||
@@ -41,9 +44,9 @@ public class CreateBreweryHandlerTests
|
|||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
var before = DateTime.UtcNow;
|
DateTime before = DateTime.UtcNow;
|
||||||
var result = await _handler.Handle(command, CancellationToken.None);
|
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
|
||||||
var after = DateTime.UtcNow;
|
DateTime after = DateTime.UtcNow;
|
||||||
|
|
||||||
persisted.Should().NotBeNull();
|
persisted.Should().NotBeNull();
|
||||||
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);
|
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);
|
||||||
@@ -65,4 +68,4 @@ public class CreateBreweryHandlerTests
|
|||||||
result.BreweryName.Should().Be("MyBrew");
|
result.BreweryName.Should().Be("MyBrew");
|
||||||
result.Location.Should().NotBeNull();
|
result.Location.Should().NotBeNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,13 +9,13 @@ public class DeleteBreweryHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_DelegatesToRepository()
|
public async Task Handle_DelegatesToRepository()
|
||||||
{
|
{
|
||||||
var repoMock = new Mock<IBreweryRepository>();
|
Mock<IBreweryRepository> repoMock = new();
|
||||||
var handler = new DeleteBreweryHandler(repoMock.Object);
|
DeleteBreweryHandler handler = new(repoMock.Object);
|
||||||
var id = Guid.NewGuid();
|
Guid id = Guid.NewGuid();
|
||||||
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
|
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
|
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
|
||||||
|
|
||||||
repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
|
repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Breweries.Commands.UpdateBrewery;
|
using Features.Breweries.Commands.UpdateBrewery;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Commands;
|
namespace Features.Breweries.Tests.Commands;
|
||||||
|
|
||||||
public class UpdateBreweryHandlerTests
|
public class UpdateBreweryHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
|
||||||
private readonly UpdateBreweryHandler _handler;
|
private readonly UpdateBreweryHandler _handler;
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
public UpdateBreweryHandlerTests()
|
public UpdateBreweryHandlerTests()
|
||||||
{
|
{
|
||||||
@@ -19,12 +19,12 @@ public class UpdateBreweryHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
|
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
|
||||||
{
|
{
|
||||||
var command = new UpdateBreweryCommand(
|
UpdateBreweryCommand command = new(
|
||||||
BreweryPostId: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
PostedById: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
BreweryName: "Renamed",
|
"Renamed",
|
||||||
Description: "New description",
|
"New description",
|
||||||
Location: null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
BreweryPost? persisted = null;
|
BreweryPost? persisted = null;
|
||||||
@@ -33,9 +33,9 @@ public class UpdateBreweryHandlerTests
|
|||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
var before = DateTime.UtcNow;
|
DateTime before = DateTime.UtcNow;
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
var after = DateTime.UtcNow;
|
DateTime after = DateTime.UtcNow;
|
||||||
|
|
||||||
persisted.Should().NotBeNull();
|
persisted.Should().NotBeNull();
|
||||||
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
|
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||||
@@ -49,12 +49,12 @@ public class UpdateBreweryHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
|
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
|
||||||
{
|
{
|
||||||
var command = new UpdateBreweryCommand(
|
UpdateBreweryCommand command = new(
|
||||||
BreweryPostId: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
PostedById: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
BreweryName: "Name",
|
"Name",
|
||||||
Description: "Description",
|
"Description",
|
||||||
Location: null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
BreweryPost? persisted = null;
|
BreweryPost? persisted = null;
|
||||||
@@ -71,20 +71,20 @@ public class UpdateBreweryHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
|
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
|
||||||
{
|
{
|
||||||
var locationCommand = new UpdateBreweryLocation(
|
UpdateBreweryLocation locationCommand = new(
|
||||||
BreweryPostLocationId: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
CityId: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
AddressLine1: "456 Oak Ave",
|
"456 Oak Ave",
|
||||||
AddressLine2: "Suite 2",
|
"Suite 2",
|
||||||
PostalCode: "54321",
|
"54321",
|
||||||
Coordinates: null
|
null
|
||||||
);
|
);
|
||||||
var command = new UpdateBreweryCommand(
|
UpdateBreweryCommand command = new(
|
||||||
BreweryPostId: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
PostedById: Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
BreweryName: "Name",
|
"Name",
|
||||||
Description: "Description",
|
"Description",
|
||||||
Location: locationCommand
|
locationCommand
|
||||||
);
|
);
|
||||||
|
|
||||||
BreweryPost? persisted = null;
|
BreweryPost? persisted = null;
|
||||||
@@ -103,4 +103,4 @@ public class UpdateBreweryHandlerTests
|
|||||||
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
|
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
|
||||||
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
|
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<RootNamespace>Features.Breweries.Tests</RootNamespace>
|
<RootNamespace>Features.Breweries.Tests</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="6.9.0"/>
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
<PackageReference Include="DbMocker" Version="1.26.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj" />
|
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using FluentAssertions;
|
using Features.Breweries.Dtos;
|
||||||
using Features.Breweries.Queries.GetAllBreweries;
|
using Features.Breweries.Queries.GetAllBreweries;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Queries;
|
namespace Features.Breweries.Tests.Queries;
|
||||||
|
|
||||||
public class GetAllBreweriesHandlerTests
|
public class GetAllBreweriesHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
|
||||||
private readonly GetAllBreweriesHandler _handler;
|
private readonly GetAllBreweriesHandler _handler;
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
public GetAllBreweriesHandlerTests()
|
public GetAllBreweriesHandlerTests()
|
||||||
{
|
{
|
||||||
@@ -22,7 +23,7 @@ public class GetAllBreweriesHandlerTests
|
|||||||
_repoMock.Setup(r => r.GetAllAsync(10, 5))
|
_repoMock.Setup(r => r.GetAllAsync(10, 5))
|
||||||
.ReturnsAsync(Array.Empty<BreweryPost>());
|
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
var result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
|
IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeEmpty();
|
result.Should().BeEmpty();
|
||||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||||
@@ -31,16 +32,16 @@ public class GetAllBreweriesHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ReturnsAllBreweries_FromRepository()
|
public async Task Handle_ReturnsAllBreweries_FromRepository()
|
||||||
{
|
{
|
||||||
var breweries = new[]
|
BreweryPost[] breweries = new[]
|
||||||
{
|
{
|
||||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
||||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }
|
||||||
};
|
};
|
||||||
_repoMock.Setup(r => r.GetAllAsync(null, null))
|
_repoMock.Setup(r => r.GetAllAsync(null, null))
|
||||||
.ReturnsAsync(breweries);
|
.ReturnsAsync(breweries);
|
||||||
|
|
||||||
var result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
|
IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
|
||||||
|
|
||||||
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using FluentAssertions;
|
using Features.Breweries.Dtos;
|
||||||
using Features.Breweries.Queries.GetBreweryById;
|
using Features.Breweries.Queries.GetBreweryById;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Queries;
|
namespace Features.Breweries.Tests.Queries;
|
||||||
|
|
||||||
public class GetBreweryByIdHandlerTests
|
public class GetBreweryByIdHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
|
||||||
private readonly GetBreweryByIdHandler _handler;
|
private readonly GetBreweryByIdHandler _handler;
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
public GetBreweryByIdHandlerTests()
|
public GetBreweryByIdHandlerTests()
|
||||||
{
|
{
|
||||||
@@ -19,11 +20,11 @@ public class GetBreweryByIdHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ReturnsBrewery_WhenFound()
|
public async Task Handle_ReturnsBrewery_WhenFound()
|
||||||
{
|
{
|
||||||
var brewery = new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
|
BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
|
||||||
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
|
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
|
||||||
.ReturnsAsync(brewery);
|
.ReturnsAsync(brewery);
|
||||||
|
|
||||||
var result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
|
BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
|
result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
|
||||||
@@ -32,12 +33,12 @@ public class GetBreweryByIdHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ReturnsNull_WhenNotFound()
|
public async Task Handle_ReturnsNull_WhenNotFound()
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
Guid id = Guid.NewGuid();
|
||||||
_repoMock.Setup(r => r.GetByIdAsync(id))
|
_repoMock.Setup(r => r.GetByIdAsync(id))
|
||||||
.ReturnsAsync((BreweryPost?)null);
|
.ReturnsAsync((BreweryPost?)null);
|
||||||
|
|
||||||
var result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
|
BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
using Apps72.Dev.Data.DbMocker;
|
||||||
using FluentAssertions;
|
|
||||||
using Features.Breweries.Repository;
|
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Repository;
|
namespace Features.Breweries.Tests.Repository;
|
||||||
|
|
||||||
public class BreweryRepositoryTests
|
public class BreweryRepositoryTests
|
||||||
{
|
{
|
||||||
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
|
private static BreweryRepository CreateRepo(MockDbConnection conn)
|
||||||
new(new TestConnectionFactory(conn));
|
{
|
||||||
|
return new BreweryRepository(new TestConnectionFactory(conn));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
|
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
|
||||||
{
|
{
|
||||||
var breweryId = Guid.NewGuid();
|
Guid breweryId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
// Repository calls the stored procedure
|
// Repository calls the stored procedure
|
||||||
const string getByIdSql = "USP_GetBreweryById";
|
const string getByIdSql = "USP_GetBreweryById";
|
||||||
|
|
||||||
var locationId = Guid.NewGuid();
|
Guid locationId = Guid.NewGuid();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
|
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
|
||||||
.ReturnsTable(
|
.ReturnsTable(
|
||||||
@@ -56,8 +58,8 @@ public class BreweryRepositoryTests
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
BreweryRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetByIdAsync(breweryId);
|
BreweryPost? result = await repo.GetByIdAsync(breweryId);
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.BreweryPostId.Should().Be(breweryId);
|
result!.BreweryPostId.Should().Be(breweryId);
|
||||||
result.Location.Should().NotBeNull();
|
result.Location.Should().NotBeNull();
|
||||||
@@ -67,23 +69,22 @@ public class BreweryRepositoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
|
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
|
||||||
{
|
{
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
|
||||||
.ReturnsTable(MockTable.Empty());
|
.ReturnsTable(MockTable.Empty());
|
||||||
var repo = CreateRepo(conn);
|
BreweryRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetByIdAsync(Guid.NewGuid());
|
BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_ExecutesSuccessfully()
|
public async Task CreateAsync_ExecutesSuccessfully()
|
||||||
{
|
{
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
|
||||||
.ReturnsScalar(1);
|
.ReturnsScalar(1);
|
||||||
var repo = CreateRepo(conn);
|
BreweryRepository repo = CreateRepo(conn);
|
||||||
var brewery = new BreweryPost
|
BreweryPost brewery = new()
|
||||||
{
|
{
|
||||||
BreweryPostId = Guid.NewGuid(),
|
BreweryPostId = Guid.NewGuid(),
|
||||||
PostedById = Guid.NewGuid(),
|
PostedById = Guid.NewGuid(),
|
||||||
@@ -101,7 +102,7 @@ public class BreweryRepositoryTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Should not throw
|
// Should not throw
|
||||||
var act = async () => await repo.CreateAsync(brewery);
|
Func<Task> act = async () => await repo.CreateAsync(brewery);
|
||||||
await act.Should().NotThrowAsync();
|
await act.Should().NotThrowAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
|||||||
{
|
{
|
||||||
private readonly DbConnection _conn = conn;
|
private readonly DbConnection _conn = conn;
|
||||||
|
|
||||||
public DbConnection CreateConnection() => _conn;
|
public DbConnection CreateConnection()
|
||||||
}
|
{
|
||||||
|
return _conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ using MediatR;
|
|||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand"/>.
|
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record CreateBreweryLocation(
|
public record CreateBreweryLocation(
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
@@ -15,11 +15,11 @@ public record CreateBreweryLocation(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>.
|
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record CreateBreweryCommand(
|
public record CreateBreweryCommand(
|
||||||
Guid PostedById,
|
Guid PostedById,
|
||||||
string BreweryName,
|
string BreweryName,
|
||||||
string Description,
|
string Description,
|
||||||
CreateBreweryLocation Location
|
CreateBreweryLocation Location
|
||||||
) : IRequest<BreweryDto>;
|
) : IRequest<BreweryDto>;
|
||||||
@@ -6,21 +6,21 @@ using MediatR;
|
|||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="CreateBreweryCommand"/> by persisting a new brewery post and its location.
|
/// Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post and its location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="repository">Repository used to persist the new brewery post.</param>
|
/// <param name="repository">Repository used to persist the new brewery post.</param>
|
||||||
public class CreateBreweryHandler(IBreweryRepository repository)
|
public class CreateBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new brewery post, generating new identifiers for the post and its location.
|
/// Creates a new brewery post, generating new identifiers for the post and its location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The details of the brewery post to create.</param>
|
/// <param name="request">The details of the brewery post to create.</param>
|
||||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||||
/// <returns>The newly created brewery post.</returns>
|
/// <returns>The newly created brewery post.</returns>
|
||||||
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
|
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entity = new BreweryPost
|
BreweryPost entity = new()
|
||||||
{
|
{
|
||||||
BreweryPostId = Guid.NewGuid(),
|
BreweryPostId = Guid.NewGuid(),
|
||||||
PostedById = request.PostedById,
|
PostedById = request.PostedById,
|
||||||
@@ -34,11 +34,11 @@ public class CreateBreweryHandler(IBreweryRepository repository)
|
|||||||
AddressLine1 = request.Location.AddressLine1,
|
AddressLine1 = request.Location.AddressLine1,
|
||||||
AddressLine2 = request.Location.AddressLine2,
|
AddressLine2 = request.Location.AddressLine2,
|
||||||
PostalCode = request.Location.PostalCode,
|
PostalCode = request.Location.PostalCode,
|
||||||
Coordinates = request.Location.Coordinates,
|
Coordinates = request.Location.Coordinates
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await repository.CreateAsync(entity);
|
await repository.CreateAsync(entity);
|
||||||
return entity.ToDto();
|
return entity.ToDto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,15 @@ using FluentValidation;
|
|||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates <see cref="CreateBreweryCommand"/> instances before they are processed.
|
/// Validates <see cref="CreateBreweryCommand" /> instances before they are processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById"/>,
|
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById" />,
|
||||||
/// <see cref="CreateBreweryCommand.BreweryName"/>, <see cref="CreateBreweryCommand.Description"/>, and
|
/// <see cref="CreateBreweryCommand.BreweryName" />, <see cref="CreateBreweryCommand.Description" />, and
|
||||||
/// <see cref="CreateBreweryCommand.Location"/> to be present, with length limits on the name, description,
|
/// <see cref="CreateBreweryCommand.Location" /> to be present, with length limits on the name, description,
|
||||||
/// address line 1, and postal code fields.
|
/// address line 1, and postal code fields.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CreateBreweryValidator()
|
public CreateBreweryValidator()
|
||||||
{
|
{
|
||||||
@@ -56,4 +56,4 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
|||||||
.When(x => x.Location is not null)
|
.When(x => x.Location is not null)
|
||||||
.WithMessage("Postal code cannot exceed 20 characters.");
|
.WithMessage("Postal code cannot exceed 20 characters.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,6 @@ using MediatR;
|
|||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a brewery post by its unique identifier.
|
/// Deletes a brewery post by its unique identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user