From 2bb90118ab126df804a41676746b37d32ccbf89b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 17:05:40 -0300 Subject: [PATCH] feat(api): GET /audit/events + /health/audit (UDT-010 B10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuditController: - GET /api/v1/audit/events?actorUserId&targetType&targetId&from&to&cursor&limit - Protected by [RequirePermission("administracion:auditoria:ver")] — reuses the existing permission (V005/V006 seed assigns it to admin). - 400 on limit out of [1,100] or from > to. - Cursor-based DESC pagination via AuditEventRepository.QueryAsync. AuditHealthCheck (IHealthCheck): - Validates SYSTEM_VERSIONING ON on Usuario/Rol/Permiso/RolPermiso. - Validates partition boundaries exist for next 3 months (both AuditEvent and SecurityEvent functions). - Reports last audit event age (lenient 24h to accommodate dev/test quiet envs). - Validates HISTORY_RETENTION_PERIOD == 10 YEARS on all 4 tables. Key fix during impl: sys.tables.history_retention_period is stored in UNITS (1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR), NOT seconds. Assertion: period=10 AND unit=6 (10 YEARS). - Mapped at /health/audit via app.MapHealthChecks with tag 'audit'. Tests (Strict TDD, integration against SIGCM2_Test): - AuditControllerTests (5): without-auth 401, without-permission 403 (cajero), admin with filter returns events, invalid limit 400, from>to 400. - AuditHealthCheckTests (1): returns Healthy with V010 applied. Suite: 378/378 Application.Tests + 147/147 Api.Tests = 525/525 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-7/8, design, tasks#B10} --- .../SIGCM2.Api/Controllers/AuditController.cs | 63 +++++++++ .../HealthChecks/AuditHealthCheck.cs | 126 ++++++++++++++++++ src/api/SIGCM2.Api/Program.cs | 11 ++ .../Audit/AuditControllerTests.cs | 126 ++++++++++++++++++ .../Audit/AuditHealthCheckTests.cs | 30 +++++ 5 files changed, 356 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/AuditController.cs create mode 100644 src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs create mode 100644 tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs create mode 100644 tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/AuditController.cs b/src/api/SIGCM2.Api/Controllers/AuditController.cs new file mode 100644 index 0000000..0bee30f --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/AuditController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Audit; + +namespace SIGCM2.Api.Controllers; + +/// +/// UDT-010: Read-only endpoint for audit events. Requires administracion:auditoria:ver. +/// Cursor-based DESC pagination with 4 filter axes (actor/target/from/to). +/// Rich UI (drilldown, export CSV, timeline) is deferred to ADM-004. +/// +[ApiController] +[Route("api/v1/audit")] +public sealed class AuditController : ControllerBase +{ + private readonly IAuditEventRepository _repo; + + public AuditController(IAuditEventRepository repo) + { + _repo = repo; + } + + /// Lists audit events with optional filters. Cursor-based DESC pagination. + [HttpGet("events")] + [RequirePermission("administracion:auditoria:ver")] + [ProducesResponseType(typeof(AuditEventPageResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetEvents( + [FromQuery] int? actorUserId = null, + [FromQuery] string? targetType = null, + [FromQuery] string? targetId = null, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + [FromQuery] string? cursor = null, + [FromQuery] int limit = 50, + CancellationToken ct = default) + { + if (limit < 1 || limit > 100) + return BadRequest(new { error = "limit must be between 1 and 100" }); + + if (from is not null && to is not null && from > to) + return BadRequest(new { error = "from must be <= to" }); + + var filter = new AuditEventFilter( + ActorUserId: actorUserId, + TargetType: targetType, + TargetId: targetId, + From: from, + To: to, + Cursor: cursor, + Limit: limit); + + var result = await _repo.QueryAsync(filter, ct); + return Ok(new AuditEventPageResponse(result.Items, result.NextCursor)); + } +} + +/// UDT-010: Paginated response wrapper for GET /api/v1/audit/events. +public sealed record AuditEventPageResponse( + IReadOnlyList Items, + string? NextCursor); diff --git a/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs b/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs new file mode 100644 index 0000000..36d781e --- /dev/null +++ b/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs @@ -0,0 +1,126 @@ +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); + } + } +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index cc45135..69fdac1 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Serilog; using Scalar.AspNetCore; using SIGCM2.Api.Authorization; +using SIGCM2.Api.HealthChecks; using SIGCM2.Api.Middleware; using SIGCM2.Application; using SIGCM2.Infrastructure; @@ -38,6 +39,10 @@ builder.Services.AddControllers(opts => // OpenAPI / Scalar builder.Services.AddOpenApi(); +// UDT-010: Audit infrastructure health check +builder.Services.AddHealthChecks() + .AddCheck("audit", tags: new[] { "audit" }); + // CORS var allowedOrigins = builder.Configuration .GetSection("Cors:AllowedOrigins") @@ -76,6 +81,12 @@ app.UseMiddleware(); app.UseAuthorization(); app.MapControllers(); +// UDT-010: /health/audit returns the audit check status (public but sparse data). +app.MapHealthChecks("/health/audit", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions +{ + Predicate = r => r.Tags.Contains("audit"), +}); + app.Run(); // Exposed for WebApplicationFactory in integration tests diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs new file mode 100644 index 0000000..ee7ddd3 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.DependencyInjection; +using SIGCM2.Api.Controllers; +using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Domain.Entities; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Api.Tests.Audit; + +/// UDT-010 Batch 10 — AuditController integration tests. +[Collection("ApiIntegration")] +public sealed class AuditControllerTests : IClassFixture +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private readonly TestWebAppFactory _factory; + + public AuditControllerTests(TestWebAppFactory factory) + { + _factory = factory; + } + + private async Task<(HttpClient client, int adminId)> AuthedAdminClientAsync() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("DELETE FROM dbo.AuditEvent;"); + + var adminId = await conn.QuerySingleAsync("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'"); + + var client = _factory.CreateClient(); + var jwt = _factory.Services.GetRequiredService(); + var token = jwt.GenerateAccessToken(new Usuario( + id: adminId, username: "admin", passwordHash: "x", + nombre: "Admin", apellido: "Sys", email: null, + rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return (client, adminId); + } + + [Fact] + public async Task GetEvents_WithoutPermission_Returns403() + { + var client = _factory.CreateClient(); + var jwt = _factory.Services.GetRequiredService(); + // Use a role without administracion:auditoria:ver (cajero only has ventas:contado:*) + var operadorToken = jwt.GenerateAccessToken(new Usuario( + id: 9999, username: "opx", passwordHash: "x", + nombre: "X", apellido: "Y", email: null, + rol: "cajero", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operadorToken); + + var response = await client.GetAsync("/api/v1/audit/events"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task GetEvents_WithoutAuth_Returns401() + { + var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/v1/audit/events"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetEvents_AuthenticatedAdmin_ReturnsAuditEvents() + { + var (client, adminId) = await AuthedAdminClientAsync(); + + // Seed 3 events directly + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + for (var i = 0; i < 3; i++) + { + await conn.ExecuteAsync(""" + INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId) + VALUES (@O, @A, @Ac, 'Usuario', @T); + """, new + { + O = DateTime.UtcNow.AddSeconds(-i), + A = adminId, + Ac = $"test.seed{i}", + T = i.ToString(), + }); + } + + var response = await client.GetAsync("/api/v1/audit/events?targetType=Usuario"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + body.Should().NotBeNull(); + body!.Items.Should().HaveCount(3); + body.Items.Should().OnlyContain(e => e.TargetType == "Usuario"); + } + + [Fact] + public async Task GetEvents_InvalidLimit_Returns400() + { + var (client, _) = await AuthedAdminClientAsync(); + + var response = await client.GetAsync("/api/v1/audit/events?limit=0"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var response2 = await client.GetAsync("/api/v1/audit/events?limit=101"); + response2.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetEvents_FromGreaterThanTo_Returns400() + { + var (client, _) = await AuthedAdminClientAsync(); + + var response = await client.GetAsync( + "/api/v1/audit/events?from=2026-05-01T00:00:00Z&to=2026-04-01T00:00:00Z"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs new file mode 100644 index 0000000..1802d0b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs @@ -0,0 +1,30 @@ +using System.Net; +using FluentAssertions; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Api.Tests.Audit; + +/// UDT-010 Batch 10 — /health/audit integration smoke. +[Collection("ApiIntegration")] +public sealed class AuditHealthCheckTests : IClassFixture +{ + private readonly TestWebAppFactory _factory; + + public AuditHealthCheckTests(TestWebAppFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task HealthAudit_WithInfraApplied_ReturnsHealthy() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health/audit"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Healthy"); + } +}