diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditCentralHealthSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditCentralHealthSnapshot.cs index 396a80f5..68b6a814 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditCentralHealthSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditCentralHealthSnapshot.cs @@ -39,10 +39,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; public sealed class AuditCentralHealthSnapshot : IAuditCentralHealthSnapshot, ICentralAuditWriteFailureCounter, - IAuditRedactionFailureCounter + IAuditRedactionFailureCounter, + IAuditInboundCeilingHitsCounter { private int _centralAuditWriteFailures; private int _auditRedactionFailure; + private int _auditInboundCeilingHits; private readonly ConcurrentDictionary _stalled = new(); /// @@ -53,6 +55,10 @@ public sealed class AuditCentralHealthSnapshot public int AuditRedactionFailure => Interlocked.CompareExchange(ref _auditRedactionFailure, 0, 0); + /// + public int AuditInboundCeilingHits => + Interlocked.CompareExchange(ref _auditInboundCeilingHits, 0, 0); + /// public IReadOnlyDictionary SiteAuditTelemetryStalled => new Dictionary(_stalled); @@ -78,4 +84,8 @@ public sealed class AuditCentralHealthSnapshot /// void IAuditRedactionFailureCounter.Increment() => Interlocked.Increment(ref _auditRedactionFailure); + + /// + void IAuditInboundCeilingHitsCounter.Increment() => + Interlocked.Increment(ref _auditInboundCeilingHits); } diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/IAuditCentralHealthSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/IAuditCentralHealthSnapshot.cs index 357b67ae..89bb4675 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/IAuditCentralHealthSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/IAuditCentralHealthSnapshot.cs @@ -50,6 +50,17 @@ public interface IAuditCentralHealthSnapshot /// int AuditRedactionFailure { get; } + /// + /// Count of inbound request/response body truncations at the + /// + /// ceiling since process start. Incremented by + /// + /// whenever either the request or response body exceeds the cap and is + /// truncated in the audit copy. A sustained non-zero count can indicate + /// callers sending unexpectedly large bodies. + /// + int AuditInboundCeilingHits { get; } + /// /// Per-site latched stalled state: true when the /// has observed two diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/IAuditInboundCeilingHitsCounter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/IAuditInboundCeilingHitsCounter.cs new file mode 100644 index 00000000..8768a1c2 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/IAuditInboundCeilingHitsCounter.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; + +/// +/// Audit Log (#23) M5.3 (T7) counter sink incremented by +/// +/// whenever an inbound request or response body is truncated at the +/// +/// ceiling. Mirrors the shape: +/// one-method, NoOp default, must-never-abort-the-user-facing-action invariant. +/// +/// +/// A ceiling hit is a normal operational event (the caller sent a large +/// body) rather than a failure, but surfacing a cumulative count lets +/// operators detect over-size callers early. The +/// production implementation +/// accumulates the count via an Interlocked field alongside +/// and +/// . +/// +public interface IAuditInboundCeilingHitsCounter +{ + /// Increment the inbound body-ceiling hit counter by one. + void Increment(); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/NoOpAuditInboundCeilingHitsCounter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/NoOpAuditInboundCeilingHitsCounter.cs new file mode 100644 index 00000000..337fc729 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/NoOpAuditInboundCeilingHitsCounter.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; + +/// +/// Default binding used when +/// the central health snapshot is not wired (e.g. site composition roots, +/// test harnesses that have no health dashboard). All increments are silently +/// dropped — correct for environments that have no audit KPI surface. +/// +public sealed class NoOpAuditInboundCeilingHitsCounter : IAuditInboundCeilingHitsCounter +{ + /// + public void Increment() { } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/PerTargetRedactionOverride.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/PerTargetRedactionOverride.cs index a72b9508..274fcb7a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/PerTargetRedactionOverride.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/PerTargetRedactionOverride.cs @@ -25,4 +25,15 @@ public sealed class PerTargetRedactionOverride /// rows. /// public string? RedactSqlParamsMatching { get; set; } + + /// + /// When true, the inbound API audit row for this target records + /// request/response headers and metadata (status, duration, actor, etc.) + /// but the request and response body strings are omitted + /// (RequestSummary / ResponseSummary are left null). The + /// audit row itself is always emitted — only the body content is suppressed. + /// Null (the default, equivalent to false) means body capture + /// proceeds normally up to . + /// + public bool SkipBodyCapture { get; set; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs index 631200a1..501ae180 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs @@ -200,6 +200,13 @@ public static class ServiceCollectionExtensions // surface on the central dashboard. services.TryAddSingleton(); + // M5.3 (T7): inbound body-ceiling hit counter — NoOp default for + // site/test roots. AddAuditLogCentralMaintenance replaces this binding + // with the AuditCentralHealthSnapshot implementation so ceiling-hit + // counts surface on the central dashboard alongside write-failure and + // redaction-failure counters. + services.TryAddSingleton(); + // M4 Bundle B: central direct-write audit writer used by // NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to // emit AuditLog rows that originate ON central, not via site telemetry. @@ -383,6 +390,12 @@ public static class ServiceCollectionExtensions // HealthMetricsAuditRedactionFailureCounter shape one-for-one. services.Replace(ServiceDescriptor.Singleton()); + // M5.3 (T7): replace the NoOp IAuditInboundCeilingHitsCounter with the + // AuditCentralHealthSnapshot so ceiling-hit counts surface on the + // central dashboard. Same singleton-forward pattern as + // ICentralAuditWriteFailureCounter above. + services.Replace(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); return services; } diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs index 9d7cc1f5..c608e5f4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; @@ -95,6 +96,7 @@ public sealed class AuditWriteMiddleware private readonly ILogger _logger; private readonly IOptionsMonitor _options; private readonly IAuditActorAccessor? _actorAccessor; + private readonly IAuditInboundCeilingHitsCounter _ceilingHitsCounter; /// /// Initializes the middleware with its required dependencies. @@ -110,18 +112,26 @@ public sealed class AuditWriteMiddleware /// construct the middleware; when absent, actor resolution falls back to the /// stashed API-key name only. /// + /// + /// M5.3 (T7, optional): incremented whenever an inbound request or response + /// body is truncated at . Optional + /// so existing tests and composition roots without the central health snapshot + /// wired still construct without the counter; a NoOp is used when absent. + /// public AuditWriteMiddleware( RequestDelegate next, ICentralAuditWriter auditWriter, ILogger logger, IOptionsMonitor options, - IAuditActorAccessor? actorAccessor = null) + IAuditActorAccessor? actorAccessor = null, + IAuditInboundCeilingHitsCounter? ceilingHitsCounter = null) { _next = next ?? throw new ArgumentNullException(nameof(next)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options ?? throw new ArgumentNullException(nameof(options)); _actorAccessor = actorAccessor; + _ceilingHitsCounter = ceilingHitsCounter ?? new NoOpAuditInboundCeilingHitsCounter(); } /// @@ -133,9 +143,11 @@ public sealed class AuditWriteMiddleware { var sw = Stopwatch.StartNew(); - // Per-request hot read of the inbound cap so a live config change + // Per-request hot read of the options snapshot so a live config change // picks up on the next request without re-resolving the singleton. - var cap = _options.CurrentValue.InboundMaxBytes; + // InboundMaxBytes is read once here and passed to the capture helpers. + var opts = _options.CurrentValue; + var cap = opts.InboundMaxBytes; // Audit Log #23 (ParentExecutionId): mint the inbound request's per-request // ExecutionId ONCE, here at the start of the request, and stash it on @@ -163,9 +175,20 @@ public sealed class AuditWriteMiddleware // ReadBufferedRequestBodyAsync's own ContentLength is 0 short-circuit // returns (null, false) for the bodyless case anyway, so the audit row // is unchanged. + // + // M5.3 (T7): check if the matched method/target has SkipBodyCapture set. + // The route value is resolved BEFORE the pipeline runs (route matching + // has already bound {methodName} at this point), so we can skip the + // EnableBuffering allocation and body read up front. + var methodNameForOverride = ctx.Request.RouteValues.TryGetValue("methodName", out var rv) + && rv is string mn && !string.IsNullOrWhiteSpace(mn) ? mn : null; + var skipBody = methodNameForOverride != null + && opts.PerTargetOverrides.TryGetValue(methodNameForOverride, out var perTarget) + && perTarget.SkipBodyCapture; + var requestBody = (string?)null; var requestTruncated = false; - if (RequestHasBody(ctx.Request)) + if (!skipBody && RequestHasBody(ctx.Request)) { ctx.Request.EnableBuffering(); (requestBody, requestTruncated) = @@ -200,7 +223,14 @@ public sealed class AuditWriteMiddleware // The forwarding wrapper has already written every byte to the // original sink; this just pulls back the bounded UTF-8 string. ctx.Response.Body = originalResponseBody; - var (responseBody, responseTruncated) = captureStream.GetCapturedBody(); + var (capturedResponseBody, capturedResponseTruncated) = captureStream.GetCapturedBody(); + // M5.3 (T7): if SkipBodyCapture is set, discard the captured response + // body (the request body was never captured above). The row + headers + // still emit with null RequestSummary / ResponseSummary. + // Truncation flags are also cleared so ceiling-hit counter is not + // bumped for methods that deliberately opt out of body capture. + var responseBody = skipBody ? null : capturedResponseBody; + var responseTruncated = skipBody ? false : capturedResponseTruncated; EmitInboundAudit( ctx, @@ -208,7 +238,9 @@ public sealed class AuditWriteMiddleware thrown, requestBody, responseBody, - requestTruncated || responseTruncated); + requestTruncated || responseTruncated, + requestTruncated, + responseTruncated); } } @@ -223,7 +255,9 @@ public sealed class AuditWriteMiddleware Exception? thrown, string? requestBody, string? responseBody, - bool payloadTruncated) + bool payloadTruncated, + bool requestTruncated = false, + bool responseTruncated = false) { try { @@ -243,10 +277,40 @@ public sealed class AuditWriteMiddleware var actor = isAuthFailure ? null : ResolveActor(ctx); var methodName = ResolveMethodName(ctx); + // M5.3 (T7): increment the ceiling-hits counter once per request + // that hit the cap on EITHER the request or response body. + if (requestTruncated || responseTruncated) + { + try { _ceilingHitsCounter.Increment(); } catch { /* swallow per §7 */ } + } + + // M5.3 (T7): capture request headers into Extra JSON alongside the + // existing remoteIp / userAgent provenance fields. The header + // collection is run through the SAME header-redaction list + // (AuditLogOptions.HeaderRedactList) that the ScadaBridgeAuditRedactor + // applies to RequestSummary / ResponseSummary — auth/sensitive + // headers are redacted before they land in the row. + var currentOpts = _options.CurrentValue; + var redactSet = new HashSet( + currentOpts.HeaderRedactList, + StringComparer.OrdinalIgnoreCase); + + var headerDict = new Dictionary(StringComparer.Ordinal); + foreach (var header in ctx.Request.Headers) + { + // Redact headers whose name appears in the HeaderRedactList — + // the same "" marker used by ScadaBridgeAuditRedactor. + var value = redactSet.Contains(header.Key) + ? "" + : header.Value.ToString(); + headerDict[header.Key] = value; + } + var extra = JsonSerializer.Serialize(new { remoteIp = ctx.Connection.RemoteIpAddress?.ToString(), userAgent = ctx.Request.Headers.UserAgent.ToString(), + requestHeaders = headerDict, }); var evt = ScadaBridgeAuditEventFactory.Create( diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index 1f0d62cb..823a2314 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using IAuditInboundCeilingHitsCounter = ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central; @@ -163,6 +164,69 @@ public class CentralAuditWriteFailuresTests : TestKit var snapshot = new AuditCentralHealthSnapshot(); Assert.Equal(0, snapshot.CentralAuditWriteFailures); Assert.Equal(0, snapshot.AuditRedactionFailure); + Assert.Equal(0, snapshot.AuditInboundCeilingHits); Assert.Empty(snapshot.SiteAuditTelemetryStalled); } + + // --------------------------------------------------------------------- + // M5.3 (T7) AuditInboundCeilingHits counter + // AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter. + // Incrementing through the interface surface is reflected on the snapshot. + // --------------------------------------------------------------------- + + [Fact] + public void AuditInboundCeilingHits_StartsAtZero() + { + var snapshot = new AuditCentralHealthSnapshot(); + Assert.Equal(0, snapshot.AuditInboundCeilingHits); + } + + [Fact] + public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot() + { + var snapshot = new AuditCentralHealthSnapshot(); + var counter = (IAuditInboundCeilingHitsCounter)snapshot; + + counter.Increment(); + counter.Increment(); + counter.Increment(); + + Assert.Equal(3, snapshot.AuditInboundCeilingHits); + } + + [Fact] + public void AuditInboundCeilingHits_IsThreadSafe() + { + // Interlocked increment must produce the correct count under concurrent + // increments — same shape as the existing counter tests. + var snapshot = new AuditCentralHealthSnapshot(); + var counter = (IAuditInboundCeilingHitsCounter)snapshot; + const int incrementCount = 1000; + + Parallel.For(0, incrementCount, _ => counter.Increment()); + + Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits); + } + + [Fact] + public void AuditInboundCeilingHits_IsIndependentOfOtherCounters() + { + // Ceiling-hits increments must not cross-contaminate the other counters + // and vice versa — each Interlocked field is independent. + var snapshot = new AuditCentralHealthSnapshot(); + var ceilingCounter = (IAuditInboundCeilingHitsCounter)snapshot; + var writeCounter = (ICentralAuditWriteFailureCounter)snapshot; + var redactCounter = (ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter)snapshot; + + ceilingCounter.Increment(); + ceilingCounter.Increment(); + writeCounter.Increment(); + redactCounter.Increment(); + redactCounter.Increment(); + redactCounter.Increment(); + + Assert.Equal(2, snapshot.AuditInboundCeilingHits); + Assert.Equal(1, snapshot.CentralAuditWriteFailures); + Assert.Equal(3, snapshot.AuditRedactionFailure); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index bf16ce10..b91e0a4c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -1022,4 +1022,429 @@ public class AuditWriteMiddlewareTests 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); + } }