Files

101 lines
3.5 KiB
C#
Raw Permalink 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.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 = 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 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"));
}
}