feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows

This commit is contained in:
Joseph Doherty
2026-05-23 06:00:24 -04:00
parent 7b619d711d
commit a8d2e13d4e
2 changed files with 165 additions and 9 deletions

View File

@@ -487,4 +487,98 @@ public class AuditWriteMiddlewareTests
Assert.NotNull(evt.DurationMs);
Assert.True(evt.DurationMs >= 0);
}
// ---------------------------------------------------------------------
// Response body capture — Audit Log #23 (inbound full-response feature).
// Until the M5-deferred work landed, ResponseSummary was always null.
// These tests pin the new contract: the middleware wraps Response.Body,
// runs the pipeline, copies the buffered bytes back to the real stream,
// and stashes a UTF-8 string copy on ResponseSummary.
// ---------------------------------------------------------------------
[Fact]
public async Task ResponseBody_IsCaptured_OnResponseSummary()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var responseJson = "{\"result\":42}";
var mw = CreateMiddleware(async hc =>
{
hc.Response.StatusCode = 200;
hc.Response.ContentType = "application/json";
await hc.Response.WriteAsync(responseJson);
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(responseJson, evt.ResponseSummary);
}
[Fact]
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
{
// Wrapping the response body must be TRANSPARENT — the real client
// stream still receives every byte the pipeline wrote.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var captured = new MemoryStream();
ctx.Response.Body = captured; // simulate the client/test sink
var responseJson = "{\"ok\":true}";
var mw = CreateMiddleware(async hc =>
{
hc.Response.StatusCode = 200;
await hc.Response.WriteAsync(responseJson);
}, writer);
await mw.InvokeAsync(ctx);
Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
}
[Fact]
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
{
// No bytes written => null, not empty-string. Mirrors the request-body
// contract in ReadBufferedRequestBodyAsync.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(hc =>
{
hc.Response.StatusCode = 204;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ResponseSummary);
Assert.Equal(204, evt.HttpStatus);
}
[Fact]
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
{
// If the handler writes some bytes then throws, the audit row still
// surfaces whatever the framework had flushed. The middleware re-throws
// (audit is best-effort, the request's error path stays authoritative).
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var boom = new InvalidOperationException("kaboom");
var mw = CreateMiddleware(async hc =>
{
hc.Response.StatusCode = 500;
await hc.Response.WriteAsync("partial");
throw boom;
}, writer);
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
() => mw.InvokeAsync(ctx));
Assert.Same(boom, thrown);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal("partial", evt.ResponseSummary);
}
}