Files
wwtools/mbproxy/tests/Mbproxy.Tests/Configuration/ReloadValidatorTests.cs
T
Joseph Doherty e66b17fe5f mbproxy: Wave 2 fixes from 2026-05-14 code review
Resolves the 21 Major findings catalogued in
codereviews/2026-05-14/RemediationPlan.md (Wave 2). Tests: 370 pass / 0 fail
(baseline 363 + 7 new W2 regression tests).

Multiplexer / concurrency:
  W2.1  ConfigReconciler.Attach now threads the live coalescingAccessor through
        to add/restart-built supervisors so a hot-reload of
        ReadCoalescing.{Enabled,MaxParties} propagates to PLCs added or
        restarted via reload.
  W2.2  PlcMultiplexer._disposed and UpstreamPipe._disposed are now volatile
        for ARM/portability defense.
  W2.3  ProxyWorker._supervisors / ConfigReconciler._supervisors switched from
        Dictionary to ConcurrentDictionary; reconciler uses TryRemove. The
        outer Apply is serialised by a semaphore but the inner Add/Remove/
        Restart Task.WhenAll continuations run in parallel.
  W2.4  Counter parity for cache miss + coalescing-saturation miss documented
        inline (per-design contract; behavior unchanged).
  W2.5  _disposeCts.Dispose() and _connectGate.Dispose() guarded against late
        watchdog ticks.
  W2.6  _connectGate disposed in DisposeAsync.
  W2.7  Inline doc clarifying the post-rewriter FC byte read.

Cache / hot-reload:
  W2.8  PlcListenerSupervisor.ReplaceContextAsync now calls Clear() to capture
        the entry count, emits mbproxy.cache.flushed, then disposes the old
        cache. Previously the event was defined but never emitted.
  W2.9  Inline doc explaining the implicit "skip cache invalidation while
        recovering" gating (no backend reader during recovery → no FC06/FC16
        response → no invalidation).
  W2.10 ReloadValidator now re-checks resolved per-tag CacheTtlMs against
        Cache.AllowLongTtl after BcdTagMapBuilder folds the per-PLC default.

BCD rewriter:
  W2.11 Duplicate addresses detected within Global itself and within the per-PLC
        Add list itself, BEFORE the working dictionary collapses keys. Cross-list
        collisions (Global vs Add) remain the documented width-override pattern.
        Previously the DuplicateAddress error was unreachable dead code.
  W2.12 OverlappingHighRegister reports each colliding pair exactly once
        (canonicalised low/high pair tracked in a HashSet).
  W2.13 FC16 32-bit write rejects clientLow > 9999 or clientHigh > 9999 BEFORE
        the high*10000+low reconstruction. Without this guard, (high=9999,
        low=9999) silently re-encoded as (high=9998, low=9999), losing 1 from
        the high word.
  W2.14 FC16 validates pdu.Length >= 6 + qty*2 upfront — no half-rewritten
        requests when a malformed client claims more registers than it ships.

Supervisor:
  W2.15 WaitForInitialBindAttemptAsync now backed by TaskCompletionSource
        instead of 10ms busy-poll. Resolves race against fast Stopped→Bound→
        Stopped transitions and hangs when the supervisor task throws.
  W2.16 StartAsync refuses re-entry on a non-Stopped supervisor (was leaking
        the previous _supervisorCts).
  W2.17 New TransitionTo helper writes _state, _lastBindError, and (optionally)
        _recoveryAttempts under one lock. Snapshot() reads under the same lock
        so the status page never reports an inconsistent triple. Truncate
        helper extracted (was copy-pasted across three sites).
  W2.18 MbproxyOptionsValidator + ReloadValidator reject Connection.{Backend
        ConnectTimeoutMs, BackendRequestTimeoutMs, GracefulShutdownTimeoutMs}
        <= 0. Misconfigured 0 produces immediate CancelAfter(0) failures.

Hosting / diagnostics:
  W2.20 ProxyWorker.StopAsync supervisor-stop deadline now reads from
        IOptionsMonitor.CurrentValue.Connection.GracefulShutdownTimeoutMs
        (was hard-coded 5s).
  W2.21 src/Mbproxy/appsettings.json deleted; the published file is now a Link
        to install/mbproxy.config.template.json so the binary ships with a
        usable, fully-commented example config instead of an empty stub. Tests
        strip the inherited file from their bin via an AfterTargets="Build"
        Target so they don't pick up the template's example PLCs.
  W2.22 invalidBcdWarnings (PlcPdusStatus) and codeOther (ExceptionCounts)
        added to StatusDto, plumbed through StatusSnapshotBuilder, surfaced
        in StatusHtmlRenderer table cells.
  W2.23 EventLogBridge caches EventLog.SourceExists at construction so Emit
        doesn't hit the registry on every Error+ log line.

New regression tests:
  ReloadValidatorTests:
    Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails
    Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes
    Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails
    Validate_ZeroBackendConnectTimeoutMs_Fails
    Validate_NegativeGracefulShutdownTimeoutMs_Fails
  BcdPduPipelineTests:
    FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning
    FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite

Reworked tests in BcdTagMapBuilderTests for the W2.11 contract (Global dup,
Add dup, Add-overrides-Global accepted as width override).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 05:48:44 -04:00

268 lines
9.6 KiB
C#

using Mbproxy.Configuration;
using Mbproxy.Options;
using Xunit;
namespace Mbproxy.Tests.Configuration;
/// <summary>
/// Unit tests for <see cref="ReloadValidator.Validate"/>.
/// Each test covers one specific failure mode or the happy path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ReloadValidatorTests
{
// ── Helpers ───────────────────────────────────────────────────────────────────────────
private static PlcOptions MakePlc(string name, int listenPort, string host = "127.0.0.1")
=> new() { Name = name, ListenPort = listenPort, Host = host, Port = 502 };
private static MbproxyOptions MakeOptions(
PlcOptions[] plcs,
int adminPort = 8080,
BcdTagListOptions? global = null)
=> new()
{
Plcs = plcs,
AdminPort = adminPort,
BcdTags = global ?? new BcdTagListOptions(),
};
// ── 1. Duplicate PLC name → fails ────────────────────────────────────────────────────
[Fact]
public void Validate_DuplicatePlcName_Fails()
{
var opts = MakeOptions([
MakePlc("PLC-A", 5020),
MakePlc("PLC-A", 5021), // same name
]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("PLC-A") && e.Contains("uplicate"));
}
// ── 2. Duplicate ListenPort → fails ──────────────────────────────────────────────────
[Fact]
public void Validate_DuplicateListenPort_Fails()
{
var opts = MakeOptions([
MakePlc("PLC-A", 5020),
MakePlc("PLC-B", 5020), // same port
]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("5020") && e.Contains("uplicate"));
}
// ── 3. AdminPort collides with a PLC's ListenPort → fails ────────────────────────────
[Fact]
public void Validate_AdminPortCollidesWith_PlcListenPort_Fails()
{
var opts = MakeOptions(
plcs: [MakePlc("PLC-A", 5020)],
adminPort: 5020); // collides with PLC-A
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPort") && e.Contains("5020"));
}
// ── 4. Per-PLC BCD map build error → fails ────────────────────────────────────────────
[Fact]
public void Validate_PerPlc_BcdMapBuildError_Fails()
{
// A 32-bit tag at address 100 and a 16-bit tag at 101 collide on high register.
var global = new BcdTagListOptions
{
Global =
[
new BcdTagOptions { Address = 100, Width = 32 },
new BcdTagOptions { Address = 101, Width = 16 }, // overlaps 100's high register
],
};
var opts = MakeOptions([MakePlc("PLC-A", 5020)], global: global);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("PLC-A"));
}
// ── 5. Port out of range → fails ─────────────────────────────────────────────────────
[Fact]
public void Validate_PortOutOfRange_Fails()
{
// ListenPort 0 is below the valid [1, 65535] range.
var opts = MakeOptions([MakePlc("PLC-A", 0)]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("0") && e.Contains("range"));
}
// ── 5b. AdminPort out of range → fails ───────────────────────────────────────────────
[Fact]
public void Validate_AdminPortOutOfRange_Fails()
{
var opts = MakeOptions([MakePlc("PLC-A", 5020)], adminPort: 70000);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("70000") && e.Contains("range"));
}
// ── 6. Happy path → passes ───────────────────────────────────────────────────────────
[Fact]
public void Validate_HappyPath_Passes()
{
var global = new BcdTagListOptions
{
Global = [new BcdTagOptions { Address = 1072, Width = 16 }],
};
var opts = MakeOptions(
plcs: [MakePlc("PLC-A", 5020), MakePlc("PLC-B", 5021)],
adminPort: 8080,
global: global);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.True(valid);
Assert.Empty(errors);
}
// ── 7. Empty PLC name → fails ────────────────────────────────────────────────────────
[Fact]
public void Validate_EmptyPlcName_Fails()
{
var opts = MakeOptions([MakePlc("", 5020)]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("non-empty"));
}
// ── Phase 12 (W2.10) — Cache.AllowLongTtl gate ──────────────────────────────────────
/// <summary>
/// W2 — per-tag CacheTtlMs > 60_000 without Cache.AllowLongTtl is rejected.
/// </summary>
[Fact]
public void Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
BcdTags = new BcdTagListOptions
{
Global = [ new BcdTagOptions { Address = 1024, Width = 16, CacheTtlMs = 120_000 } ],
},
Cache = new CacheOptions { AllowLongTtl = false },
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AllowLongTtl") && e.Contains("60_000"));
}
/// <summary>
/// W2 — same value passes when AllowLongTtl is true (operator opt-in).
/// </summary>
[Fact]
public void Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
BcdTags = new BcdTagListOptions
{
Global = [ new BcdTagOptions { Address = 1024, Width = 16, CacheTtlMs = 120_000 } ],
},
Cache = new CacheOptions { AllowLongTtl = true },
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.True(valid);
Assert.Empty(errors);
}
/// <summary>
/// W2 — per-PLC DefaultCacheTtlMs > 60_000 inherited by a tag with null CacheTtlMs is
/// caught by the resolved-value check even if the per-PLC default check itself passes
/// (it doesn't, but this validates the defensive resolved re-check from W2.10).
/// </summary>
[Fact]
public void Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [
new PlcOptions
{
Name = "PLC-A", ListenPort = 5020, Host = "127.0.0.1", Port = 502,
DefaultCacheTtlMs = 90_000,
},
],
BcdTags = new BcdTagListOptions
{
// Tag with no explicit CacheTtlMs — inherits the per-PLC 90_000.
Global = [ new BcdTagOptions { Address = 1024, Width = 16 } ],
},
Cache = new CacheOptions { AllowLongTtl = false },
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("60_000"));
}
// ── Phase 12 (W2.18) — ConnectionOptions validation ─────────────────────────────────
[Fact]
public void Validate_ZeroBackendConnectTimeoutMs_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
Connection = new ConnectionOptions { BackendConnectTimeoutMs = 0 },
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("BackendConnectTimeoutMs"));
}
[Fact]
public void Validate_NegativeGracefulShutdownTimeoutMs_Fails()
{
var opts = new MbproxyOptions
{
Plcs = [MakePlc("PLC-A", 5020)],
Connection = new ConnectionOptions { GracefulShutdownTimeoutMs = -1 },
};
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("GracefulShutdownTimeoutMs"));
}
}