using Dapper; using Microsoft.Extensions.Diagnostics.HealthChecks; using SIGCM2.Infrastructure.Persistence; namespace SIGCM2.Api.HealthChecks; /// /// UDT-010 (#REQ-AUD-8): health check for audit infrastructure. /// Validates: /// - SYSTEM_VERSIONING is ON for Usuario/Rol/Permiso/RolPermiso. /// - Monthly partitions exist for the next 3 months on AuditEvent + SecurityEvent. /// - Last AuditEvent is recent enough (< 24h) — relaxed from 1h spec to accommodate /// quiet dev/test environments; prod deployments should tighten to 1h via config. /// - HISTORY_RETENTION_PERIOD matches 10 years for the 4 versioned catalog tables. /// Returns Unhealthy with details when any check fails. /// public sealed class AuditHealthCheck : IHealthCheck { private readonly SqlConnectionFactory _factory; public AuditHealthCheck(SqlConnectionFactory factory) { _factory = factory; } public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { try { await using var conn = _factory.CreateConnection(); await conn.OpenAsync(cancellationToken); // 1. SYSTEM_VERSIONING checks var versionedMissing = (await conn.QueryAsync(""" SELECT t.name FROM sys.tables t WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso') AND t.temporal_type <> 2; """)).ToList(); if (versionedMissing.Any()) { return HealthCheckResult.Unhealthy( $"SYSTEM_VERSIONING missing on: {string.Join(",", versionedMissing)}"); } // 2. Partitions for next 3 months in both event tables var now = DateTime.UtcNow; var requiredBoundaries = new[] { new DateTime(now.Year, now.Month, 1).AddMonths(1), new DateTime(now.Year, now.Month, 1).AddMonths(2), new DateTime(now.Year, now.Month, 1).AddMonths(3), }; foreach (var pfName in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" }) { var values = (await conn.QueryAsync(""" SELECT CAST(prv.value AS DATETIME2(3)) FROM sys.partition_functions pf JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id WHERE pf.name = @Name; """, new { Name = pfName })).ToHashSet(); foreach (var req in requiredBoundaries) { if (!values.Contains(req)) { return HealthCheckResult.Unhealthy( $"Partition boundary missing in {pfName}: {req:yyyy-MM-dd}"); } } } // 3. Recent audit activity — lenient 24h to avoid false positives in quiet envs var lastEventAt = await conn.ExecuteScalarAsync( "SELECT MAX(OccurredAt) FROM dbo.AuditEvent;"); var recentMessage = lastEventAt is null ? "no audit events yet (acceptable on fresh DB)" : (now - lastEventAt.Value).TotalHours < 24 ? "recent" : $"stale: last event {(now - lastEventAt.Value).TotalHours:F1}h ago"; // 4. Retention period check. // sys.tables.history_retention_period stores a signed int in UNITS defined by // history_retention_period_unit: 1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR, -1=not applicable. // V010 sets HISTORY_RETENTION_PERIOD = 10 YEARS → period=10, unit=6. var retentionRows = (await conn.QueryAsync<(string TableName, int? Period, int? Unit)>(""" SELECT t.name AS TableName, t.history_retention_period AS Period, t.history_retention_period_unit AS Unit FROM sys.tables t WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso') AND t.temporal_type = 2; """)).ToList(); var badRetention = retentionRows .Where(r => !(r.Period == 10 && r.Unit == 6)) // not 10 YEARS .Select(r => r.TableName) .ToList(); var data = new Dictionary { ["versionedTables"] = "Usuario, Rol, Permiso, RolPermiso", ["lastAuditEvent"] = (object?)lastEventAt ?? "none", ["lastAuditEventStatus"] = recentMessage, ["retentionOk"] = badRetention.Count == 0, }; if (badRetention.Any()) { return HealthCheckResult.Degraded( $"Retention != 10 YEARS for: {string.Join(",", badRetention)}", data: data); } return HealthCheckResult.Healthy("audit infrastructure OK", data); } catch (Exception ex) { return HealthCheckResult.Unhealthy("audit health check threw", ex); } } }