// Unit tests for TwinCATDriverProbe — the degrade-guarded ADS ReadState Test-Connect probe. // // Coverage here is the set of outcomes that are *deterministic on a CI/dev box with no TwinCAT // runtime*: invalid JSON, missing host/port, an unreachable TCP target, and — the crux — the // DEGRADE path. On this box there is no AMS router, so once the TCP preflight succeeds against a // reachable listener the managed ADS client cannot initialise (it throws a "Check for a running // TwinCAT router instance!" server exception, NOT an ADS device rejection). The probe MUST treat // that as "TCP reachability only" (Ok=true) so it never regresses below the old TCP-only probe. // // The two paths that REQUIRE a live ADS target are LIVE-VERIFY DEFERRED (no TwinCAT runtime on the // rig): (a) the happy "ADS state: Run/Config/Stop" path (Ok=true with the AdsState name), and // (b) the route/auth-reject RED path (a reachable router that refuses the route, surfaced as an // AdsErrorException carrying a TargetPortNotFound / route AdsErrorCode → Ok=false with the // "check the target's ADS route table" message). Those are exercised against a real TwinCAT XAR / // remote runtime, out of scope for this unit suite. using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; [Trait("Category", "Unit")] public sealed class TwinCATDriverProbeTests { private static readonly TwinCATDriverProbe 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 // ------------------------------------------------------------------------- /// 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 first device with a malformed (non-ads://) host address returns Ok=false with a /// "no host/port" message because returns null. /// [Fact] public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage() { var result = await Probe.ProbeAsync( "{\"devices\":[{\"hostAddress\":\"not-an-ads-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 preflight fails first // ------------------------------------------------------------------------- /// /// A closed port causes the TCP preflight ConnectAsync to throw /// ; the probe returns Ok=false with "Connect failed". /// The port is reserved then released so the OS refuses the connection immediately. /// [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(); // 127.0.0.1 as the first four AMS-Net-ID octets → the probe's TCP target is 127.0.0.1. var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ads://127.0.0.1.1.1:{port}\"}}]}}"; using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(5)); var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(3), cts.Token); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); // Either a fast SocketException ("Connect failed") or, if the OS stalls, a timeout. var lower = result.Message!.ToLowerInvariant(); (lower.Contains("connect failed") || lower.Contains("timed out")).ShouldBeTrue( $"Expected 'Connect failed' or 'timed out' but got: {result.Message}"); result.Latency.ShouldBeNull(); } /// /// A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) with a short /// cancellation window times out (or fails fast on EHOSTUNREACH) at the TCP preflight. /// [Fact] public async Task BlackHoleTarget_Returns_OkFalse_WithTimedOutOrConnectFailed() { using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(1)); var configJson = "{\"devices\":[{\"hostAddress\":\"ads://192.0.2.1.1.1:851\"}]}"; var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(1), cts.Token); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); var lower = result.Message!.ToLowerInvariant(); (lower.Contains("timed out") || lower.Contains("connect failed")).ShouldBeTrue( $"Expected 'timed out' or 'Connect failed' but got: {result.Message}"); result.Latency.ShouldBeNull(); } // ------------------------------------------------------------------------- // 4. DEGRADE path — TCP reachable, but the ADS handshake can't be attempted here // ------------------------------------------------------------------------- /// /// The crux. A reachable TCP listener satisfies the preflight, but on this box (no TwinCAT /// AMS router) the managed ADS client cannot establish a session — AdsClient.Connect /// throws an environment/router exception (NOT an ADS device rejection). The probe MUST /// DEGRADE: Ok=true with the "TCP reachability only" note, never a false RED. This /// guarantees the new probe never reports worse than the old TCP-only probe on a host with /// no ADS runtime. /// [Fact] public async Task ReachableTcp_NoAdsRuntime_Degrades_To_OkTrue_WithTcpOnlyNote() { // Stand up a real TCP listener and accept (then drop) connections so the preflight // ConnectAsync succeeds. We never speak ADS — the ADS handshake is what must degrade. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var acceptCts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); var acceptLoop = Task.Run(async () => { try { while (true) { var client = await listener.AcceptTcpClientAsync(acceptCts.Token); // Hold the socket briefly so the preflight sees a clean accept, then close. _ = Task.Run(async () => { await Task.Delay(200); client.Dispose(); }); } } catch (OperationCanceledException) { /* accept cancelled — expected on teardown */ } catch (ObjectDisposedException) { /* listener stopped — expected on teardown */ } catch (SocketException) { /* listener stopped — expected on teardown */ } }); try { var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ads://127.0.0.1.1.1:{port}\"}}]}}"; using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(10)); var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(3), cts.Token); // DEGRADE guard: must be Ok=true (never worse than TCP-only), with the explicit note // and a latency. If on some box AdsClient instead threw synchronously at construction, // the probe still degrades the same way — this assertion holds either way. result.Ok.ShouldBeTrue( $"Degrade guard violated — probe returned a worse-than-TCP result: {result.Message}"); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("ADS handshake unavailable on this host"); result.Message!.ShouldContain("TCP reachability only"); result.Latency.ShouldNotBeNull(); } finally { await acceptCts.CancelAsync(); listener.Stop(); try { await acceptLoop; } catch { /* ignore teardown races */ } } } }