Files
scadalink-design/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
Joseph Doherty 7d87994ac0 feat(inboundapi): bound audit capture at InboundMaxBytes (memory safety)
AuditWriteMiddleware previously buffered the FULL request and response
bodies into memory and only let DefaultAuditPayloadFilter trim them
after persistence. A 500 MiB upload allocated 500 MiB of MemoryStream
plus 1 GiB of UTF-16 string transiently before the filter pulled it
back to the 1 MiB inbound ceiling — the cap was real on the persisted
row but not at the capture site.

Inject IOptionsMonitor<AuditLogOptions> and read InboundMaxBytes
per-request (same convention as DefaultAuditPayloadFilter so a live
config change picks up the next request). The request reader now pulls
at most cap + 1 bytes into a UTF-8 byte-safe-truncated string and
rewinds the stream so the endpoint handler still sees the full body.
The response wrap is a new CapturedResponseStream that forwards every
Write / WriteAsync to the real sink (the client still receives all
bytes) while capturing at most cap + 1 bytes for the audit copy. The
middleware now sets PayloadTruncated itself when either body hit the
cap; the filter still OR's its own determination on top.

Adds a project reference from ScadaLink.InboundAPI to
ScadaLink.AuditLog so AuditLogOptions resolves. AuditLog does NOT
reference InboundAPI back, so no cycle is introduced.

Tests:
 - All 21 existing AuditWriteMiddlewareTests still pass (the helper
   gains an optional AuditLogOptions argument; default is the standard
   1 MiB ceiling so existing small-body tests are unaffected).
 - MiddlewareOrderTests' construction site updated for the new ctor
   arg; a StaticAuditLogOptionsMonitor file-local double mirrors the
   InboundChannelCapTests pattern.
 - New RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue
   pins a 4 KiB cap against a 20 KB body: audit copy <= 4 KiB,
   PayloadTruncated = true, downstream handler reads the full 20 KB.
 - New ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue
   pins the same shape on the response side: client sink receives
   20 KB, audit copy <= 4 KiB, PayloadTruncated = true.

InboundAPI test count: 133 -> 135.
2026-05-23 09:25:00 -04:00

693 lines
26 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.InboundAPI.Middleware;
namespace ScadaLink.InboundAPI.Tests.Middleware;
/// <summary>
/// M4 Bundle D (D1) — verifies <see cref="AuditWriteMiddleware"/> emits exactly one
/// <see cref="AuditChannel.ApiInbound"/> row per request via
/// <see cref="ICentralAuditWriter"/> covering all outcome shapes:
/// success (InboundRequest/Delivered), client/server error (InboundRequest/Failed),
/// and unauthenticated (InboundAuthFailure/Failed). Audit-write failures must NEVER
/// alter the HTTP response (alog.md §13).
/// </summary>
public class AuditWriteMiddlewareTests
{
/// <summary>
/// Test-only recording <see cref="ICentralAuditWriter"/>. Captures every
/// <see cref="AuditEvent"/> the middleware emits so each test can assert on
/// the shape of the row produced for one request.
/// </summary>
private sealed class RecordingAuditWriter : ICentralAuditWriter
{
public List<AuditEvent> Events { get; } = new();
public Func<AuditEvent, Task>? OnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
{
Events.Add(evt);
}
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
}
}
/// <summary>
/// Builds an <see cref="HttpContext"/> primed for the inbound API route shape:
/// POST /api/{methodName}, optional JSON body, RemoteIpAddress + User-Agent.
/// The route value resolver mirrors the production endpoint mapping so the
/// middleware can pull the method name without owning routing itself.
/// </summary>
private static DefaultHttpContext BuildContext(
string methodName = "echo",
string? body = null,
string? userAgent = "test-agent/1.0",
IPAddress? remoteIp = null)
{
var ctx = new DefaultHttpContext();
ctx.Request.Method = "POST";
ctx.Request.Path = $"/api/{methodName}";
ctx.Request.RouteValues["methodName"] = methodName;
if (body is not null)
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Request.Body = new MemoryStream(bytes);
ctx.Request.ContentLength = bytes.Length;
ctx.Request.ContentType = "application/json";
}
if (userAgent is not null)
{
ctx.Request.Headers["User-Agent"] = userAgent;
}
ctx.Connection.RemoteIpAddress = remoteIp ?? IPAddress.Parse("10.0.0.5");
return ctx;
}
private static AuditWriteMiddleware CreateMiddleware(
RequestDelegate next,
ICentralAuditWriter writer,
AuditLogOptions? options = null) =>
new(
next,
writer,
NullLogger<AuditWriteMiddleware>.Instance,
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()));
/// <summary>
/// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
/// same snapshot on every read, no change-token plumbing required. Mirrors the
/// <c>StaticMonitor</c> pattern in
/// <c>tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs</c>.
/// </summary>
private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
// ---------------------------------------------------------------------
// 1. Happy path — InboundRequest/Delivered/HttpStatus 200
// ---------------------------------------------------------------------
[Fact]
public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal(200, evt.HttpStatus);
// Central direct-write — no ForwardState (alog.md §6).
Assert.Null(evt.ForwardState);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.Equal("echo", evt.Target);
}
// ---------------------------------------------------------------------
// 2. 400 — script/validation failure path
// ---------------------------------------------------------------------
[Fact]
public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 400;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
// A 400 is a request the auth succeeded for — still InboundRequest, not
// InboundAuthFailure. Only 401/403 maps to the auth-failure kind.
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(400, evt.HttpStatus);
}
// ---------------------------------------------------------------------
// 3. 401 — auth failure path
// ---------------------------------------------------------------------
[Fact]
public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 401;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(401, evt.HttpStatus);
// The candidate API key never resolved to a name, so Actor stays null —
// never echo back an unauthenticated principal.
Assert.Null(evt.Actor);
}
[Fact]
public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 403;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(403, evt.HttpStatus);
}
// ---------------------------------------------------------------------
// 4. 500 — handler threw OR returned 500
// ---------------------------------------------------------------------
[Fact]
public async Task Pipeline_500_EmitsEvent_Status_Failed()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 500;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(500, evt.HttpStatus);
}
[Fact]
public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var boom = new InvalidOperationException("kaboom");
var mw = CreateMiddleware(_ => throw boom, writer);
// The middleware MUST re-throw so the request's own error path is
// authoritative — audit emission is best-effort only.
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("kaboom", evt.ErrorMessage);
}
// ---------------------------------------------------------------------
// 5. Actor resolution — the endpoint handler stashes the API key name
// AFTER successful auth so the middleware can pick it up from
// HttpContext.Items.
// ---------------------------------------------------------------------
[Fact]
public async Task ApiKeyName_Resolved_From_HttpContext_AsActor()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
// The endpoint handler is expected to stash the resolved API key
// name here once ApiKeyValidator.ValidateAsync has succeeded.
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal("integration-svc", evt.Actor);
}
// ---------------------------------------------------------------------
// 6. Writer failure must NEVER alter the HTTP response
// ---------------------------------------------------------------------
[Fact]
public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success()
{
var writer = new RecordingAuditWriter
{
OnWrite = _ => throw new InvalidOperationException("writer offline"),
};
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
// Audit emission is best-effort; even a thrown writer must NOT bubble
// up and contaminate the user-facing response status.
await mw.InvokeAsync(ctx);
Assert.Equal(200, ctx.Response.StatusCode);
}
[Fact]
public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged()
{
var writer = new RecordingAuditWriter
{
OnWrite = _ => throw new InvalidOperationException("writer offline"),
};
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 500;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
Assert.Equal(500, ctx.Response.StatusCode);
}
// ---------------------------------------------------------------------
// 7. Provenance — RemoteIp + User-Agent surface in Extra JSON
// ---------------------------------------------------------------------
[Fact]
public async Task RemoteIp_And_UserAgent_AppearInExtra()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext(
userAgent: "curl/8.4.0",
remoteIp: IPAddress.Parse("192.168.50.50"));
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.Extra);
using var doc = JsonDocument.Parse(evt.Extra!);
var root = doc.RootElement;
Assert.Equal("192.168.50.50", root.GetProperty("remoteIp").GetString());
Assert.Equal("curl/8.4.0", root.GetProperty("userAgent").GetString());
}
// ---------------------------------------------------------------------
// Body capture — the small JSON body is buffered and stashed on
// RequestSummary so subsequent reads (the endpoint handler's
// JsonDocument.Parse) still see the full payload.
// ---------------------------------------------------------------------
[Fact]
public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary()
{
var writer = new RecordingAuditWriter();
var requestJson = "{\"x\":1}";
var ctx = BuildContext(body: requestJson);
string? observedAfterMiddleware = null;
var mw = CreateMiddleware(async hc =>
{
// Downstream code must still be able to read the body — the
// middleware enables buffering and rewinds so the handler sees the
// unconsumed stream.
using var reader = new StreamReader(hc.Request.Body);
observedAfterMiddleware = await reader.ReadToEndAsync();
hc.Response.StatusCode = 200;
}, writer);
await mw.InvokeAsync(ctx);
Assert.Equal(requestJson, observedAfterMiddleware);
var evt = Assert.Single(writer.Events);
Assert.Equal(requestJson, evt.RequestSummary);
}
// ---------------------------------------------------------------------
// Execution id — Audit Log #23: each inbound row carries a fresh
// per-request execution id so inbound rows are correlatable. The inbound
// row's CorrelationId stays null — CorrelationId is purely the
// per-operation-lifecycle id and an inbound request is a one-shot.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.ExecutionId);
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
// CorrelationId is the per-operation-lifecycle id; an inbound request
// is a one-shot with no multi-row operation to correlate.
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task SeparateRequests_GetDistinct_ExecutionIds()
{
var writer = new RecordingAuditWriter();
var mw = CreateMiddleware(hc =>
{
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(BuildContext());
await mw.InvokeAsync(BuildContext());
Assert.Equal(2, writer.Events.Count);
Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
}
// ---------------------------------------------------------------------
// ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
// inbound request's ExecutionId is minted ONCE, early, and stashed on
// HttpContext.Items so the endpoint handler can carry it onto the routed
// RouteToCallRequest as ParentExecutionId. The inbound row that the
// middleware itself emits stays top-level — its own ParentExecutionId is
// NEVER set.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
object? observedDuringHandler = null;
var mw = CreateMiddleware(hc =>
{
// The endpoint handler must be able to read the early-minted id —
// it is stashed before _next so a downstream reader sees it.
hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler);
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var stashed = Assert.IsType<Guid>(observedDuringHandler);
Assert.NotEqual(Guid.Empty, stashed);
}
[Fact]
public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
Guid stashedDuringHandler = Guid.Empty;
var mw = CreateMiddleware(hc =>
{
stashedDuringHandler =
(Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
// The inbound audit row's ExecutionId must be the SAME id minted early
// and shared with the endpoint handler — not a second, late mint.
var evt = Assert.Single(writer.Events);
Assert.Equal(stashedDuringHandler, evt.ExecutionId);
}
[Fact]
public async Task InboundRow_OwnParentExecutionId_StaysNull()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
// The inbound request is itself top-level — only the spawn id flows
// OUT on RouteToCallRequest. The inbound row's own ParentExecutionId
// is never set.
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
}
[Fact]
public async Task DurationMs_IsRecorded()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(async _ =>
{
// The middleware records elapsed milliseconds — a small delay
// ensures DurationMs is non-negative and roughly tracks reality
// without being flake-sensitive in CI.
await Task.Delay(5);
ctx.Response.StatusCode = 200;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
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);
}
// ---------------------------------------------------------------------
// Bounded audit capture — memory safety follow-up. The capture site now
// honours AuditLogOptions.InboundMaxBytes at READ time (not just at
// filter-time), so a 500 MiB body cannot transiently allocate 500 MiB of
// string. The cap is local to the AUDIT copy; downstream readers and the
// real client still see every byte.
// ---------------------------------------------------------------------
[Fact]
public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue()
{
// 4 KiB cap, 20 KB body — the audit copy must be UTF-8 byte-safe
// capped at 4 KiB AND PayloadTruncated must flip, while the
// downstream handler still sees the full 20 KB payload.
const int cap = 4096;
var bigBody = new string('a', 20_000);
var writer = new RecordingAuditWriter();
var ctx = BuildContext(body: bigBody);
string? observedAfterMiddleware = null;
var mw = CreateMiddleware(
async hc =>
{
using var reader = new StreamReader(hc.Request.Body);
observedAfterMiddleware = await reader.ReadToEndAsync();
hc.Response.StatusCode = 200;
},
writer,
options: new AuditLogOptions { InboundMaxBytes = cap });
await mw.InvokeAsync(ctx);
// (iii) Downstream handler still sees the FULL body — the cap applied
// only to the audit copy.
Assert.Equal(bigBody, observedAfterMiddleware);
var evt = Assert.Single(writer.Events);
// (i) Audit copy bounded at cap bytes (UTF-8 byte count).
Assert.NotNull(evt.RequestSummary);
Assert.True(
Encoding.UTF8.GetByteCount(evt.RequestSummary!) <= cap,
$"RequestSummary byte count {Encoding.UTF8.GetByteCount(evt.RequestSummary!)} exceeded cap {cap}");
// (ii) Truncation flag set by the middleware (the filter will OR its
// own determination on top, but the middleware MUST set it itself).
Assert.True(evt.PayloadTruncated);
}
[Fact]
public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue()
{
// 4 KiB cap, 20 KB response — the test sink (acts as the real client)
// MUST receive all 20 KB while the audit copy is bounded at 4 KiB.
const int cap = 4096;
var bigResponse = new string('b', 20_000);
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var captured = new MemoryStream();
ctx.Response.Body = captured; // stand-in for the client sink
var mw = CreateMiddleware(
async hc =>
{
hc.Response.StatusCode = 200;
await hc.Response.WriteAsync(bigResponse);
},
writer,
options: new AuditLogOptions { InboundMaxBytes = cap });
await mw.InvokeAsync(ctx);
// Client sink received every byte — the forwarding wrap is transparent.
Assert.Equal(bigResponse, Encoding.UTF8.GetString(captured.ToArray()));
var evt = Assert.Single(writer.Events);
// Audit copy bounded at cap bytes.
Assert.NotNull(evt.ResponseSummary);
Assert.True(
Encoding.UTF8.GetByteCount(evt.ResponseSummary!) <= cap,
$"ResponseSummary byte count {Encoding.UTF8.GetByteCount(evt.ResponseSummary!)} exceeded cap {cap}");
Assert.True(evt.PayloadTruncated);
}
}