using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Tests for . Each test spins up a real /// on 127.0.0.1 port 0 (OS-assigned) so the port is /// guaranteed available. Server-side behaviour is driven by a background task so /// the accept is always awaited before the probe fires. /// [Trait("Category", "Unit")] public sealed class ModbusDriverProbeTests { private readonly ModbusDriverProbe _probe = new(); private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3); // ── helpers ────────────────────────────────────────────────────────────── /// Allocates a TcpListener on an OS-assigned port and returns it started. private static TcpListener StartListener() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); return l; } /// Returns the port assigned to . private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port; /// /// Builds a config JSON for 127.0.0.1 at with optional unitId. /// private static string Config(int port, byte unitId = 1) => $@"{{""host"":""127.0.0.1"",""port"":{port},""unitId"":{unitId}}}"; /// /// Writes a well-formed MBAP FC03 response to , echoing the /// transaction-id from the request's first two bytes. Reads and discards the full /// request first so the client's send doesn't block. /// private static async Task WriteHappyFc03ResponseAsync(NetworkStream stream, CancellationToken ct) { // Read the MBAP header (7 bytes) + PDU (5 bytes for FC03 qty=1) = 12 bytes total. var req = new byte[12]; var read = 0; while (read < req.Length) { var n = await stream.ReadAsync(req.AsMemory(read), ct); if (n == 0) return; // client closed early read += n; } // Echo TxId from request bytes [0..1]. var txHi = req[0]; var txLo = req[1]; var unitId = req[6]; // Response PDU: FC=0x03, ByteCount=2, Data=0x00 0x00 var pdu = new byte[] { 0x03, 0x02, 0x00, 0x00 }; // MBAP: TxId(2) + Proto=0(2) + Len(2) + UnitId(1) + PDU // Length field = unitId(1) + pdu.Length var respLen = (ushort)(1 + pdu.Length); var resp = new byte[7 + pdu.Length]; resp[0] = txHi; resp[1] = txLo; // resp[2..3] = 0 (protocol id) resp[4] = (byte)(respLen >> 8); resp[5] = (byte)(respLen & 0xFF); resp[6] = unitId; Buffer.BlockCopy(pdu, 0, resp, 7, pdu.Length); await stream.WriteAsync(resp.AsMemory(), ct); await stream.FlushAsync(ct); } /// /// Writes a Modbus exception PDU response (function 0x83 + exception code 0x02) /// echoing the TxId from the request. /// private static async Task WriteExceptionPduResponseAsync(NetworkStream stream, CancellationToken ct) { // Read request var req = new byte[12]; var read = 0; while (read < req.Length) { var n = await stream.ReadAsync(req.AsMemory(read), ct); if (n == 0) return; read += n; } var txHi = req[0]; var txLo = req[1]; var unitId = req[6]; // Exception PDU: error function = 0x83 (0x03 | 0x80), exception code = 0x02 var pdu = new byte[] { 0x83, 0x02 }; var respLen = (ushort)(1 + pdu.Length); var resp = new byte[7 + pdu.Length]; resp[0] = txHi; resp[1] = txLo; resp[4] = (byte)(respLen >> 8); resp[5] = (byte)(respLen & 0xFF); resp[6] = unitId; Buffer.BlockCopy(pdu, 0, resp, 7, pdu.Length); await stream.WriteAsync(resp.AsMemory(), ct); await stream.FlushAsync(ct); } // ── Test 1: invalid JSON ────────────────────────────────────────────────── /// Invalid config JSON returns Ok=false with a message containing "invalid". [Fact] public async Task InvalidJson_returns_false_with_invalid_message() { var result = await _probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message.ShouldContain("invalid", Case.Insensitive); } // ── Test 2: no host/port ───────────────────────────────────────────────── /// Config with empty host returns Ok=false with a "no host/port" message. [Fact] public async Task Config_with_no_host_returns_false_with_no_host_port_message() { // host defaults to "127.0.0.1" in ModbusDriverOptions, but port=0 is treated as invalid. var result = await _probe.ProbeAsync(@"{""host"":"""",""port"":0}", QuickTimeout, CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message.ShouldContain("no host/port", Case.Insensitive); } // ── Test 3: unreachable (closed port) ──────────────────────────────────── /// A probe against a closed port returns Ok=false (Connect failed / refused). [Fact] public async Task Unreachable_closed_port_returns_false() { // Grab an ephemeral port then immediately stop the listener so nothing is listening. var listener = StartListener(); var port = ListenerPort(listener); listener.Stop(); var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None); result.Ok.ShouldBeFalse(); result.Latency.ShouldBeNull(); } // ── Test 4: TCP accepts then immediately closes (no Modbus reply) ───────── /// /// When the server accepts the TCP connection and then closes it without sending any /// Modbus bytes, ProbeAsync returns Ok=false with a "handshake failed" message. /// [Fact] public async Task Tcp_connect_then_immediate_close_returns_handshake_failed() { using var listener = StartListener(); var port = ListenerPort(listener); using var cts = new CancellationTokenSource(QuickTimeout); // Background: accept the TCP client and immediately close it without replying. var serverTask = Task.Run(async () => { var client = await listener.AcceptTcpClientAsync(cts.Token); client.Close(); // close without sending any bytes }, cts.Token); var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None); // The server task should have completed (or close enough). await serverTask.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message.ShouldContain("handshake failed", Case.Insensitive); result.Latency.ShouldBeNull(); } // ── Test 5: canned MBAP happy path ─────────────────────────────────────── /// /// A listener that accepts, reads the FC03 request, and replies with a valid MBAP /// FC03 response causes ProbeAsync to return Ok=true with message "Modbus FC03 OK". /// [Fact] public async Task Happy_path_returns_ok_true_with_FC03_OK_message() { using var listener = StartListener(); var port = ListenerPort(listener); using var cts = new CancellationTokenSource(QuickTimeout); var serverTask = Task.Run(async () => { var client = await listener.AcceptTcpClientAsync(cts.Token); await using var stream = client.GetStream(); await WriteHappyFc03ResponseAsync(stream, cts.Token); }, cts.Token); var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None); await serverTask.WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); result.Ok.ShouldBeTrue(); result.Message.ShouldBe("Modbus FC03 OK"); result.Latency.ShouldNotBeNull(); result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); } // ── Test 6: Modbus exception PDU ───────────────────────────────────────── /// /// When the server replies with a Modbus exception PDU (function 0x83 + exception code), /// the probe still returns Ok=true because a real Modbus device answered. /// [Fact] public async Task Exception_pdu_response_returns_ok_true_with_exception_pdu_message() { using var listener = StartListener(); var port = ListenerPort(listener); using var cts = new CancellationTokenSource(QuickTimeout); var serverTask = Task.Run(async () => { var client = await listener.AcceptTcpClientAsync(cts.Token); await using var stream = client.GetStream(); await WriteExceptionPduResponseAsync(stream, cts.Token); }, cts.Token); var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None); await serverTask.WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); result.Ok.ShouldBeTrue(); result.Message.ShouldNotBeNull(); result.Message.ShouldContain("exception PDU", Case.Insensitive); result.Latency.ShouldNotBeNull(); result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); } }