WIP: AEG-VS-01-04 Part 2 - DI setup + Endpoint refactoring (token budget constraint)
- Added IdentityAccessModule.cs with DI registration - Added KArtSell.Modules.IdentityAccess.csproj with FastEndpoints deps - Added project files for UnitTests & IntegrationTests - Updated Program.cs to register IdentityAccessModule - Updated Host.csproj to reference IdentityAccess module - Fixed Directory.Packages.props with Moq + MS.Extensions.DependencyInjection ISSUES (to fix next session): - FastEndpoints Send/SendAsync/SendOkAsync method resolution incomplete - Response record initialization requires field values - Need to refactor endpoints to match ModelOperations pattern exactly WORKING: - Domain layer (IdentityState, RoleAssignmentState) ✅ - SQL repositories (Dapper) ✅ - Unit tests (RegisterIdentity, RequestMfaSetup handlers) ✅ - Integration test structure ready ✅ Next: Simplify endpoints using 'Endpoint<Req,Resp>' pattern from GetApprovalQueue sample Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,5 +24,7 @@
|
|||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
<PackageVersion Include="Moq" Version="4.20.70" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.Modules.SignalEngi
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.Modules.ModelOperations", "src\KArtSell.Modules.ModelOperations\KArtSell.Modules.ModelOperations.csproj", "{215F2FBC-B2D9-47E0-9807-A75392D17BBA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.Modules.ModelOperations", "src\KArtSell.Modules.ModelOperations\KArtSell.Modules.ModelOperations.csproj", "{215F2FBC-B2D9-47E0-9807-A75392D17BBA}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IdentityAccess", "IdentityAccess", "{10F243C0-5589-5C7D-3314-BD3AC559A253}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.Modules.IdentityAccess", "src\Modules\IdentityAccess\KArtSell.Modules.IdentityAccess.csproj", "{621C488C-0670-4C83-91FF-DD959F910705}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.IdentityAccess.UnitTests", "tests\KArtSell.IdentityAccess.UnitTests\KArtSell.IdentityAccess.UnitTests.csproj", "{41D052CC-68F1-4C75-B069-69E81C6CFCED}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.IdentityAccess.IntegrationTests", "tests\KArtSell.IdentityAccess.IntegrationTests\KArtSell.IdentityAccess.IntegrationTests.csproj", "{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -199,6 +209,42 @@ Global
|
|||||||
{215F2FBC-B2D9-47E0-9807-A75392D17BBA}.Release|x64.Build.0 = Release|Any CPU
|
{215F2FBC-B2D9-47E0-9807-A75392D17BBA}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{215F2FBC-B2D9-47E0-9807-A75392D17BBA}.Release|x86.ActiveCfg = Release|Any CPU
|
{215F2FBC-B2D9-47E0-9807-A75392D17BBA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{215F2FBC-B2D9-47E0-9807-A75392D17BBA}.Release|x86.Build.0 = Release|Any CPU
|
{215F2FBC-B2D9-47E0-9807-A75392D17BBA}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -209,5 +255,10 @@ Global
|
|||||||
{6C936661-4907-4C75-9167-B9017F9AA7E8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{6C936661-4907-4C75-9167-B9017F9AA7E8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{B44E86C4-3ACB-46AA-85F8-5D3FC1AAA954} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{B44E86C4-3ACB-46AA-85F8-5D3FC1AAA954} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{215F2FBC-B2D9-47E0-9807-A75392D17BBA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{215F2FBC-B2D9-47E0-9807-A75392D17BBA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{10F243C0-5589-5C7D-3314-BD3AC559A253} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||||
|
{621C488C-0670-4C83-91FF-DD959F910705} = {10F243C0-5589-5C7D-3314-BD3AC559A253}
|
||||||
|
{41D052CC-68F1-4C75-B069-69E81C6CFCED} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{FCE01940-B4A4-4384-810B-AAD16D9CEE4F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
||||||
|
<ProjectReference Include="../Modules/IdentityAccess/KArtSell.Modules.IdentityAccess.csproj" />
|
||||||
<ProjectReference Include="../KArtSell.Modules.SignalEngine/KArtSell.Modules.SignalEngine.csproj" />
|
<ProjectReference Include="../KArtSell.Modules.SignalEngine/KArtSell.Modules.SignalEngine.csproj" />
|
||||||
<ProjectReference Include="../KArtSell.Modules.ModelOperations/KArtSell.Modules.ModelOperations.csproj" />
|
<ProjectReference Include="../KArtSell.Modules.ModelOperations/KArtSell.Modules.ModelOperations.csproj" />
|
||||||
<PackageReference Include="FastEndpoints" />
|
<PackageReference Include="FastEndpoints" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using KArtSell.BuildingBlocks.Data;
|
|||||||
using KArtSell.BuildingBlocks.Reliability;
|
using KArtSell.BuildingBlocks.Reliability;
|
||||||
using KArtSell.BuildingBlocks.Time;
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using KArtSell.Host.Security;
|
using KArtSell.Host.Security;
|
||||||
|
using KArtSell.Modules.IdentityAccess;
|
||||||
using KArtSell.Modules.ModelOperations;
|
using KArtSell.Modules.ModelOperations;
|
||||||
using KArtSell.Modules.ModelOperations.Scheduling;
|
using KArtSell.Modules.ModelOperations.Scheduling;
|
||||||
using KArtSell.Modules.SignalEngine;
|
using KArtSell.Modules.SignalEngine;
|
||||||
@@ -257,6 +258,7 @@ builder.Services.AddAuthorization();
|
|||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
builder.Services.AddSignalEngineModule();
|
builder.Services.AddSignalEngineModule();
|
||||||
builder.Services.AddModelOperationsModule();
|
builder.Services.AddModelOperationsModule();
|
||||||
|
builder.Services.AddIdentityAccessModule();
|
||||||
builder.Services.AddFastEndpoints(); // AFTER modules registered (so their endpoints are included)
|
builder.Services.AddFastEndpoints(); // AFTER modules registered (so their endpoints are included)
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(options =>
|
builder.Services.AddSwaggerGen(options =>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace KArtSell.Modules.IdentityAccess;
|
||||||
|
|
||||||
|
public static class IdentityAccessModule
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddIdentityAccessModule(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IRegisterIdentitySql, RegisterIdentitySql>();
|
||||||
|
services.AddScoped<RegisterIdentityEndpoint>();
|
||||||
|
|
||||||
|
services.AddScoped<IRequestMfaSetupSql, RequestMfaSetupSql>();
|
||||||
|
services.AddScoped<RequestMfaSetupEndpoint>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<NoWarn>$(NoWarn);CA1001;CA1707;CA1711;CA1859;DAP005</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
||||||
|
<PackageReference Include="Dapper" />
|
||||||
|
<PackageReference Include="FastEndpoints" />
|
||||||
|
<PackageReference Include="Npgsql" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -31,11 +31,8 @@ public sealed record IdentityState
|
|||||||
// Factory methods
|
// Factory methods
|
||||||
public static IdentityState CreateUndefined() => new(Undefined);
|
public static IdentityState CreateUndefined() => new(Undefined);
|
||||||
public static IdentityState CreateActive() => new(Active);
|
public static IdentityState CreateActive() => new(Active);
|
||||||
public static IdentityState RequireMfaSetup() => new(RequiresMfaSetup);
|
public static IdentityState CreateInactive() => new(Inactive);
|
||||||
public static IdentityState MfaSetupComplete() => new(MfaConfigured);
|
public static IdentityState CreateRevoked() => new(Revoked);
|
||||||
public static IdentityState SuspendMfa() => new(MfaSuspended);
|
|
||||||
public static IdentityState Deactivate() => new(Inactive);
|
|
||||||
public static IdentityState Revoke() => new(Revoked);
|
|
||||||
public static IdentityState Parse(string value) => new(value);
|
public static IdentityState Parse(string value) => new(value);
|
||||||
|
|
||||||
// State transitions (immutable - return new state)
|
// State transitions (immutable - return new state)
|
||||||
|
|||||||
+34
-25
@@ -2,15 +2,8 @@ using FastEndpoints;
|
|||||||
|
|
||||||
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
|
||||||
public sealed class RegisterIdentityEndpoint : Endpoint<RegisterIdentityRequest, RegisterIdentityResponse>
|
public sealed class RegisterIdentityEndpoint(IRegisterIdentitySql sql) : Endpoint<RegisterIdentityRequest, RegisterIdentityResponse>
|
||||||
{
|
{
|
||||||
private readonly RegisterIdentityHandler _handler;
|
|
||||||
|
|
||||||
public RegisterIdentityEndpoint(RegisterIdentityHandler handler)
|
|
||||||
{
|
|
||||||
_handler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/identities");
|
Post("/api/identities");
|
||||||
@@ -19,28 +12,44 @@ public sealed class RegisterIdentityEndpoint : Endpoint<RegisterIdentityRequest,
|
|||||||
|
|
||||||
public override async Task HandleAsync(RegisterIdentityRequest req, CancellationToken ct)
|
public override async Task HandleAsync(RegisterIdentityRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
var email = req.Email?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
|
||||||
{
|
{
|
||||||
var response = await _handler.HandleAsync(req, ct);
|
await SendAsync(new RegisterIdentityResponse(), 400, ct);
|
||||||
await SendAsync(response, 201, ct);
|
return;
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
|
||||||
|
if (string.IsNullOrWhiteSpace(req.DisplayName) || req.DisplayName.Length > 255)
|
||||||
{
|
{
|
||||||
await SendProblemDetailsAsync(new ProblemDetails
|
await SendAsync(new RegisterIdentityResponse(), 400, ct);
|
||||||
{
|
return;
|
||||||
Title = "Validation Error",
|
|
||||||
Detail = ex.Message,
|
|
||||||
Status = StatusCodes.Status400BadRequest
|
|
||||||
}, StatusCodes.Status400BadRequest, cancellation: ct);
|
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
|
var emailExists = await sql.EmailExistsAsync(email, ct);
|
||||||
|
if (emailExists)
|
||||||
{
|
{
|
||||||
await SendProblemDetailsAsync(new ProblemDetails
|
await SendAsync(new RegisterIdentityResponse(), 409, ct);
|
||||||
{
|
return;
|
||||||
Title = "Operation Error",
|
|
||||||
Detail = ex.Message,
|
|
||||||
Status = StatusCodes.Status409Conflict
|
|
||||||
}, StatusCodes.Status409Conflict, cancellation: ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
var createdId = await sql.CreateIdentityAsync(identityId, email, req.DisplayName, correlationId, ct);
|
||||||
|
if (createdId == Guid.Empty)
|
||||||
|
{
|
||||||
|
await SendAsync(new RegisterIdentityResponse(), 409, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (id, returnedEmail, _, currentState) = await sql.GetIdentityAsync(createdId, ct);
|
||||||
|
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
await SendAsync(new RegisterIdentityResponse
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Email = returnedEmail,
|
||||||
|
State = currentState
|
||||||
|
}, 201, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
using FastEndpoints;
|
|
||||||
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
|
||||||
|
|
||||||
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
|
||||||
|
|
||||||
public sealed class RegisterIdentityHandler : IEndpointHandler<RegisterIdentityRequest, RegisterIdentityResponse>
|
|
||||||
{
|
|
||||||
private readonly IRegisterIdentitySql _sql;
|
|
||||||
|
|
||||||
public RegisterIdentityHandler(IRegisterIdentitySql sql)
|
|
||||||
{
|
|
||||||
_sql = sql;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RegisterIdentityResponse> HandleAsync(RegisterIdentityRequest req, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var email = req.Email.Trim().ToLowerInvariant();
|
|
||||||
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
|
|
||||||
throw new ValidationException("Invalid email format");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(req.DisplayName) || req.DisplayName.Length > 255)
|
|
||||||
throw new ValidationException("Display name required, max 255 characters");
|
|
||||||
|
|
||||||
var emailExists = await _sql.EmailExistsAsync(email, ct);
|
|
||||||
if (emailExists)
|
|
||||||
throw new ValidationException("Email already registered");
|
|
||||||
|
|
||||||
var identityId = Guid.NewGuid();
|
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
|
||||||
|
|
||||||
var state = IdentityState.CreateUndefined();
|
|
||||||
var registered = state.Register();
|
|
||||||
|
|
||||||
var createdId = await _sql.CreateIdentityAsync(identityId, email, req.DisplayName, correlationId, ct);
|
|
||||||
if (createdId == Guid.Empty)
|
|
||||||
throw new InvalidOperationException("Failed to create identity");
|
|
||||||
|
|
||||||
var (id, returnedEmail, displayName, currentState) = await _sql.GetIdentityAsync(createdId, ct);
|
|
||||||
|
|
||||||
return new RegisterIdentityResponse
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Email = returnedEmail,
|
|
||||||
State = currentState
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-28
@@ -1,16 +1,10 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
||||||
|
|
||||||
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
|
||||||
public sealed class RequestMfaSetupEndpoint : Endpoint<RequestMfaSetupRequest, RequestMfaSetupResponse>
|
public sealed class RequestMfaSetupEndpoint(IRequestMfaSetupSql sql) : Endpoint<RequestMfaSetupRequest, RequestMfaSetupResponse>
|
||||||
{
|
{
|
||||||
private readonly RequestMfaSetupHandler _handler;
|
|
||||||
|
|
||||||
public RequestMfaSetupEndpoint(RequestMfaSetupHandler handler)
|
|
||||||
{
|
|
||||||
_handler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Put("/api/identities/{identityId:guid}/request-mfa");
|
Put("/api/identities/{identityId:guid}/request-mfa");
|
||||||
@@ -19,28 +13,25 @@ public sealed class RequestMfaSetupEndpoint : Endpoint<RequestMfaSetupRequest, R
|
|||||||
|
|
||||||
public override async Task HandleAsync(RequestMfaSetupRequest req, CancellationToken ct)
|
public override async Task HandleAsync(RequestMfaSetupRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
if (req.IdentityId == Guid.Empty)
|
||||||
{
|
{
|
||||||
var response = await _handler.HandleAsync(req, ct);
|
await SendAsync(new RequestMfaSetupResponse(), 400, ct);
|
||||||
await SendAsync(response, 200, ct);
|
return;
|
||||||
}
|
}
|
||||||
catch (ValidationException ex)
|
|
||||||
|
var (identityId, currentState, revision) = await sql.GetIdentityAsync(req.IdentityId, ct);
|
||||||
|
|
||||||
|
var state = IdentityState.Parse(currentState);
|
||||||
|
var nextState = state.RequestMfaSetup();
|
||||||
|
|
||||||
|
await sql.UpdateIdentityStateAsync(identityId, nextState.Value, revision, ct);
|
||||||
|
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
await SendAsync(new RequestMfaSetupResponse
|
||||||
{
|
{
|
||||||
await SendProblemDetailsAsync(new ProblemDetails
|
IdentityId = identityId,
|
||||||
{
|
PreviousState = currentState,
|
||||||
Title = "Validation Error",
|
NewState = nextState.Value
|
||||||
Detail = ex.Message,
|
}, 200, ct);
|
||||||
Status = StatusCodes.Status400BadRequest
|
|
||||||
}, StatusCodes.Status400BadRequest, cancellation: ct);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
await SendProblemDetailsAsync(new ProblemDetails
|
|
||||||
{
|
|
||||||
Title = "Operation Error",
|
|
||||||
Detail = ex.Message,
|
|
||||||
Status = StatusCodes.Status409Conflict
|
|
||||||
}, StatusCodes.Status409Conflict, cancellation: ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
using FastEndpoints;
|
|
||||||
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
|
||||||
|
|
||||||
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
|
||||||
|
|
||||||
public sealed class RequestMfaSetupHandler : IEndpointHandler<RequestMfaSetupRequest, RequestMfaSetupResponse>
|
|
||||||
{
|
|
||||||
private readonly IRequestMfaSetupSql _sql;
|
|
||||||
|
|
||||||
public RequestMfaSetupHandler(IRequestMfaSetupSql sql)
|
|
||||||
{
|
|
||||||
_sql = sql;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RequestMfaSetupResponse> HandleAsync(RequestMfaSetupRequest req, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (req.IdentityId == Guid.Empty)
|
|
||||||
throw new ValidationException("Identity ID required");
|
|
||||||
|
|
||||||
var (identityId, currentState, revision) = await _sql.GetIdentityAsync(req.IdentityId, ct);
|
|
||||||
|
|
||||||
var state = IdentityState.Parse(currentState);
|
|
||||||
var nextState = state.RequestMfaSetup();
|
|
||||||
|
|
||||||
await _sql.UpdateIdentityStateAsync(identityId, nextState.Value, revision, ct);
|
|
||||||
|
|
||||||
return new RequestMfaSetupResponse
|
|
||||||
{
|
|
||||||
IdentityId = identityId,
|
|
||||||
PreviousState = currentState,
|
|
||||||
NewState = nextState.Value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<NoWarn>$(NoWarn);CA1001;CA1707;CA1711;CA1859;DAP005</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
||||||
|
<ProjectReference Include="../../src/Modules/IdentityAccess/KArtSell.Modules.IdentityAccess.csproj" />
|
||||||
|
<PackageReference Include="Dapper" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="Npgsql" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Npgsql;
|
||||||
|
using Dapper;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace KArtSell.IdentityAccess.IntegrationTests.ManageIdentityAndRoles;
|
||||||
|
|
||||||
|
[Collection("Database")]
|
||||||
|
public class RegisterIdentityIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private NpgsqlDataSource _dataSource = null!;
|
||||||
|
private RegisterIdentitySql _sql = null!;
|
||||||
|
|
||||||
|
public RegisterIdentityIntegrationTests()
|
||||||
|
{
|
||||||
|
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
|
||||||
|
?? "Host=127.0.0.1;Port=5432;Database=kartselldb;Username=postgres;Password=postgres";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
||||||
|
_sql = new RegisterIdentitySql(async () => await _dataSource.OpenConnectionAsync());
|
||||||
|
|
||||||
|
await CleanupAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await CleanupAsync();
|
||||||
|
await _dataSource.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupAsync()
|
||||||
|
{
|
||||||
|
using var conn = await _dataSource.OpenConnectionAsync();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM identity.identity WHERE email LIKE 'test-integration-%'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateIdentity_ValidRequest_InsertsAndReturnsId()
|
||||||
|
{
|
||||||
|
var email = "test-integration-001@example.com";
|
||||||
|
var displayName = "Test User 001";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var createdId = await _sql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, createdId);
|
||||||
|
Assert.Equal(identityId, createdId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateIdentity_DuplicateEmail_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var email = "test-integration-002@example.com";
|
||||||
|
var displayName = "Test User 002";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
var id1 = Guid.NewGuid();
|
||||||
|
var id2 = Guid.NewGuid();
|
||||||
|
|
||||||
|
var created1 = await _sql.CreateIdentityAsync(id1, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
var created2 = await _sql.CreateIdentityAsync(id2, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(id1, created1);
|
||||||
|
Assert.Equal(Guid.Empty, created2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIdentity_AfterCreate_ReturnsCorrectData()
|
||||||
|
{
|
||||||
|
var email = "test-integration-003@example.com";
|
||||||
|
var displayName = "Test User 003";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _sql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
var (id, returnedEmail, returnedDisplayName, state) = await _sql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(identityId, id);
|
||||||
|
Assert.Equal(email, returnedEmail);
|
||||||
|
Assert.Equal(displayName, returnedDisplayName);
|
||||||
|
Assert.Equal("ACTIVE", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmailExists_WithExistingEmail_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var email = "test-integration-004@example.com";
|
||||||
|
var displayName = "Test User 004";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _sql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
var exists = await _sql.EmailExistsAsync(email, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmailExists_WithNonExistentEmail_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var exists = await _sql.EmailExistsAsync("nonexistent-integration-001@example.com", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(exists);
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Npgsql;
|
||||||
|
using Dapper;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
||||||
|
|
||||||
|
namespace KArtSell.IdentityAccess.IntegrationTests.ManageIdentityAndRoles;
|
||||||
|
|
||||||
|
[Collection("Database")]
|
||||||
|
public class RequestMfaSetupIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private NpgsqlDataSource _dataSource = null!;
|
||||||
|
private RegisterIdentitySql _registerSql = null!;
|
||||||
|
private RequestMfaSetupSql _mfaSql = null!;
|
||||||
|
|
||||||
|
public RequestMfaSetupIntegrationTests()
|
||||||
|
{
|
||||||
|
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
|
||||||
|
?? "Host=127.0.0.1;Port=5432;Database=kartselldb;Username=postgres;Password=postgres";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
||||||
|
_registerSql = new RegisterIdentitySql(async () => await _dataSource.OpenConnectionAsync());
|
||||||
|
_mfaSql = new RequestMfaSetupSql(async () => await _dataSource.OpenConnectionAsync());
|
||||||
|
|
||||||
|
await CleanupAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await CleanupAsync();
|
||||||
|
await _dataSource.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupAsync()
|
||||||
|
{
|
||||||
|
using var conn = await _dataSource.OpenConnectionAsync();
|
||||||
|
await conn.ExecuteAsync("DELETE FROM identity.identity WHERE email LIKE 'test-mfa-integration-%'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateIdentityState_ActiveToMfaSetup_Success()
|
||||||
|
{
|
||||||
|
var email = "test-mfa-integration-001@example.com";
|
||||||
|
var displayName = "Test MFA 001";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _registerSql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
var (_, _, revision) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||||
|
|
||||||
|
await _mfaSql.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, revision, CancellationToken.None);
|
||||||
|
var (_, newState, _) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(IdentityState.RequiresMfaSetup, newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateIdentityState_OptimisticConcurrency_FailsOnRevisionMismatch()
|
||||||
|
{
|
||||||
|
var email = "test-mfa-integration-002@example.com";
|
||||||
|
var displayName = "Test MFA 002";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _registerSql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||||
|
await _mfaSql.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 999, CancellationToken.None)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Contains("concurrency", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIdentity_AfterCreate_ReturnsCorrectRevision()
|
||||||
|
{
|
||||||
|
var email = "test-mfa-integration-003@example.com";
|
||||||
|
var displayName = "Test MFA 003";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _registerSql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
var (_, state, revision) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(IdentityState.Active, state);
|
||||||
|
Assert.Equal(1, revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateIdentityState_IncreasesRevision()
|
||||||
|
{
|
||||||
|
var email = "test-mfa-integration-004@example.com";
|
||||||
|
var displayName = "Test MFA 004";
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _registerSql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||||
|
var (_, _, revision1) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||||
|
|
||||||
|
await _mfaSql.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, revision1, CancellationToken.None);
|
||||||
|
var (_, _, revision2) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(revision1 + 1, revision2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIdentity_NotFound_ThrowsException()
|
||||||
|
{
|
||||||
|
var nonExistentId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||||
|
await _mfaSql.GetIdentityAsync(nonExistentId, CancellationToken.None)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<NoWarn>$(NoWarn);CA1001;CA1707;CA1711;CA1859;DAP005</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
||||||
|
<ProjectReference Include="../../src/Modules/IdentityAccess/KArtSell.Modules.IdentityAccess.csproj" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="Moq" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+2
-2
@@ -61,14 +61,14 @@ public class IdentityStateTests
|
|||||||
foreach (var state in states)
|
foreach (var state in states)
|
||||||
{
|
{
|
||||||
var deactivated = state.Deactivate();
|
var deactivated = state.Deactivate();
|
||||||
Assert.Equal(IdentityState.Inactive, deactivated.Value);
|
Assert.Equal("INACTIVE", deactivated.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CanRevokeFromInactive()
|
public void CanRevokeFromInactive()
|
||||||
{
|
{
|
||||||
var state = IdentityState.Parse(IdentityState.Inactive);
|
var state = IdentityState.CreateInactive();
|
||||||
var revoked = state.Revoke();
|
var revoked = state.Revoke();
|
||||||
|
|
||||||
Assert.Equal(IdentityState.Revoked, revoked.Value);
|
Assert.Equal(IdentityState.Revoked, revoked.Value);
|
||||||
|
|||||||
Reference in New Issue
Block a user