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 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 { /// Verifies that every field of an error row is redacted and the error message is capped, whether the redactor is configured globally or per target. /// True to configure the redactor through a per-target override; false to configure it globally. [Theory] [InlineData(false)] [InlineData(true)] public void EI11_ErrorMessage_AllFieldsRedactedAndCapped(bool perTarget) { var opts = new AuditLogOptions { ErrorCapBytes = 128 }; if (perTarget) opts.PerTargetOverrides["target"] = new PerTargetRedactionOverride { AdditionalBodyRedactors = ["SECRET123"] }; else opts.GlobalBodyRedactors = ["SECRET123"]; var evt = NewEvent(status: AuditStatus.Failed, outcome: AuditOutcome.Failure, target: "target", errorDetail: "SECRET123", request: "SECRET123", response: "SECRET123", extra: "SECRET123"); evt = evt with { DetailsJson = AuditDetailsCodec.Serialize(Details(evt) with { ErrorMessage = "SECRET123" + new string('x', 1000) }) }; var result = Redactor(opts).Apply(evt); Assert.DoesNotContain("SECRET123", result.DetailsJson!); Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128); } /// Verifies that the SQL parameter policy does not persist values echoed back in an exception message. [Fact] public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues() { var opts = new AuditLogOptions(); opts.PerTargetOverrides["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$" }; var evt = NewEvent(channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, outcome: AuditOutcome.Failure, target: "PrimaryDb.Query", request: "{\"parameters\":{\"@token\":\"SECRET123\"}}", errorDetail: "SqlException duplicate key value is (SECRET123)"); evt = evt with { DetailsJson = AuditDetailsCodec.Serialize(Details(evt) with { ErrorMessage = "duplicate key SECRET123" }) }; var result = Redactor(opts).Apply(evt); Assert.DoesNotContain("SECRET123", result.DetailsJson!); Assert.Equal("Failed", Details(result).Status); } 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) --------------------- /// Verifies that an Authorization bearer header is redacted. [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); } /// Verifies that header redaction is case-insensitive, so a lowercase authorization header is redacted. [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!); } /// Verifies that a custom redact list redacts a custom header name. [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!); } /// Verifies that a request summary that is not JSON passes through header redaction unchanged. [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) -------------- /// Verifies that the global regex redacts a Hunter-style password value. [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!); } /// Verifies that the body regex is applied to the error detail and the extra field. [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!); } /// Verifies that a per-target regex is applied only to the matching target. [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); } /// Verifies that a field is left unchanged when no regex is configured. [Fact] public void NoRegexConfigured_FieldUnchanged() { var evt = NewEvent(request: "{\"password\":\"hunter2\"}"); var result = Redactor().Apply(evt); Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary); } /// Verifies that a regex that times out turns the field into the redacted marker and increments the failure counter. [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(); } /// Verifies that SQL parameters are kept verbatim when no opt-in is configured. [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); } /// Verifies that an opt-in regex on parameter names redacts the matching values and keeps the others. [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!); } /// Verifies that a channel other than database-outbound is not affected by the SQL parameter policy. [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) ---------------- /// Verifies that a 10 KB request summary on a success row is truncated to 8 KB and flagged as payload-truncated. [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); } /// Verifies that a 10 KB request summary on a failed row is not truncated because it is under the error cap. [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); } /// Verifies that a 70 KB request summary on a failed row is truncated to 64 KB. [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); } /// Verifies that an attempted status is treated as an error and uses the error cap even though the outcome is success. [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); } /// Verifies that a skipped status is treated as an error and uses the error cap even though the outcome is success. [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); } /// Verifies that a submitted status is treated as a success and uses the default cap. [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); } /// Verifies that a large inbound API body uses the inbound cap rather than the default cap. [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!)); } /// Verifies that a failed inbound API body above the inbound maximum is truncated to that maximum. [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); } /// Verifies that multibyte UTF-8 text is truncated at a character boundary rather than in the middle of a byte sequence. [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); } /// Verifies that a details payload that was already marked truncated stays truncated. [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 ------------------------------------------------- /// Verifies that a target over the cap is truncated at a byte boundary. [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 --------------------------------------------------------- /// Verifies that the fast path returns the same instance when the details are null and the target is short. [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); } /// Verifies that the fast path returns the same instance when the details are empty and the target is short. [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 ------------------------------------------- /// Verifies that malformed details JSON never throws and yields a safe copy. [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); } /// Verifies that a fault thrown by the options monitor never leaks sensitive values, because the outer catch over-redacts every sensitive field. [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; /// Gets the number of recorded failures. 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; /// Initializes a new instance of the class. /// The options snapshot returned on every read. public StaticMonitor(AuditLogOptions value) => _value = value; /// Gets the options value this monitor serves. public AuditLogOptions CurrentValue => _value; /// Returns the options value regardless of name. /// The options name (ignored). /// The options value. public AuditLogOptions Get(string? name) => _value; /// Ignores the listener; the options never change. /// The change listener (ignored). /// Always null. 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 { /// Gets the options value this monitor serves. public AuditLogOptions CurrentValue => throw new InvalidOperationException("Simulated options fault for outer-catch test"); /// Returns the options value regardless of name. /// The options name (ignored). /// The options value. public AuditLogOptions Get(string? name) => new AuditLogOptions(); /// Ignores the listener; the options never change. /// The change listener (ignored). /// Always null. public IDisposable? OnChange(Action listener) => null; } }