diff --git a/Applications/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml b/Applications/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml
index 51a8631630..c04150b419 100644
--- a/Applications/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml
+++ b/Applications/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml
@@ -267,7 +267,7 @@
- %LocalApplicationData%/OPC Foundation/GDS/gdsdb.json
+
diff --git a/Applications/ConsoleReferenceServer/GdsNodeManagerFactory.cs b/Applications/ConsoleReferenceServer/GdsNodeManagerFactory.cs
index c73f8c386d..edeb3de83c 100644
--- a/Applications/ConsoleReferenceServer/GdsNodeManagerFactory.cs
+++ b/Applications/ConsoleReferenceServer/GdsNodeManagerFactory.cs
@@ -81,7 +81,9 @@ public INodeManager Create(IServerInternal server, ApplicationConfiguration conf
Directory.CreateDirectory(databaseDir);
}
- var database = JsonApplicationsDatabase.Load(databaseStorePath);
+ LinqApplicationsDatabase database = string.IsNullOrEmpty(databaseDir)
+ ? new LinqApplicationsDatabase()
+ : new JsonApplicationsDatabase(databaseStorePath);
return new ApplicationsNodeManager(
server,
diff --git a/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml b/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml
index 8e801e37cb..e86c3ee155 100644
--- a/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml
+++ b/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml
@@ -394,7 +394,7 @@
- %LocalApplicationData%/OPC Foundation/GDS/gdsdb.json
+
diff --git a/Applications/UAReferenceServer.ctt.xml b/Applications/UAReferenceServer.ctt.xml
index 970a9b61b0..f4fe6a61da 100644
--- a/Applications/UAReferenceServer.ctt.xml
+++ b/Applications/UAReferenceServer.ctt.xml
@@ -16799,7 +16799,7 @@
-
+
@@ -16843,7 +16843,7 @@
-
+
@@ -16887,7 +16887,7 @@
-
+
@@ -16931,7 +16931,7 @@
-
+
@@ -16975,7 +16975,7 @@
-
+
@@ -17019,7 +17019,7 @@
-
+
@@ -17063,7 +17063,7 @@
-
+
@@ -17107,7 +17107,7 @@
-
+
diff --git a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/ApplicationsDatabaseBase.cs b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/ApplicationsDatabaseBase.cs
index 4ae062b532..caa3e58ccf 100644
--- a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/ApplicationsDatabaseBase.cs
+++ b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/ApplicationsDatabaseBase.cs
@@ -30,6 +30,8 @@
using System;
using System.Collections.Generic;
using System.Text;
+using Opc.Ua.Security;
+using Opc.Ua.Types;
namespace Opc.Ua.Gds.Server.Database
{
@@ -115,7 +117,9 @@ public virtual NodeId RegisterApplication(ApplicationRecordDataType application)
if (application.ServerCapabilities.IsEmpty)
{
- application.ServerCapabilities = ["NA"];
+ throw new ArgumentException(
+ "At least one ServerCapability must be provided.",
+ nameof(application));
}
}
else if (!application.DiscoveryUrls.IsEmpty)
@@ -161,6 +165,11 @@ public virtual ApplicationRecordDataType GetApplication(NodeId applicationId)
public virtual ApplicationRecordDataType[] FindApplications(string applicationUri)
{
+ if (string.IsNullOrWhiteSpace(applicationUri))
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument);
+ }
return null;
}
@@ -190,6 +199,20 @@ public virtual ApplicationDescription[] QueryApplications(
{
lastCounterResetTime = DateTimeUtc.MinValue;
nextRecordId = 0;
+
+ if (applicationType > 2)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument);
+ }
+
+ if (serverCapabilities.Contains("NA", StringComparer.OrdinalIgnoreCase) &&
+ serverCapabilities.Count > 1)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument);
+ }
+
return null;
}
@@ -332,7 +355,7 @@ protected Guid GetNodeIdGuid(NodeId nodeId)
if (NamespaceIndex != nodeId.NamespaceIndex ||
!nodeId.TryGetValue(out Guid id))
{
- throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
+ throw new ServiceResultException(StatusCodes.BadNotFound);
}
return id;
@@ -367,7 +390,7 @@ protected void ValidateApplicationNodeId(NodeId nodeId)
if ((nodeId.IdType != IdType.Guid && nodeId.IdType != IdType.String) ||
NamespaceIndex != nodeId.NamespaceIndex)
{
- throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
+ throw new ServiceResultException(StatusCodes.BadNotFound);
}
}
diff --git a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/LinqApplicationsDatabase.cs b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/LinqApplicationsDatabase.cs
index 016e30ecc3..4aba023c77 100644
--- a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/LinqApplicationsDatabase.cs
+++ b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsDatabase/LinqApplicationsDatabase.cs
@@ -109,6 +109,7 @@ public override void Initialize()
public override NodeId RegisterApplication(ApplicationRecordDataType application)
{
+ bool isNewEntry = application.ApplicationId.IsNull;
NodeId appNodeId = base.RegisterApplication(application);
if (appNodeId.IsNull)
{
@@ -119,19 +120,36 @@ public override NodeId RegisterApplication(ApplicationRecordDataType application
lock (Lock)
{
+ bool existingApplication = (
+ from ii in Applications
+ where ii.ApplicationUri == application.ApplicationUri
+ select ii
+ ).Any();
+
+ if (existingApplication && isNewEntry)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadEntryExists,
+ "An application with the same application URI is already registered.");
+ }
+
Application record = null;
if (applicationId != Guid.Empty)
{
- IEnumerable results =
- from ii in Applications
+ record =
+ (from ii in Applications
where ii.ApplicationId == applicationId
- select ii;
-
- record = results.SingleOrDefault();
+ select ii).SingleOrDefault();
if (record != null)
{
+ if (record.ApplicationUri != application.ApplicationUri)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadWriteNotSupported);
+ }
+
var endpoints = (
from ii in ServerEndpoints
where ii.ApplicationId == record.ApplicationId
@@ -221,9 +239,9 @@ public override void UnregisterApplication(NodeId applicationId)
Application application =
(from ii in Applications where ii.ApplicationId == id select ii)
.SingleOrDefault()
- ?? throw new ArgumentException(
- "A record with the specified application id does not exist.",
- nameof(applicationId));
+ ?? throw new ServiceResultException(
+ StatusCodes.BadNotFound,
+ "A record with the specified application id does not exist.");
IEnumerable certificateRequests =
from ii in CertificateRequests
@@ -323,6 +341,8 @@ from ii in ServerEndpoints
public override ApplicationRecordDataType[] FindApplications(string applicationUri)
{
+ base.FindApplications(applicationUri);
+
lock (Lock)
{
IEnumerable results =
@@ -495,6 +515,7 @@ from ii in ServerEndpoints
{
if (maxRecordsToReturn != 0 && records.Count >= maxRecordsToReturn)
{
+ nextRecordId = result.ID + 1;
break;
}
@@ -524,7 +545,6 @@ from ii in ApplicationNames
DiscoveryProfileUri = null,
DiscoveryUrls = discoveryUrls
});
- nextRecordId = lastID + 1;
}
return [.. records];
}
diff --git a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs
index c41cec7421..03e8c3cb4b 100644
--- a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs
+++ b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs
@@ -65,7 +65,7 @@ public ApplicationsNodeManager(
ApplicationConfiguration configuration,
IApplicationsDatabase database,
ICertificateRequest request,
- ICertificateGroup certificateGroup,
+ ICertificateGroup certificateGroupFactory,
bool autoApprove = false)
: base(
server,
@@ -105,7 +105,7 @@ public ApplicationsNodeManager(
m_autoApprove = autoApprove;
m_database = database;
m_request = request;
- m_certificateGroupFactory = certificateGroup;
+ m_certificateGroupFactory = certificateGroupFactory;
m_certificateGroups = [];
try
@@ -652,7 +652,14 @@ private ServiceResult OnRegisterApplication(
m_logger.LogInformation("OnRegisterApplication: {ApplicationUri}", application.ApplicationUri);
- applicationId = m_database.RegisterApplication(application);
+ try
+ {
+ applicationId = m_database.RegisterApplication(application);
+ }
+ catch (ArgumentException ex)
+ {
+ throw new ServiceResultException(StatusCodes.BadInvalidArgument, ex);
+ }
if (!applicationId.IsNull)
{
@@ -687,7 +694,14 @@ private ServiceResult OnUpdateApplication(
LocalizedText.From("The application id does not exist."));
}
- m_database.RegisterApplication(application);
+ try
+ {
+ m_database.RegisterApplication(application);
+ }
+ catch (ArgumentException ex)
+ {
+ throw new ServiceResultException(StatusCodes.BadInvalidArgument, ex);
+ }
ArrayOf inputArguments = [Variant.FromStructure(application)];
Server.ReportApplicationRegistrationChangedAuditEvent(
@@ -806,6 +820,7 @@ private ServiceResult OnFindApplications(
{
AuthorizationHelper.HasAuthorization(context, AuthorizationHelper.AuthenticatedUser);
m_logger.LogInformation("OnFindApplications: {ApplicationUri}", applicationUri);
+
applications = m_database.FindApplications(applicationUri);
return ServiceResult.Good;
}
@@ -822,7 +837,20 @@ private ServiceResult OnGetApplication(
AuthorizationHelper.AuthenticatedUserOrSelfAdmin,
applicationId);
m_logger.LogInformation("OnGetApplication: {ApplicationId}", applicationId);
- application = m_database.GetApplication(applicationId);
+
+ try
+ {
+ application = m_database.GetApplication(applicationId);
+ }
+ catch (ArgumentException ex)
+ {
+ throw new ServiceResultException(StatusCodes.BadInvalidArgument, ex);
+ }
+
+ if (application == null)
+ {
+ throw new ServiceResultException(StatusCodes.BadNotFound);
+ }
return ServiceResult.Good;
}
diff --git a/Libraries/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs b/Libraries/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs
index 4fb67b10fb..b3f4b27c90 100644
--- a/Libraries/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs
+++ b/Libraries/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs
@@ -67,7 +67,7 @@ internal static void ReportCertificateDeliveredAuditEvent(
e.SetChildValue(
systemContext,
Ua.BrowseNames.SourceName,
- "Method/UpdateCertificate",
+ "Method/FinishRequest",
false);
e.SetChildValue(
systemContext,
@@ -148,7 +148,7 @@ internal static void ReportCertificateRequestedAuditEvent(
e.SetChildValue(
systemContext,
Ua.BrowseNames.SourceName,
- "Method/UpdateCertificate",
+ "Method/StartNewKeyPairRequest",
false);
e.SetChildValue(
systemContext,
@@ -180,7 +180,7 @@ internal static void ReportCertificateRequestedAuditEvent(
{
logger.LogError(
ex,
- "Error while reporting CertificateDeliveredAuditEventState event.");
+ "Error while reporting CertificateRequestedAuditEventState event.");
}
}
@@ -216,14 +216,21 @@ internal static void ReportApplicationRegistrationChangedAuditEvent(
e.SetChildValue(
systemContext,
Ua.BrowseNames.SourceName,
- "Method/UpdateCertificate",
+ "Method/RegisterApplication",
false);
+
e.SetChildValue(
systemContext,
Ua.BrowseNames.LocalTime,
TimeZoneDataType.Local,
false);
+ e.SetChildValue(
+ systemContext,
+ Ua.BrowseNames.ActionTimeStamp,
+ DateTimeUtc.Now,
+ false);
+
e.SetChildValue(systemContext, Ua.BrowseNames.MethodId, method?.NodeId ?? default, false);
e.SetChildValue(
systemContext,
@@ -237,7 +244,7 @@ internal static void ReportApplicationRegistrationChangedAuditEvent(
{
logger.LogError(
ex,
- "Error while reporting CertificateDeliveredAuditEventState event.");
+ "Error while reporting ApplicationRegistrationChangedAuditEventState event.");
}
}
}
diff --git a/Libraries/Opc.Ua.Server/Diagnostics/ParsedNodeId.cs b/Libraries/Opc.Ua.Server/Diagnostics/ParsedNodeId.cs
index 3bde7c539a..79d6a0fd4e 100644
--- a/Libraries/Opc.Ua.Server/Diagnostics/ParsedNodeId.cs
+++ b/Libraries/Opc.Ua.Server/Diagnostics/ParsedNodeId.cs
@@ -41,7 +41,6 @@ namespace Opc.Ua.Server
/// for the Root Node. The ComponentPath is constructed from the SymbolicNames
/// of one or more children of the Root Node.
///
- [Obsolete("Will be removed in a future version")]
public class ParsedNodeId
{
///
diff --git a/Libraries/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs b/Libraries/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs
index 9b3cc93563..9c734186c0 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs
@@ -132,6 +132,21 @@ public ValueTask CallAsync(
return default;
}
+ ///
+ public ValueTask FindMethodStateAsync(
+ OperationContext context,
+ CallMethodRequest methodToCall,
+ CancellationToken cancellationToken = default)
+ {
+ if (SyncNodeManager is INodeManager3 nodeManager)
+ {
+ MethodState method = nodeManager.FindMethodState(context, methodToCall);
+ return new ValueTask(method);
+ }
+
+ return new ValueTask((MethodState)null);
+ }
+
///
public ValueTask ConditionRefreshAsync(
OperationContext context,
diff --git a/Libraries/Opc.Ua.Server/NodeManager/Adapters/SyncNodeManagerAdapter.cs b/Libraries/Opc.Ua.Server/NodeManager/Adapters/SyncNodeManagerAdapter.cs
index 36b4e4b5bd..527a9befa7 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/Adapters/SyncNodeManagerAdapter.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/Adapters/SyncNodeManagerAdapter.cs
@@ -289,6 +289,14 @@ public NodeMetadata GetPermissionMetadata(
.AsTask().GetAwaiter().GetResult();
}
+ ///
+ public MethodState FindMethodState(
+ OperationContext context,
+ CallMethodRequest methodToCall)
+ {
+ return m_nodeManager.FindMethodStateAsync(context, methodToCall).AsTask().GetAwaiter().GetResult();
+ }
+
///
public ServiceResult ValidateEventRolePermissions(IEventMonitoredItem monitoredItem, IFilterTarget filterTarget)
{
diff --git a/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs
index 61ff9aafaa..7f06b67871 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs
@@ -3215,7 +3215,74 @@ protected virtual async ValueTask HistoryDeleteEventsAsync(
}
///
- /// Asycnhronously calls a method defined on an object.
+ /// Resolves the effective method for a call request.
+ ///
+ public virtual async ValueTask FindMethodStateAsync(
+ OperationContext context,
+ CallMethodRequest methodToCall,
+ CancellationToken cancellationToken = default)
+ {
+ if (methodToCall == null || methodToCall.ObjectId.IsNull || methodToCall.MethodId.IsNull)
+ {
+ return null;
+ }
+
+ ServerSystemContext systemContext = SystemContext.Copy(context);
+ IDictionary operationCache = new NodeIdDictionary();
+
+ NodeHandle handle = await GetManagerHandleAsync(
+ systemContext,
+ methodToCall.ObjectId,
+ operationCache,
+ cancellationToken).ConfigureAwait(false);
+
+ if (handle == null)
+ {
+ return null;
+ }
+
+ NodeState source = await ValidateNodeAsync(systemContext, handle, operationCache, cancellationToken).ConfigureAwait(false);
+
+ if (source == null)
+ {
+ return null;
+ }
+
+ MethodState method;
+ lock (source)
+ {
+ method = source.FindMethod(systemContext, methodToCall.MethodId);
+ }
+
+ if (method != null)
+ {
+ return method;
+ }
+
+ bool referenceExists;
+ lock (source)
+ {
+ referenceExists = source.ReferenceExists(
+ ReferenceTypeIds.HasComponent,
+ false,
+ methodToCall.MethodId);
+ }
+
+ if (referenceExists)
+ {
+ method = FindPredefinedNode(methodToCall.MethodId);
+ }
+
+ if (method == null && source is BaseInstanceState instanceState)
+ {
+ method = FindMethodInTypeHierarchy(systemContext, instanceState.TypeDefinitionId, methodToCall.MethodId);
+ }
+
+ return method;
+ }
+
+ ///
+ /// Asynchronously calls a method defined on an object.
///
public virtual async ValueTask CallAsync(
OperationContext context,
@@ -3255,51 +3322,21 @@ public virtual async ValueTask CallAsync(
methodToCall.Processed = true;
// validate the source node.
- NodeState source = await ValidateNodeAsync(systemContext, handle, operationCache, cancellationToken).ConfigureAwait(false);
-
- if (source == null)
+ if (await ValidateNodeAsync(systemContext, handle, operationCache, cancellationToken).ConfigureAwait(false) == null)
{
errors[ii] = StatusCodes.BadNodeIdUnknown;
continue;
}
- // find the method.
- lock (source)
- {
- method = source.FindMethod(systemContext, methodToCall.MethodId);
- }
+ method = await FindMethodStateAsync(
+ context,
+ methodToCall,
+ cancellationToken).ConfigureAwait(false);
if (method == null)
{
- bool referenceExists;
- lock (source)
- {
- referenceExists = source.ReferenceExists(
- ReferenceTypeIds.HasComponent,
- false,
- methodToCall.MethodId);
- }
-
- // check for loose coupling.
- if (referenceExists)
- {
- method = FindPredefinedNode(
- methodToCall.MethodId);
- }
-
- // Per OPC UA spec Part 4 section 5.12.2.2: the ObjectType of the Object
- // or a super type of that ObjectType may also be the source of a HasComponent
- // reference to the method.
- if (method == null && source is BaseInstanceState instanceState)
- {
- method = FindMethodInTypeHierarchy(systemContext, instanceState.TypeDefinitionId, methodToCall.MethodId);
- }
-
- if (method == null)
- {
- errors[ii] = StatusCodes.BadMethodInvalid;
- continue;
- }
+ errors[ii] = StatusCodes.BadMethodInvalid;
+ continue;
}
// validate the role permissions for method to be executed,
diff --git a/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs
index 38fdd5c333..c8004d67e7 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs
@@ -3068,6 +3068,64 @@ public virtual void Call(
#pragma warning restore CA2012 // Use ValueTasks correctly
}
+ ///
+ /// Resolves the effective method for a call request.
+ ///
+ public virtual MethodState FindMethodState(
+ OperationContext context,
+ CallMethodRequest methodToCall)
+ {
+ if (methodToCall == null || methodToCall.ObjectId.IsNull || methodToCall.MethodId.IsNull)
+ {
+ return null;
+ }
+
+ ServerSystemContext systemContext = SystemContext.Copy(context);
+ IDictionary operationCache = new NodeIdDictionary();
+
+ NodeHandle handle = GetManagerHandle(
+ systemContext,
+ methodToCall.ObjectId,
+ operationCache);
+
+ if (handle == null)
+ {
+ return null;
+ }
+
+ lock (Lock)
+ {
+ NodeState source = ValidateNode(systemContext, handle, operationCache);
+
+ if (source == null)
+ {
+ return null;
+ }
+
+ MethodState method = source.FindMethod(systemContext, methodToCall.MethodId);
+
+ if (method != null)
+ {
+ return method;
+ }
+
+ if (source.ReferenceExists(
+ ReferenceTypeIds.HasComponent,
+ false,
+ methodToCall.MethodId))
+ {
+ method = FindPredefinedNode(methodToCall.MethodId);
+ }
+
+ if (method == null && source is BaseInstanceState instanceState)
+ {
+ method = FindMethodInTypeHierarchy(systemContext, instanceState.TypeDefinitionId, methodToCall.MethodId);
+ }
+
+ return method;
+ }
+ }
+
///
/// Calls a method on the specified nodes.
///
@@ -3111,42 +3169,17 @@ protected virtual async ValueTask CallInternalAsync(
methodToCall.Processed = true;
// validate the source node.
- NodeState source = ValidateNode(systemContext, handle, operationCache);
-
- if (source == null)
+ if (ValidateNode(systemContext, handle, operationCache) == null)
{
errors[ii] = StatusCodes.BadNodeIdUnknown;
continue;
}
- // find the method.
- method = source.FindMethod(systemContext, methodToCall.MethodId);
-
+ method = FindMethodState(context, methodToCall);
if (method == null)
{
- // check for loose coupling.
- if (source.ReferenceExists(
- ReferenceTypeIds.HasComponent,
- false,
- methodToCall.MethodId))
- {
- method = FindPredefinedNode(
- methodToCall.MethodId);
- }
-
- // Per OPC UA spec Part 4 section 5.12.2.2: the ObjectType of the Object
- // or a super type of that ObjectType may also be the source of a HasComponent
- // reference to the method.
- if (method == null && source is BaseInstanceState instanceState)
- {
- method = FindMethodInTypeHierarchy(systemContext, instanceState.TypeDefinitionId, methodToCall.MethodId);
- }
-
- if (method == null)
- {
- errors[ii] = StatusCodes.BadMethodInvalid;
- continue;
- }
+ errors[ii] = StatusCodes.BadMethodInvalid;
+ continue;
}
// validate the role permissions for method to be executed,
diff --git a/Libraries/Opc.Ua.Server/NodeManager/INodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/INodeManager.cs
index ae96c94267..79def06a6a 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/INodeManager.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/INodeManager.cs
@@ -393,6 +393,13 @@ ServiceResult ValidateRolePermissions(
OperationContext operationContext,
NodeId nodeId,
PermissionType requestedPermission);
+
+ ///
+ /// Resolves the effective for a call request.
+ ///
+ MethodState FindMethodState(
+ OperationContext context,
+ CallMethodRequest methodToCall);
}
///
@@ -711,6 +718,14 @@ public interface IAsyncNodeManager :
IModifyMonitoredItemsAsyncNodeManager,
ICreateMonitoredItemsAsyncNodeManager
{
+ ///
+ /// Resolves the effective for a call request.
+ ///
+ ValueTask FindMethodStateAsync(
+ OperationContext context,
+ CallMethodRequest methodToCall,
+ CancellationToken cancellationToken = default);
+
///
/// Returns the NamespaceUris for the Nodes belonging to the NodeManager.
///
diff --git a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs
index 6082007492..e3c172b344 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs
@@ -1337,6 +1337,7 @@ private static void PrepareValidationCache(
Type listType = typeof(T);
if (listType != typeof(ReadValueId) &&
+ listType != typeof(WriteValue) &&
listType != typeof(BrowseDescription) &&
listType != typeof(CallMethodRequest))
{
@@ -2084,6 +2085,10 @@ await nodeManager.HistoryReadAsync(
var results = new List(count);
var diagnosticInfos = new List(count);
+ PrepareValidationCache(
+ nodesToWrite,
+ out Dictionary uniqueNodesReadAttributes);
+
// add placeholder for each result.
bool validItems = false;
@@ -2093,7 +2098,12 @@ await nodeManager.HistoryReadAsync(
DiagnosticInfo diagnosticInfo = null;
// pre-validate and pre-parse parameter. Validate also access rights and role permissions
- ServiceResult error = await ValidateWriteRequestAsync(context, nodesToWrite[ii], cancellationToken)
+ ServiceResult error = await ValidateWriteRequestAsync(
+ context,
+ nodesToWrite[ii],
+ uniqueNodesReadAttributes,
+ false,
+ cancellationToken)
.ConfigureAwait(false);
// return error status.
@@ -2325,6 +2335,10 @@ await nodeManager.HistoryUpdateAsync(
var diagnosticInfos = new List(methodsToCall.Count);
var errors = new List(methodsToCall.Count);
+ PrepareValidationCache(
+ methodsToCall,
+ out Dictionary uniqueNodesReadAttributes);
+
// add placeholder for each result.
bool validItems = false;
@@ -2339,7 +2353,14 @@ await nodeManager.HistoryUpdateAsync(
}
// validate request parameters.
- errors[ii] = ValidateCallRequestItem(context, methodsToCall[ii]);
+ errors[ii] = await ValidateCallRequestItemAsync(
+ context,
+ methodsToCall[ii],
+ uniqueNodesReadAttributes,
+ // With resolver-based method lookup and an attribute cache, GetPermissionMetadataAsync
+ // reads and caches the required permission attributes without the permissionsOnly shortcut.
+ false,
+ cancellationToken).ConfigureAwait(false);
if (ServiceResult.IsBad(errors[ii]))
{
@@ -3521,11 +3542,14 @@ protected static ServiceResult ValidateMonitoredItemModifyRequest(
}
///
- /// Validates a call request item parameter. It validates also access rights and role permissions
+ /// Validates a call request item parameter and checks access rights and role permissions.
///
- protected ServiceResult ValidateCallRequestItem(
+ protected async ValueTask ValidateCallRequestItemAsync(
OperationContext operationContext,
- CallMethodRequest callMethodRequest)
+ CallMethodRequest callMethodRequest,
+ Dictionary uniqueNodesReadAttributes = null,
+ bool permissionsOnly = false,
+ CancellationToken cancellationToken = default)
{
// check for null structure.
if (callMethodRequest == null)
@@ -3545,6 +3569,28 @@ protected ServiceResult ValidateCallRequestItem(
return StatusCodes.BadMethodInvalid;
}
+ (_, IAsyncNodeManager nodeManager) = await GetManagerHandleAsync(callMethodRequest.ObjectId, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Method resolution fallback is provided by the IAsyncNodeManager adapter path.
+ MethodState method = await nodeManager.FindMethodStateAsync(
+ operationContext,
+ callMethodRequest,
+ cancellationToken).ConfigureAwait(false);
+
+ if (method != null)
+ {
+ // check access rights and role permissions
+ return await ValidatePermissionsAsync(
+ operationContext,
+ method.NodeId,
+ PermissionType.Call,
+ uniqueNodesReadAttributes,
+ permissionsOnly,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
return StatusCodes.Good;
}
@@ -3592,6 +3638,8 @@ protected async ValueTask ValidateReadRequestAsync(
protected async ValueTask ValidateWriteRequestAsync(
OperationContext operationContext,
WriteValue writeValue,
+ Dictionary uniqueNodesServiceAttributes = null,
+ bool permissionsOnly = false,
CancellationToken cancellationToken = default)
{
ServiceResult serviceResult = WriteValue.Validate(writeValue);
@@ -3617,8 +3665,8 @@ protected async ValueTask ValidateWriteRequestAsync(
operationContext,
writeValue.NodeId,
requestedPermission,
- null,
- true,
+ uniqueNodesServiceAttributes,
+ permissionsOnly,
cancellationToken)
.ConfigureAwait(false);
}
diff --git a/Tests/Opc.Ua.Aot.Tests/GdsClientAotTests.cs b/Tests/Opc.Ua.Aot.Tests/GdsClientAotTests.cs
index abef35778f..994e685f8b 100644
--- a/Tests/Opc.Ua.Aot.Tests/GdsClientAotTests.cs
+++ b/Tests/Opc.Ua.Aot.Tests/GdsClientAotTests.cs
@@ -124,8 +124,9 @@ public async Task UpdateApplicationAsync()
.ConfigureAwait(false);
appRecord.ApplicationId = id;
- const string updatedUri = appUri + "/v2";
- appRecord.ApplicationUri = updatedUri;
+ const string updatedProductUri =
+ "http://opcfoundation.org/AotTest/Product/Updated";
+ appRecord.ProductUri = updatedProductUri;
await fixture.GdsClient
.UpdateApplicationAsync(appRecord)
.ConfigureAwait(false);
@@ -135,7 +136,7 @@ await fixture.GdsClient
.ConfigureAwait(false);
await Assert.That(result).IsNotNull();
- await Assert.That(result.ApplicationUri).IsEqualTo(updatedUri);
+ await Assert.That(result.ProductUri).IsEqualTo(updatedProductUri);
// cleanup
await fixture.GdsClient
diff --git a/Tests/Opc.Ua.Aot.Tests/GdsTestFixture.cs b/Tests/Opc.Ua.Aot.Tests/GdsTestFixture.cs
index 58cfcdd7ef..e05eae4ea6 100644
--- a/Tests/Opc.Ua.Aot.Tests/GdsTestFixture.cs
+++ b/Tests/Opc.Ua.Aot.Tests/GdsTestFixture.cs
@@ -158,7 +158,10 @@ await Task.Delay(UnsecureRandom.Shared.Next(100, 1000))
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(m_pkiRoot, "rejected")
},
- AutoAcceptUntrustedCertificates = true
+ AutoAcceptUntrustedCertificates = true,
+ RejectSHA1SignedCertificates = false,
+ RejectUnknownRevocationStatus = true,
+ MinimumCertificateKeySize = 1024
},
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
@@ -171,6 +174,26 @@ await m_clientConfiguration.ValidateAsync(ApplicationType.Client)
m_clientConfiguration.SecurityConfiguration, Telemetry);
m_clientConfiguration.CertificateManager.AcceptError = static (cert, err) => true;
+ var clientApplication = new ApplicationInstance(Telemetry)
+ {
+ ApplicationName = m_clientConfiguration.ApplicationName,
+ ApplicationType = ApplicationType.Client,
+ ApplicationConfiguration = m_clientConfiguration
+ };
+ try
+ {
+ bool haveAppCertificate = await clientApplication
+ .CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false);
+ if (!haveAppCertificate)
+ {
+ throw new InvalidOperationException("Client application certificate invalid!");
+ }
+ }
+ finally
+ {
+ await clientApplication.DisposeAsync().ConfigureAwait(false);
+ }
+
// Create the GDS client with admin credentials
GdsClient = new GlobalDiscoveryServerClient(
m_clientConfiguration);
@@ -192,12 +215,37 @@ await discoveryClient.CloseAsync(CancellationToken.None)
EndpointDescription selectedEndpoint = null;
foreach (EndpointDescription ep in endpoints)
{
- if (ep.SecurityPolicyUri == SecurityPolicies.None)
+ if (ep.SecurityMode == MessageSecurityMode.SignAndEncrypt &&
+ ep.SecurityPolicyUri == SecurityPolicies.Aes256_Sha256_RsaPss)
{
selectedEndpoint = ep;
break;
}
}
+ if (selectedEndpoint == null)
+ {
+ foreach (EndpointDescription ep in endpoints)
+ {
+ if (ep.SecurityMode == MessageSecurityMode.SignAndEncrypt &&
+ (ep.SecurityPolicyUri == SecurityPolicies.Aes128_Sha256_RsaOaep ||
+ ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256))
+ {
+ selectedEndpoint = ep;
+ break;
+ }
+ }
+ }
+ if (selectedEndpoint == null)
+ {
+ foreach (EndpointDescription ep in endpoints)
+ {
+ if (ep.SecurityMode == MessageSecurityMode.SignAndEncrypt)
+ {
+ selectedEndpoint = ep;
+ break;
+ }
+ }
+ }
selectedEndpoint ??= endpoints[0];
GdsClient.Endpoint = new ConfiguredEndpoint(
@@ -296,12 +344,16 @@ private async Task StartGdsServerAsync(int port)
};
ArrayOf applicationCerts =
- ApplicationConfigurationBuilder
- .CreateDefaultApplicationCertificates(
- "CN=GDS AOT Test Server, O=OPC Foundation, " +
- "DC=localhost",
- CertificateStoreType.Directory,
- m_gdsRoot);
+ [
+ new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = m_gdsRoot,
+ SubjectName =
+ "CN=GDS AOT Test Server, O=OPC Foundation, DC=localhost",
+ CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
+ }
+ ];
m_serverApplication = new ApplicationInstance(Telemetry)
{
diff --git a/Tests/Opc.Ua.Gds.Tests/ApplicationsDatabaseBaseTests.cs b/Tests/Opc.Ua.Gds.Tests/ApplicationsDatabaseBaseTests.cs
new file mode 100644
index 0000000000..fbfe0436a6
--- /dev/null
+++ b/Tests/Opc.Ua.Gds.Tests/ApplicationsDatabaseBaseTests.cs
@@ -0,0 +1,161 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using NUnit.Framework;
+using Opc.Ua.Gds.Server.Database;
+
+namespace Opc.Ua.Gds.Tests
+{
+ [TestFixture]
+ [Category("GDS")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class ApplicationsDatabaseBaseTests
+ {
+ [Test]
+ public void RegisterApplicationNullThrows()
+ {
+ var database = new TestApplicationsDatabase();
+
+ Assert.That(
+ () => database.RegisterApplication(null),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void RegisterApplicationInvalidApplicationUriThrows()
+ {
+ var database = new TestApplicationsDatabase();
+ ApplicationRecordDataType application = CreateValidServerApplication();
+ application.ApplicationUri = "not a uri";
+
+ Assert.That(
+ () => database.RegisterApplication(application),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void RegisterApplicationServerWithoutDiscoveryUrlsThrows()
+ {
+ var database = new TestApplicationsDatabase();
+ ApplicationRecordDataType application = CreateValidServerApplication();
+ application.DiscoveryUrls = [];
+
+ Assert.That(
+ () => database.RegisterApplication(application),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void RegisterApplicationClientWithDiscoveryUrlsThrows()
+ {
+ var database = new TestApplicationsDatabase();
+ ApplicationRecordDataType application = CreateValidServerApplication();
+ application.ApplicationType = ApplicationType.Client;
+ application.ServerCapabilities = [];
+ application.DiscoveryUrls = ["opc.tcp://localhost:4840"];
+
+ Assert.That(
+ () => database.RegisterApplication(application),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void RegisterApplicationValidServerReturnsDefaultNodeId()
+ {
+ var database = new TestApplicationsDatabase();
+ ApplicationRecordDataType application = CreateValidServerApplication();
+
+ NodeId applicationId = database.RegisterApplication(application);
+
+ Assert.That(applicationId.IsNull, Is.True);
+ }
+
+ [Test]
+ public void RegisterApplicationValidServerWithDiscoveryUrlsDoesNotThrow()
+ {
+ var database = new TestApplicationsDatabase();
+ ApplicationRecordDataType application = CreateValidServerApplication();
+
+ Assert.That(
+ () => database.RegisterApplication(application),
+ Throws.Nothing);
+ }
+
+ [Test]
+ public void FindApplicationsWhitespaceThrows()
+ {
+ var database = new TestApplicationsDatabase();
+
+ Assert.That(
+ () => database.FindApplications(" "),
+ Throws.TypeOf()
+ .With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadInvalidArgument));
+ }
+
+ [Test]
+ public void QueryApplicationsInvalidTypeThrows()
+ {
+ var database = new TestApplicationsDatabase();
+
+ Assert.That(
+ () => database.QueryApplications(
+ 0,
+ 10,
+ null,
+ null,
+ 3,
+ null,
+ [],
+ out _,
+ out _),
+ Throws.TypeOf()
+ .With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadInvalidArgument));
+ }
+
+ private static ApplicationRecordDataType CreateValidServerApplication()
+ {
+ return new ApplicationRecordDataType
+ {
+ ApplicationUri = "urn:test:application",
+ ApplicationType = ApplicationType.Server,
+ ApplicationNames = [new LocalizedText("en", "TestApp")],
+ ProductUri = "urn:test:product",
+ DiscoveryUrls = ["opc.tcp://localhost:4840"],
+ ServerCapabilities = ["LDS"]
+ };
+ }
+
+ private sealed class TestApplicationsDatabase : ApplicationsDatabaseBase
+ {
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Gds.Tests/ClientTest.cs b/Tests/Opc.Ua.Gds.Tests/ClientTest.cs
index f295769f09..a0610259bc 100644
--- a/Tests/Opc.Ua.Gds.Tests/ClientTest.cs
+++ b/Tests/Opc.Ua.Gds.Tests/ClientTest.cs
@@ -219,31 +219,9 @@ public async Task RegisterDuplicateGoodApplicationsAsync()
var newRecord = (ApplicationRecordDataType)application.ApplicationRecord
.MemberwiseClone();
newRecord.ApplicationId = default;
- NodeId id = await m_gdsClient.GDSClient.RegisterApplicationAsync(newRecord).ConfigureAwait(false);
- Assert.That(id.IsNull, Is.False);
- Assert.That(id.IdType, Is.EqualTo(IdType.Guid).Or.EqualTo(IdType.String));
- newRecord.ApplicationId = id;
- ArrayOf applicationDataRecords = await m_gdsClient.GDSClient
- .FindApplicationAsync(
- newRecord.ApplicationUri).ConfigureAwait(false);
- Assert.That(applicationDataRecords.IsNull, Is.False);
- bool newIdFound = false;
- bool registeredIdFound = false;
- foreach (ApplicationRecordDataType applicationDataRecord in applicationDataRecords.ToList())
- {
- if (applicationDataRecord.ApplicationId == newRecord.ApplicationId)
- {
- await m_gdsClient.GDSClient.UnregisterApplicationAsync(id).ConfigureAwait(false);
- newIdFound = true;
- }
- else if (applicationDataRecord.ApplicationId == application.ApplicationRecord
- .ApplicationId)
- {
- registeredIdFound = true;
- }
- }
- Assert.That(newIdFound, Is.True);
- Assert.That(registeredIdFound, Is.True);
+ ServiceResultException serviceResultException = Assert.ThrowsAsync(
+ async () => await m_gdsClient.GDSClient.RegisterApplicationAsync(newRecord).ConfigureAwait(false));
+ Assert.That(serviceResultException.StatusCode, Is.EqualTo(StatusCodes.BadEntryExists));
}
}
@@ -288,12 +266,9 @@ public async Task UpdateGoodApplicationsAsync()
var updatedApplicationRecord = (ApplicationRecordDataType)
application.ApplicationRecord.MemberwiseClone();
updatedApplicationRecord.ApplicationUri += "update";
- await m_gdsClient.GDSClient.UpdateApplicationAsync(updatedApplicationRecord).ConfigureAwait(false);
- ArrayOf result = await m_gdsClient.GDSClient.FindApplicationAsync(
- updatedApplicationRecord.ApplicationUri).ConfigureAwait(false);
- await m_gdsClient.GDSClient.UpdateApplicationAsync(application.ApplicationRecord).ConfigureAwait(false);
- Assert.That(result.IsNull, Is.False);
- Assert.That(result.Count, Is.GreaterThanOrEqualTo(1), "Couldn't find updated application record");
+ ServiceResultException serviceResultException = Assert.ThrowsAsync(
+ async () => await m_gdsClient.GDSClient.UpdateApplicationAsync(updatedApplicationRecord).ConfigureAwait(false));
+ Assert.That(serviceResultException.StatusCode, Is.EqualTo(StatusCodes.BadWriteNotSupported));
}
}
@@ -773,7 +748,6 @@ public async Task QueryServersByProductUriAsync()
}
}
- [Test]
[Order(480)]
public async Task QueryAllApplicationsAsync()
{
diff --git a/Tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs b/Tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs
index f79d8489cd..f1f715213f 100644
--- a/Tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs
+++ b/Tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs
@@ -1302,6 +1302,78 @@ public async Task CallAsync_InvokesMethodFromObjectTypeAsync()
Assert.That(syncResults[0].OutputArguments[0].GetInt32(), Is.EqualTo(42));
}
+ [Test]
+ public async Task FindMethodStateAsyncResolvesMethodFromSuperTypeOfObjectType()
+ {
+ using TestableAsyncCustomNodeManager manager = CreateManager();
+ ServerSystemContext context = manager.SystemContext;
+ ushort nsIdx = manager.NamespaceIndexes[0];
+
+ var baseType = new BaseObjectTypeState
+ {
+ NodeId = new NodeId("ResolverBaseType", nsIdx),
+ BrowseName = new QualifiedName("ResolverBaseType", nsIdx),
+ SuperTypeId = NodeId.Null
+ };
+ var derivedType = new BaseObjectTypeState
+ {
+ NodeId = new NodeId("ResolverDerivedType", nsIdx),
+ BrowseName = new QualifiedName("ResolverDerivedType", nsIdx),
+ SuperTypeId = baseType.NodeId
+ };
+ var instance = new BaseObjectState(null)
+ {
+ NodeId = new NodeId("ResolverDerivedInstance", nsIdx),
+ BrowseName = new QualifiedName("ResolverDerivedInstance", nsIdx),
+ TypeDefinitionId = derivedType.NodeId
+ };
+ try
+ {
+ var baseMethod = new MethodState(baseType)
+ {
+ NodeId = new NodeId("ResolverBaseMethod", nsIdx),
+ BrowseName = new QualifiedName("ResolverBaseMethod", nsIdx)
+ };
+ baseType.AddChild(baseMethod);
+
+ // Initialize after relationships are set up so predefined state reflects the full type hierarchy.
+ baseType.CreateAsPredefinedNode(context);
+ derivedType.CreateAsPredefinedNode(context);
+ instance.CreateAsPredefinedNode(context);
+
+ await manager.AddPredefinedNodePublicAsync(context, baseType).ConfigureAwait(false);
+ await manager.AddPredefinedNodePublicAsync(context, derivedType).ConfigureAwait(false);
+ await manager.AddNodeAsync(context, default, instance).ConfigureAwait(false);
+
+ m_mockServer.Object.TypeTree.AddSubtype(baseType.NodeId, NodeId.Null);
+ m_mockServer.Object.TypeTree.AddSubtype(derivedType.NodeId, baseType.NodeId);
+
+ var request = new CallMethodRequest
+ {
+ ObjectId = instance.NodeId,
+ MethodId = baseMethod.NodeId,
+ InputArguments = []
+ };
+ var operationContext = new OperationContext(new RequestHeader(), null, RequestType.Call, RequestLifetime.None);
+
+ MethodState method = await manager.FindMethodStateAsync(operationContext, request).ConfigureAwait(false);
+ Assert.That(method, Is.Not.Null);
+ Assert.That(method.NodeId, Is.EqualTo(baseMethod.NodeId));
+
+ var syncNodeManager = manager.SyncNodeManager as INodeManager3;
+ Assert.That(syncNodeManager, Is.Not.Null);
+ MethodState syncMethod = syncNodeManager.FindMethodState(operationContext, request);
+ Assert.That(syncMethod, Is.Not.Null);
+ Assert.That(syncMethod.NodeId, Is.EqualTo(baseMethod.NodeId));
+ }
+ finally
+ {
+ DisposeIfNeeded(instance);
+ DisposeIfNeeded(derivedType);
+ DisposeIfNeeded(baseType);
+ }
+ }
+
[Test]
public async Task CallAsync_InvokesMethodFromSuperTypeOfObjectTypeAsync()
{
@@ -3778,6 +3850,14 @@ private void SetupMasterNodeManager(TestableAsyncCustomNodeManager manager)
return new ValueTask<(object handle, IAsyncNodeManager nodeManager)>((handle, manager));
});
}
+
+ private static void DisposeIfNeeded(object value)
+ {
+ if (value is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
}
public class TestableAsyncCustomNodeManager : AsyncCustomNodeManager