Files
wwtools/mbproxy/tests/Mbproxy.Tests/Admin/StatusSnapshotBuilderTests.cs
Joseph Doherty 56eee3c563 mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the
production-ready single-listener / 1:1-backend transparent Modbus TCP
proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260
fleet. Phase 9 replaces the connection layer with a single backend
socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's
4-concurrent-client cap as an operational ceiling.

Phase 9 additions of note:
- PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap
- InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing
  for Phase 10 read coalescing — do not collapse to a single field)
- Per-request watchdog: surfaces Modbus exception 0x0B to upstream
  on BackendRequestTimeoutMs, defending against lost responses,
  dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed-
  request bug (its ServerRequestHandler.last_pdu state race)
- Status DTO + HTML gain inFlight / maxInFlight / txIdWraps /
  disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md)

Tests: 263 unit + 38 E2E. Multiplexer correctness under truly
concurrent backend traffic is proved against a stub backend in
PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus
3.13's single-PDU framer stays in known-good mode.

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

301 lines
11 KiB
C#

using System.Net;
using Mbproxy.Admin;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Serilog;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusSnapshotBuilder"/>.
/// All tests use a real in-process host with <see cref="NoopPduPipeline"/> and
/// in-memory configuration. No network I/O is required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusSnapshotBuilderTests
{
// ── 1. No PLCs configured → empty PLC list ────────────────────────────────
[Fact]
public async Task Build_NoPlcsConfigured_ReturnsEmptyPlcList()
{
var (host, builder) = await BuildAsync([]);
await using var _ = new AsyncHostDispose(host);
var result = builder.Build();
result.Plcs.ShouldBeEmpty();
result.Listeners.Configured.ShouldBe(0);
result.Listeners.Bound.ShouldBe(0);
}
// ── 2. One PLC bound → state is "bound" ───────────────────────────────────
[Fact]
public async Task Build_OnePlcBound_PopulatesListenerState_Bound()
{
int port = PickFreePort();
var (host, builder) = await BuildAsync([("PLC-A", port)]);
await using var _ = new AsyncHostDispose(host);
// Wait for the listener to bind.
await WaitForAsync(
() => CanConnect(port),
TimeSpan.FromSeconds(5),
"PLC-A listener should bind");
var result = builder.Build();
var plc = result.Plcs.ShouldHaveSingleItem();
plc.Name.ShouldBe("PLC-A");
plc.Listener.State.ShouldBe("bound");
plc.Listener.LastBindError.ShouldBeNull();
}
// ── 3. PLC recovering → state + last error + attempts ────────────────────
[Fact]
public async Task Build_PlcRecovering_PopulatesLastBindError_AndAttempts()
{
// Bind the occupier on ANY so the proxy (also ANY) cannot rebind the same port.
var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0);
occupier.Server.SetSocketOption(
System.Net.Sockets.SocketOptionLevel.Socket,
System.Net.Sockets.SocketOptionName.ExclusiveAddressUse,
true);
occupier.Start();
int port = ((IPEndPoint)occupier.LocalEndpoint).Port;
try
{
var (host, builder) = await BuildAsync([("PLC-A", port)], startupWaitMs: 500);
await using var _ = new AsyncHostDispose(host);
// Give the supervisor time to attempt and fail (it enters Recovering state).
await Task.Delay(300, TestContext.Current.CancellationToken);
var result = builder.Build();
var plc = result.Plcs.ShouldHaveSingleItem();
plc.Listener.State.ShouldBe("recovering");
}
finally
{
occupier.Stop();
}
}
// ── 4. Aggregate bound/configured ────────────────────────────────────────
[Fact]
public async Task Build_AggregatesListenersBoundAndConfigured()
{
int portA = PickFreePort();
// Occupy portB on ANY with exclusive address use so the proxy cannot rebind it.
var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0);
occupier.Server.SetSocketOption(
System.Net.Sockets.SocketOptionLevel.Socket,
System.Net.Sockets.SocketOptionName.ExclusiveAddressUse,
true);
occupier.Start();
int portB = ((IPEndPoint)occupier.LocalEndpoint).Port;
try
{
var (host, builder) = await BuildAsync([("PLC-A", portA), ("PLC-B", portB)],
startupWaitMs: 400);
await using var _ = new AsyncHostDispose(host);
await WaitForAsync(
() => CanConnect(portA),
TimeSpan.FromSeconds(5),
"PLC-A should bind");
// Give portB's supervisor time to make its first (failing) attempt.
await Task.Delay(200, TestContext.Current.CancellationToken);
var result = builder.Build();
result.Listeners.Configured.ShouldBe(2);
result.Listeners.Bound.ShouldBe(1); // only PLC-A is bound
}
finally
{
occupier.Stop();
}
}
// ── 5. Per-client snapshot populated after connection ────────────────────
[Fact]
public async Task Build_PerClientSnapshot_Includes_RemoteAndConnectedAt_AndPduCount()
{
int proxyPort = PickFreePort();
// Start a "fake backend" listener so the multiplexer's backend-connect succeeds.
var fakeBackend = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
fakeBackend.Start();
int backendPort = ((IPEndPoint)fakeBackend.LocalEndpoint).Port;
// Track accepted sockets so we can hold them open while the test runs.
var acceptedSockets = new System.Collections.Generic.List<System.Net.Sockets.Socket>();
// Accept connections in the background and keep them open.
var backendAcceptTask = Task.Run(async () =>
{
while (true)
{
try
{
var accepted = await fakeBackend.AcceptSocketAsync(CancellationToken.None);
lock (acceptedSockets) acceptedSockets.Add(accepted);
}
catch { break; }
}
}, CancellationToken.None);
try
{
var (host, builder) = await BuildAsync(
[("PLC-A", proxyPort)],
backendPort: backendPort);
await using var hostDispose = new AsyncHostDispose(host);
await WaitForAsync(
() => CanConnect(proxyPort),
TimeSpan.FromSeconds(5),
"PLC-A should bind");
// Connect a TCP client to the proxy's listen port.
using var client = new System.Net.Sockets.TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
// Give the listener a moment to register the pair.
await Task.Delay(200, TestContext.Current.CancellationToken);
var result = builder.Build();
var plc = result.Plcs.ShouldHaveSingleItem();
plc.Clients.Connected.ShouldBe(1);
var clientSnap = plc.Clients.RemoteEndpoints.ShouldHaveSingleItem();
clientSnap.Remote.ShouldNotBeNullOrEmpty();
// ConnectedAtUtc should be recent (within 10 s).
(DateTimeOffset.UtcNow - clientSnap.ConnectedAtUtc).TotalSeconds.ShouldBeLessThan(10);
}
finally
{
lock (acceptedSockets)
foreach (var s in acceptedSockets) try { s.Dispose(); } catch { }
fakeBackend.Stop();
try { await backendAcceptTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None); } catch { }
}
}
// ── 6. Service fields: uptime, version, last-reload ──────────────────────
[Fact]
public async Task Build_ServiceFields_IncludeUptime_Version_AndLastReload()
{
var (host, builder) = await BuildAsync([]);
await using var _ = new AsyncHostDispose(host);
var counters = host.Services.GetRequiredService<ServiceCounters>();
var now = DateTimeOffset.UtcNow;
counters.RecordReloadApplied(now);
var result = builder.Build();
result.Service.UptimeSeconds.ShouldBeGreaterThanOrEqualTo(0);
result.Service.Version.ShouldNotBeNullOrEmpty();
result.Service.ConfigLastReloadUtc.ShouldNotBeNull();
result.Service.ConfigReloadCount.ShouldBe(1);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
(string name, int port)[] plcs,
int startupWaitMs = 200,
int backendPort = 502)
{
var config = new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
};
for (int i = 0; i < plcs.Length; i++)
{
config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name;
config[$"Mbproxy:Plcs:{i}:ListenPort"] = plcs[i].port.ToString();
config[$"Mbproxy:Plcs:{i}:Host"] = "127.0.0.1";
config[$"Mbproxy:Plcs:{i}:Port"] = backendPort.ToString();
}
var hostBuilder = Host.CreateApplicationBuilder();
hostBuilder.Configuration.AddInMemoryCollection(config);
hostBuilder.Services.AddSerilog(
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
dispose: false);
hostBuilder.AddMbproxyOptions();
hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
// Register ProxyWorker as singleton so StatusSnapshotBuilder can resolve it by type.
hostBuilder.Services.AddSingleton<ProxyWorker>();
hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
// Admin support singletons (no AdminEndpointHost — keep unit tests lean).
hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();
var host = hostBuilder.Build();
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await host.StartAsync(startCts.Token);
await Task.Delay(startupWaitMs, TestContext.Current.CancellationToken);
var snapshotBuilder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
return (host, snapshotBuilder);
}
private static int PickFreePort()
{
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
l.Start();
int port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout, string msg)
{
using var cts = new CancellationTokenSource(timeout);
while (!predicate() && !cts.IsCancellationRequested)
await Task.Delay(50, cts.Token).ConfigureAwait(false);
predicate().ShouldBeTrue(msg);
}
private static bool CanConnect(int port)
{
try { using var c = new System.Net.Sockets.TcpClient(); c.Connect("127.0.0.1", port); return true; }
catch { return false; }
}
private sealed class AsyncHostDispose : IAsyncDisposable
{
private readonly IHost _host;
public AsyncHostDispose(IHost host) => _host = host;
public async ValueTask DisposeAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await _host.StopAsync(cts.Token); } catch { }
_host.Dispose();
}
}
}