mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compare commits
23 Commits
feat/auth-
...
3711591db1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3711591db1 | ||
|
|
194509f9c9 | ||
|
|
67d6350425 | ||
|
|
cee2917556 | ||
|
|
6f8e5dc722 | ||
|
|
db3ed3581d | ||
|
|
b54f70ccbe | ||
|
|
d4734ae9e7 | ||
|
|
27f28d5207 | ||
|
|
bf0d29fa54 | ||
|
|
4772f59a68 | ||
|
|
2e5d4923c0 | ||
|
|
62766b7638 | ||
|
|
744d4c7f8c | ||
|
|
2f28ac9350 | ||
|
|
c8bddae817 | ||
|
|
b298bd02d3 | ||
|
|
e2e5eb89e3 | ||
|
|
3dd44fca5a | ||
|
|
0c55d6c83f | ||
|
|
58cbc5b5ba | ||
|
|
5bef24696b | ||
|
|
79ae18b3e2 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 80,
|
"printWidth": 100,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"indentSize": 4,
|
"indentSize": 4,
|
||||||
"endOfLine": "lf",
|
"endOfLine": "lf",
|
||||||
|
|||||||
@@ -8,15 +8,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||||
Include="Microsoft.AspNetCore.OpenApi"
|
|
||||||
Version="9.0.11"
|
|
||||||
/>
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
Include="FluentValidation.AspNetCore"
|
|
||||||
Version="11.3.0"
|
|
||||||
/>
|
|
||||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,10 +9,7 @@
|
|||||||
|
|
||||||
<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"
|
|
||||||
Version="1.3.1"
|
|
||||||
/>
|
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -10,17 +10,11 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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 () =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MailKit" Version="4.15.1"/>
|
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -7,14 +7,8 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -7,10 +7,7 @@
|
|||||||
</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"
|
|
||||||
Version="160.1000.6"
|
|
||||||
/>
|
|
||||||
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
|
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="Microsoft.Extensions.Configuration.Abstractions"
|
Include="Microsoft.Extensions.Configuration.Abstractions"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user