Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

289 lines
12 KiB
C#

using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
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.Configuration;
/// <summary>
/// Tests for hot-reloadable <see cref="AuditLogOptions"/>
/// 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 <see cref="ScadaBridgeAuditRedactor"/> backed by a mutable
/// <see cref="IOptionsMonitor{TOptions}"/> must respond to config changes on
/// the very next event, with both cap-bytes and the regex-cache invalidation
/// flowing through without a restart.
/// </summary>
/// <remarks>
/// Distinct from <see cref="AuditLogOptionsTests"/> 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 spec calls out.
/// </remarks>
public class AuditLogOptionsBindingTests
{
/// <summary>Verifies that the AuditLog configuration section binds every field.</summary>
[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<IOptions<AuditLogOptions>>().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 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<T>; 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);
}
/// <summary>Verifies that the purge options bind from the documented section and keys.</summary>
[Fact]
public void PurgeOptions_Bind_FromDocumentedSectionAndKeys()
{
// The design doc (Component-AuditLog.md §Configuration)
// documents the purge tuning as the nested `AuditLog:Purge` section with
// keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the
// code binds from EXACTLY that shape — the section path the production
// code uses (ServiceCollectionExtensions.PurgeSectionName) AND the
// documented `ChannelPurgeBatchSize` key (mapped onto the
// ChannelPurgeBatchSizeConfigured backing property via
// [ConfigurationKeyName]). It would fail against the pre-fix code, where
// the binder looked for `ChannelPurgeBatchSizeConfigured` and silently
// ignored the documented key.
const string json = """
{
"AuditLog": {
"Purge": {
"IntervalHours": 6,
"ChannelPurgeBatchSize": 1000
}
}
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var configuration = new ConfigurationBuilder()
.AddJsonStream(stream)
.Build();
// Section path matches production (PurgeSectionName == "AuditLog:Purge").
Assert.Equal("AuditLog:Purge", ServiceCollectionExtensions.PurgeSectionName);
var services = new ServiceCollection();
services.AddOptions<AuditLogPurgeOptions>()
.Bind(configuration.GetSection(ServiceCollectionExtensions.PurgeSectionName));
using var provider = services.BuildServiceProvider();
var opts = provider.GetRequiredService<IOptions<AuditLogPurgeOptions>>().Value;
// IntervalHours bound from the nested section (not the 24 h default).
Assert.Equal(6, opts.IntervalHours);
Assert.Equal(TimeSpan.FromHours(6), opts.Interval);
// ChannelPurgeBatchSize bound via the documented key onto the backing
// property (not the 5000 default).
Assert.Equal(1000, opts.ChannelPurgeBatchSizeConfigured);
Assert.Equal(1000, opts.ChannelPurgeBatchSize);
}
/// <summary>Verifies that the filter behavior updates when the configuration reloads.</summary>
[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<AuditLogOptions>(initial);
var filter = new ScadaBridgeAuditRedactor(
monitor,
NullLogger<ScadaBridgeAuditRedactor>.Instance);
var body = new string('x', 5 * 1024);
var evt = ScadaBridgeAuditEventFactory.Create(
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.AsRow().PayloadTruncated, "5KB body at 4096 cap must be truncated");
Assert.NotNull(resultBefore.AsRow().RequestSummary);
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
// Reload: cap raised to 16384 — next event must NOT truncate. This is
// the hot-reload 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.AsRow().PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
Assert.Equal(body, resultAfter.AsRow().RequestSummary);
}
/// <summary>Verifies that the filter picks up a new body redactor when the configuration reloads.</summary>
[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<AuditLogOptions>(new AuditLogOptions());
var filter = new ScadaBridgeAuditRedactor(
monitor,
NullLogger<ScadaBridgeAuditRedactor>.Instance);
const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
var evt = ScadaBridgeAuditEventFactory.Create(
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.AsRow().RequestSummary!);
monitor.Set(new AuditLogOptions
{
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
});
var after = filter.Apply(evt);
Assert.DoesNotContain("hunter2", after.AsRow().RequestSummary!);
Assert.Contains("<redacted>", after.AsRow().RequestSummary!);
}
/// <summary>
/// IOptionsMonitor test double — exposes a <see cref="Set"/> 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.
/// </summary>
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
private T _current;
private readonly List<Action<T, string?>> _listeners = new();
/// <summary>Initializes a new instance of the <c>TestOptionsMonitor</c> class.</summary>
/// <param name="initial">The initial.</param>
public TestOptionsMonitor(T initial) => _current = initial;
/// <summary>Gets the current value value.</summary>
public T CurrentValue => _current;
/// <summary>Returns the current options value regardless of the name.</summary>
/// <param name="name">The options name; ignored.</param>
/// <returns>The current options value.</returns>
public T Get(string? name) => _current;
/// <summary>Registers a listener to be notified when the options change.</summary>
/// <param name="listener">The change callback.</param>
/// <returns>A disposable that removes the listener, or null when none is tracked.</returns>
public IDisposable? OnChange(Action<T, string?> listener)
{
lock (_listeners)
{
_listeners.Add(listener);
}
return new Unsubscribe(_listeners, listener);
}
/// <summary>Replaces the current value and notifies registered listeners.</summary>
/// <param name="value">The value.</param>
public void Set(T value)
{
_current = value;
Action<T, string?>[] snapshot;
lock (_listeners)
{
snapshot = _listeners.ToArray();
}
foreach (var l in snapshot)
{
l(_current, Options.DefaultName);
}
}
private sealed class Unsubscribe : IDisposable
{
private readonly List<Action<T, string?>> _listeners;
private readonly Action<T, string?> _listener;
/// <summary>Initializes a new instance of the <c>Unsubscribe</c> class.</summary>
/// <param name="listeners">The listeners.</param>
/// <param name="listener">The listener.</param>
public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener)
{
_listeners = listeners;
_listener = listener;
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
lock (_listeners)
{
_listeners.Remove(_listener);
}
}
}
}
}