using System.Text; 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.AuditLog.Payload; using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction; /// /// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for /// — the canonical /// implementation. Covers the header-redaction / /// body-regex / SQL-param / safety-net / truncation pipeline operating on /// canonical ZB.MOM.WW.Audit.AuditEvent records built via /// . /// public class ScadaBridgeAuditRedactorTests { private static ScadaBridgeAuditRedactor Redactor( AuditLogOptions? opts = null, IAuditRedactionFailureCounter? counter = null) => new(new StaticMonitor(opts ?? new AuditLogOptions()), NullLogger.Instance, counter); /// /// Build a canonical event whose carries the /// supplied summaries + channel/status, mirroring what the C3 emit boundary /// will produce. Category = channel name (so the ApiInbound branch is keyed /// correctly); Status travels inside DetailsJson for the fine-grained error /// cap selection. /// private static AuditEvent NewEvent( AuditChannel channel = AuditChannel.ApiOutbound, AuditStatus status = AuditStatus.Delivered, AuditOutcome outcome = AuditOutcome.Success, string? request = null, string? response = null, string? errorDetail = null, string? extra = null, string? target = null, bool detailsPayloadTruncated = false) { var details = new AuditDetails { Channel = channel.ToString(), Status = status.ToString(), RequestSummary = request, ResponseSummary = response, ErrorDetail = errorDetail, Extra = extra, PayloadTruncated = detailsPayloadTruncated, }; return new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "tester", Action = AuditFieldBuilders.BuildAction(channel, AuditKind.ApiCall), Category = AuditFieldBuilders.BuildCategory(channel), Outcome = outcome, Target = target, DetailsJson = AuditDetailsCodec.Serialize(details), }; } private static AuditDetails Details(AuditEvent evt) => AuditDetailsCodec.Deserialize(evt.DetailsJson); // ---- Header redaction (ports HeaderRedactionTests) --------------------- [Fact] public void HeaderRedaction_AuthorizationBearer_Redacted() { var request = "{\"headers\":{\"Authorization\":\"Bearer secret-token-xyz\",\"Content-Type\":\"application/json\"},\"body\":\"hello\"}"; var evt = NewEvent(request: request); var result = Redactor().Apply(evt); var d = Details(result); Assert.NotNull(d.RequestSummary); Assert.Contains("\"Authorization\":\"\"", d.RequestSummary); Assert.DoesNotContain("secret-token-xyz", d.RequestSummary); Assert.Contains("application/json", d.RequestSummary); } [Fact] public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted() { var request = "{\"headers\":{\"authorization\":\"Bearer secret-token-xyz\"},\"body\":\"hi\"}"; var evt = NewEvent(request: request); var result = Redactor().Apply(evt); Assert.Contains("\"authorization\":\"\"", Details(result).RequestSummary!); } [Fact] public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName() { var opts = new AuditLogOptions { HeaderRedactList = new List { "X-Custom-Secret" } }; var request = "{\"headers\":{\"X-Custom-Secret\":\"topsecret\",\"Authorization\":\"Bearer keep-me\"},\"body\":\"hi\"}"; var evt = NewEvent(request: request); var result = Redactor(opts).Apply(evt); var d = Details(result); Assert.Contains("\"X-Custom-Secret\":\"\"", d.RequestSummary!); Assert.Contains("Bearer keep-me", d.RequestSummary!); } [Fact] public void HeaderRedaction_NonJson_RequestSummary_Unchanged() { var evt = NewEvent(request: "this is not JSON at all"); var result = Redactor().Apply(evt); Assert.Equal("this is not JSON at all", Details(result).RequestSummary); } // ---- Body regex redaction (ports BodyRegexRedactionTests) -------------- [Fact] public void GlobalRegex_HunterPassword_Redacted() { var opts = new AuditLogOptions { GlobalBodyRedactors = new List { "\"password\":\\s*\"[^\"]*\"" }, }; var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}"); var result = Redactor(opts).Apply(evt); var d = Details(result); Assert.Contains("", d.RequestSummary!); Assert.DoesNotContain("hunter2", d.RequestSummary!); Assert.Contains("alice", d.RequestSummary!); } [Fact] public void BodyRegex_AppliesToErrorDetailAndExtra() { var opts = new AuditLogOptions { GlobalBodyRedactors = new List { "SECRET-[A-Z0-9]+" }, }; var evt = NewEvent( errorDetail: "boom SECRET-AAA111 boom", extra: "ctx SECRET-BBB222 ctx"); var result = Redactor(opts).Apply(evt); var d = Details(result); Assert.DoesNotContain("SECRET-AAA111", d.ErrorDetail!); Assert.DoesNotContain("SECRET-BBB222", d.Extra!); Assert.Contains("", d.ErrorDetail!); Assert.Contains("", d.Extra!); } [Fact] public void PerTargetRegex_OnlyAppliedToMatchingTarget() { var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["esg.A"] = new PerTargetRedactionOverride { AdditionalBodyRedactors = new List { "SECRET-[A-Z0-9]+" }, }, }, }; const string input = "token=SECRET-XYZ123 normal-text"; var matched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.A")); Assert.Contains("", Details(matched).RequestSummary!); Assert.DoesNotContain("SECRET-XYZ123", Details(matched).RequestSummary!); var unmatched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.B")); Assert.Equal(input, Details(unmatched).RequestSummary); } [Fact] public void NoRegexConfigured_FieldUnchanged() { var evt = NewEvent(request: "{\"password\":\"hunter2\"}"); var result = Redactor().Apply(evt); Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary); } [Fact] public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements() { var opts = new AuditLogOptions { GlobalBodyRedactors = new List { "^(a+)+$" } }; var counter = new CountingRedactionFailureCounter(); var evt = NewEvent(request: new string('a', 30) + "!"); var result = Redactor(opts, counter).Apply(evt); Assert.Equal("", Details(result).RequestSummary); Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}"); } // ---- SQL parameter redaction (ports SqlParamRedactionTests) ------------ private static string DbRequestSummary(string sql, params (string name, string value)[] parameters) { var sb = new StringBuilder(); sb.Append("{\"sql\":\"").Append(sql).Append('"'); if (parameters.Length > 0) { sb.Append(",\"parameters\":{"); for (var i = 0; i < parameters.Length; i++) { if (i > 0) sb.Append(','); sb.Append('"').Append(parameters[i].name).Append("\":\"") .Append(parameters[i].value).Append('"'); } sb.Append('}'); } sb.Append('}'); return sb.ToString(); } [Fact] public void NoOptIn_ParamsVerbatim_Unchanged() { var input = DbRequestSummary( "INSERT INTO Users (Name, Token) VALUES (@name, @token)", ("@name", "Alice"), ("@token", "secret-xyz")); var evt = NewEvent( channel: AuditChannel.DbOutbound, status: AuditStatus.Delivered, request: input, target: "PrimaryDb.INSERT INTO Users"); var result = Redactor().Apply(evt); Assert.Equal(input, Details(result).RequestSummary); } [Fact] public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers() { var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@(token|apikey)$" }, }, }; var input = DbRequestSummary( "INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)", ("@name", "Alice"), ("@token", "secret-xyz"), ("@apikey", "k-987")); var evt = NewEvent( channel: AuditChannel.DbOutbound, request: input, target: "PrimaryDb.INSERT INTO Users"); var result = Redactor(opts).Apply(evt); var d = Details(result); Assert.Contains("\"@name\":\"Alice\"", d.RequestSummary!); Assert.Contains("\"@token\":\"\"", d.RequestSummary!); Assert.Contains("\"@apikey\":\"\"", d.RequestSummary!); Assert.DoesNotContain("secret-xyz", d.RequestSummary!); Assert.DoesNotContain("k-987", d.RequestSummary!); } [Fact] public void NonDbOutboundChannel_NotAffected() { var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$" }, }, }; var input = DbRequestSummary("SELECT @token", ("@token", "should-survive")); // ApiOutbound channel whose summary *looks* like the DbOutbound shape. var evt = NewEvent( channel: AuditChannel.ApiOutbound, request: input, target: "PrimaryDb.SELECT"); var result = Redactor(opts).Apply(evt); Assert.Equal(input, Details(result).RequestSummary); } // ---- Truncation + cap selection (ports TruncationTests) ---------------- [Fact] public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue() { var evt = NewEvent( channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered, outcome: AuditOutcome.Success, request: new string('a', 10 * 1024)); var result = Redactor().Apply(evt); var d = Details(result); Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!)); Assert.True(d.PayloadTruncated); } [Fact] public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap() { var evt = NewEvent( channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed, outcome: AuditOutcome.Failure, request: new string('b', 10 * 1024)); var result = Redactor().Apply(evt); var d = Details(result); Assert.Equal(new string('b', 10 * 1024), d.RequestSummary); Assert.False(d.PayloadTruncated); } [Fact] public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB() { var evt = NewEvent( channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed, outcome: AuditOutcome.Failure, request: new string('c', 70 * 1024)); var result = Redactor().Apply(evt); var d = Details(result); Assert.Equal(65536, Encoding.UTF8.GetByteCount(d.RequestSummary!)); Assert.True(d.PayloadTruncated); } [Fact] public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess() { // Attempted projects to Outcome.Success, yet IsErrorStatus(Attempted)==true. // Faithful port must read d.Status and pick the error cap — a 10 KB body // must survive (under the 64 KiB error cap), NOT truncate to 8 KiB. var evt = NewEvent( channel: AuditChannel.ApiOutbound, status: AuditStatus.Attempted, outcome: AuditOutcome.Success, request: new string('d', 10 * 1024)); var result = Redactor().Apply(evt); var d = Details(result); Assert.Equal(new string('d', 10 * 1024), d.RequestSummary); Assert.False(d.PayloadTruncated); } [Fact] public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess() { var evt = NewEvent( channel: AuditChannel.ApiOutbound, status: AuditStatus.Skipped, outcome: AuditOutcome.Success, request: new string('f', 10 * 1024)); var result = Redactor().Apply(evt); Assert.False(Details(result).PayloadTruncated); } [Fact] public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap() { // Submitted is NOT an error status (IsErrorStatus==false) → default 8 KiB cap. var evt = NewEvent( channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted, outcome: AuditOutcome.Success, request: new string('g', 10 * 1024)); var result = Redactor().Apply(evt); var d = Details(result); Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!)); Assert.True(d.PayloadTruncated); } [Fact] public void ApiInbound_LargeBody_UsesInboundCap_NotDefault() { var evt = NewEvent( channel: AuditChannel.ApiInbound, status: AuditStatus.Delivered, outcome: AuditOutcome.Success, request: new string('a', 100_000)); var result = Redactor().Apply(evt); var d = Details(result); Assert.False(d.PayloadTruncated); Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!)); } [Fact] public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes() { var opts = new AuditLogOptions { InboundMaxBytes = 16_384 }; var evt = NewEvent( channel: AuditChannel.ApiInbound, status: AuditStatus.Failed, outcome: AuditOutcome.Failure, response: new string('z', 50_000)); var result = Redactor(opts).Apply(evt); var d = Details(result); Assert.True(d.PayloadTruncated); Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384); } [Fact] public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte() { var sb = new StringBuilder(); for (int i = 0; i < 2100; i++) sb.Append("😀"); var input = sb.ToString(); var evt = NewEvent(request: input); var result = Redactor().Apply(evt); var d = Details(result); var bytes = Encoding.UTF8.GetByteCount(d.RequestSummary!); Assert.True(bytes <= 8192); Assert.Equal(0, bytes % 4); Assert.DoesNotContain('�', d.RequestSummary!); Assert.True(d.PayloadTruncated); } [Fact] public void ExistingDetailsPayloadTruncated_RemainsTrue() { var evt = NewEvent(request: "small", detailsPayloadTruncated: true); var result = Redactor().Apply(evt); var d = Details(result); Assert.Equal("small", d.RequestSummary); Assert.True(d.PayloadTruncated); } // ---- Target length cap ------------------------------------------------- [Fact] public void Target_OverCap_Truncated_ToByteBoundary() { // Target is a canonical top-level field; the redactor caps it at the // default cap so an absurdly long target can't blow the column. var longTarget = new string('t', 10 * 1024); var evt = NewEvent(status: AuditStatus.Delivered, outcome: AuditOutcome.Success, target: longTarget); var result = Redactor().Apply(evt); Assert.NotNull(result.Target); Assert.True(Encoding.UTF8.GetByteCount(result.Target!) <= 8192); } // ---- Fast-path --------------------------------------------------------- [Fact] public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance() { var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "tester", Action = "ApiOutbound.ApiCall", Outcome = AuditOutcome.Success, Target = "short", DetailsJson = null, }; var result = Redactor().Apply(evt); Assert.Same(evt, result); } [Fact] public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance() { var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "tester", Action = "ApiOutbound.ApiCall", Outcome = AuditOutcome.Success, Target = null, DetailsJson = "", }; var result = Redactor().Apply(evt); Assert.Same(evt, result); } // ---- Never-throws safety net ------------------------------------------- [Fact] public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy() { // Deserialize never throws (returns empty details), but a malformed // DetailsJson with a long Target still flows the slow path. Assert the // redactor returns a result without throwing. var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "tester", Action = "ApiOutbound.ApiCall", Outcome = AuditOutcome.Success, Target = new string('x', 10 * 1024), DetailsJson = "{not valid json at all]", }; var ex = Record.Exception(() => Redactor().Apply(evt)); Assert.Null(ex); } [Fact] public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted() { // Force the outer try/catch → OverRedact path by injecting an // IOptionsMonitor whose CurrentValue getter throws. This is the FIRST // statement in the try block, so the exception escapes before any // redaction work has run — the input event could still carry live // sensitive values in all five free-text fields. // // The never-leak contract requires ALL of RequestSummary, ResponseSummary, // ErrorDetail, ErrorMessage, and Extra to be suppressed to the // over-redacted marker, and PayloadTruncated to be true. var sensitiveEvent = NewEvent( request: "SENSITIVE-REQUEST-DATA", response: "SENSITIVE-RESPONSE-DATA", errorDetail: "SENSITIVE-ERROR-DETAIL", extra: "SENSITIVE-EXTRA-DATA"); // Manually inject ErrorMessage into DetailsJson (NewEvent does not expose it). var withMsg = sensitiveEvent with { DetailsJson = AuditDetailsCodec.Serialize( AuditDetailsCodec.Deserialize(sensitiveEvent.DetailsJson) with { ErrorMessage = "SENSITIVE-ERROR-MESSAGE", }), }; var redactor = new ScadaBridgeAuditRedactor( new ThrowingMonitor(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); // (a) Apply must not throw even when options access itself throws. var ex = Record.Exception(() => redactor.Apply(withMsg)); Assert.Null(ex); // (b) Returned event must have ALL sensitive free-text fields suppressed // (never-leak guarantee), and PayloadTruncated must be true. var result = redactor.Apply(withMsg); var d = Details(result); const string marker = ""; Assert.Equal(marker, d.RequestSummary); Assert.Equal(marker, d.ResponseSummary); Assert.Equal(marker, d.ErrorDetail); Assert.Equal(marker, d.ErrorMessage); Assert.Equal(marker, d.Extra); Assert.True(d.PayloadTruncated); // Confirm no raw sensitive values survive anywhere in DetailsJson. Assert.DoesNotContain("SENSITIVE", result.DetailsJson ?? ""); } /// Counts calls. private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter { private int _count; public int Count => _count; public void Increment() => System.Threading.Interlocked.Increment(ref _count); } /// IOptionsMonitor test double — returns the same snapshot on every read. 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; } /// /// IOptionsMonitor test double that throws from to /// force the outer try/catch → OverRedact path in /// . /// private sealed class ThrowingMonitor : IOptionsMonitor { public AuditLogOptions CurrentValue => throw new InvalidOperationException("Simulated options fault for outer-catch test"); public AuditLogOptions Get(string? name) => new AuditLogOptions(); public IDisposable? OnChange(Action listener) => null; } }