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>
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Mbproxy.Admin;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Configuration.Memory;
|
||||
using NModbus;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end HTTP-level tests for the admin endpoint.
|
||||
/// Each test starts an in-process host with a live Kestrel admin server and verifies
|
||||
/// the shape and content of the responses.
|
||||
///
|
||||
/// Tests that require a Modbus simulator skip gracefully when Python / pymodbus
|
||||
/// is not available.
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class AdminEndpointTests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
private static readonly HttpClient HttpClient = new();
|
||||
|
||||
public AdminEndpointTests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
|
||||
{
|
||||
_sim = sim;
|
||||
}
|
||||
|
||||
// ── 1. GET /status.json returns valid JSON with expected top-level shape ──
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_StatusJson_ReturnsValidShape()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/status.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json");
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// service sub-object
|
||||
root.TryGetProperty("service", out var svc).ShouldBeTrue("Missing 'service' field");
|
||||
svc.TryGetProperty("uptimeSeconds", out var svcUptime).ShouldBeTrue("Missing service.uptimeSeconds");
|
||||
svc.TryGetProperty("version", out var svcVersion).ShouldBeTrue("Missing service.version");
|
||||
svc.TryGetProperty("configReloadCount", out var svcReload).ShouldBeTrue("Missing service.configReloadCount");
|
||||
|
||||
// listeners sub-object
|
||||
root.TryGetProperty("listeners", out var lst).ShouldBeTrue("Missing 'listeners' field");
|
||||
lst.TryGetProperty("bound", out var lstBound).ShouldBeTrue("Missing listeners.bound");
|
||||
lst.TryGetProperty("configured", out var lstConfigured).ShouldBeTrue("Missing listeners.configured");
|
||||
|
||||
// plcs array
|
||||
root.TryGetProperty("plcs", out var plcs).ShouldBeTrue("Missing 'plcs' field");
|
||||
plcs.ValueKind.ShouldBe(JsonValueKind.Array);
|
||||
|
||||
// per-plc shape (only if PLCs configured)
|
||||
if (plcs.GetArrayLength() > 0)
|
||||
{
|
||||
var plc0 = plcs[0];
|
||||
plc0.TryGetProperty("name", out var plcName).ShouldBeTrue("Missing plc.name");
|
||||
plc0.TryGetProperty("listener", out var listener).ShouldBeTrue("Missing plc.listener");
|
||||
listener.TryGetProperty("state", out var listenerState).ShouldBeTrue("Missing plc.listener.state");
|
||||
plc0.TryGetProperty("clients", out var clients).ShouldBeTrue("Missing plc.clients");
|
||||
clients.TryGetProperty("connected", out var clientsConn).ShouldBeTrue("Missing plc.clients.connected");
|
||||
clients.TryGetProperty("remoteEndpoints", out var clientsRemote).ShouldBeTrue("Missing plc.clients.remoteEndpoints");
|
||||
plc0.TryGetProperty("pdus", out var pdus).ShouldBeTrue("Missing plc.pdus");
|
||||
pdus.TryGetProperty("forwarded", out var pdusForwarded).ShouldBeTrue("Missing plc.pdus.forwarded");
|
||||
pdus.TryGetProperty("byFc", out var pdusByFc).ShouldBeTrue("Missing plc.pdus.byFc");
|
||||
plc0.TryGetProperty("backend", out var backend).ShouldBeTrue("Missing plc.backend");
|
||||
backend.TryGetProperty("lastRoundTripMs", out var backendRtt).ShouldBeTrue("Missing plc.backend.lastRoundTripMs");
|
||||
plc0.TryGetProperty("bytes", out var bytes).ShouldBeTrue("Missing plc.bytes");
|
||||
bytes.TryGetProperty("upstreamIn", out var bytesIn).ShouldBeTrue("Missing plc.bytes.upstreamIn");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. PDU count increases after FC03 read ────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_StatusJson_AfterReadFC03_ShowsPduCountIncreased()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: _sim.Host, simPort: _sim.Port,
|
||||
proxyPort: proxyPort, bcd16Addresses: [1072]);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
await WaitForListenerAsync(proxyPort);
|
||||
|
||||
// Read baseline PDU count.
|
||||
long before = await GetPduForwardedAsync(adminPort);
|
||||
|
||||
// Perform one FC03 read through the proxy.
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
master.ReadHoldingRegisters(1, 1072, 1);
|
||||
|
||||
// Give counters time to propagate.
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
|
||||
long after = await GetPduForwardedAsync(adminPort);
|
||||
after.ShouldBeGreaterThan(before, "PDU count should increase after an FC03 read");
|
||||
}
|
||||
|
||||
// ── 3. Partial BCD warning appears after partial overlap read ────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_StatusJson_AfterPartialBcdWrite_ShowsPartialBcdWarning()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
// Configure a 32-bit BCD tag at 1072/1073.
|
||||
var host = BuildHost(adminPort: adminPort, simHost: _sim.Host, simPort: _sim.Port,
|
||||
proxyPort: proxyPort, bcd32Addresses: [1072]);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
await WaitForListenerAsync(proxyPort);
|
||||
|
||||
// Read baseline partial BCD warning count.
|
||||
long before = await GetPartialBcdWarningsAsync(adminPort);
|
||||
|
||||
// Read only the HIGH register (1073) of the 32-bit pair → partial overlap.
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
master.ReadHoldingRegisters(1, 1073, 1); // partial overlap
|
||||
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
|
||||
long after = await GetPartialBcdWarningsAsync(adminPort);
|
||||
after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
|
||||
}
|
||||
|
||||
// ── 4. GET / returns 200 text/html with meta-refresh ─────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Get_Root_ReturnsHtml_WithMetaRefresh()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort);
|
||||
|
||||
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/",
|
||||
TestContext.Current.CancellationToken);
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
body.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
|
||||
body.ShouldContain("<!DOCTYPE html>");
|
||||
}
|
||||
|
||||
// ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task AdminPort_BindFailure_ServiceStaysUp_AndLogsBindFailed()
|
||||
{
|
||||
int adminPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
// Occupy the admin port on ANY with exclusive use so the proxy Kestrel cannot bind it.
|
||||
var occupier = new TcpListener(IPAddress.Any, adminPort);
|
||||
occupier.Server.SetSocketOption(
|
||||
SocketOptionLevel.Socket,
|
||||
SocketOptionName.ExclusiveAddressUse,
|
||||
true);
|
||||
occupier.Start();
|
||||
|
||||
try
|
||||
{
|
||||
var logSink = new CapturingSink();
|
||||
var serilog = new LoggerConfiguration()
|
||||
.MinimumLevel.Error()
|
||||
.WriteTo.Sink(logSink)
|
||||
.CreateLogger();
|
||||
|
||||
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
||||
proxyPort: proxyPort, bcd16Addresses: [], serilogOverride: serilog);
|
||||
await using var _ = new AsyncHostDispose(host);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
// StartAsync should NOT throw even though the admin port is taken.
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
// Give the service time to attempt the bind.
|
||||
await Task.Delay(500, TestContext.Current.CancellationToken);
|
||||
|
||||
// The Modbus proxy listener should still be up.
|
||||
bool proxyUp = CanConnect(proxyPort);
|
||||
proxyUp.ShouldBeTrue("Proxy listener should still be reachable despite admin bind failure");
|
||||
|
||||
// The bind-failed event should have been logged.
|
||||
bool logged = logSink.Events.Any(e =>
|
||||
e.MessageTemplate.Text.Contains("mbproxy.admin.bind.failed") ||
|
||||
e.MessageTemplate.Text.Contains("Admin endpoint bind failed"));
|
||||
logged.ShouldBeTrue("mbproxy.admin.bind.failed should be logged when the admin port is in use");
|
||||
}
|
||||
finally
|
||||
{
|
||||
occupier.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. AdminPort hot-reload → server re-binds to new port ────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task AdminPort_HotReload_RebindsToNewPort()
|
||||
{
|
||||
int adminPort1 = PickFreePort();
|
||||
int adminPort2 = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
// Write initial config to a temp file.
|
||||
string configPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"mbproxy_admin_hotreload_{Guid.NewGuid():N}.json");
|
||||
|
||||
try
|
||||
{
|
||||
WriteConfig(configPath, adminPort: adminPort1, proxyPort: proxyPort);
|
||||
|
||||
var logger = new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddJsonFile(configPath, optional: false, reloadOnChange: true);
|
||||
builder.Services.AddSerilog(logger, dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
||||
builder.Services.AddSingleton<ProxyWorker>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
||||
builder.AddMbproxyAdmin();
|
||||
|
||||
using var host = builder.Build();
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await WaitForAdminAsync(adminPort1);
|
||||
|
||||
// Mutate the config file to change AdminPort.
|
||||
WriteConfig(configPath, adminPort: adminPort2, proxyPort: proxyPort);
|
||||
|
||||
// Wait for admin endpoint to re-bind on new port.
|
||||
await WaitForAdminAsync(adminPort2);
|
||||
|
||||
// Old port should no longer serve requests.
|
||||
bool oldPortStillUp;
|
||||
try
|
||||
{
|
||||
var r = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort1}/status.json",
|
||||
new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token);
|
||||
oldPortStillUp = r.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
oldPortStillUp = false;
|
||||
}
|
||||
|
||||
oldPortStillUp.ShouldBeFalse("Old admin port should no longer be active after hot-reload");
|
||||
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await host.StopAsync(stopCts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { System.IO.File.Delete(configPath); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteConfig(string path, int adminPort, int proxyPort)
|
||||
{
|
||||
var doc = new
|
||||
{
|
||||
Mbproxy = new
|
||||
{
|
||||
AdminPort = adminPort,
|
||||
BcdTags = new { Global = Array.Empty<object>() },
|
||||
Plcs = new[] { new { Name = "PLC-A", ListenPort = proxyPort, Host = "127.0.0.1", Port = 502 } },
|
||||
Connection = new { BackendConnectTimeoutMs = 500, BackendRequestTimeoutMs = 500 },
|
||||
},
|
||||
};
|
||||
|
||||
string tmp = path + ".tmp";
|
||||
System.IO.File.WriteAllText(tmp,
|
||||
System.Text.Json.JsonSerializer.Serialize(doc,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
|
||||
System.IO.File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static IHost BuildHost(
|
||||
int adminPort,
|
||||
string simHost,
|
||||
int simPort,
|
||||
int proxyPort,
|
||||
ushort[]? bcd16Addresses = null,
|
||||
ushort[]? bcd32Addresses = null,
|
||||
Serilog.ILogger? serilogOverride = null)
|
||||
{
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = adminPort.ToString(),
|
||||
["Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
["Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
["Mbproxy:Plcs:0:Host"] = simHost,
|
||||
["Mbproxy:Plcs:0:Port"] = simPort.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
int tagIndex = 0;
|
||||
foreach (ushort addr in bcd16Addresses ?? [])
|
||||
{
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "16";
|
||||
tagIndex++;
|
||||
}
|
||||
foreach (ushort addr in bcd32Addresses ?? [])
|
||||
{
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "32";
|
||||
tagIndex++;
|
||||
}
|
||||
|
||||
var logger = serilogOverride
|
||||
?? new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();
|
||||
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddSerilog(logger, dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
||||
// Register as singleton so StatusSnapshotBuilder can inject ProxyWorker directly.
|
||||
builder.Services.AddSingleton<ProxyWorker>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
||||
builder.AddMbproxyAdmin();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static async Task WaitForAdminAsync(int adminPort)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/status.json", cts.Token);
|
||||
if (r.StatusCode == System.Net.HttpStatusCode.OK) return;
|
||||
}
|
||||
catch { }
|
||||
await Task.Delay(100, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
throw new TimeoutException($"Admin endpoint on port {adminPort} did not start in time.");
|
||||
}
|
||||
|
||||
private static async Task WaitForListenerAsync(int proxyPort)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (CanConnect(proxyPort)) return;
|
||||
await Task.Delay(50, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
throw new TimeoutException($"Proxy listener on port {proxyPort} did not start in time.");
|
||||
}
|
||||
|
||||
private static async Task<long> GetPduForwardedAsync(int adminPort)
|
||||
{
|
||||
string body = await HttpClient.GetStringAsync($"http://127.0.0.1:{adminPort}/status.json");
|
||||
var doc = JsonDocument.Parse(body);
|
||||
var plcs = doc.RootElement.GetProperty("plcs");
|
||||
if (plcs.GetArrayLength() == 0) return 0;
|
||||
return plcs[0].GetProperty("pdus").GetProperty("forwarded").GetInt64();
|
||||
}
|
||||
|
||||
private static async Task<long> GetPartialBcdWarningsAsync(int adminPort)
|
||||
{
|
||||
string body = await HttpClient.GetStringAsync($"http://127.0.0.1:{adminPort}/status.json");
|
||||
var doc = JsonDocument.Parse(body);
|
||||
var plcs = doc.RootElement.GetProperty("plcs");
|
||||
if (plcs.GetArrayLength() == 0) return 0;
|
||||
return plcs[0].GetProperty("pdus").GetProperty("partialBcdWarnings").GetInt64();
|
||||
}
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static bool CanConnect(int port)
|
||||
{
|
||||
try { using var c = new 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();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSink : Serilog.Core.ILogEventSink
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentQueue<Serilog.Events.LogEvent> _q = new();
|
||||
public System.Collections.Generic.IEnumerable<Serilog.Events.LogEvent> Events => _q;
|
||||
public void Emit(Serilog.Events.LogEvent e) => _q.Enqueue(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Mbproxy.Admin;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusHtmlRenderer"/>.
|
||||
/// All tests are pure: no network, no host, no DI.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusHtmlRendererTests
|
||||
{
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static StatusResponse MakeStatus(
|
||||
IReadOnlyList<PlcStatus>? plcs = null,
|
||||
int uptimeSeconds = 42,
|
||||
string version = "1.2.3")
|
||||
{
|
||||
var service = new ServiceFields(
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
Version: version,
|
||||
ConfigLastReloadUtc: null,
|
||||
ConfigReloadCount: 0,
|
||||
ConfigReloadRejectedCount: 0);
|
||||
|
||||
var listeners = new ListenersAggregate(Bound: plcs?.Count ?? 0, Configured: plcs?.Count ?? 0);
|
||||
return new StatusResponse(service, listeners, plcs ?? []);
|
||||
}
|
||||
|
||||
private static PlcStatus MakePlc(
|
||||
string name = "PLC-A",
|
||||
string state = "bound",
|
||||
string? lastBindError = null,
|
||||
int recoveryAttempts = 0,
|
||||
IReadOnlyList<ClientSnapshot>? clients = null)
|
||||
{
|
||||
var noClients = (IReadOnlyList<ClientSnapshot>)[];
|
||||
return new PlcStatus(
|
||||
Name: name,
|
||||
Host: "10.0.0.1",
|
||||
ListenPort: 5020,
|
||||
Listener: new PlcListenerStatus(state, lastBindError, recoveryAttempts),
|
||||
Clients: new PlcClientsStatus(clients?.Count ?? 0, clients ?? noClients),
|
||||
Pdus: new PlcPdusStatus(100, new FcCounts(50, 10, 20, 15, 5), 30, 2),
|
||||
Backend: new PlcBackendStatus(
|
||||
ConnectsSuccess: 0, ConnectsFailed: 0,
|
||||
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0),
|
||||
LastRoundTripMs: 3.5,
|
||||
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
|
||||
DisconnectCascades: 0, QueueDepth: 0),
|
||||
Bytes: new PlcBytesStatus(1024, 2048));
|
||||
}
|
||||
|
||||
// ── 1. Valid HTML with meta-refresh for a single PLC ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_OnePlc_ProducesValidHtml_WithMetaRefresh()
|
||||
{
|
||||
var status = MakeStatus([MakePlc("PLC-A", "bound")]);
|
||||
|
||||
string html = StatusHtmlRenderer.Render(status);
|
||||
|
||||
html.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
|
||||
html.ShouldContain("<!DOCTYPE html>");
|
||||
html.ShouldContain("</html>");
|
||||
html.ShouldContain("PLC-A");
|
||||
html.ShouldContain("bound");
|
||||
}
|
||||
|
||||
// ── 2. Recovering state highlights error ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_RecoveringPlc_HighlightsState()
|
||||
{
|
||||
var plc = MakePlc("PLC-B", "recovering", lastBindError: "Address already in use", recoveryAttempts: 3);
|
||||
var status = MakeStatus([plc]);
|
||||
|
||||
string html = StatusHtmlRenderer.Render(status);
|
||||
|
||||
// State should be orange.
|
||||
html.ShouldContain("class=\"recovering\"");
|
||||
html.ShouldContain("Address already in use");
|
||||
html.ShouldContain("attempt 3");
|
||||
}
|
||||
|
||||
// ── 3. Page weight under 50 KB for 54 PLCs ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_PageWeightUnder50KB_For54Plcs()
|
||||
{
|
||||
const int plcCount = 54;
|
||||
|
||||
// Build 54 realistic PLC rows with 2 clients each.
|
||||
var plcs = new List<PlcStatus>(plcCount);
|
||||
for (int i = 0; i < plcCount; i++)
|
||||
{
|
||||
var clients = new List<ClientSnapshot>
|
||||
{
|
||||
new ClientSnapshot($"10.0.0.{i + 1}:49123", DateTimeOffset.UtcNow, 42),
|
||||
new ClientSnapshot($"10.0.0.{i + 1}:49124", DateTimeOffset.UtcNow, 17),
|
||||
};
|
||||
|
||||
plcs.Add(MakePlc(
|
||||
name: $"Line{i / 10 + 1}-Station{i % 10 + 1:D2}",
|
||||
state: i % 5 == 0 ? "recovering" : "bound",
|
||||
lastBindError: i % 5 == 0 ? "EADDRINUSE" : null,
|
||||
recoveryAttempts: i % 5 == 0 ? 2 : 0,
|
||||
clients: clients));
|
||||
}
|
||||
|
||||
var status = MakeStatus(plcs);
|
||||
|
||||
string html = StatusHtmlRenderer.Render(status);
|
||||
int byteCount = System.Text.Encoding.UTF8.GetByteCount(html);
|
||||
|
||||
// Assert ≤ 50 KB.
|
||||
byteCount.ShouldBeLessThanOrEqualTo(50 * 1024,
|
||||
$"Page weight {byteCount} bytes exceeds 50 KB limit for {plcCount} PLCs");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user