using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Unit tests for . Uses in-process /// instances on 127.0.0.1 to exercise all failure paths without a real PLC. /// The happy path (real S7 setup-communication exchange) is covered in live integration /// tests against a python-snap7 simulator. /// [Trait("Category", "Unit")] public sealed class S7DriverProbeTests { private static readonly S7DriverProbe Probe = new(); // ------------------------------------------------------------------------- // 1. Invalid JSON // ------------------------------------------------------------------------- /// Invalid JSON returns Ok=false with a message containing "invalid". [Fact] public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage() { var result = await Probe.ProbeAsync( "not-valid-json{{{", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ToLowerInvariant().ShouldContain("invalid"); result.Latency.ShouldBeNull(); } // ------------------------------------------------------------------------- // 2. Config with no host // ------------------------------------------------------------------------- /// Config that serialises to null host returns Ok=false with a "no host/port" message. [Fact] public async Task NoHost_Returns_OkFalse_WithNoHostPortMessage() { // Host defaults to "127.0.0.1" in S7DriverOptions, but an empty string // is what the spec's "no host" path covers. Provide an empty host explicitly. var result = await Probe.ProbeAsync( "{\"host\":\"\",\"port\":102}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("no host/port"); result.Latency.ShouldBeNull(); } // ------------------------------------------------------------------------- // 3. Unreachable / refused port // ------------------------------------------------------------------------- /// A closed port returns Ok=false with a "Connect failed" message. [Fact] public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage() { // Bind a listener, read the OS-assigned port, then stop it so the port // is guaranteed closed (no process listening) when the probe runs. var reserved = new TcpListener(IPAddress.Loopback, 0); reserved.Start(); var port = ((IPEndPoint)reserved.LocalEndpoint).Port; reserved.Stop(); var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}"; var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); // SocketException maps to "Connect failed: " or similar. result.Message!.ShouldContain("Connect failed"); result.Latency.ShouldBeNull(); } // ------------------------------------------------------------------------- // 4. TCP accepts then immediately closes (non-S7 server) // ------------------------------------------------------------------------- /// /// A listener that accepts the TCP connection and then immediately closes it without /// exchanging any bytes simulates a non-S7 server (wrong rack/slot, plain TCP echo, /// etc.). The probe must return Ok=false with a message containing "handshake failed". /// A short probe timeout (1 s) ensures the internal S7netplus socket-read timeout /// fires well within the test wall-clock budget of 10 s. /// [Fact] public async Task TcpAcceptsThenCloses_Returns_OkFalse_WithHandshakeFailedMessage() { // Use a generous outer CTS so the test itself never races with the probe timeout. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; // Accept one connection from the background and immediately close it — // no S7 bytes exchanged, so OpenAsync must fail. var serverTask = Task.Run(async () => { try { using var client = await listener.AcceptTcpClientAsync(cts.Token); // Immediately dispose → TCP RST/FIN; the S7 handshake receives nothing. } catch (OperationCanceledException) { /* test timed out */ } }, cts.Token); try { var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}"; // Pass a 1 s probe timeout so S7netplus's internal read-timeout fires quickly, // and the 10 s outer CTS does NOT cancel first — the OCE will be from the // internal handshakeCts, routing to the "handshake failed" branch. var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(1), cts.Token); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("handshake failed"); result.Latency.ShouldBeNull(); } finally { listener.Stop(); try { await serverTask; } catch { /* ignore */ } } } }