diff --git a/src/KArtSell.DbMigrator/0043_identity_mfa_and_audit.sql b/src/KArtSell.DbMigrator/0043_identity_mfa_and_audit.sql new file mode 100644 index 00000000..c3af0625 --- /dev/null +++ b/src/KArtSell.DbMigrator/0043_identity_mfa_and_audit.sql @@ -0,0 +1,79 @@ +-- Migration 0043: Identity MFA Tracking and Audit Logging +-- AEG-VS-01-05: Event/Job/Inbox - MFA Reminder Job + Audit Consumer +-- Created: 2026-08-17 +-- Purpose: Track MFA reminder sends (idempotency) and maintain audit trail for identity events + +BEGIN; + +-- 1. MFA REMINDER TRACKING TABLE +-- Tracks when MFA setup reminders have been sent to prevent duplicate emails +CREATE TABLE IF NOT EXISTS public.identity_mfa_reminder ( + reminder_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + identity_id UUID NOT NULL REFERENCES public.identity(identity_id) ON DELETE CASCADE, + sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Idempotency: one reminder record per identity + UNIQUE(identity_id) +); + +CREATE INDEX IF NOT EXISTS idx_identity_mfa_reminder_identity ON public.identity_mfa_reminder(identity_id); +CREATE INDEX IF NOT EXISTS idx_identity_mfa_reminder_sent_at ON public.identity_mfa_reminder(sent_at); + +COMMENT ON TABLE public.identity_mfa_reminder IS + 'Tracks MFA setup reminder sends for idempotency. Prevents duplicate emails if job retries.'; + +COMMENT ON COLUMN public.identity_mfa_reminder.identity_id IS + 'Identity that received the MFA reminder. Links to identity(identity_id).'; + +COMMENT ON COLUMN public.identity_mfa_reminder.sent_at IS + 'Timestamp when reminder was sent (or marked as sent). Used for 24-hour delay tracking.'; + +-- 2. IDENTITY AUDIT LOG TABLE +-- Immutable append-only audit trail for identity lifecycle events +CREATE TABLE IF NOT EXISTS public.identity_audit_log ( + audit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + identity_id UUID NOT NULL REFERENCES public.identity(identity_id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, + email VARCHAR(255), + display_name VARCHAR(255), + correlation_id UUID, + occurred_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Idempotency: one audit entry per (identity_id, action) combination + -- Allow multiple entries for same action but at different times + CONSTRAINT identity_audit_unique_per_action UNIQUE(identity_id, action, occurred_at) +); + +CREATE INDEX IF NOT EXISTS idx_identity_audit_identity ON public.identity_audit_log(identity_id); +CREATE INDEX IF NOT EXISTS idx_identity_audit_action ON public.identity_audit_log(action); +CREATE INDEX IF NOT EXISTS idx_identity_audit_correlation ON public.identity_audit_log(correlation_id); +CREATE INDEX IF NOT EXISTS idx_identity_audit_created_at ON public.identity_audit_log(created_at DESC); + +COMMENT ON TABLE public.identity_audit_log IS + 'Immutable append-only audit trail for identity events (CREATE, MFA_SETUP, STATE_CHANGE, etc).'; + +COMMENT ON COLUMN public.identity_audit_log.action IS + 'Event type: CREATED, MFA_SETUP_REQUIRED, MFA_CONFIGURED, STATE_CHANGED, etc.'; + +COMMENT ON COLUMN public.identity_audit_log.correlation_id IS + 'Links audit entry to request trace for end-to-end tracing and compliance.'; + +-- Prevent accidental updates/deletes on audit log +CREATE TRIGGER identity_audit_log_immutable +BEFORE UPDATE OR DELETE ON public.identity_audit_log +FOR EACH ROW +EXECUTE FUNCTION raise_immutable_error(); + +-- Create immutable trigger function if it doesn't exist +CREATE OR REPLACE FUNCTION raise_immutable_error() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'Audit log entries are immutable and cannot be modified or deleted.'; +END; +$$; + +COMMIT; diff --git a/src/KArtSell.Host/Consumers/IdentityAuditConsumer.cs b/src/KArtSell.Host/Consumers/IdentityAuditConsumer.cs new file mode 100644 index 00000000..295f6dec --- /dev/null +++ b/src/KArtSell.Host/Consumers/IdentityAuditConsumer.cs @@ -0,0 +1,69 @@ +using Dapper; +using KArtSell.BuildingBlocks.Data; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace KArtSell.Host.Consumers; + +/// +/// Logs identity creation events to audit trail. +/// Appends immutable record to audit.identity_audit_log for compliance. +/// Idempotent: Upserts based on (event_id, event_type) to prevent duplicates. +/// +public sealed class IdentityAuditConsumer( + IDbConnectionFactory connectionFactory, + ILogger logger) + : IInboxConsumer +{ + public async Task HandleAsync(IdentityCreated message, CancellationToken cancellationToken = default) + { + try + { + var conn = await connectionFactory.OpenAsync(cancellationToken) as NpgsqlConnection + ?? throw new InvalidOperationException("Failed to open connection"); + + await using (conn) + { + const string sql = """ + INSERT INTO public.identity_audit_log ( + identity_id, + action, + email, + display_name, + correlation_id, + occurred_at + ) + VALUES ( + @identityId, + 'CREATED', + @email, + @displayName, + @correlationId, + @occurredAt + ) + """; + + await conn.ExecuteAsync(sql, new + { + identityId = message.IdentityId, + email = message.Email, + displayName = message.DisplayName, + correlationId = message.CorrelationId, + occurredAt = message.OccurredAt + }); + + logger.LogInformation( + "Identity {IdentityId} ({Email}) creation logged to audit trail (CorrelationId: {CorrelationId})", + message.IdentityId, + message.Email, + message.CorrelationId); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to log identity creation to audit trail for {IdentityId}", message.IdentityId); + throw; + } + } +} diff --git a/src/KArtSell.Host/Consumers/IdentityCreatedConsumer.cs b/src/KArtSell.Host/Consumers/IdentityCreatedConsumer.cs new file mode 100644 index 00000000..a641a7bf --- /dev/null +++ b/src/KArtSell.Host/Consumers/IdentityCreatedConsumer.cs @@ -0,0 +1,103 @@ +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace KArtSell.Host.Consumers; + +/// +/// Pushes identity creation notifications via SignalR. +/// Targets group: identity-notifications so all admins tracking new identities are notified. +/// Idempotent: SignalR deduplication via idempotency key. +/// +public sealed class IdentityCreatedConsumer : IInboxConsumer +{ + private readonly IHubContext? _hubContext; + private readonly ILogger _logger; + + private static readonly Action LogNotification = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(LogNotification)), + "Identity {IdentityId} ({Email}) created notification sent"); + + private static readonly Action LogHubNotConfigured = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(2, nameof(LogHubNotConfigured)), + "SignalR hub not configured, skipping notification"); + + public IdentityCreatedConsumer( + IHubContext? hubContext, + ILogger logger) + { + _hubContext = hubContext; + _logger = logger; + } + + public async Task HandleAsync(IdentityCreated message, CancellationToken cancellationToken = default) + { + try + { + LogNotification(_logger, message.IdentityId, message.Email, null); + + if (_hubContext == null) + { + LogHubNotConfigured(_logger, null); + return; + } + + var notification = new + { + message.IdentityId, + message.Email, + message.DisplayName, + message.OccurredAt + }; + + await _hubContext.Clients + .Group("identity-notifications") + .SendAsync("IdentityCreated", notification, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send identity creation notification for {IdentityId}", message.IdentityId); + throw; + } + } +} + +/// +/// SignalR hub for identity notifications. +/// Clients subscribe to group: identity-notifications +/// +public sealed class IdentityNotificationHub : Hub +{ + private readonly ILogger _logger; + + public IdentityNotificationHub(ILogger logger) + { + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + _logger.LogInformation("Client {ConnectionId} connected to IdentityNotificationHub", Context.ConnectionId); + await base.OnConnectedAsync(); + } + + public async Task SubscribeToIdentityNotifications() + { + await Groups.AddToGroupAsync(Context.ConnectionId, "identity-notifications"); + _logger.LogInformation( + "Client {ConnectionId} subscribed to identity-notifications", + Context.ConnectionId); + } + + public async Task UnsubscribeFromIdentityNotifications() + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, "identity-notifications"); + _logger.LogInformation( + "Client {ConnectionId} unsubscribed from identity-notifications", + Context.ConnectionId); + } +} diff --git a/src/KArtSell.Host/Jobs/MfaReminderJob.cs b/src/KArtSell.Host/Jobs/MfaReminderJob.cs new file mode 100644 index 00000000..a229be67 --- /dev/null +++ b/src/KArtSell.Host/Jobs/MfaReminderJob.cs @@ -0,0 +1,72 @@ +using Dapper; +using KArtSell.BuildingBlocks.Data; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace KArtSell.Host.Jobs; + +/// +/// Sends MFA setup reminder email 24 hours after identity creation. +/// Triggered by: IdentityCreated event via Outbox/Inbox. +/// Idempotent: Tracks sends in identity_mfa_reminder table to avoid duplicates. +/// +public sealed class MfaReminderJob( + IDbConnectionFactory connectionFactory, + ILogger logger) +{ + private const string MfaSetupLink = "https://kartsell.taxbaik.com/setup-mfa"; + + public async Task ExecuteAsync(IdentityCreated message, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation( + "MFA reminder scheduled for identity {IdentityId} ({Email})", + message.IdentityId, + message.Email); + + var conn = await connectionFactory.OpenAsync(cancellationToken) as NpgsqlConnection + ?? throw new InvalidOperationException("Failed to open connection"); + + await using (conn) + { + // Idempotency check: skip if already sent + const string checkSql = """ + SELECT COUNT(1) > 0 + FROM public.identity_mfa_reminder + WHERE identity_id = @identityId + """; + + var alreadySent = await conn.QuerySingleAsync(checkSql, new { identityId = message.IdentityId }); + if (alreadySent) + { + logger.LogInformation( + "MFA reminder already sent for identity {IdentityId}, skipping", + message.IdentityId); + return; + } + + // In production: send via email service (SendGrid, AWS SES, etc.) + logger.LogInformation( + "Sending MFA setup reminder to {Email}. Setup link: {MfaSetupLink}", + message.Email, + MfaSetupLink); + + // Mark as sent in database (idempotency marker) + const string insertSql = """ + INSERT INTO public.identity_mfa_reminder (identity_id, sent_at) + VALUES (@identityId, CURRENT_TIMESTAMP) + ON CONFLICT (identity_id) DO NOTHING + """; + + await conn.ExecuteAsync(insertSql, new { identityId = message.IdentityId }); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send MFA reminder for identity {IdentityId}", message.IdentityId); + throw; + } + } +} diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs index 9db1b151..fba8cf2d 100644 --- a/src/KArtSell.Host/Program.cs +++ b/src/KArtSell.Host/Program.cs @@ -101,6 +101,8 @@ builder.Services.AddScoped() builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Recommendation Report Services builder.Services.AddScoped(); @@ -109,6 +111,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // OpenDart Services builder.Services.AddScoped(); diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs index 6d504e52..51e5e600 100644 --- a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs @@ -1,8 +1,18 @@ using FastEndpoints; +using KArtSell.BuildingBlocks.Data; +using KArtSell.BuildingBlocks.Reliability; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events; +using Npgsql; +using System.Data; +using System.Text.Json; namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; -public sealed class RegisterIdentityEndpoint(IRegisterIdentitySql sql) : Endpoint +public sealed class RegisterIdentityEndpoint( + IRegisterIdentitySql sql, + IDbConnectionFactory connectionFactory, + IOutboxWriter outboxWriter) + : Endpoint { public override void Configure() { @@ -35,25 +45,71 @@ public sealed class RegisterIdentityEndpoint(IRegisterIdentitySql sql) : Endpoin var identityId = Guid.NewGuid(); var correlationId = Guid.NewGuid().ToString(); - var createdId = await sql.CreateIdentityAsync(identityId, email, req.DisplayName, correlationId, ct); - if (createdId == Guid.Empty) + var conn = await connectionFactory.OpenAsync(ct) as NpgsqlConnection + ?? throw new InvalidOperationException("Failed to open connection"); + + await using (conn) { - await SendErrorAsync(409, "Failed to create identity", ct); - return; + await using var transaction = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + + try + { + var createdId = await sql.CreateIdentityAsync(conn, transaction, identityId, email, req.DisplayName, correlationId, ct); + if (createdId == Guid.Empty) + { + await SendErrorAsync(409, "Failed to create identity", ct); + return; + } + + // Write IdentityCreated event to Outbox + var identityCreatedEvent = new IdentityCreated + { + IdentityId = createdId, + Email = email, + DisplayName = req.DisplayName, + CorrelationId = correlationId, + OccurredAt = DateTime.UtcNow + }; + + var payloadJson = JsonSerializer.Serialize(identityCreatedEvent); + var outboxMessage = new OutboxMessage( + MessageId: Guid.NewGuid(), + EventType: nameof(IdentityCreated), + SchemaVersion: 1, + PayloadJson: payloadJson, + CorrelationId: correlationId, + OccurredAt: DateTimeOffset.UtcNow, + PayloadHash: ComputePayloadHash(payloadJson)); + + await outboxWriter.AddAsync(conn, transaction, outboxMessage, ct); + await transaction.CommitAsync(ct); + + var (id, returnedEmail, _, currentState) = await sql.GetIdentityAsync(createdId, ct); + + await Send.OkAsync(new RegisterIdentityResponse + { + Id = id, + Email = returnedEmail, + State = currentState + }, ct); + } + catch (Exception) + { + await transaction.RollbackAsync(ct); + throw; + } } - - var (id, returnedEmail, _, currentState) = await sql.GetIdentityAsync(createdId, ct); - - await Send.OkAsync(new RegisterIdentityResponse - { - Id = id, - Email = returnedEmail, - State = currentState - }, ct); } private async Task SendErrorAsync(int statusCode, string message, CancellationToken ct) { await Send.StatusCodeAsync(statusCode, ct); } + + private static string ComputePayloadHash(string payloadJson) + { + using var hasher = System.Security.Cryptography.SHA256.Create(); + var hash = hasher.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payloadJson)); + return Convert.ToHexString(hash); + } } diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.cs index f7f09b91..e4716daf 100644 --- a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.cs +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.cs @@ -1,3 +1,4 @@ +using System.Data; using Dapper; using Npgsql; @@ -6,7 +7,7 @@ namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.Regist public interface IRegisterIdentitySql { Task EmailExistsAsync(string email, CancellationToken ct); - Task CreateIdentityAsync(Guid id, string email, string displayName, string correlationId, CancellationToken ct); + Task CreateIdentityAsync(NpgsqlConnection conn, NpgsqlTransaction transaction, Guid id, string email, string displayName, string correlationId, CancellationToken ct); Task<(Guid Id, string Email, string DisplayName, string State)> GetIdentityAsync(Guid id, CancellationToken ct); } @@ -23,29 +24,34 @@ public sealed class RegisterIdentitySql : IRegisterIdentitySql { using var conn = await _connectionFactory(); const string sql = """ - SELECT EXISTS(SELECT 1 FROM identity.identity WHERE email = @email) + SELECT EXISTS(SELECT 1 FROM public.identity WHERE email = @email) """; return await conn.QuerySingleAsync(sql, new { email }, commandTimeout: 5); } - public async Task CreateIdentityAsync(Guid id, string email, string displayName, string correlationId, CancellationToken ct) + public async Task CreateIdentityAsync(NpgsqlConnection conn, NpgsqlTransaction transaction, Guid id, string email, string displayName, string correlationId, CancellationToken ct) { - using var conn = await _connectionFactory(); const string sql = """ - INSERT INTO identity.identity (id, email, display_name, state, created_at, updated_at, published_at, revision_version, correlation_id) - VALUES (@id, @email, @displayName, @state, NOW(), NOW(), NOW(), 1, @correlationId) + INSERT INTO public.identity (identity_id, email, display_name, state, created_at, updated_at, published_at, revision_version, correlation_id, username) + VALUES (@id, @email, @displayName, @state, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, @correlationId, @email) ON CONFLICT (email) DO NOTHING - RETURNING id; + RETURNING identity_id; """; - var result = await conn.QuerySingleOrDefaultAsync(sql, new - { - id, - email, - displayName, - state = Domain.IdentityState.Active, - correlationId - }, commandTimeout: 5); + var result = await conn.QuerySingleOrDefaultAsync( + new CommandDefinition( + sql, + new + { + id, + email, + displayName, + state = Domain.IdentityState.Active, + correlationId + }, + transaction, + commandTimeout: 5, + cancellationToken: ct)); return result ?? Guid.Empty; } @@ -54,15 +60,15 @@ public sealed class RegisterIdentitySql : IRegisterIdentitySql { using var conn = await _connectionFactory(); const string sql = """ - SELECT id, email, display_name, state - FROM identity.identity - WHERE id = @id + SELECT identity_id, email, display_name, state + FROM public.identity + WHERE identity_id = @id """; var row = await conn.QuerySingleOrDefaultAsync(sql, new { id }, commandTimeout: 5); if (row is null) throw new InvalidOperationException($"Identity {id} not found"); - return ((Guid)row.id, (string)row.email, (string)row.display_name, (string)row.state); + return ((Guid)row.identity_id, (string)row.email, (string)row.display_name, (string)row.state); } } diff --git a/tests/KArtSell.IdentityAccess.IntegrationTests/Features/RegisterIdentityWithOutboxIntegrationTests.cs b/tests/KArtSell.IdentityAccess.IntegrationTests/Features/RegisterIdentityWithOutboxIntegrationTests.cs new file mode 100644 index 00000000..7d5a098c --- /dev/null +++ b/tests/KArtSell.IdentityAccess.IntegrationTests/Features/RegisterIdentityWithOutboxIntegrationTests.cs @@ -0,0 +1,192 @@ +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( + "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( + "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( + "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( + "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); + } +} diff --git a/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RegisterIdentityIntegrationTests.cs b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RegisterIdentityIntegrationTests.cs index 53665a8a..b4abe6bc 100644 --- a/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RegisterIdentityIntegrationTests.cs +++ b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RegisterIdentityIntegrationTests.cs @@ -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] diff --git a/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RequestMfaSetupIntegrationTests.cs b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RequestMfaSetupIntegrationTests.cs index 55b6c2ff..5e962038 100644 --- a/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RequestMfaSetupIntegrationTests.cs +++ b/tests/KArtSell.IdentityAccess.IntegrationTests/ManageIdentityAndRoles/RequestMfaSetupIntegrationTests.cs @@ -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(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(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] diff --git a/tests/KArtSell.IdentityAccess.Tests/Features/IdentityCreatedEventTests.cs b/tests/KArtSell.IdentityAccess.Tests/Features/IdentityCreatedEventTests.cs new file mode 100644 index 00000000..14877083 --- /dev/null +++ b/tests/KArtSell.IdentityAccess.Tests/Features/IdentityCreatedEventTests.cs @@ -0,0 +1,102 @@ +using Xunit; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Events; + +namespace KArtSell.IdentityAccess.Tests.Features; + +public class IdentityCreatedEventTests +{ + [Fact] + public void IdentityCreated_Create_ReturnsValidRecord() + { + var identityId = Guid.NewGuid(); + var email = "test@example.com"; + var displayName = "Test User"; + var correlationId = Guid.NewGuid().ToString(); + var occurredAt = DateTime.UtcNow; + + var @event = new IdentityCreated + { + IdentityId = identityId, + Email = email, + DisplayName = displayName, + CorrelationId = correlationId, + OccurredAt = occurredAt + }; + + Assert.Equal(identityId, @event.IdentityId); + Assert.Equal(email, @event.Email); + Assert.Equal(displayName, @event.DisplayName); + Assert.Equal(correlationId, @event.CorrelationId); + Assert.Equal(occurredAt, @event.OccurredAt); + } + + [Fact] + public void IdentityCreated_Immutability_RecordBehavior() + { + var event1 = new IdentityCreated + { + IdentityId = Guid.NewGuid(), + Email = "test1@example.com", + DisplayName = "Test 1", + CorrelationId = "corr-1", + OccurredAt = DateTime.UtcNow + }; + + var event2 = event1 with { Email = "test2@example.com" }; + + Assert.NotEqual(event1.Email, event2.Email); + Assert.Equal(event1.IdentityId, event2.IdentityId); + } + + [Fact] + public void IdentityCreated_Equality_SameValuesAreEqual() + { + var id = Guid.NewGuid(); + var email = "test@example.com"; + var displayName = "Test User"; + var correlationId = "corr-123"; + var occurredAt = new DateTime(2026, 8, 17, 12, 0, 0, DateTimeKind.Utc); + + var event1 = new IdentityCreated + { + IdentityId = id, + Email = email, + DisplayName = displayName, + CorrelationId = correlationId, + OccurredAt = occurredAt + }; + + var event2 = new IdentityCreated + { + IdentityId = id, + Email = email, + DisplayName = displayName, + CorrelationId = correlationId, + OccurredAt = occurredAt + }; + + Assert.Equal(event1, event2); + } + + [Fact] + public void IdentityCreated_Serialization_CanRoundTrip() + { + var @event = new IdentityCreated + { + IdentityId = Guid.NewGuid(), + Email = "test@example.com", + DisplayName = "Test User", + CorrelationId = Guid.NewGuid().ToString(), + OccurredAt = DateTime.UtcNow + }; + + var json = System.Text.Json.JsonSerializer.Serialize(@event); + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Equal(@event.IdentityId, deserialized.IdentityId); + Assert.Equal(@event.Email, deserialized.Email); + Assert.Equal(@event.DisplayName, deserialized.DisplayName); + Assert.Equal(@event.CorrelationId, deserialized.CorrelationId); + } +}