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; } }