using System.Net; using System.Net.Sockets; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// v3 Batch 4 (B4-WP5) — the over-the-wire proof of the dual-namespace address space. Boots the real /// (exactly as SubscriptionSurvivalTests / the multi-notifier /// alarm test do), materialises a raw device folder + a raw tag Variable (ns=Raw, s=<RawPath>) /// and an equipment folder + a UNS reference Variable (ns=UNS, s=<Area/Line/Equip/Eff>) through /// the PRODUCTION , wires the Organizes UNS→Raw edge, then drives a /// real OPC UA client to assert: /// /// BOTH namespace URIs ( + /// ) are registered + distinct (they replaced the single /// .../ns). /// Both subtrees browse + read: the raw Variable at its RawPath NodeId and the UNS Variable at its /// equipment-path NodeId. /// The UNS Variable Organizes-references its backing raw node. /// Single-source fan-out parity: one driver publish (modelled as a /// to each NodeId with identical value/quality/timestamp) /// is read back IDENTICALLY on both NodeIds. /// HistoryRead via EITHER NodeId returns GoodNoData under the shared historian tagname (the /// NullHistorianDataSource default — the offline parity path). /// The WriteOperate gate is symmetric across both NodeIds: an anonymous client write is /// rejected identically on the raw and the UNS NodeId (fail-closed at the role gate — the POSITIVE /// realm-qualified routing path is unit-covered in DriverHostActorWriteRoutingTests / /// NodeManagerWriteRevertTests, which drive a role-carrying identity the node manager /// directly). /// /// Heavy in-process server+client integration — runs in the serial integration pass, and is fully /// offline-safe (no Docker / SQL / gateway; the historian read defaults to the Null source). /// public sealed class DualNamespaceAddressSpaceTests { private const string ServerUri = "urn:OtOpcUa.DualNamespaceAddressSpace"; // Raw device tree (ns=Raw): Folder → tag Variable, both keyed by RawPath. private const string RawDeviceFolder = "Plant/Modbus/dev1"; private const string RawTagPath = "Plant/Modbus/dev1/Speed"; // UNS equipment tree (ns=UNS): equipment Folder → reference Variable keyed by the equipment path. private const string EquipFolder = "filling/line1/station1"; private const string UnsVarPath = "filling/line1/station1/Speed"; // Both NodeIds register the SAME historian tagname (the raw tag's RawPath). private const string HistorianTagname = RawTagPath; [Fact] public async Task Both_namespaces_registered_and_both_subtrees_browse_read_and_organize() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-browse-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); MaterialiseDualTree(sink); // One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload. var ts = DateTime.UtcNow; sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw); sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); // (1) both namespaces registered + distinct. var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); rawNs.ShouldBeGreaterThan((ushort)0, "the Raw namespace must be registered"); unsNs.ShouldBeGreaterThan((ushort)0, "the UNS namespace must be registered"); rawNs.ShouldNotBe(unsNs, "the two namespaces must have distinct indices"); var rawNodeId = new NodeId(RawTagPath, rawNs); var unsNodeId = new NodeId(UnsVarPath, unsNs); // (2) both subtrees browse + read. var rawValue = await session.ReadValueAsync(rawNodeId, ct); var unsValue = await session.ReadValueAsync(unsNodeId, ct); StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good"); StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good"); Convert.ToSingle(rawValue.Value).ShouldBe(42.5f); Convert.ToSingle(unsValue.Value).ShouldBe(42.5f); // (3) the UNS Variable Organizes-references its backing raw node. var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes); var organizesRaw = refs.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && r.NodeId.NamespaceIndex == rawNs && r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal)); organizesRaw.ShouldBeTrue( $"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " + $"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]"); } finally { SafeDelete(pkiRoot + "-srv"); } } [Fact] public async Task Single_source_fans_to_both_nodeids_with_identical_value_quality_and_timestamp() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-fanout-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Fanout", ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); MaterialiseDualTree(sink); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); // The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with // identical value/quality/timestamp — the fan-out drift invariant. var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc); sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw); sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns); var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct); var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct); Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value)); unsValue.StatusCode.ShouldBe(rawValue.StatusCode); unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp); unsValue.SourceTimestamp.ShouldBe(ts); } finally { SafeDelete(pkiRoot + "-srv"); } } [Fact] public async Task HistoryRead_via_either_nodeid_returns_GoodNoData_under_the_shared_tagname() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-hist-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".History", ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); MaterialiseDualTree(sink); // both variables historized under the SAME tagname (HistorianTagname) using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); var rawStatus = await HistoryReadRawStatusAsync(session, new NodeId(RawTagPath, rawNs), ct); var unsStatus = await HistoryReadRawStatusAsync(session, new NodeId(UnsVarPath, unsNs), ct); // NullHistorianDataSource default: a historized node's HistoryRead surfaces GoodNoData (never an // error) — identical for the raw and the UNS NodeId under the one registered historian tagname. rawStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "raw NodeId HistoryRead"); unsStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "uns NodeId HistoryRead"); } finally { SafeDelete(pkiRoot + "-srv"); } } [Fact] public async Task WriteOperate_gate_is_symmetric_across_both_nodeids() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-write-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Write", ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); MaterialiseDualTree(sink); // both variables writable using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); var rawWrite = await WriteValueStatusAsync(session, new NodeId(RawTagPath, rawNs), 7.0f, ct); var unsWrite = await WriteValueStatusAsync(session, new NodeId(UnsVarPath, unsNs), 7.0f, ct); // The anonymous session carries no WriteOperate role, so the node manager's fail-closed write gate // rejects BOTH writes identically — the gate applies uniformly to the raw and the UNS NodeId (a // UNS write is neither more nor less privileged than the raw write it fans from). rawWrite.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "raw NodeId write (no WriteOperate role)"); unsWrite.ShouldBe(rawWrite, "uns NodeId write rejected identically to the raw NodeId"); } finally { SafeDelete(pkiRoot + "-srv"); } } /// Materialise the raw device folder + raw tag Variable, the equipment folder + UNS reference /// Variable (both historized under the SAME tagname, both writable), and the Organizes UNS→Raw edge. private static void MaterialiseDualTree(SdkAddressSpaceSink sink) { sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); sink.EnsureVariable(RawTagPath, RawDeviceFolder, "Speed", "Float", writable: true, realm: AddressSpaceRealm.Raw, historianTagname: HistorianTagname); sink.EnsureFolder(EquipFolder, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); sink.EnsureVariable(UnsVarPath, EquipFolder, "Speed", "Float", writable: true, realm: AddressSpaceRealm.Uns, historianTagname: HistorianTagname); // Organizes UNS→Raw: the UNS variable references its backing raw node (the fan-out source). sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes"); } private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType) { // Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct. var (_, cp, refs) = await session.BrowseAsync( null, null, node, 0u, BrowseDirection.Forward, referenceType, includeSubtypes: true, nodeClassMask: 0u); return (cp, refs ?? new ReferenceDescriptionCollection()); } private static async Task HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct) { var details = new ReadRawModifiedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, IsReadModified = false, ReturnBounds = false, }; var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }; var response = await session.HistoryReadAsync( null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct); response.Results.ShouldNotBeNull(); response.Results.Count.ShouldBe(1); return response.Results[0].StatusCode; } private static async Task WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct) { var writeCollection = new WriteValueCollection { new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) }, }; var response = await session.WriteAsync(null, writeCollection, ct); response.Results.ShouldNotBeNull(); response.Results.Count.ShouldBe(1); return response.Results[0]; } private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync( int port, string pkiRoot, string appUri, CancellationToken ct) { var options = new OpcUaApplicationHostOptions { ApplicationName = appUri, ApplicationUri = appUri, OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = pkiRoot, EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, AutoAcceptUntrustedClientCertificates = true, }; var server = new OtOpcUaSdkServer(); var host = new OpcUaApplicationHost(options, NullLogger.Instance); await host.StartAsync(server, ct); return (server, host); } private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) { var appConfig = new ApplicationConfiguration { ApplicationName = "OtOpcUa.DualNamespaceClient", ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; var endpoint = await CoreClientUtils.SelectEndpointAsync( appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); var endpointConfiguration = EndpointConfiguration.Create(appConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); } private static int AllocateFreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private static void SafeDelete(string dir) { if (Directory.Exists(dir)) { try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } } } }