c289a698c5
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>
193 lines
8.0 KiB
C#
193 lines
8.0 KiB
C#
using Xunit;
|
|
using Dapper;
|
|
using Npgsql;
|
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
|
using KArtSell.BuildingBlocks.Reliability;
|
|
|
|
namespace KArtSell.IdentityAccess.IntegrationTests.Features;
|
|
|
|
[Collection("Database")]
|
|
public class RegisterIdentityWithOutboxIntegrationTests : IAsyncLifetime
|
|
{
|
|
private readonly string _connectionString;
|
|
private NpgsqlDataSource _dataSource = null!;
|
|
private RegisterIdentitySql _sql = null!;
|
|
private IOutboxWriter _outboxWriter = null!;
|
|
|
|
public RegisterIdentityWithOutboxIntegrationTests()
|
|
{
|
|
_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());
|
|
_outboxWriter = new DapperOutboxWriter();
|
|
|
|
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 public.identity WHERE email LIKE 'test-outbox-%'");
|
|
await conn.ExecuteAsync("DELETE FROM building_blocks.outbox_message WHERE event_type = 'IdentityCreated'");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterIdentity_WithOutbox_WritesToBoth()
|
|
{
|
|
var email = "test-outbox-001@example.com";
|
|
var displayName = "Test Outbox 001";
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
var identityId = Guid.NewGuid();
|
|
|
|
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
|
?? throw new InvalidOperationException("Failed to open connection");
|
|
|
|
await using (conn)
|
|
{
|
|
await using var transaction = await conn.BeginTransactionAsync();
|
|
|
|
// Create identity
|
|
var createdId = await _sql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
|
Assert.NotEqual(Guid.Empty, createdId);
|
|
|
|
// Write outbox message
|
|
var outboxMessage = new OutboxMessage(
|
|
MessageId: Guid.NewGuid(),
|
|
EventType: "IdentityCreated",
|
|
SchemaVersion: 1,
|
|
PayloadJson: System.Text.Json.JsonSerializer.Serialize(new { identityId, email, displayName, correlationId }),
|
|
CorrelationId: correlationId,
|
|
OccurredAt: DateTimeOffset.UtcNow,
|
|
PayloadHash: ComputeSha256("test-payload"));
|
|
|
|
await _outboxWriter.AddAsync(conn, transaction, outboxMessage, CancellationToken.None);
|
|
await transaction.CommitAsync();
|
|
|
|
// Verify identity was created
|
|
var (returnedId, returnedEmail, _, _) = await _sql.GetIdentityAsync(createdId, CancellationToken.None);
|
|
Assert.Equal(createdId, returnedId);
|
|
Assert.Equal(email, returnedEmail);
|
|
|
|
// Verify outbox message was written
|
|
var outboxCount = await conn.QuerySingleAsync<int>(
|
|
"SELECT COUNT(1) FROM building_blocks.outbox_message WHERE correlation_id = @correlationId",
|
|
new { correlationId });
|
|
Assert.Equal(1, outboxCount);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterIdentity_RollbackOnError_RevertsBothIdentityAndOutbox()
|
|
{
|
|
var email = "test-outbox-002@example.com";
|
|
var displayName = "Test Outbox 002";
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
var identityId = Guid.NewGuid();
|
|
|
|
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
|
?? throw new InvalidOperationException("Failed to open connection");
|
|
|
|
await using (conn)
|
|
{
|
|
await using var transaction = await conn.BeginTransactionAsync();
|
|
|
|
try
|
|
{
|
|
var createdId = await _sql.CreateIdentityAsync(conn, transaction, identityId, email, displayName, correlationId, CancellationToken.None);
|
|
Assert.NotEqual(Guid.Empty, createdId);
|
|
|
|
// Write outbox message
|
|
var outboxMessage = new OutboxMessage(
|
|
MessageId: Guid.NewGuid(),
|
|
EventType: "IdentityCreated",
|
|
SchemaVersion: 1,
|
|
PayloadJson: System.Text.Json.JsonSerializer.Serialize(new { identityId, email, displayName, correlationId }),
|
|
CorrelationId: correlationId,
|
|
OccurredAt: DateTimeOffset.UtcNow,
|
|
PayloadHash: ComputeSha256("test-payload"));
|
|
|
|
await _outboxWriter.AddAsync(conn, transaction, outboxMessage, CancellationToken.None);
|
|
|
|
// Simulate error: force rollback
|
|
throw new InvalidOperationException("Simulated error");
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
}
|
|
|
|
// Verify identity was NOT created (rolled back)
|
|
var identityExists = await conn.QuerySingleAsync<bool>(
|
|
"SELECT COUNT(1) > 0 FROM public.identity WHERE email = @email",
|
|
new { email });
|
|
Assert.False(identityExists);
|
|
|
|
// Verify outbox message was NOT written (rolled back)
|
|
var outboxCount = await conn.QuerySingleAsync<int>(
|
|
"SELECT COUNT(1) FROM building_blocks.outbox_message WHERE correlation_id = @correlationId",
|
|
new { correlationId });
|
|
Assert.Equal(0, outboxCount);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterIdentity_DuplicateEmail_NoOutboxWrite()
|
|
{
|
|
var email = "test-outbox-003@example.com";
|
|
var displayName = "Test Outbox 003";
|
|
var correlationId1 = Guid.NewGuid().ToString();
|
|
var correlationId2 = Guid.NewGuid().ToString();
|
|
var identityId1 = Guid.NewGuid();
|
|
var identityId2 = Guid.NewGuid();
|
|
|
|
var conn = await _dataSource.OpenConnectionAsync() as NpgsqlConnection
|
|
?? throw new InvalidOperationException("Failed to open connection");
|
|
|
|
await using (conn)
|
|
{
|
|
// First registration: succeeds
|
|
await using (var tx1 = await conn.BeginTransactionAsync())
|
|
{
|
|
await _sql.CreateIdentityAsync(conn, tx1, identityId1, email, displayName, correlationId1, CancellationToken.None);
|
|
var msg1 = new OutboxMessage(Guid.NewGuid(), "IdentityCreated", 1,
|
|
System.Text.Json.JsonSerializer.Serialize(new { identityId1, email, displayName, correlationId1 }),
|
|
correlationId1, DateTimeOffset.UtcNow, ComputeSha256("test"));
|
|
await _outboxWriter.AddAsync(conn, tx1, msg1, CancellationToken.None);
|
|
await tx1.CommitAsync();
|
|
}
|
|
|
|
// Second registration: fails (duplicate email), should not write outbox
|
|
await using (var tx2 = await conn.BeginTransactionAsync())
|
|
{
|
|
var result = await _sql.CreateIdentityAsync(conn, tx2, identityId2, email, displayName, correlationId2, CancellationToken.None);
|
|
Assert.Equal(Guid.Empty, result); // Conflict, returns empty
|
|
// Don't write to outbox if creation failed
|
|
await tx2.CommitAsync();
|
|
}
|
|
|
|
// Verify only first outbox message exists
|
|
var outboxCount = await conn.QuerySingleAsync<int>(
|
|
"SELECT COUNT(1) FROM building_blocks.outbox_message WHERE event_type = 'IdentityCreated'");
|
|
Assert.Equal(1, outboxCount);
|
|
}
|
|
}
|
|
|
|
private static string ComputeSha256(string input)
|
|
{
|
|
using var hasher = System.Security.Cryptography.SHA256.Create();
|
|
var hash = hasher.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
|
return Convert.ToHexString(hash);
|
|
}
|
|
}
|