feat(api): GET /audit/events + /health/audit (UDT-010 B10)
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}
2026-04-16 17:05:40 -03:00
|
|
|
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<TestWebAppFactory>
|
|
|
|
|
{
|
2026-04-18 21:44:40 -03:00
|
|
|
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
feat(api): GET /audit/events + /health/audit (UDT-010 B10)
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}
2026-04-16 17:05:40 -03:00
|
|
|
|
|
|
|
|
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<int>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
|
|
|
|
|
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
var jwt = _factory.Services.GetRequiredService<IJwtService>();
|
|
|
|
|
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<IJwtService>();
|
|
|
|
|
// 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<AuditEventPageResponse>();
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|