feat(api): audit context middleware + scoped impl (UDT-010 B4)

Wires the request-scoped audit context per design #D-2:

Middleware pipeline in Program.cs:
  app.UseCors()
  app.UseMiddleware<CorrelationIdMiddleware>()  // PRE-AUTH
  app.UseAuthentication()
  app.UseMiddleware<AuditActorMiddleware>()     // POST-AUTH
  app.UseAuthorization()
  app.MapControllers()

SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs:
- Preserves client-sent X-Correlation-Id header when a valid GUID, otherwise
  generates Guid.NewGuid(). Stores in HttpContext.Items (audit:correlationId).
- Captures Ip (Connection.RemoteIpAddress) + UserAgent header into Items.
- Echoes the correlation id back via response header (OnStarting + immediate
  set — immediate set makes unit testing against DefaultHttpContext reliable).

SIGCM2.Api/Middleware/AuditActorMiddleware.cs:
- Reads JWT 'sub' claim from authenticated HttpContext.User, parses to int,
  stores as audit:actorUserId. Anonymous / non-numeric sub leaves it unset.

SIGCM2.Infrastructure/Audit/AuditContext.cs (IAuditContext scoped impl):
- Reads Items entries via IHttpContextAccessor. Returns null / Guid.Empty
  when no HttpContext is available (jobs, tests without middleware).
- ActorRoleId intentionally null for now — rol code → id resolution is
  deferred; the logger may resolve it at persist time in a later batch.

DI registration (Infrastructure/DependencyInjection.cs):
- services.AddScoped<IAuditContext, AuditContext>()

Tests (Strict TDD):
- CorrelationIdMiddlewareTests (6): generates/preserves/handles-malformed
  correlation id, sets response header, captures ip/ua, calls next.
- AuditActorMiddlewareTests (5): authenticated/anonymous/no-sub/non-numeric/
  calls-next.
- AuditContextTests (7): reads from Items, null-http-context defaults,
  ActorRoleId currently null.

Suite: 355/355 Application.Tests + 141/141 Api.Tests = 496/496 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/9, design#D-2, tasks#B4}
This commit is contained in:
2026-04-16 13:32:13 -03:00
parent 08d6622e43
commit 0b4af4c332
8 changed files with 404 additions and 0 deletions

View File

@@ -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>();
((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();
}
}