From 7b619d711dae49899d42a0192ee2e9ffc4fad675 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 05:48:03 -0400 Subject: [PATCH] feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows --- .../Payload/DefaultAuditPayloadFilter.cs | 9 +- .../Payload/InboundChannelCapTests.cs | 133 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs diff --git a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs index 78328b1..0d22447 100644 --- a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs +++ b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs @@ -118,7 +118,14 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter try { var opts = _options.CurrentValue; - var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes; + // Inbound API gets a dedicated, larger ceiling — request/response bodies are + // captured verbatim up to InboundMaxBytes (default 1 MiB) so support can + // replay exactly what the caller sent and what we returned. Other channels + // keep the global 8 KiB / 64 KiB policy. + // See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md. + var cap = rawEvent.Channel == AuditChannel.ApiInbound + ? opts.InboundMaxBytes + : (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes); // --- Header-redaction stage (runs BEFORE truncation) ---------- var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList); diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs new file mode 100644 index 0000000..42f5cf8 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs @@ -0,0 +1,133 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Configuration; +using ScadaLink.AuditLog.Payload; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.AuditLog.Tests.Payload; + +/// +/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md +/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for +/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes / +/// ErrorCapBytes. Other channels keep the existing caps. +/// +/// +/// Uses a file-local helper mirroring the +/// convention in the sibling Payload tests (TruncationTests, +/// FilterIntegrationTests, BodyRegexRedactionTests, etc.) — the +/// TestOptionsMonitor<T> helper referenced by the plan is a +/// private nested class inside AuditLogOptionsBindingTests and thus +/// not reachable from this file. +/// +public class InboundChannelCapTests +{ + private static AuditEvent MakeInbound( + AuditStatus status, + string? request = null, + string? response = null) => + new() + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiInbound, + Kind = AuditKind.InboundRequest, + Status = status, + RequestSummary = request, + ResponseSummary = response, + }; + + [Fact] + public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated() + { + // Body well above the legacy 8 KiB default cap but under the 1 MiB + // inbound ceiling — must NOT truncate. + var body = new string('a', 100_000); + var opts = new AuditLogOptions(); // defaults + var filter = new DefaultAuditPayloadFilter( + new StaticMonitor(opts), + NullLogger.Instance); + + var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body)); + + Assert.False(result.PayloadTruncated); + Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.RequestSummary!)); + } + + [Fact] + public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated() + { + var body = new string('a', 100_000); + var opts = new AuditLogOptions(); + var filter = new DefaultAuditPayloadFilter( + new StaticMonitor(opts), + NullLogger.Instance); + + var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body)); + + Assert.False(result.PayloadTruncated); + Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.ResponseSummary!)); + } + + [Fact] + public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes() + { + // Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes). + var opts = new AuditLogOptions { InboundMaxBytes = 16_384 }; + var oversized = new string('z', 50_000); + var filter = new DefaultAuditPayloadFilter( + new StaticMonitor(opts), + NullLogger.Instance); + + var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized)); + + Assert.True(result.PayloadTruncated); + Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384); + } + + [Fact] + public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes() + { + // Regression guard: lifting the inbound cap MUST NOT change other + // channels. An ApiOutbound 100 KB body still hits the 8 KiB cap. + var opts = new AuditLogOptions(); + var body = new string('a', 100_000); + var filter = new DefaultAuditPayloadFilter( + new StaticMonitor(opts), + NullLogger.Instance); + + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + RequestSummary = body, + }; + var result = filter.Apply(evt); + + Assert.True(result.PayloadTruncated); + Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes); + } + + /// + /// IOptionsMonitor test double — returns the same snapshot on every read, + /// no change-token plumbing required for these tests. Mirrors the helper + /// used in TruncationTests, FilterIntegrationTests, etc. + /// + private sealed class StaticMonitor : IOptionsMonitor + { + private readonly AuditLogOptions _value; + + public StaticMonitor(AuditLogOptions value) => _value = value; + + public AuditLogOptions CurrentValue => _value; + + public AuditLogOptions Get(string? name) => _value; + + public IDisposable? OnChange(Action listener) => null; + } +}