Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs

124 lines
4.9 KiB
C#
Raw Normal View History

feat(jobs): 3 audit maintenance jobs (Quartz.NET, UDT-010 B11) Agrega Quartz.Extensions.Hosting 3.13.1 al catálogo central. SIGCM2.Infrastructure/Audit/Jobs/: - AuditPartitionManagerJob — mensual (cron '0 0 2 1 * ?', UTC). Extiende pf_AuditEvent_Monthly y pf_SecurityEvent_Monthly con SPLIT RANGE para el mes+2 (mantiene +1 de buffer). Idempotente: verifica existencia antes. - AuditRetentionEnforcerJob — anual (cron '0 0 3 1 1 ?', UTC). DELETE rows > 10 años en AuditEvent y > 5 años en SecurityEvent. Temporal history se purga solo vía HISTORY_RETENTION_PERIOD del engine. - AuditIntegrityCheckJob — semanal domingos (cron '0 0 1 ? * SUN', UTC). Valida SYSTEM_VERSIONING=ON + partitions próximos 3 meses. Emite SecurityEvent 'system.integrity_alert' failure via ISecurityEventLogger cuando detecta inconsistencias. AuditMaintenanceRegistration.cs: - services.AddAuditMaintenance(configuration) wraps AddQuartz + AddQuartzHostedService con los 3 triggers crónicos. Program.cs: - builder.Services.AddAuditMaintenance(configuration) wired ONLY en entornos productivos — skipeado en 'Testing' para que los integration tests no disparen los triggers cron durante el ciclo de vida del TestWebAppFactory. Row-based DELETE en RetentionEnforcerJob es la opción conservadora para la primera generación — cuando los volúmenes lo justifiquen (>200M filas), se upgradea a SWITCH OUT + DROP para partition-level drop. Documentado en comentario de la clase. Tests (Strict TDD, integration): - AuditJobsTests (3): PartitionManager crea target boundary + idempotencia, RetentionEnforcer purga > threshold (10y audit, 5y security), IntegrityCheck all-OK no emite alert. Suite: 381/381 Application.Tests + 147/147 Api.Tests = 528/528 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-6 #REQ-SEC-5, design, tasks#B11}
2026-04-16 17:10:43 -03:00
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Quartz;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit.Jobs;
using SIGCM2.Infrastructure.Persistence;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 11 — audit maintenance jobs integration tests.
/// Executes each IJob directly (no Quartz scheduler needed) against SIGCM2_Test.
[Collection("Database")]
public sealed class AuditJobsTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private SqlConnectionFactory _factory = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
_factory = new SqlConnectionFactory(ConnectionString);
}
public async Task DisposeAsync()
{
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
private static IJobExecutionContext MockContext()
{
var ctx = Substitute.For<IJobExecutionContext>();
ctx.CancellationToken.Returns(CancellationToken.None);
return ctx;
}
[Fact]
public async Task PartitionManager_ExtendsFunctionForward_Idempotent()
{
var job = new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance);
// First run: ensure the target boundary exists
await job.Execute(MockContext());
// Compute target as the job does
var now = DateTime.UtcNow;
var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
{
var count = await _connection.ExecuteScalarAsync<int>("""
SELECT COUNT(*)
FROM sys.partition_functions pf
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target;
""", new { Name = pf, Target = target });
count.Should().Be(1, $"{pf} should have boundary {target:yyyy-MM-dd}");
}
// Second run must not throw (idempotent)
await job.Execute(MockContext());
}
[Fact]
public async Task RetentionEnforcer_PurgesAuditEventOlderThan10Years_AndSecurityOlderThan5Years()
{
await _connection.ExecuteAsync("""
INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId)
VALUES
(@Ancient, 1, 'x.y', 'T', '1'), -- should be purged
(@Recent, 1, 'x.y', 'T', '2'); -- should stay
INSERT INTO dbo.SecurityEvent (OccurredAt, ActorUserId, Action, Result)
VALUES
(@Ancient5, 1, 'login', 'success'), -- should be purged
(@Recent, 1, 'login', 'success'); -- should stay
""", new
{
Ancient = DateTime.UtcNow.AddYears(-11),
Recent = DateTime.UtcNow.AddDays(-1),
Ancient5 = DateTime.UtcNow.AddYears(-6),
});
var job = new AuditRetentionEnforcerJob(_factory, NullLogger<AuditRetentionEnforcerJob>.Instance);
await job.Execute(MockContext());
var auditCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.AuditEvent;");
var securityCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.SecurityEvent;");
auditCount.Should().Be(1);
securityCount.Should().Be(1);
}
[Fact]
public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent()
{
var security = Substitute.For<ISecurityEventLogger>();
var job = new AuditIntegrityCheckJob(_factory, security, NullLogger<AuditIntegrityCheckJob>.Instance);
// Ensure partition manager has run first so next-3-months exist
await new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance).Execute(MockContext());
await job.Execute(MockContext());
await security.DidNotReceive().LogAsync(
action: "system.integrity_alert",
result: Arg.Any<string>(),
actorUserId: Arg.Any<int?>(),
attemptedUsername: Arg.Any<string?>(),
sessionId: Arg.Any<Guid?>(),
failureReason: Arg.Any<string?>(),
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
}