fd618cf1dc
Remediation from the full per-module code review at 4307c381 (findings recorded
separately in code-reviews/).
Highs fixed:
- DeploymentManager-025/SiteRuntime-031: stop broadcasting notification lists + SMTP
configs (incl. credentials) to sites; site purges already-persisted rows on apply
(enforces the central-only delivery design; clears plaintext SMTP creds at rest).
- DataConnectionLayer-023: guard the native-alarm subscribe path against the
mid-flight-unsubscribe adapter-feed leak (mirrors the DCL-021 tag-path fix).
- SiteEventLogging-024: normalize From/To query bounds to UTC (the -016 fix the
audit trail claimed but never committed).
- KpiHistory-001: add an in-flight guard to the recorder sample tick.
- ScriptAnalysis-001: harden the trust analyzer's TPA-absent fallback (resolve
forbidden anchors in the minimal reference set; warn on degraded mode) — anchors
added to validation references only, never the compile gate.
(InboundAPI-026 left to the feat/ipsen-movein effort per owner decision.)
Medium/Low: DM-026 deterministic deploy-status tiebreaker; SR-027/028/029/030
native-alarm leak/phantom-active/delete-during-redeploy fixes; AL-013/014/016;
TE-024 (folder-mutation audit rows now persisted)/025; SF-025 gauge-provider
clear-on-stop; ESG-025/026; SEC-023/024/025; SCA-007/008/009; plus doc/test
accuracy COM-023/024, HOST-025/026, HM-024/025, NS-027/028.
Full-solution build 0 warnings; ~3560 tests across 18 touched suites green.
270 lines
11 KiB
C#
270 lines
11 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>
|
|
/// Bundle D (M5-T8) 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"/> (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.
|
|
/// </remarks>
|
|
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<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 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<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);
|
|
}
|
|
|
|
[Fact]
|
|
public void PurgeOptions_Bind_FromDocumentedSectionAndKeys()
|
|
{
|
|
// AuditLog-013: 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);
|
|
}
|
|
|
|
[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 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.AsRow().PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
|
|
Assert.Equal(body, resultAfter.AsRow().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<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();
|
|
|
|
public TestOptionsMonitor(T initial) => _current = initial;
|
|
|
|
public T CurrentValue => _current;
|
|
|
|
public T Get(string? name) => _current;
|
|
|
|
public IDisposable? OnChange(Action<T, string?> listener)
|
|
{
|
|
lock (_listeners)
|
|
{
|
|
_listeners.Add(listener);
|
|
}
|
|
return new Unsubscribe(_listeners, listener);
|
|
}
|
|
|
|
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;
|
|
public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener)
|
|
{
|
|
_listeners = listeners;
|
|
_listener = listener;
|
|
}
|
|
public void Dispose()
|
|
{
|
|
lock (_listeners)
|
|
{
|
|
_listeners.Remove(_listener);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|