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);
+ }
}