using Microsoft.Extensions.Logging; 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; /// /// Bundle D (M5-T10) safety-net edge cases for /// . Bundle B already pinned the /// happy-path safety net (catastrophic-backtracking timeout → /// <redacted: redactor error> + counter bump); this fixture covers /// the pathological / config-mistake corners that production operators will /// hit when typoing a regex or shipping a half-baked redactor list. /// /// /// /// The invariants under test: /// /// /// An UNCOMPILABLE pattern (e.g. [unclosed) is logged at warning /// on first encounter and cached as invalid so it never throws again, /// but the redactor-failure COUNTER is not bumped at bind time — /// per the contract on /// the counter tracks RUNTIME redaction failures only. /// One throwing regex in the middle of a list does NOT poison the /// other patterns — the filter stops at the failing pattern, /// over-redacts the offending field, but lets every other field keep /// the prior cleanly-redacted state and lets the rest of the writer /// pipeline run. /// A live config change that introduces a broken pattern does not /// crash the filter — the bad pattern is silently dropped (logged once) /// and the still-valid patterns continue to redact normally. /// /// public class RedactionSafetyNetTests { private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) => new StaticMonitor(opts ?? new AuditLogOptions()); private static AuditEvent NewEvent( AuditStatus status = AuditStatus.Delivered, string? request = null, string? response = null, string? errorDetail = null, string? extra = null, string? target = null) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = status, Target = target, RequestSummary = request, ResponseSummary = response, ErrorDetail = errorDetail, Extra = extra, }; [Fact] public void RegexNotCompilable_AtBindTime_LoggedAndSkipped() { // `[unclosed` is a structurally invalid character class — the .NET // regex engine throws ArgumentException at compile time. We assert: // * the filter does NOT throw, // * the OTHER (valid) pattern still redacts hunter2, // * the failure counter is NOT incremented at compile time // (it tracks runtime redaction failures only), // * a warning is logged exactly once. const string badPattern = "[unclosed"; const string goodPattern = "\"password\":\\s*\"[^\"]*\""; var opts = new AuditLogOptions { GlobalBodyRedactors = new List { badPattern, goodPattern }, }; var counter = new CountingRedactionFailureCounter(); var spy = new SpyLogger(); var filter = new DefaultAuditPayloadFilter(Monitor(opts), spy, counter); var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}"); var result = filter.Apply(evt); Assert.NotNull(result.RequestSummary); Assert.DoesNotContain("hunter2", result.RequestSummary); Assert.Contains("", result.RequestSummary); Assert.Equal(0, counter.Count); // Apply twice — the invalid-pattern compile must run AT MOST once; // the sentinel-cache entry stops repeat compile attempts. _ = filter.Apply(evt); var badPatternWarnings = spy.Entries .Where(e => e.Level == LogLevel.Warning && e.Message.Contains(badPattern)) .Count(); Assert.Equal(1, badPatternWarnings); } [Fact] public void MultipleRedactors_OneThrows_OthersStillApply_ToOtherFields() { // Pattern set: [valid-A, evil, valid-B]. The evil pattern is // catastrophic-backtracking on the RequestSummary input (all-'a's + // mismatching suffix) — that field is over-redacted with the error // marker as soon as evil throws. ResponseSummary is processed // INDEPENDENTLY; its input does not trigger evil's backtracking, so // valid-A and valid-B both still apply on that field. This proves a // per-field redactor failure does not poison the rest of the writer // call (the SQL-param stage, the truncation stage, and the other // fields all continue normally). const string validA = "SECRET-[A-Z0-9]+"; const string evil = "^(a+)+$"; // catastrophic on long all-'a' string const string validB = "PIN-\\d{4}"; var opts = new AuditLogOptions { GlobalBodyRedactors = new List { validA, evil, validB }, }; var counter = new CountingRedactionFailureCounter(); var filter = new DefaultAuditPayloadFilter( Monitor(opts), NullLogger.Instance, counter); // Request: ALL 'a's + a non-'a' suffix character. valid-A does not // match (no SECRET-X prefix), so the buffer reaches `evil` untouched // and triggers the backtracking explosion. var request = new string('a', 30) + "!"; // Response: short, mismatches the evil pattern cleanly (no // backtracking), so both valid-A and valid-B run and redact. const string response = "SECRET-ABC456 PIN-9999 other-text"; var result = filter.Apply(NewEvent(request: request, response: response)); // RequestSummary: over-redacted (evil pattern threw). Assert.Equal("", result.RequestSummary); Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}"); // ResponseSummary: clean — both valid regexes still applied; the evil // one ran without throwing on this short input. Assert.NotNull(result.ResponseSummary); Assert.DoesNotContain("SECRET-ABC456", result.ResponseSummary); Assert.DoesNotContain("PIN-9999", result.ResponseSummary); Assert.Contains("", result.ResponseSummary); Assert.Contains("other-text", result.ResponseSummary); } // Edge case 3 (RedactorReturnsNonStringExceptionType) intentionally // skipped — the brief permits dropping it: there is no portable way to // artificially trigger an OutOfMemoryException inside System.Text.RegularExpressions // from a unit test without writing native interop, and the existing // per-stage try/catch already covers Exception (which OOM and similar // would derive from). Bundle B's RegexThrowsTimeout coverage exercises // the same catch path with a deterministic trigger. [Fact] public void ConfigChange_WithBadRegex_LiveTrafficKeepsApplyingValidRegexes() { // Initial config: one valid global redactor — hunter2 is redacted. // Reload: ADD a malformed pattern alongside the original. Per the // safety contract, the bad pattern is logged + skipped, the original // valid pattern keeps redacting, and the filter NEVER throws on the // hot path. The counter must not be bumped at reload time (the // CompiledRegex sentinel covers the bind error before runtime even // sees it). var monitor = new MutableMonitor(new AuditLogOptions { GlobalBodyRedactors = new List { "\"password\":\\s*\"[^\"]*\"" }, }); var counter = new CountingRedactionFailureCounter(); var spy = new SpyLogger(); var filter = new DefaultAuditPayloadFilter(monitor, spy, counter); var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}"); var before = filter.Apply(evt); Assert.DoesNotContain("hunter2", before.RequestSummary!); // Reload: malformed pattern added to the list. monitor.Set(new AuditLogOptions { GlobalBodyRedactors = new List { "\"password\":\\s*\"[^\"]*\"", "[unclosed", }, }); var after = filter.Apply(evt); Assert.NotNull(after.RequestSummary); Assert.DoesNotContain("hunter2", after.RequestSummary); Assert.Contains("", after.RequestSummary); Assert.Equal(0, counter.Count); // Compile-time warning logged for the broken pattern. Assert.Contains( spy.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("[unclosed")); } /// 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 supports a live — /// mirrors the helper used in /// ; /// kept private here so the safety-net test file remains self-contained. /// private sealed class MutableMonitor : IOptionsMonitor { private AuditLogOptions _current; public MutableMonitor(AuditLogOptions initial) => _current = initial; public AuditLogOptions CurrentValue => _current; public AuditLogOptions Get(string? name) => _current; public IDisposable? OnChange(Action listener) => null; public void Set(AuditLogOptions value) => _current = value; } /// /// Minimal ILogger that records each formatted log line so tests can /// assert on the compile-time warning emission contract — counting /// warnings and grepping the message text. /// private sealed class SpyLogger : ILogger { private readonly List _entries = new(); public IReadOnlyList Entries { get { lock (_entries) return _entries.ToArray(); } } public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var msg = formatter(state, exception); lock (_entries) _entries.Add(new LogEntry(logLevel, msg)); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } public sealed record LogEntry(LogLevel Level, string Message); }