d52a3b1c64
Client.Shared now project-references Driver.OpcUaClient.Contracts (Core.Abstractions only, no NuGet/SDK; pinned by ContractsIsDependencyLightTests) and delegates the host/port rewrite to OpcUaEndpointUrlRewriter, deleting its own copy. The two copies differed; reconciled toward the client so its pinned behaviour is unchanged (EndpointSelectorRewriteParityTests, written green against the old copy): - no-op rewrite (same host case-insensitively + same port) now returns the advertised string verbatim in the shared rewriter too (it re-serialised: added a trailing slash and case-folded a case-only host difference); - a real rewrite serialises via AbsoluteUri, not Uri.ToString(), which un-escaped the advertised path (/x%20y became /x y) in the driver + browser; - unusable URLs: the rewriter still falls back to the configured URL (driver/browser); the client keeps its loud failure as call-site validation (null -> InvalidOperation, unparseable -> UriFormatException).
154 lines
6.5 KiB
C#
154 lines
6.5 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #676 — the driver connects to the endpoint URL the server ADVERTISES in GetEndpoints. A server behind NAT,
|
|
/// a port map or a proxy advertises an address the client cannot reach (or one that bypasses the proxy), so the
|
|
/// driver must rewrite host AND port to the configured URL when the scheme matches — what the SDK's own
|
|
/// <c>CoreClientUtils.SelectEndpoint</c> does. Gitea #806 moved the rule to the shared
|
|
/// <see cref="OpcUaEndpointUrlRewriter"/> so the AdminUI browse picker applies the identical rule; these tests pin
|
|
/// the shared implementation and that the driver delegates to it.
|
|
/// </summary>
|
|
public sealed class OpcUaClientEndpointRewriteTests
|
|
{
|
|
[Fact]
|
|
public void Host_and_port_of_the_advertised_url_are_replaced_by_the_configured_ones()
|
|
{
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840/OtOpcUa")
|
|
.ShouldBe("opc.tcp://10.1.2.3:54840/OtOpcUa");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_806_docker_port_publish_shape_is_rewritten()
|
|
{
|
|
// The exact #806 fixture: opc-plc in a container advertises its container hostname + internal port; the
|
|
// operator reaches it through a published host port.
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://otopcua-sdk1-browser-nat-parity:50000/", "opc.tcp://127.0.0.1:32772")
|
|
.ShouldBe("opc.tcp://127.0.0.1:32772/");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_advertised_path_is_kept()
|
|
{
|
|
// The path is the server's to choose (several endpoints may share a host:port); only the address is ours.
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://plc-a:4840/Server/Ua", "opc.tcp://gateway:6000/")
|
|
.ShouldBe("opc.tcp://gateway:6000/Server/Ua");
|
|
}
|
|
|
|
[Fact]
|
|
public void Only_the_port_differs_still_rewrites()
|
|
{
|
|
// The in-process stall proxy shape: same host, the configured port is the proxy's.
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://127.0.0.1:4840/OtOpcUa", "opc.tcp://127.0.0.1:61000/OtOpcUa")
|
|
.ShouldBe("opc.tcp://127.0.0.1:61000/OtOpcUa");
|
|
}
|
|
|
|
[Fact]
|
|
public void Same_host_and_port_is_unchanged()
|
|
{
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://plc-a:4840/Server", "opc.tcp://plc-a:4840/Server")
|
|
.ShouldBe("opc.tcp://plc-a:4840/Server");
|
|
}
|
|
|
|
[Fact]
|
|
public void Host_matching_is_case_insensitive_so_a_case_only_difference_is_not_a_rewrite()
|
|
{
|
|
// #812: unified with the Client CLI/UI rule — nothing to rewrite, so the advertised string comes back verbatim
|
|
// (it used to be re-serialised with the configured spelling; DNS is case-insensitive, so the target is the same).
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://PLC-A:4840/Server", "OPC.TCP://plc-a:4840/")
|
|
.ShouldBe("opc.tcp://PLC-A:4840/Server");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_no_op_rewrite_returns_the_advertised_string_verbatim()
|
|
{
|
|
// #812: no trailing slash appended when nothing changes (the Client CLI/UI's long-standing behaviour).
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://server:4840", "opc.tcp://server:4840")
|
|
.ShouldBe("opc.tcp://server:4840");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_rewrite_keeps_the_advertised_path_escaping()
|
|
{
|
|
// #812: Uri.ToString() un-escapes ("/x%20y" became "/x y"); AbsoluteUri keeps the server's own escaping.
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://a:4840/x%20y?q=1", "opc.tcp://b:1/")
|
|
.ShouldBe("opc.tcp://b:1/x%20y?q=1");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_configured_url_without_a_port_keeps_the_advertised_port()
|
|
{
|
|
// Review of 3b88056f: Uri.Port is -1 for a port-less opc.tcp URL; copying it would drop the advertised port and
|
|
// the SDK would fall back to 4840.
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:62541/Server", "opc.tcp://gateway/Server")
|
|
.ShouldBe("opc.tcp://gateway:62541/Server");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_IPv6_configured_host_is_bracketed()
|
|
{
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fd00::1]:54840/OtOpcUa")
|
|
.ShouldBe("opc.tcp://[fd00::1]:54840/OtOpcUa");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_IPv6_advertised_host_is_replaced_by_a_configured_name()
|
|
{
|
|
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://[fd00::7]:4840/Ua", "opc.tcp://gateway:6000")
|
|
.ShouldBe("opc.tcp://gateway:6000/Ua");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_different_scheme_is_left_alone()
|
|
{
|
|
// An https endpoint advertised next to an opc.tcp one is a different transport, not a NAT artefact.
|
|
OpcUaEndpointUrlRewriter.Rewrite("https://srv:443/ua", "opc.tcp://srv:4840/ua")
|
|
.ShouldBe("https://srv:443/ua");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("not a url")]
|
|
public void An_unusable_advertised_url_falls_back_to_the_configured_one(string? advertised)
|
|
{
|
|
OpcUaEndpointUrlRewriter.Rewrite(advertised, "opc.tcp://srv:4840/ua").ShouldBe("opc.tcp://srv:4840/ua");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_driver_delegates_to_the_shared_rewriter()
|
|
{
|
|
OpcUaClientDriver.RewriteEndpointUrl("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840")
|
|
.ShouldBe(OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Shared_selection_filters_policy_and_mode_then_rewrites_the_match()
|
|
{
|
|
var advertised = new[]
|
|
{
|
|
new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://internal:50000/", SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
},
|
|
new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://internal:50000/", SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
},
|
|
};
|
|
|
|
var picked = OpcUaEndpointSelection.SelectMatching(
|
|
advertised, "opc.tcp://127.0.0.1:32772", OpcUaSecurityPolicy.Basic256Sha256, OpcUaSecurityMode.SignAndEncrypt);
|
|
|
|
picked.ShouldBeSameAs(advertised[1]);
|
|
picked.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
|
picked.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
|
picked.EndpointUrl.ShouldBe("opc.tcp://127.0.0.1:32772/");
|
|
}
|
|
}
|