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() { }
}
}
}