test(client): pin Client.Shared EndpointSelector rewrite behaviour before unifying it (#812)

Reuses the #806 rewrite cases plus the Client.Shared-specific edges (verbatim
no-op, case-only host difference, escaped path, loud failure on unusable
URLs). Green against the pre-#812 copy: 343/343.
This commit is contained in:
Joseph Doherty
2026-11-10 22:44:09 -05:00
parent 308b1b3b56
commit 90491aa8b5
@@ -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;
/// <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")]
// 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);
}
/// <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"));
}
}