mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
184f19a2b0
# Description `MethodInvocationResult` carries the operation result, declaration-ordered outputs and optional position-preserving input results. Asynchronous callbacks retain the actual receiver, cancellation token and resolved diagnostic strings. Fluent predefined, typed, instance, reference-child, resolved and virtual method builders reject conflicting handlers in either registration order and never overwrite materialized handlers. Direct configuration remains available. Shared Call-result assembly retains input diagnostics, including useful text on Good input positions, and requested operation diagnostics on Good results. Diagnostic encoding preserves nested Good-status text, status information bits and the outer-inclusive depth limit. Request admission clears client-supplied internal diagnostic permission flags before early returns or validation failures; admitted sessions grant the flag only after the existing administrator-role checks. Includes corresponding unit/integration regressions, complete XML API documentation, and method-result guidance consolidated with the fluent APIs in `docs/NodeManagers.md`. No WoT receiver/projection/remote-execution, event-readiness/native-admission, or bulk/history implementation is included. ## Related Issues Part of the user-approved two-wave extraction plan tracked by #4511. _If this is a relatively large or complex change, a design must have been discussed in the related tracking issue and signed off (which becomes the Architectural Decision Record (ADR))._ - Related to #4511. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] 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. - [ ] 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. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
336 lines
17 KiB
C#
336 lines
17 KiB
C#
/* ========================================================================
|
|
* 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.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Verifies complete method results across native asynchronous and adapted legacy node managers.
|
|
/// </summary>
|
|
public partial class AsyncCustomNodeManagerTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that ordinary callbacks retain their null-result success convention.
|
|
/// </summary>
|
|
/// <param name="asynchronousLegacyCall">Whether the legacy manager opts into asynchronous method calls.</param>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task OrdinaryMethodCallbacksRetainNullAsSuccessfulCompletion(bool asynchronousLegacyCall)
|
|
{
|
|
using ITestNodeManager manager = CreateManager(asynchronousLegacyCall);
|
|
ServerSystemContext context = manager.SystemContext;
|
|
ushort ns = manager.NamespaceIndexes[0];
|
|
var owner = new BaseObjectState(null)
|
|
{
|
|
NodeId = new NodeId("LegacyOwner", ns),
|
|
BrowseName = new QualifiedName("LegacyOwner", ns)
|
|
};
|
|
var method = new MethodState(owner)
|
|
{
|
|
NodeId = new NodeId("LegacyCall", ns),
|
|
BrowseName = new QualifiedName("LegacyCall", ns)
|
|
};
|
|
int calls = 0;
|
|
method.OnCallMethod2 = (_, _, _, _, _) =>
|
|
{
|
|
calls++;
|
|
return null!;
|
|
};
|
|
owner.AddChild(method);
|
|
await manager.AddNodeAsync(context, NodeId.Null, owner).ConfigureAwait(false);
|
|
using var operation = new OperationContext(Mock.Of<ISession>(), DiagnosticsMasks.None);
|
|
var results = new List<CallMethodResult> { null! };
|
|
var errors = new List<ServiceResult> { null! };
|
|
|
|
await manager.CallAsync(
|
|
operation,
|
|
[new CallMethodRequest { ObjectId = owner.NodeId, MethodId = method.NodeId }],
|
|
results, errors).ConfigureAwait(false);
|
|
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
Assert.That(ServiceResult.IsGood(errors[0]), Is.True);
|
|
Assert.That(results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(results[0].InputArgumentResults.IsEmpty, Is.True);
|
|
Assert.That(results[0].OutputArguments.IsEmpty, Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies ordered input results and permission-gated diagnostic reencoding into the receiving string table.
|
|
/// </summary>
|
|
/// <param name="diagnostics">Whether operation diagnostics are requested.</param>
|
|
/// <param name="authorized">Whether additional diagnostic information is authorized.</param>
|
|
/// <param name="successfulInputDetail">Whether a Good input result has diagnostic text.</param>
|
|
/// <param name="asynchronousLegacyCall">Whether the legacy manager opts into asynchronous method calls.</param>
|
|
[TestCase(false, false, false, false)]
|
|
[TestCase(false, true, false, false)]
|
|
[TestCase(true, false, false, false)]
|
|
[TestCase(true, true, false, false)]
|
|
[TestCase(false, false, true, false)]
|
|
[TestCase(false, true, true, false)]
|
|
[TestCase(true, false, true, false)]
|
|
[TestCase(true, true, true, false)]
|
|
[TestCase(true, true, true, true)]
|
|
public async Task CallResultRetainsInputPositionsAndEncodesDiagnosticsInTheResponseTable(
|
|
bool diagnostics, bool authorized, bool successfulInputDetail, bool asynchronousLegacyCall)
|
|
{
|
|
using ITestNodeManager manager = CreateManager(asynchronousLegacyCall);
|
|
ServerSystemContext context = manager.SystemContext;
|
|
ushort ns = manager.NamespaceIndexes[0];
|
|
var parent = new BaseObjectState(null);
|
|
parent.CreateAsPredefinedNode(context);
|
|
parent.NodeId = new NodeId("ResultOwner", ns);
|
|
parent.BrowseName = new QualifiedName("ResultOwner", ns);
|
|
var method = new MethodState(parent)
|
|
{
|
|
NodeId = new NodeId("CheckedCall", ns),
|
|
BrowseName = new QualifiedName("CheckedCall", ns)
|
|
};
|
|
method.InputArguments =
|
|
new PropertyState<ArrayOf<Argument>>.Implementation<StructureBuilder<Argument>>(method)
|
|
{
|
|
Value =
|
|
[
|
|
new Argument { Name = "Mode", DataType = DataTypeIds.Int32, ValueRank = ValueRanks.Scalar },
|
|
new Argument { Name = "Speed", DataType = DataTypeIds.Int32, ValueRank = ValueRanks.Scalar }
|
|
]
|
|
};
|
|
var operation = new ServiceResult(
|
|
"urn:upstream", new StatusCode(StatusCodes.BadInvalidArgument.Code, "CallRejected"),
|
|
new LocalizedText("en", "Call rejected"), "operation detail", innerResult: null);
|
|
var inputError = new ServiceResult(
|
|
"urn:upstream", new StatusCode(StatusCodes.BadOutOfRange.Code, "SpeedRejected"),
|
|
new LocalizedText("de", "Drehzahl zu hoch"), "limit: 100", innerResult: null);
|
|
ServiceResult accepted = successfulInputDetail
|
|
? new ServiceResult(
|
|
"urn:upstream", new StatusCode(StatusCodes.Good.Code, "AcceptedMode"),
|
|
new LocalizedText("en", "Mode is valid"), null, innerResult: null)
|
|
: ServiceResult.Good;
|
|
int calls = 0;
|
|
method.OnCallMethodWithResultAsync = (_, _, _, _, _) =>
|
|
{
|
|
calls++;
|
|
return new ValueTask<MethodInvocationResult>(new MethodInvocationResult(
|
|
operation, inputArgumentResults: [accepted, inputError]));
|
|
};
|
|
parent.AddChild(method);
|
|
await manager.AddNodeAsync(context, default, parent).ConfigureAwait(false);
|
|
DiagnosticsMasks mask = diagnostics ? DiagnosticsMasks.OperationAll : 0;
|
|
if (authorized)
|
|
{
|
|
mask |= DiagnosticsMasks.UserPermissionAdditionalInfo;
|
|
}
|
|
using var requestContext = new OperationContext(Mock.Of<ISession>(), mask);
|
|
requestContext.StringTable.Append("existing-local-symbol");
|
|
requestContext.StringTable.Append("existing-local-text");
|
|
var results = new List<CallMethodResult> { null! };
|
|
var errors = new List<ServiceResult> { null! };
|
|
|
|
await manager.CallAsync(
|
|
requestContext,
|
|
[new CallMethodRequest
|
|
{
|
|
ObjectId = parent.NodeId,
|
|
MethodId = method.NodeId,
|
|
InputArguments = [new Variant(1), new Variant(200)]
|
|
}],
|
|
results, errors).ConfigureAwait(false);
|
|
|
|
if (m_managerType == AsyncCustomNodeManagerType.CustomNodeManager2ViaAdapter && !asynchronousLegacyCall)
|
|
{
|
|
Assert.That(calls, Is.Zero, "A synchronous-only manager must not block on the new async handler.");
|
|
Assert.That(errors[0].StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
|
|
Assert.That(results[0].InputArgumentResults.IsEmpty, Is.True);
|
|
Assert.That(results[0].OutputArguments.IsEmpty, Is.True);
|
|
return;
|
|
}
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
Assert.That(errors[0].StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
Assert.That(errors[0].SymbolicId, Is.EqualTo("CallRejected"));
|
|
Assert.That(results[0].InputArgumentResults.Count, Is.EqualTo(2));
|
|
Assert.That(results[0].InputArgumentResults[0], Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(results[0].InputArgumentResults[1], Is.EqualTo(StatusCodes.BadOutOfRange));
|
|
Assert.That(results[0].OutputArguments.IsEmpty, Is.True);
|
|
if (diagnostics)
|
|
{
|
|
Assert.That(results[0].InputArgumentDiagnosticInfos.Count, Is.EqualTo(2));
|
|
DiagnosticInfo actual = results[0].InputArgumentDiagnosticInfos[1];
|
|
var table = requestContext.StringTable.ToArrayOf();
|
|
if (successfulInputDetail)
|
|
{
|
|
DiagnosticInfo first = results[0].InputArgumentDiagnosticInfos[0];
|
|
Assert.That(first, Is.Not.Null);
|
|
Assert.That(table[first.SymbolicId], Is.EqualTo("AcceptedMode"));
|
|
Assert.That(table[first.LocalizedText], Is.EqualTo("Mode is valid"));
|
|
}
|
|
else
|
|
{
|
|
Assert.That(results[0].InputArgumentDiagnosticInfos[0], Is.Null);
|
|
}
|
|
Assert.That(actual.SymbolicId, Is.GreaterThan(1));
|
|
Assert.That(table[actual.SymbolicId], Is.EqualTo("SpeedRejected"));
|
|
Assert.That(table[actual.NamespaceUri], Is.EqualTo("urn:upstream"));
|
|
Assert.That(table[actual.Locale], Is.EqualTo("de"));
|
|
Assert.That(table[actual.LocalizedText], Is.EqualTo("Drehzahl zu hoch"));
|
|
Assert.That(actual.AdditionalInfo, Is.EqualTo(authorized ? "limit: 100" : null),
|
|
"Forwarding must retain the existing permission gate for additional diagnostic information.");
|
|
Assert.That(table[0], Is.EqualTo("existing-local-symbol"));
|
|
}
|
|
else
|
|
{
|
|
Assert.That(results[0].InputArgumentDiagnosticInfos.IsEmpty, Is.True);
|
|
Assert.That(requestContext.StringTable.Count, Is.EqualTo(2));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies final Call assembly preserves requested Good-operation details through the public dispatcher.
|
|
/// </summary>
|
|
/// <param name="diagnostics">Whether operation diagnostics are requested.</param>
|
|
/// <param name="innerStatusOnly">
|
|
/// Whether the operation has only an inner status and no diagnostic strings.
|
|
/// </param>
|
|
[TestCase(false, false)]
|
|
[TestCase(true, false)]
|
|
[TestCase(false, true)]
|
|
[TestCase(true, true)]
|
|
public async Task CallDispatcherPreservesRequestedGoodOperationDiagnostics(
|
|
bool diagnostics, bool innerStatusOnly)
|
|
{
|
|
m_mockServer.Setup(server => server.Telemetry).Returns(NUnitTelemetryContext.Create());
|
|
using var resources = new ResourceManager(m_configuration);
|
|
m_mockServer.Setup(server => server.ResourceManager).Returns(resources);
|
|
ITestNodeManager manager = CreateManager(asynchronousLegacyCall: true);
|
|
var core = new Mock<ICoreNodeManager>();
|
|
core.Setup(value => value.SyncNodeManager).Returns(Mock.Of<INodeManager>());
|
|
var factory = new Mock<IMainNodeManagerFactory>();
|
|
factory.Setup(value => value.CreateConfigurationNodeManager())
|
|
.Returns(Mock.Of<IConfigurationNodeManager>());
|
|
factory.Setup(value => value.CreateCoreNodeManager(It.IsAny<ushort>()))
|
|
.Returns(core.Object);
|
|
m_mockServer.Setup(server => server.MainNodeManagerFactory).Returns(factory.Object);
|
|
await using var dispatcher = new MasterNodeManager(m_mockServer.Object, m_configuration, null, manager);
|
|
m_mockServer.Setup(server => server.NodeManager).Returns(dispatcher);
|
|
ushort ns = manager.NamespaceIndexes[0];
|
|
var owner = new BaseObjectState(null)
|
|
{
|
|
NodeId = new NodeId("DiagnosticOwner", ns),
|
|
BrowseName = new QualifiedName("DiagnosticOwner", ns)
|
|
};
|
|
var method = new MethodState(owner)
|
|
{
|
|
NodeId = new NodeId("DiagnosticCall", ns),
|
|
BrowseName = new QualifiedName("DiagnosticCall", ns)
|
|
};
|
|
ServiceResult operation = innerStatusOnly
|
|
? new ServiceResult(StatusCodes.Good, new ServiceResult(StatusCodes.BadTypeMismatch))
|
|
: new ServiceResult(
|
|
"urn:dispatcher", new StatusCode(StatusCodes.Good.Code, "Completed"),
|
|
new LocalizedText("en", "Done"), null, innerResult: null);
|
|
int calls = 0;
|
|
method.OnCallMethodWithResultAsync = (_, _, _, _, _) =>
|
|
{
|
|
calls++;
|
|
return new ValueTask<MethodInvocationResult>(new MethodInvocationResult(operation));
|
|
};
|
|
owner.AddChild(method);
|
|
await manager.AddNodeAsync(manager.SystemContext, NodeId.Null, owner).ConfigureAwait(false);
|
|
DiagnosticsMasks mask = !diagnostics
|
|
? DiagnosticsMasks.None
|
|
: innerStatusOnly
|
|
? DiagnosticsMasks.OperationInnerStatusCode
|
|
: DiagnosticsMasks.OperationSymbolicId | DiagnosticsMasks.OperationLocalizedText;
|
|
using var context = new OperationContext(m_mockSession.Object, mask);
|
|
|
|
(ArrayOf<CallMethodResult> results, ArrayOf<DiagnosticInfo> diagnosticInfos) =
|
|
await dispatcher.CallAsync(
|
|
context,
|
|
[new CallMethodRequest { ObjectId = owner.NodeId, MethodId = method.NodeId }])
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
Assert.That(results.Count, Is.EqualTo(1));
|
|
Assert.That(results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(results[0].InputArgumentResults.IsEmpty, Is.True);
|
|
Assert.That(results[0].OutputArguments.IsEmpty, Is.True);
|
|
if (!diagnostics)
|
|
{
|
|
Assert.That(diagnosticInfos.IsEmpty, Is.True);
|
|
Assert.That(context.StringTable.Count, Is.Zero);
|
|
return;
|
|
}
|
|
Assert.That(diagnosticInfos.Count, Is.EqualTo(1));
|
|
DiagnosticInfo actual = diagnosticInfos[0];
|
|
Assert.That(actual, Is.Not.Null);
|
|
if (innerStatusOnly)
|
|
{
|
|
Assert.That(context.StringTable.Count, Is.Zero);
|
|
Assert.That(actual.InnerStatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
|
|
Assert.That(actual.IsNullDiagnosticInfo, Is.False);
|
|
}
|
|
else
|
|
{
|
|
Assert.That(context.StringTable.GetString((uint)actual.SymbolicId), Is.EqualTo("Completed"));
|
|
Assert.That(context.StringTable.GetString((uint)actual.NamespaceUri), Is.EqualTo("urn:dispatcher"));
|
|
Assert.That(context.StringTable.GetString((uint)actual.LocalizedText), Is.EqualTo("Done"));
|
|
Assert.That(context.StringTable.GetString((uint)actual.Locale), Is.EqualTo("en"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables the legacy manager's existing asynchronous method-dispatch capability for these tests.
|
|
/// </summary>
|
|
private sealed class AsyncCallTestNodeManager : TestableCustomNodeManager2, ICallAsyncNodeManager
|
|
{
|
|
/// <summary>
|
|
/// Initializes the legacy asynchronous method-dispatch test manager.
|
|
/// </summary>
|
|
/// <param name="server">The test server.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <param name="useSamplingGroups">Whether the test uses sampling groups.</param>
|
|
/// <param name="logger">The contextual logger.</param>
|
|
/// <param name="namespaceUri">The manager's namespace URI.</param>
|
|
public AsyncCallTestNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
bool useSamplingGroups,
|
|
ILogger logger,
|
|
string namespaceUri)
|
|
: base(server, configuration, useSamplingGroups, logger, namespaceUri)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|