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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user