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, "InboundMaxBytes": 524288 } } """; 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); Assert.Equal(524_288, opts.InboundMaxBytes); // 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); } } } } }