Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs
dmolinari e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00

150 lines
6.0 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 = TestConnectionStrings.ApiTestDb;
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 });
}
}