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;
///
/// 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.
///
[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("");
body.ShouldContain("");
}
// ── 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();
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
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