Files
lmxopcua/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorRewriteParityTests.cs
T
Joseph Doherty 8e8408a9ca fix(opcuaclient): keep the configured IPv6 zone index through the endpoint rewrite (#819)
.NET's Uri parses a zone ([fe80::1%eth0]) but drops it from Host, Authority,
AbsoluteUri and ToString(); only DnsSafeHost/IdnHost carry it. The shared
OpcUaEndpointUrlRewriter rebuilt the URL through UriBuilder, so the zone was
lost and link-local endpoints were unroutable. The SDK dials
new Uri(EndpointUrl).IdnHost (TcpByteTransport -> DnsEndPoint), which keeps
it, so splicing the zone back into the string is enough. Kept verbatim
(%eth0, %4, or RFC 6874 %25eth0) so the session dials exactly what
GetEndpoints reached. Applies to the driver, the AdminUI browser and the
Client CLI/UI (all share the rewriter).
2026-11-11 01:32:38 -05:00

80 lines
4.7 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
/// <summary>
/// Gitea #812 — pins the Client CLI/UI endpoint-URL rewrite (<see cref="EndpointSelector.SelectBest"/>) case by case,
/// reusing the #806 rewrite cases of <c>OpcUaClientEndpointRewriteTests</c> (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
/// <c>OpcUaEndpointUrlRewriter</c>, so they are the "behaviour stays identical" proof for the swap.
/// </summary>
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")]
// #819: an IPv6 zone index on the configured host is KEPT, verbatim (it used to be dropped — UriBuilder behaviour,
// preserved deliberately through #812 — leaving link-local endpoints unroutable).
[InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%25eth0]:4841", "opc.tcp://[fe80::1%25eth0]:4841/OtOpcUa")]
[InlineData("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fe80::1%eth0]:4841", "opc.tcp://[fe80::1%eth0]:4841/OtOpcUa")]
public void SelectBest_rewrites_exactly_as_before(string advertised, string configured, string expected)
{
Select(advertised, configured).ShouldBe(expected);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void SelectBest_throws_on_an_endpoint_without_a_url()
{
Should.Throw<InvalidOperationException>(() => 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<UriFormatException>(() => Select(advertised, "opc.tcp://srv:4840/ua"));
}
[Fact]
public void SelectBest_throws_on_an_unparseable_configured_url()
{
Should.Throw<UriFormatException>(() => Select("opc.tcp://a:4840/x", "not a url"));
}
}