using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Unit tests for . Covers the offline-determinable failure /// paths (invalid JSON, missing host/port, unreachable closed port) plus the Phase-8 /// truthfulness behaviour: a TCP-reachable endpoint that is NOT a FOCAS CNC (a bare listener) /// must report Ok=false, because the probe now completes a real FocasWireClient /// session (initiate handshake + cnc_statinfo) rather than degrading to "TCP /// reachability only" when FWLIB is absent. /// /// Live-verify DEFERRED. The happy path (a real CNC completes the handshake + read → /// "FOCAS session OK") cannot run on this rig — there is no FANUC CNC available at unit-test /// time. It is verified against the live 31i-B at 10.201.31.5 (see the implementation /// plan's deploy/validate step). /// /// [Trait("Category", "Unit")] public sealed class FocasDriverProbeTests { private static readonly FocasDriverProbe 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 device / no host // ------------------------------------------------------------------------- /// A config with an empty Devices list returns Ok=false with a "no host/port" message. [Fact] public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage() { var result = await Probe.ProbeAsync( "{\"devices\":[]}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("no host/port"); result.Latency.ShouldBeNull(); } /// /// A config whose first device carries a malformed host address (not focas://) returns /// Ok=false with a "no host/port" message because /// returns null for the address. /// [Fact] public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage() { // "not-a-focas-url" is not a focas:// URL — TryParse returns null. var result = await Probe.ProbeAsync( "{\"devices\":[{\"hostAddress\":\"not-a-focas-url\"}]}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("no host/port"); result.Latency.ShouldBeNull(); } // ------------------------------------------------------------------------- // 3. Unreachable target — TCP connect fails at preflight // ------------------------------------------------------------------------- /// /// A closed port causes ConnectAsync to throw ; the /// probe must return Ok=false with a "Connect failed" message. The port is reserved then /// released so the OS refuses the connection immediately (no black-hole delay needed). /// [Fact] public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage() { // Reserve an ephemeral port then release it so the port is definitely closed. var reserved = new TcpListener(IPAddress.Loopback, 0); reserved.Start(); var port = ((IPEndPoint)reserved.LocalEndpoint).Port; reserved.Stop(); using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(5)); var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}"; var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(3), cts.Token); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("Connect failed"); result.Latency.ShouldBeNull(); } // ------------------------------------------------------------------------- // 4. TCP reachable but not a CNC — wire-session probe must say Ok=false (Phase 8) // ------------------------------------------------------------------------- /// /// Against an in-process that accepts the connection but speaks no /// FOCAS (drops each accepted socket), the TCP preflight succeeds but the Phase-2 wire /// session can't complete the initiate handshake + cnc_statinfo read. The probe MUST /// report Ok=false — a bare TCP listener is not a CNC. This is the Phase-8 fix: the /// old probe degraded such a listener to Ok=true "FWLIB absent, TCP reachability /// only", which made any TCP listener look HEALTHY. /// [Fact] public async Task TcpReachable_NotACnc_Returns_OkFalse() { // Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; // Keep accepting so the connect always completes; drop the accepted socket. _ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(15)); var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}"; var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(3), cts.Token); // A bare listener is not a CNC — the FOCAS session fails, so the probe is NOT ok. result.Ok.ShouldBeFalse( $"Expected Ok=false for a non-CNC TCP listener but got: {result.Message}"); result.Message.ShouldNotBeNull(); result.Latency.ShouldBeNull(); } finally { listener.Stop(); } } private static async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct) { try { while (!ct.IsCancellationRequested) { var socket = await listener.AcceptSocketAsync(ct); // Drop the connection immediately — we only need the TCP handshake to complete. socket.Dispose(); } } catch { // Listener stopped / token cancelled — expected at test teardown. } } }