Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs

231 lines
9.5 KiB
C#
Raw Normal View History

feat(infra): audit + security event repositories (UDT-010 B5) Introduces persistence layer for audit and security events per design #D-6: SIGCM2.Application/Audit/: - IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination - ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is queried only from an admin dashboard deferred to ADM-004) - AuditEventQueryResult: (Items, NextCursor) record SIGCM2.Infrastructure/Audit/: - AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for DESC pagination. TryDecode is fail-open — malformed cursor returns null and the query starts from the top. - AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic WHERE composition with parameterized filters (zero SQL injection risk). LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto. Pagination fetches Limit+1 rows to detect "more pages"; emits cursor from the Nth row when overflow observed. - SecurityEventRepository: straight INSERT for login/logout/refresh/ permission.denied events. DI: AddScoped for both repos in AddInfrastructure. Integration tests (Strict TDD): 13 total, all against SIGCM2_Test. - AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor, filter-by-target, filter-by-date-range, cursor pagination across 3 pages (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario populates username, cursor encode/decode roundtrip, cursor malformed variants. - SecurityEventRepositoryTests (3): insert success, insert failure with null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection. Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1, design#D-6, tasks#B5}
2026-04-16 13:38:05 -03:00
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 = TestConnectionStrings.AppTestDb;
feat(infra): audit + security event repositories (UDT-010 B5) Introduces persistence layer for audit and security events per design #D-6: SIGCM2.Application/Audit/: - IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination - ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is queried only from an admin dashboard deferred to ADM-004) - AuditEventQueryResult: (Items, NextCursor) record SIGCM2.Infrastructure/Audit/: - AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for DESC pagination. TryDecode is fail-open — malformed cursor returns null and the query starts from the top. - AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic WHERE composition with parameterized filters (zero SQL injection risk). LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto. Pagination fetches Limit+1 rows to detect "more pages"; emits cursor from the Nth row when overflow observed. - SecurityEventRepository: straight INSERT for login/logout/refresh/ permission.denied events. DI: AddScoped for both repos in AddInfrastructure. Integration tests (Strict TDD): 13 total, all against SIGCM2_Test. - AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor, filter-by-target, filter-by-date-range, cursor pagination across 3 pages (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario populates username, cursor encode/decode roundtrip, cursor malformed variants. - SecurityEventRepositoryTests (3): insert success, insert failure with null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection. Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1, design#D-6, tasks#B5}
2026-04-16 13:38:05 -03:00
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()
{
// Determinístico: DATETIME2(3) + cursor roundtrip via "O" format puede perder ticks
// sub-ms de `DateTime.UtcNow` (observado ~37% flake rate, cursor vuelve como parentesis
// de la página anterior). Timestamp fijo con sub-ms = 0 elimina la ambigüedad.
var t0 = new DateTime(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
feat(infra): audit + security event repositories (UDT-010 B5) Introduces persistence layer for audit and security events per design #D-6: SIGCM2.Application/Audit/: - IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination - ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is queried only from an admin dashboard deferred to ADM-004) - AuditEventQueryResult: (Items, NextCursor) record SIGCM2.Infrastructure/Audit/: - AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for DESC pagination. TryDecode is fail-open — malformed cursor returns null and the query starts from the top. - AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic WHERE composition with parameterized filters (zero SQL injection risk). LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto. Pagination fetches Limit+1 rows to detect "more pages"; emits cursor from the Nth row when overflow observed. - SecurityEventRepository: straight INSERT for login/logout/refresh/ permission.denied events. DI: AddScoped for both repos in AddInfrastructure. Integration tests (Strict TDD): 13 total, all against SIGCM2_Test. - AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor, filter-by-target, filter-by-date-range, cursor pagination across 3 pages (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario populates username, cursor encode/decode roundtrip, cursor malformed variants. - SecurityEventRepositoryTests (3): insert success, insert failure with null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection. Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1, design#D-6, tasks#B5}
2026-04-16 13:38:05 -03:00
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<int?>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
if (adminId is null)
{
// Seed admin if not present (Respawn wiped it)
adminId = await _connection.ExecuteScalarAsync<int>("""
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);
}
}
}