Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs
T
Joseph Doherty ac1b33555a test(infra): port-NAT opc-plc fixture for the NAT browse tests; wire OPCUA_NAT_ENDPOINT into CI (#813)
Docker/nat/docker-compose.yml (stack /opt/otopcua-opcuaclient-nat on 10.100.0.35,
project: lmxopcua label): opc-plc 2.14.10 advertising --ph=otopcua-opc-plc-nat-private.invalid
on internal 50000, published on 50010, so every advertised URL is unreachable on host AND
port. Deployed; NatPublishedEndpointBrowseTests 3/3 against opc.tcp://10.100.0.35:50010
(and the secure case positive once, with the fixture cert temporarily trusted).

CI: integration-fixtures.sh starts a throwaway otopcua-ci-opcplc-nat (50110->50000) and
exports OPCUA_NAT_ENDPOINT; v2-e2e.yml points it at the shared stack. infra/README.md,
scripts/ci/README.md and the fixture README document it.
2026-11-10 22:53:01 -05:00

151 lines
7.8 KiB
C#

using System.Net;
using System.Net.Sockets;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests;
/// <summary>
/// Gitea #806 — real NAT / port-publish browse. The fixture is an opc-plc container whose hostname is private and
/// whose internal port differs from the published one, so its GetEndpoints advertise an address this machine
/// cannot reach:
/// <code>
/// # shared docker host (#813): compose in Driver.OpcUaClient.IntegrationTests/Docker/nat/
/// ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat &amp;&amp; docker compose up -d'
/// OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test --filter Fixture=opc-plc-nat
/// # or locally / in CI (scripts/ci/integration-fixtures.sh starts the same shape as otopcua-ci-opcplc-nat):
/// docker run -d --rm --name otopcua-806-nat --hostname otopcua-806-nat-parity -p 32781:50000 \
/// mcr.microsoft.com/iotedge/opc-plc:2.14.10 --pn=50000 --ph=otopcua-806-nat-parity.invalid --autoaccept --ut
/// OPCUA_NAT_ENDPOINT=opc.tcp://127.0.0.1:32781 dotnet test --filter Fixture=opc-plc-nat
/// </code>
/// Skipped unless <c>OPCUA_NAT_ENDPOINT</c> is set and accepts TCP. Every test first proves the premise (the
/// advertised URL is NOT the configured host:port, and its host does not resolve here), so a fixture that
/// advertises a reachable address cannot make these pass vacuously. Before the fix the positive cases failed with
/// <c>SocketException: nodename nor servname provided, or not known</c>.
/// </summary>
[Trait("Category", "Integration"), Trait("Fixture", "opc-plc-nat")]
public sealed class NatPublishedEndpointBrowseTests
{
private const string EnvVar = "OPCUA_NAT_ENDPOINT";
private static string? Endpoint => Environment.GetEnvironmentVariable(EnvVar) is { Length: > 0 } e ? e : null;
private static string Json(string endpoint, string policy = "None", string mode = "None") => $$"""
{
"EndpointUrl":"{{endpoint}}",
"SecurityPolicy":"{{policy}}","SecurityMode":"{{mode}}","AuthType":"Anonymous",
"SessionTimeout":"00:01:00","Timeout":"00:00:10","PerEndpointConnectTimeout":"00:00:10"
}
""";
private static string RequireFixture()
{
var endpoint = Endpoint;
if (endpoint is null) Assert.Skip($"{EnvVar} not set — no NAT/port-published opc-plc fixture.");
var uri = new Uri(endpoint);
try
{
using var tcp = new TcpClient();
if (!tcp.ConnectAsync(uri.Host, uri.Port).Wait(TimeSpan.FromSeconds(2)) || !tcp.Connected)
Assert.Skip($"{EnvVar}={endpoint} did not accept TCP within 2s.");
}
catch (Exception ex) when (ex is not Xunit.Sdk.SkipException)
{
Assert.Skip($"{EnvVar}={endpoint} unreachable: {ex.GetBaseException().Message}");
}
return endpoint;
}
/// <summary>The premise: the server advertises a host:port other than the configured one, and that host is unresolvable.</summary>
private static async Task AssertAdvertisesUnreachableAddressAsync(string endpoint, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa #806 premise",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration(),
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
DisableHiResClock = true,
};
using var discovery = await DiscoveryClient.CreateAsync(appConfig, new Uri(endpoint), DiagnosticsMasks.None, ct);
var endpoints = (await discovery.GetEndpointsAsync(default, ct)).ToList();
endpoints.ShouldNotBeEmpty();
var configured = new Uri(endpoint);
foreach (var advertised in endpoints.Select(e => new Uri(e.EndpointUrl!)))
{
(advertised.Host, advertised.Port).ShouldNotBe((configured.Host, configured.Port),
"fixture must advertise a different host:port, or this test proves nothing");
await Should.ThrowAsync<SocketException>(() => Dns.GetHostAddressesAsync(advertised.Host, ct),
$"advertised host '{advertised.Host}' must be unresolvable from here, or this test proves nothing");
}
}
[Fact]
public async Task Browse_through_a_published_port_succeeds_and_namespaced_ids_round_trip()
{
var endpoint = RequireFixture();
var ct = TestContext.Current.CancellationToken;
await AssertAdvertisesUnreachableAddressAsync(endpoint, ct);
await using var session = await new OpcUaClientDriverBrowser().OpenAsync(Json(endpoint), ct);
var roots = await session.RootAsync(ct);
roots.Count.ShouldBeGreaterThan(0);
// opc-plc's own nodes live in a non-zero namespace; the browse encodes them namespace-URI-stable (nsu=).
var folder = roots.FirstOrDefault(n => n.Kind == BrowseNodeKind.Folder && n.NodeId.StartsWith("nsu="));
folder.ShouldNotBeNull("expected an opc-plc folder in its own (nsu=) namespace under Objects");
var children = await session.ExpandAsync(folder.NodeId, ct);
children.Count.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Unreachable_configured_endpoint_still_fails_cleanly()
{
RequireFixture();
var ct = TestContext.Current.CancellationToken;
// A closed loopback port: the rewrite must not invent a reachable address; the open fails promptly.
var sw = System.Diagnostics.Stopwatch.StartNew();
await Should.ThrowAsync<Exception>(() => new OpcUaClientDriverBrowser().OpenAsync(Json("opc.tcp://127.0.0.1:1"), ct));
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(30));
}
[Fact]
public async Task Secure_browse_reaches_the_server_and_keeps_strict_certificate_validation()
{
var endpoint = RequireFixture();
var ct = TestContext.Current.CancellationToken;
await AssertAdvertisesUnreachableAddressAsync(endpoint, ct);
// Basic256Sha256/SignAndEncrypt through the published port. The browse PKI never auto-accepts, so unless the
// operator trusted this fixture's certificate in the browse PKI (then set OPCUA_NAT_SERVER_CERT_TRUSTED=1) the
// open must be refused on the certificate — which proves the session reached the SERVER (no DNS failure on the
// advertised host) and that the rewrite did not relax trust.
var trusted = Environment.GetEnvironmentVariable("OPCUA_NAT_SERVER_CERT_TRUSTED") == "1";
var json = Json(endpoint, "Basic256Sha256", "SignAndEncrypt");
if (trusted)
{
await using var session = await new OpcUaClientDriverBrowser().OpenAsync(json, ct);
(await session.RootAsync(ct)).Count.ShouldBeGreaterThan(0);
return;
}
var ex = await Should.ThrowAsync<Exception>(async () =>
{
await using var session = await new OpcUaClientDriverBrowser().OpenAsync(json, ct);
});
TestContext.Current.TestOutputHelper?.WriteLine($"secure open refused: {ex.GetType().Name}: {ex.Message}");
ex.ShouldNotBeOfType<SocketException>($"must not fail on DNS for the advertised host: {ex}");
var sre = ex as ServiceResultException ?? ex.InnerException as ServiceResultException;
sre.ShouldNotBeNull($"expected a certificate refusal, got {ex.GetType().Name}: {ex.Message}");
sre.StatusCode.ShouldBeOneOf(
StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed,
StatusCodes.BadCertificateChainIncomplete);
}
}