/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua.Server.RuntimeNodeSet; using Opc.Ua.Server.TestFramework; using Opc.Ua.Tests; using Quickstarts.ReferenceServer; namespace Opc.Ua.Server.Tests.RuntimeNodeSet { /// /// Verifies that Runtime NodeSets composed before startup participate in the /// live NodeManager lifecycle and expose typed Method argument children. /// [TestFixture(true)] [TestFixture(false)] [Category("NodeManagerLifecycle")] [Category("RuntimeNodeSet")] [Category("Server")] [SetCulture("en-us")] [SetUICulture("en-us")] [NonParallelizable] public sealed class StartupRuntimeNodeSetLifecycleTests { public StartupRuntimeNodeSetLifecycleTests(bool includeArgumentParentHints) { m_includeArgumentParentHints = includeArgumentParentHints; } private readonly bool m_includeArgumentParentHints; private string m_pkiRoot; private ServerFixture m_fixture; private StartupRuntimeNodeSetServer m_server; private RequestHeader m_requestHeader; private SecureChannelContext m_secureChannelContext; private ILogger m_logger; /// /// Starts a server with two Runtime NodeSets registered before startup. /// [OneTimeSetUp] public async Task OneTimeSetUpAsync() { m_pkiRoot = Path.Combine( TestContext.CurrentContext.WorkDirectory, nameof(StartupRuntimeNodeSetLifecycleTests), Guid.NewGuid().ToString("N")); m_fixture = new ServerFixture( telemetry => new StartupRuntimeNodeSetServer(telemetry, m_includeArgumentParentHints)) { UriScheme = Utils.UriSchemeOpcTcp, SecurityNone = true, AutoAccept = true }; m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false); m_logger = NUnitTelemetryContext.Create() .CreateLogger(); (m_requestHeader, m_secureChannelContext) = await m_server .CreateAndActivateSessionAsync(nameof(StartupRuntimeNodeSetLifecycleTests)) .ConfigureAwait(false); m_requestHeader.Timestamp = DateTimeUtc.Now; } /// /// Stops the server and removes its temporary PKI. /// [OneTimeTearDown] public async Task OneTimeTearDownAsync() { if (m_requestHeader is not null) { m_requestHeader.Timestamp = DateTimeUtc.Now; await m_server .CloseSessionAsync( m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) .ConfigureAwait(false); } m_server?.Dispose(); if (m_fixture is not null) { await m_fixture.StopAsync().ConfigureAwait(false); } Assert.Multiple(() => { Assert.That( m_server!.Probe.DeleteAddressSpaceCount, Is.EqualTo(1)); Assert.That( m_server.Probe.DisposeCount, Is.EqualTo(1)); }); if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) { Directory.Delete(m_pkiRoot, recursive: true); } } /// /// A Method imported at startup must expose its typed argument properties /// and accept a Call carrying the declared input. /// [Test] [Order(100)] public async Task ImportedMethodAcceptsDeclaredArgumentsAsync() { IServerInternal server = m_server.CurrentInstance; NodeManagerRegistration registration = FindRegistration( StartupRuntimeNodeSetServer.PrimaryNamespaceUri); var manager = (RuntimeNodeSetNodeManager)registration.NodeManager; ushort namespaceIndex = (ushort)server.NamespaceUris.GetIndex( StartupRuntimeNodeSetServer.PrimaryNamespaceUri); var rootId = new NodeId( StartupRuntimeNodeSetServer.PrimaryRootNodeId, namespaceIndex); var methodId = new NodeId( StartupRuntimeNodeSetServer.LoadMethodNodeId, namespaceIndex); MethodState method = manager.FindPredefinedNode(methodId); Assert.Multiple(() => { Assert.That(method, Is.Not.Null); Assert.That(method.InputArguments, Is.Not.Null); Assert.That(method.OutputArguments, Is.Not.Null); Assert.That(method.InputArguments!.Value, Has.Count.EqualTo(1)); Assert.That(method.InputArguments.Value[0].Name, Is.EqualTo("revision")); Assert.That(method.OutputArguments!.Value, Has.Count.EqualTo(1)); Assert.That(method.OutputArguments.Value[0].Name, Is.EqualTo("accepted")); }); ArrayOf methodsToCall = [ new CallMethodRequest { ObjectId = rootId, MethodId = methodId, InputArguments = [Variant.From("Rev1")] } ]; m_requestHeader.Timestamp = DateTimeUtc.Now; CallResponse response = await m_server .CallAsync( m_secureChannelContext, m_requestHeader, methodsToCall, RequestLifetime.None) .ConfigureAwait(false); ServerFixtureUtils.ValidateResponse( response.ResponseHeader, response.Results, methodsToCall); ServerFixtureUtils.ValidateDiagnosticInfos( response.DiagnosticInfos, methodsToCall, response.ResponseHeader.StringTable, m_logger); Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(response.Results[0].OutputArguments, Has.Count.EqualTo(1)); Assert.That( response.Results[0].OutputArguments[0].GetBoolean(), Is.True); } /// /// A factory that returns an already-live manager must be rejected /// without deleting or disposing the existing startup registration. /// [Test] [Order(150)] public async Task DuplicateFactoryOutputDoesNotDestroyStartupManagerAsync() { NodeManagerRegistration registration = FindRegistration( StartupRuntimeNodeSetServer.ProbeNamespaceUri); var factory = new ExistingNodeManagerFactory( registration.NodeManager, registration.NamespaceUris); Assert.That( async () => await m_server.NodeManagerLifecycle .AddAsync(factory, null) .ConfigureAwait(false), Throws.TypeOf() .With.Message.EqualTo("The NodeManager is already registered.")); Assert.That( async () => await m_server.NodeManagerLifecycle .ReloadAsync(registration, factory, null) .ConfigureAwait(false), Throws.TypeOf() .With.Message.EqualTo("The NodeManager is already registered.")); Assert.Multiple(() => { Assert.That(m_server.Probe.DeleteAddressSpaceCount, Is.Zero); Assert.That(m_server.Probe.DisposeCount, Is.Zero); Assert.That( FindRegistration( StartupRuntimeNodeSetServer.ProbeNamespaceUri), Is.SameAs(registration)); Assert.That( m_server.CurrentInstance.NodeManager.AsyncNodeManagers.Any( manager => ReferenceEquals( manager, registration.NodeManager)), Is.True); }); } /// /// Startup registrations must support reload and removal without deleting /// external references contributed by another startup NodeManager. /// [Test] [Order(200)] public async Task StartupRuntimeNodeSetCanReloadAndRemoveWithoutAffectingPeerAsync() { IServerInternal server = m_server.CurrentInstance; var master = (MasterNodeManager)server.NodeManager; ArrayOf registrations = m_server.NodeManagerLifecycle.Registrations; NodeManagerRegistration primary = FindRegistration( StartupRuntimeNodeSetServer.PrimaryNamespaceUri); NodeManagerRegistration peer = FindRegistration( StartupRuntimeNodeSetServer.PeerNamespaceUri); NodeManagerRegistration probe = FindRegistration( StartupRuntimeNodeSetServer.ProbeNamespaceUri); Assert.Multiple(() => { Assert.That(primary.Generation, Is.EqualTo(1)); Assert.That(peer.Generation, Is.EqualTo(1)); Assert.That(probe.Generation, Is.EqualTo(1)); Assert.That(probe.NodeManager, Is.InstanceOf()); Assert.That( registrations.Find(registration => ReferenceEquals( registration.NodeManager, master.AsyncNodeManagers[0])), Is.Null); Assert.That( registrations.Find(registration => ReferenceEquals( registration.NodeManager, master.AsyncNodeManagers[1])), Is.Null); }); Assert.That(await ReadPrimaryValueAsync().ConfigureAwait(false), Is.EqualTo(1)); AssertObjectsFolderContains( await BrowseObjectsFolderAsync().ConfigureAwait(false), expectedPrimary: true, expectedPeer: true); NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle .ReloadRuntimeNodeSetAsync( primary, StartupRuntimeNodeSetServer.CreatePrimaryOptions(generation: 2), callerContext: null) .ConfigureAwait(false); Assert.Multiple(() => { Assert.That(reloaded.Id, Is.EqualTo(primary.Id)); Assert.That(reloaded.Generation, Is.EqualTo(2)); }); Assert.That(await ReadPrimaryValueAsync().ConfigureAwait(false), Is.EqualTo(2)); await m_server.NodeManagerLifecycle .RemoveAsync(reloaded, callerContext: null) .ConfigureAwait(false); ushort primaryNamespaceIndex = (ushort)server.NamespaceUris.GetIndex( StartupRuntimeNodeSetServer.PrimaryNamespaceUri); NodeState removedRoot = (await server.NodeManager .FindNodeInAddressSpaceAsync( new NodeId( StartupRuntimeNodeSetServer.PrimaryRootNodeId, primaryNamespaceIndex)) .ConfigureAwait(false))!; ArrayOf afterRemove = m_server.NodeManagerLifecycle.Registrations; Assert.Multiple(() => { Assert.That(removedRoot, Is.Null); Assert.That( afterRemove.Find(registration => registration.Id == reloaded.Id), Is.Null); Assert.That( afterRemove.Find(registration => registration.Id == peer.Id), Is.Not.Null); }); AssertObjectsFolderContains( await BrowseObjectsFolderAsync().ConfigureAwait(false), expectedPrimary: false, expectedPeer: true); } /// /// A failure after the address space starts but before startup commits /// must not expose disposed startup managers through the lifecycle. /// [Test] [Order(300)] public async Task FailedStartupPublishesNoLifecycleRegistrationsAsync() { // Leave room for certificate filenames within the .NET Framework path limit. string pkiRoot = Path.Combine( Path.GetTempPath(), "ua-startup-" + Guid.NewGuid().ToString("N")); FailingStartupRuntimeNodeSetServer? failedServer = null; var fixture = new ServerFixture( telemetry => { failedServer = new FailingStartupRuntimeNodeSetServer(telemetry); return failedServer; }) { UriScheme = Utils.UriSchemeOpcTcp, SecurityNone = true, AutoAccept = true }; try { Assert.That( () => fixture.StartAsync(pkiRoot), Throws.TypeOf()); Assert.That(failedServer, Is.Not.Null); Assert.That( failedServer.NodeManagerLifecycle.Registrations, Is.Empty); } finally { await fixture.StopAsync().ConfigureAwait(false); failedServer?.Dispose(); if (Directory.Exists(pkiRoot)) { Directory.Delete(pkiRoot, recursive: true); } } } /// /// A stopped server must re-arm its lifecycle provider and adopt the /// newly created startup NodeManager generations on restart. /// [Test] [Order(400)] public async Task ServerRestartRecreatesStartupRegistrationsAsync() { string pkiRoot = Path.Combine( TestContext.CurrentContext.WorkDirectory, nameof(ServerRestartRecreatesStartupRegistrationsAsync), Guid.NewGuid().ToString("N")); var fixture = new ServerFixture( telemetry => new StartupRuntimeNodeSetServer(telemetry)) { UriScheme = Utils.UriSchemeOpcTcp, SecurityNone = true, AutoAccept = true }; try { StartupRuntimeNodeSetServer server = await fixture .StartAsync(pkiRoot) .ConfigureAwait(false); var firstRegistrationIds = new HashSet(); server.NodeManagerLifecycle.Registrations.ForEach( registration => firstRegistrationIds.Add(registration.Id)); await server.StopAsync().ConfigureAwait(false); await server.StartAsync(fixture.Config).ConfigureAwait(false); ArrayOf restartedRegistrations = server.NodeManagerLifecycle.Registrations; Assert.Multiple(() => { Assert.That(server.NodeManagerLifecycle.IsShuttingDown, Is.False); Assert.That( restartedRegistrations, Has.Count.EqualTo(firstRegistrationIds.Count)); Assert.That( restartedRegistrations.Find(registration => firstRegistrationIds.Contains(registration.Id)), Is.Null); }); } finally { await fixture.StopAsync().ConfigureAwait(false); if (Directory.Exists(pkiRoot)) { Directory.Delete(pkiRoot, recursive: true); } } } private NodeManagerRegistration FindRegistration(string namespaceUri) { return m_server.NodeManagerLifecycle.Registrations.Find(registration => registration.NamespaceUris.Contains(namespaceUri)) ?? throw new InvalidOperationException( $"No lifecycle registration owns namespace '{namespaceUri}'."); } private async Task ReadPrimaryValueAsync() { IServerInternal server = m_server.CurrentInstance; ushort namespaceIndex = (ushort)server.NamespaceUris.GetIndex( StartupRuntimeNodeSetServer.PrimaryNamespaceUri); NodeState node = (await server.NodeManager .FindNodeInAddressSpaceAsync( new NodeId( StartupRuntimeNodeSetServer.PrimaryValueNodeId, namespaceIndex)) .ConfigureAwait(false))!; Assert.That(node, Is.InstanceOf()); return ((BaseVariableState)node).WrappedValue.GetInt32(); } private async Task BrowseObjectsFolderAsync() { var services = new ServerTestServices( m_server, m_secureChannelContext); var template = new BrowseDescription { BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All }; ArrayOf nodesToBrowse = ServerFixtureUtils.CreateBrowseDescriptionCollectionFromNodeId( [ObjectIds.ObjectsFolder], template); m_requestHeader.Timestamp = DateTimeUtc.Now; BrowseResponse response = await services .BrowseAsync( m_requestHeader, view: null!, requestedMaxReferencesPerNode: 0, nodesToBrowse) .ConfigureAwait(false); ServerFixtureUtils.ValidateResponse( response.ResponseHeader, response.Results, nodesToBrowse); ServerFixtureUtils.ValidateDiagnosticInfos( response.DiagnosticInfos, nodesToBrowse, response.ResponseHeader.StringTable, m_logger); return response; } private static void AssertObjectsFolderContains( BrowseResponse response, bool expectedPrimary, bool expectedPeer) { ArrayOf references = response.Results[0].References; Assert.Multiple(() => { Assert.That( references.Contains(reference => reference.BrowseName.Name == StartupRuntimeNodeSetServer.PrimaryRootBrowseName), Is.EqualTo(expectedPrimary)); Assert.That( references.Contains(reference => reference.BrowseName.Name == StartupRuntimeNodeSetServer.PeerRootBrowseName), Is.EqualTo(expectedPeer)); }); } } internal sealed class StartupRuntimeNodeSetServer : ReferenceServer { public const string PrimaryNamespaceUri = "urn:opcfoundation.org:Tests:StartupRuntimeNodeSet:Primary"; public const string PeerNamespaceUri = "urn:opcfoundation.org:Tests:StartupRuntimeNodeSet:Peer"; public const string ProbeNamespaceUri = "urn:opcfoundation.org:Tests:StartupRuntimeNodeSet:Probe"; public const string PrimaryRootBrowseName = "StartupPrimary"; public const string PeerRootBrowseName = "StartupPeer"; public const uint PrimaryRootNodeId = 1000; public const uint PrimaryValueNodeId = 1001; public const uint LoadMethodNodeId = 1010; public StartupRuntimeNodeSetServer(ITelemetryContext telemetry, bool includeArgumentParentHints = true) : base(telemetry) { Probe = new StartupNodeManagerProbe(); AddNodeManager(new RuntimeNodeSetNodeManagerFactory( CreatePrimaryOptions(generation: 1, includeArgumentParentHints))); AddNodeManager(new RuntimeNodeSetNodeManagerFactory( CreatePeerOptions())); AddNodeManager(new StartupProbeNodeManagerFactory(Probe)); } public StartupNodeManagerProbe Probe { get; } public static RuntimeNodeSetOptions CreatePrimaryOptions( int generation, bool includeArgumentParentHints = true, bool useNamespaceUriTargets = false) { return new RuntimeNodeSetOptions { Sources = [ RuntimeNodeSetSource.FromStream( $"StartupPrimary-{generation}", _ => new ValueTask( new MemoryStream( Encoding.UTF8.GetBytes(BuildPrimaryNodeSetXml( includeArgumentParentHints, useNamespaceUriTargets)))), [PrimaryNamespaceUri]) ], DefaultNamespaceUri = PrimaryNamespaceUri, Configure = builder => { builder.Variable( $"{PrimaryRootBrowseName}/Value").Node.Value = generation; builder.Node( $"{PrimaryRootBrowseName}/Load").OnCall( static ( ISystemContext context, MethodState method, NodeId objectId, ArrayOf inputArguments, List outputArguments) => { if (inputArguments.Count != 1 || !inputArguments[0].TryGetValue(out string revision) || string.IsNullOrEmpty(revision)) { return StatusCodes.BadInvalidArgument; } outputArguments[0] = Variant.From(true); return ServiceResult.Good; }); } }; } private static RuntimeNodeSetOptions CreatePeerOptions() { return new RuntimeNodeSetOptions { Sources = [ RuntimeNodeSetSource.FromStream( "StartupPeer", _ => new ValueTask( new MemoryStream( Encoding.UTF8.GetBytes(BuildPeerNodeSetXml()))), [PeerNamespaceUri]) ], DefaultNamespaceUri = PeerNamespaceUri }; } private static string BuildPrimaryNodeSetXml(bool includeArgumentParentHints, bool useNamespaceUriTargets) { string argumentParent = includeArgumentParentHints ? $"ParentNodeId=\"ns=1;i={LoadMethodNodeId}\"" : string.Empty; string xml = $""" {PrimaryNamespaceUri} {PrimaryRootBrowseName} i=58 ns=1;i={PrimaryValueNodeId} ns=1;i={LoadMethodNodeId} i=85 Value i=63 ns=1;i={PrimaryRootNodeId} Load ns=1;i=1011 ns=1;i=1012 ns=1;i={PrimaryRootNodeId} InputArguments i=68 ns=1;i={LoadMethodNodeId} i=297 revision i=12 -1 OutputArguments i=68 ns=1;i={LoadMethodNodeId} i=297 accepted i=1 -1 """; return useNamespaceUriTargets ? xml.Replace(">ns=1;", $">nsu={PrimaryNamespaceUri};", StringComparison.Ordinal) : xml; } private static string BuildPeerNodeSetXml() { return $""" {PeerNamespaceUri} {PeerRootBrowseName} i=58 i=85 """; } } internal sealed class StartupNodeManagerProbe { public int DeleteAddressSpaceCount => Volatile.Read(ref m_deleteAddressSpaceCount); public int DisposeCount => Volatile.Read(ref m_disposeCount); public void RecordDeleteAddressSpace() { Interlocked.Increment(ref m_deleteAddressSpaceCount); } public void RecordDispose() { Interlocked.Increment(ref m_disposeCount); } private int m_deleteAddressSpaceCount; private int m_disposeCount; } internal sealed class ExistingNodeManagerFactory : IAsyncNodeManagerFactory { public ExistingNodeManagerFactory( IAsyncNodeManager nodeManager, ArrayOf namespaceUris) { m_nodeManager = nodeManager; NamespacesUris = namespaceUris; } public ArrayOf NamespacesUris { get; } public ValueTask CreateAsync( IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { return new ValueTask(m_nodeManager); } private readonly IAsyncNodeManager m_nodeManager; } internal sealed class FailingStartupRuntimeNodeSetServer : ReferenceServer { public FailingStartupRuntimeNodeSetServer(ITelemetryContext telemetry) : base(telemetry) { AddNodeManager(new RuntimeNodeSetNodeManagerFactory( StartupRuntimeNodeSetServer.CreatePrimaryOptions(generation: 1))); } protected override async ValueTask OnServerStartedAsync( CancellationToken cancellationToken = default) { await base.OnServerStartedAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException("Fail startup after the address space is ready."); } } internal sealed class StartupProbeNodeManagerFactory : IAsyncNodeManagerFactory { public StartupProbeNodeManagerFactory(StartupNodeManagerProbe probe) { m_probe = probe; } public ArrayOf NamespacesUris { get; } = [StartupRuntimeNodeSetServer.ProbeNamespaceUri]; public ValueTask CreateAsync( IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { return new ValueTask( new StartupProbeNodeManager( server, configuration, m_probe, NamespacesUris[0])); } private readonly StartupNodeManagerProbe m_probe; } internal sealed class StartupProbeNodeManager : AsyncCustomNodeManager { public StartupProbeNodeManager( IServerInternal server, ApplicationConfiguration configuration, StartupNodeManagerProbe probe, string namespaceUri) : base(server, configuration, namespaceUri) { m_probe = probe; } public override ValueTask DeleteAddressSpaceAsync( CancellationToken cancellationToken = default) { m_probe.RecordDeleteAddressSpace(); return base.DeleteAddressSpaceAsync(cancellationToken); } protected override void Dispose(bool disposing) { if (disposing) { m_probe.RecordDispose(); } base.Dispose(disposing); } private readonly StartupNodeManagerProbe m_probe; } }