feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user