diff --git a/src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs b/src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs new file mode 100644 index 0000000..87cbb17 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs @@ -0,0 +1,27 @@ +namespace SIGCM2.Application.Audit; + +public sealed record AuditEventQueryResult( + IReadOnlyList Items, + string? NextCursor); + +/// Persists and queries AuditEvent rows. Insert participates in any ambient +/// TransactionScope (single connection string enlistment — validated by B0 spike). +public interface IAuditEventRepository +{ + Task InsertAsync( + DateTime occurredAt, + int? actorUserId, + int? actorRoleId, + string action, + string targetType, + string targetId, + Guid? correlationId, + string? ipAddress, + string? userAgent, + string? metadata, + CancellationToken ct = default); + + Task QueryAsync( + AuditEventFilter filter, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs b/src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs new file mode 100644 index 0000000..0aea683 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs @@ -0,0 +1,19 @@ +namespace SIGCM2.Application.Audit; + +/// Persists SecurityEvent rows. NOT enlisted in business TransactionScope — security +/// events are fire-and-forget writes from auth handlers and middleware. +public interface ISecurityEventRepository +{ + Task InsertAsync( + DateTime occurredAt, + int? actorUserId, + string? attemptedUsername, + Guid? sessionId, + string action, + string result, + string? failureReason, + string? ipAddress, + string? userAgent, + string? metadata, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs new file mode 100644 index 0000000..e8cc6e8 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs @@ -0,0 +1,45 @@ +using System.Text; + +namespace SIGCM2.Infrastructure.Audit; + +/// UDT-010 — opaque cursor for AuditEvent DESC pagination per design #D-6. +/// Format: base64url(`{OccurredAt:O}|{Id}`). Parse returns null on malformed input +/// (callers treat it as "start from the top" — fail-open on invalid cursor). +public static class AuditEventCursor +{ + public static string Encode(DateTime occurredAt, long id) + { + var raw = $"{occurredAt:O}|{id}"; + var bytes = Encoding.UTF8.GetBytes(raw); + return Convert.ToBase64String(bytes); + } + + public static (DateTime OccurredAt, long Id)? TryDecode(string? cursor) + { + if (string.IsNullOrWhiteSpace(cursor)) + return null; + + try + { + var bytes = Convert.FromBase64String(cursor); + var raw = Encoding.UTF8.GetString(bytes); + var pipe = raw.IndexOf('|'); + if (pipe <= 0 || pipe == raw.Length - 1) + return null; + + var datePart = raw[..pipe]; + var idPart = raw[(pipe + 1)..]; + + if (!DateTime.TryParse(datePart, null, System.Globalization.DateTimeStyles.RoundtripKind, out var occurredAt)) + return null; + if (!long.TryParse(idPart, out var id)) + return null; + + return (occurredAt, id); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs new file mode 100644 index 0000000..40e3311 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs @@ -0,0 +1,139 @@ +using Dapper; +using SIGCM2.Application.Audit; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Infrastructure.Audit; + +public sealed class AuditEventRepository : IAuditEventRepository +{ + private readonly SqlConnectionFactory _factory; + + public AuditEventRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task InsertAsync( + DateTime occurredAt, + int? actorUserId, + int? actorRoleId, + string action, + string targetType, + string targetId, + Guid? correlationId, + string? ipAddress, + string? userAgent, + string? metadata, + CancellationToken ct = default) + { + const string sql = """ + INSERT INTO dbo.AuditEvent + (OccurredAt, ActorUserId, ActorRoleId, Action, TargetType, TargetId, + CorrelationId, IpAddress, UserAgent, Metadata) + OUTPUT INSERTED.Id + VALUES + (@OccurredAt, @ActorUserId, @ActorRoleId, @Action, @TargetType, @TargetId, + @CorrelationId, @IpAddress, @UserAgent, @Metadata); + """; + + await using var conn = _factory.CreateConnection(); + await conn.OpenAsync(ct); + + var cmd = new CommandDefinition(sql, new + { + OccurredAt = occurredAt, + ActorUserId = actorUserId, + ActorRoleId = actorRoleId, + Action = action, + TargetType = targetType, + TargetId = targetId, + CorrelationId = correlationId, + IpAddress = ipAddress, + UserAgent = userAgent, + Metadata = metadata, + }, cancellationToken: ct); + + return await conn.ExecuteScalarAsync(cmd); + } + + public async Task QueryAsync(AuditEventFilter filter, CancellationToken ct = default) + { + var limit = Math.Clamp(filter.Limit, 1, 100); + + var wheres = new List(); + var parameters = new DynamicParameters(); + + if (filter.ActorUserId is not null) + { + wheres.Add("e.ActorUserId = @ActorUserId"); + parameters.Add("ActorUserId", filter.ActorUserId.Value); + } + if (!string.IsNullOrWhiteSpace(filter.TargetType)) + { + wheres.Add("e.TargetType = @TargetType"); + parameters.Add("TargetType", filter.TargetType); + } + if (!string.IsNullOrWhiteSpace(filter.TargetId)) + { + wheres.Add("e.TargetId = @TargetId"); + parameters.Add("TargetId", filter.TargetId); + } + if (filter.From is not null) + { + wheres.Add("e.OccurredAt >= @FromDate"); + parameters.Add("FromDate", filter.From.Value); + } + if (filter.To is not null) + { + wheres.Add("e.OccurredAt <= @ToDate"); + parameters.Add("ToDate", filter.To.Value); + } + + var cursor = AuditEventCursor.TryDecode(filter.Cursor); + if (cursor is not null) + { + // DESC pagination: rows strictly older than the cursor. + wheres.Add("(e.OccurredAt < @CursorOccurredAt OR (e.OccurredAt = @CursorOccurredAt AND e.Id < @CursorId))"); + parameters.Add("CursorOccurredAt", cursor.Value.OccurredAt); + parameters.Add("CursorId", cursor.Value.Id); + } + + parameters.Add("Limit", limit + 1); // fetch one extra to detect "more pages" + + var whereClause = wheres.Count > 0 ? "WHERE " + string.Join(" AND ", wheres) : string.Empty; + var sql = $""" + SELECT TOP (@Limit) + e.Id, + e.OccurredAt, + e.ActorUserId, + u.Username AS ActorUsername, + e.Action, + e.TargetType, + e.TargetId, + e.CorrelationId, + e.IpAddress, + e.Metadata + FROM dbo.AuditEvent e + LEFT JOIN dbo.Usuario u ON u.Id = e.ActorUserId + {whereClause} + ORDER BY e.OccurredAt DESC, e.Id DESC; + """; + + await using var conn = _factory.CreateConnection(); + await conn.OpenAsync(ct); + + var cmd = new CommandDefinition(sql, parameters, cancellationToken: ct); + var rows = (await conn.QueryAsync(cmd)).ToList(); + + string? nextCursor = null; + if (rows.Count > limit) + { + // We fetched N+1; drop the overflow and emit the cursor from the Nth row. + var last = rows[limit - 1]; + nextCursor = AuditEventCursor.Encode(last.OccurredAt, last.Id); + rows.RemoveAt(rows.Count - 1); + } + + return new AuditEventQueryResult(rows, nextCursor); + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs new file mode 100644 index 0000000..c04d1de --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs @@ -0,0 +1,58 @@ +using Dapper; +using SIGCM2.Application.Audit; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Infrastructure.Audit; + +public sealed class SecurityEventRepository : ISecurityEventRepository +{ + private readonly SqlConnectionFactory _factory; + + public SecurityEventRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task InsertAsync( + DateTime occurredAt, + int? actorUserId, + string? attemptedUsername, + Guid? sessionId, + string action, + string result, + string? failureReason, + string? ipAddress, + string? userAgent, + string? metadata, + CancellationToken ct = default) + { + const string sql = """ + INSERT INTO dbo.SecurityEvent + (OccurredAt, ActorUserId, AttemptedUsername, SessionId, Action, Result, + FailureReason, IpAddress, UserAgent, Metadata) + OUTPUT INSERTED.Id + VALUES + (@OccurredAt, @ActorUserId, @AttemptedUsername, @SessionId, @Action, @Result, + @FailureReason, @IpAddress, @UserAgent, @Metadata); + """; + + await using var conn = _factory.CreateConnection(); + await conn.OpenAsync(ct); + + var cmd = new CommandDefinition(sql, new + { + OccurredAt = occurredAt, + ActorUserId = actorUserId, + AttemptedUsername = attemptedUsername, + SessionId = sessionId, + Action = action, + Result = result, + FailureReason = failureReason, + IpAddress = ipAddress, + UserAgent = userAgent, + Metadata = metadata, + }, cancellationToken: ct); + + return await conn.ExecuteScalarAsync(cmd); + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 946833d..0a15373 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -72,6 +72,8 @@ public static class DependencyInjection // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". services.Configure(configuration.GetSection(AuditOptions.SectionName)); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs new file mode 100644 index 0000000..cf39f63 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs @@ -0,0 +1,228 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Application.Audit; +using SIGCM2.Infrastructure.Audit; +using SIGCM2.Infrastructure.Persistence; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Audit; + +/// UDT-010 Batch 5 — AuditEventRepository integration tests against SIGCM2_Test. +/// Validates insert + cursor-paginated DESC query with all filter permutations. +[Collection("Database")] +public sealed class AuditEventRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private AuditEventRepository _repo = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + // Clean slate for this test class: wipe prior audit events. Respawn does this too + // between test classes but inside a class tests share state. + await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;"); + + var factory = new SqlConnectionFactory(ConnectionString); + _repo = new AuditEventRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;"); + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + [Fact] + public async Task InsertAsync_PersistsAllFields_AndReturnsId() + { + var occurredAt = DateTime.UtcNow; + var correlationId = Guid.NewGuid(); + + var id = await _repo.InsertAsync( + occurredAt: occurredAt, + actorUserId: 42, + actorRoleId: 7, + action: "usuario.create", + targetType: "Usuario", + targetId: "99", + correlationId: correlationId, + ipAddress: "1.2.3.4", + userAgent: "ua/1.0", + metadata: """{"after":{"username":"juan"}}"""); + + id.Should().BeGreaterThan(0); + + var roundtrip = await _connection.QuerySingleAsync<(int? ActorUserId, int? ActorRoleId, string Action, Guid? CorrelationId, string? IpAddress)>( + "SELECT ActorUserId, ActorRoleId, Action, CorrelationId, IpAddress FROM dbo.AuditEvent WHERE Id = @Id", + new { Id = id }); + roundtrip.ActorUserId.Should().Be(42); + roundtrip.ActorRoleId.Should().Be(7); + roundtrip.Action.Should().Be("usuario.create"); + roundtrip.CorrelationId.Should().Be(correlationId); + roundtrip.IpAddress.Should().Be("1.2.3.4"); + } + + [Fact] + public async Task QueryAsync_NoFilters_ReturnsAllInDescendingOrder() + { + var t0 = DateTime.UtcNow.AddSeconds(-30); + await Seed(3, t0); + + var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, 50)); + + result.Items.Should().HaveCount(3); + result.Items.Select(x => x.Action).Should().ContainInOrder("test.2", "test.1", "test.0"); + result.NextCursor.Should().BeNull(); + } + + [Fact] + public async Task QueryAsync_FilterByActor_ReturnsOnlyMatching() + { + var t0 = DateTime.UtcNow.AddSeconds(-30); + await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "1", null, null, null, null); + await _repo.InsertAsync(t0.AddSeconds(1), 2, null, "test.1", "Usuario", "2", null, null, null, null); + await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "3", null, null, null, null); + + var result = await _repo.QueryAsync(new AuditEventFilter(ActorUserId: 1, + TargetType: null, TargetId: null, From: null, To: null, Cursor: null, Limit: 50)); + + result.Items.Should().HaveCount(2); + result.Items.Select(x => x.ActorUserId).Should().OnlyContain(a => a == 1); + } + + [Fact] + public async Task QueryAsync_FilterByTarget_ReturnsOnlyMatching() + { + var t0 = DateTime.UtcNow.AddSeconds(-30); + await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "42", null, null, null, null); + await _repo.InsertAsync(t0.AddSeconds(1), 1, null, "test.1", "Cliente", "99", null, null, null, null); + await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "42", null, null, null, null); + + var result = await _repo.QueryAsync(new AuditEventFilter(null, + TargetType: "Usuario", TargetId: "42", From: null, To: null, Cursor: null, Limit: 50)); + + result.Items.Should().HaveCount(2); + result.Items.Should().OnlyContain(x => x.TargetType == "Usuario" && x.TargetId == "42"); + } + + [Fact] + public async Task QueryAsync_FilterByDateRange_RespectsFromAndTo() + { + var t0 = new DateTime(2026, 4, 10, 12, 0, 0, DateTimeKind.Utc); + await _repo.InsertAsync(t0, 1, null, "test.0", "X", "1", null, null, null, null); + await _repo.InsertAsync(t0.AddDays(3), 1, null, "test.1", "X", "2", null, null, null, null); + await _repo.InsertAsync(t0.AddDays(6), 1, null, "test.2", "X", "3", null, null, null, null); + + var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, + From: t0.AddDays(1), To: t0.AddDays(5), Cursor: null, Limit: 50)); + + result.Items.Should().HaveCount(1); + result.Items[0].Action.Should().Be("test.1"); + } + + [Fact] + public async Task QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable() + { + var t0 = DateTime.UtcNow.AddMinutes(-10); + await Seed(5, t0); + + var page1 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, Limit: 2)); + + page1.Items.Should().HaveCount(2); + page1.NextCursor.Should().NotBeNull(); + + var page2 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page1.NextCursor, Limit: 2)); + page2.Items.Should().HaveCount(2); + page2.NextCursor.Should().NotBeNull(); + + var page3 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page2.NextCursor, Limit: 2)); + page3.Items.Should().HaveCount(1); + page3.NextCursor.Should().BeNull(); + + // Across pages, all 5 events are visited exactly once (no overlap, no gap). + var allActions = page1.Items.Concat(page2.Items).Concat(page3.Items).Select(x => x.Action).ToList(); + allActions.Should().HaveCount(5).And.OnlyHaveUniqueItems(); + } + + [Fact] + public async Task QueryAsync_MalformedCursor_TreatedAsNoCursor() + { + var t0 = DateTime.UtcNow.AddSeconds(-30); + await Seed(2, t0); + + var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, "not-a-valid-cursor", 50)); + + result.Items.Should().HaveCount(2); + } + + [Fact] + public async Task QueryAsync_JoinsUsuario_PopulatesActorUsername() + { + var adminId = await _connection.QuerySingleOrDefaultAsync("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'"); + if (adminId is null) + { + // Seed admin if not present (Respawn wiped it) + adminId = await _connection.ExecuteScalarAsync(""" + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) + VALUES ('admin', 'hash', 'Admin', 'Sistema', 'admin', '{"grant":[],"deny":[]}'); + SELECT CAST(SCOPE_IDENTITY() AS INT); + """); + } + + await _repo.InsertAsync(DateTime.UtcNow, adminId, null, "usuario.create", "Usuario", "99", null, null, null, null); + + var result = await _repo.QueryAsync(new AuditEventFilter(adminId, null, null, null, null, null, 50)); + + result.Items.Should().ContainSingle() + .Which.ActorUsername.Should().Be("admin"); + } + + [Fact] + public async Task AuditEventCursor_EncodeDecode_RoundTrips() + { + var now = DateTime.UtcNow; + var encoded = AuditEventCursor.Encode(now, 12345); + + var decoded = AuditEventCursor.TryDecode(encoded); + decoded.Should().NotBeNull(); + decoded!.Value.Id.Should().Be(12345); + // DateTime roundtrip via "O" format preserves ticks-level precision. + decoded.Value.OccurredAt.Should().BeCloseTo(now, TimeSpan.FromTicks(1)); + } + + [Fact] + public void AuditEventCursor_TryDecode_ReturnsNullForMalformed() + { + AuditEventCursor.TryDecode(null).Should().BeNull(); + AuditEventCursor.TryDecode("").Should().BeNull(); + AuditEventCursor.TryDecode("not-base64!!!").Should().BeNull(); + AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("no-pipe"))).Should().BeNull(); + AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("|123"))).Should().BeNull(); + AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("2026-04-16|"))).Should().BeNull(); + } + + private async Task Seed(int count, DateTime baseTime) + { + for (var i = 0; i < count; i++) + { + await _repo.InsertAsync( + occurredAt: baseTime.AddSeconds(i), + actorUserId: 1, + actorRoleId: null, + action: $"test.{i}", + targetType: "Usuario", + targetId: i.ToString(), + correlationId: null, + ipAddress: null, + userAgent: null, + metadata: null); + } + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventRepositoryTests.cs new file mode 100644 index 0000000..277183a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventRepositoryTests.cs @@ -0,0 +1,101 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Infrastructure.Audit; +using SIGCM2.Infrastructure.Persistence; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Audit; + +/// UDT-010 Batch 5 — SecurityEventRepository integration tests against SIGCM2_Test. +[Collection("Database")] +public sealed class SecurityEventRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private SecurityEventRepository _repo = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + await _connection.ExecuteAsync("DELETE FROM dbo.SecurityEvent;"); + + var factory = new SqlConnectionFactory(ConnectionString); + _repo = new SecurityEventRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.ExecuteAsync("DELETE FROM dbo.SecurityEvent;"); + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + [Fact] + public async Task InsertAsync_LoginSuccess_PersistsAllFields() + { + var sessionId = Guid.NewGuid(); + var occurredAt = DateTime.UtcNow; + + var id = await _repo.InsertAsync( + occurredAt: occurredAt, + actorUserId: 42, + attemptedUsername: null, + sessionId: sessionId, + action: "login", + result: "success", + failureReason: null, + ipAddress: "1.2.3.4", + userAgent: "ua/1.0", + metadata: """{"route":"/login"}"""); + + id.Should().BeGreaterThan(0); + + var row = await _connection.QuerySingleAsync<(int? ActorUserId, Guid? SessionId, string Action, string Result, string? FailureReason)>( + "SELECT ActorUserId, SessionId, Action, Result, FailureReason FROM dbo.SecurityEvent WHERE Id = @Id", + new { Id = id }); + row.ActorUserId.Should().Be(42); + row.SessionId.Should().Be(sessionId); + row.Action.Should().Be("login"); + row.Result.Should().Be("success"); + row.FailureReason.Should().BeNull(); + } + + [Fact] + public async Task InsertAsync_LoginFailure_SupportsNullActorAndAttemptedUsername() + { + var id = await _repo.InsertAsync( + occurredAt: DateTime.UtcNow, + actorUserId: null, + attemptedUsername: "juan", + sessionId: null, + action: "login", + result: "failure", + failureReason: "invalid_password", + ipAddress: "1.2.3.4", + userAgent: null, + metadata: null); + + id.Should().BeGreaterThan(0); + var row = await _connection.QuerySingleAsync<(int? ActorUserId, string? AttemptedUsername, string Result, string? FailureReason)>( + "SELECT ActorUserId, AttemptedUsername, Result, FailureReason FROM dbo.SecurityEvent WHERE Id = @Id", + new { Id = id }); + row.ActorUserId.Should().BeNull(); + row.AttemptedUsername.Should().Be("juan"); + row.Result.Should().Be("failure"); + row.FailureReason.Should().Be("invalid_password"); + } + + [Fact] + public async Task InsertAsync_InvalidResult_FailsCheckConstraint() + { + var act = async () => await _repo.InsertAsync( + DateTime.UtcNow, null, null, null, "login", "neutral", null, null, null, null); + + await act.Should().ThrowAsync() + .Where(e => e.Message.Contains("CK_SecurityEvent_Result")); + } +}