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() }, 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 ───────────────────────────────────────── /// /// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE) /// with HTTP 405 Method Not Allowed. The design intentionally exposes only `GET /` /// and `GET /status.json`; this test guards against an accidental MapPost/Map* being /// added later. /// [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 { ["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(); // Register as singleton so StatusSnapshotBuilder can inject ProxyWorker directly. builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); 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 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 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 _q = new(); public System.Collections.Generic.IEnumerable Events => _q; public void Emit(Serilog.Events.LogEvent e) => _q.Enqueue(e); } }