using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// The per-driver Transport switch: config parsing, driver factory selection and the probe. [Trait("Category", "Unit")] public sealed class AbCipTransportSwitchTests { private const string Device = """{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}"""; private static string Config(string? transportJson) => $$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}"""; /// /// Transport values select the factory trio; absent parses to null ("use the default") and the driver /// resolves that to Native (Task 22). An explicit Libplctag still wins. /// [Theory] [InlineData("\"Native\"", AbCipTransport.Native)] [InlineData("\"native\"", AbCipTransport.Native)] [InlineData("\"Libplctag\"", AbCipTransport.Libplctag)] [InlineData(null, AbCipTransport.Native)] public void Config_transport_selects_the_factory(string? json, AbCipTransport expected) { var parsed = AbCipDriverFactoryExtensions.ParseOptions("ab1", Config(json)); if (json is null) parsed.Transport.ShouldBeNull(); else parsed.Transport.ShouldBe(expected); var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config(json)); driver.EffectiveTransport.ShouldBe(expected); if (expected == AbCipTransport.Native) driver.TagFactory.ShouldBeOfType(); else driver.TagFactory.ShouldBeOfType(); } /// The factory trio matches the transport for all three factory kinds. [Fact] public void Factories_For_returns_a_matching_trio() { var n = AbCipTransportFactories.For(AbCipTransport.Native); n.Tags.ShouldBeOfType(); n.Enumerators.ShouldBeOfType(); n.Templates.ShouldBeOfType(); var l = AbCipTransportFactories.For(AbCipTransport.Libplctag); l.Tags.ShouldBeOfType(); l.Enumerators.ShouldBeOfType(); l.Templates.ShouldBeOfType(); } /// A bad value fails the way an existing bad enum field (PlcFamily) does. [Fact] public void Bad_transport_fails_like_a_bad_PlcFamily() { var bad = Should.Throw( () => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\""))); bad.Message.ShouldContain("unknown Transport 'Bogus'"); var existing = Should.Throw(() => AbCipDriverFactoryExtensions.ParseOptions( "ab1", """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Bogus"}]}""")); existing.GetType().ShouldBe(bad.GetType()); existing.Message.ShouldContain("unknown PlcFamily 'Bogus'"); // #574: a device-level field must not be reported as a tag named 'device'. existing.Message.ShouldNotContain("tag '"); existing.Message.ShouldStartWith("AB CIP config in 'ab1' has unknown PlcFamily 'Bogus'"); } /// /// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review /// r1 M2): Enum.TryParse alone accepts "7". A defined numeric ("1") still parses, as before. /// [Fact] public void An_undefined_numeric_transport_is_rejected() { Should.Throw( () => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\""))) .Message.ShouldContain("unknown Transport '7'"); AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbCipTransport.Native); } /// A bad driver-level value names the driver config, not a "tag" (Task 22 review r1 M3). [Fact] public void A_bad_transport_names_the_driver_config() { var ex = Should.Throw( () => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\""))); ex.Message.ShouldStartWith("AB CIP config in 'ab1' has unknown Transport 'Bogus'"); } /// An explicitly injected factory wins over the option. [Fact] public void Injected_factory_wins_over_the_option() { var fake = new FakeAbCipTagFactory(); var driver = new AbCipDriver(new AbCipDriverOptions { Transport = AbCipTransport.Native }, "ab1", fake); driver.TagFactory.ShouldBeSameAs(fake); } /// /// Reinit does NOT swap transports (by design): the factories are constructor-held, so a changed /// transport in the reinit JSON is inert. A deploy recreates the instance instead (#516). /// [Fact] public async Task Reinit_with_a_changed_transport_keeps_the_constructed_transport() { var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config("\"Libplctag\"")); var before = driver.TagFactory; await driver.ReinitializeAsync(Config("\"Native\""), TestContext.Current.CancellationToken); driver.TagFactory.ShouldBeSameAs(before); driver.TagFactory.ShouldBeOfType(); } /// /// Positive control for the libplctag path against a listener that accepts TCP and never speaks CIP. /// libplctag reports the stall two ways that race on the wall clock: the probe's own cancellation /// ("Probe timed out after Ns") or libplctag's internal timeout, which has the same duration and /// surfaces as the ErrorTimeout status. Which fires first depends on scheduler load, so both are /// the libplctag outcome; the native path never produces either (it fails at once with "Connection lost"). /// private static void ShouldBeLibplctagTimeout(string? message) { message.ShouldNotBeNull(); (message.Contains("timed out", StringComparison.OrdinalIgnoreCase) || message.Contains("ErrorTimeout", StringComparison.Ordinal)) .ShouldBeTrue($"expected the libplctag stall outcome (timed out / ErrorTimeout) but was: {message}"); } /// /// The probe's Phase 1 TCP preflight rejects a refused port before any runtime exists, so the native /// path is proven against a listener that accepts TCP and hangs up: only a runtime that really opens /// an EtherNet/IP session reports the native transport's own failure text. /// [Fact] public async Task Probe_with_Native_uses_the_native_path() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; _ = Task.Run(async () => { while (true) { try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } catch { return; } } }, TestContext.Current.CancellationToken); string Cfg(string t) => $$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}"""; var probe = new AbCipDriverProbe(); var native = await probe.ProbeAsync(Cfg("Native"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); var lib = await probe.ProbeAsync(Cfg("Libplctag"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); listener.Stop(); native.Ok.ShouldBeFalse(); native.Message.ShouldNotBeNull(); native.Message!.ShouldContain("CIP handshake failed: Connection lost"); lib.Message!.ShouldNotContain("Connection lost"); // Positive (#574): the libplctag path ran and reported its own outcome (the bounded probe timeout), // not merely "something other than the native text". ShouldBeLibplctagTimeout(lib.Message); } /// /// The probe resolves an ABSENT transport through the same resolver as the driver: with no key it takes /// the native path (native failure text), and an explicit Libplctag on the same listener does not. /// [Fact] public async Task Probe_with_no_transport_resolves_like_the_driver_to_native() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; _ = Task.Run(async () => { while (true) { try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } catch { return; } } }, TestContext.Current.CancellationToken); var device = $$"""{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}"""; var probe = new AbCipDriverProbe(); var absent = await probe.ProbeAsync($$"""{"devices":[{{device}}]}""", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); var lib = await probe.ProbeAsync($$"""{"transport":"Libplctag","devices":[{{device}}]}""", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); listener.Stop(); AbCipTransportResolver.Resolve(AbCipDriverFactoryExtensions.ParseOptions("p", $$"""{"devices":[{{device}}]}""")) .ShouldBe(AbCipTransport.Native); absent.Ok.ShouldBeFalse(); absent.Message!.ShouldContain("CIP handshake failed: Connection lost"); lib.Message!.ShouldNotContain("Connection lost"); // Positive (#574): the libplctag path ran and reported its own outcome (the bounded probe timeout), // not merely "something other than the native text". ShouldBeLibplctagTimeout(lib.Message); } /// The probe deserialises with its own string-enum converter; a bad transport is a config error. [Fact] public async Task Probe_with_a_bad_transport_reports_invalid_config() { var r = await new AbCipDriverProbe().ProbeAsync( Config("\"Bogus\""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); r.Ok.ShouldBeFalse(); r.Message!.ShouldContain("Config JSON is invalid"); } /// /// Final review I1 precedence: explicit key > node default > compiled Native. The pinned rows are /// the falsification rows — a resolver that ignores the node default returns Native for them. /// [Theory] [InlineData(null, AbCipTransport.Libplctag, AbCipTransport.Libplctag)] [InlineData(null, AbCipTransport.Native, AbCipTransport.Native)] [InlineData(AbCipTransport.Native, AbCipTransport.Libplctag, AbCipTransport.Native)] [InlineData(AbCipTransport.Libplctag, AbCipTransport.Native, AbCipTransport.Libplctag)] public void Explicit_key_beats_node_default_beats_compiled_default( AbCipTransport? configured, AbCipTransport nodeDefault, AbCipTransport expected) { AbCipTransportResolver.Resolve(configured, nodeDefault).ShouldBe(expected); var json = configured is null ? Config(null) : Config($"\"{configured}\""); AbCipDriverFactoryExtensions.CreateInstance("ab1", json, loggerFactory: null, defaultTransport: nodeDefault) .EffectiveTransport.ShouldBe(expected); } /// The init-log rendering distinguishes the compiled default from a node pin. [Fact] public void Describe_distinguishes_compiled_default_node_default_and_configured() { AbCipTransportResolver.Describe(null).ShouldBe("Native (default)"); AbCipTransportResolver.Describe(null, AbCipTransport.Libplctag).ShouldBe("Libplctag (node default)"); AbCipTransportResolver.Describe(AbCipTransport.Native, AbCipTransport.Libplctag).ShouldBe("Native (configured)"); } [Theory] [InlineData("Native", true)] [InlineData("libplctag", true)] [InlineData(" Libplctag ", true)] [InlineData("1", false)] [InlineData("Native,Libplctag", false)] [InlineData("", false)] [InlineData("Bogus", false)] public void TryParse_accepts_names_only(string raw, bool ok) => AbCipTransportResolver.TryParse(raw, out _).ShouldBe(ok); /// /// The probe honours its node default for an absent key: pinned to Libplctag it does NOT produce the /// native failure text against the same listener the unpinned probe does (falsification control). /// [Fact] public async Task Probe_with_no_transport_uses_the_node_default() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; _ = Task.Run(async () => { while (true) { try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } catch { return; } } }, TestContext.Current.CancellationToken); var cfg = $$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}"""; var pinned = await new AbCipDriverProbe(defaultTransport: AbCipTransport.Libplctag) .ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); var unpinned = await new AbCipDriverProbe() .ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); listener.Stop(); unpinned.Message!.ShouldContain("CIP handshake failed: Connection lost"); pinned.Ok.ShouldBeFalse(); pinned.Message!.ShouldNotContain("Connection lost"); ShouldBeLibplctagTimeout(pinned.Message); // positive: libplctag's own outcome (#574) } /// Gitea #572: browsePageDelayMs parses (default 5 ms, 0 allowed, negative clamps to 0). [Theory] [InlineData(null, 5)] [InlineData("0", 0)] [InlineData("25", 25)] [InlineData("-3", 0)] public void Config_browsePageDelayMs_parses(string? json, int expectedMs) { var config = $$"""{{{(json is null ? "" : $"\"browsePageDelayMs\":{json},")}}"devices":[{{Device}}]}"""; AbCipDriverFactoryExtensions.ParseOptions("ab1", config).BrowsePageDelay .ShouldBe(TimeSpan.FromMilliseconds(expectedMs)); } }