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; /// /// Guards the fail-closed contract for unavailable redactor patterns /// (Gitea #35). /// /// /// The defect. rejects a pattern that is /// invalid OR whose compile exceeds a 100 ms budget, and caches that rejection /// for the process lifetime. then dropped /// the rejected pattern from its redactor list and emitted the payload anyway — /// publishing exactly the values the operator configured it to suppress, on a /// row that looks completely normal downstream. /// /// /// /// Why it hid. The budget is wall-clock timed and, before the warm-up /// added alongside these tests, was measured on the audit hot path. /// RegexOptions.Compiled emits IL during construction, so a busy node /// could blow the budget on a perfectly valid pattern. It surfaced only as an /// intermittent failure of /// AuditLogOptionsBindingTests.Filter_PicksUp_NewBodyRedactor_OnConfigReload /// under a loaded full-solution sweep, where it read as a flake. /// /// /// /// Why these tests use an invalid pattern. An unclosed character class /// fails compilation deterministically and reaches the SAME rejection sentinel /// as a timing rejection. Driving the 100 ms budget directly would require /// making the machine slow on demand — the very nondeterminism that let the /// defect through. The lever differs from production; the code path does not. /// /// public class AuditRedactorFailClosedTests { /// Unclosed character class — never compiles, on any machine, at any load. private const string UncompilablePattern = "[unterminated"; private const string WorkingPattern = "\"password\":\\s*\"[^\"]*\""; private const string Secret = "hunter2"; private static AuditEvent NewEvent( string? target = null, AuditChannel channel = AuditChannel.ApiOutbound) { var details = new AuditDetails { RequestSummary = $"{{\"user\":\"alice\",\"password\":\"{Secret}\"}}", Status = nameof(AuditStatus.Delivered), }; return new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "tester", Action = AuditFieldBuilders.BuildAction(channel, AuditKind.ApiCall), Category = AuditFieldBuilders.BuildCategory(channel), Outcome = AuditOutcome.Success, Target = target, DetailsJson = AuditDetailsCodec.Serialize(details), }; } private static (ScadaBridgeAuditRedactor Redactor, CountingCounter Counter) Build(AuditLogOptions opts) { var counter = new CountingCounter(); var redactor = new ScadaBridgeAuditRedactor( new StaticMonitor(opts), NullLogger.Instance, counter); return (redactor, counter); } private static string? RequestOf(AuditEvent e) => AuditDetailsCodec.Deserialize(e.DetailsJson).RequestSummary; /// /// Positive control. Without it the "secret is absent" assertions below would /// also pass if the redactor simply dropped every payload for any reason, and /// would prove nothing about the fail-closed path specifically. /// [Fact] public void Control_WorkingPattern_RedactsTheSecretAndKeepsTheRestOfTheBody() { var (redactor, counter) = Build(new AuditLogOptions { GlobalBodyRedactors = new List { WorkingPattern }, }); var result = RequestOf(redactor.Apply(NewEvent())); Assert.DoesNotContain(Secret, result); Assert.Contains("alice", result); // a working redactor is surgical, not wholesale Assert.Equal(0, counter.Count); } /// /// Second control: with NO redactors configured the body is emitted verbatim. /// This pins the distinction the fix turns on — "nothing to redact" must stay /// permissive, and only "configured but unavailable" may suppress. /// [Fact] public void Control_NoRedactorsConfigured_EmitsBodyUnchanged() { var (redactor, counter) = Build(new AuditLogOptions()); var result = RequestOf(redactor.Apply(NewEvent())); Assert.Contains(Secret, result); Assert.Equal(0, counter.Count); } [Fact] public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw() { var (redactor, counter) = Build(new AuditLogOptions { GlobalBodyRedactors = new List { UncompilablePattern }, }); var result = RequestOf(redactor.Apply(NewEvent())); Assert.DoesNotContain(Secret, result); Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result); Assert.Equal(1, counter.Count); } /// /// The dangerous mixed case: one good pattern and one unusable one. Applying /// only the good pattern looks like success while the unusable pattern's /// target sails through, so the whole payload must still be suppressed. /// [Fact] public void OneUnavailablePatternAmongWorkingOnes_StillSuppressesPayload() { var (redactor, counter) = Build(new AuditLogOptions { GlobalBodyRedactors = new List { WorkingPattern, UncompilablePattern }, }); var result = RequestOf(redactor.Apply(NewEvent())); Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result); Assert.Equal(1, counter.Count); } [Fact] public void UnavailablePerTargetBodyRedactor_SuppressesPayload() { var opts = new AuditLogOptions(); opts.PerTargetOverrides["svc"] = new PerTargetRedactionOverride { AdditionalBodyRedactors = new List { UncompilablePattern }, }; var (redactor, counter) = Build(opts); var result = RequestOf(redactor.Apply(NewEvent(target: "svc"))); Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result); Assert.Equal(1, counter.Count); } /// /// SQL-parameter redactors reach the cache by a separate path whose bare /// false return conflated "not configured" with "will not compile". /// CLAUDE.md records SQL parameter capture as on by default, so this path /// leaks parameter values. /// [Fact] public void UnavailableSqlParamRedactor_SuppressesPayload() { var opts = new AuditLogOptions(); opts.PerTargetOverrides["conn"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = UncompilablePattern, }; var (redactor, counter) = Build(opts); var result = RequestOf( redactor.Apply(NewEvent(target: "conn.Table", channel: AuditChannel.DbOutbound))); Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result); Assert.Equal(1, counter.Count); } /// /// A SQL redactor configured for a DIFFERENT connection must not suppress /// this row — otherwise fail-closed would over-reach into unrelated targets. /// [Fact] public void UnavailableSqlParamRedactor_OnAnotherConnection_DoesNotSuppressThisRow() { var opts = new AuditLogOptions(); opts.PerTargetOverrides["other"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = UncompilablePattern, }; var (redactor, counter) = Build(opts); var result = RequestOf( redactor.Apply(NewEvent(target: "conn.Table", channel: AuditChannel.DbOutbound))); Assert.Contains(Secret, result); Assert.Equal(0, counter.Count); } /// /// The reload path: a pattern that becomes unavailable AFTER construction must /// also fail closed. This is the shape that made the original defect a live /// risk rather than a boot-time one. /// [Fact] public void PatternThatBecomesUnavailableOnReload_FailsClosed() { var monitor = new MutableMonitor(new AuditLogOptions { GlobalBodyRedactors = new List { WorkingPattern }, }); var counter = new CountingCounter(); using var redactor = new ScadaBridgeAuditRedactor( monitor, NullLogger.Instance, counter); Assert.DoesNotContain(Secret, RequestOf(redactor.Apply(NewEvent()))); monitor.Set(new AuditLogOptions { GlobalBodyRedactors = new List { UncompilablePattern }, }); Assert.Equal( AuditRedactionPrimitives.OverRedactedEventMarker, RequestOf(redactor.Apply(NewEvent()))); } /// /// Warm-up must happen at construction, so the compile budget is spent off the /// hot path (audit-log roadmap: "patterns precompiled at startup"). Asserted /// behaviourally: the FIRST event after construction must already be handled /// by a compiled pattern. /// [Fact] public void ConstructionWarmsPatterns_SoTheFirstEventIsNotTheOneThatCompiles() { var monitor = new MutableMonitor(new AuditLogOptions { GlobalBodyRedactors = new List { WorkingPattern }, }); using var redactor = new ScadaBridgeAuditRedactor( monitor, NullLogger.Instance, new CountingCounter()); // Construction alone must have read the options to warm them; a redactor // that only compiled lazily would not have touched CurrentValue yet. Assert.True( monitor.CurrentValueReads > 0, "the redactor must read its options at construction to precompile patterns"); } private sealed class CountingCounter : IAuditRedactionFailureCounter { private int _count; public int Count => Volatile.Read(ref _count); public void Increment() => Interlocked.Increment(ref _count); } 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; } private sealed class MutableMonitor : IOptionsMonitor { private AuditLogOptions _value; private readonly List> _listeners = new(); private int _reads; public MutableMonitor(AuditLogOptions value) => _value = value; public int CurrentValueReads => Volatile.Read(ref _reads); public AuditLogOptions CurrentValue { get { Interlocked.Increment(ref _reads); return _value; } } public AuditLogOptions Get(string? name) => CurrentValue; public IDisposable? OnChange(Action listener) { lock (_listeners) { _listeners.Add(listener); } return new Noop(); } public void Set(AuditLogOptions value) { _value = value; Action[] snapshot; lock (_listeners) { snapshot = _listeners.ToArray(); } foreach (var l in snapshot) { l(value, Options.DefaultName); } } private sealed class Noop : IDisposable { public void Dispose() { } } } }