using System.Net; using System.Net.Sockets; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Unit tests for . Covers the three offline-determinable /// failure paths: invalid JSON, missing host/port, and an unreachable (TCP-level rejected) /// target. The happy path (real CIP EIP session + Ok=true) and the /// "controller reachable but probe tag not found" path require a live CIP controller or /// simulator and are covered in integration tests against the AB-CIP Docker fixture /// (10.100.0.35:44818). /// [Trait("Category", "Unit")] public sealed class AbCipDriverProbeTests { private static readonly AbCipDriverProbe 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 ab://) returns /// Ok=false with a "no host/port" message because /// returns null for the address. /// [Fact] public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage() { // "not-an-ab-url" is not an ab:// URL — TryParse returns null. var result = await Probe.ProbeAsync( "{\"devices\":[{\"hostAddress\":\"not-an-ab-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 Phase 1 // ------------------------------------------------------------------------- /// /// 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(); // Use a short timeout — we only need Phase 1 to fail fast. using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(5)); var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}"; var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(3), cts.Token); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); // Phase 1 SocketException → "Connect failed: " result.Message!.ShouldContain("Connect failed"); result.Latency.ShouldBeNull(); } /// /// A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) causes the /// probe to time out at Phase 1 when given a very short cancellation window. /// The probe must return Ok=false with a "timed out" message. /// [Fact] public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutMessage() { // 192.0.2.1 is TEST-NET-1 (RFC 5737) — reserved, non-routable, connection black-holes. // Use a 1-second cancellation so the test stays fast on any network. using var cts = CancellationTokenSource.CreateLinkedTokenSource( TestContext.Current.CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(1)); var configJson = "{\"devices\":[{\"hostAddress\":\"ab://192.0.2.1/1,0\"}]}"; var result = await Probe.ProbeAsync( configJson, TimeSpan.FromSeconds(1), cts.Token); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); // Either "timed out" (OperationCanceledException) or "Connect failed" (EHOSTUNREACH on // some network stacks) — both are acceptable "not reachable" outcomes. 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(); } // ------------------------------------------------------------------------- // 5. Reachable-vs-failed classification (offline — no controller needed) // ------------------------------------------------------------------------- /// /// The exception path classifies by the SAME rule as the status path: a controller that answered /// "tag does not exist" is reachable (the answer every Test Connect gets on a device with no /// authored tags); a session or transport failure is not. /// /// The status the transport threw on. /// Whether the controller should be reported reachable. [Theory] [InlineData(AbStatus.NotFound, true)] [InlineData(AbStatus.Timeout, false)] [InlineData(AbStatus.BadConnection, false)] [InlineData(AbStatus.BadReply, false)] [InlineData(AbStatus.Partial, false)] [InlineData(AbStatus.Abort, false)] public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(AbStatus status, bool expectedReachable) { var ex = new AbTransportException(status, status.ToString()); AbCipDriverProbe.IsReachableException(ex).ShouldBe(expectedReachable); AbCipDriverProbe.IsReachableStatus(status).ShouldBe(expectedReachable); } /// /// A message that names no status at all stays UNreachable rather than defaulting green — the /// conservative half, and the one an over-eager "treat anything we cannot parse as reachable" /// fix would break. /// [Fact] public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable() => AbCipDriverProbe.IsReachableException( new AbTransportException(AbStatus.BadConnection, "something the wrapper has never said")).ShouldBeFalse(); /// A fake runtime reporting NotFound is reachable ("probe tag not found"). [Fact] public async Task Injected_factory_NotFound_status_is_reported_reachable() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }, }; var result = await new AbCipDriverProbe(factory).ProbeAsync( $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); result.Ok.ShouldBeTrue(); result.Message!.ShouldContain("probe tag not found"); } }