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(); ctx.CancellationToken.Returns(CancellationToken.None); return ctx; } [Fact] public async Task PartitionManager_ExtendsFunctionForward_Idempotent() { var job = new AuditPartitionManagerJob(_factory, NullLogger.Instance, TimeProvider.System); // 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(""" 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.Instance, TimeProvider.System); await job.Execute(MockContext()); var auditCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.AuditEvent;"); var securityCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.SecurityEvent;"); auditCount.Should().Be(1); securityCount.Should().Be(1); } [Fact] public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent() { var security = Substitute.For(); var job = new AuditIntegrityCheckJob(_factory, security, NullLogger.Instance, TimeProvider.System); // Ensure partition manager has run first so next-3-months exist await new AuditPartitionManagerJob(_factory, NullLogger.Instance, TimeProvider.System).Execute(MockContext()); await job.Execute(MockContext()); await security.DidNotReceive().LogAsync( action: "system.integrity_alert", result: Arg.Any(), actorUserId: Arg.Any(), attemptedUsername: Arg.Any(), sessionId: Arg.Any(), failureReason: Arg.Any(), metadata: Arg.Any(), ct: Arg.Any()); } }