/* ========================================================================
* Copyright (c) 2005-2025 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.Globalization;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.ModelChange;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Server.RuntimeNodeSet;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Server.Tests.NodeManager
{
///
/// Regression coverage for
/// :
/// registering a NodeManager on a running Server appends a namespace
/// uri and bumps UrisVersion. A Client with model change
/// tracking enabled must re-read its namespace table when it observes
/// the resulting model change, otherwise it resolves NodeIds from the
/// new namespace against a stale NamespaceUris table.
///
[TestFixture]
[Category("Client")]
[Category("ModelChange")]
[Category("NodeManagerLifecycle")]
[Category("Server")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[NonParallelizable]
public sealed class NodeManagerLifecycleNamespaceRefreshTests
{
private const string kLiveNamespaceUri =
"urn:opcfoundation.org:Tests:NodeManagerLifecycleNamespaceRefresh";
private const uint kRootNodeId = 8100;
private const string kRootBrowseName = "NamespaceRefreshRoot";
///
/// Starts a fresh on opc.tcp and loads a
/// client configuration that shares its PKI root.
///
[SetUp]
public async Task SetUpAsync()
{
m_telemetry = NUnitTelemetryContext.Create();
m_pkiRoot = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
nameof(NodeManagerLifecycleNamespaceRefreshTests),
Guid.NewGuid().ToString("N"));
m_fixture = new ServerFixture(t => new ReferenceServer(t))
{
UriScheme = Utils.UriSchemeOpcTcp,
SecurityNone = true,
AutoAccept = true
};
m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false);
m_clientFixture = new ClientFixture(m_telemetry);
await m_clientFixture.LoadClientConfigurationAsync(m_pkiRoot)
.ConfigureAwait(false);
}
///
/// Stops the server and cleans up PKI artefacts.
///
[TearDown]
public async Task TearDownAsync()
{
m_clientFixture?.Dispose();
m_server?.Dispose();
if (m_fixture is not null)
{
await m_fixture.StopAsync().ConfigureAwait(false);
}
if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot))
{
Directory.Delete(m_pkiRoot, recursive: true);
}
}
///
/// A live NodeManager add appends a namespace uri on the Server. A
/// tracking Client must pick the new uri up and be able to resolve a
/// NodeId from it, while a subsequent same-uri reload must leave the
/// Client table unchanged.
///
[Test]
[CancelAfter(120_000)]
public async Task LiveNodeManagerAddRefreshesTheClientNamespaceTable(
CancellationToken ct)
{
ManagedSession session = await ConnectTrackingSessionAsync(ct)
.ConfigureAwait(false);
await using (session.ConfigureAwait(false))
{
var refreshed = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
Assert.That(session.ModelChange, Is.Not.Null);
session.ModelChange!.ModelChanged += (_, e) =>
{
if (e.NamespaceTableRefreshed)
{
refreshed.TrySetResult(e);
}
};
int namespaceCountBefore = session.NamespaceUris.Count;
Assert.That(session.NamespaceUris.GetIndex(kLiveNamespaceUri),
Is.EqualTo(-1),
"the live namespace must not be known before the add");
NodeManagerRegistration registration = await m_server
.NodeManagerLifecycle
.AddRuntimeNodeSetAsync(CreateOptions(), null, ct)
.ConfigureAwait(false);
ModelChangedEventArgs observed = await refreshed.Task
.WaitAsync(TimeSpan.FromSeconds(60), ct)
.ConfigureAwait(false);
Assert.That(observed.NamespaceTableRefreshed, Is.True);
Assert.That(session.NamespaceUris.Count,
Is.EqualTo(namespaceCountBefore + 1));
int liveIndex = session.NamespaceUris.GetIndex(kLiveNamespaceUri);
Assert.That(liveIndex, Is.EqualTo(namespaceCountBefore),
"the live namespace uri must be appended to the client table");
// The whole point of the refresh: a NodeId expressed in the
// new namespace must now resolve client side.
var expanded = new ExpandedNodeId(kRootNodeId, 0, kLiveNamespaceUri, 0);
NodeId resolved = ExpandedNodeId.ToNodeId(expanded, session.NamespaceUris);
Assert.That(resolved.IsNull, Is.False);
Assert.That(resolved.NamespaceIndex, Is.EqualTo((ushort)liveIndex));
// A same-uri reload must not grow the table any further.
await m_server.NodeManagerLifecycle
.ReloadRuntimeNodeSetAsync(registration, CreateOptions(), null, ct)
.ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
Assert.That(session.NamespaceUris.Count,
Is.EqualTo(namespaceCountBefore + 1));
await session.CloseAsync(ct).ConfigureAwait(false);
}
}
private async Task ConnectTrackingSessionAsync(
CancellationToken ct)
{
var url = new Uri(
Utils.UriSchemeOpcTcp +
"://localhost:" +
m_fixture.Port.ToString(CultureInfo.InvariantCulture));
ConfiguredEndpoint endpoint = await m_clientFixture
.GetEndpointAsync(url, SecurityPolicies.None)
.ConfigureAwait(false);
return await new ManagedSessionBuilder(m_clientFixture.Config, m_telemetry)
.UseEndpoint(endpoint)
.WithSessionName(nameof(NodeManagerLifecycleNamespaceRefreshTests))
.WithSessionTimeout(TimeSpan.FromSeconds(120))
.WithModelChangeTracking()
.ConnectAsync(ct)
.ConfigureAwait(false);
}
///
/// Builds the runtime NodeSet options for a model that owns
/// and contributes a single object
/// organized under the Objects folder.
///
private static RuntimeNodeSetOptions CreateOptions()
{
string xml = $"""
{kLiveNamespaceUri}
{kRootBrowseName}
i=58
i=85
""";
return new RuntimeNodeSetOptions
{
Sources =
[
RuntimeNodeSetSource.FromStream(
$"NamespaceRefreshTests-{kLiveNamespaceUri}",
_ => new ValueTask(
new MemoryStream(Encoding.UTF8.GetBytes(xml))),
[kLiveNamespaceUri])
]
};
}
private ITelemetryContext m_telemetry;
private string m_pkiRoot;
private ServerFixture m_fixture;
private ClientFixture m_clientFixture;
private ReferenceServer m_server;
}
}