formatting

This commit is contained in:
Aaron Po
2026-06-20 15:09:40 -04:00
parent 254431928f
commit cd99c1fbc3
131 changed files with 1306 additions and 1447 deletions

View File

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

View File

@@ -44,29 +44,19 @@ public class JwtAuthenticationHandler(
{ {
// 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.
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET"); string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret)) secret = configuration["Jwt:SecretKey"]; if (string.IsNullOrWhiteSpace(secret))
secret = configuration["Jwt:SecretKey"];
if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured"); if (string.IsNullOrWhiteSpace(secret))
return AuthenticateResult.Fail("JWT secret is not configured");
// Check if Authorization header exists // Check if Authorization header exists
if ( if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
!Request.Headers.TryGetValue(
"Authorization",
out StringValues authHeaderValue
)
)
return AuthenticateResult.Fail("Authorization header is missing"); return AuthenticateResult.Fail("Authorization header is missing");
string authHeader = authHeaderValue.ToString(); string authHeader = authHeaderValue.ToString();
if ( if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
!authHeader.StartsWith( return AuthenticateResult.Fail("Invalid authorization header format");
"Bearer ",
StringComparison.OrdinalIgnoreCase
)
)
return AuthenticateResult.Fail(
"Invalid authorization header format"
);
string token = authHeader.Substring("Bearer ".Length).Trim(); string token = authHeader.Substring("Bearer ".Length).Trim();
@@ -81,9 +71,7 @@ public class JwtAuthenticationHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
return AuthenticateResult.Fail( return AuthenticateResult.Fail($"Token validation failed: {ex.Message}");
$"Token validation failed: {ex.Message}"
);
} }
} }
@@ -97,7 +85,10 @@ public class JwtAuthenticationHandler(
Response.ContentType = "application/json"; Response.ContentType = "application/json";
Response.StatusCode = 401; Response.StatusCode = 401;
ResponseBody response = new() { 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);
} }
} }
@@ -106,6 +97,4 @@ public class JwtAuthenticationHandler(
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />. /// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
/// </summary> /// </summary>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks> /// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
{
}

View File

@@ -31,7 +31,7 @@ public class ProtectedController : ControllerBase
new ResponseBody<object> new ResponseBody<object>
{ {
Message = "Protected endpoint accessed successfully", Message = "Protected endpoint accessed successfully",
Payload = new { userId, username } Payload = new { userId, username },
} }
); );
} }

View File

@@ -14,8 +14,7 @@ namespace API.Core;
/// 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,
@@ -84,10 +83,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
case ValidationException fluentValidationException: case ValidationException fluentValidationException:
Dictionary<string, string[]> errors = fluentValidationException Dictionary<string, string[]> errors = fluentValidationException
.Errors.GroupBy(e => e.PropertyName) .Errors.GroupBy(e => e.PropertyName)
.ToDictionary( .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult( context.Result = new BadRequestObjectResult(
new { message = "Validation failed", errors } new { message = "Validation failed", errors }
@@ -96,41 +92,33 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case ConflictException ex: case ConflictException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 409 StatusCode = 409,
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
case NotFoundException ex: case NotFoundException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 404 StatusCode = 404,
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
case UnauthorizedException ex: case UnauthorizedException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 401 StatusCode = 401,
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
case ForbiddenException ex: case ForbiddenException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 403 StatusCode = 403,
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
@@ -140,30 +128,25 @@ 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;
case Domain.Exceptions.ValidationException ex: case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 400 StatusCode = 400,
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
default: default:
context.Result = new ObjectResult( context.Result = new ObjectResult(
new ResponseBody new ResponseBody { Message = "An unexpected error occurred" }
{
Message = "An unexpected error occurred"
}
) )
{ {
StatusCode = 500 StatusCode = 500,
}; };
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;

View File

@@ -17,7 +17,11 @@ using Shared.Application.Behaviors;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter // Global Exception Filter
builder.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); }) builder
.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
})
.AddApplicationPart(typeof(BreweryController).Assembly) .AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly) .AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly); .AddApplicationPart(typeof(AuthController).Assembly);
@@ -51,14 +55,12 @@ 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()) builder.Logging.AddDebug(); if (!builder.Environment.IsProduction())
builder.Logging.AddDebug();
// Configure Dependency Injection ------------------------------------------------------------------------------------- // Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton< builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
ISqlConnectionFactory,
DefaultSqlConnectionFactory
>();
builder.Services.AddFeaturesBreweries(); builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement(); builder.Services.AddFeaturesUserManagement();
@@ -75,10 +77,7 @@ builder.Services.AddScoped<GlobalExceptionFilter>();
// Configure JWT Authentication // Configure JWT Authentication
builder builder
.Services.AddAuthentication("JWT") .Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>( .AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
"JWT",
options => { }
);
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();

View File

@@ -8,15 +8,15 @@
</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"
@@ -24,24 +24,21 @@
/> />
<!-- ASP.NET Core integration testing --> <!-- ASP.NET Core integration testing -->
<PackageReference <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Ensure feature files are included in the project --> <!-- Ensure feature files are included in the project -->
<None Include="Features\**\*.feature"/> <None Include="Features\**\*.feature" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit"/> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj"/> <ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj"/> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -8,11 +8,7 @@ public class MockEmailDispatcher : IEmailDispatcher
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new(); public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync( public Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
string firstName,
string email,
string confirmationToken
)
{ {
SentRegistrationEmails.Add( SentRegistrationEmails.Add(
new RegistrationEmail new RegistrationEmail
@@ -20,7 +16,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 +35,7 @@ public class MockEmailDispatcher : IEmailDispatcher
FirstName = firstName, FirstName = firstName,
Email = email, Email = email,
ConfirmationToken = confirmationToken, ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow SentAt = DateTime.UtcNow,
} }
); );

View File

@@ -10,12 +10,7 @@ public class MockEmailProvider : IEmailProvider
{ {
public List<SentEmail> SentEmails { get; } = new(); public List<SentEmail> SentEmails { get; } = new();
public Task SendAsync( public Task SendAsync(string to, string subject, string body, bool isHtml = true)
string to,
string subject,
string body,
bool isHtml = true
)
{ {
SentEmails.Add( SentEmails.Add(
new SentEmail new SentEmail
@@ -24,19 +19,14 @@ public class MockEmailProvider : IEmailProvider
Subject = subject, Subject = subject,
Body = body, Body = body,
IsHtml = isHtml, IsHtml = isHtml,
SentAt = DateTime.UtcNow SentAt = DateTime.UtcNow,
} }
); );
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SendAsync( public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
)
{ {
SentEmails.Add( SentEmails.Add(
new SentEmail new SentEmail
@@ -45,7 +35,7 @@ public class MockEmailProvider : IEmailProvider
Subject = subject, Subject = subject,
Body = body, Body = body,
IsHtml = isHtml, IsHtml = isHtml,
SentAt = DateTime.UtcNow SentAt = DateTime.UtcNow,
} }
); );

View File

@@ -15,7 +15,8 @@ public class ApiGeneralSteps(ScenarioContext scenario)
private HttpClient GetClient() private HttpClient GetClient()
{ {
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client)) return client; if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>( TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey, FactoryKey,
@@ -47,11 +48,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
HttpRequestMessage requestMessage = new(new HttpMethod(method), url) HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
{ {
Content = new StringContent( Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
jsonBody,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -62,16 +59,10 @@ public class ApiGeneralSteps(ScenarioContext scenario)
} }
[When("I send an HTTP request {string} to {string}")] [When("I send an HTTP request {string} to {string}")]
public async Task WhenISendAnHttpRequestStringToString( public async Task WhenISendAnHttpRequestStringToString(string method, string url)
string method,
string url
)
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
new HttpMethod(method),
url
);
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
@@ -100,19 +91,13 @@ public class ApiGeneralSteps(ScenarioContext scenario)
} }
[Then("the response JSON should have {string} equal {string}")] [Then("the response JSON should have {string} equal {string}")]
public void ThenTheResponseJsonShouldHaveStringEqualString( public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
string field,
string expected
)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should()
.BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement; JsonElement root = doc.RootElement;
@@ -125,25 +110,16 @@ public class ApiGeneralSteps(ScenarioContext scenario)
"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'",
field field
); );
payloadElem payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem payloadElem
.TryGetProperty(field, out value) .TryGetProperty(field, out value)
.Should() .Should()
.BeTrue( .BeTrue("Expected field '{0}' to be present inside 'payload'", field);
"Expected field '{0}' to be present inside 'payload'",
field
);
} }
value value
.ValueKind.Should() .ValueKind.Should()
.Be( .Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
value.GetString().Should().Be(expected); value.GetString().Should().Be(expected);
} }
@@ -157,10 +133,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should()
.BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement; JsonElement root = doc.RootElement;
@@ -173,25 +146,16 @@ public class ApiGeneralSteps(ScenarioContext scenario)
"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'",
field field
); );
payloadElem payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem payloadElem
.TryGetProperty(field, out value) .TryGetProperty(field, out value)
.Should() .Should()
.BeTrue( .BeTrue("Expected field '{0}' to be present inside 'payload'", field);
"Expected field '{0}' to be present inside 'payload'",
field
);
} }
value value
.ValueKind.Should() .ValueKind.Should()
.Be( .Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
string? actualValue = value.GetString(); string? actualValue = value.GetString();
actualValue actualValue
.Should() .Should()

View File

@@ -21,7 +21,8 @@ public class AuthSteps(ScenarioContext scenario)
private HttpClient GetClient() private HttpClient GetClient()
{ {
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client)) return client; if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>( TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey, FactoryKey,
@@ -39,9 +40,7 @@ public class AuthSteps(ScenarioContext scenario)
private static string GetRequiredEnvVar(string name) private static string GetRequiredEnvVar(string name)
{ {
return Environment.GetEnvironmentVariable(name) return Environment.GetEnvironmentVariable(name)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException($"{name} environment variable is not set");
$"{name} environment variable is not set"
);
} }
private static string GenerateJwtToken( private static string GenerateJwtToken(
@@ -57,21 +56,13 @@ public class AuthSteps(ScenarioContext scenario)
private static Guid ParseRegisteredUserId(JsonElement root) private static Guid ParseRegisteredUserId(JsonElement root)
{ {
return root return root.GetProperty("payload").GetProperty("userAccountId").GetGuid();
.GetProperty("payload")
.GetProperty("userAccountId")
.GetGuid();
} }
private static string ParseRegisteredUsername(JsonElement root) private static string ParseRegisteredUsername(JsonElement root)
{ {
return root return root.GetProperty("payload").GetProperty("username").GetString()
.GetProperty("payload") ?? throw new InvalidOperationException("username missing from registration payload");
.GetProperty("username")
.GetString()
?? throw new InvalidOperationException(
"username missing from registration payload"
);
} }
private static string ParseTokenFromPayload( private static string ParseTokenFromPayload(
@@ -85,9 +76,7 @@ public class AuthSteps(ScenarioContext scenario)
|| payload.TryGetProperty(pascalCaseName, out tokenElem) || payload.TryGetProperty(pascalCaseName, out tokenElem)
) )
return tokenElem.GetString() return tokenElem.GetString()
?? throw new InvalidOperationException( ?? throw new InvalidOperationException($"{camelCaseName} is null");
$"{camelCaseName} is null"
);
throw new InvalidOperationException( throw new InvalidOperationException(
$"Could not find token field '{camelCaseName}' in payload" $"Could not find token field '{camelCaseName}' in payload"
@@ -119,16 +108,9 @@ public class AuthSteps(ScenarioContext scenario)
string body = JsonSerializer.Serialize(new { username, password }); string body = JsonSerializer.Serialize(new { username, password });
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -144,16 +126,9 @@ public class AuthSteps(ScenarioContext scenario)
HttpClient client = GetClient(); HttpClient client = GetClient();
string body = JsonSerializer.Serialize(new { password = "test" }); string body = JsonSerializer.Serialize(new { password = "test" });
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -169,16 +144,9 @@ public class AuthSteps(ScenarioContext scenario)
HttpClient client = GetClient(); HttpClient client = GetClient();
string body = JsonSerializer.Serialize(new { username = "test" }); string body = JsonSerializer.Serialize(new { username = "test" });
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -192,16 +160,9 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing() public async Task WhenISubmitALoginRequestWithBothUsernameAndPasswordMissing()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent( Content = new StringContent("{}", Encoding.UTF8, "application/json"),
"{}",
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -218,10 +179,7 @@ public class AuthSteps(ScenarioContext scenario)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should()
.BeTrue();
JsonDocument doc = JsonDocument.Parse(responseBody!); JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement; JsonElement root = doc.RootElement;
@@ -236,11 +194,7 @@ public class AuthSteps(ScenarioContext scenario)
payloadElem.TryGetProperty("accessToken", out tokenElem) payloadElem.TryGetProperty("accessToken", out tokenElem)
|| payloadElem.TryGetProperty("AccessToken", out tokenElem); || payloadElem.TryGetProperty("AccessToken", out tokenElem);
hasToken hasToken.Should().BeTrue("Expected an access token either at the root or inside 'payload'");
.Should()
.BeTrue(
"Expected an access token either at the root or inside 'payload'"
);
string? token = tokenElem.GetString(); string? token = tokenElem.GetString();
token.Should().NotBeNullOrEmpty(); token.Should().NotBeNullOrEmpty();
@@ -251,16 +205,9 @@ public class AuthSteps(ScenarioContext scenario)
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
// testing GET // testing GET
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/auth/login")
HttpMethod.Get,
"/api/auth/login"
)
{ {
Content = new StringContent( Content = new StringContent("{}", Encoding.UTF8, "application/json"),
"{}",
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -282,7 +229,8 @@ public class AuthSteps(ScenarioContext scenario)
string email = row["Email"] ?? ""; string email = row["Email"] ?? "";
string dateOfBirth = row["DateOfBirth"] ?? ""; string dateOfBirth = row["DateOfBirth"] ?? "";
if (dateOfBirth == "{underage_date}") dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd"); if (dateOfBirth == "{underage_date}")
dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd");
// Keep default registration fixture values unique across repeated runs. // Keep default registration fixture values unique across repeated runs.
if (email == "newuser@example.com") if (email == "newuser@example.com")
@@ -290,7 +238,8 @@ public class AuthSteps(ScenarioContext scenario)
string suffix = Guid.NewGuid().ToString("N")[..8]; string suffix = Guid.NewGuid().ToString("N")[..8];
email = $"newuser-{suffix}@example.com"; email = $"newuser-{suffix}@example.com";
if (username == "newuser") username = $"newuser-{suffix}"; if (username == "newuser")
username = $"newuser-{suffix}";
} }
string? password = row["Password"]; string? password = row["Password"];
@@ -302,21 +251,14 @@ public class AuthSteps(ScenarioContext scenario)
lastName, lastName,
email, email,
dateOfBirth, dateOfBirth,
password password,
}; };
string body = JsonSerializer.Serialize(registrationData); string body = JsonSerializer.Serialize(registrationData);
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
HttpMethod.Post,
"/api/auth/register"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -330,16 +272,9 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARegistrationRequestUsingAGetRequest() public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/auth/register")
HttpMethod.Get,
"/api/auth/register"
)
{ {
Content = new StringContent( Content = new StringContent("{}", Encoding.UTF8, "application/json"),
"{}",
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -361,20 +296,13 @@ public class AuthSteps(ScenarioContext scenario)
lastName = "User", lastName = "User",
email = $"newuser-{suffix}@example.com", email = $"newuser-{suffix}@example.com",
dateOfBirth = "1990-01-01", dateOfBirth = "1990-01-01",
password = "Password1!" password = "Password1!",
}; };
string body = JsonSerializer.Serialize(registrationData); string body = JsonSerializer.Serialize(registrationData);
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
HttpMethod.Post,
"/api/auth/register"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -395,16 +323,9 @@ public class AuthSteps(ScenarioContext scenario)
var loginData = new { username = "test.user", password = "password" }; var loginData = new { username = "test.user", password = "password" };
string body = JsonSerializer.Serialize(loginData); string body = JsonSerializer.Serialize(loginData);
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")
HttpMethod.Post,
"/api/auth/login"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -445,17 +366,10 @@ public class AuthSteps(ScenarioContext scenario)
{ {
Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id) Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException( : throw new InvalidOperationException("registered user ID not found in scenario");
"registered user ID not found in scenario" string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
);
string? username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out string? user
)
? user ? user
: throw new InvalidOperationException( : throw new InvalidOperationException("registered username not found in scenario");
"registered username not found in scenario"
);
string secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET"); string secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
scenario["accessToken"] = GenerateJwtToken( scenario["accessToken"] = GenerateJwtToken(
@@ -471,17 +385,10 @@ public class AuthSteps(ScenarioContext scenario)
{ {
Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id) Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException( : throw new InvalidOperationException("registered user ID not found in scenario");
"registered user ID not found in scenario" string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
);
string? username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out string? user
)
? user ? user
: throw new InvalidOperationException( : throw new InvalidOperationException("registered username not found in scenario");
"registered username not found in scenario"
);
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
scenario["confirmationToken"] = GenerateJwtToken( scenario["confirmationToken"] = GenerateJwtToken(
@@ -497,17 +404,10 @@ public class AuthSteps(ScenarioContext scenario)
{ {
Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id) Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException( : throw new InvalidOperationException("registered user ID not found in scenario");
"registered user ID not found in scenario" string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
);
string? username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out string? user
)
? user ? user
: throw new InvalidOperationException( : throw new InvalidOperationException("registered username not found in scenario");
"registered username not found in scenario"
);
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET"); string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
scenario["confirmationToken"] = GenerateJwtToken( scenario["confirmationToken"] = GenerateJwtToken(
@@ -523,20 +423,12 @@ public class AuthSteps(ScenarioContext scenario)
{ {
Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id) Guid userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException( : throw new InvalidOperationException("registered user ID not found in scenario");
"registered user ID not found in scenario" string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
);
string? username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out string? user
)
? user ? user
: throw new InvalidOperationException( : throw new InvalidOperationException("registered username not found in scenario");
"registered username not found in scenario"
);
const string wrongSecret = const string wrongSecret = "wrong-confirmation-secret-that-is-very-long-1234567890";
"wrong-confirmation-secret-that-is-very-long-1234567890";
scenario["confirmationToken"] = GenerateJwtToken( scenario["confirmationToken"] = GenerateJwtToken(
userId, userId,
username, username,
@@ -545,9 +437,7 @@ public class AuthSteps(ScenarioContext scenario)
); );
} }
[When( [When("I submit a request to a protected endpoint with a valid access token")]
"I submit a request to a protected endpoint with a valid access token"
)]
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken() public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
@@ -555,12 +445,9 @@ public class AuthSteps(ScenarioContext scenario)
? t ? t
: "invalid-token"; : "invalid-token";
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/protected")
HttpMethod.Get,
"/api/protected"
)
{ {
Headers = { { "Authorization", $"Bearer {token}" } } Headers = { { "Authorization", $"Bearer {token}" } },
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -569,18 +456,13 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
} }
[When( [When("I submit a request to a protected endpoint with an invalid access token")]
"I submit a request to a protected endpoint with an invalid access token"
)]
public async Task WhenISubmitARequestToAProtectedEndpointWithAnInvalidAccessToken() public async Task WhenISubmitARequestToAProtectedEndpointWithAnInvalidAccessToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/protected")
HttpMethod.Get,
"/api/protected"
)
{ {
Headers = { { "Authorization", "Bearer invalid-token-format" } } Headers = { { "Authorization", "Bearer invalid-token-format" } },
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -673,16 +555,9 @@ public class AuthSteps(ScenarioContext scenario)
: "valid-refresh-token"; : "valid-refresh-token";
string body = JsonSerializer.Serialize(new { refreshToken = token }); string body = JsonSerializer.Serialize(new { refreshToken = token });
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
HttpMethod.Post,
"/api/auth/refresh"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -695,20 +570,11 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken() public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string body = JsonSerializer.Serialize( string body = JsonSerializer.Serialize(new { refreshToken = "invalid-refresh-token" });
new { refreshToken = "invalid-refresh-token" }
);
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
HttpMethod.Post,
"/api/auth/refresh"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -722,20 +588,11 @@ public class AuthSteps(ScenarioContext scenario)
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
// Use an expired token // Use an expired token
string body = JsonSerializer.Serialize( string body = JsonSerializer.Serialize(new { refreshToken = "expired-refresh-token" });
new { refreshToken = "expired-refresh-token" }
);
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
HttpMethod.Post,
"/api/auth/refresh"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -750,16 +607,9 @@ public class AuthSteps(ScenarioContext scenario)
HttpClient client = GetClient(); HttpClient client = GetClient();
string body = JsonSerializer.Serialize(new { }); string body = JsonSerializer.Serialize(new { });
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
HttpMethod.Post,
"/api/auth/refresh"
)
{ {
Content = new StringContent( Content = new StringContent(body, Encoding.UTF8, "application/json"),
body,
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -772,16 +622,9 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestUsingAGETRequest() public async Task WhenISubmitARefreshTokenRequestUsingAGETRequest()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/auth/refresh")
HttpMethod.Get,
"/api/auth/refresh"
)
{ {
Content = new StringContent( Content = new StringContent("{}", Encoding.UTF8, "application/json"),
"{}",
Encoding.UTF8,
"application/json"
)
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -795,10 +638,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithoutAnAccessToken() public async Task WhenISubmitARequestToAProtectedEndpointWithoutAnAccessToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/protected");
HttpMethod.Get,
"/api/protected"
);
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
@@ -830,12 +670,9 @@ public class AuthSteps(ScenarioContext scenario)
? t ? t
: "expired-token"; : "expired-token";
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/protected")
HttpMethod.Get,
"/api/protected"
)
{ {
Headers = { { "Authorization", $"Bearer {token}" } } Headers = { { "Authorization", $"Bearer {token}" } },
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -852,12 +689,9 @@ public class AuthSteps(ScenarioContext scenario)
? t ? t
: "tampered-token"; : "tampered-token";
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/protected")
HttpMethod.Get,
"/api/protected"
)
{ {
Headers = { { "Authorization", $"Bearer {token}" } } Headers = { { "Authorization", $"Bearer {token}" } },
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -876,12 +710,9 @@ public class AuthSteps(ScenarioContext scenario)
? t ? t
: "refresh-token"; : "refresh-token";
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/protected")
HttpMethod.Get,
"/api/protected"
)
{ {
Headers = { { "Authorization", $"Bearer {token}" } } Headers = { { "Authorization", $"Bearer {token}" } },
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -991,12 +822,9 @@ public class AuthSteps(ScenarioContext scenario)
? t ? t
: "confirmation-token"; : "confirmation-token";
HttpRequestMessage requestMessage = new( HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/protected")
HttpMethod.Get,
"/api/protected"
)
{ {
Headers = { { "Authorization", $"Bearer {token}" } } Headers = { { "Authorization", $"Bearer {token}" } },
}; };
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
@@ -1049,54 +877,30 @@ public class AuthSteps(ScenarioContext scenario)
[Then("the response JSON should have a new access token")] [Then("the response JSON should have a new access token")]
public void ThenTheResponseJsonShouldHaveANewAccessToken() public void ThenTheResponseJsonShouldHaveANewAccessToken()
{ {
scenario scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should()
.BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload"); JsonElement payload = doc.RootElement.GetProperty("payload");
string accessToken = ParseTokenFromPayload( string accessToken = ParseTokenFromPayload(payload, "accessToken", "AccessToken");
payload,
"accessToken",
"AccessToken"
);
accessToken.Should().NotBeNullOrWhiteSpace(); accessToken.Should().NotBeNullOrWhiteSpace();
if ( if (scenario.TryGetValue<string>(PreviousAccessTokenKey, out string? previousAccessToken))
scenario.TryGetValue<string>(
PreviousAccessTokenKey,
out string? previousAccessToken
)
)
accessToken.Should().NotBe(previousAccessToken); accessToken.Should().NotBe(previousAccessToken);
} }
[Then("the response JSON should have a new refresh token")] [Then("the response JSON should have a new refresh token")]
public void ThenTheResponseJsonShouldHaveANewRefreshToken() public void ThenTheResponseJsonShouldHaveANewRefreshToken()
{ {
scenario scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should()
.BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload"); JsonElement payload = doc.RootElement.GetProperty("payload");
string refreshToken = ParseTokenFromPayload( string refreshToken = ParseTokenFromPayload(payload, "refreshToken", "RefreshToken");
payload,
"refreshToken",
"RefreshToken"
);
refreshToken.Should().NotBeNullOrWhiteSpace(); refreshToken.Should().NotBeNullOrWhiteSpace();
if ( if (scenario.TryGetValue<string>(PreviousRefreshTokenKey, out string? previousRefreshToken))
scenario.TryGetValue<string>(
PreviousRefreshTokenKey,
out string? previousRefreshToken
)
)
refreshToken.Should().NotBe(previousRefreshToken); refreshToken.Should().NotBe(previousRefreshToken);
} }

View File

@@ -20,7 +20,8 @@ public class TestApiFactory : WebApplicationFactory<Program>
d.ServiceType == typeof(IEmailProvider) d.ServiceType == typeof(IEmailProvider)
); );
if (emailProviderDescriptor != null) services.Remove(emailProviderDescriptor); if (emailProviderDescriptor != null)
services.Remove(emailProviderDescriptor);
services.AddScoped<IEmailProvider, MockEmailProvider>(); services.AddScoped<IEmailProvider, MockEmailProvider>();
@@ -29,7 +30,8 @@ public class TestApiFactory : WebApplicationFactory<Program>
d.ServiceType == typeof(IEmailDispatcher) d.ServiceType == typeof(IEmailDispatcher)
); );
if (emailDispatcherDescriptor != null) services.Remove(emailDispatcherDescriptor); if (emailDispatcherDescriptor != null)
services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>(); services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
}); });

View File

@@ -9,11 +9,11 @@
</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">

View File

@@ -35,21 +35,25 @@ public static class Program
/// </exception> /// </exception>
private static string BuildConnectionString(string? databaseName = null) private static string BuildConnectionString(string? databaseName = null)
{ {
string 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");
string 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");
string 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");
string 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");
string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate =
?? "True"; Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
SqlConnectionStringBuilder builder = new() SqlConnectionStringBuilder builder = new()
{ {
@@ -58,7 +62,7 @@ public static class Program
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate), TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true Encrypt = true,
}; };
return builder.ConnectionString; return builder.ConnectionString;
@@ -99,9 +103,10 @@ public static class Program
// First, set the database to single user mode to close all connections // First, set the database to single user mode to close all connections
SqlCommand setModeCommand = new( 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
);
try try
{ {
setModeCommand.ExecuteNonQuery(); setModeCommand.ExecuteNonQuery();
@@ -132,7 +137,8 @@ public static class Program
} }
finally finally
{ {
if (myConn.State == ConnectionState.Open) myConn.Close(); if (myConn.State == ConnectionState.Open)
myConn.Close();
} }
return true; return true;
@@ -166,7 +172,8 @@ public static class Program
} }
finally finally
{ {
if (myConn.State == ConnectionState.Open) myConn.Close(); if (myConn.State == ConnectionState.Open)
myConn.Close();
} }
return true; return true;

View File

@@ -8,16 +8,13 @@
</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" Version="1.3.1" />
Include="Konscious.Security.Cryptography.Argon2" <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
Version="1.3.1" <PackageReference Include="dbup" Version="5.0.41" />
/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4"/>
<PackageReference Include="dbup" Version="5.0.41"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -15,24 +15,22 @@ namespace Database.Seed;
internal class LocationSeeder : ISeeder internal class LocationSeeder : ISeeder
{ {
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary> /// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
private static readonly IReadOnlyList<( private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
string CountryName,
string CountryCode
)> Countries =
[ [
("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,
get; string StateProvinceCode,
} = string CountryCode
)> States { get; } =
[ [
("Alabama", "US-AL", "US"), ("Alabama", "US-AL", "US"),
("Alaska", "US-AK", "US"), ("Alaska", "US-AK", "US"),
@@ -134,7 +132,7 @@ 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>
@@ -255,7 +253,7 @@ 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>
@@ -269,9 +267,7 @@ internal class LocationSeeder : ISeeder
foreach ((string countryName, string countryCode) in Countries) foreach ((string countryName, string countryCode) in Countries)
await CreateCountryAsync(connection, countryName, countryCode); await CreateCountryAsync(connection, countryName, countryCode);
foreach ( foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
(string stateProvinceName, string stateProvinceCode, string countryCode) in States
)
await CreateStateProvinceAsync( await CreateStateProvinceAsync(
connection, connection,
stateProvinceName, stateProvinceName,
@@ -294,10 +290,7 @@ internal class LocationSeeder : ISeeder
string countryCode string countryCode
) )
{ {
await using SqlCommand command = new( await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
"dbo.USP_CreateCountry",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CountryName", countryName); command.Parameters.AddWithValue("@CountryName", countryName);
command.Parameters.AddWithValue("@ISO3166_1", countryCode); command.Parameters.AddWithValue("@ISO3166_1", countryCode);
@@ -318,15 +311,9 @@ internal class LocationSeeder : ISeeder
string countryCode string countryCode
) )
{ {
await using SqlCommand command = new( await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
"dbo.USP_CreateStateProvince",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue( command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
"@StateProvinceName",
stateProvinceName
);
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode); command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
command.Parameters.AddWithValue("@CountryCode", countryCode); command.Parameters.AddWithValue("@CountryCode", countryCode);
@@ -344,16 +331,10 @@ internal class LocationSeeder : ISeeder
string stateProvinceCode string stateProvinceCode
) )
{ {
await using SqlCommand command = new( await using SqlCommand command = new("dbo.USP_CreateCity", connection);
"dbo.USP_CreateCity",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CityName", cityName); command.Parameters.AddWithValue("@CityName", cityName);
command.Parameters.AddWithValue( command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
"@StateProvinceCode",
stateProvinceCode
);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }

View File

@@ -17,20 +17,24 @@ using Microsoft.Data.SqlClient;
/// </exception> /// </exception>
string BuildConnectionString() string BuildConnectionString()
{ {
string 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");
string 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");
string 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");
string 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");
string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate =
?? "True"; Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
SqlConnectionStringBuilder builder = new() SqlConnectionStringBuilder builder = new()
{ {
@@ -39,13 +43,12 @@ string BuildConnectionString()
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate), TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true Encrypt = true,
}; };
return builder.ConnectionString; return builder.ConnectionString;
} }
try try
{ {
string connectionString = BuildConnectionString(); string connectionString = BuildConnectionString();
@@ -74,17 +77,14 @@ try
connection = null; connection = null;
} }
if (connection == null) throw new Exception($"Failed to connect to database after {maxRetries} attempts."); if (connection == null)
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
Console.WriteLine("Starting seeding..."); Console.WriteLine("Starting seeding...");
using (connection) using (connection)
{ {
ISeeder[] seeders = ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
[
new LocationSeeder(),
new UserSeeder()
];
foreach (ISeeder seeder in seeders) foreach (ISeeder seeder in seeders)
{ {

View File

@@ -19,10 +19,7 @@ namespace Database.Seed;
internal class UserSeeder : ISeeder internal class UserSeeder : ISeeder
{ {
/// <summary>The first/last name pairs used to generate seed user accounts.</summary> /// <summary>The first/last name pairs used to generate seed user accounts.</summary>
private static readonly IReadOnlyList<( private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
string FirstName,
string LastName
)> SeedNames =
[ [
("Aarya", "Mathews"), ("Aarya", "Mathews"),
("Aiden", "Wells"), ("Aiden", "Wells"),
@@ -123,7 +120,7 @@ internal class UserSeeder : ISeeder
("Zara", "Wilkinson"), ("Zara", "Wilkinson"),
("Zaria", "Gibson"), ("Zaria", "Gibson"),
("Zion", "Watkins"), ("Zion", "Watkins"),
("Zoie", "Armstrong") ("Zoie", "Armstrong"),
]; ];
/// <summary> /// <summary>
@@ -168,14 +165,9 @@ internal class UserSeeder : ISeeder
DateTime 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(64, 10, 10);
64,
10,
10
);
string hash = GeneratePasswordHash(pwd); string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential) // register the user (creates account + credential)
Guid id = await RegisterUserAsync( Guid id = await RegisterUserAsync(
connection, connection,
@@ -189,9 +181,9 @@ internal class UserSeeder : ISeeder
createdUsers++; createdUsers++;
createdCredentials++; createdCredentials++;
// add user verification // add user verification
if (await HasUserVerificationAsync(connection, id)) continue; if (await HasUserVerificationAsync(connection, id))
continue;
await AddUserVerificationAsync(connection, id); await AddUserVerificationAsync(connection, id);
createdVerifications++; createdVerifications++;
@@ -227,7 +219,6 @@ internal class UserSeeder : ISeeder
await using SqlCommand command = new("dbo.USP_RegisterUser", connection); await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username; command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName; command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName; command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
@@ -237,7 +228,6 @@ internal class UserSeeder : ISeeder
object? result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return (Guid)result!; return (Guid)result!;
} }
@@ -257,7 +247,7 @@ internal class UserSeeder : ISeeder
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);
@@ -288,15 +278,9 @@ internal class UserSeeder : ISeeder
/// <param name="connection">An open connection to the target database.</param> /// <param name="connection">An open connection to the target database.</param>
/// <param name="userAccountId">The identifier of the user account to verify.</param> /// <param name="userAccountId">The identifier of the user account to verify.</param>
/// <returns>A task that completes when the verification record has been created.</returns> /// <returns>A task that completes when the verification record has been created.</returns>
private static async Task AddUserVerificationAsync( private static async Task AddUserVerificationAsync(SqlConnection connection, Guid userAccountId)
SqlConnection connection,
Guid userAccountId
)
{ {
await using SqlCommand command = new( await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
"dbo.USP_CreateUserVerification",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountID_", userAccountId); command.Parameters.AddWithValue("@UserAccountID_", userAccountId);

View File

@@ -1,9 +1,7 @@
<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>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -30,7 +30,7 @@ public class ConfirmUserHandlerTests
{ {
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsPrincipal principal = new(new ClaimsIdentity(claims)); ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
return new ValidatedToken(userId, username, principal); return new ValidatedToken(userId, username, principal);
@@ -46,10 +46,15 @@ public class ConfirmUserHandlerTests
ValidatedToken validatedToken = MakeValidatedToken(userId, username); ValidatedToken validatedToken = MakeValidatedToken(userId, username);
UserAccount userAccount = new() { 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);
ConfirmationPayload 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);
@@ -64,7 +69,8 @@ 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"));
Func<Task<ConfirmationPayload>> 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>();
} }
@@ -79,10 +85,18 @@ public class ConfirmUserHandlerTests
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.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);
Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None); Func<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle(
new ConfirmUserCommand(confirmationToken),
CancellationToken.None
);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*"); await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
} }
} }

View File

@@ -21,7 +21,10 @@ public class RefreshTokenHandlerTests
.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"));
LoginPayload result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None); LoginPayload result = await handler.Handle(
new RefreshTokenCommand("old-refresh-token"),
CancellationToken.None
);
result.UserAccountId.Should().Be(userId); result.UserAccountId.Should().Be(userId);
result.Username.Should().Be("testuser"); result.Username.Should().Be("testuser");

View File

@@ -35,9 +35,19 @@ 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"
)
{ {
return new RegisterUserCommand(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]
@@ -47,14 +57,27 @@ public class RegisterUserHandlerTests
const string hashedPassword = "hashed_password_value"; const string hashedPassword = "hashed_password_value";
Guid expectedUserId = Guid.NewGuid(); Guid expectedUserId = Guid.NewGuid();
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null); _authRepoMock
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null); .Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword); _passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock _authRepoMock
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, .Setup(x =>
command.DateOfBirth, hashedPassword)) x.RegisterUserAsync(
.ReturnsAsync(new UserAccount command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
)
)
.ReturnsAsync(
new UserAccount
{ {
UserAccountId = expectedUserId, UserAccountId = expectedUserId,
Username = command.Username, Username = command.Username,
@@ -62,12 +85,18 @@ 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
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); .Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>())) .Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
_tokenServiceMock
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
.Returns("confirmation-token"); .Returns("confirmation-token");
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None); RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
@@ -89,18 +118,36 @@ public class RegisterUserHandlerTests
public async Task Handle_WithExistingUsername_ThrowsConflictException() public async Task Handle_WithExistingUsername_ThrowsConflictException()
{ {
RegisterUserCommand command = ValidCommand("existinguser"); RegisterUserCommand command = ValidCommand("existinguser");
UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Username = "existinguser" }; UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Username = "existinguser",
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser); _authRepoMock
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null); .Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync(existingUser);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
Func<Task<RegistrationPayload>> 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>(), x =>
It.IsAny<DateTime>(), It.IsAny<string>()), x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Never Times.Never
); );
} }
@@ -109,14 +156,23 @@ public class RegisterUserHandlerTests
public async Task Handle_WithExistingEmail_ThrowsConflictException() public async Task Handle_WithExistingEmail_ThrowsConflictException()
{ {
RegisterUserCommand command = ValidCommand(email: "existing@example.com"); RegisterUserCommand command = ValidCommand(email: "existing@example.com");
UserAccount existingUser = new() { 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);
Func<Task<RegistrationPayload>> 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");
} }
[Fact] [Fact]
@@ -125,24 +181,47 @@ public class RegisterUserHandlerTests
RegisterUserCommand 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
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null); .Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(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>(), .Setup(x =>
It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword)) 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
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); .Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
_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, x =>
command.DateOfBirth, hashedPassword), x.RegisterUserAsync(
command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
),
Times.Once Times.Once
); );
} }
@@ -152,19 +231,43 @@ public class RegisterUserHandlerTests
{ {
RegisterUserCommand command = ValidCommand(); RegisterUserCommand command = ValidCommand();
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null); _authRepoMock
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null); .Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed"); _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>(), .Setup(x =>
It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>())) x.RegisterUserAsync(
.ReturnsAsync(new UserAccount It.IsAny<string>(),
{ UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email }); It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
)
)
.ReturnsAsync(
new UserAccount
{
UserAccountId = Guid.NewGuid(),
Username = command.Username,
Email = command.Email,
}
);
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token"); _tokenServiceMock
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); .Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
_mediatorMock _mediatorMock
.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"));
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None); RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);

View File

@@ -17,15 +17,23 @@ public class ResendConfirmationEmailHandlerTests
public ResendConfirmationEmailHandlerTests() public ResendConfirmationEmailHandlerTests()
{ {
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _handler = new ResendConfirmationEmailHandler(
_mediatorMock.Object); _authRepositoryMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
} }
[Fact] [Fact]
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified() public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{ {
Guid userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
UserAccount user = new() { 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);
@@ -34,9 +42,15 @@ public class ResendConfirmationEmailHandlerTests
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify( _mediatorMock.Verify(
x => x.Send(It.Is<SendResendConfirmationEmailCommand>(c => x =>
c.FirstName == "Aaron" && c.Email == "aaron@example.com" && c.ConfirmationToken == "fresh-token" x.Send(
), It.IsAny<CancellationToken>()), It.Is<SendResendConfirmationEmailCommand>(c =>
c.FirstName == "Aaron"
&& c.Email == "aaron@example.com"
&& c.ConfirmationToken == "fresh-token"
),
It.IsAny<CancellationToken>()
),
Times.Once Times.Once
); );
} }
@@ -50,7 +64,11 @@ public class ResendConfirmationEmailHandlerTests
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify( _mediatorMock.Verify(
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()), x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never Times.Never
); );
} }
@@ -67,7 +85,11 @@ public class ResendConfirmationEmailHandlerTests
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify( _mediatorMock.Verify(
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()), x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never Times.Never
); );
} }

View File

@@ -8,19 +8,19 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2"/> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72"/> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0"/> <PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0"/> <PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit"/> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj"/> <ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -22,7 +22,11 @@ public class LoginHandlerTests
_authRepoMock = new Mock<IAuthRepository>(); _authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>(); _passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_handler = new LoginHandler(_authRepoMock.Object, _passwordInfraMock.Object, _tokenServiceMock.Object); _handler = new LoginHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object
);
} }
[Fact] [Fact]
@@ -38,7 +42,7 @@ public class LoginHandlerTests
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),
}; };
UserCredential userCredential = new() UserCredential userCredential = new()
@@ -46,16 +50,27 @@ public class LoginHandlerTests
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);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential); _authRepoMock
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(true); .Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token"); .ReturnsAsync(userCredential);
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); _passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
LoginPayload 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);
@@ -68,12 +83,18 @@ public class LoginHandlerTests
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException() public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
{ {
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);
Func<Task<LoginPayload>> 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
);
} }
[Fact] [Fact]
@@ -84,13 +105,20 @@ public class LoginHandlerTests
UserAccount userAccount = new() { 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)) _authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync((UserCredential?)null); .ReturnsAsync((UserCredential?)null);
Func<Task<LoginPayload>> 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()
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never); .ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
} }
[Fact] [Fact]
@@ -99,14 +127,29 @@ public class LoginHandlerTests
const string username = "RCarnap"; const string username = "RCarnap";
Guid userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username }; UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
UserCredential userCredential = new() { 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
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false); .Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None); Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(
new LoginQuery(username, "wrong-password"),
CancellationToken.None
);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password."); await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
} }
} }

View File

@@ -18,8 +18,7 @@ public class AuthRepositoryTests
Guid expectedUserId = Guid.NewGuid(); Guid expectedUserId = Guid.NewGuid();
MockDbConnection conn = new(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser") conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
.ReturnsScalar(expectedUserId);
// Mock the subsequent read for the newly created user by id // Mock the subsequent read for the newly created user by id
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById") conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
@@ -132,9 +131,7 @@ public class AuthRepositoryTests
Guid userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
MockDbConnection conn = new(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable( .ReturnsTable(
MockTable MockTable
.WithColumns( .WithColumns(
@@ -175,9 +172,7 @@ public class AuthRepositoryTests
{ {
MockDbConnection conn = new(); 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());
AuthRepository repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
@@ -193,9 +188,7 @@ public class AuthRepositoryTests
Guid credentialId = Guid.NewGuid(); Guid credentialId = Guid.NewGuid();
MockDbConnection conn = new(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
.ReturnsTable( .ReturnsTable(
MockTable MockTable
.WithColumns( .WithColumns(
@@ -205,13 +198,7 @@ public class AuthRepositoryTests
("CreatedAt", typeof(DateTime)), ("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
) )
.AddRow( .AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
credentialId,
userId,
"hashed_password_value",
DateTime.UtcNow,
null
)
); );
AuthRepository repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
@@ -229,9 +216,7 @@ public class AuthRepositoryTests
Guid userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
MockDbConnection conn = new(); 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());
AuthRepository repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
@@ -247,14 +232,12 @@ public class AuthRepositoryTests
string newPasswordHash = "new_hashed_password"; string newPasswordHash = "new_hashed_password";
MockDbConnection conn = new(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential") conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
.ReturnsScalar(1);
AuthRepository repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
// Should not throw // Should not throw
Func<Task> act = async () => Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync(); await act.Should().NotThrowAsync();
} }
} }

View File

@@ -22,15 +22,20 @@ public class TokenServiceRefreshTests
_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(
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); "ACCESS_TOKEN_SECRET",
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
); );
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
} }
[Fact] [Fact]
@@ -45,7 +50,7 @@ public class TokenServiceRefreshTests
{ {
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -58,7 +63,7 @@ public class TokenServiceRefreshTests
FirstName = "Test", FirstName = "Test",
LastName = "User", LastName = "User",
Email = "test@example.com", Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1) DateOfBirth = new DateTime(1990, 1, 1),
}; };
// Mock the validation of refresh token // Mock the validation of refresh token
@@ -69,11 +74,11 @@ public class TokenServiceRefreshTests
// Mock the generation of new tokens // Mock the generation of new tokens
_tokenInfraMock _tokenInfraMock
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>())) .Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"); .Returns(
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
);
_authRepositoryMock _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
.Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync(userAccount);
// Act // Act
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken); RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
@@ -85,14 +90,17 @@ public class TokenServiceRefreshTests
result.AccessToken.Should().NotBeEmpty(); result.AccessToken.Should().NotBeEmpty();
result.RefreshToken.Should().NotBeEmpty(); result.RefreshToken.Should().NotBeEmpty();
_authRepositoryMock.Verify( _authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
x => x.GetUserByIdAsync(userId),
Times.Once
);
// Verify tokens were generated (called twice - once for access, once for refresh) // Verify tokens were generated (called twice - once for access, once for refresh)
_tokenInfraMock.Verify( _tokenInfraMock.Verify(
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()), x =>
x.GenerateJwt(
It.IsAny<Guid>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Exactly(2) Times.Exactly(2)
); );
} }
@@ -108,9 +116,10 @@ public class TokenServiceRefreshTests
.ThrowsAsync(new UnauthorizedException("Invalid refresh token")); .ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.RefreshTokenAsync(invalidToken) .Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -124,9 +133,10 @@ public class TokenServiceRefreshTests
.ThrowsAsync(new UnauthorizedException("Refresh token has expired")); .ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.RefreshTokenAsync(expiredToken) .Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -141,7 +151,7 @@ public class TokenServiceRefreshTests
{ {
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -151,14 +161,13 @@ public class TokenServiceRefreshTests
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
_authRepositoryMock _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
.Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync((UserAccount?)null);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.RefreshTokenAsync(refreshToken) .Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*"); .WithMessage("*User account not found*");
} }
} }

View File

@@ -21,15 +21,20 @@ public class TokenServiceValidationTests
_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(
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); "ACCESS_TOKEN_SECRET",
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
); );
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
} }
[Fact] [Fact]
@@ -44,7 +49,7 @@ public class TokenServiceValidationTests
{ {
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -55,15 +60,17 @@ public class TokenServiceValidationTests
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
ValidatedToken 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]
@@ -78,7 +85,7 @@ public class TokenServiceValidationTests
{ {
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -89,8 +96,7 @@ public class TokenServiceValidationTests
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
ValidatedToken result = ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
await _tokenService.ValidateRefreshTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -110,7 +116,7 @@ public class TokenServiceValidationTests
{ {
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -121,8 +127,7 @@ public class TokenServiceValidationTests
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
ValidatedToken result = ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
await _tokenService.ValidateConfirmationTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -141,9 +146,10 @@ public class TokenServiceValidationTests
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -154,14 +160,13 @@ public class TokenServiceValidationTests
_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
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -175,7 +180,7 @@ public class TokenServiceValidationTests
List<Claim> claims = new() List<Claim> claims = new()
{ {
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -186,9 +191,10 @@ public class TokenServiceValidationTests
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*"); .WithMessage("*missing required claims*");
} }
@@ -203,7 +209,7 @@ public class TokenServiceValidationTests
List<Claim> claims = new() List<Claim> claims = new()
{ {
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -214,9 +220,10 @@ public class TokenServiceValidationTests
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*"); .WithMessage("*missing required claims*");
} }
@@ -232,7 +239,7 @@ public class TokenServiceValidationTests
{ {
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"), new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new Claim(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
ClaimsIdentity claimsIdentity = new(claims); ClaimsIdentity claimsIdentity = new(claims);
@@ -243,9 +250,10 @@ public class TokenServiceValidationTests
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*"); .WithMessage("*malformed user ID*");
} }
@@ -260,9 +268,10 @@ public class TokenServiceValidationTests
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateRefreshTokenAsync(token) .Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -276,8 +285,9 @@ public class TokenServiceValidationTests
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateConfirmationTokenAsync(token) .Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
} }

View File

@@ -13,21 +13,25 @@ namespace Features.Auth.Commands.ConfirmUser;
/// </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>
public class ConfirmUserHandler( public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
IAuthRepository authRepository, : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
ITokenService tokenService
) : 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
)
{ {
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token); ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(
request.Token
);
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId); UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
if (user == null) throw new UnauthorizedException("User account not found"); if (user == null)
throw new UnauthorizedException("User account not found");
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow); return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
} }

View File

@@ -12,10 +12,17 @@ namespace Features.Auth.Commands.RefreshToken;
public class RefreshTokenHandler(ITokenService tokenService) public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload> : IRequestHandler<RefreshTokenCommand, LoginPayload>
{ {
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken) public async Task<LoginPayload> Handle(
RefreshTokenCommand request,
CancellationToken cancellationToken
)
{ {
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken); RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, return new LoginPayload(
result.AccessToken); result.UserAccount.UserAccountId,
result.UserAccount.Username,
result.RefreshToken,
result.AccessToken
);
} }
} }

View File

@@ -12,8 +12,6 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
/// </summary> /// </summary>
public RefreshTokenValidator() public RefreshTokenValidator()
{ {
RuleFor(x => x.RefreshToken) RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
.NotEmpty()
.WithMessage("Refresh token is required");
} }
} }

View File

@@ -28,7 +28,10 @@ public class RegisterUserHandler(
/// <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);
@@ -48,14 +51,23 @@ public class RegisterUserHandler(
string 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(
false); createdUser.UserAccountId,
createdUser.Username,
string.Empty,
string.Empty,
false
);
bool emailSent = false; bool emailSent = false;
try try
{ {
await mediator.Send( await mediator.Send(
new SendRegistrationEmailCommand(createdUser.FirstName, createdUser.Email, confirmationToken), new SendRegistrationEmailCommand(
createdUser.FirstName,
createdUser.Email,
confirmationToken
),
cancellationToken cancellationToken
); );
emailSent = true; emailSent = true;
@@ -67,8 +79,13 @@ public class RegisterUserHandler(
// ignored // ignored
} }
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, return new RegistrationPayload(
emailSent); createdUser.UserAccountId,
createdUser.Username,
refreshToken,
accessToken,
emailSent
);
} }
/// <exception cref="ConflictException"> /// <exception cref="ConflictException">

View File

@@ -61,8 +61,6 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
.Matches("[0-9]") .Matches("[0-9]")
.WithMessage("Password must contain at least one number") .WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]") .Matches("[^a-zA-Z0-9]")
.WithMessage( .WithMessage("Password must contain at least one special character");
"Password must contain at least one special character"
);
} }
} }

View File

@@ -23,12 +23,17 @@ public class ResendConfirmationEmailHandler(
IMediator mediator IMediator mediator
) : IRequestHandler<ResendConfirmationEmailCommand> ) : IRequestHandler<ResendConfirmationEmailCommand>
{ {
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken) public async Task Handle(
ResendConfirmationEmailCommand request,
CancellationToken cancellationToken
)
{ {
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId); UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null) return; // Silent return to prevent user enumeration if (user == null)
return; // Silent return to prevent user enumeration
if (await authRepository.IsUserVerifiedAsync(request.UserId)) return; // Already confirmed, no-op if (await authRepository.IsUserVerifiedAsync(request.UserId))
return; // Already confirmed, no-op
string confirmationToken = tokenService.GenerateConfirmationToken(user); string confirmationToken = tokenService.GenerateConfirmationToken(user);
await mediator.Send( await mediator.Send(

View File

@@ -40,11 +40,14 @@ public class AuthController(IMediator mediator) : ControllerBase
) )
{ {
RegistrationPayload payload = await mediator.Send(command); RegistrationPayload payload = await mediator.Send(command);
return Created("/", new ResponseBody<RegistrationPayload> return Created(
"/",
new ResponseBody<RegistrationPayload>
{ {
Message = "User registered successfully.", Message = "User registered successfully.",
Payload = payload Payload = payload,
}); }
);
} }
/// <summary> /// <summary>
@@ -61,11 +64,13 @@ public class AuthController(IMediator mediator) : ControllerBase
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query) public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
{ {
LoginPayload payload = await mediator.Send(query); LoginPayload payload = await mediator.Send(query);
return Ok(new ResponseBody<LoginPayload> return Ok(
new ResponseBody<LoginPayload>
{ {
Message = "Logged in successfully.", Message = "Logged in successfully.",
Payload = payload Payload = payload,
}); }
);
} }
/// <summary> /// <summary>
@@ -78,14 +83,18 @@ public class AuthController(IMediator mediator) : ControllerBase
/// <param name="token">The confirmation token supplied via the confirmation email link.</param> /// <param name="token">The confirmation token supplied via the confirmation email link.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")] [HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token) public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
[FromQuery] string token
)
{ {
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token)); ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
return Ok(new ResponseBody<ConfirmationPayload> return Ok(
new ResponseBody<ConfirmationPayload>
{ {
Message = "User with ID " + payload.UserAccountId + " is confirmed.", Message = "User with ID " + payload.UserAccountId + " is confirmed.",
Payload = payload Payload = payload,
}); }
);
} }
/// <summary> /// <summary>
@@ -119,10 +128,12 @@ public class AuthController(IMediator mediator) : ControllerBase
) )
{ {
LoginPayload payload = await mediator.Send(command); LoginPayload payload = await mediator.Send(command);
return Ok(new ResponseBody<LoginPayload> return Ok(
new ResponseBody<LoginPayload>
{ {
Message = "Token refreshed successfully.", Message = "Token refreshed successfully.",
Payload = payload Payload = payload,
}); }
);
} }
} }

View File

@@ -7,16 +7,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -81,8 +81,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
} }
} }
return await GetUserByIdAsync(userAccountId) ?? return await GetUserByIdAsync(userAccountId)
throw new Exception("Failed to retrieve newly registered user."); ?? throw new Exception("Failed to retrieve newly registered user.");
} }
/// <summary> /// <summary>
@@ -92,9 +92,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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<UserAccount?> GetUserByEmailAsync( public async Task<UserAccount?> GetUserByEmailAsync(string email)
string email
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -114,9 +112,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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<UserAccount?> GetUserByUsernameAsync( public async Task<UserAccount?> GetUserByUsernameAsync(string username)
string username
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -136,9 +132,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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>
/// <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<UserCredential?> GetActiveCredentialByUserAccountIdAsync( public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
Guid userAccountId
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -158,10 +152,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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>
/// <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 RotateCredentialAsync( public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
Guid userAccountId,
string newPasswordHash
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -180,9 +171,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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<UserAccount?> GetUserByIdAsync( public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
Guid userAccountId
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -207,15 +196,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Thrown when the database command fails for a reason other than /// Thrown when the database command fails for a reason other than
/// a duplicate verification record. /// a duplicate verification record.
/// </exception> /// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync( public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
Guid userAccountId
)
{ {
UserAccount? user = await GetUserByIdAsync(userAccountId); UserAccount? user = await GetUserByIdAsync(userAccountId);
if (user == null) return null; if (user == null)
return null;
// Idempotency: if already verified, treat as successful confirmation. // Idempotency: if already verified, treat as successful confirmation.
if (await IsUserVerifiedAsync(userAccountId)) return user; if (await IsUserVerifiedAsync(userAccountId))
return user;
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -270,15 +259,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return ex.Number == 2601 || ex.Number == 2627; return ex.Number == 2601 || ex.Number == 2627;
} }
/// <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 UserAccount MapToEntity( protected override UserAccount MapToEntity(DbDataReader reader)
DbDataReader reader
)
{ {
return new UserAccount return new UserAccount
{ {
@@ -292,9 +278,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
? null ? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
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 : (byte[])reader["Timer"],
? null
: (byte[])reader["Timer"]
}; };
} }
@@ -308,12 +292,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
{ {
UserCredential entity = new() 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
@@ -327,7 +309,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
"Timer", "Timer",
StringComparison.OrdinalIgnoreCase StringComparison.OrdinalIgnoreCase
) )
) ?? false; )
?? false;
if (hasTimer) if (hasTimer)
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
@@ -344,11 +327,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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, string name, object? value)
DbCommand command,
string name,
object? value
)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();
p.ParameterName = name; p.ParameterName = name;

View File

@@ -49,9 +49,7 @@ public interface IAuthRepository
/// </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>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync( Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
Guid userAccountId
);
/// <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.

View File

@@ -15,7 +15,7 @@ public enum TokenType
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>
@@ -32,11 +32,7 @@ public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Princ
/// <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>
/// <param name="AccessToken">The newly issued access token.</param> /// <param name="AccessToken">The newly issued access token.</param>
public record RefreshTokenResult( public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
/// <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" />.
@@ -85,7 +81,8 @@ public interface ITokenService
/// <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.

View File

@@ -29,25 +29,28 @@ public class TokenService : ITokenService
/// 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, IAuthRepository authRepository)
ITokenInfrastructure tokenInfrastructure,
IAuthRepository authRepository
)
{ {
_tokenInfrastructure = tokenInfrastructure; _tokenInfrastructure = tokenInfrastructure;
_authRepository = authRepository; _authRepository = authRepository;
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET") _accessTokenSecret =
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"ACCESS_TOKEN_SECRET environment variable is not set"); "ACCESS_TOKEN_SECRET environment variable is not set"
);
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET") _refreshTokenSecret =
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"REFRESH_TOKEN_SECRET environment variable is not set"); "REFRESH_TOKEN_SECRET environment variable is not set"
);
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET") _confirmationTokenSecret =
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set"); "CONFIRMATION_TOKEN_SECRET environment variable is not set"
);
} }
/// <summary> /// <summary>
@@ -59,7 +62,12 @@ public class TokenService : ITokenService
public string GenerateAccessToken(UserAccount user) public string GenerateAccessToken(UserAccount user)
{ {
DateTime 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>
@@ -70,8 +78,15 @@ public class TokenService : ITokenService
/// <returns>The signed refresh token string.</returns> /// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user) public string GenerateRefreshToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret); TokenServiceExpirationHours.RefreshTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_refreshTokenSecret
);
} }
/// <summary> /// <summary>
@@ -82,8 +97,15 @@ public class TokenService : ITokenService
/// <returns>The signed confirmation token string.</returns> /// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user) public string GenerateConfirmationToken(UserAccount user)
{ {
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret); TokenServiceExpirationHours.ConfirmationTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_confirmationTokenSecret
);
} }
/// <summary> /// <summary>
@@ -97,7 +119,8 @@ public class TokenService : ITokenService
/// <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");
@@ -112,7 +135,7 @@ public class TokenService : ITokenService
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"),
}; };
} }
@@ -189,7 +212,11 @@ public class TokenService : ITokenService
/// 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
{ {
@@ -199,7 +226,9 @@ public class TokenService : ITokenService
string? 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 Guid 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");

View File

@@ -19,24 +19,13 @@ public class CreateBreweryHandlerTests
private static CreateBreweryLocation ValidLocation() private static CreateBreweryLocation ValidLocation()
{ {
return new CreateBreweryLocation( return new CreateBreweryLocation(Guid.NewGuid(), "123 Main St", null, "12345", null);
Guid.NewGuid(),
"123 Main St",
null,
"12345",
null
);
} }
[Fact] [Fact]
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt() public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
{ {
CreateBreweryCommand command = new( CreateBreweryCommand command = new(Guid.NewGuid(), "MyBrew", "Desc", ValidLocation());
Guid.NewGuid(),
"MyBrew",
"Desc",
ValidLocation()
);
BreweryPost? persisted = null; BreweryPost? persisted = null;
_repoMock _repoMock

View File

@@ -96,7 +96,9 @@ public class UpdateBreweryHandlerTests
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
persisted!.Location.Should().NotBeNull(); persisted!.Location.Should().NotBeNull();
persisted.Location!.BreweryPostLocationId.Should().Be(locationCommand.BreweryPostLocationId); persisted
.Location!.BreweryPostLocationId.Should()
.Be(locationCommand.BreweryPostLocationId);
persisted.Location.BreweryPostId.Should().Be(command.BreweryPostId); persisted.Location.BreweryPostId.Should().Be(command.BreweryPostId);
persisted.Location.CityId.Should().Be(locationCommand.CityId); persisted.Location.CityId.Should().Be(locationCommand.CityId);
persisted.Location.AddressLine1.Should().Be(locationCommand.AddressLine1); persisted.Location.AddressLine1.Should().Be(locationCommand.AddressLine1);

View File

@@ -8,19 +8,19 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2"/> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72"/> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0"/> <PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0"/> <PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit"/> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj"/> <ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -20,10 +20,12 @@ public class GetAllBreweriesHandlerTests
[Fact] [Fact]
public async Task Handle_PassesLimitAndOffset_ToRepository() public async Task Handle_PassesLimitAndOffset_ToRepository()
{ {
_repoMock.Setup(r => r.GetAllAsync(10, 5)) _repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<BreweryPost>());
.ReturnsAsync(Array.Empty<BreweryPost>());
IEnumerable<BreweryDto> 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);
@@ -35,13 +37,18 @@ public class GetAllBreweriesHandlerTests
BreweryPost[] 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);
IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None); IEnumerable<BreweryDto> result = await _handler.Handle(
new GetAllBreweriesQuery(null, null),
CancellationToken.None
);
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId)); result
.Select(b => b.BreweryPostId)
.Should()
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
} }
} }

View File

@@ -21,10 +21,12 @@ public class GetBreweryByIdHandlerTests
public async Task Handle_ReturnsBrewery_WhenFound() public async Task Handle_ReturnsBrewery_WhenFound()
{ {
BreweryPost brewery = new() { 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);
BreweryDto? 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);
@@ -34,10 +36,12 @@ public class GetBreweryByIdHandlerTests
public async Task Handle_ReturnsNull_WhenNotFound() public async Task Handle_ReturnsNull_WhenNotFound()
{ {
Guid 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);
BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None); BreweryDto? result = await _handler.Handle(
new GetBreweryByIdQuery(id),
CancellationToken.None
);
result.Should().BeNull(); result.Should().BeNull();
} }

View File

@@ -81,8 +81,7 @@ public class BreweryRepositoryTests
public async Task CreateAsync_ExecutesSuccessfully() public async Task CreateAsync_ExecutesSuccessfully()
{ {
MockDbConnection conn = new(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery") conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery").ReturnsScalar(1);
.ReturnsScalar(1);
BreweryRepository repo = CreateRepo(conn); BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new() BreweryPost brewery = new()
{ {
@@ -97,8 +96,8 @@ public class BreweryRepositoryTests
CityId = Guid.NewGuid(), CityId = Guid.NewGuid(),
AddressLine1 = "123 Main St", AddressLine1 = "123 Main St",
PostalCode = "12345", PostalCode = "12345",
Coordinates = [0x00, 0x01] Coordinates = [0x00, 0x01],
} },
}; };
// Should not throw // Should not throw

View File

@@ -18,7 +18,10 @@ public class CreateBreweryHandler(IBreweryRepository repository)
/// <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
)
{ {
BreweryPost entity = new() BreweryPost entity = new()
{ {
@@ -34,8 +37,8 @@ public class CreateBreweryHandler(IBreweryRepository repository)
AddressLine1 = request.Location.AddressLine1, AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2, AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode, PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates Coordinates = request.Location.Coordinates,
} },
}; };
await repository.CreateAsync(entity); await repository.CreateAsync(entity);

View File

@@ -15,9 +15,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
/// </summary> /// </summary>
public CreateBreweryValidator() public CreateBreweryValidator()
{ {
RuleFor(x => x.PostedById) RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");
.NotEmpty()
.WithMessage("PostedById is required.");
RuleFor(x => x.BreweryName) RuleFor(x => x.BreweryName)
.NotEmpty() .NotEmpty()
@@ -31,9 +29,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
.MaximumLength(512) .MaximumLength(512)
.WithMessage("Description cannot exceed 512 characters."); .WithMessage("Description cannot exceed 512 characters.");
RuleFor(x => x.Location) RuleFor(x => x.Location).NotNull().WithMessage("Location is required.");
.NotNull()
.WithMessage("Location is required.");
RuleFor(x => x.Location.CityId) RuleFor(x => x.Location.CityId)
.NotEmpty() .NotEmpty()

View File

@@ -19,7 +19,10 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// <param name="request">The updated details of the brewery post.</param> /// <param name="request">The updated details of the brewery post.</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 updated brewery post.</returns> /// <returns>The updated brewery post.</returns>
public async Task<BreweryDto> Handle(UpdateBreweryCommand request, CancellationToken cancellationToken) public async Task<BreweryDto> Handle(
UpdateBreweryCommand request,
CancellationToken cancellationToken
)
{ {
BreweryPost entity = new() BreweryPost entity = new()
{ {
@@ -38,8 +41,8 @@ public class UpdateBreweryHandler(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.UpdateAsync(entity); await repository.UpdateAsync(entity);

View File

@@ -41,11 +41,13 @@ public class BreweryController(IMediator mediator) : ControllerBase
if (brewery is null) if (brewery is null)
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." }); return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
return Ok(new ResponseBody<BreweryDto> return Ok(
new ResponseBody<BreweryDto>
{ {
Message = "Brewery retrieved successfully.", Message = "Brewery retrieved successfully.",
Payload = brewery Payload = brewery,
}); }
);
} }
/// <summary> /// <summary>
@@ -59,14 +61,19 @@ public class BreweryController(IMediator mediator) : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll( public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] int? offset) [FromQuery] int? offset
)
{ {
IEnumerable<BreweryDto> breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset)); IEnumerable<BreweryDto> breweries = await mediator.Send(
return Ok(new ResponseBody<IEnumerable<BreweryDto>> new GetAllBreweriesQuery(limit, offset)
);
return Ok(
new ResponseBody<IEnumerable<BreweryDto>>
{ {
Message = "Breweries retrieved successfully.", Message = "Breweries retrieved successfully.",
Payload = breweries Payload = breweries,
}); }
);
} }
/// <summary> /// <summary>
@@ -78,14 +85,19 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <see cref="BreweryDto" />. /// <see cref="BreweryDto" />.
/// </returns> /// </returns>
[HttpPost] [HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command) public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
[FromBody] CreateBreweryCommand command
)
{ {
BreweryDto brewery = await mediator.Send(command); BreweryDto brewery = await mediator.Send(command);
return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody<BreweryDto> return Created(
$"/api/brewery/{brewery.BreweryPostId}",
new ResponseBody<BreweryDto>
{ {
Message = "Brewery created successfully.", Message = "Brewery created successfully.",
Payload = brewery Payload = brewery,
}); }
);
} }
/// <summary> /// <summary>
@@ -105,17 +117,22 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// when the route ID does not match the payload ID. /// when the route ID does not match the payload ID.
/// </returns> /// </returns>
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] UpdateBreweryCommand command) public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id,
[FromBody] UpdateBreweryCommand command
)
{ {
if (command.BreweryPostId != id) if (command.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." }); return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
BreweryDto brewery = await mediator.Send(command); BreweryDto brewery = await mediator.Send(command);
return Ok(new ResponseBody<BreweryDto> return Ok(
new ResponseBody<BreweryDto>
{ {
Message = "Brewery updated successfully.", Message = "Brewery updated successfully.",
Payload = brewery Payload = brewery,
}); }
);
} }
/// <summary> /// <summary>

View File

@@ -34,8 +34,8 @@ public static class BreweryDtoMapper
AddressLine1 = brewery.Location.AddressLine1, AddressLine1 = brewery.Location.AddressLine1,
AddressLine2 = brewery.Location.AddressLine2, AddressLine2 = brewery.Location.AddressLine2,
PostalCode = brewery.Location.PostalCode, PostalCode = brewery.Location.PostalCode,
Coordinates = brewery.Location.Coordinates Coordinates = brewery.Location.Coordinates,
} },
}; };
} }
} }

View File

@@ -7,13 +7,13 @@
</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="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -12,9 +12,15 @@ namespace Features.Breweries.Queries.GetAllBreweries;
public class GetAllBreweriesHandler(IBreweryRepository repository) public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>> : IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{ {
public async Task<IEnumerable<BreweryDto>> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken) public async Task<IEnumerable<BreweryDto>> Handle(
GetAllBreweriesQuery request,
CancellationToken cancellationToken
)
{ {
IEnumerable<BreweryPost> breweries = await repository.GetAllAsync(request.Limit, request.Offset); IEnumerable<BreweryPost> breweries = await repository.GetAllAsync(
request.Limit,
request.Offset
);
return breweries.Select(b => b.ToDto()); return breweries.Select(b => b.ToDto());
} }
} }

View File

@@ -12,7 +12,10 @@ namespace Features.Breweries.Queries.GetBreweryById;
public class GetBreweryByIdHandler(IBreweryRepository repository) public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?> : IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{ {
public async Task<BreweryDto?> Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken) public async Task<BreweryDto?> Handle(
GetBreweryByIdQuery request,
CancellationToken cancellationToken
)
{ {
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId); BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId);
return brewery?.ToDto(); return brewery?.ToDto();

View File

@@ -11,7 +11,8 @@ namespace Features.Breweries.Repository;
/// </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 BreweryRepository(ISqlConnectionFactory connectionFactory) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory), IBreweryRepository : Repository<BreweryPost>(connectionFactory),
IBreweryRepository
{ {
/// <summary> /// <summary>
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure. /// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
@@ -29,7 +30,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@BreweryPostID", id); AddParameter(command, "@BreweryPostID", id);
await using DbDataReader reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync()) return MapToEntity(reader); if (await reader.ReadAsync())
return MapToEntity(reader);
return null; return null;
} }
@@ -58,7 +60,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await using DbDataReader reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
List<BreweryPost> breweries = new(); List<BreweryPost> breweries = new();
while (await reader.ReadAsync()) breweries.Add(MapToEntity(reader)); while (await reader.ReadAsync())
breweries.Add(MapToEntity(reader));
return breweries; return breweries;
} }
@@ -121,7 +124,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
command.CommandText = "USP_CreateBrewery"; command.CommandText = "USP_CreateBrewery";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
if (brewery.Location is null) throw new ArgumentException("Location must be provided when creating a brewery."); if (brewery.Location is null)
throw new ArgumentException("Location must be provided when creating a brewery.");
AddParameter(command, "@BreweryName", brewery.BreweryName); AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description); AddParameter(command, "@Description", brewery.Description);
@@ -195,7 +199,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")), PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates"))
? null ? null
: reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates")) : reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates")),
}; };
brewery.Location = location; brewery.Location = location;
} }
@@ -215,11 +219,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <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. "@BreweryName").</param> /// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</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, string name, object? value)
DbCommand command,
string name,
object? value
)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();
p.ParameterName = name; p.ParameterName = name;

View File

@@ -8,18 +8,18 @@
</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" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit"/> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Emails\Features.Emails.csproj"/> <ProjectReference Include="..\Features.Emails\Features.Emails.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -14,6 +14,10 @@ public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
{ {
public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken) public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken)
{ {
return emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken); return emailDispatcher.SendRegistrationEmailAsync(
request.FirstName,
request.Email,
request.ConfirmationToken
);
} }
} }

View File

@@ -12,9 +12,15 @@ namespace Features.Emails.Commands.SendResendConfirmationEmail;
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher) public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
: IRequestHandler<SendResendConfirmationEmailCommand> : IRequestHandler<SendResendConfirmationEmailCommand>
{ {
public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken) public Task Handle(
SendResendConfirmationEmailCommand request,
CancellationToken cancellationToken
)
{ {
return emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email, return emailDispatcher.SendResendConfirmationEmailAsync(
request.ConfirmationToken); request.FirstName,
request.Email,
request.ConfirmationToken
);
} }
} }

View File

@@ -7,8 +7,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -29,36 +29,35 @@ public class EmailDispatcher(
/// Builds a confirmation link from the given token, renders the registration welcome email template, /// Builds a confirmation link from the given token, renders the registration welcome email template,
/// and sends it to the newly created user. /// and sends it to the newly created user.
/// </summary> /// </summary>
public async Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken) public async Task SendRegistrationEmailAsync(
string firstName,
string email,
string confirmationToken
)
{ {
string confirmationLink = string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
string emailHtml = string emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
firstName, firstName,
confirmationLink confirmationLink
); );
await emailProvider.SendAsync( await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
email,
"Welcome to The Biergarten App!",
emailHtml,
true
);
} }
/// <summary> /// <summary>
/// Builds a confirmation link from the given token, renders the resend-confirmation email template, /// Builds a confirmation link from the given token, renders the resend-confirmation email template,
/// and sends it to the user. /// and sends it to the user.
/// </summary> /// </summary>
public async Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken) public async Task SendResendConfirmationEmailAsync(
string firstName,
string email,
string confirmationToken
)
{ {
string confirmationLink = string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
string emailHtml = string emailHtml = await emailTemplateProvider.RenderResendConfirmationEmailAsync(
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
firstName, firstName,
confirmationLink confirmationLink
); );

View File

@@ -8,19 +8,19 @@
</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.UserManagement\Features.UserManagement.csproj"/> <ProjectReference Include="..\Features.UserManagement\Features.UserManagement.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -15,7 +15,10 @@ public class GetAllUsersHandlerTests
GetAllUsersHandler handler = new(repoMock.Object); GetAllUsersHandler handler = new(repoMock.Object);
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>()); repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
IEnumerable<UserAccount> result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None); IEnumerable<UserAccount> result = await handler.Handle(
new GetAllUsersQuery(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);

View File

@@ -23,7 +23,10 @@ public class GetUserByIdHandlerTests
UserAccount user = new() { UserAccountId = Guid.NewGuid(), Username = "test" }; UserAccount user = new() { UserAccountId = Guid.NewGuid(), Username = "test" };
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
UserAccount result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None); UserAccount result = await _handler.Handle(
new GetUserByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.Should().Be(user); result.Should().Be(user);
} }
@@ -34,7 +37,8 @@ public class GetUserByIdHandlerTests
Guid id = Guid.NewGuid(); Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null); _repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
Func<Task<UserAccount>> act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None); Func<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>(); await act.Should().ThrowAsync<NotFoundException>();
} }

View File

@@ -98,19 +98,14 @@ public class UserAccountRepositoryTests
UserAccountRepository repo = CreateRepo(conn); UserAccountRepository repo = CreateRepo(conn);
List<UserAccount> results = (await repo.GetAllAsync(null, null)).ToList(); List<UserAccount> results = (await repo.GetAllAsync(null, null)).ToList();
results.Should().HaveCount(2); results.Should().HaveCount(2);
results results.Select(r => r.Username).Should().BeEquivalentTo("a", "b");
.Select(r => r.Username)
.Should()
.BeEquivalentTo("a", "b");
} }
[Fact] [Fact]
public async Task GetByUsername_ReturnsRow() public async Task GetByUsername_ReturnsRow()
{ {
MockDbConnection conn = new(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable( .ReturnsTable(
MockTable MockTable
.WithColumns( .WithColumns(

View File

@@ -7,14 +7,14 @@
</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.Sql\Infrastructure.Sql.csproj"/> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -11,7 +11,10 @@ namespace Features.UserManagement.Queries.GetAllUsers;
public class GetAllUsersHandler(IUserAccountRepository repository) public class GetAllUsersHandler(IUserAccountRepository repository)
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>> : IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
{ {
public Task<IEnumerable<UserAccount>> Handle(GetAllUsersQuery request, CancellationToken cancellationToken) public Task<IEnumerable<UserAccount>> Handle(
GetAllUsersQuery request,
CancellationToken cancellationToken
)
{ {
return repository.GetAllAsync(request.Limit, request.Offset); return repository.GetAllAsync(request.Limit, request.Offset);
} }

View File

@@ -13,7 +13,10 @@ public class GetUserByIdHandler(IUserAccountRepository repository)
: IRequestHandler<GetUserByIdQuery, UserAccount> : IRequestHandler<GetUserByIdQuery, UserAccount>
{ {
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception> /// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<UserAccount> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) public async Task<UserAccount> Handle(
GetUserByIdQuery request,
CancellationToken cancellationToken
)
{ {
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId); UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
if (user is null) if (user is null)

View File

@@ -20,10 +20,7 @@ public interface IUserAccountRepository
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param> /// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param> /// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns> /// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns>
Task<IEnumerable<UserAccount>> GetAllAsync( Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
int? limit,
int? offset
);
/// <summary> /// <summary>
/// Updates an existing user account's details. /// Updates an existing user account's details.

View File

@@ -42,10 +42,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param> /// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns> /// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</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<IEnumerable<UserAccount>> GetAllAsync( public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
int? limit,
int? offset
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -61,7 +58,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
await using DbDataReader reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
List<UserAccount> users = new(); List<UserAccount> users = new();
while (await reader.ReadAsync()) users.Add(MapToEntity(reader)); while (await reader.ReadAsync())
users.Add(MapToEntity(reader));
return users; return users;
} }
@@ -111,9 +109,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <param name="username">The username to search for.</param> /// <param name="username">The username to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns> /// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</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<UserAccount?> GetByUsernameAsync( public async Task<UserAccount?> GetByUsernameAsync(string username)
string username
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -132,9 +128,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <param name="email">The email address to search for.</param> /// <param name="email">The email address to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns> /// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</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<UserAccount?> GetByEmailAsync( public async Task<UserAccount?> GetByEmailAsync(string email)
string email
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
@@ -152,9 +146,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// </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 UserAccount MapToEntity( protected override UserAccount MapToEntity(DbDataReader reader)
DbDataReader reader
)
{ {
return new UserAccount return new UserAccount
{ {
@@ -168,9 +160,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
? null ? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
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 : (byte[])reader["Timer"],
? null
: (byte[])reader["Timer"]
}; };
} }
@@ -181,11 +171,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <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, string name, object? value)
DbCommand command,
string name,
object? value
)
{ {
DbParameter p = command.CreateParameter(); DbParameter p = command.CreateParameter();
p.ParameterName = name; p.ParameterName = name;

View File

@@ -6,21 +6,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<SupportedPlatform Include="browser"/> <SupportedPlatform Include="browser" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.1" />
Include="Microsoft.AspNetCore.Components.Web"
Version="10.0.1"
/>
<PackageReference <PackageReference
Include="Microsoft.Extensions.DependencyInjection.Abstractions" Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.1" Version="10.0.1"
/> />
<PackageReference <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
Include="Microsoft.Extensions.Logging.Abstractions"
Version="10.0.1"
/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -14,10 +14,8 @@ namespace Infrastructure.Email.Templates.Rendering;
/// pipeline. /// pipeline.
/// </param> /// </param>
/// <param name="loggerFactory">The logger factory passed to the <see cref="HtmlRenderer" /> used to render components.</param> /// <param name="loggerFactory">The logger factory passed to the <see cref="HtmlRenderer" /> used to render components.</param>
public class EmailTemplateProvider( public class EmailTemplateProvider(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
IServiceProvider serviceProvider, : IEmailTemplateProvider
ILoggerFactory loggerFactory
) : IEmailTemplateProvider
{ {
/// <summary> /// <summary>
/// Renders the UserRegisteredEmail template with the specified parameters. /// Renders the UserRegisteredEmail template with the specified parameters.
@@ -33,7 +31,7 @@ public class EmailTemplateProvider(
Dictionary<string, object?> parameters = new() Dictionary<string, object?> parameters = new()
{ {
{ nameof(UserRegistration.Username), username }, { nameof(UserRegistration.Username), username },
{ nameof(UserRegistration.ConfirmationLink), confirmationLink } { nameof(UserRegistration.ConfirmationLink), confirmationLink },
}; };
return await RenderComponentAsync<UserRegistration>(parameters); return await RenderComponentAsync<UserRegistration>(parameters);
@@ -53,7 +51,7 @@ public class EmailTemplateProvider(
Dictionary<string, object?> parameters = new() Dictionary<string, object?> parameters = new()
{ {
{ nameof(ResendConfirmation.Username), username }, { nameof(ResendConfirmation.Username), username },
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink } { nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
}; };
return await RenderComponentAsync<ResendConfirmation>(parameters); return await RenderComponentAsync<ResendConfirmation>(parameters);
@@ -72,10 +70,7 @@ public class EmailTemplateProvider(
) )
where TComponent : IComponent where TComponent : IComponent
{ {
await using HtmlRenderer htmlRenderer = new( await using HtmlRenderer htmlRenderer = new(serviceProvider, loggerFactory);
serviceProvider,
loggerFactory
);
string html = await htmlRenderer.Dispatcher.InvokeAsync(async () => string html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{ {

View File

@@ -11,10 +11,7 @@ public interface IEmailTemplateProvider
/// <param name="username">The username to include in the email</param> /// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The email confirmation link</param> /// <param name="confirmationLink">The email confirmation link</param>
/// <returns>The rendered HTML string</returns> /// <returns>The rendered HTML string</returns>
Task<string> RenderUserRegisteredEmailAsync( Task<string> RenderUserRegisteredEmailAsync(string username, string confirmationLink);
string username,
string confirmationLink
);
/// <summary> /// <summary>
/// Renders the ResendConfirmation template with the specified parameters. /// Renders the ResendConfirmation template with the specified parameters.
@@ -22,8 +19,5 @@ public interface IEmailTemplateProvider
/// <param name="username">The username to include in the email</param> /// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The new confirmation link</param> /// <param name="confirmationLink">The new confirmation link</param>
/// <returns>The rendered HTML string</returns> /// <returns>The rendered HTML string</returns>
Task<string> RenderResendConfirmationEmailAsync( Task<string> RenderResendConfirmationEmailAsync(string username, string confirmationLink);
string username,
string confirmationLink
);
} }

View File

@@ -21,10 +21,5 @@ public interface IEmailProvider
/// <param name="subject">Email subject line</param> /// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param> /// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param> /// <param name="isHtml">Whether the body is HTML (default: true)</param>
Task SendAsync( Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true);
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
);
} }

View File

@@ -7,6 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.15.1"/> <PackageReference Include="MailKit" Version="4.15.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -58,22 +58,16 @@ public class SmtpEmailProvider : IEmailProvider
{ {
_host = _host =
Environment.GetEnvironmentVariable("SMTP_HOST") Environment.GetEnvironmentVariable("SMTP_HOST")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException("SMTP_HOST environment variable is not set");
"SMTP_HOST environment variable is not set"
);
string portString = string portString = Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587";
Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587";
if (!int.TryParse(portString, out _port)) if (!int.TryParse(portString, out _port))
throw new InvalidOperationException( throw new InvalidOperationException($"SMTP_PORT '{portString}' is not a valid integer");
$"SMTP_PORT '{portString}' is not a valid integer"
);
_username = Environment.GetEnvironmentVariable("SMTP_USERNAME"); _username = Environment.GetEnvironmentVariable("SMTP_USERNAME");
_password = Environment.GetEnvironmentVariable("SMTP_PASSWORD"); _password = Environment.GetEnvironmentVariable("SMTP_PASSWORD");
string useSslString = string useSslString = Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
_useSsl = bool.Parse(useSslString); _useSsl = bool.Parse(useSslString);
_fromEmail = _fromEmail =
@@ -82,9 +76,7 @@ public class SmtpEmailProvider : IEmailProvider
"SMTP_FROM_EMAIL environment variable is not set" "SMTP_FROM_EMAIL environment variable is not set"
); );
_fromName = _fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
Environment.GetEnvironmentVariable("SMTP_FROM_NAME")
?? "The Biergarten";
} }
/// <summary> /// <summary>
@@ -95,12 +87,7 @@ public class SmtpEmailProvider : IEmailProvider
/// <param name="body">Email body (HTML or plain text)</param> /// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param> /// <param name="isHtml">Whether the body is HTML (default: true)</param>
/// <exception cref="InvalidOperationException">Thrown when connecting, authenticating, or sending via SMTP fails.</exception> /// <exception cref="InvalidOperationException">Thrown when connecting, authenticating, or sending via SMTP fails.</exception>
public async Task SendAsync( public async Task SendAsync(string to, string subject, string body, bool isHtml = true)
string to,
string subject,
string body,
bool isHtml = true
)
{ {
await SendAsync([to], subject, body, isHtml); await SendAsync([to], subject, body, isHtml);
} }
@@ -128,7 +115,8 @@ public class SmtpEmailProvider : IEmailProvider
MimeMessage message = new(); MimeMessage message = new();
message.From.Add(new MailboxAddress(_fromName, _fromEmail)); message.From.Add(new MailboxAddress(_fromName, _fromEmail));
foreach (string recipient in to) message.To.Add(MailboxAddress.Parse(recipient)); foreach (string recipient in to)
message.To.Add(MailboxAddress.Parse(recipient));
message.Subject = subject; message.Subject = subject;
@@ -152,10 +140,7 @@ public class SmtpEmailProvider : IEmailProvider
await client.ConnectAsync(_host, _port, secureSocketOptions); await client.ConnectAsync(_host, _port, secureSocketOptions);
// Authenticate if credentials are provided // Authenticate if credentials are provided
if ( if (!string.IsNullOrEmpty(_username) && !string.IsNullOrEmpty(_password))
!string.IsNullOrEmpty(_username)
&& !string.IsNullOrEmpty(_password)
)
await client.AuthenticateAsync(_username, _password); await client.AuthenticateAsync(_username, _password);
await client.SendAsync(message); await client.SendAsync(message);
@@ -163,10 +148,7 @@ public class SmtpEmailProvider : IEmailProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
throw new InvalidOperationException( throw new InvalidOperationException($"Failed to send email: {ex.Message}", ex);
$"Failed to send email: {ex.Message}",
ex
);
} }
} }
} }

View File

@@ -15,12 +15,7 @@ public interface ITokenInfrastructure
/// <param name="expiry">The date and time at which the token expires.</param> /// <param name="expiry">The date and time at which the token expires.</param>
/// <param name="secret">The symmetric secret used to sign the token.</param> /// <param name="secret">The symmetric secret used to sign the token.</param>
/// <returns>The serialized, signed JWT string.</returns> /// <returns>The serialized, signed JWT string.</returns>
string GenerateJwt( string GenerateJwt(Guid userId, string username, DateTime expiry, string secret);
Guid userId,
string username,
DateTime expiry,
string secret
);
/// <summary> /// <summary>
/// Validates a JWT and returns the resulting claims principal. /// Validates a JWT and returns the resulting claims principal.

View File

@@ -7,17 +7,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.2.1" />
Include="Microsoft.IdentityModel.JsonWebTokens" <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
Version="8.2.1"
/>
<PackageReference
Include="System.IdentityModel.Tokens.Jwt"
Version="8.2.1"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -25,12 +25,7 @@ public class JwtInfrastructure : ITokenInfrastructure
/// <param name="expiry">The date and time at which the token expires.</param> /// <param name="expiry">The date and time at which the token expires.</param>
/// <param name="secret">The symmetric secret used to sign the token (encoded as UTF-8 bytes).</param> /// <param name="secret">The symmetric secret used to sign the token (encoded as UTF-8 bytes).</param>
/// <returns>The serialized, signed JWT string.</returns> /// <returns>The serialized, signed JWT string.</returns>
public string GenerateJwt( public string GenerateJwt(Guid userId, string username, DateTime expiry, string secret)
Guid userId,
string username,
DateTime expiry,
string secret
)
{ {
JsonWebTokenHandler handler = new(); JsonWebTokenHandler handler = new();
byte[] key = Encoding.UTF8.GetBytes(secret); byte[] key = Encoding.UTF8.GetBytes(secret);
@@ -46,7 +41,7 @@ public class JwtInfrastructure : ITokenInfrastructure
JwtRegisteredClaimNames.Exp, JwtRegisteredClaimNames.Exp,
new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString() new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()
), ),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
SecurityTokenDescriptor tokenDescriptor = new() SecurityTokenDescriptor tokenDescriptor = new()
@@ -56,13 +51,12 @@ public class JwtInfrastructure : ITokenInfrastructure
SigningCredentials = new SigningCredentials( SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key), new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256 SecurityAlgorithms.HmacSha256
) ),
}; };
return handler.CreateToken(tokenDescriptor); return handler.CreateToken(tokenDescriptor);
} }
/// <summary> /// <summary>
/// Validates a JWT's signature and lifetime (issuer and audience validation are disabled), /// Validates a JWT's signature and lifetime (issuer and audience validation are disabled),
/// using the provided secret as the HMAC-SHA256 symmetric signing key. /// using the provided secret as the HMAC-SHA256 symmetric signing key.
@@ -74,21 +68,16 @@ public class JwtInfrastructure : ITokenInfrastructure
/// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation /// Thrown when the token is invalid, has no claims identity, is expired, or otherwise fails validation
/// (including when validation itself throws, e.g. due to a malformed token or signature mismatch). /// (including when validation itself throws, e.g. due to a malformed token or signature mismatch).
/// </exception> /// </exception>
public async Task<ClaimsPrincipal> ValidateJwtAsync( public async Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret)
string token,
string secret
)
{ {
JsonWebTokenHandler handler = new(); JsonWebTokenHandler handler = new();
byte[] keyBytes = Encoding.UTF8.GetBytes( byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
secret
);
TokenValidationParameters parameters = new() TokenValidationParameters parameters = new()
{ {
ValidateIssuer = false, ValidateIssuer = false,
ValidateAudience = false, ValidateAudience = false,
ValidateLifetime = true, ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes) IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
}; };
try try

View File

@@ -32,7 +32,7 @@ public class Argon2Infrastructure : IPasswordInfrastructure
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb, MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations Iterations = ArgonIterations,
}; };
byte[] hash = argon2.GetBytes(HashSize); byte[] hash = argon2.GetBytes(HashSize);
@@ -57,10 +57,7 @@ public class Argon2Infrastructure : IPasswordInfrastructure
{ {
try try
{ {
string[] parts = stored.Split( string[] parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries);
':',
StringSplitOptions.RemoveEmptyEntries
);
if (parts.Length != 2) if (parts.Length != 2)
return false; return false;
@@ -72,7 +69,7 @@ public class Argon2Infrastructure : IPasswordInfrastructure
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb, MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations Iterations = ArgonIterations,
}; };
byte[] actual = argon2.GetBytes(expected.Length); byte[] actual = argon2.GetBytes(expected.Length);

View File

@@ -7,9 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -9,12 +9,9 @@ namespace Infrastructure.Sql;
/// resolving the connection string from environment variables or application configuration. /// resolving the connection string from environment variables or application configuration.
/// </summary> /// </summary>
/// <param name="configuration">The application configuration, used as a fallback source for the connection string.</param> /// <param name="configuration">The application configuration, used as a fallback source for the connection string.</param>
public class DefaultSqlConnectionFactory(IConfiguration configuration) public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
: ISqlConnectionFactory
{ {
private readonly string _connectionString = GetConnectionString( private readonly string _connectionString = GetConnectionString(configuration);
configuration
);
/// <summary> /// <summary>
/// Creates a new, unopened <see cref="SqlConnection" /> using the resolved connection string. /// Creates a new, unopened <see cref="SqlConnection" /> using the resolved connection string.
@@ -39,10 +36,9 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration)
private static string GetConnectionString(IConfiguration configuration) private static string GetConnectionString(IConfiguration configuration)
{ {
// Check for full connection string first // Check for full connection string first
string? fullConnectionString = Environment.GetEnvironmentVariable( string? fullConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
"DB_CONNECTION_STRING" if (!string.IsNullOrEmpty(fullConnectionString))
); return fullConnectionString;
if (!string.IsNullOrEmpty(fullConnectionString)) return fullConnectionString;
// Try to build from individual environment variables (preferred method for Docker) // Try to build from individual environment variables (preferred method for Docker)
try try
@@ -53,7 +49,8 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration)
{ {
// Fall back to configuration-based connection string if env vars are not set // Fall back to configuration-based connection string if env vars are not set
string? connString = configuration.GetConnectionString("Default"); string? connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString)) return connString; if (!string.IsNullOrEmpty(connString))
return connString;
throw new InvalidOperationException( throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default." "Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."

View File

@@ -6,12 +6,9 @@
<RootNamespace>Infrastructure.Sql</RootNamespace> <RootNamespace>Infrastructure.Sql</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4"/> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference <PackageReference Include="Microsoft.SqlServer.Types" Version="160.1000.6" />
Include="Microsoft.SqlServer.Types" <PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
Version="160.1000.6"
/>
<PackageReference Include="System.Data.SqlClient" Version="4.9.0"/>
<PackageReference <PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions" Include="Microsoft.Extensions.Configuration.Abstractions"
Version="9.0.0" Version="9.0.0"

View File

@@ -17,28 +17,20 @@ public static class SqlConnectionStringHelper
{ {
string server = string server =
Environment.GetEnvironmentVariable("DB_SERVER") Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
"DB_SERVER environment variable is not set"
);
string dbName = string dbName =
databaseName databaseName
?? Environment.GetEnvironmentVariable("DB_NAME") ?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException("DB_NAME environment variable is not set");
"DB_NAME environment variable is not set"
);
string user = string user =
Environment.GetEnvironmentVariable("DB_USER") Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException("DB_USER environment variable is not set");
"DB_USER environment variable is not set"
);
string password = string password =
Environment.GetEnvironmentVariable("DB_PASSWORD") Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
"DB_PASSWORD environment variable is not set"
);
SqlConnectionStringBuilder builder = new() SqlConnectionStringBuilder builder = new()
{ {
@@ -47,7 +39,7 @@ public static class SqlConnectionStringHelper
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = true, TrustServerCertificate = true,
Encrypt = true Encrypt = true,
}; };
return builder.ConnectionString; return builder.ConnectionString;

View File

@@ -19,20 +19,20 @@ public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TReq
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (!validators.Any()) return await next(); if (!validators.Any())
return await next();
ValidationContext<TRequest> context = new(request); ValidationContext<TRequest> context = new(request);
List<ValidationFailure> failures = ( List<ValidationFailure> failures = (
await Task.WhenAll( await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)))
validators.Select(v => v.ValidateAsync(context, cancellationToken))
)
) )
.SelectMany(result => result.Errors) .SelectMany(result => result.Errors)
.Where(failure => failure != null) .Where(failure => failure != null)
.ToList(); .ToList();
if (failures.Count > 0) throw new ValidationException(failures); if (failures.Count > 0)
throw new ValidationException(failures);
return await next(); return await next();
} }

View File

@@ -6,8 +6,5 @@ namespace Shared.Application.Emails;
/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a /// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a
/// registration confirmation email, without either slice taking a project reference on the other. /// registration confirmation email, without either slice taking a project reference on the other.
/// </summary> /// </summary>
public record SendRegistrationEmailCommand( public record SendRegistrationEmailCommand(string FirstName, string Email, string ConfirmationToken)
string FirstName, : IRequest;
string Email,
string ConfirmationToken
) : IRequest;

View File

@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="12.4.1"/> <PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="FluentValidation" Version="11.3.0"/> <PackageReference Include="FluentValidation" Version="11.3.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>