/* ======================================================================== * 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.IO; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Client; using Opc.Ua.Client.TestFramework; using Opc.Ua.Export; namespace Opc.Ua.Sessions.Tests { /// /// Client NodeSet export tests. /// [TestFixture] [Category("Client")] [Category("NodeSetExport")] [SetCulture("en-us")] [SetUICulture("en-us")] [TestFixtureSource(nameof(FixtureArgs))] public class NodeSetExportTest : ClientTestFramework { public static new readonly object[] FixtureArgs = [ new object[] { Utils.UriSchemeOpcTcp } ]; public NodeSetExportTest(string uriScheme = Utils.UriSchemeOpcTcp) : base(uriScheme) { } /// /// Set up a Server and a Client instance. /// [OneTimeSetUp] public override Task OneTimeSetUpAsync() { SupportsExternalServerUrl = true; return base.OneTimeSetUpAsync(); } /// /// Tear down the Server and the Client. /// [OneTimeTearDown] public override Task OneTimeTearDownAsync() { return base.OneTimeTearDownAsync(); } /// /// Test setup. /// [SetUp] public override Task SetUpAsync() { return base.SetUpAsync(); } /// /// Test teardown. /// [TearDown] public override Task TearDownAsync() { return base.TearDownAsync(); } /// /// Test exporting nodes to NodeSet2 XML. /// [Test] public async Task ExportNodesToNodeSet2Async() { // Browse to get some nodes var browser = new Browser(Session) { BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = 0 }; var nodesToBrowse = new List { ObjectIds.Server }; var allNodes = new List(); // Browse starting from Server object ArrayOf references = await browser.BrowseAsync(nodesToBrowse[0]).ConfigureAwait(false); // Fetch the actual nodes foreach (ReferenceDescription reference in references.ToList()) { INode node = (await Session.NodeCache.FindAsync(reference.NodeId).ConfigureAwait(false))!; if (node != null) { allNodes.Add(node); } } Assert.That(allNodes, Is.Not.Empty, "Should have browsed at least one node"); // Export to NodeSet2 string tempFile = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFile, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream); } // Verify the file was created and has content var fileInfo = new FileInfo(tempFile); Assert.That(fileInfo.Exists, Is.True, "NodeSet2 file should exist"); Assert.That(fileInfo.Length, Is.GreaterThan(0), "NodeSet2 file should not be empty"); // Try to read it back using (var stream = new FileStream(tempFile, FileMode.Open)) { var nodeSet = UANodeSet.Read(stream); Assert.That(nodeSet, Is.Not.Null, "Should be able to read the exported NodeSet2"); Assert.That(nodeSet.Items, Is.Not.Null, "NodeSet2 should contain items"); Assert.That(nodeSet.Items, Is.Not.Empty, "NodeSet2 should contain at least one node"); } } finally { if (File.Exists(tempFile)) { File.Delete(tempFile); } } } /// /// Test exporting different node types to NodeSet2 XML. /// [Test] public async Task ExportDifferentNodeTypesAsync() { var allNodes = new List(); // Get different types of nodes // Object node INode serverNode = (await Session.NodeCache.FindAsync(ObjectIds.Server).ConfigureAwait(false))!; if (serverNode != null) { allNodes.Add(serverNode); } // Variable node INode serverStatusNode = (await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false))!; if (serverStatusNode != null) { allNodes.Add(serverStatusNode); } // ObjectType node INode baseObjectTypeNode = (await Session.NodeCache.FindAsync(ObjectTypeIds.BaseObjectType).ConfigureAwait(false))!; if (baseObjectTypeNode != null) { allNodes.Add(baseObjectTypeNode); } // VariableType node INode baseVariableTypeNode = (await Session.NodeCache.FindAsync(VariableTypeIds.BaseVariableType).ConfigureAwait(false))!; if (baseVariableTypeNode != null) { allNodes.Add(baseVariableTypeNode); } Assert.That(allNodes, Is.Not.Empty, "Should have found at least one node"); // Export to NodeSet2 string tempFile = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFile, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream); } // Read it back and verify we have different node types using (var stream = new FileStream(tempFile, FileMode.Open)) { var nodeSet = UANodeSet.Read(stream); Assert.That(nodeSet, Is.Not.Null, "Should be able to read the exported NodeSet2"); Assert.That(nodeSet.Items, Is.Not.Null, "NodeSet2 should contain items"); Assert.That(nodeSet.Items, Has.Length.EqualTo(allNodes.Count), "Should have exported all nodes"); // Verify we have different node types var nodeTypes = nodeSet.Items.Select(n => n.GetType()).Distinct().ToList(); Assert.That(nodeTypes, Has.Count.GreaterThan(1), "Should have multiple node types"); } } finally { if (File.Exists(tempFile)) { File.Delete(tempFile); } } } /// /// Test exporting and re-importing nodes. /// [Test] public async Task ExportAndReimportNodesAsync() { var allNodes = new List(); // Get some nodes INode serverNode = (await Session.NodeCache.FindAsync(ObjectIds.Server).ConfigureAwait(false))!; if (serverNode != null) { allNodes.Add(serverNode); } INode serverStatusNode = (await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false))!; if (serverStatusNode != null) { allNodes.Add(serverStatusNode); } Assert.That(allNodes, Is.Not.Empty, "Should have found at least one node"); // Export to NodeSet2 string tempFile = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFile, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream); } // Read and import using (var stream = new FileStream(tempFile, FileMode.Open)) { var nodeSet = UANodeSet.Read(stream); var importedNodeStates = new NodeStateCollection(); var localContext = new SystemContext(Telemetry) { NamespaceUris = new NamespaceTable() }; if (nodeSet!.NamespaceUris != null) { foreach (string namespaceUri in nodeSet.NamespaceUris) { localContext.NamespaceUris.Append(namespaceUri); } } nodeSet.Import(localContext, importedNodeStates); Assert.That(importedNodeStates, Has.Count.EqualTo(allNodes.Count), "Should have imported all nodes"); } } finally { if (File.Exists(tempFile)) { File.Delete(tempFile); } } } /// /// Test exporting nodes with default options (no values). /// [Test] public async Task ExportNodesToNodeSet2_DefaultOptionsAsync() { var allNodes = new List(); // Get variable node that has a value INode serverStatusNode = (await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false))!; if (serverStatusNode != null) { allNodes.Add(serverStatusNode); } // Get another variable INode stateNode = (await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus_State).ConfigureAwait(false))!; if (stateNode != null) { allNodes.Add(stateNode); } Assert.That(allNodes, Is.Not.Empty, "Should have found at least one node"); DateTime now = DateTime.UtcNow; // Export with default options string tempFile = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFile, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2( systemContext, allNodes, stream, NodeSetExportOptions.Default, lastModified: now); } // Read it back and verify values are not exported using (var stream = new FileStream(tempFile, FileMode.Open)) { var nodeSet = UANodeSet.Read(stream); Assert.That(nodeSet, Is.Not.Null, "Should be able to read the exported NodeSet2"); Assert.That(nodeSet.Items, Is.Not.Null, "NodeSet2 should contain items"); // Check that variables don't have values foreach (UAVariable variable in nodeSet.Items.OfType()) { Assert.That(variable.Value, Is.Null, "Value should not be exported with Default options"); } } // Verify default file is smaller or equal to complete var defaultFile = new FileInfo(tempFile); long defaultSize = defaultFile.Length; // Export with complete options string tempFileComplete = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFileComplete, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2( systemContext, allNodes, stream, NodeSetExportOptions.Complete, lastModified: now); } var completeFile = new FileInfo(tempFileComplete); long completeSize = completeFile.Length; // Default should be smaller or equal to complete // (Equal if nodes don't have values to export) Assert.That(completeSize, Is.GreaterThanOrEqualTo(defaultSize), "Default export should not be larger than Complete"); } finally { if (File.Exists(tempFileComplete)) { File.Delete(tempFileComplete); } } } finally { if (File.Exists(tempFile)) { File.Delete(tempFile); } } } /// /// Test exporting nodes with complete options (all metadata). /// [Test] public async Task ExportNodesToNodeSet2_CompleteOptionsAsync() { var allNodes = new List(); // Get variable node INode serverStatusNode = (await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false))!; if (serverStatusNode != null) { allNodes.Add(serverStatusNode); } Assert.That(allNodes, Is.Not.Empty, "Should have found at least one node"); // Export with complete options string tempFile = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFile, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Complete); } // Read it back using (var stream = new FileStream(tempFile, FileMode.Open)) { var nodeSet = UANodeSet.Read(stream); Assert.That(nodeSet, Is.Not.Null, "Should be able to read the exported NodeSet2"); Assert.That(nodeSet.Items, Is.Not.Null, "NodeSet2 should contain items"); } } finally { if (File.Exists(tempFile)) { File.Delete(tempFile); } } } /// /// Test exporting nodes with custom options. /// [Test] public async Task ExportNodesToNodeSet2_CustomOptionsAsync() { var allNodes = new List(); // Get variable node INode serverStatusNode = (await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false))!; if (serverStatusNode != null) { allNodes.Add(serverStatusNode); } Assert.That(allNodes, Is.Not.Empty, "Should have found at least one node"); // Export with custom options - values exported var customOptions = new NodeSetExportOptions { ExportValues = true, ExportParentNodeId = false }; string tempFile = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFile, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, customOptions); } // Verify the file was created and has content var fileInfo = new FileInfo(tempFile); Assert.That(fileInfo.Exists, Is.True, "NodeSet2 file should exist"); Assert.That(fileInfo.Length, Is.GreaterThan(0), "NodeSet2 file should not be empty"); } finally { if (File.Exists(tempFile)) { File.Delete(tempFile); } } } /// /// Test that default export options preserve backward compatibility. /// [Test] public async Task ExportNodesToNodeSet2_BackwardCompatibilityAsync() { var allNodes = new List(); // Get some nodes INode serverNode = (await Session.NodeCache.FindAsync(ObjectIds.Server).ConfigureAwait(false))!; if (serverNode != null) { allNodes.Add(serverNode); } Assert.That(allNodes, Is.Not.Empty, "Should have found at least one node"); string tempFile1 = Path.GetTempFileName(); string tempFile2 = Path.GetTempFileName(); try { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; // Export without options (backward compatibility) using (var stream = new FileStream(tempFile1, FileMode.Create)) { CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream); } // Export with default options using (var stream = new FileStream(tempFile2, FileMode.Create)) { CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Default); } // Both should be readable using (var stream = new FileStream(tempFile1, FileMode.Open)) { var nodeSet = UANodeSet.Read(stream); Assert.That(nodeSet, Is.Not.Null, "Should be able to read backward compatible export"); } using (var stream = new FileStream(tempFile2, FileMode.Open)) { var nodeSet = UANodeSet.Read(stream); Assert.That(nodeSet, Is.Not.Null, "Should be able to read default options export"); } } finally { if (File.Exists(tempFile1)) { File.Delete(tempFile1); } if (File.Exists(tempFile2)) { File.Delete(tempFile2); } } } /// /// Test exporting with user context option. /// [Test] public async Task ExportNodesToNodeSet2_UserContextOptionsAsync() { var allNodes = new List(); // Get method node that has UserExecutable INode getMonitoredItemsNode = (await Session.NodeCache.FindAsync(MethodIds.Server_GetMonitoredItems).ConfigureAwait(false))!; if (getMonitoredItemsNode != null) { allNodes.Add(getMonitoredItemsNode); } // Get variable node that has UserAccessLevel INode serverStatusNode = (await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false))!; if (serverStatusNode != null) { allNodes.Add(serverStatusNode); } Assert.That(allNodes, Is.Not.Empty, "Should have found at least one node"); // Export WITHOUT user context string tempFileNoContext = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFileNoContext, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; var optionsNoContext = new NodeSetExportOptions { ExportValues = false, ExportParentNodeId = false, ExportUserContext = false }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, optionsNoContext); } // Export WITH user context string tempFileWithContext = Path.GetTempFileName(); try { using (var stream = new FileStream(tempFileWithContext, FileMode.Create)) { var systemContext = new SystemContext(Telemetry) { NamespaceUris = Session.NamespaceUris, ServerUris = Session.ServerUris }; var optionsWithContext = new NodeSetExportOptions { ExportValues = false, ExportParentNodeId = false, ExportUserContext = true }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, optionsWithContext); } // Read both files and compare UANodeSet nodeSetNoContext; using (var stream = new FileStream(tempFileNoContext, FileMode.Open)) { nodeSetNoContext = UANodeSet.Read(stream)!; Assert.That(nodeSetNoContext, Is.Not.Null, "Should be able to read NodeSet without user context"); } UANodeSet nodeSetWithContext; using (var stream = new FileStream(tempFileWithContext, FileMode.Open)) { nodeSetWithContext = UANodeSet.Read(stream)!; Assert.That(nodeSetWithContext, Is.Not.Null, "Should be able to read NodeSet with user context"); } // Verify that methods in the context version have UserExecutable List methodsNoContext = nodeSetNoContext.Items?.OfType().ToList() ?? []; List methodsWithContext = nodeSetWithContext.Items?.OfType().ToList() ?? []; Assert.That(methodsWithContext, Has.Count.EqualTo(methodsNoContext.Count), "Should have same number of methods"); // Variables with context should potentially have UserAccessLevel if different from AccessLevel List variablesNoContext = nodeSetNoContext.Items?.OfType().ToList() ?? []; List variablesWithContext = nodeSetWithContext.Items?.OfType().ToList() ?? []; Assert.That(variablesWithContext, Has.Count.EqualTo(variablesNoContext.Count), "Should have same number of variables"); // File with user context should be larger or equal var fileNoContext = new FileInfo(tempFileNoContext); var fileWithContext = new FileInfo(tempFileWithContext); // Note: Sizes might be equal if UserAccessLevel == AccessLevel for all variables // The important part is that export completes successfully with both options Assert.That(fileWithContext.Length, Is.GreaterThan(0), "Export with user context should produce a valid file"); Assert.That(fileNoContext.Length, Is.GreaterThan(0), "Export without user context should produce a valid file"); } finally { if (File.Exists(tempFileWithContext)) { File.Delete(tempFileWithContext); } } } finally { if (File.Exists(tempFileNoContext)) { File.Delete(tempFileNoContext); } } } } }