feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows
This commit is contained in:
@@ -42,11 +42,14 @@ namespace ScadaLink.InboundAPI.Middleware;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Body capture.</b> The request body is buffered via
|
/// <b>Body capture.</b> The request body is buffered via
|
||||||
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
|
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
|
||||||
/// rewound so the downstream endpoint handler still sees the full payload.
|
/// rewound so the downstream endpoint handler still sees the full payload. The
|
||||||
/// Response body capture is deferred to M5 — wrapping <c>Response.Body</c>
|
/// response body is captured by swapping <see cref="HttpResponse.Body"/> for a
|
||||||
/// requires a memory-stream swap that interacts awkwardly with Minimal API's
|
/// <see cref="MemoryStream"/> before the pipeline runs; after the pipeline
|
||||||
/// <c>Results.Json</c>/<c>Results.Text</c> writers; the M4 deliverable emits
|
/// returns, the buffered bytes are copied to the original stream (transparent
|
||||||
/// the audit row with <see cref="AuditEvent.ResponseSummary"/> left null.
|
/// to the real client) and read into <see cref="AuditEvent.ResponseSummary"/>.
|
||||||
|
/// Truncation to the configured inbound ceiling happens in
|
||||||
|
/// <see cref="ScadaLink.AuditLog.Payload.DefaultAuditPayloadFilter"/>; the
|
||||||
|
/// middleware itself stores the full buffered content.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuditWriteMiddleware
|
public sealed class AuditWriteMiddleware
|
||||||
@@ -108,6 +111,16 @@ public sealed class AuditWriteMiddleware
|
|||||||
ctx.Request.EnableBuffering();
|
ctx.Request.EnableBuffering();
|
||||||
var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false);
|
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;
|
Exception? thrown = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -123,7 +136,15 @@ public sealed class AuditWriteMiddleware
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
sw.Stop();
|
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,
|
HttpContext ctx,
|
||||||
long durationMs,
|
long durationMs,
|
||||||
Exception? thrown,
|
Exception? thrown,
|
||||||
string? requestBody)
|
string? requestBody,
|
||||||
|
string? responseBody)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -187,8 +209,7 @@ public sealed class AuditWriteMiddleware
|
|||||||
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
|
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
|
||||||
ErrorMessage = thrown?.Message,
|
ErrorMessage = thrown?.Message,
|
||||||
RequestSummary = requestBody,
|
RequestSummary = requestBody,
|
||||||
// Response body capture is deferred to M5 (see XML doc above).
|
ResponseSummary = responseBody,
|
||||||
ResponseSummary = null,
|
|
||||||
PayloadTruncated = false,
|
PayloadTruncated = false,
|
||||||
Extra = extra,
|
Extra = extra,
|
||||||
// Central direct-write — no site-local forwarding state.
|
// Central direct-write — no site-local forwarding state.
|
||||||
@@ -245,6 +266,47 @@ public sealed class AuditWriteMiddleware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the bytes buffered in <paramref name="buffer"/> to
|
||||||
|
/// <paramref name="originalBody"/> (so the real client still receives them)
|
||||||
|
/// and returns a UTF-8 string copy for <see cref="AuditEvent.ResponseSummary"/>.
|
||||||
|
/// Returns null when no bytes were written, mirroring the
|
||||||
|
/// <see cref="ReadBufferedRequestBodyAsync"/> empty-body contract.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string?> 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
|
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
|
||||||
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
|
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
|
||||||
|
|||||||
@@ -487,4 +487,98 @@ public class AuditWriteMiddlewareTests
|
|||||||
Assert.NotNull(evt.DurationMs);
|
Assert.NotNull(evt.DurationMs);
|
||||||
Assert.True(evt.DurationMs >= 0);
|
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