refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
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.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.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="DefaultAuditPayloadFilter"/> 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 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 DefaultAuditPayloadFilter(
|
||||
monitor,
|
||||
NullLogger<DefaultAuditPayloadFilter>.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<AuditLogOptions>(new AuditLogOptions());
|
||||
var filter = new DefaultAuditPayloadFilter(
|
||||
monitor,
|
||||
NullLogger<DefaultAuditPayloadFilter>.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<string> { "\"password\":\\s*\"[^\"]*\"" },
|
||||
});
|
||||
|
||||
var after = filter.Apply(evt);
|
||||
Assert.DoesNotContain("hunter2", after.RequestSummary!);
|
||||
Assert.Contains("<redacted>", after.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator
|
||||
/// behavior. The validator enforces invariants used by M2+ writers
|
||||
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
|
||||
/// <c>DefaultCapBytes > 0</c>, <c>ErrorCapBytes >= DefaultCapBytes</c>,
|
||||
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
|
||||
/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie.
|
||||
/// </summary>
|
||||
public class AuditLogOptionsTests
|
||||
{
|
||||
private static IOptions<AuditLogOptions> BuildOptions(Dictionary<string, string?> config)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(config)
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddAuditLog(configuration);
|
||||
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidBinding_PopulatesAllScalarFields()
|
||||
{
|
||||
var opts = BuildOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:DefaultCapBytes"] = "4096",
|
||||
["AuditLog:ErrorCapBytes"] = "32768",
|
||||
["AuditLog:RetentionDays"] = "180",
|
||||
}).Value;
|
||||
|
||||
Assert.Equal(4096, opts.DefaultCapBytes);
|
||||
Assert.Equal(32768, opts.ErrorCapBytes);
|
||||
Assert.Equal(180, opts.RetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultsAreReasonable_WhenSectionEmpty()
|
||||
{
|
||||
var opts = BuildOptions(new Dictionary<string, string?>()).Value;
|
||||
|
||||
Assert.Equal(8192, opts.DefaultCapBytes);
|
||||
Assert.Equal(65536, opts.ErrorCapBytes);
|
||||
Assert.Equal(365, opts.RetentionDays);
|
||||
Assert.Contains("Authorization", opts.HeaderRedactList);
|
||||
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||
Assert.Contains("Cookie", opts.HeaderRedactList);
|
||||
Assert.Contains("Set-Cookie", opts.HeaderRedactList);
|
||||
Assert.Empty(opts.GlobalBodyRedactors);
|
||||
Assert.Empty(opts.PerTargetOverrides);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
|
||||
{
|
||||
// Microsoft.Extensions.Configuration's collection binder appends to a
|
||||
// defaulted list (it does not replace it), so config-supplied entries
|
||||
// augment the built-in redact list rather than overriding it. The
|
||||
// built-in entries are the safety-net defaults documented on
|
||||
// AuditLogOptions; supplying additional headers is the supported
|
||||
// extension point.
|
||||
var opts = BuildOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:HeaderRedactList:0"] = "X-Custom-Auth",
|
||||
["AuditLog:HeaderRedactList:1"] = "X-Tenant-Id",
|
||||
}).Value;
|
||||
|
||||
Assert.Contains("X-Custom-Auth", opts.HeaderRedactList);
|
||||
Assert.Contains("X-Tenant-Id", opts.HeaderRedactList);
|
||||
Assert.Contains("Authorization", opts.HeaderRedactList);
|
||||
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerTargetOverrides_BindsFromConfig()
|
||||
{
|
||||
var opts = BuildOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:PerTargetOverrides:CRM:CapBytes"] = "16384",
|
||||
["AuditLog:PerTargetOverrides:CRM:AdditionalBodyRedactors:0"] = @"\d{16}",
|
||||
}).Value;
|
||||
|
||||
Assert.True(opts.PerTargetOverrides.ContainsKey("CRM"));
|
||||
var crm = opts.PerTargetOverrides["CRM"];
|
||||
Assert.Equal(16384, crm.CapBytes);
|
||||
Assert.NotNull(crm.AdditionalBodyRedactors);
|
||||
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDefaultCapBytes_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { DefaultCapBytes = 0 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("DefaultCapBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidErrorCapBytes_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { DefaultCapBytes = 1000, ErrorCapBytes = 100 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ErrorCapBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetentionDaysBelowMinimum_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { RetentionDays = 0 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetentionDaysAboveMaximum_FailsValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions { RetentionDays = 3651 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_PassValidation()
|
||||
{
|
||||
var result = new AuditLogOptionsValidator().Validate(
|
||||
Options.DefaultName,
|
||||
new AuditLogOptions());
|
||||
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:RetentionDays"] = "0",
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddAuditLog(configuration);
|
||||
var provider = services.BuildServiceProvider();
|
||||
var opts = provider.GetRequiredService<IOptions<AuditLogOptions>>();
|
||||
|
||||
var ex = Assert.Throws<OptionsValidationException>(() => _ = opts.Value);
|
||||
Assert.Contains("RetentionDays", ex.Message);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Task 1 of <c>docs/plans/2026-05-23-inbound-api-full-response-audit.md</c>:
|
||||
/// pins the <see cref="AuditLogOptions.InboundMaxBytes"/> default to 1 MiB and
|
||||
/// the validator bounds to <c>[8 KiB, 16 MiB]</c>. The inbound channel needs a
|
||||
/// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other
|
||||
/// channels use, but unbounded would let any caller flood the central
|
||||
/// <c>AuditLog</c> table with arbitrarily large bodies — hence the upper bound.
|
||||
/// Companion to <see cref="AuditLogOptionsTests"/> which covers the existing
|
||||
/// cap-bytes + retention invariants.
|
||||
/// </summary>
|
||||
public class AuditLogOptionsValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
|
||||
{
|
||||
// The doc'd default per docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
|
||||
// is 1 048 576 bytes (1 MiB). Pin it so a config drift is a test failure,
|
||||
// not a silent operational surprise.
|
||||
var opts = new AuditLogOptions();
|
||||
Assert.Equal(1_048_576, opts.InboundMaxBytes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(8_192)] // documented min
|
||||
[InlineData(1_048_576)] // default
|
||||
[InlineData(16_777_216)] // documented max
|
||||
public void Validate_InboundMaxBytes_InRange_Passes(int value)
|
||||
{
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
var opts = new AuditLogOptions { InboundMaxBytes = value };
|
||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(8_191)]
|
||||
[InlineData(16_777_217)]
|
||||
[InlineData(int.MaxValue)]
|
||||
public void Validate_InboundMaxBytes_OutOfRange_Fails(int value)
|
||||
{
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
var opts = new AuditLogOptions { InboundMaxBytes = value };
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user