+
+ {/*
+ TODOs para ADM-004:
+ - Drill-down del evento (modal con metadata JSON formatted)
+ - Export CSV de los resultados filtrados
+ - Timeline visualization por entidad
+ */}
+
+ )
+}
diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx
index 9feac6f..2a83906 100644
--- a/src/web/src/router.tsx
+++ b/src/web/src/router.tsx
@@ -12,6 +12,7 @@ import { RolesPage } from './features/roles/pages/RolesPage'
import { NewRolPage } from './features/roles/pages/NewRolPage'
import { EditRolPage } from './features/roles/pages/EditRolPage'
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
+import { AuditPage } from './pages/admin/audit/AuditPage'
import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -154,6 +155,15 @@ export function AppRoutes() {
}
/>
+
+
+
+ }
+ />
+
} />
)
diff --git a/src/web/src/tests/features/admin/audit/AuditPage.test.tsx b/src/web/src/tests/features/admin/audit/AuditPage.test.tsx
new file mode 100644
index 0000000..f242ba2
--- /dev/null
+++ b/src/web/src/tests/features/admin/audit/AuditPage.test.tsx
@@ -0,0 +1,254 @@
+import {
+ describe,
+ it,
+ expect,
+ beforeAll,
+ afterAll,
+ afterEach,
+ vi,
+} from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { http, HttpResponse } from 'msw'
+import { setupServer } from 'msw/node'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import { TooltipProvider } from '../../../../components/ui/tooltip'
+import { AuditPage } from '../../../../pages/admin/audit/AuditPage'
+
+const API_URL = 'http://localhost:5000'
+
+// Sonner toast is mocked so we can assert it was called without rendering.
+vi.mock('sonner', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}))
+
+function makeEvent(id: number, overrides: Record = {}) {
+ return {
+ id,
+ occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
+ actorUserId: 1,
+ actorUsername: `user${id}`,
+ action: 'user.created',
+ targetType: 'User',
+ targetId: String(100 + id),
+ correlationId: `corr-${id}`,
+ ipAddress: '10.0.0.1',
+ metadata: null,
+ ...overrides,
+ }
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => {
+ server.resetHandlers()
+ vi.clearAllMocks()
+})
+afterAll(() => server.close())
+
+function renderPage() {
+ const qc = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ return render(
+
+
+
+
+
+
+ ,
+ )
+}
+
+describe('AuditPage', () => {
+ it('renders the table with rows returned by the API', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, () =>
+ HttpResponse.json({
+ items: [makeEvent(1), makeEvent(2), makeEvent(3)],
+ nextCursor: null,
+ }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect(screen.getByText('user1')).toBeInTheDocument(),
+ )
+ expect(screen.getByText('user2')).toBeInTheDocument()
+ expect(screen.getByText('user3')).toBeInTheDocument()
+
+ // Action badge present
+ const badges = screen.getAllByText('user.created')
+ expect(badges.length).toBe(3)
+ })
+
+ it('shows empty state when no items are returned', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, () =>
+ HttpResponse.json({ items: [], nextCursor: null }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect(
+ screen.getByText(/sin resultados|no se encontraron eventos/i),
+ ).toBeInTheDocument(),
+ )
+ })
+
+ it('applies filters on submit — sends actor + targetType as query params', async () => {
+ const requests: string[] = []
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
+ requests.push(request.url)
+ return HttpResponse.json({ items: [], nextCursor: null })
+ }),
+ )
+
+ const u = userEvent.setup()
+ renderPage()
+
+ await waitFor(() => expect(requests.length).toBeGreaterThan(0))
+
+ const actorInput = screen.getByLabelText(/usuario \(id\)/i)
+ await u.type(actorInput, '42')
+
+ const targetTypeInput = screen.getByLabelText(/tipo de entidad/i)
+ await u.type(targetTypeInput, 'User')
+
+ const applyBtn = screen.getByRole('button', { name: /aplicar filtros/i })
+ await u.click(applyBtn)
+
+ await waitFor(() => {
+ const withFilters = requests.find(
+ (url) => url.includes('actor=42') && url.includes('targetType=User'),
+ )
+ expect(withFilters).toBeTruthy()
+ })
+ })
+
+ it('"Cargar más" is disabled when nextCursor is null', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, () =>
+ HttpResponse.json({
+ items: [makeEvent(1)],
+ nextCursor: null,
+ }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect(screen.getByText('user1')).toBeInTheDocument(),
+ )
+
+ const loadMoreBtn = screen.getByRole('button', {
+ name: /cargar más/i,
+ })
+ expect(loadMoreBtn).toBeDisabled()
+ })
+
+ it('"Cargar más" fetches next page with cursor and appends rows', async () => {
+ const requests: string[] = []
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
+ requests.push(request.url)
+ const url = new URL(request.url)
+ const cursor = url.searchParams.get('cursor')
+ if (cursor === 'cursor-page-2') {
+ return HttpResponse.json({
+ items: [makeEvent(10), makeEvent(11)],
+ nextCursor: null,
+ })
+ }
+ return HttpResponse.json({
+ items: [makeEvent(1), makeEvent(2)],
+ nextCursor: 'cursor-page-2',
+ })
+ }),
+ )
+
+ const u = userEvent.setup()
+ renderPage()
+
+ await waitFor(() =>
+ expect(screen.getByText('user1')).toBeInTheDocument(),
+ )
+ expect(screen.getByText('user2')).toBeInTheDocument()
+
+ const loadMoreBtn = screen.getByRole('button', {
+ name: /cargar más/i,
+ })
+ expect(loadMoreBtn).not.toBeDisabled()
+ await u.click(loadMoreBtn)
+
+ // Second request with cursor param
+ await waitFor(() => {
+ const paged = requests.find((url) =>
+ url.includes('cursor=cursor-page-2'),
+ )
+ expect(paged).toBeTruthy()
+ })
+
+ // Appended rows appear alongside originals
+ await waitFor(() =>
+ expect(screen.getByText('user10')).toBeInTheDocument(),
+ )
+ expect(screen.getByText('user11')).toBeInTheDocument()
+ // Original rows still visible (append, not replace)
+ expect(screen.getByText('user1')).toBeInTheDocument()
+ })
+
+ it('shows "sistema" placeholder when actorUsername is null', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, () =>
+ HttpResponse.json({
+ items: [makeEvent(1, { actorUsername: null, actorUserId: null })],
+ nextCursor: null,
+ }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect(screen.getByText('sistema')).toBeInTheDocument(),
+ )
+ })
+
+ it('"Limpiar" clears the form inputs', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, () =>
+ HttpResponse.json({ items: [], nextCursor: null }),
+ ),
+ )
+
+ const u = userEvent.setup()
+ renderPage()
+
+ const actorInput = screen.getByLabelText(
+ /usuario \(id\)/i,
+ ) as HTMLInputElement
+ await u.type(actorInput, '42')
+ expect(actorInput.value).toBe('42')
+
+ await u.click(screen.getByRole('button', { name: /limpiar/i }))
+
+ // Form field cleared after reset
+ expect(actorInput.value).toBe('')
+ })
+})
diff --git a/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts b/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts
new file mode 100644
index 0000000..3ff6f80
--- /dev/null
+++ b/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
+import { renderHook, waitFor } from '@testing-library/react'
+import { http, HttpResponse } from 'msw'
+import { setupServer } from 'msw/node'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import React from 'react'
+import { useAuditEvents } from '../../../../features/admin/audit/useAuditEvents'
+
+const API_URL = 'http://localhost:5000'
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())
+
+function createWrapper() {
+ const qc = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ return ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: qc }, children)
+}
+
+function makeEvent(id: number) {
+ return {
+ id,
+ occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
+ actorUserId: 1,
+ actorUsername: 'admin',
+ action: 'user.created',
+ targetType: 'User',
+ targetId: String(100 + id),
+ correlationId: `corr-${id}`,
+ ipAddress: '10.0.0.1',
+ metadata: null,
+ }
+}
+
+describe('useAuditEvents', () => {
+ it('fetches the first page (no cursor) and returns items + nextCursor', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, () =>
+ HttpResponse.json({
+ items: [makeEvent(1), makeEvent(2)],
+ nextCursor: 'cursor-page-2',
+ }),
+ ),
+ )
+
+ const { result } = renderHook(() => useAuditEvents({}), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(result.current.data?.items).toHaveLength(2)
+ expect(result.current.data?.nextCursor).toBe('cursor-page-2')
+ })
+
+ it('forwards filters as query params (actor, targetType, targetId, from, to)', async () => {
+ let capturedUrl: string | null = null
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
+ capturedUrl = request.url
+ return HttpResponse.json({ items: [], nextCursor: null })
+ }),
+ )
+
+ const { result } = renderHook(
+ () =>
+ useAuditEvents({
+ actor: 42,
+ targetType: 'User',
+ targetId: 'abc-123',
+ from: '2026-04-01T00:00:00Z',
+ to: '2026-04-16T23:59:59Z',
+ }),
+ { wrapper: createWrapper() },
+ )
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(capturedUrl).toContain('actor=42')
+ expect(capturedUrl).toContain('targetType=User')
+ expect(capturedUrl).toContain('targetId=abc-123')
+ expect(capturedUrl).toContain('from=2026-04-01')
+ expect(capturedUrl).toContain('to=2026-04-16')
+ })
+
+ it('forwards cursor param for successive pages', async () => {
+ let capturedUrl: string | null = null
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
+ capturedUrl = request.url
+ return HttpResponse.json({
+ items: [makeEvent(3)],
+ nextCursor: null,
+ })
+ }),
+ )
+
+ const { result } = renderHook(
+ () => useAuditEvents({ cursor: 'cursor-page-2' }),
+ { wrapper: createWrapper() },
+ )
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+ expect(capturedUrl).toContain('cursor=cursor-page-2')
+ })
+
+ it('omits undefined/empty filters from the querystring', async () => {
+ let capturedUrl: string | null = null
+ server.use(
+ http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
+ capturedUrl = request.url
+ return HttpResponse.json({ items: [], nextCursor: null })
+ }),
+ )
+
+ const { result } = renderHook(
+ () => useAuditEvents({ actor: 1, targetType: '' }),
+ { wrapper: createWrapper() },
+ )
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(capturedUrl).toContain('actor=1')
+ expect(capturedUrl).not.toContain('targetType=')
+ expect(capturedUrl).not.toContain('from=')
+ expect(capturedUrl).not.toContain('to=')
+ expect(capturedUrl).not.toContain('cursor=')
+ })
+})
diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs
new file mode 100644
index 0000000..d8732be
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs
@@ -0,0 +1,87 @@
+using System.Security.Claims;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using SIGCM2.Api.Middleware;
+using Xunit;
+
+namespace SIGCM2.Api.Tests.Audit;
+
+/// UDT-010 Batch 4 — AuditActorMiddleware unit tests (Strict TDD).
+/// Reads ActorUserId from the JWT "sub" claim after auth middleware populates HttpContext.User.
+public sealed class AuditActorMiddlewareTests
+{
+ [Fact]
+ public async Task Invoke_AuthenticatedUserWithSubClaim_SetsActorUserId()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("sub", "42"),
+ new Claim("rol", "admin"),
+ }, authenticationType: "Bearer"));
+
+ var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
+ await mw.InvokeAsync(ctx);
+
+ ctx.Items["audit:actorUserId"].Should().Be(42);
+ }
+
+ [Fact]
+ public async Task Invoke_AnonymousRequest_LeavesActorUserIdNull()
+ {
+ var ctx = new DefaultHttpContext();
+ // User is an unauthenticated ClaimsPrincipal by default
+ var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
+
+ await mw.InvokeAsync(ctx);
+
+ ctx.Items.TryGetValue("audit:actorUserId", out var value).Should().BeFalse();
+ value.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Invoke_AuthenticatedWithoutSubClaim_LeavesActorUserIdNull()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("name", "admin"),
+ }, authenticationType: "Bearer"));
+
+ var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
+ await mw.InvokeAsync(ctx);
+
+ ctx.Items.TryGetValue("audit:actorUserId", out _).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Invoke_SubClaimIsNonNumeric_LeavesActorUserIdNull()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("sub", "not-an-int"),
+ }, authenticationType: "Bearer"));
+
+ var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
+ await mw.InvokeAsync(ctx);
+
+ ctx.Items.TryGetValue("audit:actorUserId", out _).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Invoke_CallsNextDelegate()
+ {
+ var ctx = new DefaultHttpContext();
+ var nextCalled = false;
+ var mw = new AuditActorMiddleware(_ =>
+ {
+ nextCalled = true;
+ return Task.CompletedTask;
+ });
+
+ await mw.InvokeAsync(ctx);
+
+ nextCalled.Should().BeTrue();
+ }
+}
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");
+ }
+}
diff --git a/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs b/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs
new file mode 100644
index 0000000..847eac4
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs
@@ -0,0 +1,100 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using SIGCM2.Api.Middleware;
+using Xunit;
+
+namespace SIGCM2.Api.Tests.Audit;
+
+/// UDT-010 Batch 4 — CorrelationIdMiddleware unit tests (Strict TDD).
+/// Validates #REQ-AUD-9 (CorrelationId in response header) and population of
+/// HttpContext.Items entries consumed by AuditContext.
+public sealed class CorrelationIdMiddlewareTests
+{
+ private const string HeaderName = "X-Correlation-Id";
+
+ [Fact]
+ public async Task Invoke_HeaderAbsent_GeneratesNewCorrelationId()
+ {
+ var ctx = new DefaultHttpContext();
+ var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
+
+ await mw.InvokeAsync(ctx);
+
+ ctx.Items.TryGetValue("audit:correlationId", out var value).Should().BeTrue();
+ value.Should().BeOfType();
+ ((Guid)value!).Should().NotBe(Guid.Empty);
+ }
+
+ [Fact]
+ public async Task Invoke_HeaderPresent_UsesClientProvidedCorrelationId()
+ {
+ var expected = Guid.NewGuid();
+ var ctx = new DefaultHttpContext();
+ ctx.Request.Headers[HeaderName] = expected.ToString("D");
+ var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
+
+ await mw.InvokeAsync(ctx);
+
+ ctx.Items["audit:correlationId"].Should().Be(expected);
+ }
+
+ [Fact]
+ public async Task Invoke_HeaderIsMalformed_GeneratesNewCorrelationId()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.Request.Headers[HeaderName] = "not-a-guid";
+ var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
+
+ await mw.InvokeAsync(ctx);
+
+ var stored = (Guid)ctx.Items["audit:correlationId"]!;
+ stored.Should().NotBe(Guid.Empty);
+ }
+
+ [Fact]
+ public async Task Invoke_SetsResponseHeader_WithCorrelationId()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.Response.Body = new MemoryStream();
+ var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
+
+ await mw.InvokeAsync(ctx);
+
+ // OnStarting callbacks fire when response starts — simulate by writing
+ await ctx.Response.Body.FlushAsync();
+ // For DefaultHttpContext + MemoryStream, the OnStarting hook must fire when body writes start.
+ // We assert the header is present after invoking a manual start-write.
+ ctx.Response.Headers.TryGetValue(HeaderName, out var headerValue).Should().BeTrue();
+ Guid.TryParse(headerValue.ToString(), out _).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Invoke_SetsIpAndUserAgentInItems()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.20.30.40");
+ ctx.Request.Headers.UserAgent = "test-agent/1.0";
+ var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
+
+ await mw.InvokeAsync(ctx);
+
+ ctx.Items["audit:ip"].Should().Be("10.20.30.40");
+ ctx.Items["audit:userAgent"].Should().Be("test-agent/1.0");
+ }
+
+ [Fact]
+ public async Task Invoke_CallsNextDelegate()
+ {
+ var ctx = new DefaultHttpContext();
+ var nextCalled = false;
+ var mw = new CorrelationIdMiddleware(_ =>
+ {
+ nextCalled = true;
+ return Task.CompletedTask;
+ });
+
+ await mw.InvokeAsync(ctx);
+
+ nextCalled.Should().BeTrue();
+ }
+}
diff --git a/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs b/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs
new file mode 100644
index 0000000..8c7bcf6
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs
@@ -0,0 +1,59 @@
+using System.Transactions;
+using FluentAssertions;
+using Microsoft.Data.SqlClient;
+using Xunit;
+
+namespace SIGCM2.Api.Tests.Audit;
+
+/// UDT-010 Batch 0 — anti-MSDTC spike. Validates design decision #D-1:
+/// TransactionScope with AsyncFlowEnabled must NOT escalate to MSDTC when
+/// multiple SqlConnections share a single connection string. If this fails,
+/// UDT-010 must pivot to explicit IUnitOfWork.
+[Collection("ApiIntegration")]
+public sealed class TransactionScopeSpikeTests
+{
+ private const string ConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ [Fact]
+ public async Task TransactionScope_DoesNotEscalateToMSDTC_WithSingleConnectionString()
+ {
+ var txOptions = new TransactionOptions
+ {
+ IsolationLevel = IsolationLevel.ReadCommitted
+ };
+
+ using var tx = new TransactionScope(
+ TransactionScopeOption.Required,
+ txOptions,
+ TransactionScopeAsyncFlowOption.Enabled);
+
+ await using (var conn1 = new SqlConnection(ConnectionString))
+ {
+ await conn1.OpenAsync();
+ using var cmd1 = conn1.CreateCommand();
+ cmd1.CommandText = "SELECT 1";
+ var result1 = await cmd1.ExecuteScalarAsync();
+ result1.Should().Be(1);
+ }
+
+ await using (var conn2 = new SqlConnection(ConnectionString))
+ {
+ await conn2.OpenAsync();
+ using var cmd2 = conn2.CreateCommand();
+ cmd2.CommandText = "SELECT 1";
+ var result2 = await cmd2.ExecuteScalarAsync();
+ result2.Should().Be(1);
+ }
+
+ var current = Transaction.Current;
+ current.Should().NotBeNull("TransactionScope must set an ambient transaction");
+ current!.TransactionInformation.DistributedIdentifier
+ .Should().Be(Guid.Empty,
+ "SQL Server with a single connection string must NOT escalate to MSDTC. " +
+ "If this assertion fails, UDT-010 design must pivot to explicit IUnitOfWork " +
+ "(see sdd/udt-010-auditoria-trazabilidad/design #D-1).");
+
+ tx.Complete();
+ }
+}
diff --git a/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs b/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs
new file mode 100644
index 0000000..e433d95
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs
@@ -0,0 +1,150 @@
+using Dapper;
+using FluentAssertions;
+using Microsoft.Data.SqlClient;
+using Xunit;
+
+namespace SIGCM2.Api.Tests.Audit;
+
+/// UDT-010 Batch 1 — V010 migration integration smoke tests.
+/// Validates:
+/// REQ-AUD-1: SYSTEM_VERSIONING active on catalog entities (smoke: Usuario + Usuario_History query).
+/// REQ-AUD-2: dbo.AuditEvent exists, accepts valid INSERT, CHECK constraints reject invalid data.
+/// REQ-SEC-1: dbo.SecurityEvent exists, CK_SecurityEvent_Result rejects invalid Result.
+[Collection("ApiIntegration")]
+public sealed class V010MigrationTests : IClassFixture
+{
+ private const string ConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ public V010MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
+ {
+ // Depend on the factory so SqlTestFixture.InitializeAsync runs (validates V010 applied + resets DB).
+ }
+
+ [Fact]
+ public async Task AuditEvent_Insert_WithValidData_Persists()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ var correlationId = Guid.NewGuid();
+ var insertedId = await conn.ExecuteScalarAsync("""
+ INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, CorrelationId, Metadata)
+ VALUES (@ActorUserId, @Action, @TargetType, @TargetId, @CorrelationId, @Metadata);
+ SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
+ """,
+ new
+ {
+ ActorUserId = 1,
+ Action = "usuario.create",
+ TargetType = "Usuario",
+ TargetId = "42",
+ CorrelationId = correlationId,
+ Metadata = """{"after":{"username":"juan"}}"""
+ });
+
+ insertedId.Should().BeGreaterThan(0);
+
+ var roundtrip = await conn.QuerySingleAsync<(string Action, string TargetType, string TargetId, Guid? CorrelationId)>(
+ "SELECT Action, TargetType, TargetId, CorrelationId FROM dbo.AuditEvent WHERE Id = @Id",
+ new { Id = insertedId });
+ roundtrip.Action.Should().Be("usuario.create");
+ roundtrip.TargetType.Should().Be("Usuario");
+ roundtrip.TargetId.Should().Be("42");
+ roundtrip.CorrelationId.Should().Be(correlationId);
+ }
+
+ [Fact]
+ public async Task AuditEvent_Insert_WithInvalidActionFormat_FailsCheckConstraint()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ var act = async () => await conn.ExecuteAsync("""
+ INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId)
+ VALUES (1, 'invalid_no_dot', 'Usuario', '1');
+ """);
+
+ await act.Should().ThrowAsync()
+ .Where(e => e.Message.Contains("CK_AuditEvent_Action"));
+ }
+
+ [Fact]
+ public async Task AuditEvent_Insert_WithNonJsonMetadata_FailsCheckConstraint()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ var act = async () => await conn.ExecuteAsync("""
+ INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, Metadata)
+ VALUES (1, 'usuario.create', 'Usuario', '1', 'not-json');
+ """);
+
+ await act.Should().ThrowAsync()
+ .Where(e => e.Message.Contains("CK_AuditEvent_Metadata"));
+ }
+
+ [Fact]
+ public async Task SecurityEvent_Insert_WithInvalidResult_FailsCheckConstraint()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ var act = async () => await conn.ExecuteAsync("""
+ INSERT INTO dbo.SecurityEvent (ActorUserId, Action, Result)
+ VALUES (1, 'login', 'neutral');
+ """);
+
+ await act.Should().ThrowAsync()
+ .Where(e => e.Message.Contains("CK_SecurityEvent_Result"));
+ }
+
+ [Fact]
+ public async Task Usuario_SystemVersioning_IsActive_And_TemporalQueryReturnsHistory()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ // Seed: insert a scratch user and wait long enough for ValidFrom to be in the past
+ var username = "b1spike_" + Guid.NewGuid().ToString("N")[..8];
+ var newId = await conn.ExecuteScalarAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson)
+ VALUES (@u, 'hash', 'Temp', 'User', 'admin', '{"grant":[],"deny":[]}');
+ SELECT CAST(SCOPE_IDENTITY() AS INT);
+ """, new { u = username });
+
+ // Capture a timestamp *after* creation but *before* update
+ await Task.Delay(50);
+ var snapshotTime = DateTime.UtcNow;
+ await Task.Delay(50);
+
+ // Update the email
+ await conn.ExecuteAsync(
+ "UPDATE dbo.Usuario SET Email = @e WHERE Id = @id",
+ new { e = "new@example.com", id = newId });
+
+ // Temporal query: state at snapshotTime should NOT yet have the new email
+ var historicalEmail = await conn.ExecuteScalarAsync("""
+ SELECT Email FROM dbo.Usuario
+ FOR SYSTEM_TIME AS OF @ts
+ WHERE Id = @id
+ """, new { ts = snapshotTime, id = newId });
+
+ historicalEmail.Should().BeNull("the historical snapshot predates the email update");
+
+ // Current state HAS the new email
+ var currentEmail = await conn.ExecuteScalarAsync(
+ "SELECT Email FROM dbo.Usuario WHERE Id = @id",
+ new { id = newId });
+ currentEmail.Should().Be("new@example.com");
+
+ // Usuario_History must have at least one row for this user
+ var historyCount = await conn.ExecuteScalarAsync(
+ "SELECT COUNT(*) FROM dbo.Usuario_History WHERE Id = @id",
+ new { id = newId });
+ historyCount.Should().BeGreaterThan(0);
+
+ // Cleanup: Respawn will reset between test runs, but this test could run alone — best-effort delete.
+ await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Id = @id", new { id = newId });
+ }
+}
diff --git a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
index 53606e2..68af237 100644
--- a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
+++ b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Api.Tests.Authorization;
@@ -18,6 +19,7 @@ public sealed class PermissionAuthorizationHandlerTests
{
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For();
private readonly IUsuarioRepository _usuarioRepo = Substitute.For();
+ private readonly ISecurityEventLogger _security = Substitute.For();
private readonly PermissionAuthorizationHandler _handler;
public PermissionAuthorizationHandlerTests()
@@ -29,6 +31,7 @@ public sealed class PermissionAuthorizationHandlerTests
_handler = new PermissionAuthorizationHandler(
_rolPermisoRepo,
_usuarioRepo,
+ _security,
NullLogger.Instance);
}
diff --git a/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs b/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs
new file mode 100644
index 0000000..8870165
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs
@@ -0,0 +1,179 @@
+using FluentAssertions;
+using NSubstitute;
+using SIGCM2.Application.Audit;
+using SIGCM2.Domain.Exceptions;
+using Xunit;
+
+namespace SIGCM2.Application.Tests.Audit;
+
+/// UDT-010 Batch 2 — shape contract tests for audit abstractions.
+/// These tests fail to compile if the interface/DTO/exception shape drifts from #D-8.
+public sealed class AuditAbstractionsTests
+{
+ [Fact]
+ public void IAuditContext_ExposesExpectedReadOnlyProperties()
+ {
+ var ctx = Substitute.For();
+ ctx.ActorUserId.Returns(42);
+ ctx.ActorRoleId.Returns(7);
+ ctx.Ip.Returns("127.0.0.1");
+ ctx.UserAgent.Returns("test-agent/1.0");
+ var corrId = Guid.NewGuid();
+ ctx.CorrelationId.Returns(corrId);
+
+ ctx.ActorUserId.Should().Be(42);
+ ctx.ActorRoleId.Should().Be(7);
+ ctx.Ip.Should().Be("127.0.0.1");
+ ctx.UserAgent.Should().Be("test-agent/1.0");
+ ctx.CorrelationId.Should().Be(corrId);
+ }
+
+ [Fact]
+ public void IAuditContext_AllowsNullActorAndConnectionMetadata()
+ {
+ var ctx = Substitute.For();
+ ctx.ActorUserId.Returns((int?)null);
+ ctx.ActorRoleId.Returns((int?)null);
+ ctx.Ip.Returns((string?)null);
+ ctx.UserAgent.Returns((string?)null);
+
+ ctx.ActorUserId.Should().BeNull();
+ ctx.ActorRoleId.Should().BeNull();
+ ctx.Ip.Should().BeNull();
+ ctx.UserAgent.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task IAuditLogger_LogAsync_ExposesExpectedSignature()
+ {
+ var logger = Substitute.For();
+
+ await logger.LogAsync(
+ action: "usuario.create",
+ targetType: "Usuario",
+ targetId: "42",
+ metadata: new { username = "juan" },
+ ct: CancellationToken.None);
+
+ await logger.Received(1).LogAsync(
+ "usuario.create",
+ "Usuario",
+ "42",
+ Arg.Any