/* ======================================================================== * 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 NUnit.Framework; using Opc.Ua.Export; using Opc.Ua.Server.RuntimeNodeSet; namespace Opc.Ua.Server.Tests.RuntimeNodeSet { /// /// Unit tests for , /// , and /// covering argument /// validation, metadata extraction, stream ownership, and /// cancellation forwarding. /// [TestFixture] [Category("RuntimeNodeSet")] [Parallelizable(ParallelScope.All)] [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] public sealed class RuntimeNodeSetSourceTests { private const string kTestNamespaceUri = "urn:opcfoundation.org:RuntimeNodeSetUnitTest"; private const string kMinimalNodeSetXml = "\r\n" + "\r\n" + " \r\n" + " " + kTestNamespaceUri + "\r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + ""; private const string kNoModelsNodeSetXml = "\r\n" + "\r\n" + " \r\n" + " " + kTestNamespaceUri + "\r\n" + " \r\n" + ""; private string m_testNodeSetFile; /// /// Writes the minimal test NodeSet file to the work directory before each test. /// [SetUp] public void SetUp() { string workDir = Path.Combine( TestContext.CurrentContext.WorkDirectory, "RuntimeNodeSetSourceTests"); Directory.CreateDirectory(workDir); m_testNodeSetFile = Path.Combine(workDir, Guid.NewGuid().ToString("N") + ".NodeSet2.xml"); File.WriteAllText(m_testNodeSetFile, kMinimalNodeSetXml, Encoding.UTF8); } /// /// Removes the temporary file after each test. /// [TearDown] public void TearDown() { if (!string.IsNullOrEmpty(m_testNodeSetFile) && File.Exists(m_testNodeSetFile)) { File.Delete(m_testNodeSetFile); } } /// /// must throw when /// filePath is null. /// [Test] public void FromFileRejectsNullFilePath() { Assert.That( () => RuntimeNodeSetSource.FromFile(null!), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("filePath")); } /// /// must throw /// when filePath is null. /// [Test] public void FromFileWithExplicitNsRejectsNullFilePath() { Assert.That( () => RuntimeNodeSetSource.FromFile(null!, [kTestNamespaceUri]), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("filePath")); } /// /// must throw /// when modelNamespaceUris is the default (null) . /// [Test] public void FromFileWithExplicitNsRejectsNullModelNamespaceUris() { Assert.That( () => RuntimeNodeSetSource.FromFile(m_testNodeSetFile, default), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("modelNamespaceUris")); } /// /// must throw when /// name is null, empty, or whitespace. /// [TestCase(null)] [TestCase("")] [TestCase(" ")] public void FromStreamRejectsBlankName(string? name) { Assert.That( () => RuntimeNodeSetSource.FromStream( name!, _ => new ValueTask(Stream.Null), [kTestNamespaceUri]), Throws.ArgumentException.With.Property("ParamName").EqualTo("name")); } /// /// must throw when /// openStream is null. /// [Test] public void FromStreamRejectsNullOpenStream() { Assert.That( () => RuntimeNodeSetSource.FromStream( "source", null!, [kTestNamespaceUri]), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("openStream")); } /// /// must throw when /// modelNamespaceUris is the default (null) . /// [Test] public void FromStreamRejectsNullModelNamespaceUris() { Assert.That( () => RuntimeNodeSetSource.FromStream( "source", _ => new ValueTask(Stream.Null), default), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("modelNamespaceUris")); } /// /// must reject an empty /// owned namespace declaration. /// [Test] public void FromStreamRejectsEmptyModelNamespaceUris() { Assert.That( () => RuntimeNodeSetSource.FromStream( "source", _ => new ValueTask(Stream.Null), []), Throws.ArgumentException.With.Property("ParamName").EqualTo("modelNamespaceUris")); } /// /// returns the file path /// supplied at construction. /// [Test] public void FileSourceNameIsFilePath() { FileRuntimeNodeSetSource source = RuntimeNodeSetSource.FromFile(m_testNodeSetFile); Assert.That(source.Name, Is.EqualTo(m_testNodeSetFile)); } /// /// exposes the file path. /// [Test] public void FileSourceFilePathPropertyMatchesSuppliedPath() { FileRuntimeNodeSetSource source = RuntimeNodeSetSource.FromFile(m_testNodeSetFile); Assert.That(source.FilePath, Is.EqualTo(m_testNodeSetFile)); } /// /// reflects the /// model URIs extracted from the file's Models section at construction time. /// [Test] public void FileSourceModelNamespaceUrisExtractedFromFile() { FileRuntimeNodeSetSource source = RuntimeNodeSetSource.FromFile(m_testNodeSetFile); Assert.That(source.ModelNamespaceUris.Count, Is.EqualTo(1)); Assert.That(source.ModelNamespaceUris[0], Is.EqualTo(kTestNamespaceUri)); } /// /// returns a fresh readable /// stream positioned at the beginning of the file. /// [Test] public async Task FileSourceOpenReadReturnsReadableStreamAsync() { FileRuntimeNodeSetSource source = RuntimeNodeSetSource.FromFile(m_testNodeSetFile); Stream stream = await source.OpenReadAsync().ConfigureAwait(false); using (stream) { Assert.That(stream, Is.Not.Null); Assert.That(stream.CanRead, Is.True); Assert.That(stream.Position, Is.Zero); } } /// /// propagates a cancelled /// by throwing /// . /// [Test] public void FileSourceOpenReadHonoursCancellation() { FileRuntimeNodeSetSource source = RuntimeNodeSetSource.FromFile(m_testNodeSetFile); using var cts = new CancellationTokenSource(); cts.Cancel(); Assert.That( async () => await source.OpenReadAsync(cts.Token).ConfigureAwait(false), Throws.InstanceOf()); } /// /// Constructing a for a file without a /// Models section throws unless explicit namespace URIs are provided. /// [Test] public void FileSourceRequiresModelsOrExplicitNamespaceUris() { string noModelsFile = Path.Combine( Path.GetDirectoryName(m_testNodeSetFile)!, Guid.NewGuid().ToString("N") + ".NoModels.NodeSet2.xml"); File.WriteAllText(noModelsFile, kNoModelsNodeSetXml, Encoding.UTF8); try { Assert.That( () => RuntimeNodeSetSource.FromFile(noModelsFile), Throws.InvalidOperationException.With.Message.Contains("Models section")); } finally { File.Delete(noModelsFile); } } /// /// Constructing a for a file without a /// Models section succeeds when explicit namespace URIs are provided. /// [Test] public void FileSourceWithExplicitNsSucceedsForLegacyNodeSet() { string noModelsFile = Path.Combine( Path.GetDirectoryName(m_testNodeSetFile)!, Guid.NewGuid().ToString("N") + ".NoModelsExplicit.NodeSet2.xml"); File.WriteAllText(noModelsFile, kNoModelsNodeSetXml, Encoding.UTF8); try { FileRuntimeNodeSetSource source = RuntimeNodeSetSource.FromFile( noModelsFile, [kTestNamespaceUri]); Assert.That(source.ModelNamespaceUris.Count, Is.EqualTo(1)); Assert.That(source.ModelNamespaceUris[0], Is.EqualTo(kTestNamespaceUri)); } finally { File.Delete(noModelsFile); } } /// /// returns the name supplied /// at construction. /// [Test] public void StreamSourceNameMatchesConstructorArgument() { StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream( "MySource", _ => new ValueTask(Stream.Null), [kTestNamespaceUri]); Assert.That(source.Name, Is.EqualTo("MySource")); } /// /// returns exactly /// the URIs supplied at construction. /// [Test] public void StreamSourceModelNamespaceUrisMatchDeclared() { ArrayOf declared = ["urn:a", "urn:b"]; StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream( "S", _ => new ValueTask(Stream.Null), declared); Assert.That(source.ModelNamespaceUris.Count, Is.EqualTo(2)); Assert.That(source.ModelNamespaceUris[0], Is.EqualTo("urn:a")); Assert.That(source.ModelNamespaceUris[1], Is.EqualTo("urn:b")); } /// /// returns the stream /// produced by the delegate supplied at construction. /// [Test] public async Task StreamSourceOpenReadDelegatesToSuppliedFactoryAsync() { var expected = new MemoryStream(Encoding.UTF8.GetBytes(kMinimalNodeSetXml)); StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream( "TestStream", _ => new ValueTask(expected), [kTestNamespaceUri]); Stream result = await source.OpenReadAsync().ConfigureAwait(false); Assert.That(result, Is.SameAs(expected)); expected.Dispose(); } /// /// forwards the /// to the delegate. /// [Test] public async Task StreamSourceOpenReadForwardsCancellationTokenAsync() { CancellationToken capturedToken = CancellationToken.None; StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream( "CancelTest", ct => { capturedToken = ct; return new ValueTask(new MemoryStream(Encoding.UTF8.GetBytes(kMinimalNodeSetXml))); }, [kTestNamespaceUri]); using var cts = new CancellationTokenSource(); Stream stream = await source.OpenReadAsync(cts.Token).ConfigureAwait(false); stream.Dispose(); Assert.That(capturedToken, Is.EqualTo(cts.Token)); } /// /// extracts the /// ModelUri attributes from a file's Models section. /// [Test] public void ScanModelUrisExtractsModelUrisFromFile() { ArrayOf uris = RuntimeNodeSetSource.ScanModelUris(m_testNodeSetFile); Assert.That(uris.Count, Is.EqualTo(1)); Assert.That(uris[0], Is.EqualTo(kTestNamespaceUri)); } /// /// returns an empty array /// when the file has no Models section. /// [Test] public void ScanModelUrisReturnsEmptyForFileWithoutModels() { string noModelsFile = Path.Combine( Path.GetDirectoryName(m_testNodeSetFile)!, Guid.NewGuid().ToString("N") + ".ScanNoModels.NodeSet2.xml"); File.WriteAllText(noModelsFile, kNoModelsNodeSetXml, Encoding.UTF8); try { ArrayOf uris = RuntimeNodeSetSource.ScanModelUris(noModelsFile); Assert.That(uris.Count, Is.Zero); } finally { File.Delete(noModelsFile); } } /// /// ignores elements named /// Model that are not UANodeSet/Models/Model declarations, such /// as a structure field inside a variable value. /// [Test] public void ScanModelUrisIgnoresModelElementsOutsideModels() { const string xml = "\r\n" + "\r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " X100\r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + ""; string file = Path.Combine( Path.GetDirectoryName(m_testNodeSetFile)!, Guid.NewGuid().ToString("N") + ".ScanForeignModel.NodeSet2.xml"); File.WriteAllText(file, xml, Encoding.UTF8); try { ArrayOf uris = RuntimeNodeSetSource.ScanModelUris(file); Assert.That(uris.Count, Is.EqualTo(1)); Assert.That(uris[0], Is.EqualTo(kTestNamespaceUri)); } finally { File.Delete(file); } } /// /// returns the /// Models[].ModelUri values when the Models array is present. /// [Test] public void ExtractModelUrisUsesModelsSection() { UANodeSet nodeSet = UANodeSet.Read( new MemoryStream(Encoding.UTF8.GetBytes(kMinimalNodeSetXml)))!; ArrayOf uris = RuntimeNodeSetSource.ExtractModelUris(nodeSet!, default); Assert.That(uris.Count, Is.EqualTo(1)); Assert.That(uris[0], Is.EqualTo(kTestNamespaceUri)); } /// /// falls back to the /// caller-declared URIs when the NodeSet has no Models element. /// [Test] public void ExtractModelUrisFallsBackToDeclaredWhenModelsAbsent() { UANodeSet nodeSet = UANodeSet.Read( new MemoryStream(Encoding.UTF8.GetBytes(kNoModelsNodeSetXml)))!; ArrayOf declared = [kTestNamespaceUri]; ArrayOf uris = RuntimeNodeSetSource.ExtractModelUris(nodeSet!, declared); Assert.That(uris.Count, Is.EqualTo(1)); Assert.That(uris[0], Is.EqualTo(kTestNamespaceUri)); } } }