diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/RedactionSafetyNetTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/RedactionSafetyNetTests.cs new file mode 100644 index 0000000..9ebddb0 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Payload/RedactionSafetyNetTests.cs @@ -0,0 +1,270 @@ +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); +}