feat(wbs): AEG-VS-01-05 Event/Job/Inbox - Part 2-3 Complete (Outbox/Inbox + Consumers)
Part 2: Transaction + Outbox Integration - RegisterIdentityEndpoint: DbConnection → DbTransaction → Outbox write - RegisterIdentitySql: Accept NpgsqlConnection + NpgsqlTransaction (Dapper) - Fixed schema references: identity.identity → public.identity - Hash computation (SHA256) for Outbox payload integrity Part 3: Consumer + Job Implementation - IdentityCreatedConsumer: SignalR group 'identity-notifications' - MfaReminderJob: Hangfire job, 24-hour reminder, idempotent via DB tracking - IdentityAuditConsumer: Immutable append-only audit trail - Migration 0043: identity_mfa_reminder + identity_audit_log tables Testing - Unit: IdentityCreated event serialization + immutability (4 tests) - Integration: RegisterIdentityWithOutbox (3 tests: happy path, rollback, duplicate email) - Updated existing tests: Transaction management (6 test methods) Architecture - Outbox/Inbox pattern ensures exactly-once delivery - Consumers decouple from identity creation (async, independent retry) - Audit trail immutable (trigger prevents updates/deletes) - MFA reminder idempotent (tracked in DB) Status: 40% COMPLETE (event + endpoint + 3 consumers) Next: E2E tests + Hangfire job registration + Admin UI Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+54
-17
@@ -36,7 +36,7 @@ public class RegisterIdentityIntegrationTests : IAsyncLifetime
|
||||
private async Task CleanupAsync()
|
||||
{
|
||||
using var conn = await _dataSource.OpenConnectionAsync();
|
||||
await conn.ExecuteAsync("DELETE FROM identity.identity WHERE email LIKE 'test-integration-%'");
|
||||
await conn.ExecuteAsync("DELETE FROM public.identity WHERE email LIKE 'test-integration-%'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,10 +47,18 @@ public class RegisterIdentityIntegrationTests : IAsyncLifetime
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var identityId = Guid.NewGuid();
|
||||
|
||||
var createdId = await _sql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
Assert.NotEqual(Guid.Empty, createdId);
|
||||
Assert.Equal(identityId, createdId);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
var createdId = await _sql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
Assert.NotEqual(Guid.Empty, createdId);
|
||||
Assert.Equal(identityId, createdId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,11 +71,22 @@ public class RegisterIdentityIntegrationTests : IAsyncLifetime
|
||||
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);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
Assert.Equal(id1, created1);
|
||||
Assert.Equal(Guid.Empty, created2);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
var created1 = await _sql.CreateIdentityAsync(conn, transaction, id1, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
await using var transaction2 = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
var created2 = await _sql.CreateIdentityAsync(conn, transaction2, id2, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction2.CommitAsync();
|
||||
|
||||
Assert.Equal(id1, created1);
|
||||
Assert.Equal(Guid.Empty, created2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -78,13 +97,22 @@ public class RegisterIdentityIntegrationTests : IAsyncLifetime
|
||||
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);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
Assert.Equal(identityId, id);
|
||||
Assert.Equal(email, returnedEmail);
|
||||
Assert.Equal(displayName, returnedDisplayName);
|
||||
Assert.Equal("ACTIVE", state);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
await _sql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
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]
|
||||
@@ -95,10 +123,19 @@ public class RegisterIdentityIntegrationTests : IAsyncLifetime
|
||||
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);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
Assert.True(exists);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
await _sql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
var exists = await _sql.EmailExistsAsync(email, CancellationToken.None);
|
||||
|
||||
Assert.True(exists);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+56
-20
@@ -4,6 +4,7 @@ using Dapper;
|
||||
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
||||
using System.Data;
|
||||
|
||||
namespace KArtSell.IdentityAccess.IntegrationTests.ManageIdentityAndRoles;
|
||||
|
||||
@@ -39,7 +40,7 @@ public class RequestMfaSetupIntegrationTests : IAsyncLifetime
|
||||
private async Task CleanupAsync()
|
||||
{
|
||||
using var conn = await _dataSource.OpenConnectionAsync();
|
||||
await conn.ExecuteAsync("DELETE FROM identity.identity WHERE email LIKE 'test-mfa-integration-%'");
|
||||
await conn.ExecuteAsync("DELETE FROM public.identity WHERE email LIKE 'test-mfa-integration-%'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -50,13 +51,22 @@ public class RequestMfaSetupIntegrationTests : IAsyncLifetime
|
||||
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);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
await _mfaSql.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, revision, CancellationToken.None);
|
||||
var (_, newState, _) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
await _registerSql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
Assert.Equal(IdentityState.RequiresMfaSetup, newState);
|
||||
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]
|
||||
@@ -67,13 +77,21 @@ public class RequestMfaSetupIntegrationTests : IAsyncLifetime
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var identityId = Guid.NewGuid();
|
||||
|
||||
await _registerSql.CreateIdentityAsync(identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await _mfaSql.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 999, CancellationToken.None)
|
||||
);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
await _registerSql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
Assert.Contains("concurrency", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await _mfaSql.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 999, CancellationToken.None)
|
||||
);
|
||||
|
||||
Assert.Contains("concurrency", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,11 +102,20 @@ public class RequestMfaSetupIntegrationTests : IAsyncLifetime
|
||||
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);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
Assert.Equal(IdentityState.Active, state);
|
||||
Assert.Equal(1, revision);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
await _registerSql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
var (_, state, revision) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(IdentityState.Active, state);
|
||||
Assert.Equal(1, revision);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,13 +126,22 @@ public class RequestMfaSetupIntegrationTests : IAsyncLifetime
|
||||
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);
|
||||
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
||||
?? throw new InvalidOperationException("Failed to open connection");
|
||||
|
||||
await _mfaSql.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, revision1, CancellationToken.None);
|
||||
var (_, _, revision2) = await _mfaSql.GetIdentityAsync(identityId, CancellationToken.None);
|
||||
await using (conn)
|
||||
{
|
||||
await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
|
||||
await _registerSql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
Assert.Equal(revision1 + 1, revision2);
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user