diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs new file mode 100644 index 000000000..84d861b46 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs @@ -0,0 +1,77 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters; + +namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters; + +/// +/// Gitea #812 — pins the Client CLI/UI endpoint-URL rewrite () case by case, +/// reusing the #806 rewrite cases of OpcUaClientEndpointRewriteTests (Driver.OpcUaClient.Tests). These were +/// written and run green against Client.Shared's own pre-#812 copy of the rule, BEFORE it was swapped for the shared +/// OpcUaEndpointUrlRewriter, so they are the "behaviour stays identical" proof for the swap. +/// +public sealed class EndpointSelectorRewriteParityTests +{ + private static string Select(string? advertised, string configured) + { + var ep = new EndpointDescription + { + EndpointUrl = advertised, SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None, + }; + return EndpointSelector.SelectBest([ep], configured, MessageSecurityMode.None).EndpointUrl!; + } + + [Theory] + // #806 cases (exact strings shared with the driver's rewriter tests). + [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840/OtOpcUa", "opc.tcp://10.1.2.3:54840/OtOpcUa")] + [InlineData("opc.tcp://otopcua-sdk1-browser-nat-parity:50000/", "opc.tcp://127.0.0.1:32772", "opc.tcp://127.0.0.1:32772/")] + [InlineData("opc.tcp://plc-a:4840/Server/Ua", "opc.tcp://gateway:6000/", "opc.tcp://gateway:6000/Server/Ua")] + [InlineData("opc.tcp://127.0.0.1:4840/OtOpcUa", "opc.tcp://127.0.0.1:61000/OtOpcUa", "opc.tcp://127.0.0.1:61000/OtOpcUa")] + [InlineData("opc.tcp://plc-a:4840/Server", "opc.tcp://plc-a:4840/Server", "opc.tcp://plc-a:4840/Server")] + [InlineData("opc.tcp://internal-host:62541/Server", "opc.tcp://gateway/Server", "opc.tcp://gateway:62541/Server")] + [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fd00::1]:54840/OtOpcUa", "opc.tcp://[fd00::1]:54840/OtOpcUa")] + [InlineData("opc.tcp://[fd00::7]:4840/Ua", "opc.tcp://gateway:6000", "opc.tcp://gateway:6000/Ua")] + [InlineData("https://srv:443/ua", "opc.tcp://srv:4840/ua", "https://srv:443/ua")] + // The #813 NAT fixture's shape: private .invalid host AND a different internal port. + [InlineData("opc.tcp://otopcua-opc-plc-nat-private.invalid:50000", "opc.tcp://10.100.0.35:50010", "opc.tcp://10.100.0.35:50010/")] + // Client.Shared-specific: a no-op rewrite returns the advertised string VERBATIM (no trailing slash added) … + [InlineData("opc.tcp://server:4840", "opc.tcp://server:4840", "opc.tcp://server:4840")] + // … and a case-only host difference is not a rewrite (DNS is case-insensitive; the advertised spelling stays). + [InlineData("opc.tcp://PLC-A:4840/Server", "OPC.TCP://plc-a:4840/", "opc.tcp://PLC-A:4840/Server")] + [InlineData("opc.tcp://plc-a:4840/Server", "opc.tcp://PLC-A:4840/", "opc.tcp://plc-a:4840/Server")] + // A real rewrite keeps the advertised path's percent-escaping intact. + [InlineData("opc.tcp://a:4840/x%20y?q=1", "opc.tcp://b:1/", "opc.tcp://b:1/x%20y?q=1")] + // An IPv6 zone index on the configured host is dropped (UriBuilder behaviour, same in both copies). + [InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1]:4841/OtOpcUa")] + public void SelectBest_rewrites_exactly_as_before(string advertised, string configured, string expected) + { + Select(advertised, configured).ShouldBe(expected); + } + + /// + /// Client.Shared fails LOUDLY on an unusable URL, where the driver/browser rewriter falls back to the configured + /// URL. The CLI/UI is an interactive diagnostic tool talking to whatever the operator typed, so a server + /// advertising garbage is reported rather than papered over. Kept as call-site validation in #812. + /// + [Fact] + public void SelectBest_throws_on_an_endpoint_without_a_url() + { + Should.Throw(() => Select(null, "opc.tcp://srv:4840/ua")) + .Message.ShouldContain("no URL"); + } + + [Theory] + [InlineData("")] + [InlineData("not a url")] + public void SelectBest_throws_on_an_unparseable_advertised_url(string advertised) + { + Should.Throw(() => Select(advertised, "opc.tcp://srv:4840/ua")); + } + + [Fact] + public void SelectBest_throws_on_an_unparseable_configured_url() + { + Should.Throw(() => Select("opc.tcp://a:4840/x", "not a url")); + } +}