Files
wwtools/mbproxy/tests/Mbproxy.Tests/Admin/AdminEndpointTests.cs
T
Joseph Doherty e719dd51c1 mbproxy: replace status page with a live SignalR web dashboard
The single auto-refreshing zero-JS status page gave operators a 25-column
wall and no way to drill into one connection. This adds a Bootstrap fleet
dashboard (filterable/sortable KPI table) and a per-PLC detail page with a
real-time debug view of raw PLC-side BCD vs. decoded client-side values,
streamed live over a SignalR feed. The debug view is fed by an on-demand
per-tag value capture, armed only while a detail page is open. All assets
(Bootstrap, SignalR client, fonts) are embedded so the UI works unchanged
on firewalled networks; GET /status.json is untouched for scrapers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:41:02 -04:00

569 lines
24 KiB
C#

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 / and GET /plc/{name} serve the embedded SPA shells ───────────
[Fact(Timeout = 5_000)]
public async Task Get_Root_ReturnsDashboardShell()
{
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("<!doctype html>");
body.ShouldContain("/assets/dashboard.js");
}
[Fact(Timeout = 5_000)]
public async Task Get_PlcDetailRoute_ReturnsDetailShell()
{
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}/plc/anything",
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("/assets/detail.js");
}
[Theory(Timeout = 5_000)]
[InlineData("bootstrap.min.css", "text/css")]
[InlineData("signalr.min.js", "text/javascript")]
[InlineData("dashboard.js", "text/javascript")]
[InlineData("theme.css", "text/css")]
[InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
{
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}/assets/{file}",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
response.Headers.CacheControl?.ToString().ShouldContain("immutable");
var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
bytes.Length.ShouldBeGreaterThan(0);
}
[Fact(Timeout = 5_000)]
public async Task Get_UnknownAsset_Returns404()
{
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}/assets/no-such-file.js",
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
// ── 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);
}
// ── non-GET methods rejected ─────────────────────────────────────────
/// <summary>
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
/// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
/// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
/// here.) Guards against an accidental MapPost/Map* being added later.
/// </summary>
[Theory(Timeout = 5_000)]
[InlineData("POST")]
[InlineData("PUT")]
[InlineData("DELETE")]
[InlineData("PATCH")]
public async Task NonGetMethod_AgainstAdminRoutes_Returns405(string method)
{
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);
await host.StartAsync(TestContext.Current.CancellationToken);
await WaitForAdminAsync(adminPort);
foreach (string path in new[] { "/", "/status.json" })
{
using var req = new HttpRequestMessage(new HttpMethod(method),
$"http://127.0.0.1:{adminPort}{path}");
using var resp = await HttpClient.SendAsync(req, TestContext.Current.CancellationToken);
resp.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed,
$"{method} {path} must be rejected (admin endpoint is read-only)");
}
}
// ── 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);
}
}