feat(db): V010 audit infrastructure + temporal tables
Applied to SIGCM2 (dev) and SIGCM2_Test.
V010__audit_infrastructure.sql (idempotent, ~280 LoC):
- Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names
via DB_NAME() prefix to avoid collision in dev/test).
- pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT,
DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends
forward monthly in B11.
- dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes
(Actor/Target/Action/Correlation) with PAGE compression.
- dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure).
- CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure).
- SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention +
PAGE compression in history tables.
- No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion).
V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history).
database/README.md: migration order + V010 prod-apply notes.
tests/SIGCM2.TestSupport/SqlTestFixture.cs:
- EnsureV010SchemaAsync() validates audit infra is applied (fails fast with
clear message if not — migration itself requires ALTER DATABASE privileges
and is applied manually via sqlcmd).
- Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE
on system-versioned history tables).
tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests:
- AuditEvent insert+roundtrip with CorrelationId.
- CK_AuditEvent_Action rejects Action without '.'.
- CK_AuditEvent_Metadata rejects non-JSON.
- CK_SecurityEvent_Result rejects invalid Result.
- Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns
pre-update state + Usuario_History populated.
Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1,
design#D-4, tasks}
2026-04-16 13:10:04 -03:00
|
|
|
using Dapper;
|
|
|
|
|
using FluentAssertions;
|
|
|
|
|
using Microsoft.Data.SqlClient;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace SIGCM2.Api.Tests.Audit;
|
|
|
|
|
|
|
|
|
|
/// UDT-010 Batch 1 — V010 migration integration smoke tests.
|
|
|
|
|
/// Validates:
|
|
|
|
|
/// REQ-AUD-1: SYSTEM_VERSIONING active on catalog entities (smoke: Usuario + Usuario_History query).
|
|
|
|
|
/// REQ-AUD-2: dbo.AuditEvent exists, accepts valid INSERT, CHECK constraints reject invalid data.
|
|
|
|
|
/// REQ-SEC-1: dbo.SecurityEvent exists, CK_SecurityEvent_Result rejects invalid Result.
|
2026-04-16 13:22:56 -03:00
|
|
|
[Collection("ApiIntegration")]
|
feat(db): V010 audit infrastructure + temporal tables
Applied to SIGCM2 (dev) and SIGCM2_Test.
V010__audit_infrastructure.sql (idempotent, ~280 LoC):
- Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names
via DB_NAME() prefix to avoid collision in dev/test).
- pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT,
DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends
forward monthly in B11.
- dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes
(Actor/Target/Action/Correlation) with PAGE compression.
- dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure).
- CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure).
- SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention +
PAGE compression in history tables.
- No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion).
V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history).
database/README.md: migration order + V010 prod-apply notes.
tests/SIGCM2.TestSupport/SqlTestFixture.cs:
- EnsureV010SchemaAsync() validates audit infra is applied (fails fast with
clear message if not — migration itself requires ALTER DATABASE privileges
and is applied manually via sqlcmd).
- Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE
on system-versioned history tables).
tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests:
- AuditEvent insert+roundtrip with CorrelationId.
- CK_AuditEvent_Action rejects Action without '.'.
- CK_AuditEvent_Metadata rejects non-JSON.
- CK_SecurityEvent_Result rejects invalid Result.
- Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns
pre-update state + Usuario_History populated.
Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions.
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1,
design#D-4, tasks}
2026-04-16 13:10:04 -03:00
|
|
|
public sealed class V010MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
|
|
|
|
|
{
|
|
|
|
|
private const string ConnectionString =
|
|
|
|
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
|
|
|
|
|
|
|
|
|
public V010MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
|
|
|
|
|
{
|
|
|
|
|
// Depend on the factory so SqlTestFixture.InitializeAsync runs (validates V010 applied + resets DB).
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AuditEvent_Insert_WithValidData_Persists()
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
|
|
|
|
|
var correlationId = Guid.NewGuid();
|
|
|
|
|
var insertedId = await conn.ExecuteScalarAsync<long>("""
|
|
|
|
|
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, CorrelationId, Metadata)
|
|
|
|
|
VALUES (@ActorUserId, @Action, @TargetType, @TargetId, @CorrelationId, @Metadata);
|
|
|
|
|
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
|
|
|
|
|
""",
|
|
|
|
|
new
|
|
|
|
|
{
|
|
|
|
|
ActorUserId = 1,
|
|
|
|
|
Action = "usuario.create",
|
|
|
|
|
TargetType = "Usuario",
|
|
|
|
|
TargetId = "42",
|
|
|
|
|
CorrelationId = correlationId,
|
|
|
|
|
Metadata = """{"after":{"username":"juan"}}"""
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
insertedId.Should().BeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
var roundtrip = await conn.QuerySingleAsync<(string Action, string TargetType, string TargetId, Guid? CorrelationId)>(
|
|
|
|
|
"SELECT Action, TargetType, TargetId, CorrelationId FROM dbo.AuditEvent WHERE Id = @Id",
|
|
|
|
|
new { Id = insertedId });
|
|
|
|
|
roundtrip.Action.Should().Be("usuario.create");
|
|
|
|
|
roundtrip.TargetType.Should().Be("Usuario");
|
|
|
|
|
roundtrip.TargetId.Should().Be("42");
|
|
|
|
|
roundtrip.CorrelationId.Should().Be(correlationId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AuditEvent_Insert_WithInvalidActionFormat_FailsCheckConstraint()
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
|
|
|
|
|
var act = async () => await conn.ExecuteAsync("""
|
|
|
|
|
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId)
|
|
|
|
|
VALUES (1, 'invalid_no_dot', 'Usuario', '1');
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<SqlException>()
|
|
|
|
|
.Where(e => e.Message.Contains("CK_AuditEvent_Action"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AuditEvent_Insert_WithNonJsonMetadata_FailsCheckConstraint()
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
|
|
|
|
|
var act = async () => await conn.ExecuteAsync("""
|
|
|
|
|
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, Metadata)
|
|
|
|
|
VALUES (1, 'usuario.create', 'Usuario', '1', 'not-json');
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<SqlException>()
|
|
|
|
|
.Where(e => e.Message.Contains("CK_AuditEvent_Metadata"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task SecurityEvent_Insert_WithInvalidResult_FailsCheckConstraint()
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
|
|
|
|
|
var act = async () => await conn.ExecuteAsync("""
|
|
|
|
|
INSERT INTO dbo.SecurityEvent (ActorUserId, Action, Result)
|
|
|
|
|
VALUES (1, 'login', 'neutral');
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<SqlException>()
|
|
|
|
|
.Where(e => e.Message.Contains("CK_SecurityEvent_Result"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Usuario_SystemVersioning_IsActive_And_TemporalQueryReturnsHistory()
|
|
|
|
|
{
|
|
|
|
|
await using var conn = new SqlConnection(ConnectionString);
|
|
|
|
|
await conn.OpenAsync();
|
|
|
|
|
|
|
|
|
|
// Seed: insert a scratch user and wait long enough for ValidFrom to be in the past
|
|
|
|
|
var username = "b1spike_" + Guid.NewGuid().ToString("N")[..8];
|
|
|
|
|
var newId = await conn.ExecuteScalarAsync<int>("""
|
|
|
|
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson)
|
|
|
|
|
VALUES (@u, 'hash', 'Temp', 'User', 'admin', '{"grant":[],"deny":[]}');
|
|
|
|
|
SELECT CAST(SCOPE_IDENTITY() AS INT);
|
|
|
|
|
""", new { u = username });
|
|
|
|
|
|
|
|
|
|
// Capture a timestamp *after* creation but *before* update
|
|
|
|
|
await Task.Delay(50);
|
|
|
|
|
var snapshotTime = DateTime.UtcNow;
|
|
|
|
|
await Task.Delay(50);
|
|
|
|
|
|
|
|
|
|
// Update the email
|
|
|
|
|
await conn.ExecuteAsync(
|
|
|
|
|
"UPDATE dbo.Usuario SET Email = @e WHERE Id = @id",
|
|
|
|
|
new { e = "new@example.com", id = newId });
|
|
|
|
|
|
|
|
|
|
// Temporal query: state at snapshotTime should NOT yet have the new email
|
|
|
|
|
var historicalEmail = await conn.ExecuteScalarAsync<string?>("""
|
|
|
|
|
SELECT Email FROM dbo.Usuario
|
|
|
|
|
FOR SYSTEM_TIME AS OF @ts
|
|
|
|
|
WHERE Id = @id
|
|
|
|
|
""", new { ts = snapshotTime, id = newId });
|
|
|
|
|
|
|
|
|
|
historicalEmail.Should().BeNull("the historical snapshot predates the email update");
|
|
|
|
|
|
|
|
|
|
// Current state HAS the new email
|
|
|
|
|
var currentEmail = await conn.ExecuteScalarAsync<string?>(
|
|
|
|
|
"SELECT Email FROM dbo.Usuario WHERE Id = @id",
|
|
|
|
|
new { id = newId });
|
|
|
|
|
currentEmail.Should().Be("new@example.com");
|
|
|
|
|
|
|
|
|
|
// Usuario_History must have at least one row for this user
|
|
|
|
|
var historyCount = await conn.ExecuteScalarAsync<int>(
|
|
|
|
|
"SELECT COUNT(*) FROM dbo.Usuario_History WHERE Id = @id",
|
|
|
|
|
new { id = newId });
|
|
|
|
|
historyCount.Should().BeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
// Cleanup: Respawn will reset between test runs, but this test could run alone — best-effort delete.
|
|
|
|
|
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Id = @id", new { id = newId });
|
|
|
|
|
}
|
|
|
|
|
}
|