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 ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware; /// /// M4 Bundle D (D1) — verifies emits exactly one /// row per request via /// 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). /// public class AuditWriteMiddlewareTests { /// /// Test-only recording . Captures every /// the middleware emits so each test can assert on /// the shape of the row produced for one request. /// private sealed class RecordingAuditWriter : ICentralAuditWriter { // C3 (Task 2.5): store the decomposed row view so assertions keep // reading the ScadaBridge domain fields as typed properties. public List Events { get; } = new(); public Func? OnWrite { get; set; } public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) { Events.Add(evt.AsRow()); } return OnWrite?.Invoke(evt) ?? Task.CompletedTask; } } /// /// Builds an 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. /// 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, IAuditActorAccessor? actorAccessor = null) => new( next, writer, NullLogger.Instance, new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()), actorAccessor); /// /// File-local test double returning a fixed /// actor string — stands in for the HTTP-backed accessor that reads the /// authenticated principal off the ambient request (Phase 3). /// private sealed class StubAuditActorAccessor : IAuditActorAccessor { public StubAuditActorAccessor(string? currentActor) => CurrentActor = currentActor; public string? CurrentActor { get; } } /// /// File-local test double — returns the /// same snapshot on every read, no change-token plumbing required. Mirrors the /// StaticMonitor pattern in /// tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/InboundChannelCapTests.cs. /// private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor { private readonly AuditLogOptions _value; public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value; public AuditLogOptions CurrentValue => _value; public AuditLogOptions Get(string? name) => _value; public IDisposable? OnChange(Action 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); // C3: ForwardState is no longer a canonical field (site-storage-only); // central direct-write rows never carry it. 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( () => 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 the shared ZB.MOM.WW.Auth.ApiKeys verifier has // authenticated the Bearer token. 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); } // --------------------------------------------------------------------- // 5b. Phase 3 — Actor from the authenticated principal. When no API-key // name was stashed, the actor is sourced from IAuditActorAccessor // (the authenticated interactive cookie/LDAP user). The API-key stash // still takes precedence, and auth-failure / no-principal paths stay // null — never echo an unauthenticated principal back. // --------------------------------------------------------------------- [Fact] public async Task AuthenticatedUser_FromAccessor_RecordedAsActor_WhenNoApiKeyStash() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware( _ => { // No API-key name stashed — this is an interactive cookie/LDAP // authenticated inbound user, surfaced via the accessor. ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer, actorAccessor: new StubAuditActorAccessor("alice")); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal("alice", evt.Actor); } [Fact] public async Task ApiKeyStash_TakesPrecedence_OverAuthenticatedPrincipal() { // A key-authenticated caller: the endpoint handler stashed the API key // name. Even if an accessor would resolve a principal, the API-key // identity is the canonical actor for the key-authenticated path. var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware( _ => { ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc"; ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer, actorAccessor: new StubAuditActorAccessor("should-not-win")); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal("integration-svc", evt.Actor); } [Fact] public async Task AuthFailure_KeepsActorNull_EvenWhenAccessorResolvesPrincipal() { // 401/403 force the actor null BEFORE resolution — an auth failure must // never echo a principal back, even one the accessor could produce. var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware( _ => { ctx.Response.StatusCode = 401; return Task.CompletedTask; }, writer, actorAccessor: new StubAuditActorAccessor("attacker")); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind); Assert.Null(evt.Actor); } [Fact] public async Task NoApiKey_NoAuthenticatedPrincipal_LeavesActorNull() { // Accessor present but returns null (no authenticated interactive user) // and no API-key stash — the actor stays null rather than empty/echoed. var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware( _ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer, actorAccessor: new StubAuditActorAccessor(currentActor: null)); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Null(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(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( () => 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); } // --------------------------------------------------------------------- // InboundAPI-018: asynchronously faulted audit-write tasks must be // observed (logged) rather than silently dropped — but must still NOT // alter the user-facing HTTP response (alog.md §13). // --------------------------------------------------------------------- /// /// Test-only writer whose returns a Task that /// faults AFTER an asynchronous boundary, so the throw happens after /// 's synchronous try/catch can see it — /// exactly the fire-and-forget bug InboundAPI-018 closes. /// private sealed class AsyncFaultingAuditWriter : ICentralAuditWriter { public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { return FaultAsync(); static async Task FaultAsync() { // Yield off-thread so the fault surfaces ASYNCHRONOUSLY (not // captured by a synchronous try/catch around the WriteAsync // call site). await Task.Yield(); throw new InvalidOperationException("async audit write failed"); } } } /// /// Captures log entries written through a /// so the test can assert on the Warning that /// emits. /// private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger { public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; public void Log( Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, Exception? exception, Func formatter) { lock (Entries) { Entries.Add((logLevel, formatter(state, exception), exception)); } } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } [Fact] public async Task AuditWriter_AsyncFault_IsObserved_AsWarning_AndDoesNotAlterResponse() { var writer = new AsyncFaultingAuditWriter(); var logger = new RecordingLogger(); var ctx = BuildContext(); var mw = new AuditWriteMiddleware( next: _ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, auditWriter: writer, logger: logger, options: new StaticAuditLogOptionsMonitor(new AuditLogOptions())); await mw.InvokeAsync(ctx); // The user-facing response is untouched — audit emission is best-effort. Assert.Equal(200, ctx.Response.StatusCode); // Give the off-thread continuation a moment to fire and log. Spin // briefly rather than sleep-then-assert so the test is resilient to // scheduler jitter without inflating runtime on success. var deadline = DateTime.UtcNow.AddSeconds(2); while (DateTime.UtcNow < deadline) { lock (logger.Entries) { if (logger.Entries.Any(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Exception is not null && e.Message.Contains("async audit write faulted"))) { return; } } await Task.Delay(20); } // If we reach this point, the continuation did not fire — pre-fix the // fault would have been swallowed entirely and no log line emitted. var snapshot = logger.Entries.Select(e => $"{e.Level}: {e.Message}").ToList(); Assert.Fail( "Expected a Warning log entry observing the async audit-write fault — none found. " + $"Entries: [{string.Join(", ", snapshot)}]"); } // --------------------------------------------------------------------- // InboundAPI-019 — bodyless requests skip EnableBuffering so the // FileBufferingReadStream allocation is avoided on GET/HEAD/DELETE // and any request whose Content-Length is 0. The audit row still emits // with a null RequestSummary, mirroring the bodyless-POST contract. // --------------------------------------------------------------------- [Theory] [InlineData("GET")] [InlineData("HEAD")] [InlineData("DELETE")] public async Task BodylessMethod_SkipsEnableBuffering_RequestStreamIsNotReplaced(string method) { // The middleware previously called EnableBuffering on every request, // installing a FileBufferingReadStream wrapper even when the request // had no body. The bodyless-method short-circuit must leave // Request.Body untouched (still the original empty stream the test // assigns below), proving the buffering wrapper allocation is avoided. var writer = new RecordingAuditWriter(); var ctx = new DefaultHttpContext(); ctx.Request.Method = method; ctx.Request.Path = "/api/echo"; ctx.Request.RouteValues["methodName"] = "echo"; ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5"); // Distinct sentinel stream — the production code path that called // EnableBuffering would replace this with FileBufferingReadStream. // After the fix the original stream survives untouched. var sentinel = new MemoryStream(); ctx.Request.Body = sentinel; Stream? observedDuringHandler = null; var mw = CreateMiddleware(hc => { observedDuringHandler = hc.Request.Body; hc.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); Assert.Same(sentinel, observedDuringHandler); var evt = Assert.Single(writer.Events); // No body → RequestSummary stays null, matching the bodyless-POST contract. Assert.Null(evt.RequestSummary); } [Fact] public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering() { // A POST with an explicit Content-Length of 0 is also bodyless — even // though POST is conventionally a body-carrying method, the explicit // zero short-circuits buffering. This pins the ContentLength branch of // the RequestHasBody guard. var writer = new RecordingAuditWriter(); var ctx = new DefaultHttpContext(); ctx.Request.Method = "POST"; ctx.Request.Path = "/api/echo"; ctx.Request.RouteValues["methodName"] = "echo"; ctx.Request.ContentLength = 0; ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5"); var sentinel = new MemoryStream(); ctx.Request.Body = sentinel; Stream? observedDuringHandler = null; var mw = CreateMiddleware(hc => { observedDuringHandler = hc.Request.Body; hc.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); Assert.Same(sentinel, observedDuringHandler); var evt = Assert.Single(writer.Events); Assert.Null(evt.RequestSummary); } [Fact] public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary() { // Regression: the bodyless short-circuit must NOT regress the existing // body-capture contract for normal POSTs — we still need to buffer + // capture the request body for the audit row. var writer = new RecordingAuditWriter(); var requestJson = "{\"a\":42}"; var ctx = BuildContext(body: requestJson); 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); await mw.InvokeAsync(ctx); Assert.Equal(requestJson, observedAfterMiddleware); var evt = Assert.Single(writer.Events); Assert.Equal(requestJson, evt.RequestSummary); } // --------------------------------------------------------------------- // M5.3 (T7) Increment 1: Request headers in Extra JSON // Request headers are captured into the Extra JSON object alongside the // existing remoteIp / userAgent fields. Sensitive headers (e.g. // Authorization, X-Api-Key) are redacted to "" using the same // HeaderRedactList as ScadaBridgeAuditRedactor. // --------------------------------------------------------------------- [Fact] public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); ctx.Request.Headers["X-Custom-Header"] = "custom-value"; 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; // Extra must carry a requestHeaders object. Assert.True(root.TryGetProperty("requestHeaders", out var headers), "Extra JSON must contain a 'requestHeaders' property"); Assert.Equal(JsonValueKind.Object, headers.ValueKind); // The non-sensitive custom header must appear unredacted. Assert.True(headers.TryGetProperty("X-Custom-Header", out var customVal), "requestHeaders must contain 'X-Custom-Header'"); Assert.Equal("custom-value", customVal.GetString()); } [Fact] public async Task RequestHeaders_AuthorizationHeader_IsRedacted() { // Authorization is in the default HeaderRedactList and must appear as // "" rather than the real token value. var writer = new RecordingAuditWriter(); var ctx = BuildContext(); ctx.Request.Headers["Authorization"] = "Bearer secret-token-abc"; 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; var headers = root.GetProperty("requestHeaders"); Assert.True(headers.TryGetProperty("Authorization", out var authVal), "requestHeaders must contain 'Authorization'"); Assert.Equal("", authVal.GetString()); } [Fact] public async Task RequestHeaders_XApiKeyHeader_IsRedacted() { // X-Api-Key is in the default HeaderRedactList and must be redacted. var writer = new RecordingAuditWriter(); var ctx = BuildContext(); ctx.Request.Headers["X-Api-Key"] = "sbk_12345_secretkey"; 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; var headers = root.GetProperty("requestHeaders"); Assert.True(headers.TryGetProperty("X-Api-Key", out var keyVal)); Assert.Equal("", keyVal.GetString()); } [Fact] public async Task RequestHeaders_CustomRedactListEntry_IsRedacted() { // A non-default entry added to HeaderRedactList must also be redacted. var opts = new AuditLogOptions { HeaderRedactList = new List { "Authorization", "X-Api-Key", "Cookie", "Set-Cookie", "X-Internal-Secret", // custom addition }, }; var writer = new RecordingAuditWriter(); var ctx = BuildContext(); ctx.Request.Headers["X-Internal-Secret"] = "my-secret-value"; ctx.Request.Headers["X-Safe-Header"] = "safe-value"; var mw = CreateMiddleware( _ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer, options: opts); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); using var doc = JsonDocument.Parse(evt.Extra!); var headers = doc.RootElement.GetProperty("requestHeaders"); Assert.Equal("", headers.GetProperty("X-Internal-Secret").GetString()); Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString()); } [Fact] public async Task RequestHeaders_Redaction_IsCaseInsensitive() { // HeaderRedactList match must be case-insensitive (mirrors the // ScadaBridgeAuditRedactor behaviour — the redact set uses // OrdinalIgnoreCase). var writer = new RecordingAuditWriter(); var ctx = BuildContext(); // Vary the casing from the list entry ("Authorization"). ctx.Request.Headers["authorization"] = "Bearer lower-case-token"; var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); using var doc = JsonDocument.Parse(evt.Extra!); var headers = doc.RootElement.GetProperty("requestHeaders"); // ASP.NET Core normalises the header name to "authorization" in the dict; // the redact set (OrdinalIgnoreCase) must still match it. Assert.Equal("", headers.GetProperty("authorization").GetString()); } // --------------------------------------------------------------------- // M5.3 (T7) Increment 2: AuditInboundCeilingHits counter // When request OR response exceeds InboundMaxBytes, the middleware // increments IAuditInboundCeilingHitsCounter once per request. // --------------------------------------------------------------------- /// /// In-memory that records /// every call. /// private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter { private int _count; public int Count => Volatile.Read(ref _count); public void Increment() => Interlocked.Increment(ref _count); } private static AuditWriteMiddleware CreateMiddlewareWithCounter( RequestDelegate next, ICentralAuditWriter writer, AuditLogOptions? options, ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter counter) => new( next, writer, NullLogger.Instance, new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()), actorAccessor: null, ceilingHitsCounter: counter); [Fact] public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter() { const int cap = 1024; var bigBody = new string('x', cap + 100); var writer = new RecordingAuditWriter(); var counter = new RecordingCeilingHitsCounter(); var ctx = BuildContext(body: bigBody); var mw = CreateMiddlewareWithCounter( hc => { hc.Response.StatusCode = 200; return Task.CompletedTask; }, writer, options: new AuditLogOptions { InboundMaxBytes = cap }, counter: counter); await mw.InvokeAsync(ctx); Assert.Equal(1, counter.Count); // Verify the truncation did happen to confirm ceiling was hit. var evt = Assert.Single(writer.Events); Assert.True(evt.PayloadTruncated); } [Fact] public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter() { const int cap = 1024; var bigResponse = new string('y', cap + 100); var writer = new RecordingAuditWriter(); var counter = new RecordingCeilingHitsCounter(); var ctx = BuildContext(); ctx.Response.Body = new MemoryStream(); var mw = CreateMiddlewareWithCounter( async hc => { hc.Response.StatusCode = 200; await hc.Response.WriteAsync(bigResponse); }, writer, options: new AuditLogOptions { InboundMaxBytes = cap }, counter: counter); await mw.InvokeAsync(ctx); Assert.Equal(1, counter.Count); var evt = Assert.Single(writer.Events); Assert.True(evt.PayloadTruncated); } [Fact] public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter() { var writer = new RecordingAuditWriter(); var counter = new RecordingCeilingHitsCounter(); var smallBody = "{\"ok\":true}"; var ctx = BuildContext(body: smallBody); // Cap is well above the body size. var mw = CreateMiddlewareWithCounter( hc => { hc.Response.StatusCode = 200; return Task.CompletedTask; }, writer, options: new AuditLogOptions { InboundMaxBytes = 8192 }, counter: counter); await mw.InvokeAsync(ctx); Assert.Equal(0, counter.Count); } // --------------------------------------------------------------------- // M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out // A target with SkipBodyCapture=true produces an audit row with // headers/metadata but empty/omitted body. A normal target still captures. // --------------------------------------------------------------------- private static DefaultHttpContext BuildContextWithRoute( string methodName, string? body = null) { var ctx = new DefaultHttpContext(); ctx.Request.Method = "POST"; ctx.Request.Path = $"/api/{methodName}"; ctx.Request.RouteValues["methodName"] = methodName; ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1"); 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"; } return ctx; } [Fact] public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull() { // A target with SkipBodyCapture=true must produce an audit row (the // row must not be suppressed entirely) but RequestSummary and // ResponseSummary must both be null — only the body is omitted. var writer = new RecordingAuditWriter(); var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["secret-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride { SkipBodyCapture = true, }, }, }; var ctx = BuildContextWithRoute("secret-method", body: "{\"sensitive\":\"data\"}"); var mw = CreateMiddleware( async hc => { hc.Response.StatusCode = 200; await hc.Response.WriteAsync("{\"result\":\"secret\"}"); }, writer, options: opts); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); // Row IS emitted — only the body content is suppressed. Assert.Equal("secret-method", evt.Target); Assert.Equal(AuditStatus.Delivered, evt.Status); // Bodies are null — SkipBodyCapture stripped them. Assert.Null(evt.RequestSummary); Assert.Null(evt.ResponseSummary); // Headers / metadata are still present. Assert.NotNull(evt.Extra); using var doc = JsonDocument.Parse(evt.Extra!); Assert.True(doc.RootElement.TryGetProperty("requestHeaders", out _), "Headers must be present even when body capture is skipped"); Assert.Equal(200, evt.HttpStatus); } [Fact] public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented() { // When SkipBodyCapture=true the body is never measured against the cap; // the counter must NOT be bumped even if the body would have exceeded it. var writer = new RecordingAuditWriter(); var counter = new RecordingCeilingHitsCounter(); const int cap = 64; var bigBody = new string('z', cap + 1000); var opts = new AuditLogOptions { InboundMaxBytes = cap, PerTargetOverrides = new Dictionary { ["large-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride { SkipBodyCapture = true, }, }, }; var ctx = BuildContextWithRoute("large-method", body: bigBody); var mw = CreateMiddlewareWithCounter( hc => { hc.Response.StatusCode = 200; return Task.CompletedTask; }, writer, options: opts, counter: counter); await mw.InvokeAsync(ctx); Assert.Equal(0, counter.Count); } [Fact] public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody() { // Regression: a target WITHOUT SkipBodyCapture (or with SkipBodyCapture=false) // must still capture the body normally. var writer = new RecordingAuditWriter(); var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["normal-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride { SkipBodyCapture = false, }, }, }; var requestJson = "{\"a\":1}"; var ctx = BuildContextWithRoute("normal-method", body: requestJson); var mw = CreateMiddleware( async hc => { hc.Response.StatusCode = 200; await hc.Response.WriteAsync("{\"result\":1}"); }, writer, options: opts); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal(requestJson, evt.RequestSummary); Assert.Equal("{\"result\":1}", evt.ResponseSummary); } [Fact] public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody() { // A target with no per-target override at all must still capture the body — // SkipBodyCapture defaults to false and must not suppress capture. var writer = new RecordingAuditWriter(); var requestJson = "{\"x\":99}"; var ctx = BuildContext(body: requestJson); var mw = CreateMiddleware( async hc => { hc.Response.StatusCode = 200; await hc.Response.WriteAsync("{\"y\":99}"); }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal(requestJson, evt.RequestSummary); Assert.Equal("{\"y\":99}", evt.ResponseSummary); } }