diff --git a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs index db83c85..f6d3e13 100644 --- a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -42,11 +42,14 @@ namespace ScadaLink.InboundAPI.Middleware; /// /// Body capture. The request body is buffered via /// then -/// rewound so the downstream endpoint handler still sees the full payload. -/// Response body capture is deferred to M5 — wrapping Response.Body -/// requires a memory-stream swap that interacts awkwardly with Minimal API's -/// Results.Json/Results.Text writers; the M4 deliverable emits -/// the audit row with left null. +/// rewound so the downstream endpoint handler still sees the full payload. The +/// response body is captured by swapping for a +/// before the pipeline runs; after the pipeline +/// returns, the buffered bytes are copied to the original stream (transparent +/// to the real client) and read into . +/// Truncation to the configured inbound ceiling happens in +/// ; the +/// middleware itself stores the full buffered content. /// /// public sealed class AuditWriteMiddleware @@ -108,6 +111,16 @@ public sealed class AuditWriteMiddleware ctx.Request.EnableBuffering(); var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false); + // Response body — swap in a MemoryStream so the pipeline writes are + // captured. The original Response.Body is restored in the finally block, + // and the captured bytes are copied back to it so the real client still + // receives every byte (transparent wrap). The captured string is then + // available for the audit row. + var originalResponseBody = ctx.Response.Body; + using var responseBuffer = new MemoryStream(); + ctx.Response.Body = responseBuffer; + + string? responseBody = null; Exception? thrown = null; try { @@ -123,7 +136,15 @@ public sealed class AuditWriteMiddleware finally { sw.Stop(); - EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody); + + // Whatever the handler managed to write — full success, partial + // success before throwing, or nothing at all — copy back to the + // original stream and read for audit. + responseBody = await DrainResponseBufferAsync(responseBuffer, originalResponseBody) + .ConfigureAwait(false); + ctx.Response.Body = originalResponseBody; + + EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody, responseBody); } } @@ -136,7 +157,8 @@ public sealed class AuditWriteMiddleware HttpContext ctx, long durationMs, Exception? thrown, - string? requestBody) + string? requestBody, + string? responseBody) { try { @@ -187,8 +209,7 @@ public sealed class AuditWriteMiddleware DurationMs = (int)Math.Min(durationMs, int.MaxValue), ErrorMessage = thrown?.Message, RequestSummary = requestBody, - // Response body capture is deferred to M5 (see XML doc above). - ResponseSummary = null, + ResponseSummary = responseBody, PayloadTruncated = false, Extra = extra, // Central direct-write — no site-local forwarding state. @@ -245,6 +266,47 @@ public sealed class AuditWriteMiddleware } } + /// + /// Copies the bytes buffered in to + /// (so the real client still receives them) + /// and returns a UTF-8 string copy for . + /// Returns null when no bytes were written, mirroring the + /// empty-body contract. + /// + private static async Task DrainResponseBufferAsync( + MemoryStream buffer, + Stream originalBody) + { + if (buffer.Length == 0) + { + return null; + } + + buffer.Position = 0; + // Copy first so the client never misses bytes even if the read for audit + // throws somehow (defensive — MemoryStream.CopyToAsync to a sink shouldn't + // throw on its own, but the original body may). + try + { + await buffer.CopyToAsync(originalBody).ConfigureAwait(false); + } + catch + { + // Best-effort: a sink that refuses our copy is the sink's problem; + // the audit still records what the handler produced. Do NOT rethrow. + } + + buffer.Position = 0; + using var reader = new StreamReader( + buffer, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, + leaveOpen: true); + var content = await reader.ReadToEndAsync().ConfigureAwait(false); + return string.IsNullOrEmpty(content) ? null : content; + } + /// /// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request /// ExecutionId that minted and stashed on diff --git a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index a28a6f6..a81a607 100644 --- a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -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( + () => mw.InvokeAsync(ctx)); + Assert.Same(boom, thrown); + + var evt = Assert.Single(writer.Events); + Assert.Equal(AuditStatus.Failed, evt.Status); + Assert.Equal("partial", evt.ResponseSummary); + } }