From dce2528c688d6ab0ca4553e1608df3fe2f423ad8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:29:53 -0400 Subject: [PATCH] =?UTF-8?q?test(opcua):=20DualEndpointTests=20=E2=80=94=20?= =?UTF-8?q?real=20client=20reads=20peer=20URIs=20from=20Server.ServerArray?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DualEndpointTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs new file mode 100644 index 0000000..d1652dd --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ClientSession = Opc.Ua.Client.Session; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Source plan Task 60 — closes the audit gap. Boots two real +/// instances on loopback, each configured with the other's ApplicationUri in +/// . A real OPC UA client connects +/// to Node A, reads Server.ServerArray, and asserts both URIs are visible — the +/// warm-redundancy discovery contract clients depend on. +/// +public sealed class DualEndpointTests +{ + private const string NodeAUri = "urn:OtOpcUa.DualEndpoint.NodeA"; + private const string NodeBUri = "urn:OtOpcUa.DualEndpoint.NodeB"; + + [Fact] + public async Task Client_reads_both_ApplicationUris_from_NodeA_ServerArray() + { + var pkiRootA = Path.Combine(Path.GetTempPath(), $"otopcua-pki-a-{Guid.NewGuid():N}"); + var pkiRootB = Path.Combine(Path.GetTempPath(), $"otopcua-pki-b-{Guid.NewGuid():N}"); + var portA = AllocateFreePort(); + var portB = AllocateFreePort(); + + try + { + await using var nodeA = await StartNodeAsync(NodeAUri, portA, pkiRootA, peers: new[] { NodeBUri }); + await using var nodeB = await StartNodeAsync(NodeBUri, portB, pkiRootB, peers: new[] { NodeAUri }); + + var serverArray = await ReadServerArrayAsync($"opc.tcp://127.0.0.1:{portA}/OtOpcUa"); + serverArray.ShouldContain(NodeAUri); + serverArray.ShouldContain(NodeBUri); + } + finally + { + if (Directory.Exists(pkiRootA)) Directory.Delete(pkiRootA, recursive: true); + if (Directory.Exists(pkiRootB)) Directory.Delete(pkiRootB, recursive: true); + } + } + + private static async Task StartNodeAsync( + string applicationUri, int port, string pkiRoot, string[] peers) + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = applicationUri, + ApplicationUri = applicationUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, + AutoAcceptUntrustedClientCertificates = true, + PeerApplicationUris = peers, + }; + var server = new StandardServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, CancellationToken.None); + return host; + } + + private static async Task ReadServerArrayAsync(string endpointUrl) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.DualEndpointClient", + ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier(), + AutoAcceptUntrustedCertificates = true, + }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + // SDK 1.5.378 deprecates the no-arg ctors of CertificateValidator / DefaultSessionFactory + // and the non-telemetry overloads of SelectEndpointAsync. Inject a no-op telemetry context + // so the integration test (with TreatWarningsAsErrors=true) doesn't trip the CS0618 wall. + var telemetry = DefaultTelemetry.Create(static _ => { }); + await appConfig.ValidateAsync(ApplicationType.Client, CancellationToken.None); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, useSecurity: false, discoverTimeout: 15_000, telemetry, + CancellationToken.None); + var endpointConfiguration = EndpointConfiguration.Create(appConfig); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); + + var factory = new DefaultSessionFactory(telemetry); + using var session = (ClientSession)await factory.CreateAsync( + appConfig, + configuredEndpoint, + updateBeforeConnect: false, + sessionName: "DualEndpointTests", + sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: null, + CancellationToken.None); + + var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, CancellationToken.None); + return (string[])value.Value; + } + + private static int AllocateFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +}