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}
This commit is contained in:
2026-04-16 13:38:05 -03:00
parent 0b4af4c332
commit 300badda73
8 changed files with 619 additions and 0 deletions

View File

@@ -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<SqlException>()
.Where(e => e.Message.Contains("CK_SecurityEvent_Result"));
}
}