diff --git a/Directory.Packages.props b/Directory.Packages.props
index 083dd70d..aa10fbb0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,5 +24,7 @@
+
+
diff --git a/KArtSell.sln b/KArtSell.sln
index 1f4b8127..8e037a01 100644
--- a/KArtSell.sln
+++ b/KArtSell.sln
@@ -33,6 +33,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.Modules.SignalEngi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KArtSell.Modules.ModelOperations", "src\KArtSell.Modules.ModelOperations\KArtSell.Modules.ModelOperations.csproj", "{215F2FBC-B2D9-47E0-9807-A75392D17BBA}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -209,5 +255,10 @@ Global
{6C936661-4907-4C75-9167-B9017F9AA7E8} = {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}
+ {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
EndGlobal
diff --git a/src/KArtSell.Host/KArtSell.Host.csproj b/src/KArtSell.Host/KArtSell.Host.csproj
index e5b986a8..e0e431c8 100644
--- a/src/KArtSell.Host/KArtSell.Host.csproj
+++ b/src/KArtSell.Host/KArtSell.Host.csproj
@@ -18,6 +18,7 @@
+
diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs
index 6b31c2a3..9db1b151 100644
--- a/src/KArtSell.Host/Program.cs
+++ b/src/KArtSell.Host/Program.cs
@@ -13,6 +13,7 @@ using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Reliability;
using KArtSell.BuildingBlocks.Time;
using KArtSell.Host.Security;
+using KArtSell.Modules.IdentityAccess;
using KArtSell.Modules.ModelOperations;
using KArtSell.Modules.ModelOperations.Scheduling;
using KArtSell.Modules.SignalEngine;
@@ -257,6 +258,7 @@ builder.Services.AddAuthorization();
builder.Services.AddSignalR();
builder.Services.AddSignalEngineModule();
builder.Services.AddModelOperationsModule();
+builder.Services.AddIdentityAccessModule();
builder.Services.AddFastEndpoints(); // AFTER modules registered (so their endpoints are included)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
diff --git a/src/Modules/IdentityAccess/IdentityAccessModule.cs b/src/Modules/IdentityAccess/IdentityAccessModule.cs
new file mode 100644
index 00000000..fc395b9f
--- /dev/null
+++ b/src/Modules/IdentityAccess/IdentityAccessModule.cs
@@ -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();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/src/Modules/IdentityAccess/KArtSell.Modules.IdentityAccess.csproj b/src/Modules/IdentityAccess/KArtSell.Modules.IdentityAccess.csproj
new file mode 100644
index 00000000..f024cde3
--- /dev/null
+++ b/src/Modules/IdentityAccess/KArtSell.Modules.IdentityAccess.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ enable
+ $(NoWarn);CA1001;CA1707;CA1711;CA1859;DAP005
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/IdentityState.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/IdentityState.cs
index f86fd5fc..50546a71 100644
--- a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/IdentityState.cs
+++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Domain/IdentityState.cs
@@ -31,11 +31,8 @@ public sealed record IdentityState
// Factory methods
public static IdentityState CreateUndefined() => new(Undefined);
public static IdentityState CreateActive() => new(Active);
- public static IdentityState RequireMfaSetup() => new(RequiresMfaSetup);
- public static IdentityState MfaSetupComplete() => new(MfaConfigured);
- public static IdentityState SuspendMfa() => new(MfaSuspended);
- public static IdentityState Deactivate() => new(Inactive);
- public static IdentityState Revoke() => new(Revoked);
+ public static IdentityState CreateInactive() => new(Inactive);
+ public static IdentityState CreateRevoked() => new(Revoked);
public static IdentityState Parse(string value) => new(value);
// State transitions (immutable - return new state)
diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs
index 3fe4abf0..c7bcefb6 100644
--- a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs
+++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs
@@ -2,15 +2,8 @@ using FastEndpoints;
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
-public sealed class RegisterIdentityEndpoint : Endpoint
+public sealed class RegisterIdentityEndpoint(IRegisterIdentitySql sql) : Endpoint
{
- private readonly RegisterIdentityHandler _handler;
-
- public RegisterIdentityEndpoint(RegisterIdentityHandler handler)
- {
- _handler = handler;
- }
-
public override void Configure()
{
Post("/api/identities");
@@ -19,28 +12,44 @@ public sealed class RegisterIdentityEndpoint : Endpoint 255)
{
- await SendProblemDetailsAsync(new ProblemDetails
- {
- Title = "Validation Error",
- Detail = ex.Message,
- Status = StatusCodes.Status400BadRequest
- }, StatusCodes.Status400BadRequest, cancellation: ct);
+ await SendAsync(new RegisterIdentityResponse(), 400, ct);
+ return;
}
- catch (InvalidOperationException ex)
+
+ var emailExists = await sql.EmailExistsAsync(email, ct);
+ if (emailExists)
{
- await SendProblemDetailsAsync(new ProblemDetails
- {
- Title = "Operation Error",
- Detail = ex.Message,
- Status = StatusCodes.Status409Conflict
- }, StatusCodes.Status409Conflict, cancellation: ct);
+ await SendAsync(new RegisterIdentityResponse(), 409, ct);
+ return;
}
+
+ 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);
}
}
diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityHandler.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityHandler.cs
deleted file mode 100644
index 5053116a..00000000
--- a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityHandler.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using FastEndpoints;
-using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
-
-namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
-
-public sealed class RegisterIdentityHandler : IEndpointHandler
-{
- private readonly IRegisterIdentitySql _sql;
-
- public RegisterIdentityHandler(IRegisterIdentitySql sql)
- {
- _sql = sql;
- }
-
- public async Task 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
- };
- }
-}
diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupEndpoint.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupEndpoint.cs
index b4850a54..9073c6c8 100644
--- a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupEndpoint.cs
+++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupEndpoint.cs
@@ -1,16 +1,10 @@
using FastEndpoints;
+using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
-public sealed class RequestMfaSetupEndpoint : Endpoint
+public sealed class RequestMfaSetupEndpoint(IRequestMfaSetupSql sql) : Endpoint
{
- private readonly RequestMfaSetupHandler _handler;
-
- public RequestMfaSetupEndpoint(RequestMfaSetupHandler handler)
- {
- _handler = handler;
- }
-
public override void Configure()
{
Put("/api/identities/{identityId:guid}/request-mfa");
@@ -19,28 +13,25 @@ public sealed class RequestMfaSetupEndpoint : Endpoint
-{
- private readonly IRequestMfaSetupSql _sql;
-
- public RequestMfaSetupHandler(IRequestMfaSetupSql sql)
- {
- _sql = sql;
- }
-
- public async Task 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
- };
- }
-}
diff --git a/tests/KArtSell.IdentityAccess.IntegrationTests/KArtSell.IdentityAccess.IntegrationTests.csproj b/tests/KArtSell.IdentityAccess.IntegrationTests/KArtSell.IdentityAccess.IntegrationTests.csproj
new file mode 100644
index 00000000..8b02e5df
--- /dev/null
+++ b/tests/KArtSell.IdentityAccess.IntegrationTests/KArtSell.IdentityAccess.IntegrationTests.csproj
@@ -0,0 +1,16 @@
+
+
+ false
+ true
+ $(NoWarn);CA1001;CA1707;CA1711;CA1859;DAP005
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RegisterIdentityIntegrationTests.cs b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RegisterIdentityIntegrationTests.cs
new file mode 100644
index 00000000..53665a8a
--- /dev/null
+++ b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RegisterIdentityIntegrationTests.cs
@@ -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);
+ }
+}
diff --git a/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RequestMfaSetupIntegrationTests.cs b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RequestMfaSetupIntegrationTests.cs
new file mode 100644
index 00000000..55b6c2ff
--- /dev/null
+++ b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RequestMfaSetupIntegrationTests.cs
@@ -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(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(async () =>
+ await _mfaSql.GetIdentityAsync(nonExistentId, CancellationToken.None)
+ );
+
+ Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/tests/KArtSell.IdentityAccess.UnitTests/KArtSell.IdentityAccess.UnitTests.csproj b/tests/KArtSell.IdentityAccess.UnitTests/KArtSell.IdentityAccess.UnitTests.csproj
new file mode 100644
index 00000000..708f161f
--- /dev/null
+++ b/tests/KArtSell.IdentityAccess.UnitTests/KArtSell.IdentityAccess.UnitTests.csproj
@@ -0,0 +1,15 @@
+
+
+ false
+ true
+ $(NoWarn);CA1001;CA1707;CA1711;CA1859;DAP005
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/IdentityStateTests.cs b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/IdentityStateTests.cs
index 2549c475..b662fb11 100644
--- a/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/IdentityStateTests.cs
+++ b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/IdentityStateTests.cs
@@ -61,14 +61,14 @@ public class IdentityStateTests
foreach (var state in states)
{
var deactivated = state.Deactivate();
- Assert.Equal(IdentityState.Inactive, deactivated.Value);
+ Assert.Equal("INACTIVE", deactivated.Value);
}
}
[Fact]
public void CanRevokeFromInactive()
{
- var state = IdentityState.Parse(IdentityState.Inactive);
+ var state = IdentityState.CreateInactive();
var revoked = state.Revoke();
Assert.Equal(IdentityState.Revoked, revoked.Value);