diff --git a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs index 7061801..78328b1 100644 --- a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs +++ b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs @@ -22,7 +22,13 @@ namespace ScadaLink.AuditLog.Payload; /// /// Uses (not ) /// so the M5-T8 hot-reload path sees fresh values without re-resolving the -/// singleton. +/// singleton. reads +/// on every call, and the regex cache is keyed by pattern string — patterns +/// added via a live config change compile on first use of the next event; +/// patterns removed simply stop being looked up. No OnChange subscription +/// or explicit cache invalidation is required (the +/// AuditLogOptionsBindingTests fixture in ScadaLink.AuditLog.Tests +/// pins this behaviour). /// /// /// "Error row" = NOT IN (Delivered, diff --git a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs new file mode 100644 index 0000000..f9829cd --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs @@ -0,0 +1,220 @@ +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +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.Configuration; + +/// +/// Bundle D (M5-T8) tests for hot-reloadable +/// binding. The first test pins the JSON-realistic binding shape end-to-end +/// (scalars, lists, per-target overrides) so accidental drift in the section +/// layout breaks the build. The second test exercises the live hot-reload +/// path: a backed by a mutable +/// must respond to config changes on +/// the very next event, with both cap-bytes and the regex-cache invalidation +/// flowing through without a restart. +/// +/// +/// Distinct from (M1-T9) which covered +/// section binding + validator failures via single-key in-memory config — those +/// tests exist; these add (a) end-to-end binding from a realistic JSON literal +/// and (b) the hot-reload behavioural contract the M5-T8 spec calls out. +/// +public class AuditLogOptionsBindingTests +{ + [Fact] + public void AuditLog_Section_Binds_AllFields() + { + const string json = """ + { + "AuditLog": { + "DefaultCapBytes": 4096, + "ErrorCapBytes": 32768, + "HeaderRedactList": ["Authorization", "Custom-Token"], + "GlobalBodyRedactors": ["\"password\":\\s*\"[^\"]*\""], + "PerTargetOverrides": { + "myconnection": { + "CapBytes": 16384, + "AdditionalBodyRedactors": [], + "RedactSqlParamsMatching": "@token|@secret" + } + }, + "RetentionDays": 180 + } + } + """; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var configuration = new ConfigurationBuilder() + .AddJsonStream(stream) + .Build(); + var services = new ServiceCollection(); + services.AddAuditLog(configuration); + using var provider = services.BuildServiceProvider(); + + var opts = provider.GetRequiredService>().Value; + + // Scalars. + Assert.Equal(4096, opts.DefaultCapBytes); + Assert.Equal(32768, opts.ErrorCapBytes); + Assert.Equal(180, opts.RetentionDays); + + // HeaderRedactList: the Microsoft.Extensions.Configuration list binder + // APPENDS to the default list, so we assert containment rather than + // exact equality (see M1-T9 AuditLogOptionsTests for the rationale). + Assert.Contains("Authorization", opts.HeaderRedactList); + Assert.Contains("Custom-Token", opts.HeaderRedactList); + + // GlobalBodyRedactors: pattern arrived intact, regex-escape sequences + // and all. + Assert.Contains("\"password\":\\s*\"[^\"]*\"", opts.GlobalBodyRedactors); + + // PerTargetOverrides: keyed by connection name, each field bound. + Assert.True(opts.PerTargetOverrides.ContainsKey("myconnection")); + var ov = opts.PerTargetOverrides["myconnection"]; + Assert.Equal(16384, ov.CapBytes); + // Microsoft.Extensions.Configuration JSON binder leaves an empty array + // null on a nullable List; either null or empty is acceptable as + // "no additional redactors" — both result in zero patterns at use. + Assert.True(ov.AdditionalBodyRedactors is null || ov.AdditionalBodyRedactors.Count == 0); + Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching); + } + + [Fact] + public void Filter_Behavior_Updates_OnConfigReload() + { + // Start at the default cap (4096). A 5 KB body should be truncated; + // PayloadTruncated flips to true. + var initial = new AuditLogOptions { DefaultCapBytes = 4096 }; + var monitor = new TestOptionsMonitor(initial); + var filter = new DefaultAuditPayloadFilter( + monitor, + NullLogger.Instance); + + var body = new string('x', 5 * 1024); + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + RequestSummary = body, + }; + + var resultBefore = filter.Apply(evt); + Assert.True(resultBefore.PayloadTruncated, "5KB body at 4096 cap must be truncated"); + Assert.NotNull(resultBefore.RequestSummary); + Assert.True(Encoding.UTF8.GetByteCount(resultBefore.RequestSummary!) <= 4096); + + // Reload: cap raised to 16384 — next event must NOT truncate. This is + // the M5-T8 contract: the filter sees the new value on the very next + // Apply, without process restart. + monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 }); + + var resultAfter = filter.Apply(evt); + Assert.False(resultAfter.PayloadTruncated, "5KB body at 16384 cap must NOT be truncated"); + Assert.Equal(body, resultAfter.RequestSummary); + } + + [Fact] + public void Filter_PicksUp_NewBodyRedactor_OnConfigReload() + { + // The regex cache is keyed by pattern string — a redactor added via + // config reload must compile + apply on the very next event without a + // process restart. Pre-reload: no redactor, hunter2 survives. After + // reload: hunter2 redacted. + var monitor = new TestOptionsMonitor(new AuditLogOptions()); + var filter = new DefaultAuditPayloadFilter( + monitor, + NullLogger.Instance); + + const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}"; + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + RequestSummary = body, + }; + + var before = filter.Apply(evt); + Assert.Contains("hunter2", before.RequestSummary!); + + monitor.Set(new AuditLogOptions + { + GlobalBodyRedactors = new List { "\"password\":\\s*\"[^\"]*\"" }, + }); + + var after = filter.Apply(evt); + Assert.DoesNotContain("hunter2", after.RequestSummary!); + Assert.Contains("", after.RequestSummary!); + } + + /// + /// IOptionsMonitor test double — exposes a method that + /// updates the current value and fires registered OnChange callbacks. + /// Avoids depending on Microsoft.Extensions.Configuration's reload-token + /// plumbing, which is awkward to drive deterministically from xUnit. + /// + private sealed class TestOptionsMonitor : IOptionsMonitor + { + private T _current; + private readonly List> _listeners = new(); + + public TestOptionsMonitor(T initial) => _current = initial; + + public T CurrentValue => _current; + + public T Get(string? name) => _current; + + public IDisposable? OnChange(Action listener) + { + lock (_listeners) + { + _listeners.Add(listener); + } + return new Unsubscribe(_listeners, listener); + } + + public void Set(T value) + { + _current = value; + Action[] snapshot; + lock (_listeners) + { + snapshot = _listeners.ToArray(); + } + foreach (var l in snapshot) + { + l(_current, Options.DefaultName); + } + } + + private sealed class Unsubscribe : IDisposable + { + private readonly List> _listeners; + private readonly Action _listener; + public Unsubscribe(List> listeners, Action listener) + { + _listeners = listeners; + _listener = listener; + } + public void Dispose() + { + lock (_listeners) + { + _listeners.Remove(_listener); + } + } + } + } +}