From 12a76ade55ef3540ac520faeea3db2f8c4ea548a Mon Sep 17 00:00:00 2001 From: Wolfgang Colsman <52833340+wolfgangcolsman@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:27:52 -0400 Subject: [PATCH] PubSub server: organize PublishedDataSets under PublishSubscribe.PublishedDataSets (#4793) # Description PubSubNodeManager looked up the parent of the PublishedDataSet and DataSetFolder objects it materializes by NodeId i=14478. That is DataSetFolderType's placeholder, an InstanceDeclaration of the type, not the PublishSubscribe.PublishedDataSets object (i=17371). Every PublishedDataSet therefore became a child of the type definition and the standard PublishedDataSets folder stayed empty. Use ObjectIds.PublishSubscribe_PublishedDataSets as the parent. The test harnesses now expose the real folder and the placeholder, and a new test asserts that a PublishedDataSet ends up under the folder and that the placeholder gains no children. ## Related Issues None. ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [x] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Marc Schier --- docs/PubSub.md | 6 +- .../PubSubMethodHandlers.cs | 4276 +++++++++-------- src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs | 179 +- .../PubSubMethodHandlersMutationTests.cs | 934 ++-- .../PubSubNodeManagerDeterministicTests.cs | 14 +- .../PubSubNodeManagerTests.cs | 1881 +++++--- .../PublishedDataSetsNodeIds.cs | 61 + 7 files changed, 4016 insertions(+), 3335 deletions(-) create mode 100644 tests/Opc.Ua.PubSub.Server.Tests/PublishedDataSetsNodeIds.cs diff --git a/docs/PubSub.md b/docs/PubSub.md index d8f838acdb..a79eced732 100644 --- a/docs/PubSub.md +++ b/docs/PubSub.md @@ -1440,6 +1440,8 @@ What the server side adds: `PubSubKeyPushTargetFolder`. - One Object per `PubSubConnection`, `WriterGroup`, `ReaderGroup`, `DataSetWriter`, `DataSetReader`, `PublishedDataSet`, and DataSet folder. + PublishedDataSets and DataSet folders are organized below the standard + `PublishSubscribe.PublishedDataSets` folder (`i=17371`). - Per-instance `Status` / `State` and `ConfigurationVersion` Variables so a client can observe the same runtime state the scheduler uses. 2. Method bindings ([§9.1.5](https://reference.opcfoundation.org/specs/OPC-10000-14/v1.05.06/9.1.5)): @@ -1447,7 +1449,9 @@ What the server side adds: `AddReaderGroup`, `AddDataSetWriter`, `AddDataSetReader`, `Remove*`, `Enable`, and `Disable`, plus `AddPublishedDataItems`, `AddPublishedEvents`, `AddPublishedDataItemsTemplate`, `AddVariables`, - `RemoveVariables`, `AddDataSetFolder`, and `RemoveDataSetFolder`. + `RemoveVariables`, `AddDataSetFolder`, and `RemoveDataSetFolder`. The + folder methods are bound on the `PublishSubscribe.PublishedDataSets` + instance and on every DataSet folder created through `AddDataSetFolder`. 3. `PubSubConfigurationType` File import/export: clients can open/read the current `PubSubConfigurationDataType` file or write a replacement file; the server applies it through `ReplaceConfigurationAsync` and returns per-item diff --git a/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs b/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs index 431d58261a..d208a34464 100644 --- a/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs +++ b/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs @@ -1,2132 +1,2144 @@ -/* ======================================================================== - * 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.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Opc.Ua.PubSub.Application; -using Opc.Ua.PubSub.Configuration; -using Opc.Ua.PubSub.Security; -using Opc.Ua.PubSub.Security.Sks; - -namespace Opc.Ua.PubSub.Server -{ - /// - /// Hosts the synchronous method-handler delegates the - /// attaches to the standard - /// PublishSubscribe Method nodes (Part 14 §9.1.3, - /// §9.1.10 and §8.3.1). - /// - /// - /// Implements the configuration-mutation entry-points - /// via the mutable surface. - /// All entry-points adhere to the legacy synchronous - /// GenericMethodCalledEventHandler contract; every async - /// call is forwarded via .AsTask().GetAwaiter().GetResult() - /// — the single sanctioned sync-over-async bridge. - /// - internal sealed class PubSubMethodHandlers - { - private const string DefaultSecurityPolicyUri = - "http://opcfoundation.org/UA/SecurityPolicy#PubSub-Aes256-CTR"; - - private readonly IPubSubApplication m_application; - private readonly IPubSubKeyServiceServer? m_keyService; - private readonly PubSubServerOptions m_options; - private readonly SksMethodHandler? m_sks; - private readonly PushSecurityKeyProvider[] m_pushProviders; - private readonly ILogger m_logger; - private readonly Dictionary m_securityGroupNodeIds = []; - private readonly System.Threading.Lock m_gate = new(); - private ushort m_securityGroupNamespaceIndex; - - /// - /// Creates a new . - /// - /// Runtime application. - /// - /// SKS server, or when the host is - /// not acting as an SKS. - /// - /// PubSub server options. - /// Telemetry context. - /// Optional SetSecurityKeys push providers. - public PubSubMethodHandlers( - IPubSubApplication application, - IPubSubKeyServiceServer? keyService, - PubSubServerOptions options, - ITelemetryContext telemetry, - IEnumerable? pushProviders = null) - { - if (application is null) - { - throw new ArgumentNullException(nameof(application)); - } - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - if (telemetry is null) - { - throw new ArgumentNullException(nameof(telemetry)); - } - m_application = application; - m_keyService = keyService; - m_options = options; - m_sks = keyService is null ? null : new SksMethodHandler(keyService, telemetry); - m_pushProviders = pushProviders?.ToArray() ?? []; - m_logger = telemetry.CreateLogger(); - } - - /// - /// Sets the namespace index used for SecurityGroup instance NodeIds. - /// - /// PubSub node-manager namespace index. - public void SetSecurityGroupNamespaceIndex(ushort namespaceIndex) - { - lock (m_gate) - { - m_securityGroupNamespaceIndex = namespaceIndex; - } - } - - /// - /// Registers a materialized SecurityGroup node. - /// - /// SecurityGroup identifier. - /// Routable SecurityGroup node id. - /// - public void RegisterSecurityGroupNodeId(string securityGroupId, NodeId nodeId) - { - if (string.IsNullOrEmpty(securityGroupId)) - { - throw new ArgumentException("SecurityGroupId must be non-empty.", nameof(securityGroupId)); - } - - lock (m_gate) - { - m_securityGroupNodeIds[nodeId] = securityGroupId; - } - } - - /// - /// Implements Part 14 §9.1.10.2 Status.Enable. - /// - /// System context. - /// Calling method node. - /// Input arguments (none). - /// Output arguments (none). - public ServiceResult OnEnable( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = inputArguments; - _ = outputArguments; - try - { - m_application.StartAsync().AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (Exception ex) - { - m_logger.PublishSubscribeEnableFailed(ex); - return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.10.3 Status.Disable. - /// - /// System context. - /// Calling method node. - /// Input arguments (none). - /// Output arguments (none). - public ServiceResult OnDisable( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = inputArguments; - _ = outputArguments; - try - { - m_application.StopAsync().AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (Exception ex) - { - m_logger.PublishSubscribeDisableFailed(ex); - return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.3.4 AddConnection. - /// Delegates to - /// . - /// - public ServiceResult OnAddConnection( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddConnection expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out ExtensionObject ext)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddConnection argument 0 is not an ExtensionObject.")); - } - if (!ext.TryGetValue(out PubSubConnectionDataType? cfg) || cfg is null) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddConnection argument 0 body is not a PubSubConnectionDataType.")); - } - try - { - NodeId id = m_application.AddConnectionAsync(cfg) - .AsTask().GetAwaiter().GetResult(); - outputArguments.Add(Variant.From(id)); - return ServiceResult.Good; - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.AddConnectionFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.3.5 RemoveConnection. - /// Delegates to - /// . - /// - public ServiceResult OnRemoveConnection( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = outputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveConnection expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out NodeId connectionId) || - connectionId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveConnection argument 0 is not a valid NodeId.")); - } - try - { - m_application.RemoveConnectionAsync(connectionId) - .AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (Exception ex) - { - m_logger.RemoveConnectionFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.6 SetConfiguration. - /// - public ServiceResult OnSetConfiguration( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("SetConfiguration expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out ExtensionObject ext)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("SetConfiguration argument 0 is not an ExtensionObject.")); - } - if (!ext.TryGetValue(out PubSubConfigurationDataType? cfg) || cfg is null) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "SetConfiguration argument 0 body is not a PubSubConfigurationDataType.")); - } - try - { - ArrayOf results = m_application - .ReplaceConfigurationAsync(cfg) - .AsTask().GetAwaiter().GetResult(); - outputArguments.Add(Variant.From([.. results])); - return ServiceResult.Good; - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.SetConfigurationFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.6 GetConfiguration. - /// - public ServiceResult OnGetConfiguration( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = inputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - try - { - PubSubConfigurationDataType config = m_application.GetConfiguration(); - outputArguments.Add(Variant.From(new ExtensionObject(config))); - return ServiceResult.Good; - } - catch (Exception ex) - { - m_logger.GetConfigurationFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.4.5 AddPublishedDataItems. - /// - public ServiceResult OnAddPublishedDataItems( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 4) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedDataItems expects 4 input arguments.")); - } - if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedDataItems argument 0 (Name) is missing or empty.")); - } - string[] aliases = TryGetStringArray(inputArguments[1]); - if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedDataItems argument 3 is not a PublishedVariableDataType array.")); - } - PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet(name, aliases, variables, null); - return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: true); - } - - /// - /// Implements Part 14 §9.1.4.5 AddPublishedEvents. - /// - public ServiceResult OnAddPublishedEvents( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 6) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedEvents expects 6 input arguments.")); - } - if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedEvents argument 0 (Name) is missing or empty.")); - } - if (!inputArguments[1].TryGetValue(out NodeId eventNotifier) || eventNotifier.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedEvents argument 1 is not a valid NodeId.")); - } - string[] aliases = TryGetStringArray(inputArguments[2]); - if (!TryGetEncodeableArray(inputArguments[4], context, out SimpleAttributeOperand[] selectedFields)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedEvents argument 4 is not a SimpleAttributeOperand array.")); - } - ContentFilter filter = inputArguments[5].TryGetValue(out ExtensionObject filterObject) && - filterObject.TryGetValue(out ContentFilter? decodedFilter) && - decodedFilter is not null - ? decodedFilter - : new ContentFilter(); - PublishedDataSetDataType dataSet = CreatePublishedEventsDataSet( - name, - eventNotifier, - aliases, - selectedFields, - filter, - null); - return AddPublishedDataSet(dataSet, selectedFields.Length, outputArguments, includeConfigurationVersion: true); - } - - /// - /// Implements Part 14 §9.1.4.5 AddPublishedDataItemsTemplate. - /// - public ServiceResult OnAddPublishedDataItemsTemplate( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 3) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedDataItemsTemplate expects 3 input arguments.")); - } - if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedDataItemsTemplate argument 0 (Name) is missing or empty.")); - } - if (!inputArguments[1].TryGetValue(out ExtensionObject metaDataObject) || - !metaDataObject.TryGetValue(out DataSetMetaDataType? metaData) || - metaData is null) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedDataItemsTemplate argument 1 is not DataSetMetaDataType.")); - } - if (!TryGetEncodeableArray(inputArguments[2], context, out PublishedVariableDataType[] variables)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddPublishedDataItemsTemplate argument 2 is not a PublishedVariableDataType array.")); - } - PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet( - name, - [], - variables, - metaData); - return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: false); - } - - /// - /// Implements Part 14 §9.1.6 RemovePublishedDataSet. - /// - public ServiceResult OnRemovePublishedDataSet( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = outputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemovePublishedDataSet expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out NodeId dataSetId) || - dataSetId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "RemovePublishedDataSet argument 0 is not a valid NodeId.")); - } - try - { - m_application.RemovePublishedDataSetAsync(dataSetId) - .AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.RemovePublishedDataSetFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.5 AddDataSetFolder. - /// The server NodeManager materializes the returned folder NodeId - /// because folders are address-space objects, not configuration - /// records. - /// - public ServiceResult OnAddDataSetFolder( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddDataSetFolder expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out string folderName) || - string.IsNullOrEmpty(folderName)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddDataSetFolder argument 0 (FolderName) is missing or empty.")); - } - outputArguments.Add(Variant.From( - new NodeId($"pubsub:folder:{folderName}", 0))); - return ServiceResult.Good; - } - - /// - /// Implements Part 14 §9.1.5 RemoveDataSetFolder. - /// The server NodeManager owns address-space removal for folder nodes. - /// - public ServiceResult OnRemoveDataSetFolder( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = outputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveDataSetFolder expects 1 input argument.")); - } - return ServiceResult.Good; - } - - /// - /// Implements Part 14 §9.1.6 AddWriterGroup. - /// - public ServiceResult OnAddWriterGroup( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 2) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddWriterGroup expects 2 input arguments.")); - } - if (!inputArguments[0].TryGetValue(out NodeId connectionId) || - connectionId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddWriterGroup argument 0 (ConnectionId) is not a valid NodeId.")); - } - if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddWriterGroup argument 1 is not an ExtensionObject.")); - } - if (!ext.TryGetValue(out WriterGroupDataType? cfg) || cfg is null) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddWriterGroup argument 1 body is not a WriterGroupDataType.")); - } - try - { - NodeId id = m_application.AddWriterGroupAsync(connectionId, cfg) - .AsTask().GetAwaiter().GetResult(); - outputArguments.Add(Variant.From(id)); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.AddWriterGroupFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.6 AddReaderGroup. - /// - public ServiceResult OnAddReaderGroup( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 2) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddReaderGroup expects 2 input arguments.")); - } - if (!inputArguments[0].TryGetValue(out NodeId connectionId) || - connectionId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddReaderGroup argument 0 (ConnectionId) is not a valid NodeId.")); - } - if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddReaderGroup argument 1 is not an ExtensionObject.")); - } - if (!ext.TryGetValue(out ReaderGroupDataType? cfg) || cfg is null) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddReaderGroup argument 1 body is not a ReaderGroupDataType.")); - } - try - { - NodeId id = m_application.AddReaderGroupAsync(connectionId, cfg) - .AsTask().GetAwaiter().GetResult(); - outputArguments.Add(Variant.From(id)); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.AddReaderGroupFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.6 RemoveGroup. - /// - public ServiceResult OnRemoveGroup( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = outputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveGroup expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out NodeId groupId) || - groupId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "RemoveGroup argument 0 is not a valid NodeId.")); - } - try - { - m_application.RemoveGroupAsync(groupId) - .AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.RemoveGroupFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.7 AddDataSetWriter. - /// - public ServiceResult OnAddDataSetWriter( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 2) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddDataSetWriter expects 2 input arguments.")); - } - if (!inputArguments[0].TryGetValue(out NodeId writerGroupId) || - writerGroupId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddDataSetWriter argument 0 (WriterGroupId) is not a valid NodeId.")); - } - if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddDataSetWriter argument 1 is not an ExtensionObject.")); - } - if (!ext.TryGetValue(out DataSetWriterDataType? cfg) || cfg is null) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddDataSetWriter argument 1 body is not a DataSetWriterDataType.")); - } - try - { - NodeId id = m_application.AddDataSetWriterAsync(writerGroupId, cfg) - .AsTask().GetAwaiter().GetResult(); - outputArguments.Add(Variant.From(id)); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.AddDataSetWriterFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.7 RemoveDataSetWriter. - /// - public ServiceResult OnRemoveDataSetWriter( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = outputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveDataSetWriter expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out NodeId writerId) || - writerId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "RemoveDataSetWriter argument 0 is not a valid NodeId.")); - } - try - { - m_application.RemoveDataSetWriterAsync(writerId) - .AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.RemoveDataSetWriterFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.8 AddDataSetReader. - /// - public ServiceResult OnAddDataSetReader( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 2) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddDataSetReader expects 2 input arguments.")); - } - if (!inputArguments[0].TryGetValue(out NodeId readerGroupId) || - readerGroupId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddDataSetReader argument 0 (ReaderGroupId) is not a valid NodeId.")); - } - if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddDataSetReader argument 1 is not an ExtensionObject.")); - } - if (!ext.TryGetValue(out DataSetReaderDataType? cfg) || cfg is null) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "AddDataSetReader argument 1 body is not a DataSetReaderDataType.")); - } - try - { - NodeId id = m_application.AddDataSetReaderAsync(readerGroupId, cfg) - .AsTask().GetAwaiter().GetResult(); - outputArguments.Add(Variant.From(id)); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.AddDataSetReaderFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.8 RemoveDataSetReader. - /// - public ServiceResult OnRemoveDataSetReader( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = outputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveDataSetReader expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out NodeId readerId) || - readerId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText( - "RemoveDataSetReader argument 0 is not a valid NodeId.")); - } - try - { - m_application.RemoveDataSetReaderAsync(readerId) - .AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (ArgumentException aex) - { - return new ServiceResult( - StatusCodes.BadNodeIdUnknown, - new LocalizedText(aex.Message)); - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult( - StatusCodes.BadConfigurationError, - new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.RemoveDataSetReaderFailed(ex); - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText(ex.Message)); - } - } - - /// - /// Implements Part 14 §9.1.4.3 AddVariables. - /// - public ServiceResult OnAddVariables( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (!TryGetPublishedDataSetName(method, out string dataSetName)) - { - return new ServiceResult(StatusCodes.BadNodeIdUnknown); - } - if (inputArguments.Count < 4) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddVariables expects 4 input arguments.")); - } - string[] aliases = TryGetStringArray(inputArguments[1]); - if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddVariables argument 3 is not a PublishedVariableDataType array.")); - } - - return MutatePublishedDataItems( - dataSetName, - (dataSet, items) => - { - List published = ClonePublishedVariables(items); - published.AddRange(variables); - items.PublishedData = [.. published]; - AppendMetaDataFields(dataSet, aliases, variables.Length); - return variables.Length; - }, - outputArguments); - } - - /// - /// Implements Part 14 §9.1.4.3 RemoveVariables. - /// - public ServiceResult OnRemoveVariables( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (!TryGetPublishedDataSetName(method, out string dataSetName)) - { - return new ServiceResult(StatusCodes.BadNodeIdUnknown); - } - if (inputArguments.Count < 2) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveVariables expects 2 input arguments.")); - } - if (!TryGetUInt32Array(inputArguments[1], out uint[] variablesToRemove)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveVariables argument 1 is not a UInt32 array.")); - } - - return MutatePublishedDataItems( - dataSetName, - (dataSet, items) => - { - List published = ClonePublishedVariables(items); - List fields = CloneMetaDataFields(dataSet.DataSetMetaData); - Array.Sort(variablesToRemove); - int removed = 0; - for (int i = variablesToRemove.Length - 1; i >= 0; i--) - { - int index = checked((int)variablesToRemove[i]); - if (index < 0 || index >= published.Count) - { - continue; - } - published.RemoveAt(index); - if (index < fields.Count) - { - fields.RemoveAt(index); - } - removed++; - } - items.PublishedData = [.. published]; - dataSet.DataSetMetaData ??= new DataSetMetaDataType(); - dataSet.DataSetMetaData.Fields = [.. fields]; - return removed; - }, - outputArguments); - } - - private ServiceResult AddPublishedDataSet( - PublishedDataSetDataType dataSet, - int resultCount, - List outputArguments, - bool includeConfigurationVersion) - { - try - { - NodeId dataSetId = m_application.AddPublishedDataSetAsync(dataSet) - .AsTask().GetAwaiter().GetResult(); - PublishedDataSetDataType? added = FindPublishedDataSet(dataSet.Name ?? string.Empty); - outputArguments.Add(Variant.From(dataSetId)); - if (includeConfigurationVersion) - { - outputArguments.Add(Variant.From(new ExtensionObject( - added?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType()))); - } - outputArguments.Add(Variant.From(CreateGoodResults(resultCount))); - return ServiceResult.Good; - } - catch (PubSubConfigurationException vex) - { - return new ServiceResult(StatusCodes.BadConfigurationError, new LocalizedText(vex.Message)); - } - catch (Exception ex) - { - m_logger.AddPublishedDataSetFailed(ex); - return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); - } - } - - private ServiceResult MutatePublishedDataItems( - string dataSetName, - Func mutator, - List outputArguments) - { - try - { - PubSubConfigurationDataType configuration = m_application.GetConfiguration(); - var clone = (PubSubConfigurationDataType)configuration.Clone(); - if (clone.PublishedDataSets.IsNull) - { - return new ServiceResult(StatusCodes.BadNodeIdUnknown); - } - int index = FindIndexByName(clone.PublishedDataSets, dataSetName); - if (index < 0) - { - return new ServiceResult(StatusCodes.BadNodeIdUnknown); - } - PublishedDataSetDataType dataSet = clone.PublishedDataSets[index]; - if (dataSet.DataSetSource.IsNull || - !dataSet.DataSetSource.TryGetValue(out PublishedDataItemsDataType? items) || - items is null) - { - return new ServiceResult( - StatusCodes.BadInvalidState, - new LocalizedText("The PublishedDataSet is not a PublishedDataItemsType instance.")); - } - - int resultCount = mutator(dataSet, items); - dataSet.DataSetSource = new ExtensionObject(items); - _ = m_application.ReplaceConfigurationAsync(clone) - .AsTask().GetAwaiter().GetResult(); - PublishedDataSetDataType? updated = FindPublishedDataSet(dataSetName); - outputArguments.Add(Variant.From(new ExtensionObject( - updated?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType()))); - outputArguments.Add(Variant.From(CreateGoodResults(resultCount))); - return ServiceResult.Good; - } - catch (Exception ex) - { - m_logger.PublishedDataItemsMutationFailed(ex); - return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); - } - } - - private static PublishedDataSetDataType CreatePublishedDataItemsDataSet( - string name, - string[] aliases, - PublishedVariableDataType[] variables, - DataSetMetaDataType? templateMetaData) - { - DataSetMetaDataType metaData = templateMetaData is null - ? CreateMetaData(name, aliases, variables.Length) - : (DataSetMetaDataType)templateMetaData.Clone(); - if (templateMetaData is not null) - { - metaData.Fields = [.. CloneMetaDataFields(templateMetaData)]; - } - if (metaData.Fields.IsNull || metaData.Fields.Count == 0) - { - metaData.Fields = CreateFields(aliases, variables.Length); - } - return new PublishedDataSetDataType - { - Name = name, - DataSetMetaData = metaData, - DataSetSource = new ExtensionObject(new PublishedDataItemsDataType - { - PublishedData = [.. variables] - }) - }; - } - - private static PublishedDataSetDataType CreatePublishedEventsDataSet( - string name, - NodeId eventNotifier, - string[] aliases, - SimpleAttributeOperand[] selectedFields, - ContentFilter filter, - DataSetMetaDataType? templateMetaData) - { - DataSetMetaDataType metaData = templateMetaData is null - ? CreateMetaData(name, aliases, selectedFields.Length) - : (DataSetMetaDataType)templateMetaData.Clone(); - if (templateMetaData is not null) - { - metaData.Fields = [.. CloneMetaDataFields(templateMetaData)]; - } - if (metaData.Fields.IsNull || metaData.Fields.Count == 0) - { - metaData.Fields = CreateFields(aliases, selectedFields.Length); - } - return new PublishedDataSetDataType - { - Name = name, - DataSetMetaData = metaData, - DataSetSource = new ExtensionObject(new PublishedEventsDataType - { - EventNotifier = eventNotifier, - SelectedFields = [.. selectedFields], - Filter = filter - }) - }; - } - - private static DataSetMetaDataType CreateMetaData( - string name, - string[] aliases, - int fieldCount) - { - return new DataSetMetaDataType - { - Name = name, - Fields = CreateFields(aliases, fieldCount) - }; - } - - private static ArrayOf CreateFields(string[] aliases, int fieldCount) - { - var fields = new FieldMetaData[fieldCount]; - for (int i = 0; i < fields.Length; i++) - { - string fieldName = i < aliases.Length && !string.IsNullOrEmpty(aliases[i]) - ? aliases[i] - : $"Field{i + 1}"; - fields[i] = new FieldMetaData - { - Name = fieldName, - DataType = DataTypeIds.BaseDataType, - ValueRank = ValueRanks.Scalar, - Properties = [] - }; - } - return [.. fields]; - } - - private static void AppendMetaDataFields( - PublishedDataSetDataType dataSet, - string[] aliases, - int fieldCount) - { - dataSet.DataSetMetaData ??= new DataSetMetaDataType(); - List fields = CloneMetaDataFields(dataSet.DataSetMetaData); - ArrayOf newFields = CreateFields(aliases, fieldCount); - for (int i = 0; i < newFields.Count; i++) - { - fields.Add(newFields[i]); - } - dataSet.DataSetMetaData.Fields = [.. fields]; - } - - private PublishedDataSetDataType? FindPublishedDataSet(string name) - { - PubSubConfigurationDataType configuration = m_application.GetConfiguration(); - if (configuration.PublishedDataSets.IsNull) - { - return null; - } - int index = FindIndexByName(configuration.PublishedDataSets, name); - return index < 0 ? null : configuration.PublishedDataSets[index]; - } - - private static int FindIndexByName( - ArrayOf dataSets, - string name) - { - for (int i = 0; i < dataSets.Count; i++) - { - if (StringComparer.Ordinal.Equals(dataSets[i].Name, name)) - { - return i; - } - } - return -1; - } - - private static List ClonePublishedVariables( - PublishedDataItemsDataType items) - { - var published = new List(); - if (items.PublishedData.IsNull) - { - return published; - } - foreach (PublishedVariableDataType item in items.PublishedData) - { - published.Add((PublishedVariableDataType)item.Clone()); - } - return published; - } - - private static List CloneMetaDataFields(DataSetMetaDataType? metaData) - { - var fields = new List(); - if (metaData is null || metaData.Fields.IsNull) - { - return fields; - } - foreach (FieldMetaData field in metaData.Fields) - { - fields.Add((FieldMetaData)field.Clone()); - } - return fields; - } - - private static StatusCode[] CreateGoodResults(int count) - { - var results = new StatusCode[count]; - for (int i = 0; i < results.Length; i++) - { - results[i] = StatusCodes.Good; - } - return results; - } - - private static string[] TryGetStringArray(Variant value) - { - if (!value.TryGetValue(out ArrayOf values) || values.IsNull) - { - return []; - } - string[] result = new string[values.Count]; - for (int i = 0; i < values.Count; i++) - { - result[i] = values[i]; - } - return result; - } - - private static bool TryGetUInt32Array(Variant value, out uint[] result) - { - result = []; - if (!value.TryGetValue(out ArrayOf values) || values.IsNull) - { - return false; - } - result = new uint[values.Count]; - for (int i = 0; i < values.Count; i++) - { - result[i] = values[i]; - } - return true; - } - - private static bool TryGetEncodeableArray( - Variant value, - ISystemContext context, - out T[] result) - where T : class, IEncodeable - { - result = []; - IServiceMessageContext? messageContext = context as IServiceMessageContext - ?? AmbientMessageContext.CurrentContext; - if (!value.TryGetValue(out ArrayOf values, messageContext) || values.IsNull) - { - return false; - } - result = new T[values.Count]; - for (int i = 0; i < values.Count; i++) - { - result[i] = values[i]; - } - return true; - } - - private static bool TryGetPublishedDataSetName(MethodState method, out string dataSetName) - { - dataSetName = string.Empty; - string nodeId; - if (method?.Parent is BaseObjectState parent) - { - nodeId = parent.NodeId.IdentifierAsString; - } - else if (method?.NodeId is not null) - { - nodeId = method.NodeId.IdentifierAsString; - } - else - { - return false; - } - const string prefix = "pubsub:published-data-set:"; - if (!nodeId.StartsWith(prefix, StringComparison.Ordinal)) - { - return false; - } - dataSetName = nodeId[prefix.Length..]; - int separator = dataSetName.IndexOf(':', StringComparison.Ordinal); - if (separator >= 0) - { - dataSetName = dataSetName[..separator]; - } - return dataSetName.Length > 0; - } - - /// - /// Implements Part 14 §8.3.4 AddSecurityGroup. - /// Delegates to - /// . - /// - /// System context. - /// Calling method node. - /// Input arguments. - /// Output arguments. - public ServiceResult OnAddSecurityGroup( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (m_keyService is null) - { - return new ServiceResult(StatusCodes.BadServiceUnsupported); - } - if (inputArguments.Count < 5) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText($"AddSecurityGroup expects 5 input arguments; got {inputArguments.Count}.")); - } - if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddSecurityGroup argument 0 (SecurityGroupName) is missing or empty.")); - } - if (!inputArguments[1].TryGetValue(out double keyLifetimeMs) || keyLifetimeMs <= 0) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddSecurityGroup argument 1 (KeyLifetime) must be a positive Duration.")); - } - if (!inputArguments[2].TryGetValue(out string? policyUri) || string.IsNullOrEmpty(policyUri)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddSecurityGroup argument 2 (SecurityPolicyUri) is missing or empty.")); - } - if (!inputArguments[3].TryGetValue(out uint maxFuture)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddSecurityGroup argument 3 (MaxFutureKeyCount) is not a UInt32.")); - } - if (!inputArguments[4].TryGetValue(out uint maxPast)) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("AddSecurityGroup argument 4 (MaxPastKeyCount) is not a UInt32.")); - } - - var group = new SksSecurityGroup( - securityGroupId: name, - securityPolicyUri: policyUri, - keyLifetime: TimeSpan.FromMilliseconds(keyLifetimeMs), - maxFutureKeyCount: (int)Math.Min(maxFuture, int.MaxValue), - maxPastKeyCount: (int)Math.Min(maxPast, int.MaxValue), - keys: Array.Empty(), - authorizedCallerIdentities: TryReadAuthorizedCallers(inputArguments, 6), - rolePermissions: TryReadRolePermissions(inputArguments, 5)); - - try - { - m_keyService - .AddSecurityGroupAsync(group) - .AsTask() - .GetAwaiter() - .GetResult(); - } - catch (OpcUaSksException ex) - { - m_logger.AddSecurityGroupRejected(ex, name, ex.Status); - return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); - } - catch (Exception ex) - { - m_logger.AddSecurityGroupThrewUnexpectedly(ex, name); - return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message)); - } - - NodeId groupNodeId = GetOrAllocateSecurityGroupNodeId(name); - outputArguments.Add(Variant.From(name)); - outputArguments.Add(Variant.From(groupNodeId)); - return ServiceResult.Good; - } - - /// - /// Implements Part 14 §8.3.5 RemoveSecurityGroup. - /// - /// System context. - /// Calling method node. - /// Input arguments. - /// Output arguments (none). - public ServiceResult OnRemoveSecurityGroup( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = outputArguments; - if (m_keyService is null) - { - return new ServiceResult(StatusCodes.BadServiceUnsupported); - } - if (inputArguments.Count < 1) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveSecurityGroup expects 1 input argument.")); - } - if (!inputArguments[0].TryGetValue(out NodeId groupNodeId) || groupNodeId.IsNull) - { - return new ServiceResult( - StatusCodes.BadInvalidArgument, - new LocalizedText("RemoveSecurityGroup argument 0 (SecurityGroupNodeId) is missing or not a NodeId.")); - } - string? id = LookupSecurityGroupId(groupNodeId); - if (id is null) - { - return new ServiceResult(StatusCodes.BadNodeIdUnknown); - } - try - { - m_keyService - .RemoveSecurityGroupAsync(id) - .AsTask() - .GetAwaiter() - .GetResult(); - } - catch (OpcUaSksException ex) - { - m_logger.RemoveSecurityGroupRejected(ex, id, ex.Status); - return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); - } - catch (Exception ex) - { - m_logger.RemoveSecurityGroupThrewUnexpectedly(ex, id); - return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message)); - } - lock (m_gate) - { - m_securityGroupNodeIds.Remove(groupNodeId); - } - return ServiceResult.Good; - } - - /// - /// Implements Part 14 §8.3.2 GetSecurityKeys. - /// Delegates to . - /// - /// System context. - /// Calling method node. - /// Object the method is called on. - /// Input arguments. - /// Output arguments. - public ServiceResult OnGetSecurityKeys( - ISystemContext context, - MethodState method, - NodeId objectId, - ArrayOf inputArguments, - List outputArguments) - { - _ = method; - if (m_sks is null) - { - return new ServiceResult(StatusCodes.BadServiceUnsupported); - } - return m_sks.HandleGetSecurityKeys(context, objectId, inputArguments.ToList(), outputArguments); - } - - /// - /// Implements Part 14 §8.3.3 GetSecurityGroup. - /// - public ServiceResult OnGetSecurityGroup( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - if (m_keyService is null) - { - return new ServiceResult(StatusCodes.BadServiceUnsupported); - } - if (inputArguments.Count < 1 || - !inputArguments[0].TryGetValue(out string? securityGroupId) || - string.IsNullOrEmpty(securityGroupId)) - { - return new ServiceResult(StatusCodes.BadInvalidArgument); - } - - SksSecurityGroup? group = m_keyService - .GetSecurityGroupAsync(securityGroupId) - .AsTask() - .GetAwaiter() - .GetResult(); - if (group is null) - { - return new ServiceResult(StatusCodes.BadNoMatch); - } - - outputArguments.Add(Variant.From(GetOrAllocateSecurityGroupNodeId(securityGroupId))); - return ServiceResult.Good; - } - - /// - /// Implements Part 14 §9.1.3.3 SetSecurityKeys. - /// - public ServiceResult OnSetSecurityKeys( - ISystemContext context, - MethodState method, - ArrayOf inputArguments, - List outputArguments) - { - _ = method; - _ = outputArguments; - if (!m_options.ExposeConfigurationMethods) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (!IsSecurityKeyPushAuthorized(context)) - { - return new ServiceResult(StatusCodes.BadUserAccessDenied); - } - if (inputArguments.Count < 7) - { - return new ServiceResult(StatusCodes.BadInvalidArgument); - } - if (!inputArguments[0].TryGetValue(out string? securityGroupId) || - string.IsNullOrEmpty(securityGroupId) || - !inputArguments[1].TryGetValue(out string? policyUri) || - string.IsNullOrEmpty(policyUri) || - !inputArguments[2].TryGetValue(out uint currentTokenId) || - !inputArguments[3].TryGetValue(out ByteString currentKey) || - !inputArguments[4].TryGetValue(out ArrayOf futureKeys) || - !inputArguments[5].TryGetValue(out double timeToNextKeyMs) || - !inputArguments[6].TryGetValue(out double keyLifetimeMs)) - { - return new ServiceResult(StatusCodes.BadInvalidArgument); - } - - PushSecurityKeyProvider? provider = FindPushProvider(securityGroupId); - if (provider is null) - { - return new ServiceResult(StatusCodes.BadNotFound); - } - - try - { - provider.SetSecurityKeysAsync( - policyUri, - currentTokenId, - currentKey, - futureKeys, - TimeSpan.FromMilliseconds(timeToNextKeyMs), - TimeSpan.FromMilliseconds(keyLifetimeMs)) - .AsTask() - .GetAwaiter() - .GetResult(); - return ServiceResult.Good; - } - catch (OpcUaSksException ex) - { - return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); - } - } - - private static bool IsSecurityKeyPushAuthorized(ISystemContext context) - { - if (StringComparer.Ordinal.Equals(context.UserId, "sks")) - { - return true; - } - - if (context is not ISessionOperationContext sessionContext) - { - return false; - } - - ArrayOf grantedRoleIds = sessionContext.UserIdentity?.GrantedRoleIds ?? []; - return grantedRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin); - } - - /// - /// Implements Part 14 §8.4.2 InvalidateKeys. - /// - public ServiceResult OnInvalidateKeys( - ISystemContext context, - MethodState method, - NodeId objectId, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = inputArguments; - _ = outputArguments; - return RotateOrInvalidateKeys(objectId, invalidate: true); - } - - /// - /// Implements Part 14 §8.4.3 ForceKeyRotation. - /// - public ServiceResult OnForceKeyRotation( - ISystemContext context, - MethodState method, - NodeId objectId, - ArrayOf inputArguments, - List outputArguments) - { - _ = context; - _ = method; - _ = inputArguments; - _ = outputArguments; - return RotateOrInvalidateKeys(objectId, invalidate: false); - } - - /// - /// Returns the NodeId previously allocated for the - /// SecurityGroup identified by , - /// or when the id is unknown to this - /// handler. - /// - /// SecurityGroup identifier. - public NodeId? TryGetSecurityGroupNodeId(string securityGroupId) - { - if (string.IsNullOrEmpty(securityGroupId)) - { - return null; - } - lock (m_gate) - { - foreach (KeyValuePair kvp in m_securityGroupNodeIds) - { - if (string.Equals(kvp.Value, securityGroupId, StringComparison.Ordinal)) - { - return kvp.Key; - } - } - return null; - } - } - - /// - /// Resolves a SecurityGroup node id to its SecurityGroupId. - /// - /// SecurityGroup node id. - public string? LookupSecurityGroupIdForNode(NodeId groupNodeId) - { - return LookupSecurityGroupId(groupNodeId); - } - - private ServiceResult RotateOrInvalidateKeys(NodeId groupNodeId, bool invalidate) - { - if (m_keyService is null) - { - return new ServiceResult(StatusCodes.BadServiceUnsupported); - } - string? id = LookupSecurityGroupId(groupNodeId); - if (id is null) - { - return new ServiceResult(StatusCodes.BadNodeIdUnknown); - } - try - { - ValueTask task = invalidate - ? m_keyService.InvalidateKeysAsync(id) - : m_keyService.ForceKeyRotationAsync(id); - task.AsTask().GetAwaiter().GetResult(); - return ServiceResult.Good; - } - catch (OpcUaSksException ex) - { - return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); - } - } - - private PushSecurityKeyProvider? FindPushProvider(string securityGroupId) - { - for (int i = 0; i < m_pushProviders.Length; i++) - { - if (string.Equals(m_pushProviders[i].SecurityGroupId, securityGroupId, StringComparison.Ordinal)) - { - return m_pushProviders[i]; - } - } - return null; - } - - private NodeId GetOrAllocateSecurityGroupNodeId(string securityGroupId) - { - NodeId? existing = TryGetSecurityGroupNodeId(securityGroupId); - return existing ?? CreateSecurityGroupNodeId(securityGroupId); - } - - private static ArrayOf TryReadRolePermissions( - ArrayOf inputArguments, - int index) - { - if (inputArguments.Count <= index) - { - return []; - } - if (!inputArguments[index].TryGetValue(out ArrayOf rolePermissionsArray)) - { - return []; - } - - var rolePermissions = new List(rolePermissionsArray.Count); - for (int i = 0; i < rolePermissionsArray.Count; i++) - { - if (rolePermissionsArray[i].TryGetValue(out RolePermissionType? rolePermission) && - rolePermission is not null) - { - rolePermissions.Add(rolePermission); - } - } - return [.. rolePermissions]; - } - - private static ArrayOf TryReadAuthorizedCallers(ArrayOf inputArguments, int index) - { - if (inputArguments.Count <= index) - { - return []; - } - return inputArguments[index].TryGetValue(out ArrayOf callers) ? callers : []; - } - - private string? LookupSecurityGroupId(NodeId groupNodeId) - { - lock (m_gate) - { - if (m_securityGroupNodeIds.TryGetValue(groupNodeId, out string? id)) - { - return id; - } - } - if (groupNodeId.IdType == IdType.String && - groupNodeId.TryGetValue(out string identifier) && - !string.IsNullOrEmpty(identifier)) - { - const string prefix = "pubsub:security-group:"; - return identifier.StartsWith(prefix, StringComparison.Ordinal) - ? identifier[prefix.Length..] - : identifier; - } - return null; - } - - private NodeId CreateSecurityGroupNodeId(string securityGroupId) - { - ushort namespaceIndex; - lock (m_gate) - { - namespaceIndex = m_securityGroupNamespaceIndex; - } - var nodeId = new NodeId($"pubsub:security-group:{securityGroupId}", namespaceIndex); - lock (m_gate) - { - m_securityGroupNodeIds[nodeId] = securityGroupId; - } - return nodeId; - } - - /// - /// Returns the default SecurityPolicyUri for the SKS host. - /// - public string DefaultPolicyUri => m_options.DefaultSecurityPolicyUri ?? DefaultSecurityPolicyUri; - } - - /// - /// Source-generated log messages for PubSubMethodHandlers. - /// - internal static partial class PubSubMethodHandlersLog - { - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 0, Level = LogLevel.Warning, - Message = "PublishSubscribe Enable failed.")] - public static partial void PublishSubscribeEnableFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 1, Level = LogLevel.Warning, - Message = "PublishSubscribe Disable failed.")] - public static partial void PublishSubscribeDisableFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 2, Level = LogLevel.Warning, - Message = "AddConnection failed.")] - public static partial void AddConnectionFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 3, Level = LogLevel.Warning, - Message = "RemoveConnection failed.")] - public static partial void RemoveConnectionFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 4, Level = LogLevel.Warning, - Message = "SetConfiguration failed.")] - public static partial void SetConfigurationFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 5, Level = LogLevel.Warning, - Message = "GetConfiguration failed.")] - public static partial void GetConfigurationFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 6, Level = LogLevel.Warning, - Message = "RemovePublishedDataSet failed.")] - public static partial void RemovePublishedDataSetFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 7, Level = LogLevel.Warning, - Message = "AddWriterGroup failed.")] - public static partial void AddWriterGroupFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 8, Level = LogLevel.Warning, - Message = "AddReaderGroup failed.")] - public static partial void AddReaderGroupFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 9, Level = LogLevel.Warning, - Message = "RemoveGroup failed.")] - public static partial void RemoveGroupFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 10, Level = LogLevel.Warning, - Message = "AddDataSetWriter failed.")] - public static partial void AddDataSetWriterFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 11, Level = LogLevel.Warning, - Message = "RemoveDataSetWriter failed.")] - public static partial void RemoveDataSetWriterFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 12, Level = LogLevel.Warning, - Message = "AddDataSetReader failed.")] - public static partial void AddDataSetReaderFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 13, Level = LogLevel.Warning, - Message = "RemoveDataSetReader failed.")] - public static partial void RemoveDataSetReaderFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 14, Level = LogLevel.Warning, - Message = "AddPublishedDataSet failed.")] - public static partial void AddPublishedDataSetFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 15, Level = LogLevel.Warning, - Message = "PublishedDataItems mutation failed.")] - public static partial void PublishedDataItemsMutationFailed(this ILogger logger, Exception exception); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 16, Level = LogLevel.Debug, - Message = "AddSecurityGroup {Name} rejected with {Status}.")] - public static partial void AddSecurityGroupRejected( - this ILogger logger, - Exception exception, - string name, - StatusCode status); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 17, Level = LogLevel.Error, - Message = "AddSecurityGroup {Name} threw unexpectedly.")] - public static partial void AddSecurityGroupThrewUnexpectedly( - this ILogger logger, - Exception exception, - string name); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 18, Level = LogLevel.Debug, - Message = "RemoveSecurityGroup {Id} rejected with {Status}.")] - public static partial void RemoveSecurityGroupRejected( - this ILogger logger, - Exception exception, - string id, - StatusCode status); - - [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 19, Level = LogLevel.Error, - Message = "RemoveSecurityGroup {Id} threw unexpectedly.")] - public static partial void RemoveSecurityGroupThrewUnexpectedly( - this ILogger logger, - Exception exception, - string id); - - } - -} +/* ======================================================================== + * 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.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua.PubSub.Application; +using Opc.Ua.PubSub.Configuration; +using Opc.Ua.PubSub.Security; +using Opc.Ua.PubSub.Security.Sks; + +namespace Opc.Ua.PubSub.Server +{ + /// + /// Hosts the synchronous method-handler delegates the + /// attaches to the standard + /// PublishSubscribe Method nodes (Part 14 §9.1.3, + /// §9.1.10 and §8.3.1). + /// + /// + /// Implements the configuration-mutation entry-points + /// via the mutable surface. + /// All entry-points adhere to the legacy synchronous + /// GenericMethodCalledEventHandler contract; every async + /// call is forwarded via .AsTask().GetAwaiter().GetResult() + /// — the single sanctioned sync-over-async bridge. + /// + internal sealed class PubSubMethodHandlers + { + private const string DefaultSecurityPolicyUri = + "http://opcfoundation.org/UA/SecurityPolicy#PubSub-Aes256-CTR"; + + private readonly IPubSubApplication m_application; + private readonly IPubSubKeyServiceServer? m_keyService; + private readonly PubSubServerOptions m_options; + private readonly SksMethodHandler? m_sks; + private readonly PushSecurityKeyProvider[] m_pushProviders; + private readonly ILogger m_logger; + private readonly Dictionary m_securityGroupNodeIds = []; + private readonly System.Threading.Lock m_gate = new(); + private ushort m_securityGroupNamespaceIndex; + + /// + /// Creates a new . + /// + /// Runtime application. + /// + /// SKS server, or when the host is + /// not acting as an SKS. + /// + /// PubSub server options. + /// Telemetry context. + /// Optional SetSecurityKeys push providers. + public PubSubMethodHandlers( + IPubSubApplication application, + IPubSubKeyServiceServer? keyService, + PubSubServerOptions options, + ITelemetryContext telemetry, + IEnumerable? pushProviders = null) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + if (telemetry is null) + { + throw new ArgumentNullException(nameof(telemetry)); + } + m_application = application; + m_keyService = keyService; + m_options = options; + m_sks = keyService is null ? null : new SksMethodHandler(keyService, telemetry); + m_pushProviders = pushProviders?.ToArray() ?? []; + m_logger = telemetry.CreateLogger(); + } + + /// + /// Sets the namespace index used for SecurityGroup instance NodeIds. + /// + /// PubSub node-manager namespace index. + public void SetSecurityGroupNamespaceIndex(ushort namespaceIndex) + { + lock (m_gate) + { + m_securityGroupNamespaceIndex = namespaceIndex; + } + } + + /// + /// Registers a materialized SecurityGroup node. + /// + /// SecurityGroup identifier. + /// Routable SecurityGroup node id. + /// + public void RegisterSecurityGroupNodeId(string securityGroupId, NodeId nodeId) + { + if (string.IsNullOrEmpty(securityGroupId)) + { + throw new ArgumentException("SecurityGroupId must be non-empty.", nameof(securityGroupId)); + } + + lock (m_gate) + { + m_securityGroupNodeIds[nodeId] = securityGroupId; + } + } + + /// + /// Implements Part 14 §9.1.10.2 Status.Enable. + /// + /// System context. + /// Calling method node. + /// Input arguments (none). + /// Output arguments (none). + public ServiceResult OnEnable( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = inputArguments; + _ = outputArguments; + try + { + m_application.StartAsync().AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (Exception ex) + { + m_logger.PublishSubscribeEnableFailed(ex); + return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.10.3 Status.Disable. + /// + /// System context. + /// Calling method node. + /// Input arguments (none). + /// Output arguments (none). + public ServiceResult OnDisable( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = inputArguments; + _ = outputArguments; + try + { + m_application.StopAsync().AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (Exception ex) + { + m_logger.PublishSubscribeDisableFailed(ex); + return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.3.4 AddConnection. + /// Delegates to + /// . + /// + public ServiceResult OnAddConnection( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddConnection expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out ExtensionObject ext)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddConnection argument 0 is not an ExtensionObject.")); + } + if (!ext.TryGetValue(out PubSubConnectionDataType? cfg) || cfg is null) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddConnection argument 0 body is not a PubSubConnectionDataType.")); + } + try + { + NodeId id = m_application.AddConnectionAsync(cfg) + .AsTask().GetAwaiter().GetResult(); + outputArguments.Add(Variant.From(id)); + return ServiceResult.Good; + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.AddConnectionFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.3.5 RemoveConnection. + /// Delegates to + /// . + /// + public ServiceResult OnRemoveConnection( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = outputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveConnection expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out NodeId connectionId) || + connectionId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveConnection argument 0 is not a valid NodeId.")); + } + try + { + m_application.RemoveConnectionAsync(connectionId) + .AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (Exception ex) + { + m_logger.RemoveConnectionFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.6 SetConfiguration. + /// + public ServiceResult OnSetConfiguration( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("SetConfiguration expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out ExtensionObject ext)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("SetConfiguration argument 0 is not an ExtensionObject.")); + } + if (!ext.TryGetValue(out PubSubConfigurationDataType? cfg) || cfg is null) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "SetConfiguration argument 0 body is not a PubSubConfigurationDataType.")); + } + try + { + ArrayOf results = m_application + .ReplaceConfigurationAsync(cfg) + .AsTask().GetAwaiter().GetResult(); + outputArguments.Add(Variant.From([.. results])); + return ServiceResult.Good; + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.SetConfigurationFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.6 GetConfiguration. + /// + public ServiceResult OnGetConfiguration( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = inputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + try + { + PubSubConfigurationDataType config = m_application.GetConfiguration(); + outputArguments.Add(Variant.From(new ExtensionObject(config))); + return ServiceResult.Good; + } + catch (Exception ex) + { + m_logger.GetConfigurationFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.4.5 AddPublishedDataItems. + /// + public ServiceResult OnAddPublishedDataItems( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 4) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedDataItems expects 4 input arguments.")); + } + if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedDataItems argument 0 (Name) is missing or empty.")); + } + string[] aliases = TryGetStringArray(inputArguments[1]); + if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedDataItems argument 3 is not a PublishedVariableDataType array.")); + } + PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet(name, aliases, variables, null); + return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: true); + } + + /// + /// Implements Part 14 §9.1.4.5 AddPublishedEvents. + /// + public ServiceResult OnAddPublishedEvents( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 6) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedEvents expects 6 input arguments.")); + } + if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedEvents argument 0 (Name) is missing or empty.")); + } + if (!inputArguments[1].TryGetValue(out NodeId eventNotifier) || eventNotifier.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedEvents argument 1 is not a valid NodeId.")); + } + string[] aliases = TryGetStringArray(inputArguments[2]); + if (!TryGetEncodeableArray(inputArguments[4], context, out SimpleAttributeOperand[] selectedFields)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedEvents argument 4 is not a SimpleAttributeOperand array.")); + } + ContentFilter filter = inputArguments[5].TryGetValue(out ExtensionObject filterObject) && + filterObject.TryGetValue(out ContentFilter? decodedFilter) && + decodedFilter is not null + ? decodedFilter + : new ContentFilter(); + PublishedDataSetDataType dataSet = CreatePublishedEventsDataSet( + name, + eventNotifier, + aliases, + selectedFields, + filter, + null); + var dataSetOutputs = new List(); + ServiceResult result = AddPublishedDataSet( + dataSet, + selectedFields.Length, + dataSetOutputs, + includeConfigurationVersion: true); + if (StatusCode.IsBad(result.StatusCode)) + { + return result; + } + outputArguments.Add(dataSetOutputs[1]); + outputArguments.Add(dataSetOutputs[0]); + return result; + } + + /// + /// Implements Part 14 §9.1.4.5 AddPublishedDataItemsTemplate. + /// + public ServiceResult OnAddPublishedDataItemsTemplate( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 3) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedDataItemsTemplate expects 3 input arguments.")); + } + if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedDataItemsTemplate argument 0 (Name) is missing or empty.")); + } + if (!inputArguments[1].TryGetValue(out ExtensionObject metaDataObject) || + !metaDataObject.TryGetValue(out DataSetMetaDataType? metaData) || + metaData is null) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedDataItemsTemplate argument 1 is not DataSetMetaDataType.")); + } + if (!TryGetEncodeableArray(inputArguments[2], context, out PublishedVariableDataType[] variables)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddPublishedDataItemsTemplate argument 2 is not a PublishedVariableDataType array.")); + } + PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet( + name, + [], + variables, + metaData); + return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: false); + } + + /// + /// Implements Part 14 §9.1.6 RemovePublishedDataSet. + /// + public ServiceResult OnRemovePublishedDataSet( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = outputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemovePublishedDataSet expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out NodeId dataSetId) || + dataSetId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "RemovePublishedDataSet argument 0 is not a valid NodeId.")); + } + try + { + m_application.RemovePublishedDataSetAsync(dataSetId) + .AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.RemovePublishedDataSetFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.5 AddDataSetFolder. + /// The server NodeManager materializes the returned folder NodeId + /// because folders are address-space objects, not configuration + /// records. + /// + public ServiceResult OnAddDataSetFolder( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddDataSetFolder expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out string folderName) || + string.IsNullOrEmpty(folderName)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddDataSetFolder argument 0 (FolderName) is missing or empty.")); + } + outputArguments.Add(Variant.From( + new NodeId($"pubsub:folder:{folderName}", 0))); + return ServiceResult.Good; + } + + /// + /// Implements Part 14 §9.1.5 RemoveDataSetFolder. + /// The server NodeManager owns address-space removal for folder nodes. + /// + public ServiceResult OnRemoveDataSetFolder( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = outputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveDataSetFolder expects 1 input argument.")); + } + return ServiceResult.Good; + } + + /// + /// Implements Part 14 §9.1.6 AddWriterGroup. + /// + public ServiceResult OnAddWriterGroup( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 2) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddWriterGroup expects 2 input arguments.")); + } + if (!inputArguments[0].TryGetValue(out NodeId connectionId) || + connectionId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddWriterGroup argument 0 (ConnectionId) is not a valid NodeId.")); + } + if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddWriterGroup argument 1 is not an ExtensionObject.")); + } + if (!ext.TryGetValue(out WriterGroupDataType? cfg) || cfg is null) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddWriterGroup argument 1 body is not a WriterGroupDataType.")); + } + try + { + NodeId id = m_application.AddWriterGroupAsync(connectionId, cfg) + .AsTask().GetAwaiter().GetResult(); + outputArguments.Add(Variant.From(id)); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.AddWriterGroupFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.6 AddReaderGroup. + /// + public ServiceResult OnAddReaderGroup( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 2) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddReaderGroup expects 2 input arguments.")); + } + if (!inputArguments[0].TryGetValue(out NodeId connectionId) || + connectionId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddReaderGroup argument 0 (ConnectionId) is not a valid NodeId.")); + } + if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddReaderGroup argument 1 is not an ExtensionObject.")); + } + if (!ext.TryGetValue(out ReaderGroupDataType? cfg) || cfg is null) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddReaderGroup argument 1 body is not a ReaderGroupDataType.")); + } + try + { + NodeId id = m_application.AddReaderGroupAsync(connectionId, cfg) + .AsTask().GetAwaiter().GetResult(); + outputArguments.Add(Variant.From(id)); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.AddReaderGroupFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.6 RemoveGroup. + /// + public ServiceResult OnRemoveGroup( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = outputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveGroup expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out NodeId groupId) || + groupId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "RemoveGroup argument 0 is not a valid NodeId.")); + } + try + { + m_application.RemoveGroupAsync(groupId) + .AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.RemoveGroupFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.7 AddDataSetWriter. + /// + public ServiceResult OnAddDataSetWriter( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 2) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddDataSetWriter expects 2 input arguments.")); + } + if (!inputArguments[0].TryGetValue(out NodeId writerGroupId) || + writerGroupId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddDataSetWriter argument 0 (WriterGroupId) is not a valid NodeId.")); + } + if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddDataSetWriter argument 1 is not an ExtensionObject.")); + } + if (!ext.TryGetValue(out DataSetWriterDataType? cfg) || cfg is null) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddDataSetWriter argument 1 body is not a DataSetWriterDataType.")); + } + try + { + NodeId id = m_application.AddDataSetWriterAsync(writerGroupId, cfg) + .AsTask().GetAwaiter().GetResult(); + outputArguments.Add(Variant.From(id)); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.AddDataSetWriterFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.7 RemoveDataSetWriter. + /// + public ServiceResult OnRemoveDataSetWriter( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = outputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveDataSetWriter expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out NodeId writerId) || + writerId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "RemoveDataSetWriter argument 0 is not a valid NodeId.")); + } + try + { + m_application.RemoveDataSetWriterAsync(writerId) + .AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.RemoveDataSetWriterFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.8 AddDataSetReader. + /// + public ServiceResult OnAddDataSetReader( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 2) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddDataSetReader expects 2 input arguments.")); + } + if (!inputArguments[0].TryGetValue(out NodeId readerGroupId) || + readerGroupId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddDataSetReader argument 0 (ReaderGroupId) is not a valid NodeId.")); + } + if (!inputArguments[1].TryGetValue(out ExtensionObject ext)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddDataSetReader argument 1 is not an ExtensionObject.")); + } + if (!ext.TryGetValue(out DataSetReaderDataType? cfg) || cfg is null) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "AddDataSetReader argument 1 body is not a DataSetReaderDataType.")); + } + try + { + NodeId id = m_application.AddDataSetReaderAsync(readerGroupId, cfg) + .AsTask().GetAwaiter().GetResult(); + outputArguments.Add(Variant.From(id)); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.AddDataSetReaderFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.8 RemoveDataSetReader. + /// + public ServiceResult OnRemoveDataSetReader( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = outputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveDataSetReader expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out NodeId readerId) || + readerId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText( + "RemoveDataSetReader argument 0 is not a valid NodeId.")); + } + try + { + m_application.RemoveDataSetReaderAsync(readerId) + .AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (ArgumentException aex) + { + return new ServiceResult( + StatusCodes.BadNodeIdUnknown, + new LocalizedText(aex.Message)); + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.RemoveDataSetReaderFailed(ex); + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText(ex.Message)); + } + } + + /// + /// Implements Part 14 §9.1.4.3 AddVariables. + /// + public ServiceResult OnAddVariables( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (!TryGetPublishedDataSetName(method, out string dataSetName)) + { + return new ServiceResult(StatusCodes.BadNodeIdUnknown); + } + if (inputArguments.Count < 4) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddVariables expects 4 input arguments.")); + } + string[] aliases = TryGetStringArray(inputArguments[1]); + if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddVariables argument 3 is not a PublishedVariableDataType array.")); + } + + return MutatePublishedDataItems( + dataSetName, + (dataSet, items) => + { + List published = ClonePublishedVariables(items); + published.AddRange(variables); + items.PublishedData = [.. published]; + AppendMetaDataFields(dataSet, aliases, variables.Length); + return variables.Length; + }, + outputArguments); + } + + /// + /// Implements Part 14 §9.1.4.3 RemoveVariables. + /// + public ServiceResult OnRemoveVariables( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (!TryGetPublishedDataSetName(method, out string dataSetName)) + { + return new ServiceResult(StatusCodes.BadNodeIdUnknown); + } + if (inputArguments.Count < 2) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveVariables expects 2 input arguments.")); + } + if (!TryGetUInt32Array(inputArguments[1], out uint[] variablesToRemove)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveVariables argument 1 is not a UInt32 array.")); + } + + return MutatePublishedDataItems( + dataSetName, + (dataSet, items) => + { + List published = ClonePublishedVariables(items); + List fields = CloneMetaDataFields(dataSet.DataSetMetaData); + Array.Sort(variablesToRemove); + int removed = 0; + for (int i = variablesToRemove.Length - 1; i >= 0; i--) + { + int index = checked((int)variablesToRemove[i]); + if (index < 0 || index >= published.Count) + { + continue; + } + published.RemoveAt(index); + if (index < fields.Count) + { + fields.RemoveAt(index); + } + removed++; + } + items.PublishedData = [.. published]; + dataSet.DataSetMetaData ??= new DataSetMetaDataType(); + dataSet.DataSetMetaData.Fields = [.. fields]; + return removed; + }, + outputArguments); + } + + private ServiceResult AddPublishedDataSet( + PublishedDataSetDataType dataSet, + int resultCount, + List outputArguments, + bool includeConfigurationVersion) + { + try + { + NodeId dataSetId = m_application.AddPublishedDataSetAsync(dataSet) + .AsTask().GetAwaiter().GetResult(); + PublishedDataSetDataType? added = FindPublishedDataSet(dataSet.Name ?? string.Empty); + outputArguments.Add(Variant.From(dataSetId)); + if (includeConfigurationVersion) + { + outputArguments.Add(Variant.From(new ExtensionObject( + added?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType()))); + } + outputArguments.Add(Variant.From(CreateGoodResults(resultCount))); + return ServiceResult.Good; + } + catch (PubSubConfigurationException vex) + { + return new ServiceResult(StatusCodes.BadConfigurationError, new LocalizedText(vex.Message)); + } + catch (Exception ex) + { + m_logger.AddPublishedDataSetFailed(ex); + return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); + } + } + + private ServiceResult MutatePublishedDataItems( + string dataSetName, + Func mutator, + List outputArguments) + { + try + { + PubSubConfigurationDataType configuration = m_application.GetConfiguration(); + var clone = (PubSubConfigurationDataType)configuration.Clone(); + if (clone.PublishedDataSets.IsNull) + { + return new ServiceResult(StatusCodes.BadNodeIdUnknown); + } + int index = FindIndexByName(clone.PublishedDataSets, dataSetName); + if (index < 0) + { + return new ServiceResult(StatusCodes.BadNodeIdUnknown); + } + PublishedDataSetDataType dataSet = clone.PublishedDataSets[index]; + if (dataSet.DataSetSource.IsNull || + !dataSet.DataSetSource.TryGetValue(out PublishedDataItemsDataType? items) || + items is null) + { + return new ServiceResult( + StatusCodes.BadInvalidState, + new LocalizedText("The PublishedDataSet is not a PublishedDataItemsType instance.")); + } + + int resultCount = mutator(dataSet, items); + dataSet.DataSetSource = new ExtensionObject(items); + _ = m_application.ReplaceConfigurationAsync(clone) + .AsTask().GetAwaiter().GetResult(); + PublishedDataSetDataType? updated = FindPublishedDataSet(dataSetName); + outputArguments.Add(Variant.From(new ExtensionObject( + updated?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType()))); + outputArguments.Add(Variant.From(CreateGoodResults(resultCount))); + return ServiceResult.Good; + } + catch (Exception ex) + { + m_logger.PublishedDataItemsMutationFailed(ex); + return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message)); + } + } + + private static PublishedDataSetDataType CreatePublishedDataItemsDataSet( + string name, + string[] aliases, + PublishedVariableDataType[] variables, + DataSetMetaDataType? templateMetaData) + { + DataSetMetaDataType metaData = templateMetaData is null + ? CreateMetaData(name, aliases, variables.Length) + : (DataSetMetaDataType)templateMetaData.Clone(); + if (templateMetaData is not null) + { + metaData.Fields = [.. CloneMetaDataFields(templateMetaData)]; + } + if (metaData.Fields.IsNull || metaData.Fields.Count == 0) + { + metaData.Fields = CreateFields(aliases, variables.Length); + } + return new PublishedDataSetDataType + { + Name = name, + DataSetMetaData = metaData, + DataSetSource = new ExtensionObject(new PublishedDataItemsDataType + { + PublishedData = [.. variables] + }) + }; + } + + private static PublishedDataSetDataType CreatePublishedEventsDataSet( + string name, + NodeId eventNotifier, + string[] aliases, + SimpleAttributeOperand[] selectedFields, + ContentFilter filter, + DataSetMetaDataType? templateMetaData) + { + DataSetMetaDataType metaData = templateMetaData is null + ? CreateMetaData(name, aliases, selectedFields.Length) + : (DataSetMetaDataType)templateMetaData.Clone(); + if (templateMetaData is not null) + { + metaData.Fields = [.. CloneMetaDataFields(templateMetaData)]; + } + if (metaData.Fields.IsNull || metaData.Fields.Count == 0) + { + metaData.Fields = CreateFields(aliases, selectedFields.Length); + } + return new PublishedDataSetDataType + { + Name = name, + DataSetMetaData = metaData, + DataSetSource = new ExtensionObject(new PublishedEventsDataType + { + EventNotifier = eventNotifier, + SelectedFields = [.. selectedFields], + Filter = filter + }) + }; + } + + private static DataSetMetaDataType CreateMetaData( + string name, + string[] aliases, + int fieldCount) + { + return new DataSetMetaDataType + { + Name = name, + Fields = CreateFields(aliases, fieldCount) + }; + } + + private static ArrayOf CreateFields(string[] aliases, int fieldCount) + { + var fields = new FieldMetaData[fieldCount]; + for (int i = 0; i < fields.Length; i++) + { + string fieldName = i < aliases.Length && !string.IsNullOrEmpty(aliases[i]) + ? aliases[i] + : $"Field{i + 1}"; + fields[i] = new FieldMetaData + { + Name = fieldName, + DataType = DataTypeIds.BaseDataType, + ValueRank = ValueRanks.Scalar, + Properties = [] + }; + } + return [.. fields]; + } + + private static void AppendMetaDataFields( + PublishedDataSetDataType dataSet, + string[] aliases, + int fieldCount) + { + dataSet.DataSetMetaData ??= new DataSetMetaDataType(); + List fields = CloneMetaDataFields(dataSet.DataSetMetaData); + ArrayOf newFields = CreateFields(aliases, fieldCount); + for (int i = 0; i < newFields.Count; i++) + { + fields.Add(newFields[i]); + } + dataSet.DataSetMetaData.Fields = [.. fields]; + } + + private PublishedDataSetDataType? FindPublishedDataSet(string name) + { + PubSubConfigurationDataType configuration = m_application.GetConfiguration(); + if (configuration.PublishedDataSets.IsNull) + { + return null; + } + int index = FindIndexByName(configuration.PublishedDataSets, name); + return index < 0 ? null : configuration.PublishedDataSets[index]; + } + + private static int FindIndexByName( + ArrayOf dataSets, + string name) + { + for (int i = 0; i < dataSets.Count; i++) + { + if (StringComparer.Ordinal.Equals(dataSets[i].Name, name)) + { + return i; + } + } + return -1; + } + + private static List ClonePublishedVariables( + PublishedDataItemsDataType items) + { + var published = new List(); + if (items.PublishedData.IsNull) + { + return published; + } + foreach (PublishedVariableDataType item in items.PublishedData) + { + published.Add((PublishedVariableDataType)item.Clone()); + } + return published; + } + + private static List CloneMetaDataFields(DataSetMetaDataType? metaData) + { + var fields = new List(); + if (metaData is null || metaData.Fields.IsNull) + { + return fields; + } + foreach (FieldMetaData field in metaData.Fields) + { + fields.Add((FieldMetaData)field.Clone()); + } + return fields; + } + + private static StatusCode[] CreateGoodResults(int count) + { + var results = new StatusCode[count]; + for (int i = 0; i < results.Length; i++) + { + results[i] = StatusCodes.Good; + } + return results; + } + + private static string[] TryGetStringArray(Variant value) + { + if (!value.TryGetValue(out ArrayOf values) || values.IsNull) + { + return []; + } + string[] result = new string[values.Count]; + for (int i = 0; i < values.Count; i++) + { + result[i] = values[i]; + } + return result; + } + + private static bool TryGetUInt32Array(Variant value, out uint[] result) + { + result = []; + if (!value.TryGetValue(out ArrayOf values) || values.IsNull) + { + return false; + } + result = new uint[values.Count]; + for (int i = 0; i < values.Count; i++) + { + result[i] = values[i]; + } + return true; + } + + private static bool TryGetEncodeableArray( + Variant value, + ISystemContext context, + out T[] result) + where T : class, IEncodeable + { + result = []; + IServiceMessageContext? messageContext = context as IServiceMessageContext + ?? AmbientMessageContext.CurrentContext; + if (!value.TryGetValue(out ArrayOf values, messageContext) || values.IsNull) + { + return false; + } + result = new T[values.Count]; + for (int i = 0; i < values.Count; i++) + { + result[i] = values[i]; + } + return true; + } + + private static bool TryGetPublishedDataSetName(MethodState method, out string dataSetName) + { + dataSetName = string.Empty; + string nodeId; + if (method?.Parent is BaseObjectState parent) + { + nodeId = parent.NodeId.IdentifierAsString; + } + else if (method?.NodeId is not null) + { + nodeId = method.NodeId.IdentifierAsString; + } + else + { + return false; + } + const string prefix = "pubsub:published-data-set:"; + if (!nodeId.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + dataSetName = nodeId[prefix.Length..]; + int separator = dataSetName.IndexOf(':', StringComparison.Ordinal); + if (separator >= 0) + { + dataSetName = dataSetName[..separator]; + } + return dataSetName.Length > 0; + } + + /// + /// Implements Part 14 §8.3.4 AddSecurityGroup. + /// Delegates to + /// . + /// + /// System context. + /// Calling method node. + /// Input arguments. + /// Output arguments. + public ServiceResult OnAddSecurityGroup( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (m_keyService is null) + { + return new ServiceResult(StatusCodes.BadServiceUnsupported); + } + if (inputArguments.Count < 5) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText($"AddSecurityGroup expects 5 input arguments; got {inputArguments.Count}.")); + } + if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddSecurityGroup argument 0 (SecurityGroupName) is missing or empty.")); + } + if (!inputArguments[1].TryGetValue(out double keyLifetimeMs) || keyLifetimeMs <= 0) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddSecurityGroup argument 1 (KeyLifetime) must be a positive Duration.")); + } + if (!inputArguments[2].TryGetValue(out string? policyUri) || string.IsNullOrEmpty(policyUri)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddSecurityGroup argument 2 (SecurityPolicyUri) is missing or empty.")); + } + if (!inputArguments[3].TryGetValue(out uint maxFuture)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddSecurityGroup argument 3 (MaxFutureKeyCount) is not a UInt32.")); + } + if (!inputArguments[4].TryGetValue(out uint maxPast)) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("AddSecurityGroup argument 4 (MaxPastKeyCount) is not a UInt32.")); + } + + var group = new SksSecurityGroup( + securityGroupId: name, + securityPolicyUri: policyUri, + keyLifetime: TimeSpan.FromMilliseconds(keyLifetimeMs), + maxFutureKeyCount: (int)Math.Min(maxFuture, int.MaxValue), + maxPastKeyCount: (int)Math.Min(maxPast, int.MaxValue), + keys: Array.Empty(), + authorizedCallerIdentities: TryReadAuthorizedCallers(inputArguments, 6), + rolePermissions: TryReadRolePermissions(inputArguments, 5)); + + try + { + m_keyService + .AddSecurityGroupAsync(group) + .AsTask() + .GetAwaiter() + .GetResult(); + } + catch (OpcUaSksException ex) + { + m_logger.AddSecurityGroupRejected(ex, name, ex.Status); + return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); + } + catch (Exception ex) + { + m_logger.AddSecurityGroupThrewUnexpectedly(ex, name); + return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message)); + } + + NodeId groupNodeId = GetOrAllocateSecurityGroupNodeId(name); + outputArguments.Add(Variant.From(name)); + outputArguments.Add(Variant.From(groupNodeId)); + return ServiceResult.Good; + } + + /// + /// Implements Part 14 §8.3.5 RemoveSecurityGroup. + /// + /// System context. + /// Calling method node. + /// Input arguments. + /// Output arguments (none). + public ServiceResult OnRemoveSecurityGroup( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = outputArguments; + if (m_keyService is null) + { + return new ServiceResult(StatusCodes.BadServiceUnsupported); + } + if (inputArguments.Count < 1) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveSecurityGroup expects 1 input argument.")); + } + if (!inputArguments[0].TryGetValue(out NodeId groupNodeId) || groupNodeId.IsNull) + { + return new ServiceResult( + StatusCodes.BadInvalidArgument, + new LocalizedText("RemoveSecurityGroup argument 0 (SecurityGroupNodeId) is missing or not a NodeId.")); + } + string? id = LookupSecurityGroupId(groupNodeId); + if (id is null) + { + return new ServiceResult(StatusCodes.BadNodeIdUnknown); + } + try + { + m_keyService + .RemoveSecurityGroupAsync(id) + .AsTask() + .GetAwaiter() + .GetResult(); + } + catch (OpcUaSksException ex) + { + m_logger.RemoveSecurityGroupRejected(ex, id, ex.Status); + return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); + } + catch (Exception ex) + { + m_logger.RemoveSecurityGroupThrewUnexpectedly(ex, id); + return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message)); + } + lock (m_gate) + { + m_securityGroupNodeIds.Remove(groupNodeId); + } + return ServiceResult.Good; + } + + /// + /// Implements Part 14 §8.3.2 GetSecurityKeys. + /// Delegates to . + /// + /// System context. + /// Calling method node. + /// Object the method is called on. + /// Input arguments. + /// Output arguments. + public ServiceResult OnGetSecurityKeys( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments) + { + _ = method; + if (m_sks is null) + { + return new ServiceResult(StatusCodes.BadServiceUnsupported); + } + return m_sks.HandleGetSecurityKeys(context, objectId, inputArguments.ToList(), outputArguments); + } + + /// + /// Implements Part 14 §8.3.3 GetSecurityGroup. + /// + public ServiceResult OnGetSecurityGroup( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + if (m_keyService is null) + { + return new ServiceResult(StatusCodes.BadServiceUnsupported); + } + if (inputArguments.Count < 1 || + !inputArguments[0].TryGetValue(out string? securityGroupId) || + string.IsNullOrEmpty(securityGroupId)) + { + return new ServiceResult(StatusCodes.BadInvalidArgument); + } + + SksSecurityGroup? group = m_keyService + .GetSecurityGroupAsync(securityGroupId) + .AsTask() + .GetAwaiter() + .GetResult(); + if (group is null) + { + return new ServiceResult(StatusCodes.BadNoMatch); + } + + outputArguments.Add(Variant.From(GetOrAllocateSecurityGroupNodeId(securityGroupId))); + return ServiceResult.Good; + } + + /// + /// Implements Part 14 §9.1.3.3 SetSecurityKeys. + /// + public ServiceResult OnSetSecurityKeys( + ISystemContext context, + MethodState method, + ArrayOf inputArguments, + List outputArguments) + { + _ = method; + _ = outputArguments; + if (!m_options.ExposeConfigurationMethods) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (!IsSecurityKeyPushAuthorized(context)) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + if (inputArguments.Count < 7) + { + return new ServiceResult(StatusCodes.BadInvalidArgument); + } + if (!inputArguments[0].TryGetValue(out string? securityGroupId) || + string.IsNullOrEmpty(securityGroupId) || + !inputArguments[1].TryGetValue(out string? policyUri) || + string.IsNullOrEmpty(policyUri) || + !inputArguments[2].TryGetValue(out uint currentTokenId) || + !inputArguments[3].TryGetValue(out ByteString currentKey) || + !inputArguments[4].TryGetValue(out ArrayOf futureKeys) || + !inputArguments[5].TryGetValue(out double timeToNextKeyMs) || + !inputArguments[6].TryGetValue(out double keyLifetimeMs)) + { + return new ServiceResult(StatusCodes.BadInvalidArgument); + } + + PushSecurityKeyProvider? provider = FindPushProvider(securityGroupId); + if (provider is null) + { + return new ServiceResult(StatusCodes.BadNotFound); + } + + try + { + provider.SetSecurityKeysAsync( + policyUri, + currentTokenId, + currentKey, + futureKeys, + TimeSpan.FromMilliseconds(timeToNextKeyMs), + TimeSpan.FromMilliseconds(keyLifetimeMs)) + .AsTask() + .GetAwaiter() + .GetResult(); + return ServiceResult.Good; + } + catch (OpcUaSksException ex) + { + return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); + } + } + + private static bool IsSecurityKeyPushAuthorized(ISystemContext context) + { + if (StringComparer.Ordinal.Equals(context.UserId, "sks")) + { + return true; + } + + if (context is not ISessionOperationContext sessionContext) + { + return false; + } + + ArrayOf grantedRoleIds = sessionContext.UserIdentity?.GrantedRoleIds ?? []; + return grantedRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin); + } + + /// + /// Implements Part 14 §8.4.2 InvalidateKeys. + /// + public ServiceResult OnInvalidateKeys( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = inputArguments; + _ = outputArguments; + return RotateOrInvalidateKeys(objectId, invalidate: true); + } + + /// + /// Implements Part 14 §8.4.3 ForceKeyRotation. + /// + public ServiceResult OnForceKeyRotation( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments) + { + _ = context; + _ = method; + _ = inputArguments; + _ = outputArguments; + return RotateOrInvalidateKeys(objectId, invalidate: false); + } + + /// + /// Returns the NodeId previously allocated for the + /// SecurityGroup identified by , + /// or when the id is unknown to this + /// handler. + /// + /// SecurityGroup identifier. + public NodeId? TryGetSecurityGroupNodeId(string securityGroupId) + { + if (string.IsNullOrEmpty(securityGroupId)) + { + return null; + } + lock (m_gate) + { + foreach (KeyValuePair kvp in m_securityGroupNodeIds) + { + if (string.Equals(kvp.Value, securityGroupId, StringComparison.Ordinal)) + { + return kvp.Key; + } + } + return null; + } + } + + /// + /// Resolves a SecurityGroup node id to its SecurityGroupId. + /// + /// SecurityGroup node id. + public string? LookupSecurityGroupIdForNode(NodeId groupNodeId) + { + return LookupSecurityGroupId(groupNodeId); + } + + private ServiceResult RotateOrInvalidateKeys(NodeId groupNodeId, bool invalidate) + { + if (m_keyService is null) + { + return new ServiceResult(StatusCodes.BadServiceUnsupported); + } + string? id = LookupSecurityGroupId(groupNodeId); + if (id is null) + { + return new ServiceResult(StatusCodes.BadNodeIdUnknown); + } + try + { + ValueTask task = invalidate + ? m_keyService.InvalidateKeysAsync(id) + : m_keyService.ForceKeyRotationAsync(id); + task.AsTask().GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (OpcUaSksException ex) + { + return new ServiceResult(ex.Status, new LocalizedText(ex.Message)); + } + } + + private PushSecurityKeyProvider? FindPushProvider(string securityGroupId) + { + for (int i = 0; i < m_pushProviders.Length; i++) + { + if (string.Equals(m_pushProviders[i].SecurityGroupId, securityGroupId, StringComparison.Ordinal)) + { + return m_pushProviders[i]; + } + } + return null; + } + + private NodeId GetOrAllocateSecurityGroupNodeId(string securityGroupId) + { + NodeId? existing = TryGetSecurityGroupNodeId(securityGroupId); + return existing ?? CreateSecurityGroupNodeId(securityGroupId); + } + + private static ArrayOf TryReadRolePermissions( + ArrayOf inputArguments, + int index) + { + if (inputArguments.Count <= index) + { + return []; + } + if (!inputArguments[index].TryGetValue(out ArrayOf rolePermissionsArray)) + { + return []; + } + + var rolePermissions = new List(rolePermissionsArray.Count); + for (int i = 0; i < rolePermissionsArray.Count; i++) + { + if (rolePermissionsArray[i].TryGetValue(out RolePermissionType? rolePermission) && + rolePermission is not null) + { + rolePermissions.Add(rolePermission); + } + } + return [.. rolePermissions]; + } + + private static ArrayOf TryReadAuthorizedCallers(ArrayOf inputArguments, int index) + { + if (inputArguments.Count <= index) + { + return []; + } + return inputArguments[index].TryGetValue(out ArrayOf callers) ? callers : []; + } + + private string? LookupSecurityGroupId(NodeId groupNodeId) + { + lock (m_gate) + { + if (m_securityGroupNodeIds.TryGetValue(groupNodeId, out string? id)) + { + return id; + } + } + if (groupNodeId.IdType == IdType.String && + groupNodeId.TryGetValue(out string identifier) && + !string.IsNullOrEmpty(identifier)) + { + const string prefix = "pubsub:security-group:"; + return identifier.StartsWith(prefix, StringComparison.Ordinal) + ? identifier[prefix.Length..] + : identifier; + } + return null; + } + + private NodeId CreateSecurityGroupNodeId(string securityGroupId) + { + ushort namespaceIndex; + lock (m_gate) + { + namespaceIndex = m_securityGroupNamespaceIndex; + } + var nodeId = new NodeId($"pubsub:security-group:{securityGroupId}", namespaceIndex); + lock (m_gate) + { + m_securityGroupNodeIds[nodeId] = securityGroupId; + } + return nodeId; + } + + /// + /// Returns the default SecurityPolicyUri for the SKS host. + /// + public string DefaultPolicyUri => m_options.DefaultSecurityPolicyUri ?? DefaultSecurityPolicyUri; + } + + /// + /// Source-generated log messages for PubSubMethodHandlers. + /// + internal static partial class PubSubMethodHandlersLog + { + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 0, Level = LogLevel.Warning, + Message = "PublishSubscribe Enable failed.")] + public static partial void PublishSubscribeEnableFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 1, Level = LogLevel.Warning, + Message = "PublishSubscribe Disable failed.")] + public static partial void PublishSubscribeDisableFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 2, Level = LogLevel.Warning, + Message = "AddConnection failed.")] + public static partial void AddConnectionFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 3, Level = LogLevel.Warning, + Message = "RemoveConnection failed.")] + public static partial void RemoveConnectionFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 4, Level = LogLevel.Warning, + Message = "SetConfiguration failed.")] + public static partial void SetConfigurationFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 5, Level = LogLevel.Warning, + Message = "GetConfiguration failed.")] + public static partial void GetConfigurationFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 6, Level = LogLevel.Warning, + Message = "RemovePublishedDataSet failed.")] + public static partial void RemovePublishedDataSetFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 7, Level = LogLevel.Warning, + Message = "AddWriterGroup failed.")] + public static partial void AddWriterGroupFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 8, Level = LogLevel.Warning, + Message = "AddReaderGroup failed.")] + public static partial void AddReaderGroupFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 9, Level = LogLevel.Warning, + Message = "RemoveGroup failed.")] + public static partial void RemoveGroupFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 10, Level = LogLevel.Warning, + Message = "AddDataSetWriter failed.")] + public static partial void AddDataSetWriterFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 11, Level = LogLevel.Warning, + Message = "RemoveDataSetWriter failed.")] + public static partial void RemoveDataSetWriterFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 12, Level = LogLevel.Warning, + Message = "AddDataSetReader failed.")] + public static partial void AddDataSetReaderFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 13, Level = LogLevel.Warning, + Message = "RemoveDataSetReader failed.")] + public static partial void RemoveDataSetReaderFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 14, Level = LogLevel.Warning, + Message = "AddPublishedDataSet failed.")] + public static partial void AddPublishedDataSetFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 15, Level = LogLevel.Warning, + Message = "PublishedDataItems mutation failed.")] + public static partial void PublishedDataItemsMutationFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 16, Level = LogLevel.Debug, + Message = "AddSecurityGroup {Name} rejected with {Status}.")] + public static partial void AddSecurityGroupRejected( + this ILogger logger, + Exception exception, + string name, + StatusCode status); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 17, Level = LogLevel.Error, + Message = "AddSecurityGroup {Name} threw unexpectedly.")] + public static partial void AddSecurityGroupThrewUnexpectedly( + this ILogger logger, + Exception exception, + string name); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 18, Level = LogLevel.Debug, + Message = "RemoveSecurityGroup {Id} rejected with {Status}.")] + public static partial void RemoveSecurityGroupRejected( + this ILogger logger, + Exception exception, + string id, + StatusCode status); + + [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 19, Level = LogLevel.Error, + Message = "RemoveSecurityGroup {Id} threw unexpectedly.")] + public static partial void RemoveSecurityGroupThrewUnexpectedly( + this ILogger logger, + Exception exception, + string id); + + } + +} diff --git a/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs b/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs index 5e093184a1..06e1bf133a 100644 --- a/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs +++ b/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs @@ -87,13 +87,7 @@ namespace Opc.Ua.PubSub.Server private const uint RemoveSecurityGroupNodeId = 15447; private const uint AddPushTargetNodeId = 25441; private const uint RemovePushTargetNodeId = 25444; - private const uint AddPublishedDataItemsNodeId = 14479; - private const uint AddPublishedEventsNodeId = 14482; - private const uint AddPublishedDataItemsTemplateNodeId = 16842; - private const uint RemovePublishedDataSetNodeId = 14485; - private const uint AddDataSetFolderNodeId = 16884; - private const uint RemoveDataSetFolderNodeId = 16923; - private static readonly NodeId s_publishedDataSetsNodeId = new(14478u); + private static readonly NodeId s_publishedDataSetsNodeId = ObjectIds.PublishSubscribe_PublishedDataSets; private static readonly NodeId s_securityGroupsNodeId = new(15443u); private static readonly NodeId s_keyPushTargetsNodeId = new(25440u); @@ -312,6 +306,118 @@ namespace Opc.Ua.PubSub.Server BindStandardMethod(file.ReserveIds, OnReservePubSubConfigurationIds); BindStandardMethod(file.CloseAndUpdate, OnCloseAndUpdatePubSubConfigurationFile); diagnosticsNodeManager.AddNode(file); + + // The DataSetFolderType methods of PublishSubscribe.PublishedDataSets + // are Optional, so the standard address space does not contain them. + if (publishSubscribe.PublishedDataSets is { } publishedDataSets) + { + foreach (MethodState method in AddPublishedDataSetsMethods(SystemContext, publishedDataSets)) + { + diagnosticsNodeManager.AddNode(method); + } + } + } + + /// + /// Materializes the implemented DataSetFolderType methods on the standard + /// PublishSubscribe.PublishedDataSets instance with their well-known + /// instance NodeIds. The generated Add* extensions create them with the + /// NodeIds of the DataSetFolderType declarations, which the address space + /// already holds. + /// + internal static ArrayOf AddPublishedDataSetsMethods( + ISystemContext context, + DataSetFolderState publishedDataSets) + { + publishedDataSets.AddAddPublishedDataItems(context); + publishedDataSets.AddAddPublishedEvents(context); + publishedDataSets.AddAddPublishedDataItemsTemplate(context); + publishedDataSets.AddRemovePublishedDataSet(context); + publishedDataSets.AddAddDataSetFolder(context); + publishedDataSets.AddRemoveDataSetFolder(context); + + return + [ + AssignInstanceIds( + publishedDataSets.AddPublishedDataItems, + nameof(publishedDataSets.AddPublishedDataItems), + PublishedDataSetsIds.AddPublishedDataItems, + PublishedDataSetsIds.AddPublishedDataItemsInputArguments, + PublishedDataSetsIds.AddPublishedDataItemsOutputArguments), + AssignInstanceIds( + publishedDataSets.AddPublishedEvents, + nameof(publishedDataSets.AddPublishedEvents), + PublishedDataSetsIds.AddPublishedEvents, + PublishedDataSetsIds.AddPublishedEventsInputArguments, + PublishedDataSetsIds.AddPublishedEventsOutputArguments), + AssignInstanceIds( + publishedDataSets.AddPublishedDataItemsTemplate, + nameof(publishedDataSets.AddPublishedDataItemsTemplate), + PublishedDataSetsIds.AddPublishedDataItemsTemplate, + PublishedDataSetsIds.AddPublishedDataItemsTemplateInputArguments, + PublishedDataSetsIds.AddPublishedDataItemsTemplateOutputArguments), + AssignInstanceIds( + publishedDataSets.RemovePublishedDataSet, + nameof(publishedDataSets.RemovePublishedDataSet), + PublishedDataSetsIds.RemovePublishedDataSet, + PublishedDataSetsIds.RemovePublishedDataSetInputArguments, + outputArgumentsId: null), + AssignInstanceIds( + publishedDataSets.AddDataSetFolder, + nameof(publishedDataSets.AddDataSetFolder), + PublishedDataSetsIds.AddDataSetFolder, + PublishedDataSetsIds.AddDataSetFolderInputArguments, + PublishedDataSetsIds.AddDataSetFolderOutputArguments), + AssignInstanceIds( + publishedDataSets.RemoveDataSetFolder, + nameof(publishedDataSets.RemoveDataSetFolder), + PublishedDataSetsIds.RemoveDataSetFolder, + PublishedDataSetsIds.RemoveDataSetFolderInputArguments, + outputArgumentsId: null) + ]; + } + + /// + /// Gives a materialized method and its argument variables their instance + /// NodeIds. A method without output arguments passes a null + /// ; an argument variable the generated + /// method does not match is a configuration error. + /// + internal static MethodState AssignInstanceIds( + MethodState? method, + string name, + uint methodId, + uint inputArgumentsId, + uint? outputArgumentsId) + { + if (method == null) + { + throw new ServiceResultException(StatusCodes.BadConfigurationError, + $"The generated PublishedDataSets method {name} (i={methodId}) was not created."); + } + if (method.InputArguments == null) + { + throw new ServiceResultException(StatusCodes.BadConfigurationError, + $"The generated PublishedDataSets method {name} (i={methodId}) has no InputArguments."); + } + if (method.OutputArguments != null && outputArgumentsId == null) + { + throw new ServiceResultException(StatusCodes.BadConfigurationError, + $"The generated PublishedDataSets method {name} (i={methodId}) has OutputArguments " + + "that its standard declaration does not have."); + } + if (method.OutputArguments == null && outputArgumentsId != null) + { + throw new ServiceResultException(StatusCodes.BadConfigurationError, + $"The generated PublishedDataSets method {name} (i={methodId}) has no OutputArguments."); + } + method.NodeId = new NodeId(methodId); + method.InputArguments.NodeId = new NodeId(inputArgumentsId); + if (method.OutputArguments != null && outputArgumentsId is uint outputId) + { + method.OutputArguments.NodeId = new NodeId(outputId); + } + return method; } private void BindMethods(IDiagnosticsNodeManager diagnosticsNodeManager) @@ -373,28 +479,44 @@ namespace Opc.Ua.PubSub.Server m_methodsBound = enable is not null || disable is not null; } + /// + /// Binds the methods of the standard PublishSubscribe.PublishedDataSets + /// instance, the folder that holds the PublishedDataSets and DataSetFolders. + /// private void BindPublishedDataSetFolderMethods(IDiagnosticsNodeManager diagnosticsNodeManager) { BindStandardMethod( - diagnosticsNodeManager, AddPublishedDataItemsNodeId, MethodHandlers.OnAddPublishedDataItems); - BindStandardMethod(diagnosticsNodeManager, AddPublishedEventsNodeId, MethodHandlers.OnAddPublishedEvents); + diagnosticsNodeManager, + new NodeId(PublishedDataSetsIds.AddPublishedDataItems), + MethodHandlers.OnAddPublishedDataItems); BindStandardMethod( diagnosticsNodeManager, - AddPublishedDataItemsTemplateNodeId, + new NodeId(PublishedDataSetsIds.AddPublishedEvents), + MethodHandlers.OnAddPublishedEvents); + BindStandardMethod( + diagnosticsNodeManager, + new NodeId(PublishedDataSetsIds.AddPublishedDataItemsTemplate), MethodHandlers.OnAddPublishedDataItemsTemplate); BindStandardMethod( - diagnosticsNodeManager, RemovePublishedDataSetNodeId, MethodHandlers.OnRemovePublishedDataSet); - BindStandardMethod(diagnosticsNodeManager, AddDataSetFolderNodeId, OnAddDataSetFolder); - BindStandardMethod(diagnosticsNodeManager, RemoveDataSetFolderNodeId, OnRemoveDataSetFolder); + diagnosticsNodeManager, + new NodeId(PublishedDataSetsIds.RemovePublishedDataSet), + MethodHandlers.OnRemovePublishedDataSet); + BindStandardMethod( + diagnosticsNodeManager, + new NodeId(PublishedDataSetsIds.AddDataSetFolder), + OnAddDataSetFolder); + BindStandardMethod( + diagnosticsNodeManager, + new NodeId(PublishedDataSetsIds.RemoveDataSetFolder), + OnRemoveDataSetFolder); } private static void BindStandardMethod( IDiagnosticsNodeManager diagnosticsNodeManager, - uint nodeId, + NodeId nodeId, GenericMethodCalledEventHandler handler) { - MethodState? method = diagnosticsNodeManager - .FindPredefinedNode(new NodeId(nodeId)); + MethodState? method = diagnosticsNodeManager.FindPredefinedNode(nodeId); BindStandardMethod(method, handler); } @@ -1907,6 +2029,31 @@ namespace Opc.Ua.PubSub.Server } } + /// + /// The NodeIds of the methods of the PublishSubscribe.PublishedDataSets + /// instance (i=17371) and of their argument variables, not those of the + /// DataSetFolderType declarations. + /// + private static class PublishedDataSetsIds + { + public const uint AddPublishedDataItems = 17372; + public const uint AddPublishedDataItemsInputArguments = 17373; + public const uint AddPublishedDataItemsOutputArguments = 17374; + public const uint AddPublishedEvents = 17375; + public const uint AddPublishedEventsInputArguments = 17376; + public const uint AddPublishedEventsOutputArguments = 17377; + public const uint AddPublishedDataItemsTemplate = 17378; + public const uint AddPublishedDataItemsTemplateInputArguments = 17379; + public const uint AddPublishedDataItemsTemplateOutputArguments = 17380; + public const uint RemovePublishedDataSet = 17384; + public const uint RemovePublishedDataSetInputArguments = 17385; + public const uint AddDataSetFolder = 17398; + public const uint AddDataSetFolderInputArguments = 17401; + public const uint AddDataSetFolderOutputArguments = 17402; + public const uint RemoveDataSetFolder = 17403; + public const uint RemoveDataSetFolderInputArguments = 17404; + } + private sealed class PubSubKeyPushTargetRegistration { public PubSubKeyPushTargetRegistration( diff --git a/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs index 913bf5375d..1bb70807e6 100644 --- a/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs +++ b/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs @@ -1,464 +1,470 @@ -/* ======================================================================== - * 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.Threading; -using System.Threading.Tasks; -using NUnit.Framework; -using Opc.Ua.PubSub.Application; -using Opc.Ua.PubSub.Tests; -using Opc.Ua.PubSub.Transports; -using Opc.Ua.Tests; - -namespace Opc.Ua.PubSub.Server.Tests -{ - [TestFixture] - [TestSpec("9.1.3.4", Summary = "AddConnection handler")] - [TestSpec("9.1.3.5", Summary = "RemoveConnection handler")] - [TestSpec("9.1.6", Summary = "Configuration methods")] - [TestSpec("9.1.4.3", Summary = "PublishedDataItemsType variable methods")] - [TestSpec("9.1.4.5", Summary = "DataSetFolderType dataset methods")] - public class PubSubMethodHandlersMutationTests - { - [Test] - [TestSpec("9.1.3.4")] - public void OnAddConnection_ValidInput_ReturnsGoodAndNodeId() - { - PubSubMethodHandlers handlers = CreateHandlers(); - var connCfg = new PubSubConnectionDataType - { - Name = "handler-conn", - TransportProfileUri = - "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp", - Address = new ExtensionObject( - new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" }) - }; - ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(connCfg))); - var outputs = new List(); - ServiceResult result = handlers.OnAddConnection( - BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs); - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - Assert.That(outputs, Has.Count.EqualTo(1)); - Assert.That(outputs[0].TryGetValue(out NodeId id), Is.True); - Assert.That(id.IsNull, Is.False); - }); - } - - [Test] - [TestSpec("9.1.3.5")] - public void OnRemoveConnection_ValidInput_ReturnsGood() - { - PubSubMethodHandlers handlers = CreateHandlers(); - var connCfg = new PubSubConnectionDataType - { - Name = "to-remove", - TransportProfileUri = - "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp", - Address = new ExtensionObject( - new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" }) - }; - ArrayOf addInputs = BuildArray(Variant.From(new ExtensionObject(connCfg))); - var addOutputs = new List(); - handlers.OnAddConnection( - BuildContext(), method: null!, inputArguments: addInputs, outputArguments: addOutputs); - Assert.That(addOutputs[0].TryGetValue(out NodeId connId), Is.True); - - ArrayOf removeInputs = BuildArray(Variant.From(connId)); - var removeOutputs = new List(); - ServiceResult result = handlers.OnRemoveConnection( - BuildContext(), method: null!, inputArguments: removeInputs, - outputArguments: removeOutputs); - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - } - - [Test] - [TestSpec("9.1.6")] - public void OnGetConfiguration_ReturnsGood() - { - PubSubMethodHandlers handlers = CreateHandlers(); - var outputs = new List(); - ServiceResult result = handlers.OnGetConfiguration( - BuildContext(), method: null!, inputArguments: default, outputArguments: outputs); - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - Assert.That(outputs, Has.Count.EqualTo(1)); - }); - } - - [Test] - [TestSpec("9.1.6")] - public void OnSetConfiguration_ValidInput_ReturnsGood() - { - PubSubMethodHandlers handlers = CreateHandlers(); - var cfg = new PubSubConfigurationDataType - { - Connections = [], - PublishedDataSets = [] - }; - ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(cfg))); - var outputs = new List(); - ServiceResult result = handlers.OnSetConfiguration( - BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs); - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - } - - [Test] - [TestSpec("9.1.4.5")] - public void OnAddPublishedDataItems_RegistersPublishedDataItemsDataSet() - { - IPubSubApplication application = CreateApplication(); - PubSubMethodHandlers handlers = CreateHandlers(application); - var outputs = new List(); - var variable = new PublishedVariableDataType - { - PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime), - AttributeId = Attributes.Value - }; - - ServiceResult result = handlers.OnAddPublishedDataItems( - BuildContext(), - method: null!, - inputArguments: BuildArray( - Variant.From("items"), - Variant.From(new ArrayOf(s_currentTimeAlias)), - Variant.From(Array.Empty()), - Variant.FromStructure(new ArrayOf( - new[] { variable }))), - outputArguments: outputs); - - PubSubConfigurationDataType configuration = application.GetConfiguration(); - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - Assert.That(outputs, Has.Count.EqualTo(3)); - Assert.That(configuration.PublishedDataSets, Has.Count.EqualTo(1)); - Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( - out PublishedDataItemsDataType? _), Is.True); - }); - } - - [Test] - [TestSpec("9.1.4.5")] - public void OnAddPublishedDataItemsTemplate_UsesProvidedMetaData() - { - IPubSubApplication application = CreateApplication(); - PubSubMethodHandlers handlers = CreateHandlers(application); - var outputs = new List(); - var variable = new PublishedVariableDataType - { - PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime), - AttributeId = Attributes.Value - }; - var metaData = new DataSetMetaDataType - { - Name = "template", - Fields = new ArrayOf(new[] - { - new FieldMetaData - { - Name = "FromTemplate", - DataType = DataTypeIds.DateTime, - ValueRank = ValueRanks.Scalar - } - }) - }; - - ServiceResult result = handlers.OnAddPublishedDataItemsTemplate( - BuildContext(), - method: null!, - inputArguments: BuildArray( - Variant.From("template"), - Variant.From(new ExtensionObject(metaData)), - Variant.FromStructure(new ArrayOf( - new[] { variable }))), - outputArguments: outputs); - - PubSubConfigurationDataType configuration = application.GetConfiguration(); - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - Assert.That(outputs, Has.Count.EqualTo(2)); - Assert.That(configuration.PublishedDataSets[0].Name, Is.EqualTo("template")); - Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( - out PublishedDataItemsDataType? _), Is.True); - }); - } - - [Test] - [TestSpec("9.1.4.5")] - public void OnAddPublishedEvents_RegistersPublishedEventsDataSet() - { - IPubSubApplication application = CreateApplication(); - PubSubMethodHandlers handlers = CreateHandlers(application); - var outputs = new List(); - var selectedField = new SimpleAttributeOperand - { - TypeDefinitionId = ObjectTypeIds.BaseEventType, - BrowsePath = new ArrayOf(new[] { new QualifiedName(BrowseNames.EventId) }), - AttributeId = Attributes.Value - }; - - ServiceResult result = handlers.OnAddPublishedEvents( - BuildContext(), - method: NewPublishedDataItemsMethod("items", "AddVariables"), - inputArguments: BuildArray( - Variant.From("events"), - Variant.From(ObjectIds.Server), - Variant.From(new ArrayOf(s_eventIdAlias)), - Variant.From(Array.Empty()), - Variant.FromStructure(new ArrayOf( - new[] { selectedField })), - Variant.From(new ExtensionObject(new ContentFilter()))), - outputArguments: outputs); - - PubSubConfigurationDataType configuration = application.GetConfiguration(); - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - Assert.That(outputs, Has.Count.EqualTo(3)); - Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( - out PublishedEventsDataType? _), Is.True); - }); - } - - [Test] - [TestSpec("9.1.4.3")] - public void OnAddVariablesAndRemoveVariables_MutateFieldsAndBumpConfigurationVersion() - { - IPubSubApplication application = CreateApplication(); - PubSubMethodHandlers handlers = CreateHandlers(application); - var addDataSetOutputs = new List(); - var variable = new PublishedVariableDataType - { - PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime), - AttributeId = Attributes.Value - }; - handlers.OnAddPublishedDataItems( - BuildContext(), - method: NewPublishedDataItemsMethod("items", "RemoveVariables"), - inputArguments: BuildArray( - Variant.From("items"), - Variant.From(new ArrayOf(s_currentTimeAlias)), - Variant.From(Array.Empty()), - Variant.FromStructure(new ArrayOf( - new[] { variable }))), - outputArguments: addDataSetOutputs); - - var addedVariable = new PublishedVariableDataType - { - PublishedVariable = new NodeId(Variables.Server_ServerStatus_StartTime), - AttributeId = Attributes.Value - }; - var addVariableOutputs = new List(); - ServiceResult addResult = handlers.OnAddVariables( - BuildContext(), - method: NewPublishedDataItemsMethod("items", "AddVariables"), - inputArguments: BuildArray( - addDataSetOutputs[1], - Variant.From(new ArrayOf(s_startTimeAlias)), - Variant.From(s_notPromotedField), - Variant.FromStructure(new ArrayOf( - new[] { addedVariable }))), - outputArguments: addVariableOutputs); - Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); - Assert.That(addVariableOutputs, Has.Count.EqualTo(2)); - var removeVariableOutputs = new List(); - ServiceResult removeResult = handlers.OnRemoveVariables( - BuildContext(), - method: NewPublishedDataItemsMethod("items", "RemoveVariables"), - inputArguments: BuildArray(addVariableOutputs[0], Variant.From(new ArrayOf(s_firstVariableIndex))), - outputArguments: removeVariableOutputs); - - PubSubConfigurationDataType configuration = application.GetConfiguration(); - Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( - out PublishedDataItemsDataType? items), Is.True); - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); - Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True); - Assert.That(items!.PublishedData, Has.Count.EqualTo(1)); - Assert.That(configuration.PublishedDataSets[0].DataSetMetaData.ConfigurationVersion.MinorVersion, - Is.GreaterThan(1u)); - }); - } - - [Test] - [TestSpec("9.1.5")] - public void OnAddDataSetFolder_ReturnsGoodWithNodeId() - { - PubSubMethodHandlers handlers = CreateHandlers(); - ArrayOf inputs = BuildArray(Variant.From("my-folder")); - var outputs = new List(); - ServiceResult result = handlers.OnAddDataSetFolder( - BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs); - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); - Assert.That(outputs, Has.Count.EqualTo(1)); - }); - } - - private static PubSubMethodHandlers CreateHandlers() - { - return CreateHandlers(CreateApplication()); - } - - private static PubSubMethodHandlers CreateHandlers(IPubSubApplication app) - { - var options = new PubSubServerOptions - { - ExposeConfigurationMethods = true - }; - return new PubSubMethodHandlers( - app, null, options, NUnitTelemetryContext.Create()); - } - - private static IPubSubApplication CreateApplication() - { - return new PubSubApplicationBuilder( - NUnitTelemetryContext.Create()) - .WithApplicationId("handler-mutation-tests") - .UseConfiguration(new PubSubConfigurationDataType - { - Connections = [], - PublishedDataSets = [] - }) - .UseAllStandardEncoders() - .AddTransportFactory(new StubTransportFactory()) - .Build(); - } - - private static SystemContext BuildContext() - { - return new SystemContext(NUnitTelemetryContext.Create()); - } - - private static ArrayOf BuildArray(params Variant[] values) - { - return new ArrayOf(values); - } - - private static MethodState NewPublishedDataItemsMethod(string dataSetName, string methodName) - { - var parent = new BaseObjectState(null) - { - NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}", 0), - BrowseName = new QualifiedName(dataSetName) - }; - var method = new MethodState(parent) - { - NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}:{methodName}", 0), - BrowseName = new QualifiedName(methodName) - }; - parent.AddChild(method); - return method; - } - - private static readonly string[] s_currentTimeAlias = ["CurrentTime"]; - private static readonly string[] s_eventIdAlias = ["EventId"]; - private static readonly string[] s_startTimeAlias = ["StartTime"]; - private static readonly bool[] s_notPromotedField = [false]; - private static readonly uint[] s_firstVariableIndex = [0u]; - - private sealed class StubTransportFactory : IPubSubTransportFactory - { - public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; - - public IPubSubTransport Create( - PubSubConnectionDataType connection, - ITelemetryContext telemetry, - TimeProvider timeProvider) - { - _ = connection; - _ = telemetry; - _ = timeProvider; - return new StubTransport(); - } - } - - private sealed class StubTransport : IPubSubTransport - { - private bool m_isConnected; - - public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; - - public PubSubTransportDirection Direction => PubSubTransportDirection.SendReceive; - - public bool IsConnected => m_isConnected; - - public event EventHandler? StateChanged - { - add { } - remove { } - } - - public ValueTask OpenAsync(CancellationToken cancellationToken = default) - { - _ = cancellationToken; - m_isConnected = true; - return default; - } - - public ValueTask CloseAsync(CancellationToken cancellationToken = default) - { - _ = cancellationToken; - m_isConnected = false; - return default; - } - - public ValueTask SendAsync( - ReadOnlyMemory payload, - string? topic = null, - CancellationToken cancellationToken = default) - { - _ = payload; - _ = topic; - _ = cancellationToken; - return default; - } - - public IAsyncEnumerable ReceiveAsync( - CancellationToken cancellationToken = default) - { - _ = cancellationToken; - return TestAsyncEnumerable.Empty(); - } - - public ValueTask DisposeAsync() - { - m_isConnected = false; - return default; - } - } - } -} +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.PubSub.Application; +using Opc.Ua.PubSub.Tests; +using Opc.Ua.PubSub.Transports; +using Opc.Ua.Tests; + +namespace Opc.Ua.PubSub.Server.Tests +{ + [TestFixture] + [TestSpec("9.1.3.4", Summary = "AddConnection handler")] + [TestSpec("9.1.3.5", Summary = "RemoveConnection handler")] + [TestSpec("9.1.6", Summary = "Configuration methods")] + [TestSpec("9.1.4.3", Summary = "PublishedDataItemsType variable methods")] + [TestSpec("9.1.4.5", Summary = "DataSetFolderType dataset methods")] + public class PubSubMethodHandlersMutationTests + { + [Test] + [TestSpec("9.1.3.4")] + public void OnAddConnection_ValidInput_ReturnsGoodAndNodeId() + { + PubSubMethodHandlers handlers = CreateHandlers(); + var connCfg = new PubSubConnectionDataType + { + Name = "handler-conn", + TransportProfileUri = + "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp", + Address = new ExtensionObject( + new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" }) + }; + ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(connCfg))); + var outputs = new List(); + ServiceResult result = handlers.OnAddConnection( + BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(outputs, Has.Count.EqualTo(1)); + Assert.That(outputs[0].TryGetValue(out NodeId id), Is.True); + Assert.That(id.IsNull, Is.False); + }); + } + + [Test] + [TestSpec("9.1.3.5")] + public void OnRemoveConnection_ValidInput_ReturnsGood() + { + PubSubMethodHandlers handlers = CreateHandlers(); + var connCfg = new PubSubConnectionDataType + { + Name = "to-remove", + TransportProfileUri = + "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp", + Address = new ExtensionObject( + new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" }) + }; + ArrayOf addInputs = BuildArray(Variant.From(new ExtensionObject(connCfg))); + var addOutputs = new List(); + handlers.OnAddConnection( + BuildContext(), method: null!, inputArguments: addInputs, outputArguments: addOutputs); + Assert.That(addOutputs[0].TryGetValue(out NodeId connId), Is.True); + + ArrayOf removeInputs = BuildArray(Variant.From(connId)); + var removeOutputs = new List(); + ServiceResult result = handlers.OnRemoveConnection( + BuildContext(), method: null!, inputArguments: removeInputs, + outputArguments: removeOutputs); + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + } + + [Test] + [TestSpec("9.1.6")] + public void OnGetConfiguration_ReturnsGood() + { + PubSubMethodHandlers handlers = CreateHandlers(); + var outputs = new List(); + ServiceResult result = handlers.OnGetConfiguration( + BuildContext(), method: null!, inputArguments: default, outputArguments: outputs); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(outputs, Has.Count.EqualTo(1)); + }); + } + + [Test] + [TestSpec("9.1.6")] + public void OnSetConfiguration_ValidInput_ReturnsGood() + { + PubSubMethodHandlers handlers = CreateHandlers(); + var cfg = new PubSubConfigurationDataType + { + Connections = [], + PublishedDataSets = [] + }; + ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(cfg))); + var outputs = new List(); + ServiceResult result = handlers.OnSetConfiguration( + BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs); + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + } + + [Test] + [TestSpec("9.1.4.5")] + public void OnAddPublishedDataItems_RegistersPublishedDataItemsDataSet() + { + IPubSubApplication application = CreateApplication(); + PubSubMethodHandlers handlers = CreateHandlers(application); + var outputs = new List(); + var variable = new PublishedVariableDataType + { + PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime), + AttributeId = Attributes.Value + }; + + ServiceResult result = handlers.OnAddPublishedDataItems( + BuildContext(), + method: null!, + inputArguments: BuildArray( + Variant.From("items"), + Variant.From(new ArrayOf(s_currentTimeAlias)), + Variant.From(Array.Empty()), + Variant.FromStructure(new ArrayOf( + new[] { variable }))), + outputArguments: outputs); + + PubSubConfigurationDataType configuration = application.GetConfiguration(); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(outputs, Has.Count.EqualTo(3)); + Assert.That(configuration.PublishedDataSets, Has.Count.EqualTo(1)); + Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( + out PublishedDataItemsDataType? _), Is.True); + }); + } + + [Test] + [TestSpec("9.1.4.5")] + public void OnAddPublishedDataItemsTemplate_UsesProvidedMetaData() + { + IPubSubApplication application = CreateApplication(); + PubSubMethodHandlers handlers = CreateHandlers(application); + var outputs = new List(); + var variable = new PublishedVariableDataType + { + PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime), + AttributeId = Attributes.Value + }; + var metaData = new DataSetMetaDataType + { + Name = "template", + Fields = new ArrayOf(new[] + { + new FieldMetaData + { + Name = "FromTemplate", + DataType = DataTypeIds.DateTime, + ValueRank = ValueRanks.Scalar + } + }) + }; + + ServiceResult result = handlers.OnAddPublishedDataItemsTemplate( + BuildContext(), + method: null!, + inputArguments: BuildArray( + Variant.From("template"), + Variant.From(new ExtensionObject(metaData)), + Variant.FromStructure(new ArrayOf( + new[] { variable }))), + outputArguments: outputs); + + PubSubConfigurationDataType configuration = application.GetConfiguration(); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(outputs, Has.Count.EqualTo(2)); + Assert.That(configuration.PublishedDataSets[0].Name, Is.EqualTo("template")); + Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( + out PublishedDataItemsDataType? _), Is.True); + }); + } + + [Test] + [TestSpec("9.1.4.5")] + public void OnAddPublishedEvents_RegistersPublishedEventsDataSet() + { + IPubSubApplication application = CreateApplication(); + PubSubMethodHandlers handlers = CreateHandlers(application); + var outputs = new List(); + var selectedField = new SimpleAttributeOperand + { + TypeDefinitionId = ObjectTypeIds.BaseEventType, + BrowsePath = new ArrayOf(new[] { new QualifiedName(BrowseNames.EventId) }), + AttributeId = Attributes.Value + }; + + ServiceResult result = handlers.OnAddPublishedEvents( + BuildContext(), + method: NewPublishedDataItemsMethod("items", "AddVariables"), + inputArguments: BuildArray( + Variant.From("events"), + Variant.From(ObjectIds.Server), + Variant.From(new ArrayOf(s_eventIdAlias)), + Variant.From(Array.Empty()), + Variant.FromStructure(new ArrayOf( + new[] { selectedField })), + Variant.From(new ExtensionObject(new ContentFilter()))), + outputArguments: outputs); + + PubSubConfigurationDataType configuration = application.GetConfiguration(); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(outputs, Has.Count.EqualTo(2)); + Assert.That(outputs[0].TryGetValue(out ExtensionObject configurationVersion), Is.True); + Assert.That( + configurationVersion.TryGetValue(out ConfigurationVersionDataType? _), + Is.True); + Assert.That(outputs[1].TryGetValue(out NodeId dataSetId), Is.True); + Assert.That(dataSetId.IsNull, Is.False); + Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( + out PublishedEventsDataType? _), Is.True); + }); + } + + [Test] + [TestSpec("9.1.4.3")] + public void OnAddVariablesAndRemoveVariables_MutateFieldsAndBumpConfigurationVersion() + { + IPubSubApplication application = CreateApplication(); + PubSubMethodHandlers handlers = CreateHandlers(application); + var addDataSetOutputs = new List(); + var variable = new PublishedVariableDataType + { + PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime), + AttributeId = Attributes.Value + }; + handlers.OnAddPublishedDataItems( + BuildContext(), + method: NewPublishedDataItemsMethod("items", "RemoveVariables"), + inputArguments: BuildArray( + Variant.From("items"), + Variant.From(new ArrayOf(s_currentTimeAlias)), + Variant.From(Array.Empty()), + Variant.FromStructure(new ArrayOf( + new[] { variable }))), + outputArguments: addDataSetOutputs); + + var addedVariable = new PublishedVariableDataType + { + PublishedVariable = new NodeId(Variables.Server_ServerStatus_StartTime), + AttributeId = Attributes.Value + }; + var addVariableOutputs = new List(); + ServiceResult addResult = handlers.OnAddVariables( + BuildContext(), + method: NewPublishedDataItemsMethod("items", "AddVariables"), + inputArguments: BuildArray( + addDataSetOutputs[1], + Variant.From(new ArrayOf(s_startTimeAlias)), + Variant.From(s_notPromotedField), + Variant.FromStructure(new ArrayOf( + new[] { addedVariable }))), + outputArguments: addVariableOutputs); + Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); + Assert.That(addVariableOutputs, Has.Count.EqualTo(2)); + var removeVariableOutputs = new List(); + ServiceResult removeResult = handlers.OnRemoveVariables( + BuildContext(), + method: NewPublishedDataItemsMethod("items", "RemoveVariables"), + inputArguments: BuildArray(addVariableOutputs[0], Variant.From(new ArrayOf(s_firstVariableIndex))), + outputArguments: removeVariableOutputs); + + PubSubConfigurationDataType configuration = application.GetConfiguration(); + Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue( + out PublishedDataItemsDataType? items), Is.True); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); + Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True); + Assert.That(items!.PublishedData, Has.Count.EqualTo(1)); + Assert.That(configuration.PublishedDataSets[0].DataSetMetaData.ConfigurationVersion.MinorVersion, + Is.GreaterThan(1u)); + }); + } + + [Test] + [TestSpec("9.1.5")] + public void OnAddDataSetFolder_ReturnsGoodWithNodeId() + { + PubSubMethodHandlers handlers = CreateHandlers(); + ArrayOf inputs = BuildArray(Variant.From("my-folder")); + var outputs = new List(); + ServiceResult result = handlers.OnAddDataSetFolder( + BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(outputs, Has.Count.EqualTo(1)); + }); + } + + private static PubSubMethodHandlers CreateHandlers() + { + return CreateHandlers(CreateApplication()); + } + + private static PubSubMethodHandlers CreateHandlers(IPubSubApplication app) + { + var options = new PubSubServerOptions + { + ExposeConfigurationMethods = true + }; + return new PubSubMethodHandlers( + app, null, options, NUnitTelemetryContext.Create()); + } + + private static IPubSubApplication CreateApplication() + { + return new PubSubApplicationBuilder( + NUnitTelemetryContext.Create()) + .WithApplicationId("handler-mutation-tests") + .UseConfiguration(new PubSubConfigurationDataType + { + Connections = [], + PublishedDataSets = [] + }) + .UseAllStandardEncoders() + .AddTransportFactory(new StubTransportFactory()) + .Build(); + } + + private static SystemContext BuildContext() + { + return new SystemContext(NUnitTelemetryContext.Create()); + } + + private static ArrayOf BuildArray(params Variant[] values) + { + return new ArrayOf(values); + } + + private static MethodState NewPublishedDataItemsMethod(string dataSetName, string methodName) + { + var parent = new BaseObjectState(null) + { + NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}", 0), + BrowseName = new QualifiedName(dataSetName) + }; + var method = new MethodState(parent) + { + NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}:{methodName}", 0), + BrowseName = new QualifiedName(methodName) + }; + parent.AddChild(method); + return method; + } + + private static readonly string[] s_currentTimeAlias = ["CurrentTime"]; + private static readonly string[] s_eventIdAlias = ["EventId"]; + private static readonly string[] s_startTimeAlias = ["StartTime"]; + private static readonly bool[] s_notPromotedField = [false]; + private static readonly uint[] s_firstVariableIndex = [0u]; + + private sealed class StubTransportFactory : IPubSubTransportFactory + { + public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; + + public IPubSubTransport Create( + PubSubConnectionDataType connection, + ITelemetryContext telemetry, + TimeProvider timeProvider) + { + _ = connection; + _ = telemetry; + _ = timeProvider; + return new StubTransport(); + } + } + + private sealed class StubTransport : IPubSubTransport + { + private bool m_isConnected; + + public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; + + public PubSubTransportDirection Direction => PubSubTransportDirection.SendReceive; + + public bool IsConnected => m_isConnected; + + public event EventHandler? StateChanged + { + add { } + remove { } + } + + public ValueTask OpenAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + m_isConnected = true; + return default; + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + m_isConnected = false; + return default; + } + + public ValueTask SendAsync( + ReadOnlyMemory payload, + string? topic = null, + CancellationToken cancellationToken = default) + { + _ = payload; + _ = topic; + _ = cancellationToken; + return default; + } + + public IAsyncEnumerable ReceiveAsync( + CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return TestAsyncEnumerable.Empty(); + } + + public ValueTask DisposeAsync() + { + m_isConnected = false; + return default; + } + } + } +} diff --git a/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerDeterministicTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerDeterministicTests.cs index 02220d1f1b..7368e66ecb 100644 --- a/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerDeterministicTests.cs +++ b/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerDeterministicTests.cs @@ -722,8 +722,8 @@ namespace Opc.Ua.PubSub.Server.Tests RemoveSecurityGroupMethod = NewMethod(15447); AddPushTargetMethod = NewMethod(25441); RemovePushTargetMethod = NewMethod(25444); - AddDataSetFolderMethod = NewMethod(16884); - RemoveDataSetFolderMethod = NewMethod(16923); + AddDataSetFolderMethod = NewMethod(PublishedDataSetsNodeIds.AddDataSetFolder); + RemoveDataSetFolderMethod = NewMethod(PublishedDataSetsNodeIds.RemoveDataSetFolder); StatusVariable = new BaseDataVariableState(null) { NodeId = new NodeId(17406u), @@ -736,7 +736,7 @@ namespace Opc.Ua.PubSub.Server.Tests }; PublishedDataSetsObject = new BaseObjectState(PublishSubscribeObject) { - NodeId = new NodeId(14478u), + NodeId = ObjectIds.PublishSubscribe_PublishedDataSets, BrowseName = new QualifiedName("PublishedDataSets") }; SecurityGroupsObject = new BaseObjectState(PublishSubscribeObject) @@ -762,14 +762,16 @@ namespace Opc.Ua.PubSub.Server.Tests diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15447u))).Returns(RemoveSecurityGroupMethod); diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25441u))).Returns(AddPushTargetMethod); diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25444u))).Returns(RemovePushTargetMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(16884u))).Returns(AddDataSetFolderMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(16923u))).Returns(RemoveDataSetFolderMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.AddDataSetFolder))).Returns(AddDataSetFolderMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.RemoveDataSetFolder))).Returns(RemoveDataSetFolderMethod); diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17406u))).Returns(StatusVariable); diagnosticsNm.Setup(m => m.FindPredefinedNode(It.IsAny())) .Returns((NodeId id) => id == new NodeId(17406u) ? StatusVariable : null!); diagnosticsNm.Setup(m => m.FindPredefinedNode(ObjectIds.PublishSubscribe)) .Returns(PublishSubscribeObject); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(14478u))) + diagnosticsNm.Setup(m => m.FindPredefinedNode(ObjectIds.PublishSubscribe_PublishedDataSets)) .Returns(PublishedDataSetsObject); diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15443u))) .Returns(SecurityGroupsObject); diff --git a/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerTests.cs index c8cb55cb1b..a5ce8e98f5 100644 --- a/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerTests.cs +++ b/tests/Opc.Ua.PubSub.Server.Tests/PubSubNodeManagerTests.cs @@ -1,716 +1,1165 @@ -/* ======================================================================== - * 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.Threading.Tasks; -using Moq; -using NUnit.Framework; -using Opc.Ua.PubSub.Application; -using Opc.Ua.PubSub.Security; -using Opc.Ua.PubSub.Security.Sks; -using Opc.Ua.PubSub.Tests; -using Opc.Ua.PubSub.Transports; -using Opc.Ua.Server; -using Opc.Ua.Tests; - -namespace Opc.Ua.PubSub.Server.Tests -{ - /// - /// Coverage for and - /// : namespace - /// registration, address-space initialization, method-handler - /// binding through a mocked - /// , and default - /// SecurityGroup seeding. - /// - [TestFixture] - [TestSpec("9.1.5", Summary = "PublishSubscribe Object mounting")] - [TestSpec("9.1.10", Summary = "Status.State binding")] - public class PubSubNodeManagerTests - { - [Test] - public async Task CreateAddressSpaceAsync_BindsStandardMethods() - { - using var harness = new Harness(); - - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - - Assert.That(harness.Manager.AreMethodsBound, Is.True); - Assert.That(harness.Manager.StatusBinding, Is.Not.Null); - Assert.That(harness.Manager.StatusBinding!.StateBound, Is.True); - Assert.That(harness.EnableMethod.OnCallMethod, Is.Not.Null); - Assert.That(harness.DisableMethod.OnCallMethod, Is.Not.Null); - Assert.That(harness.SetSecurityKeysMethod.OnCallMethod, Is.Not.Null); - Assert.That(harness.AddConnectionMethod.OnCallMethod, Is.Not.Null); - Assert.That(harness.RemoveConnectionMethod.OnCallMethod, Is.Not.Null); - } - - [Test] - public async Task CreateAddressSpaceAsync_WhenSksExposed_BindsSecurityKeyMethods() - { - using var harness = new Harness(opt => opt.ExposeSecurityKeyService = true, includeSks: true); - - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - - Assert.Multiple(() => - { - Assert.That(harness.GetSecurityKeysMethod.OnCallMethod2, Is.Not.Null); - Assert.That(harness.GetSecurityGroupMethod.OnCallMethod, Is.Not.Null); - Assert.That(harness.AddSecurityGroupMethod.OnCallMethod, Is.Not.Null); - Assert.That(harness.RemoveSecurityGroupMethod.OnCallMethod, Is.Not.Null); - }); - } - - [Test] - public async Task CreateAddressSpaceAsync_WhenConfigMethodsDisabled_SkipsAddRemove() - { - using var harness = new Harness(opt => opt.ExposeConfigurationMethods = false); - - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - - Assert.That(harness.SetSecurityKeysMethod.OnCallMethod, Is.Null); - Assert.That(harness.AddConnectionMethod.OnCallMethod, Is.Null); - Assert.That(harness.RemoveConnectionMethod.OnCallMethod, Is.Null); - // Enable/Disable on PubSubStatusType is always bound — those - // belong to the Status object, not the configuration set. - Assert.That(harness.EnableMethod.OnCallMethod, Is.Not.Null); - } - - [Test] - public async Task CreateAddressSpaceAsync_WithDefaultSecurityGroup_SeedsGroup() - { - using var harness = new Harness(opt => - { - opt.ExposeSecurityKeyService = true; - opt.DefaultSecurityGroupId = "seed-grp"; - opt.DefaultSecurityPolicyUri = PubSubSecurityPolicyUri.PubSubAes128Ctr; - opt.DefaultKeyLifetimeMs = 60_000; - }, includeSks: true); - - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - - Assert.That(((string[]?)harness.SksServer.SecurityGroupIds) ?? [], Contains.Item("seed-grp")); - } - - [Test] - public async Task CreateAddressSpaceAsync_WithSeededExistingGroup_DoesNotDuplicate() - { - using var harness = new Harness(opt => - { - opt.ExposeSecurityKeyService = true; - opt.DefaultSecurityGroupId = "preexisting"; - }, includeSks: true); - await harness.SksServer.AddSecurityGroupAsync(new SksSecurityGroup( - "preexisting", - PubSubSecurityPolicyUri.PubSubAes128Ctr, - TimeSpan.FromMinutes(1), - 2, 2, Array.Empty())).ConfigureAwait(false); - - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - - Assert.That(harness.SksServer.SecurityGroupIds, Has.Count.EqualTo(1)); - } - - [Test] - [TestSpec("8.3.4", Summary = "AddSecurityGroup returns a browseable SecurityGroupType node")] - [TestSpec("8.4.2", Summary = "SecurityGroupType InvalidateKeys is callable")] - [TestSpec("8.4.3", Summary = "SecurityGroupType ForceKeyRotation is callable")] - public async Task AddSecurityGroupMaterializesRoutableNodeAndKeyMethods() - { - using var harness = new Harness(opt => opt.ExposeSecurityKeyService = true, includeSks: true); - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - var outputs = new List(); - ArrayOf rolePermissions = - [ - new ExtensionObject(new RolePermissionType - { - RoleId = ObjectIds.WellKnownRole_SecurityAdmin, - Permissions = (uint)PermissionType.Call - }) - ]; - - ServiceResult addResult = harness.AddSecurityGroupMethod.OnCallMethod!( - harness.Context, - harness.AddSecurityGroupMethod, - BuildArray( - Variant.From("rd3-group"), - Variant.From(60_000.0), - Variant.From(PubSubSecurityPolicyUri.PubSubAes128Ctr), - Variant.From(1U), - Variant.From(1U), - Variant.From(rolePermissions)), - outputs); - Assert.That(outputs[1].TryGetValue(out NodeId groupNodeId), Is.True); - BaseObjectState groupNode = harness.Manager.FindPredefinedNode(groupNodeId); - var invalidate = (MethodState)groupNode.FindChild( - harness.Context, - new QualifiedName("InvalidateKeys", harness.Manager.AddressSpaceNamespaceIndex))!; - var rotate = (MethodState)groupNode.FindChild( - harness.Context, - new QualifiedName("ForceKeyRotation", harness.Manager.AddressSpaceNamespaceIndex))!; - SksKeyResponse before = await harness.SksServer.GetSecurityKeysAsync( - "admin", - new SksKeyRequest("rd3-group", 0, 1), - [ObjectIds.WellKnownRole_SecurityAdmin]).ConfigureAwait(false); - - ServiceResult rotateResult = rotate.OnCallMethod!( - harness.Context, - rotate, - [], - []); - SksKeyResponse afterRotate = await harness.SksServer.GetSecurityKeysAsync( - "admin", - new SksKeyRequest("rd3-group", 0, 1), - [ObjectIds.WellKnownRole_SecurityAdmin]).ConfigureAwait(false); - ServiceResult invalidateResult = invalidate.OnCallMethod!( - harness.Context, - invalidate, - [], - []); - - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); - Assert.That(groupNode, Is.Not.Null); - Assert.That(groupNode.TypeDefinitionId, Is.EqualTo(new NodeId(15471u))); - Assert.That(groupNode.RolePermissions, Has.Count.EqualTo(1)); - Assert.That(StatusCode.IsGood(rotateResult.StatusCode), Is.True); - Assert.That(afterRotate.FirstTokenId, Is.Not.EqualTo(before.FirstTokenId)); - Assert.That(StatusCode.IsGood(invalidateResult.StatusCode), Is.True); - }); - } - - [Test] - [TestSpec("8.7.2", Summary = "KeyPushTargets AddPushTarget materializes PubSubKeyPushTargetType")] - [TestSpec("8.6.3", Summary = "Push targets connect SecurityGroups")] - [TestSpec("8.6.6", Summary = "TriggerKeyUpdate pushes keys to the target")] - public async Task KeyPushTargetCanBeAddedConnectedTriggeredAndRemoved() - { - var pushProvider = new PushSecurityKeyProvider("push-endpoint", NUnitTelemetryContext.Create()); - using var harness = new Harness(opt => opt.ExposeSecurityKeyService = true, includeSks: true, pushProvider: pushProvider); - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - await harness.SksServer.AddSecurityGroupAsync(new SksSecurityGroup( - "rd4-group", - PubSubSecurityPolicyUri.PubSubAes128Ctr, - TimeSpan.FromMinutes(1), - 1, - 1, - Array.Empty(), - rolePermissions: - [ - new RolePermissionType - { - RoleId = ObjectIds.WellKnownRole_SecurityAdmin, - Permissions = (uint)PermissionType.Call - } - ])).ConfigureAwait(false); - await harness.Manager.RebuildSksAddressSpaceForTestsAsync().ConfigureAwait(false); - NodeId groupNodeId = harness.Manager.MethodHandlers.TryGetSecurityGroupNodeId("rd4-group") ?? NodeId.Null; - var addOutputs = new List(); - - ServiceResult addResult = harness.AddPushTargetMethod.OnCallMethod!( - harness.Context, - harness.AddPushTargetMethod, - BuildArray( - Variant.From("target-app"), - Variant.From("push-endpoint"), - Variant.From(PubSubSecurityPolicyUri.PubSubAes128Ctr), - Variant.From(UserTokenType.Anonymous), - Variant.From((ushort)1), - Variant.From(1_000.0)), - addOutputs); - Assert.That(addOutputs[0].TryGetValue(out NodeId targetNodeId), Is.True); - BaseObjectState targetNode = harness.Manager.FindPredefinedNode(targetNodeId); - var connect = (MethodState)targetNode.FindChild( - harness.Context, - new QualifiedName("ConnectSecurityGroups", harness.Manager.AddressSpaceNamespaceIndex))!; - var trigger = (MethodState)targetNode.FindChild( - harness.Context, - new QualifiedName("TriggerKeyUpdate", harness.Manager.AddressSpaceNamespaceIndex))!; - MethodState remove = harness.RemovePushTargetMethod; - var connectOutputs = new List(); - - ServiceResult connectResult = connect.OnCallMethod!( - harness.Context, - connect, - BuildArray(Variant.From(new ArrayOf(new[] { groupNodeId }))), - connectOutputs); - ServiceResult triggerResult = trigger.OnCallMethod!(harness.Context, trigger, [], []); - PubSubSecurityKey pushed = await pushProvider.GetCurrentKeyAsync().ConfigureAwait(false); - ServiceResult removeResult = remove.OnCallMethod!( - harness.Context, - remove, - BuildArray(Variant.From(targetNodeId)), - []); - - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); - Assert.That(targetNode.TypeDefinitionId, Is.EqualTo(new NodeId(25337u))); - Assert.That(StatusCode.IsGood(connectResult.StatusCode), Is.True); - Assert.That(StatusCode.IsGood(triggerResult.StatusCode), Is.True); - Assert.That(pushed.TokenId, Is.GreaterThan(0U)); - Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True); - Assert.That(harness.Manager.FindPredefinedNode(targetNodeId), Is.Null); - }); - } - - [Test] - public async Task CreateAddressSpaceAsync_WithDiagnosticsExposureNone_SkipsBinding() - { - using var harness = new Harness(opt => opt.DiagnosticsExposure = PubSubDiagnosticsExposure.None); - - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - - Assert.That(harness.Manager.StatusBinding, Is.Null); - Assert.That(harness.Manager.AreMethodsBound, Is.True); - } - - [Test] - [TestSpec("9.1.3", Summary = "PubSubConnectionType instances are materialized under PublishSubscribe")] - [TestSpec("9.1.10", Summary = "Per-instance Status exposes Enable and Disable methods")] - public async Task ConfigurationMutation_MaterializesConnectionNodeAndStatusMethods() - { - using var harness = new Harness(); - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - - NodeId connectionId = await harness.Application.AddConnectionAsync(new PubSubConnectionDataType - { - Name = "conn-tree", - TransportProfileUri = Profiles.PubSubUdpUadpTransport, - Address = new ExtensionObject(new NetworkAddressUrlDataType - { - Url = "opc.udp://224.0.0.22:4840" - }) - }).ConfigureAwait(false); - - BaseObjectState connectionNode = harness.Manager.FindPredefinedNode(connectionId); - BaseObjectState statusNode = harness.Manager.FindPredefinedNode( - new NodeId("pubsub:connection:conn-tree:Status", harness.Manager.AddressSpaceNamespaceIndex)); - MethodState enable = harness.Manager.FindPredefinedNode( - new NodeId("pubsub:connection:conn-tree:Status:Enable", harness.Manager.AddressSpaceNamespaceIndex)); - BaseDataVariableState version = harness.Manager.FindPredefinedNode( - new NodeId( - "pubsub:connection:conn-tree:ConfigurationVersion", - harness.Manager.AddressSpaceNamespaceIndex)); - - Assert.Multiple(() => - { - Assert.That(connectionId.NamespaceIndex, Is.EqualTo(harness.Manager.AddressSpaceNamespaceIndex)); - Assert.That(connectionNode, Is.Not.Null); - Assert.That(connectionNode.TypeDefinitionId, Is.EqualTo(new NodeId(14209u))); - Assert.That(statusNode, Is.Not.Null); - Assert.That(enable.OnCallMethod, Is.Not.Null); - Assert.That(version, Is.Not.Null); - }); - } - - [Test] - [TestSpec("9.1.4.5", Summary = "DataSetFolderType AddDataSetFolder creates browseable folder nodes")] - public async Task AddDataSetFolderMethod_MaterializesAndRemovesFolderNode() - { - using var harness = new Harness(); - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - MethodState addFolder = harness.AddDataSetFolderMethod; - var addOutputs = new List(); - - ServiceResult addResult = addFolder.OnCallMethod!( - harness.Context, - addFolder, - BuildArray(Variant.From("folder1")), - addOutputs); - Assert.That(addOutputs[0].TryGetValue(out NodeId folderId), Is.True); - BaseObjectState folder = harness.Manager.FindPredefinedNode(folderId); - MethodState removeFolder = harness.RemoveDataSetFolderMethod; - - ServiceResult removeResult = removeFolder.OnCallMethod!( - harness.Context, - removeFolder, - BuildArray(Variant.From(folderId)), - []); - - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); - Assert.That(folder, Is.Not.Null); - Assert.That(folder.TypeDefinitionId, Is.EqualTo(new NodeId(14477u))); - Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True); - Assert.That(harness.Manager.FindPredefinedNode(folderId), Is.Null); - }); - } - - [Test] - [TestSpec("9.1.3.7", Summary = "PubSubConfigurationType exposes FileType-style import/export")] - public async Task PubSubConfigurationFileMethods_ReadAndCloseAndUpdateConfiguration() - { - using var harness = new Harness(); - await harness.Manager.CreateAddressSpaceAsync( - new Dictionary>()).ConfigureAwait(false); - BaseObjectState fileNode = harness.Manager.FindPredefinedNode( - new NodeId("pubsub:configuration", harness.Manager.AddressSpaceNamespaceIndex))!; - var open = (MethodState)fileNode.FindChild( - harness.Context, - new QualifiedName("Open", harness.Manager.AddressSpaceNamespaceIndex))!; - var read = (MethodState)fileNode.FindChild( - harness.Context, - new QualifiedName("Read", harness.Manager.AddressSpaceNamespaceIndex))!; - var reserve = (MethodState)fileNode.FindChild( - harness.Context, - new QualifiedName("ReserveIds", harness.Manager.AddressSpaceNamespaceIndex))!; - var closeAndUpdate = (MethodState)fileNode.FindChild( - harness.Context, - new QualifiedName("CloseAndUpdate", harness.Manager.AddressSpaceNamespaceIndex))!; - var reserveOutputs = new List(); - ServiceResult reserveResult = reserve.OnCallMethod!( - harness.Context, - reserve, - BuildArray(Variant.From(Profiles.PubSubUdpUadpTransport), Variant.From((ushort)1), Variant.From((ushort)1)), - reserveOutputs); - var openReadOutputs = new List(); - open.OnCallMethod!( - harness.Context, - open, - BuildArray(Variant.From((byte)1)), - openReadOutputs); - Assert.That(openReadOutputs[0].TryGetValue(out uint readHandle), Is.True); - var readOutputs = new List(); - - ServiceResult readResult = read.OnCallMethod!( - harness.Context, - read, - BuildArray(Variant.From(readHandle), Variant.From(4096)), - readOutputs); - Assert.That(readOutputs[0].TryGetValue(out ArrayOf payload), Is.True); - var openWriteOutputs = new List(); - open.OnCallMethod!( - harness.Context, - open, - BuildArray(Variant.From((byte)2)), - openWriteOutputs); - Assert.That(openWriteOutputs[0].TryGetValue(out uint writeHandle), Is.True); - var write = (MethodState)fileNode.FindChild( - harness.Context, - new QualifiedName("Write", harness.Manager.AddressSpaceNamespaceIndex))!; - write.OnCallMethod!( - harness.Context, - write, - BuildArray(Variant.From(writeHandle), Variant.From(payload)), - []); - var updateOutputs = new List(); - - ServiceResult updateResult = closeAndUpdate.OnCallMethod!( - harness.Context, - closeAndUpdate, - BuildArray(Variant.From(writeHandle), Variant.From(false), Variant.Null), - updateOutputs); - - Assert.Multiple(() => - { - Assert.That(StatusCode.IsGood(readResult.StatusCode), Is.True); - Assert.That(StatusCode.IsGood(reserveResult.StatusCode), Is.True); - Assert.That(reserveOutputs[1].TryGetValue(out ArrayOf writerIds), Is.True); - Assert.That(writerIds, Has.Count.EqualTo(1)); - Assert.That(payload, Is.Not.Empty); - Assert.That(StatusCode.IsGood(updateResult.StatusCode), Is.True); - Assert.That(updateOutputs[0].TryGetValue(out bool applied), Is.True); - Assert.That(applied, Is.True); - }); - } - - [Test] - public void Constructor_NullArgs_Throw() - { - using var harness = new Harness(); - // The harness already produced one Manager, so use its - // collaborators to exercise null-arg paths for the - // public constructor. - ApplicationConfiguration config = harness.Configuration; - IPubSubApplication app = harness.Application; - ITelemetryContext telemetry = NUnitTelemetryContext.Create(); - var options = new PubSubServerOptions(); - - Assert.Multiple(() => - { - Assert.That(() => new PubSubNodeManager( - harness.MockServer.Object, config, null!, null, options, telemetry), - Throws.ArgumentNullException); - Assert.That(() => new PubSubNodeManager( - harness.MockServer.Object, config, app, null, null!, telemetry), - Throws.ArgumentNullException); - Assert.That(() => new PubSubNodeManager( - harness.MockServer.Object, config, app, null, options, null!), - Throws.ArgumentNullException); - }); - } - - [Test] - public void Factory_Create_ReturnsAttachedSyncNodeManager() - { - using var harness = new Harness(); - var factory = new PubSubNodeManagerFactory( - harness.Application, - null, - new PubSubServerOptions(), - NUnitTelemetryContext.Create()); - - Assert.That(factory.NamespacesUris, Is.Not.Empty); - INodeManager nm = factory.Create(harness.MockServer.Object, harness.Configuration); - - Assert.That(nm, Is.Not.Null); - (nm as IDisposable)?.Dispose(); - } - - [Test] - public void Factory_NullArgs_Throw() - { - using var harness = new Harness(); - var options = new PubSubServerOptions(); - ITelemetryContext telemetry = NUnitTelemetryContext.Create(); - - Assert.Multiple(() => - { - Assert.That(() => new PubSubNodeManagerFactory(null!, null, options, telemetry), - Throws.ArgumentNullException); - Assert.That(() => new PubSubNodeManagerFactory(harness.Application, null, null!, telemetry), - Throws.ArgumentNullException); - Assert.That(() => new PubSubNodeManagerFactory(harness.Application, null, options, null!), - Throws.ArgumentNullException); - }); - } - - private static ArrayOf BuildArray(params Variant[] values) - { - return new ArrayOf(values); - } - - private sealed class Harness : IDisposable - { - public Harness( - Action? configure = null, - bool includeSks = false, - PushSecurityKeyProvider? pushProvider = null) - { - MockServer = new Mock(); - NamespaceTable = new NamespaceTable(); - NamespaceTable.Append(Namespaces.OpcUa); - MockServer.Setup(s => s.NamespaceUris).Returns(NamespaceTable); - MockServer.Setup(s => s.ServerUris).Returns(new StringTable()); - MockServer.Setup(s => s.Factory).Returns(EncodeableFactory.Create()); - MockServer.Setup(s => s.TypeTree).Returns(new TypeTable(NamespaceTable)); - - var mockMaster = new Mock(); - var mockConfig = new Mock(); - mockMaster.Setup(m => m.ConfigurationNodeManager).Returns(mockConfig.Object); - MockServer.Setup(s => s.NodeManager).Returns(mockMaster.Object); - - ITelemetryContext telemetry = NUnitTelemetryContext.Create(); - MockServer.Setup(s => s.Telemetry).Returns(telemetry); - - m_queueFactory = new MonitoredItemQueueFactory(telemetry); - MockServer.Setup(s => s.MonitoredItemQueueFactory).Returns(m_queueFactory); - - Context = new ServerSystemContext(MockServer.Object); - MockServer.Setup(s => s.DefaultSystemContext).Returns(Context); - - Configuration = new ApplicationConfiguration - { - ServerConfiguration = new ServerConfiguration - { - MaxNotificationQueueSize = 100, - MaxDurableNotificationQueueSize = 200 - } - }; - - EnableMethod = NewMethod(17407); - DisableMethod = NewMethod(17408); - SetSecurityKeysMethod = NewMethod(17364); - AddConnectionMethod = NewMethod(17366); - RemoveConnectionMethod = NewMethod(17369); - GetSecurityKeysMethod = NewMethod(15215); - GetSecurityGroupMethod = NewMethod(15440); - AddSecurityGroupMethod = NewMethod(15444); - RemoveSecurityGroupMethod = NewMethod(15447); - AddPushTargetMethod = NewMethod(25441); - RemovePushTargetMethod = NewMethod(25444); - AddDataSetFolderMethod = NewMethod(16884); - RemoveDataSetFolderMethod = NewMethod(16923); - StatusVariable = new BaseDataVariableState(null) - { - NodeId = new NodeId(17406u), - BrowseName = new QualifiedName("State") - }; - PublishSubscribeObject = new BaseObjectState(null) - { - NodeId = ObjectIds.PublishSubscribe, - BrowseName = new QualifiedName("PublishSubscribe") - }; - PublishedDataSetsObject = new BaseObjectState(PublishSubscribeObject) - { - NodeId = new NodeId(14478u), - BrowseName = new QualifiedName("PublishedDataSets") - }; - SecurityGroupsObject = new BaseObjectState(PublishSubscribeObject) - { - NodeId = new NodeId(15443u), - BrowseName = new QualifiedName("SecurityGroups") - }; - KeyPushTargetsObject = new BaseObjectState(PublishSubscribeObject) - { - NodeId = new NodeId(25440u), - BrowseName = new QualifiedName("KeyPushTargets") - }; - - var diagnosticsNm = new Mock(); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17407u))).Returns(EnableMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17408u))).Returns(DisableMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17364u))).Returns(SetSecurityKeysMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17366u))).Returns(AddConnectionMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17369u))).Returns(RemoveConnectionMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15215u))).Returns(GetSecurityKeysMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15440u))).Returns(GetSecurityGroupMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15444u))).Returns(AddSecurityGroupMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15447u))).Returns(RemoveSecurityGroupMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25441u))).Returns(AddPushTargetMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25444u))).Returns(RemovePushTargetMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(16884u))).Returns(AddDataSetFolderMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(16923u))).Returns(RemoveDataSetFolderMethod); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17406u))).Returns(StatusVariable); - diagnosticsNm.Setup(m => m.FindPredefinedNode(It.IsAny())) - .Returns((NodeId id) => id == new NodeId(17406u) ? StatusVariable : null!); - diagnosticsNm.Setup(m => m.FindPredefinedNode(ObjectIds.PublishSubscribe)) - .Returns(PublishSubscribeObject); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(14478u))) - .Returns(PublishedDataSetsObject); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15443u))) - .Returns(SecurityGroupsObject); - diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25440u))) - .Returns(KeyPushTargetsObject); - MockServer.Setup(s => s.DiagnosticsNodeManager).Returns(diagnosticsNm.Object); - - Application = new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) - .WithApplicationId("test-nodemanager") - .UseConfiguration(new PubSubConfigurationDataType - { - Connections = [], - PublishedDataSets = [] - }) - .UseAllStandardEncoders() - .AddTransportFactory(new StubTransportFactory()) - .Build(); - - SksServer = new InMemoryPubSubKeyServiceServer(); - - Options = new PubSubServerOptions(); - configure?.Invoke(Options); - - Manager = new PubSubNodeManager( - MockServer.Object, - Configuration, - Application, - includeSks ? SksServer : null, - Options, - telemetry, - pushKeyProviders: pushProvider is null ? null : [pushProvider]); - } - - public Mock MockServer { get; } - public NamespaceTable NamespaceTable { get; } - public ApplicationConfiguration Configuration { get; } - public IPubSubApplication Application { get; } - public InMemoryPubSubKeyServiceServer SksServer { get; } - public PubSubServerOptions Options { get; } - public PubSubNodeManager Manager { get; } - public MethodState EnableMethod { get; } - public MethodState DisableMethod { get; } - public MethodState SetSecurityKeysMethod { get; } - public MethodState AddConnectionMethod { get; } - public MethodState RemoveConnectionMethod { get; } - public MethodState GetSecurityKeysMethod { get; } - public MethodState GetSecurityGroupMethod { get; } - public MethodState AddSecurityGroupMethod { get; } - public MethodState RemoveSecurityGroupMethod { get; } - public MethodState AddPushTargetMethod { get; } - public MethodState RemovePushTargetMethod { get; } - public MethodState AddDataSetFolderMethod { get; } - public MethodState RemoveDataSetFolderMethod { get; } - public BaseDataVariableState StatusVariable { get; } - public BaseObjectState PublishSubscribeObject { get; } - public BaseObjectState PublishedDataSetsObject { get; } - public BaseObjectState SecurityGroupsObject { get; } - public BaseObjectState KeyPushTargetsObject { get; } - public ServerSystemContext Context { get; } - - public void Dispose() - { - Manager.Dispose(); - (Application as IDisposable)?.Dispose(); - (Application as IAsyncDisposable)?.DisposeAsync().AsTask().GetAwaiter().GetResult(); - m_queueFactory.Dispose(); - } - - private static MethodState NewMethod(uint nodeId) - { - return new MethodState(null) - { - NodeId = new NodeId(nodeId), - BrowseName = new QualifiedName("M" + nodeId) - }; - } - - private readonly MonitoredItemQueueFactory m_queueFactory; - - private sealed class StubTransportFactory : IPubSubTransportFactory - { - public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; - - public IPubSubTransport Create( - PubSubConnectionDataType connection, - ITelemetryContext telemetry, - TimeProvider timeProvider) - { - _ = connection; - _ = telemetry; - _ = timeProvider; - throw new NotSupportedException(); - } - } - } - } -} +/* ======================================================================== + * 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.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.PubSub.Application; +using Opc.Ua.PubSub.Security; +using Opc.Ua.PubSub.Security.Sks; +using Opc.Ua.PubSub.Tests; +using Opc.Ua.PubSub.Transports; +using Opc.Ua.Server; +using Opc.Ua.Tests; + +namespace Opc.Ua.PubSub.Server.Tests +{ + /// + /// Coverage for and + /// : namespace + /// registration, address-space initialization, method-handler + /// binding through a mocked + /// , and default + /// SecurityGroup seeding. + /// + [TestFixture] + [TestSpec("9.1.5", Summary = "PublishSubscribe Object mounting")] + [TestSpec("9.1.10", Summary = "Status.State binding")] + public class PubSubNodeManagerTests + { + private static readonly string[] s_eventIdAlias = ["EventId"]; + + [Test] + public async Task CreateAddressSpaceAsync_BindsStandardMethods() + { + using var harness = new Harness(); + + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + Assert.That(harness.Manager.AreMethodsBound, Is.True); + Assert.That(harness.Manager.StatusBinding, Is.Not.Null); + Assert.That(harness.Manager.StatusBinding!.StateBound, Is.True); + Assert.That(harness.EnableMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.DisableMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.SetSecurityKeysMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.AddConnectionMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.RemoveConnectionMethod.OnCallMethod, Is.Not.Null); + } + + [Test] + public async Task CreateAddressSpaceAsync_WhenSksExposed_BindsSecurityKeyMethods() + { + using var harness = new Harness(opt => opt.ExposeSecurityKeyService = true, includeSks: true); + + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(harness.GetSecurityKeysMethod.OnCallMethod2, Is.Not.Null); + Assert.That(harness.GetSecurityGroupMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.AddSecurityGroupMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.RemoveSecurityGroupMethod.OnCallMethod, Is.Not.Null); + }); + } + + [Test] + public async Task CreateAddressSpaceAsync_WhenConfigMethodsDisabled_SkipsAddRemove() + { + using var harness = new Harness(opt => opt.ExposeConfigurationMethods = false); + + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + Assert.That(harness.SetSecurityKeysMethod.OnCallMethod, Is.Null); + Assert.That(harness.AddConnectionMethod.OnCallMethod, Is.Null); + Assert.That(harness.RemoveConnectionMethod.OnCallMethod, Is.Null); + // Enable/Disable on PubSubStatusType is always bound — those + // belong to the Status object, not the configuration set. + Assert.That(harness.EnableMethod.OnCallMethod, Is.Not.Null); + } + + [Test] + public async Task CreateAddressSpaceAsync_WithDefaultSecurityGroup_SeedsGroup() + { + using var harness = new Harness(opt => + { + opt.ExposeSecurityKeyService = true; + opt.DefaultSecurityGroupId = "seed-grp"; + opt.DefaultSecurityPolicyUri = PubSubSecurityPolicyUri.PubSubAes128Ctr; + opt.DefaultKeyLifetimeMs = 60_000; + }, includeSks: true); + + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + Assert.That(((string[]?)harness.SksServer.SecurityGroupIds) ?? [], Contains.Item("seed-grp")); + } + + [Test] + public async Task CreateAddressSpaceAsync_WithSeededExistingGroup_DoesNotDuplicate() + { + using var harness = new Harness(opt => + { + opt.ExposeSecurityKeyService = true; + opt.DefaultSecurityGroupId = "preexisting"; + }, includeSks: true); + await harness.SksServer.AddSecurityGroupAsync(new SksSecurityGroup( + "preexisting", + PubSubSecurityPolicyUri.PubSubAes128Ctr, + TimeSpan.FromMinutes(1), + 2, 2, Array.Empty())).ConfigureAwait(false); + + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + Assert.That(harness.SksServer.SecurityGroupIds, Has.Count.EqualTo(1)); + } + + [Test] + [TestSpec("8.3.4", Summary = "AddSecurityGroup returns a browseable SecurityGroupType node")] + [TestSpec("8.4.2", Summary = "SecurityGroupType InvalidateKeys is callable")] + [TestSpec("8.4.3", Summary = "SecurityGroupType ForceKeyRotation is callable")] + public async Task AddSecurityGroupMaterializesRoutableNodeAndKeyMethods() + { + using var harness = new Harness(opt => opt.ExposeSecurityKeyService = true, includeSks: true); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + var outputs = new List(); + ArrayOf rolePermissions = + [ + new ExtensionObject(new RolePermissionType + { + RoleId = ObjectIds.WellKnownRole_SecurityAdmin, + Permissions = (uint)PermissionType.Call + }) + ]; + + ServiceResult addResult = harness.AddSecurityGroupMethod.OnCallMethod!( + harness.Context, + harness.AddSecurityGroupMethod, + BuildArray( + Variant.From("rd3-group"), + Variant.From(60_000.0), + Variant.From(PubSubSecurityPolicyUri.PubSubAes128Ctr), + Variant.From(1U), + Variant.From(1U), + Variant.From(rolePermissions)), + outputs); + Assert.That(outputs[1].TryGetValue(out NodeId groupNodeId), Is.True); + BaseObjectState groupNode = harness.Manager.FindPredefinedNode(groupNodeId); + var invalidate = (MethodState)groupNode.FindChild( + harness.Context, + new QualifiedName("InvalidateKeys", harness.Manager.AddressSpaceNamespaceIndex))!; + var rotate = (MethodState)groupNode.FindChild( + harness.Context, + new QualifiedName("ForceKeyRotation", harness.Manager.AddressSpaceNamespaceIndex))!; + SksKeyResponse before = await harness.SksServer.GetSecurityKeysAsync( + "admin", + new SksKeyRequest("rd3-group", 0, 1), + [ObjectIds.WellKnownRole_SecurityAdmin]).ConfigureAwait(false); + + ServiceResult rotateResult = rotate.OnCallMethod!( + harness.Context, + rotate, + [], + []); + SksKeyResponse afterRotate = await harness.SksServer.GetSecurityKeysAsync( + "admin", + new SksKeyRequest("rd3-group", 0, 1), + [ObjectIds.WellKnownRole_SecurityAdmin]).ConfigureAwait(false); + ServiceResult invalidateResult = invalidate.OnCallMethod!( + harness.Context, + invalidate, + [], + []); + + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); + Assert.That(groupNode, Is.Not.Null); + Assert.That(groupNode.TypeDefinitionId, Is.EqualTo(new NodeId(15471u))); + Assert.That(groupNode.RolePermissions, Has.Count.EqualTo(1)); + Assert.That(StatusCode.IsGood(rotateResult.StatusCode), Is.True); + Assert.That(afterRotate.FirstTokenId, Is.Not.EqualTo(before.FirstTokenId)); + Assert.That(StatusCode.IsGood(invalidateResult.StatusCode), Is.True); + }); + } + + [Test] + [TestSpec("8.7.2", Summary = "KeyPushTargets AddPushTarget materializes PubSubKeyPushTargetType")] + [TestSpec("8.6.3", Summary = "Push targets connect SecurityGroups")] + [TestSpec("8.6.6", Summary = "TriggerKeyUpdate pushes keys to the target")] + public async Task KeyPushTargetCanBeAddedConnectedTriggeredAndRemoved() + { + var pushProvider = new PushSecurityKeyProvider("push-endpoint", NUnitTelemetryContext.Create()); + using var harness = new Harness(opt => opt.ExposeSecurityKeyService = true, includeSks: true, pushProvider: pushProvider); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + await harness.SksServer.AddSecurityGroupAsync(new SksSecurityGroup( + "rd4-group", + PubSubSecurityPolicyUri.PubSubAes128Ctr, + TimeSpan.FromMinutes(1), + 1, + 1, + Array.Empty(), + rolePermissions: + [ + new RolePermissionType + { + RoleId = ObjectIds.WellKnownRole_SecurityAdmin, + Permissions = (uint)PermissionType.Call + } + ])).ConfigureAwait(false); + await harness.Manager.RebuildSksAddressSpaceForTestsAsync().ConfigureAwait(false); + NodeId groupNodeId = harness.Manager.MethodHandlers.TryGetSecurityGroupNodeId("rd4-group") ?? NodeId.Null; + var addOutputs = new List(); + + ServiceResult addResult = harness.AddPushTargetMethod.OnCallMethod!( + harness.Context, + harness.AddPushTargetMethod, + BuildArray( + Variant.From("target-app"), + Variant.From("push-endpoint"), + Variant.From(PubSubSecurityPolicyUri.PubSubAes128Ctr), + Variant.From(UserTokenType.Anonymous), + Variant.From((ushort)1), + Variant.From(1_000.0)), + addOutputs); + Assert.That(addOutputs[0].TryGetValue(out NodeId targetNodeId), Is.True); + BaseObjectState targetNode = harness.Manager.FindPredefinedNode(targetNodeId); + var connect = (MethodState)targetNode.FindChild( + harness.Context, + new QualifiedName("ConnectSecurityGroups", harness.Manager.AddressSpaceNamespaceIndex))!; + var trigger = (MethodState)targetNode.FindChild( + harness.Context, + new QualifiedName("TriggerKeyUpdate", harness.Manager.AddressSpaceNamespaceIndex))!; + MethodState remove = harness.RemovePushTargetMethod; + var connectOutputs = new List(); + + ServiceResult connectResult = connect.OnCallMethod!( + harness.Context, + connect, + BuildArray(Variant.From(new ArrayOf(new[] { groupNodeId }))), + connectOutputs); + ServiceResult triggerResult = trigger.OnCallMethod!(harness.Context, trigger, [], []); + PubSubSecurityKey pushed = await pushProvider.GetCurrentKeyAsync().ConfigureAwait(false); + ServiceResult removeResult = remove.OnCallMethod!( + harness.Context, + remove, + BuildArray(Variant.From(targetNodeId)), + []); + + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); + Assert.That(targetNode.TypeDefinitionId, Is.EqualTo(new NodeId(25337u))); + Assert.That(StatusCode.IsGood(connectResult.StatusCode), Is.True); + Assert.That(StatusCode.IsGood(triggerResult.StatusCode), Is.True); + Assert.That(pushed.TokenId, Is.GreaterThan(0U)); + Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True); + Assert.That(harness.Manager.FindPredefinedNode(targetNodeId), Is.Null); + }); + } + + [Test] + public async Task CreateAddressSpaceAsync_WithDiagnosticsExposureNone_SkipsBinding() + { + using var harness = new Harness(opt => opt.DiagnosticsExposure = PubSubDiagnosticsExposure.None); + + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + Assert.That(harness.Manager.StatusBinding, Is.Null); + Assert.That(harness.Manager.AreMethodsBound, Is.True); + } + + [Test] + [TestSpec("9.1.3", Summary = "PubSubConnectionType instances are materialized under PublishSubscribe")] + [TestSpec("9.1.10", Summary = "Per-instance Status exposes Enable and Disable methods")] + public async Task ConfigurationMutation_MaterializesConnectionNodeAndStatusMethods() + { + using var harness = new Harness(); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + NodeId connectionId = await harness.Application.AddConnectionAsync(new PubSubConnectionDataType + { + Name = "conn-tree", + TransportProfileUri = Profiles.PubSubUdpUadpTransport, + Address = new ExtensionObject(new NetworkAddressUrlDataType + { + Url = "opc.udp://224.0.0.22:4840" + }) + }).ConfigureAwait(false); + + BaseObjectState connectionNode = harness.Manager.FindPredefinedNode(connectionId); + BaseObjectState statusNode = harness.Manager.FindPredefinedNode( + new NodeId("pubsub:connection:conn-tree:Status", harness.Manager.AddressSpaceNamespaceIndex)); + MethodState enable = harness.Manager.FindPredefinedNode( + new NodeId("pubsub:connection:conn-tree:Status:Enable", harness.Manager.AddressSpaceNamespaceIndex)); + BaseDataVariableState version = harness.Manager.FindPredefinedNode( + new NodeId( + "pubsub:connection:conn-tree:ConfigurationVersion", + harness.Manager.AddressSpaceNamespaceIndex)); + + Assert.Multiple(() => + { + Assert.That(connectionId.NamespaceIndex, Is.EqualTo(harness.Manager.AddressSpaceNamespaceIndex)); + Assert.That(connectionNode, Is.Not.Null); + Assert.That(connectionNode.TypeDefinitionId, Is.EqualTo(new NodeId(14209u))); + Assert.That(statusNode, Is.Not.Null); + Assert.That(enable.OnCallMethod, Is.Not.Null); + Assert.That(version, Is.Not.Null); + }); + } + + [Test] + [TestSpec("9.1.4.5", Summary = "DataSetFolderType AddDataSetFolder creates browseable folder nodes")] + public async Task AddDataSetFolderMethod_MaterializesAndRemovesFolderNode() + { + using var harness = new Harness(); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + MethodState addFolder = harness.AddDataSetFolderMethod; + var addOutputs = new List(); + + ServiceResult addResult = addFolder.OnCallMethod!( + harness.Context, + addFolder, + BuildArray(Variant.From("folder1")), + addOutputs); + Assert.That(addOutputs[0].TryGetValue(out NodeId folderId), Is.True); + BaseObjectState folder = harness.Manager.FindPredefinedNode(folderId); + MethodState removeFolder = harness.RemoveDataSetFolderMethod; + + ServiceResult removeResult = removeFolder.OnCallMethod!( + harness.Context, + removeFolder, + BuildArray(Variant.From(folderId)), + []); + + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True); + Assert.That(folder, Is.Not.Null); + Assert.That(folder.TypeDefinitionId, Is.EqualTo(new NodeId(14477u))); + Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True); + Assert.That(harness.Manager.FindPredefinedNode(folderId), Is.Null); + }); + } + + [Test] + [TestSpec("9.1.4.5", Summary = "PublishSubscribe.PublishedDataSets holds the PublishedDataSet objects")] + public async Task PublishedDataSetIsMaterializedUnderPublishSubscribePublishedDataSets() + { + using var harness = new Harness(); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + await harness.Application.AddPublishedDataSetAsync(new PublishedDataSetDataType + { + Name = "pds-parent", + DataSetMetaData = new DataSetMetaDataType { Name = "pds-parent" }, + DataSetSource = new ExtensionObject(new PublishedDataItemsDataType + { + PublishedData = [] + }) + }).ConfigureAwait(false); + + BaseObjectState dataSetNode = harness.Manager.FindPredefinedNode( + new NodeId( + "pubsub:published-data-set:pds-parent", + harness.Manager.AddressSpaceNamespaceIndex)); + var placeholderChildren = new List(); + harness.DataSetFolderPlaceholderObject.GetChildren(harness.Context, placeholderChildren); + + Assert.Multiple(() => + { + Assert.That(dataSetNode, Is.Not.Null); + Assert.That( + dataSetNode?.Parent?.NodeId, + Is.EqualTo(ObjectIds.PublishSubscribe_PublishedDataSets)); + Assert.That(placeholderChildren, Is.Empty); + }); + } + + [Test] + [TestSpec("9.1.4.5", Summary = "PublishSubscribe.PublishedDataSets exposes the DataSetFolderType methods")] + public void PublishedDataSetsMethodsAreMaterializedWithTheirInstanceNodeIds() + { + using var harness = new Harness(); + var publishedDataSets = new DataSetFolderState(null) + { + NodeId = ObjectIds.PublishSubscribe_PublishedDataSets, + BrowseName = new QualifiedName("PublishedDataSets") + }; + + ArrayOf methods = PubSubNodeManager.AddPublishedDataSetsMethods( + harness.Context, + publishedDataSets); + + var ids = new List<(NodeId Method, NodeId Input, NodeId Output)>(); + foreach (MethodState method in methods) + { + ids.Add(( + method.NodeId, + method.InputArguments?.NodeId ?? NodeId.Null, + method.OutputArguments?.NodeId ?? NodeId.Null)); + Assert.That(method.Parent, Is.SameAs(publishedDataSets)); + } + + Assert.That(ids, Is.EqualTo(new List<(NodeId, NodeId, NodeId)> + { + (new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItems), + new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItemsInputArguments), + new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItemsOutputArguments)), + (new NodeId(PublishedDataSetsNodeIds.AddPublishedEvents), + new NodeId(PublishedDataSetsNodeIds.AddPublishedEventsInputArguments), + new NodeId(PublishedDataSetsNodeIds.AddPublishedEventsOutputArguments)), + (new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItemsTemplate), + new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItemsTemplateInputArguments), + new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItemsTemplateOutputArguments)), + (new NodeId(PublishedDataSetsNodeIds.RemovePublishedDataSet), + new NodeId(PublishedDataSetsNodeIds.RemovePublishedDataSetInputArguments), + NodeId.Null), + (new NodeId(PublishedDataSetsNodeIds.AddDataSetFolder), + new NodeId(PublishedDataSetsNodeIds.AddDataSetFolderInputArguments), + new NodeId(PublishedDataSetsNodeIds.AddDataSetFolderOutputArguments)), + (new NodeId(PublishedDataSetsNodeIds.RemoveDataSetFolder), + new NodeId(PublishedDataSetsNodeIds.RemoveDataSetFolderInputArguments), + NodeId.Null) + })); + } + + [Test] + [TestSpec("9.1.4.5", Summary = "PublishSubscribe.PublishedDataSets instance methods are registered and callable")] + public async Task CreateAddressSpaceAsyncGeneratedPublishedDataSetsRegistersBrowseableMethodsAndArguments() + { + using var harness = new Harness(materializePublishedDataSets: true); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + var publishSubscribe = (PublishSubscribeState)harness.PublishSubscribeObject; + DataSetFolderState publishedDataSets = publishSubscribe.PublishedDataSets!; + var expectedMethods = new (uint MethodId, uint InputArgumentsId, uint? OutputArgumentsId)[] + { + (PublishedDataSetsNodeIds.AddPublishedDataItems, + PublishedDataSetsNodeIds.AddPublishedDataItemsInputArguments, + PublishedDataSetsNodeIds.AddPublishedDataItemsOutputArguments), + (PublishedDataSetsNodeIds.AddPublishedEvents, + PublishedDataSetsNodeIds.AddPublishedEventsInputArguments, + PublishedDataSetsNodeIds.AddPublishedEventsOutputArguments), + (PublishedDataSetsNodeIds.AddPublishedDataItemsTemplate, + PublishedDataSetsNodeIds.AddPublishedDataItemsTemplateInputArguments, + PublishedDataSetsNodeIds.AddPublishedDataItemsTemplateOutputArguments), + (PublishedDataSetsNodeIds.RemovePublishedDataSet, + PublishedDataSetsNodeIds.RemovePublishedDataSetInputArguments, + null), + (PublishedDataSetsNodeIds.AddDataSetFolder, + PublishedDataSetsNodeIds.AddDataSetFolderInputArguments, + PublishedDataSetsNodeIds.AddDataSetFolderOutputArguments), + (PublishedDataSetsNodeIds.RemoveDataSetFolder, + PublishedDataSetsNodeIds.RemoveDataSetFolderInputArguments, + null) + }; + + foreach ((uint methodId, uint inputArgumentsId, uint? outputArgumentsId) in expectedMethods) + { + MethodState method = harness.FindRegisteredNode(new NodeId(methodId))!; + BaseVariableState inputArguments = harness.FindRegisteredNode( + new NodeId(inputArgumentsId))!; + + Assert.Multiple(() => + { + Assert.That(method, Is.Not.Null); + Assert.That(method.Parent, Is.SameAs(publishedDataSets)); + Assert.That(inputArguments, Is.SameAs(method.InputArguments)); + Assert.That( + method.FindChild(harness.Context, QualifiedName.From(BrowseNames.InputArguments)), + Is.SameAs(inputArguments)); + }); + + if (outputArgumentsId is uint outputId) + { + BaseVariableState outputArguments = harness.FindRegisteredNode( + new NodeId(outputId))!; + Assert.Multiple(() => + { + Assert.That(outputArguments, Is.SameAs(method.OutputArguments)); + Assert.That( + method.FindChild(harness.Context, QualifiedName.From(BrowseNames.OutputArguments)), + Is.SameAs(outputArguments)); + }); + } + } + + MethodState addPublishedEvents = harness.FindRegisteredNode( + new NodeId(PublishedDataSetsNodeIds.AddPublishedEvents))!; + var selectedField = new SimpleAttributeOperand + { + TypeDefinitionId = ObjectTypeIds.BaseEventType, + BrowsePath = new ArrayOf(new[] { new QualifiedName(BrowseNames.EventId) }), + AttributeId = Attributes.Value + }; + var outputs = new List(); + var argumentErrors = new List(); + + ServiceResult result = addPublishedEvents.Call( + harness.Context, + ObjectIds.PublishSubscribe_PublishedDataSets, + BuildArray( + Variant.From("pds-events"), + Variant.From(ObjectIds.Server), + Variant.From(new ArrayOf(s_eventIdAlias)), + Variant.From(Array.Empty()), + Variant.FromStructure(new ArrayOf(new[] { selectedField })), + Variant.From(new ExtensionObject(new ContentFilter()))), + argumentErrors, + outputs); + + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(argumentErrors, Is.All.Matches(ServiceResult.IsGood)); + Assert.That(addPublishedEvents.OutputArguments!.Value, Has.Count.EqualTo(2)); + Assert.That(addPublishedEvents.OutputArguments.Value![0].Name, Is.EqualTo("ConfigurationVersion")); + Assert.That(addPublishedEvents.OutputArguments.Value[1].Name, Is.EqualTo("DataSetNodeId")); + Assert.That(outputs, Has.Count.EqualTo(2)); + Assert.That(outputs[0].TryGetValue(out ExtensionObject configurationVersion), Is.True); + Assert.That( + configurationVersion.TryGetValue(out ConfigurationVersionDataType? _), + Is.True); + Assert.That(outputs[1].TryGetValue(out NodeId dataSetNodeId), Is.True); + Assert.That(dataSetNodeId.IsNull, Is.False); + }); + } + + [Test] + public void PublishedDataSetsMethodThatWasNotCreatedIsAConfigurationError() + { + ServiceResultException ex = Assert.Throws( + () => PubSubNodeManager.AssignInstanceIds( + null, + "AddDataSetFolder", + PublishedDataSetsNodeIds.AddDataSetFolder, + PublishedDataSetsNodeIds.AddDataSetFolderInputArguments, + PublishedDataSetsNodeIds.AddDataSetFolderOutputArguments))!; + + Assert.Multiple(() => + { + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(ex.Message, Does.Contain("AddDataSetFolder (i=17398)")); + }); + } + + [TestCase(false, true, true, "has no InputArguments", + TestName = "PublishedDataSetsMethodWithoutInputArgumentsIsAConfigurationError")] + [TestCase(true, true, false, "that its standard declaration does not have", + TestName = "PublishedDataSetsMethodWithUndeclaredOutputArgumentsIsAConfigurationError")] + [TestCase(true, false, true, "has no OutputArguments", + TestName = "PublishedDataSetsMethodWithoutDeclaredOutputArgumentsIsAConfigurationError")] + public void PublishedDataSetsMethodWhoseArgumentsDoNotMatchIsAConfigurationError( + bool hasInputArguments, + bool hasOutputArguments, + bool declaresOutputArguments, + string mismatch) + { + var method = new MethodState(null) { BrowseName = new QualifiedName("AddDataSetFolder") }; + if (hasInputArguments) + { + method.InputArguments = PropertyState>.With>(method); + } + if (hasOutputArguments) + { + method.OutputArguments = PropertyState>.With>(method); + } + + ServiceResultException ex = Assert.Throws( + () => PubSubNodeManager.AssignInstanceIds( + method, + "AddDataSetFolder", + PublishedDataSetsNodeIds.AddDataSetFolder, + PublishedDataSetsNodeIds.AddDataSetFolderInputArguments, + declaresOutputArguments ? PublishedDataSetsNodeIds.AddDataSetFolderOutputArguments : null))!; + + Assert.Multiple(() => + { + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(ex.Message, Does.Contain("AddDataSetFolder (i=17398)")); + Assert.That(ex.Message, Does.Contain(mismatch)); + Assert.That(method.NodeId, Is.EqualTo(NodeId.Null)); + }); + } + + [Test] + [TestSpec("9.1.4.5", Summary = "PublishSubscribe.PublishedDataSets exposes the DataSetFolderType methods")] + public async Task PublishedDataSetsInstanceMethodsAreBound() + { + using var harness = new Harness(); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(harness.AddPublishedDataItemsMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.AddPublishedEventsMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.AddPublishedDataItemsTemplateMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.RemovePublishedDataSetMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.AddDataSetFolderMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.RemoveDataSetFolderMethod.OnCallMethod, Is.Not.Null); + Assert.That(harness.PlaceholderAddPublishedDataItemsMethod.OnCallMethod, Is.Null); + Assert.That(harness.PlaceholderAddDataSetFolderMethod.OnCallMethod, Is.Null); + }); + } + + [Test] + [TestSpec("9.1.4.5", Summary = "PublishedDataSets.AddPublishedDataItems adds a PublishedDataSet below it")] + public async Task PublishedDataSetsAddPublishedDataItemsMaterializesTheDataSetBelowIt() + { + using var harness = new Harness(); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + MethodState add = harness.AddPublishedDataItemsMethod; + var outputs = new List(); + var variable = new PublishedVariableDataType + { + PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime), + AttributeId = Attributes.Value + }; + ArrayOf fieldAliases = ["CurrentTime"]; + + ServiceResult result = add.OnCallMethod!( + harness.Context, + add, + BuildArray( + Variant.From("pds-method"), + Variant.From(fieldAliases), + Variant.From(Array.Empty()), + Variant.FromStructure(new ArrayOf(new[] { variable }))), + outputs); + + BaseObjectState dataSetNode = harness.Manager.FindPredefinedNode( + new NodeId( + "pubsub:published-data-set:pds-method", + harness.Manager.AddressSpaceNamespaceIndex)); + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(dataSetNode, Is.Not.Null); + Assert.That( + dataSetNode?.Parent?.NodeId, + Is.EqualTo(ObjectIds.PublishSubscribe_PublishedDataSets)); + }); + } + + [Test] + [TestSpec("9.1.4.5", Summary = "PublishedDataSets.AddPublishedEvents returns its declared outputs")] + public async Task PublishedDataSetsAddPublishedEventsReturnsConfigurationVersionThenDataSetId() + { + using var harness = new Harness(); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + MethodState add = harness.AddPublishedEventsMethod; + var outputs = new List(); + var selectedField = new SimpleAttributeOperand + { + TypeDefinitionId = ObjectTypeIds.BaseEventType, + BrowsePath = new ArrayOf(new[] { new QualifiedName(BrowseNames.EventId) }), + AttributeId = Attributes.Value + }; + + ServiceResult result = add.OnCallMethod!( + harness.Context, + add, + BuildArray( + Variant.From("pds-events"), + Variant.From(ObjectIds.Server), + Variant.From(new ArrayOf(s_eventIdAlias)), + Variant.From(Array.Empty()), + Variant.FromStructure(new ArrayOf(new[] { selectedField })), + Variant.From(new ExtensionObject(new ContentFilter()))), + outputs); + + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(outputs, Has.Count.EqualTo(2)); + Assert.That(outputs[0].TryGetValue(out ExtensionObject configurationVersion), Is.True); + Assert.That( + configurationVersion.TryGetValue(out ConfigurationVersionDataType? _), + Is.True); + Assert.That(outputs[1].TryGetValue(out NodeId dataSetId), Is.True); + Assert.That(dataSetId.IsNull, Is.False); + }); + } + + [Test] + [TestSpec("9.1.3.7", Summary = "PubSubConfigurationType exposes FileType-style import/export")] + public async Task PubSubConfigurationFileMethods_ReadAndCloseAndUpdateConfiguration() + { + using var harness = new Harness(); + await harness.Manager.CreateAddressSpaceAsync( + new Dictionary>()).ConfigureAwait(false); + BaseObjectState fileNode = harness.Manager.FindPredefinedNode( + new NodeId("pubsub:configuration", harness.Manager.AddressSpaceNamespaceIndex))!; + var open = (MethodState)fileNode.FindChild( + harness.Context, + new QualifiedName("Open", harness.Manager.AddressSpaceNamespaceIndex))!; + var read = (MethodState)fileNode.FindChild( + harness.Context, + new QualifiedName("Read", harness.Manager.AddressSpaceNamespaceIndex))!; + var reserve = (MethodState)fileNode.FindChild( + harness.Context, + new QualifiedName("ReserveIds", harness.Manager.AddressSpaceNamespaceIndex))!; + var closeAndUpdate = (MethodState)fileNode.FindChild( + harness.Context, + new QualifiedName("CloseAndUpdate", harness.Manager.AddressSpaceNamespaceIndex))!; + var reserveOutputs = new List(); + ServiceResult reserveResult = reserve.OnCallMethod!( + harness.Context, + reserve, + BuildArray(Variant.From(Profiles.PubSubUdpUadpTransport), Variant.From((ushort)1), Variant.From((ushort)1)), + reserveOutputs); + var openReadOutputs = new List(); + open.OnCallMethod!( + harness.Context, + open, + BuildArray(Variant.From((byte)1)), + openReadOutputs); + Assert.That(openReadOutputs[0].TryGetValue(out uint readHandle), Is.True); + var readOutputs = new List(); + + ServiceResult readResult = read.OnCallMethod!( + harness.Context, + read, + BuildArray(Variant.From(readHandle), Variant.From(4096)), + readOutputs); + Assert.That(readOutputs[0].TryGetValue(out ArrayOf payload), Is.True); + var openWriteOutputs = new List(); + open.OnCallMethod!( + harness.Context, + open, + BuildArray(Variant.From((byte)2)), + openWriteOutputs); + Assert.That(openWriteOutputs[0].TryGetValue(out uint writeHandle), Is.True); + var write = (MethodState)fileNode.FindChild( + harness.Context, + new QualifiedName("Write", harness.Manager.AddressSpaceNamespaceIndex))!; + write.OnCallMethod!( + harness.Context, + write, + BuildArray(Variant.From(writeHandle), Variant.From(payload)), + []); + var updateOutputs = new List(); + + ServiceResult updateResult = closeAndUpdate.OnCallMethod!( + harness.Context, + closeAndUpdate, + BuildArray(Variant.From(writeHandle), Variant.From(false), Variant.Null), + updateOutputs); + + Assert.Multiple(() => + { + Assert.That(StatusCode.IsGood(readResult.StatusCode), Is.True); + Assert.That(StatusCode.IsGood(reserveResult.StatusCode), Is.True); + Assert.That(reserveOutputs[1].TryGetValue(out ArrayOf writerIds), Is.True); + Assert.That(writerIds, Has.Count.EqualTo(1)); + Assert.That(payload, Is.Not.Empty); + Assert.That(StatusCode.IsGood(updateResult.StatusCode), Is.True); + Assert.That(updateOutputs[0].TryGetValue(out bool applied), Is.True); + Assert.That(applied, Is.True); + }); + } + + [Test] + public void Constructor_NullArgs_Throw() + { + using var harness = new Harness(); + // The harness already produced one Manager, so use its + // collaborators to exercise null-arg paths for the + // public constructor. + ApplicationConfiguration config = harness.Configuration; + IPubSubApplication app = harness.Application; + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var options = new PubSubServerOptions(); + + Assert.Multiple(() => + { + Assert.That(() => new PubSubNodeManager( + harness.MockServer.Object, config, null!, null, options, telemetry), + Throws.ArgumentNullException); + Assert.That(() => new PubSubNodeManager( + harness.MockServer.Object, config, app, null, null!, telemetry), + Throws.ArgumentNullException); + Assert.That(() => new PubSubNodeManager( + harness.MockServer.Object, config, app, null, options, null!), + Throws.ArgumentNullException); + }); + } + + [Test] + public void Factory_Create_ReturnsAttachedSyncNodeManager() + { + using var harness = new Harness(); + var factory = new PubSubNodeManagerFactory( + harness.Application, + null, + new PubSubServerOptions(), + NUnitTelemetryContext.Create()); + + Assert.That(factory.NamespacesUris, Is.Not.Empty); + INodeManager nm = factory.Create(harness.MockServer.Object, harness.Configuration); + + Assert.That(nm, Is.Not.Null); + (nm as IDisposable)?.Dispose(); + } + + [Test] + public void Factory_NullArgs_Throw() + { + using var harness = new Harness(); + var options = new PubSubServerOptions(); + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + Assert.Multiple(() => + { + Assert.That(() => new PubSubNodeManagerFactory(null!, null, options, telemetry), + Throws.ArgumentNullException); + Assert.That(() => new PubSubNodeManagerFactory(harness.Application, null, null!, telemetry), + Throws.ArgumentNullException); + Assert.That(() => new PubSubNodeManagerFactory(harness.Application, null, options, null!), + Throws.ArgumentNullException); + }); + } + + private static ArrayOf BuildArray(params Variant[] values) + { + return new ArrayOf(values); + } + + private sealed class Harness : IDisposable + { + public Harness( + Action? configure = null, + bool includeSks = false, + PushSecurityKeyProvider? pushProvider = null, + bool materializePublishedDataSets = false) + { + MockServer = new Mock(); + NamespaceTable = new NamespaceTable(); + NamespaceTable.Append(Namespaces.OpcUa); + MockServer.Setup(s => s.NamespaceUris).Returns(NamespaceTable); + MockServer.Setup(s => s.ServerUris).Returns(new StringTable()); + MockServer.Setup(s => s.Factory).Returns(EncodeableFactory.Create()); + var typeTree = new TypeTable(NamespaceTable); + if (materializePublishedDataSets) + { + typeTree.AddSubtype(DataTypeIds.BaseDataType, NodeId.Null); + typeTree.AddSubtype(DataTypeIds.Structure, DataTypeIds.BaseDataType); + typeTree.AddSubtype(DataTypeIds.Enumeration, DataTypeIds.BaseDataType); + typeTree.AddSubtype(new NodeId(15904u), DataTypeIds.Enumeration); + typeTree.AddSubtype(new NodeId(601u), DataTypeIds.Structure); + typeTree.AddSubtype(new NodeId(586u), DataTypeIds.Structure); + } + MockServer.Setup(s => s.TypeTree).Returns(typeTree); + + var mockMaster = new Mock(); + var mockConfig = new Mock(); + mockMaster.Setup(m => m.ConfigurationNodeManager).Returns(mockConfig.Object); + MockServer.Setup(s => s.NodeManager).Returns(mockMaster.Object); + + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + MockServer.Setup(s => s.Telemetry).Returns(telemetry); + + m_queueFactory = new MonitoredItemQueueFactory(telemetry); + MockServer.Setup(s => s.MonitoredItemQueueFactory).Returns(m_queueFactory); + + Context = new ServerSystemContext(MockServer.Object); + MockServer.Setup(s => s.DefaultSystemContext).Returns(Context); + + Configuration = new ApplicationConfiguration + { + ServerConfiguration = new ServerConfiguration + { + MaxNotificationQueueSize = 100, + MaxDurableNotificationQueueSize = 200 + } + }; + + EnableMethod = NewMethod(17407); + DisableMethod = NewMethod(17408); + SetSecurityKeysMethod = NewMethod(17364); + AddConnectionMethod = NewMethod(17366); + RemoveConnectionMethod = NewMethod(17369); + GetSecurityKeysMethod = NewMethod(15215); + GetSecurityGroupMethod = NewMethod(15440); + AddSecurityGroupMethod = NewMethod(15444); + RemoveSecurityGroupMethod = NewMethod(15447); + AddPushTargetMethod = NewMethod(25441); + RemovePushTargetMethod = NewMethod(25444); + AddDataSetFolderMethod = NewMethod(PublishedDataSetsNodeIds.AddDataSetFolder); + RemoveDataSetFolderMethod = NewMethod(PublishedDataSetsNodeIds.RemoveDataSetFolder); + AddPublishedDataItemsMethod = NewMethod(PublishedDataSetsNodeIds.AddPublishedDataItems); + AddPublishedEventsMethod = NewMethod(PublishedDataSetsNodeIds.AddPublishedEvents); + AddPublishedDataItemsTemplateMethod = NewMethod(PublishedDataSetsNodeIds.AddPublishedDataItemsTemplate); + RemovePublishedDataSetMethod = NewMethod(PublishedDataSetsNodeIds.RemovePublishedDataSet); + PlaceholderAddPublishedDataItemsMethod = + NewMethod(PublishedDataSetsNodeIds.PlaceholderAddPublishedDataItems); + PlaceholderAddDataSetFolderMethod = NewMethod(PublishedDataSetsNodeIds.PlaceholderAddDataSetFolder); + StatusVariable = new BaseDataVariableState(null) + { + NodeId = new NodeId(17406u), + BrowseName = new QualifiedName("State") + }; + PublishSubscribeObject = new BaseObjectState(null) + { + NodeId = ObjectIds.PublishSubscribe, + BrowseName = new QualifiedName("PublishSubscribe") + }; + if (materializePublishedDataSets) + { + var publishSubscribe = new PublishSubscribeState(null) + { + NodeId = ObjectIds.PublishSubscribe, + BrowseName = new QualifiedName("PublishSubscribe") + }; + publishSubscribe.CreateOrReplacePublishedDataSets( + Context, + new DataSetFolderState(publishSubscribe) + { + NodeId = ObjectIds.PublishSubscribe_PublishedDataSets, + BrowseName = new QualifiedName("PublishedDataSets") + }, + assignInstanceNodeIds: false); + publishSubscribe.AddPubSubConfiguration( + Context, + new NodeId("pubsub:configuration", 1)); + PublishSubscribeObject = publishSubscribe; + } + PublishedDataSetsObject = new BaseObjectState(PublishSubscribeObject) + { + NodeId = ObjectIds.PublishSubscribe_PublishedDataSets, + BrowseName = new QualifiedName("PublishedDataSets") + }; + DataSetFolderPlaceholderObject = new BaseObjectState(null) + { + NodeId = ObjectIds.DataSetFolderType_DataSetFolderName_Placeholder, + BrowseName = new QualifiedName("") + }; + SecurityGroupsObject = new BaseObjectState(PublishSubscribeObject) + { + NodeId = new NodeId(15443u), + BrowseName = new QualifiedName("SecurityGroups") + }; + KeyPushTargetsObject = new BaseObjectState(PublishSubscribeObject) + { + NodeId = new NodeId(25440u), + BrowseName = new QualifiedName("KeyPushTargets") + }; + + var diagnosticsNm = new Mock(); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17407u))).Returns(EnableMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17408u))).Returns(DisableMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17364u))).Returns(SetSecurityKeysMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17366u))).Returns(AddConnectionMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17369u))).Returns(RemoveConnectionMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15215u))).Returns(GetSecurityKeysMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15440u))).Returns(GetSecurityGroupMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15444u))).Returns(AddSecurityGroupMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15447u))).Returns(RemoveSecurityGroupMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25441u))).Returns(AddPushTargetMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25444u))).Returns(RemovePushTargetMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.AddDataSetFolder))).Returns(AddDataSetFolderMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.RemoveDataSetFolder))).Returns(RemoveDataSetFolderMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItems))) + .Returns(AddPublishedDataItemsMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.AddPublishedEvents))) + .Returns(AddPublishedEventsMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.AddPublishedDataItemsTemplate))) + .Returns(AddPublishedDataItemsTemplateMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.RemovePublishedDataSet))) + .Returns(RemovePublishedDataSetMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.PlaceholderAddPublishedDataItems))) + .Returns(PlaceholderAddPublishedDataItemsMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + new NodeId(PublishedDataSetsNodeIds.PlaceholderAddDataSetFolder))) + .Returns(PlaceholderAddDataSetFolderMethod); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(17406u))).Returns(StatusVariable); + diagnosticsNm.Setup(m => m.FindPredefinedNode(It.IsAny())) + .Returns((NodeId id) => id == new NodeId(17406u) ? StatusVariable : null!); + diagnosticsNm.Setup(m => m.FindPredefinedNode(ObjectIds.PublishSubscribe)) + .Returns(PublishSubscribeObject); + diagnosticsNm.Setup(m => m.FindPredefinedNode(ObjectIds.PublishSubscribe_PublishedDataSets)) + .Returns(PublishedDataSetsObject); + diagnosticsNm.Setup(m => m.FindPredefinedNode( + ObjectIds.DataSetFolderType_DataSetFolderName_Placeholder)) + .Returns(DataSetFolderPlaceholderObject); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(15443u))) + .Returns(SecurityGroupsObject); + diagnosticsNm.Setup(m => m.FindPredefinedNode(new NodeId(25440u))) + .Returns(KeyPushTargetsObject); + if (materializePublishedDataSets) + { + RegisterNode(EnableMethod); + RegisterNode(DisableMethod); + RegisterNode(SetSecurityKeysMethod); + RegisterNode(AddConnectionMethod); + RegisterNode(RemoveConnectionMethod); + diagnosticsNm.Setup(m => m.AddNode(It.IsAny())) + .Callback(RegisterNode); + diagnosticsNm.Setup(m => m.FindPredefinedNode(It.IsAny())) + .Returns((NodeId nodeId) => FindRegisteredNode(nodeId)!); + } + MockServer.Setup(s => s.DiagnosticsNodeManager).Returns(diagnosticsNm.Object); + + Application = new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) + .WithApplicationId("test-nodemanager") + .UseConfiguration(new PubSubConfigurationDataType + { + Connections = [], + PublishedDataSets = [] + }) + .UseAllStandardEncoders() + .AddTransportFactory(new StubTransportFactory()) + .Build(); + + SksServer = new InMemoryPubSubKeyServiceServer(); + + Options = new PubSubServerOptions(); + configure?.Invoke(Options); + + Manager = new PubSubNodeManager( + MockServer.Object, + Configuration, + Application, + includeSks ? SksServer : null, + Options, + telemetry, + pushKeyProviders: pushProvider is null ? null : [pushProvider]); + } + + public Mock MockServer { get; } + public NamespaceTable NamespaceTable { get; } + public ApplicationConfiguration Configuration { get; } + public IPubSubApplication Application { get; } + public InMemoryPubSubKeyServiceServer SksServer { get; } + public PubSubServerOptions Options { get; } + public PubSubNodeManager Manager { get; } + public MethodState EnableMethod { get; } + public MethodState DisableMethod { get; } + public MethodState SetSecurityKeysMethod { get; } + public MethodState AddConnectionMethod { get; } + public MethodState RemoveConnectionMethod { get; } + public MethodState GetSecurityKeysMethod { get; } + public MethodState GetSecurityGroupMethod { get; } + public MethodState AddSecurityGroupMethod { get; } + public MethodState RemoveSecurityGroupMethod { get; } + public MethodState AddPushTargetMethod { get; } + public MethodState RemovePushTargetMethod { get; } + public MethodState AddDataSetFolderMethod { get; } + public MethodState RemoveDataSetFolderMethod { get; } + public MethodState AddPublishedDataItemsMethod { get; } + public MethodState AddPublishedEventsMethod { get; } + public MethodState AddPublishedDataItemsTemplateMethod { get; } + public MethodState RemovePublishedDataSetMethod { get; } + public MethodState PlaceholderAddPublishedDataItemsMethod { get; } + public MethodState PlaceholderAddDataSetFolderMethod { get; } + public BaseDataVariableState StatusVariable { get; } + public BaseObjectState PublishSubscribeObject { get; } + public BaseObjectState PublishedDataSetsObject { get; } + public BaseObjectState DataSetFolderPlaceholderObject { get; } + public BaseObjectState SecurityGroupsObject { get; } + public BaseObjectState KeyPushTargetsObject { get; } + public ServerSystemContext Context { get; } + + public T? FindRegisteredNode(NodeId nodeId) where T : NodeState + { + return m_registeredNodes.TryGetValue(nodeId, out NodeState? node) ? node as T : null; + } + + public void Dispose() + { + Manager.Dispose(); + (Application as IDisposable)?.Dispose(); + (Application as IAsyncDisposable)?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + m_queueFactory.Dispose(); + } + + private static MethodState NewMethod(uint nodeId) + { + return new MethodState(null) + { + NodeId = new NodeId(nodeId), + BrowseName = new QualifiedName("M" + nodeId) + }; + } + + private void RegisterNode(NodeState node) + { + m_registeredNodes[node.NodeId] = node; + var children = new List(); + node.GetChildren(Context, children); + foreach (BaseInstanceState child in children) + { + RegisterNode(child); + } + } + + private readonly MonitoredItemQueueFactory m_queueFactory; + private readonly Dictionary m_registeredNodes = new(); + + private sealed class StubTransportFactory : IPubSubTransportFactory + { + public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; + + public IPubSubTransport Create( + PubSubConnectionDataType connection, + ITelemetryContext telemetry, + TimeProvider timeProvider) + { + _ = connection; + _ = telemetry; + _ = timeProvider; + throw new NotSupportedException(); + } + } + } + } +} diff --git a/tests/Opc.Ua.PubSub.Server.Tests/PublishedDataSetsNodeIds.cs b/tests/Opc.Ua.PubSub.Server.Tests/PublishedDataSetsNodeIds.cs new file mode 100644 index 0000000000..022cbb911c --- /dev/null +++ b/tests/Opc.Ua.PubSub.Server.Tests/PublishedDataSetsNodeIds.cs @@ -0,0 +1,61 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Opc.Ua.PubSub.Server.Tests +{ + /// + /// The standard NodeIds of the methods of PublishSubscribe.PublishedDataSets + /// (i=17371) and of their argument variables, as declared by the OPC UA NodeSet. + /// The tests keep their own copy so that they check the NodeIds the node manager + /// assigns against the standard, not against its own constants. The + /// Placeholder* ids are the method declarations of the DataSetFolderType + /// <DataSetFolderName> placeholder, which must not be bound. + /// + internal static class PublishedDataSetsNodeIds + { + public const uint AddPublishedDataItems = 17372; + public const uint AddPublishedDataItemsInputArguments = 17373; + public const uint AddPublishedDataItemsOutputArguments = 17374; + public const uint AddPublishedEvents = 17375; + public const uint AddPublishedEventsInputArguments = 17376; + public const uint AddPublishedEventsOutputArguments = 17377; + public const uint AddPublishedDataItemsTemplate = 17378; + public const uint AddPublishedDataItemsTemplateInputArguments = 17379; + public const uint AddPublishedDataItemsTemplateOutputArguments = 17380; + public const uint RemovePublishedDataSet = 17384; + public const uint RemovePublishedDataSetInputArguments = 17385; + public const uint AddDataSetFolder = 17398; + public const uint AddDataSetFolderInputArguments = 17401; + public const uint AddDataSetFolderOutputArguments = 17402; + public const uint RemoveDataSetFolder = 17403; + public const uint RemoveDataSetFolderInputArguments = 17404; + public const uint PlaceholderAddPublishedDataItems = 14479; + public const uint PlaceholderAddDataSetFolder = 16884; + } +} \ No newline at end of file