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
|
|
|
|
|
{
|
2026-04-18 21:44:36 -03:00
|
|
|
private const string ConnectionString = TestConnectionStrings.AppTestDb;
|
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
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
{
|
2026-04-18 11:07:47 -03:00
|
|
|
var job = new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance, TimeProvider.System);
|
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
|
|
|
|
|
|
|
|
// 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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-18 11:07:47 -03:00
|
|
|
var job = new AuditRetentionEnforcerJob(_factory, NullLogger<AuditRetentionEnforcerJob>.Instance, TimeProvider.System);
|
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
|
|
|
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>();
|
2026-04-18 11:07:47 -03:00
|
|
|
var job = new AuditIntegrityCheckJob(_factory, security, NullLogger<AuditIntegrityCheckJob>.Instance, TimeProvider.System);
|
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
|
|
|
|
|
|
|
|
// Ensure partition manager has run first so next-3-months exist
|
2026-04-18 11:07:47 -03:00
|
|
|
await new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance, TimeProvider.System).Execute(MockContext());
|
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
|
|
|
|
|
|
|
|
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>());
|
|
|
|
|
}
|
|
|
|
|
}
|