Follow-up of B1 (V010 migration). Issues found when running the full suite
cross-assembly:
1. Respawn 'Cannot delete rows from a temporal history table' error:
4 per-class Respawner configs in SIGCM2.Application.Tests did not
include the newly-created *_History tables introduced by V010
(Usuario_History / Rol_History / Permiso_History / RolPermiso_History).
The engine rejects direct DELETE on system-versioned history tables.
Extended TablesToIgnore in all 4 configs.
2. FK_RefreshToken_Usuario violation in RolRepositoryTests.InitializeAsync:
Manual 'DELETE FROM Usuario' failed when residual RefreshTokens from
prior suites existed. Added 'DELETE FROM RefreshToken' before the
Usuario cleanup to respect FK order. Latent bug surfaced by a new
test-run ordering — not UDT-010 specific, but fixed in scope.
3. UQ_Usuario_Username duplicate admin race:
TransactionScopeSpikeTests (B0) and V010MigrationTests (B1) were
missing [Collection("ApiIntegration")], causing them to run in
parallel with the rest of SIGCM2.Api.Tests and race on SeedAdmin.
Serialized by adding the Collection attribute.
Suite now passes cross-assembly: 130/130 Api.Tests + 336/336 Application.Tests.
Refs: sdd/udt-010-auditoria-trazabilidad/apply-progress (B1 follow-up)
151 lines
6.1 KiB
C#
151 lines
6.1 KiB
C#
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.
|
|
[Collection("ApiIntegration")]
|
|
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 });
|
|
}
|
|
}
|