/* ======================================================================== * 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.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Opc.Ua.Server.RuntimeNodeSet; namespace Opc.Ua.Server.Tests.RuntimeNodeSet { /// /// Unit tests for covering /// constructor argument validation, /// population, options snapshot semantics, cycle detection, and duplicate model URI /// rejection. /// [TestFixture] [Category("RuntimeNodeSet")] [Parallelizable(ParallelScope.All)] [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] public sealed class RuntimeNodeSetNodeManagerFactoryTests { private const string kUriA = "urn:test:ModelA"; private const string kUriB = "urn:test:ModelB"; private static StreamRuntimeNodeSetSource MakeStreamSource(string modelUri, string xml) { return RuntimeNodeSetSource.FromStream( modelUri, _ => new ValueTask( new MemoryStream(Encoding.UTF8.GetBytes(xml))), [modelUri]); } private static string MakeMinimalXml(string modelUri) { return "\r\n" + "\r\n" + " \r\n" + " \r\n" + " \r\n" + ""; } private static string MakeCycleXml(string ownUri, string requiresUri) { return "\r\n" + "\r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + ""; } /// /// Constructor must throw when options is null. /// [Test] public void ConstructorRejectsNullOptions() { Assert.That( () => new RuntimeNodeSetNodeManagerFactory(null!), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("options")); } /// /// Constructor must throw when is empty. /// [Test] public void ConstructorRejectsEmptySourceList() { var options = new RuntimeNodeSetOptions { Sources = [] }; Assert.That( () => new RuntimeNodeSetNodeManagerFactory(options), Throws.InvalidOperationException.With.Message.Contains("At least one")); } /// /// Constructor must throw when two sources declare the same model namespace URI. /// Duplicates are detected eagerly before the server starts. /// [Test] public void ConstructorRejectsDuplicateModelUrisAcrossSources() { StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var options = new RuntimeNodeSetOptions { Sources = [sourceA, sourceB] }; Assert.That( () => new RuntimeNodeSetNodeManagerFactory(options), Throws.InvalidOperationException.With.Message.Contains(kUriA)); } /// /// Constructor must throw when /// names a URI that is not owned by any configured source. /// [Test] public void ConstructorRejectsDefaultNsNotOwnedByAnySources() { StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var options = new RuntimeNodeSetOptions { Sources = [source], DefaultNamespaceUri = "urn:not:owned" }; Assert.That( () => new RuntimeNodeSetNodeManagerFactory(options), Throws.InvalidOperationException.With.Message.Contains("not owned")); } /// /// aggregates the /// model URIs from all configured sources in declaration order. /// [Test] public void NamespacesUrisAggregatesAllSourceModelUris() { StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriB, MakeMinimalXml(kUriB)); var options = new RuntimeNodeSetOptions { Sources = [sourceA, sourceB] }; var factory = new RuntimeNodeSetNodeManagerFactory(options); Assert.That(factory.NamespacesUris.Count, Is.EqualTo(2)); Assert.That(factory.NamespacesUris[0], Is.EqualTo(kUriA)); Assert.That(factory.NamespacesUris[1], Is.EqualTo(kUriB)); } /// /// Mutating after the factory is /// constructed must not change the factory's /// . /// [Test] public void NamespacesUrisSnapshotAtConstruction() { StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var options = new RuntimeNodeSetOptions { Sources = [sourceA] }; var factory = new RuntimeNodeSetNodeManagerFactory(options); Assert.That(factory.NamespacesUris.Count, Is.EqualTo(1)); // Mutate the options after construction — must not affect the factory. options.Sources = []; Assert.That(factory.NamespacesUris.Count, Is.EqualTo(1)); Assert.That(factory.NamespacesUris[0], Is.EqualTo(kUriA)); } /// /// A single source with a valid /// that matches one of the owned URIs is accepted without error. /// [Test] public void ValidDefaultNamespaceUriIsAccepted() { StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var options = new RuntimeNodeSetOptions { Sources = [source], DefaultNamespaceUri = kUriA }; Assert.That( () => new RuntimeNodeSetNodeManagerFactory(options), Throws.Nothing); } /// /// When two sources form a mutual dependency cycle, /// throws /// with a message that names the /// participating sources. /// [Test] public async Task CreateAsyncThrowsOnCycleInIncludedSourcesAsync() { // Source A requires B, source B requires A → cycle. StreamRuntimeNodeSetSource sourceA = RuntimeNodeSetSource.FromStream( kUriA, _ => new ValueTask( new MemoryStream(Encoding.UTF8.GetBytes(MakeCycleXml(kUriA, kUriB)))), [kUriA]); StreamRuntimeNodeSetSource sourceB = RuntimeNodeSetSource.FromStream( kUriB, _ => new ValueTask( new MemoryStream(Encoding.UTF8.GetBytes(MakeCycleXml(kUriB, kUriA)))), [kUriB]); var options = new RuntimeNodeSetOptions { Sources = [sourceA, sourceB] }; var factory = new RuntimeNodeSetNodeManagerFactory(options); Mock mockServer = CreateMinimalServerMock(); InvalidOperationException ex = Assert.ThrowsAsync( async () => await factory.CreateAsync( mockServer.Object, new ApplicationConfiguration(), default).ConfigureAwait(false)); Assert.That(ex.Message, Does.Contain("circular")); } /// /// When two sources in a group declare the same ModelUri inside their /// NodeSet XML (as opposed to in the declared metadata), CreateAsync /// rejects them with . /// [Test] public async Task CreateAsyncThrowsOnDuplicateModelUriInParsedDocumentsAsync() { // Both sources declare kUriA in their XML but the factory metadata // declares different URIs to bypass the constructor check. When the // documents are parsed, the ModelUri from the XML overrides the // declared URIs and the factory detects the duplicate. const string kUriExtra = "urn:extra:X"; StreamRuntimeNodeSetSource source1 = RuntimeNodeSetSource.FromStream( kUriA, _ => new ValueTask( new MemoryStream(Encoding.UTF8.GetBytes(MakeMinimalXml(kUriA)))), [kUriA]); // Use a second source that declares a different URI at the factory // level but whose embedded XML resolves to the same ModelUri as source1. // This creates a conflict detected during ParseAndSortAsync. StreamRuntimeNodeSetSource source2 = RuntimeNodeSetSource.FromStream( kUriExtra, _ => new ValueTask( new MemoryStream(Encoding.UTF8.GetBytes(MakeMinimalXml(kUriA)))), [kUriExtra]); var options = new RuntimeNodeSetOptions { Sources = [source1, source2] }; var factory = new RuntimeNodeSetNodeManagerFactory(options); Mock mockServer = CreateMinimalServerMock(); // Parsing fails because the XML in source2 declares kUriA, which // does not match the declared URI kUriExtra. await Assert.ThatAsync( () => factory.CreateAsync( mockServer.Object, new ApplicationConfiguration(), default).AsTask(), Throws.TypeOf()).ConfigureAwait(false); } /// /// rejects a /// null server. /// [Test] public void CreateAsyncRejectsNullServer() { StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var factory = new RuntimeNodeSetNodeManagerFactory( new RuntimeNodeSetOptions { Sources = [source] }); Assert.That( () => factory.CreateAsync(null!, new ApplicationConfiguration(), default).AsTask(), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("server")); } /// /// A single-source group with no fluent callback creates a runtime node /// manager whose owned namespace matches the source. /// [Test] public async Task CreateAsyncReturnsManagerForSingleSourceAsync() { StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var factory = new RuntimeNodeSetNodeManagerFactory( new RuntimeNodeSetOptions { Sources = [source] }); IAsyncNodeManager manager = await factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), CancellationToken.None).ConfigureAwait(false); using (manager as IDisposable) { Assert.That(manager, Is.Not.Null); } } /// /// When a fluent callback is present and an explicit default namespace is /// configured, the manager is created using that namespace. /// [Test] public async Task CreateAsyncWithExplicitDefaultNamespaceAsync() { StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions { Sources = [source], DefaultNamespaceUri = kUriA, Configure = _ => { } }); IAsyncNodeManager manager = await factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), CancellationToken.None).ConfigureAwait(false); using (manager as IDisposable) { Assert.That(manager, Is.Not.Null); } } /// /// When a fluent callback is present but no explicit default namespace is /// set, a single-source group infers the namespace from that source. /// [Test] public async Task CreateAsyncInfersDefaultNamespaceForSingleSourceAsync() { StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions { Sources = [source], Configure = _ => { } }); IAsyncNodeManager manager = await factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), CancellationToken.None).ConfigureAwait(false); using (manager as IDisposable) { Assert.That(manager, Is.Not.Null); } } /// /// A single source that declares two models where one requires the other is /// well-formed and must not be reported as a circular dependency. /// [Test] public async Task CreateAsyncAcceptsIntraDocumentModelDependencyAsync() { const string xml = "\r\n" + "\r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + ""; StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream( "combined", _ => new ValueTask(new MemoryStream(Encoding.UTF8.GetBytes(xml))), [kUriA, kUriB]); var factory = new RuntimeNodeSetNodeManagerFactory( new RuntimeNodeSetOptions { Sources = [source] }); IAsyncNodeManager manager = await factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), CancellationToken.None).ConfigureAwait(false); using (manager as IDisposable) { Assert.That(manager, Is.Not.Null); } } /// /// With a fluent callback and two dependent sources, the default namespace /// is inferred as the unique leaf model (the one not required by another). /// [Test] public async Task CreateAsyncInfersLeafNamespaceForDependentSourcesAsync() { // Source A requires B, so A is the leaf and B is a dependency. StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeCycleXml(kUriA, kUriB)); StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriB, MakeMinimalXml(kUriB)); var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions { Sources = [sourceA, sourceB], Configure = _ => { } }); IAsyncNodeManager manager = await factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), CancellationToken.None).ConfigureAwait(false); using (manager as IDisposable) { Assert.That(manager, Is.Not.Null); } } /// /// With a fluent callback and two independent leaf models, the default /// namespace cannot be inferred and creation fails with a clear error. /// [Test] public void CreateAsyncThrowsWhenDefaultNamespaceAmbiguous() { StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA)); StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriB, MakeMinimalXml(kUriB)); var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions { Sources = [sourceA, sourceB], Configure = _ => { } }); Assert.That( () => factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), default).AsTask(), Throws.InvalidOperationException.With.Message.Contains("infer")); } /// /// When a source declares more owned namespaces than the parsed NodeSet /// defines, creation fails during validation. /// [Test] public void CreateAsyncThrowsWhenDeclaredNamespaceCountMismatch() { StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream( "mismatch", _ => new ValueTask( new MemoryStream(Encoding.UTF8.GetBytes(MakeMinimalXml(kUriA)))), [kUriA, kUriB]); var factory = new RuntimeNodeSetNodeManagerFactory( new RuntimeNodeSetOptions { Sources = [source] }); Assert.That( () => factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), default).AsTask(), Throws.InvalidOperationException); } /// /// Creates a minimal mock sufficient for /// to attempt /// parsing (a real logger is injected so diagnostic output works). /// private static Mock CreateMinimalServerMock() { var mockLoggerFactory = LoggerFactory.Create(b => b.AddDebug()); var mockTelemetry = new Mock(); mockTelemetry.SetupGet(t => t.LoggerFactory).Returns(mockLoggerFactory); var mockServer = new Mock(); mockServer.SetupGet(s => s.Telemetry).Returns(mockTelemetry.Object); return mockServer; } /// /// Creates an mock with the namespace table /// and system context required to fully construct a runtime node manager /// during . /// private static Mock BuildMockServer() { var mockLoggerFactory = LoggerFactory.Create(b => b.AddDebug()); var mockTelemetry = new Mock(); mockTelemetry.SetupGet(t => t.LoggerFactory).Returns(mockLoggerFactory); var mockServer = new Mock(); mockServer.SetupGet(s => s.Telemetry).Returns(mockTelemetry.Object); mockServer.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); var systemContext = new ServerSystemContext(mockServer.Object); mockServer.SetupGet(s => s.DefaultSystemContext).Returns(systemContext); return mockServer; } } }