/* ========================================================================
* 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.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
using Moq;
using NUnit.Framework;
using Opc.Ua.Programs;
using Opc.Ua.Server.Hosting;
using Opc.Ua.Server.Programs;
using Opc.Ua.Server.Tests.NodeManager;
using Opc.Ua.Tests;
using ProgramTypeIds = Opc.Ua.Programs.ObjectTypeIds;
namespace Opc.Ua.Server.Tests.Programs
{
///
/// Tests checked Program admission, type policy, provider ownership and managed address-space removal.
///
[TestFixture]
[Category("Programs")]
[Parallelizable(ParallelScope.All)]
public sealed class ProgramNodeManagerTests
{
[Test]
public void FactoryRejectsDuplicateConcreteTypes()
{
var provider = new ControlledProvider();
ProgramDefinition definition = CreateDefinition(provider);
Assert.That(
() => new ProgramNodeManagerFactory([definition, definition]),
Throws.ArgumentException);
}
[Test]
public void FactoryRejectsUnregisteredAddNodesTypes()
{
Assert.That(
() => new ProgramNodeManagerFactory(
default, addNodesTypes: [ProgramTypeIds.ManagedProgramType]),
Throws.ArgumentException);
}
[Test]
public void FluentRegistrationUsesOneHostWithoutCreatingWorkloads()
{
var builder = new Mock();
builder.SetupGet(value => value.Services).Returns(new ServiceCollection());
var provider = new ControlledProvider();
ProgramDefinition definition = CreateDefinition(provider);
Assert.That(builder.Object.AddPrograms([definition]), Is.SameAs(builder.Object));
Assert.That(provider.CreateCount, Is.Zero);
Assert.That(() => builder.Object.AddPrograms([definition]), Throws.InvalidOperationException);
}
[Test]
public async Task RegistrationDoesNotCreateOrStartAWorkload()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
Assert.That(harness.Provider.CreateCount, Is.Zero);
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.Zero);
Assert.That(harness.Manager.Find(harness.Manager.RootId), Is.TypeOf());
Assert.That(harness.Manager.NamespaceIndexes, Does.Not.Contain((ushort)0));
NodeId typeId = ExpandedNodeId.ToNodeId(
ProgramTypeIds.ManagedProgramType, harness.Manager.SystemContext.NamespaceUris);
NodeState type = harness.Manager.Find(typeId)!;
BaseVariableState count = (BaseVariableState)type.FindChildWithQualifiedName(
harness.Manager.SystemContext, new QualifiedName(BrowseNames.InstanceCount))!;
Assert.That(count.WrappedValue.TryGetValue(out uint value), Is.True);
Assert.That(value, Is.Zero);
}
[Test]
public async Task ServerCreationCountsEvenWhenClientsCannotCreate()
{
await using Harness harness = await Harness.CreateAsync(creatable: false).ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("First").ConfigureAwait(false);
Assert.That(runtime.CurrentState, Is.EqualTo(ProgramState.Ready));
Assert.That(harness.Provider.CreateCount, Is.EqualTo(1));
Assert.That(harness.Provider.Executions[0].ControlCount, Is.Zero);
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
Assert.That(
async () => await harness.CreateAsync("Second").ConfigureAwait(false),
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadTooManyOperations));
Assert.That(harness.Provider.CreateCount, Is.EqualTo(1));
}
[Test]
public async Task FailedProviderInitializationReleasesCapacity()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
harness.Provider.FailNext = true;
Assert.That(
async () => await harness.CreateAsync("Failure").ConfigureAwait(false),
Throws.TypeOf());
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.Zero);
ProgramRuntime runtime = await harness.CreateAsync("Success").ConfigureAwait(false);
Assert.That(harness.Manager.Find(runtime.Node.NodeId), Is.SameAs(runtime.Node));
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
}
[Test]
public async Task ConcurrentAdmissionsDoNotExceedCapacity()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
harness.Provider.AdmissionStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
harness.Provider.ReleaseAdmission = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
Task first = harness.CreateAsync("First").AsTask();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await harness.Provider.AdmissionStarted.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
Task second = harness.CreateAsync("Second").AsTask();
harness.Provider.ReleaseAdmission.TrySetResult(true);
await first.ConfigureAwait(false);
Assert.That(() => second,
Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadTooManyOperations));
Assert.That(harness.Provider.CreateCount, Is.EqualTo(1));
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
}
[Test]
public async Task DisposeCancelsProviderAdmissionBeforeWaitingForOperationsToDrain()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
harness.Provider.AdmissionStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
harness.Provider.ReleaseAdmission = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
Task creation = harness.CreateAsync("Pending").AsTask();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await harness.Provider.AdmissionStarted.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
await harness.Manager.DisposeAsync().AsTask().WaitAsync(timeout.Token).ConfigureAwait(false);
Assert.That(() => creation, Throws.InstanceOf());
Assert.That(harness.Provider.Executions, Is.Empty);
}
[Test]
public async Task AdoptedInvocationUsesTheSameCapacityAndReceivesFinalChildIds()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
var node = new ManagedProgramState(null);
node.Create(harness.Manager.SystemContext, NodeId.Null,
harness.Name("Adopted"), default, true);
ProgramRuntime runtime = await harness.Manager.AdoptInvocationAsync(
ProgramTypeIds.ManagedProgramType, node).ConfigureAwait(false);
Assert.That(runtime.Node, Is.SameAs(node));
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
var children = new List();
node.GetChildren(harness.Manager.SystemContext, children);
foreach (BaseInstanceState child in children)
{
Assert.That(child.NodeId.NamespaceIndex, Is.EqualTo(harness.Manager.NamespaceIndex));
Assert.That(harness.Manager.Find(child.NodeId), Is.SameAs(child));
}
}
[Test]
public async Task ReadoptingAnIndexedInvocationDoesNotRemoveOrDisposeIt()
{
await using Harness harness = await Harness.CreateAsync(maxInstances: 2).ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("Invocation").ConfigureAwait(false);
Assert.That(
async () => await harness.Manager.AdoptInvocationAsync(
ProgramTypeIds.ManagedProgramType, runtime.Node).ConfigureAwait(false),
Throws.TypeOf());
Assert.That(harness.Manager.Find(runtime.Node.NodeId), Is.SameAs(runtime.Node));
Assert.That(harness.Manager.TryGetRuntime(runtime.Node.NodeId, out ProgramRuntime? registered), Is.True);
Assert.That(registered, Is.SameAs(runtime));
Assert.That(harness.Provider.Executions[0].DisposeCount, Is.Zero);
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
}
[Test]
public async Task FactoryCannotReuseAnIndexedInvocationForADifferentBrowseName()
{
ProgramStateMachineState? singleton = null;
await using Harness harness = await Harness.CreateAsync(
maxInstances: 2, nodeFactory: parent => singleton ??= new ManagedProgramState(parent))
.ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("First").ConfigureAwait(false);
Assert.That(async () => await harness.CreateAsync("Second").ConfigureAwait(false),
Throws.TypeOf());
Assert.That(runtime.Node.BrowseName, Is.EqualTo(harness.Name("First")));
Assert.That(harness.Manager.Find(runtime.Node.NodeId), Is.SameAs(runtime.Node));
Assert.That(harness.Provider.CreateCount, Is.EqualTo(1));
Assert.That(harness.Provider.Executions[0].DisposeCount, Is.Zero);
}
[Test]
public async Task UnknownTypeCountIsAnErrorRatherThanASuccessShapedZero()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
Assert.That(
() => harness.Manager.GetInstanceCount(new ExpandedNodeId(1u, "urn:unregistered-program-type")),
Throws.ArgumentException);
}
[Test]
public async Task HostedServersHaveIndependentInvocationCapacity()
{
await using Harness first = await Harness.CreateAsync().ConfigureAwait(false);
await using Harness second = await Harness.CreateAsync().ConfigureAwait(false);
await first.CreateAsync("First").ConfigureAwait(false);
await second.CreateAsync("Second").ConfigureAwait(false);
Assert.That(first.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
Assert.That(second.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
}
[TestCase(ProgramState.Ready, false)]
[TestCase(ProgramState.Halted, true)]
public async Task ClientDeletionRequiresHaltedAndDisposesExactlyOnce(ProgramState initialState, bool succeeds)
{
StatusCode expected = succeeds ? StatusCodes.Good : StatusCodes.BadInvalidState;
await using Harness harness = await Harness.CreateAsync(initialState: initialState).ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("Invocation").ConfigureAwait(false);
ServiceResult result = await harness.Manager.DeleteNodeAsync(
harness.OperationContext,
new DeleteNodesItem { NodeId = runtime.Node.NodeId, DeleteTargetReferences = true })
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(expected));
if (expected == StatusCodes.Good)
{
Assert.That(harness.Manager.Find(runtime.Node.NodeId), Is.Null);
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.Zero);
Assert.That(harness.Provider.Executions[0].DisposeCount, Is.EqualTo(1));
result = await harness.Manager.DeleteNodeAsync(harness.OperationContext,
new DeleteNodesItem { NodeId = runtime.Node.NodeId }).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
Assert.That(harness.Provider.Executions[0].DisposeCount, Is.EqualTo(1));
}
}
[Test]
public async Task ClientCannotDeleteNonDeletableInvocationOrBypassThroughChildrenAndFolder()
{
await using Harness harness = await Harness.CreateAsync(
initialState: ProgramState.Halted, deletable: false).ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("Invocation").ConfigureAwait(false);
NodeId[] protectedNodes =
[runtime.Node.NodeId, runtime.Node.CurrentState!.NodeId, harness.Manager.RootId];
foreach (NodeId id in protectedNodes)
{
ServiceResult result = await harness.Manager.DeleteNodeAsync(
harness.OperationContext, new DeleteNodesItem { NodeId = id }).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.EqualTo(1));
}
[TestCase(false, true, false)]
[TestCase(true, false, false)]
[TestCase(true, true, true)]
public async Task AddNodesRequiresExplicitTypeOptInAndCreatable(bool addNodes, bool creatable, bool succeeds)
{
StatusCode expected = succeeds ? StatusCodes.Good : StatusCodes.BadUserAccessDenied;
await using Harness harness = await Harness.CreateAsync(
addNodes: addNodes, creatable: creatable).ConfigureAwait(false);
(ServiceResult result, NodeId nodeId) = await harness.Manager.AddNodeAsync(
harness.OperationContext, harness.AddItem("Created")).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(expected));
Assert.That(nodeId.IsNull, Is.EqualTo(expected != StatusCodes.Good));
Assert.That(harness.Provider.CreateCount, Is.EqualTo(expected == StatusCodes.Good ? 1 : 0));
}
[Test]
public async Task AddNodesRejectsForeignParentAndBrowseNamespaceBeforeProviderInitialization()
{
await using Harness harness = await Harness.CreateAsync(addNodes: true).ConfigureAwait(false);
AddNodesItem item = harness.AddItem("Created");
item.ParentNodeId = ObjectIds.ObjectsFolder;
(ServiceResult result, _) = await harness.Manager.AddNodeAsync(harness.OperationContext, item)
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadParentNodeIdInvalid));
item.ParentNodeId = harness.Manager.RootId;
item.BrowseName = new QualifiedName("Created");
(result, _) = await harness.Manager.AddNodeAsync(harness.OperationContext, item).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameInvalid));
Assert.That(harness.Provider.CreateCount, Is.Zero);
}
[Test]
public async Task ClientReferenceMutationIsDenied()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
ServiceResult add = await harness.Manager.AddReferenceAsync(
harness.OperationContext, new AddReferencesItem()).ConfigureAwait(false);
ServiceResult delete = await harness.Manager.DeleteReferenceAsync(
harness.OperationContext, new DeleteReferencesItem()).ConfigureAwait(false);
Assert.That(add.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(delete.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public async Task TypeCreateMethodUsesItsDeclaredSignatureAndChecksTheParent()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
NodeId methodId = ExpandedNodeId.ToNodeId(
Opc.Ua.Programs.MethodIds.ManagedProgramType_Create, harness.Manager.SystemContext.NamespaceUris);
var method = (MethodState)harness.Manager.Find(methodId)!;
NodeId typeId = ExpandedNodeId.ToNodeId(
ProgramTypeIds.ManagedProgramType, harness.Manager.SystemContext.NamespaceUris);
ServerSystemContext context = harness.Manager.SystemContext.Copy(harness.OperationContext);
var outputs = new List();
var argumentErrors = new List();
ServiceResult rejected = await method.CallAsync(
context, typeId,
[new Variant(ObjectIds.ObjectsFolder), new Variant(harness.Name("Rejected"))],
argumentErrors, outputs).ConfigureAwait(false);
Assert.That(rejected.StatusCode, Is.EqualTo(StatusCodes.BadParentNodeIdInvalid));
Assert.That(harness.Provider.CreateCount, Is.Zero);
outputs.Clear();
argumentErrors.Clear();
ServiceResult accepted = await method.CallAsync(
context, typeId,
[new Variant(harness.Manager.RootId), new Variant(harness.Name("Accepted"))],
argumentErrors, outputs).ConfigureAwait(false);
Assert.That(accepted.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(outputs, Has.Count.EqualTo(1));
Assert.That(outputs[0].TryGetValue(out NodeId invocationId), Is.True);
Assert.That(harness.Manager.TryGetRuntime(invocationId, out _), Is.True);
}
[Test]
public async Task CreationChecksParentPermissionsBeforeCreatingProviderResources()
{
await using Harness harness = await Harness.CreateAsync(addNodes: true).ConfigureAwait(false);
NodeState root = harness.Manager.Find(harness.Manager.RootId)!;
root.RolePermissions =
[
new RolePermissionType
{
RoleId = ObjectIds.WellKnownRole_Anonymous,
Permissions = (uint)PermissionType.Browse
}
];
var identity = new Mock();
identity.SetupGet(value => value.GrantedRoleIds).Returns([ObjectIds.WellKnownRole_Anonymous]);
var clientContext = new OperationContext(
new RequestHeader(), null, RequestType.AddNodes, RequestLifetime.None, identity.Object);
(ServiceResult result, _) = await harness.Manager.AddNodeAsync(
clientContext, harness.AddItem("Denied")).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(harness.Provider.CreateCount, Is.Zero);
}
[Test]
public async Task ParentPermissionCannotOverrideNamespaceCreationRestrictions()
{
await using Harness harness = await Harness.CreateAsync(addNodes: true).ConfigureAwait(false);
NodeState root = harness.Manager.Find(harness.Manager.RootId)!;
root.RolePermissions =
[
new RolePermissionType
{
RoleId = ObjectIds.WellKnownRole_Anonymous,
Permissions = (uint)(PermissionType.AddReference | PermissionType.AddNode)
}
];
var metadata = new NamespaceMetadataState(null);
metadata.DefaultRolePermissions =
PropertyState>.With>(
metadata,
[
new RolePermissionType
{
RoleId = ObjectIds.WellKnownRole_Anonymous,
Permissions = (uint)PermissionType.Browse
}
]);
Mock.Get(harness.Manager.SystemContext.Server.NodeManager.ConfigurationNodeManager!)
.Setup(value => value.GetNamespaceMetadataStateAsync(
harness.Manager.NamespaceIndex, It.IsAny()))
.Returns(new ValueTask(metadata));
var identity = new Mock();
identity.SetupGet(value => value.GrantedRoleIds).Returns([ObjectIds.WellKnownRole_Anonymous]);
var context = new OperationContext(
new RequestHeader(), null, RequestType.AddNodes, RequestLifetime.None, identity.Object);
(ServiceResult result, _) = await harness.Manager.AddNodeAsync(context, harness.AddItem("Denied"))
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(harness.Provider.CreateCount, Is.Zero);
}
[Test]
public async Task InvocationEventsPropagateToTheOwningFolderOnce()
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("Invocation").ConfigureAwait(false);
int eventCount = 0;
NodeState root = harness.Manager.Find(harness.Manager.RootId)!;
root.OnReportEvent = (_, _, _) => Interlocked.Increment(ref eventCount);
MethodInvocationResult result = await runtime.ControlAsync(
harness.Manager.SystemContext, ProgramControlMethod.Start).ConfigureAwait(false);
Assert.That(result.OperationResult.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(eventCount, Is.EqualTo(1));
}
[Test]
public async Task AutoDeletionWaitsForExecutionTerminationNotInitialHaltedState()
{
await using Harness harness = await Harness.CreateAsync(
initialState: ProgramState.Halted, autoDelete: true).ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("Invocation").ConfigureAwait(false);
Assert.That(harness.Manager.Find(runtime.Node.NodeId), Is.SameAs(runtime.Node));
await runtime.ControlAsync(harness.Manager.SystemContext, ProgramControlMethod.Reset)
.ConfigureAwait(false);
await runtime.ControlAsync(harness.Manager.SystemContext, ProgramControlMethod.Start)
.ConfigureAwait(false);
MethodInvocationResult halt = await runtime.ControlAsync(
harness.Manager.SystemContext, ProgramControlMethod.Halt).ConfigureAwait(false);
Assert.That(halt.OperationResult.StatusCode, Is.EqualTo(StatusCodes.Good));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await harness.Provider.Executions[0].Disposed.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
Assert.That(harness.Manager.Find(runtime.Node.NodeId), Is.Null);
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.Zero);
}
[Test]
public async Task ShutdownRemovesInvocationsAndAwaitsOwnedExecution()
{
await using Harness harness = await Harness.CreateAsync(deletable: false).ConfigureAwait(false);
ProgramRuntime runtime = await harness.CreateAsync("Invocation").ConfigureAwait(false);
await harness.Manager.DeleteAddressSpaceAsync().ConfigureAwait(false);
Assert.That(harness.Manager.Find(runtime.Node.NodeId), Is.Null);
Assert.That(harness.Provider.Executions[0].DisposeCount, Is.EqualTo(1));
Assert.That(harness.Manager.GetInstanceCount(ProgramTypeIds.ManagedProgramType), Is.Zero);
Assert.That(harness.Provider.LastTimeProvider, Is.SameAs(harness.Clock));
}
[TestCase(true)]
[TestCase(false)]
public async Task ProgramAuditsDistinguishClientAndInternalTransitionsWithoutSuppressingRepeatedCauses(
bool auditing)
{
await using Harness harness = await Harness.CreateAsync().ConfigureAwait(false);
IServerInternal server = harness.Manager.SystemContext.Server;
Mock mock = Mock.Get(server);
mock.SetupGet(value => value.Auditing).Returns(auditing);
var audits = new List();
mock.Setup(value => value.ReportAuditEvent(It.IsAny(), It.IsAny()))
.Callback((_, value) => audits.Add(value));
ProgramRuntime runtime = await harness.CreateAsync("Audited").ConfigureAwait(false);
var identity = new Mock();
identity.SetupGet(value => value.DisplayName).Returns("Program operator");
var caller = new ServerSystemContext(server)
{
SessionId = new NodeId("Creator", harness.Manager.NamespaceIndex),
UserIdentity = identity.Object,
AuditEntryId = "ProgramCall"
};
await runtime.ControlAsync(caller, ProgramControlMethod.Start).ConfigureAwait(false);
await runtime.ApplyUpdateAsync(new ProgramExecutionUpdate(runtime.RunId, ProgramState.Ready))
.ConfigureAwait(false);
await runtime.ControlAsync(caller, ProgramControlMethod.Start).ConfigureAwait(false);
MethodInvocationResult rejected = await runtime.ControlAsync(caller, ProgramControlMethod.Start)
.ConfigureAwait(false);
Assert.That(rejected.OperationResult.StatusCode, Is.EqualTo(StatusCodes.BadNotExecutable));
AuditProgramTransitionEventState[] transitions =
audits.OfType().ToArray();
Assert.That(transitions, Has.Length.EqualTo(auditing ? 3 : 0));
if (auditing)
{
Assert.Multiple(() =>
{
Assert.That(transitions[0].Status!.Value, Is.True);
Assert.That(transitions[0].SourceName!.Value, Is.EqualTo("Method/Start"));
Assert.That(transitions[0].ClientUserId!.Value, Is.EqualTo("Program operator"));
Assert.That(transitions[0].TransitionNumber!.Value, Is.EqualTo(2));
Assert.That(transitions[1].Status!.Value, Is.False);
Assert.That(transitions[1].SourceName!.Value, Is.EqualTo("Internal"));
Assert.That(transitions[1].ClientUserId!.Value, Is.Null.Or.Empty);
Assert.That(transitions[1].ClientAuditEntryId!.Value, Is.Null.Or.Empty);
Assert.That(transitions[1].TransitionNumber!.Value, Is.EqualTo(4));
Assert.That(transitions[2].TransitionNumber!.Value, Is.EqualTo(2));
Assert.That(transitions[2].ActionTimeStamp!.Value,
Is.EqualTo(runtime.Node.LastTransition!.TransitionTime!.Value));
});
}
}
private static ProgramDefinition CreateDefinition(
ControlledProvider provider,
bool creatable = true,
bool deletable = true,
ProgramState initialState = ProgramState.Ready,
bool autoDelete = false,
uint maxInstances = 1,
Func? nodeFactory = null)
{
return new ProgramDefinition(
ProgramTypeIds.ManagedProgramType,
nodeFactory ?? (static parent => new ManagedProgramState(parent)),
provider,
[ProgramControlMethod.Start, ProgramControlMethod.Halt, ProgramControlMethod.Reset],
initialState,
maxInstances,
3)
{
Creatable = creatable,
Deletable = deletable,
AutoDelete = autoDelete,
DiagnosticsEnabled = false
};
}
private sealed class Harness(
ProgramNodeManager manager,
ControlledProvider provider,
MonitoredItemQueueFactory queues,
FakeTimeProvider clock) : IAsyncDisposable
{
public ProgramNodeManager Manager { get; } = manager;
public ControlledProvider Provider { get; } = provider;
public FakeTimeProvider Clock { get; } = clock;
public OperationContext OperationContext { get; } = CreateOperationContext();
public static async Task CreateAsync(
bool creatable = true,
bool deletable = true,
ProgramState initialState = ProgramState.Ready,
bool autoDelete = false,
bool addNodes = false,
uint maxInstances = 1,
Func? nodeFactory = null)
{
var clock = new FakeTimeProvider();
Mock server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queues, clock);
server.Setup(s => s.Telemetry).Returns(NUnitTelemetryContext.Create());
var tree = (TypeTable)server.Object.TypeTree;
tree.AddSubtype(ObjectTypeIds.BaseObjectType, NodeId.Null);
tree.AddSubtype(ObjectTypeIds.StateMachineType, ObjectTypeIds.BaseObjectType);
tree.AddSubtype(ObjectTypeIds.FiniteStateMachineType, ObjectTypeIds.StateMachineType);
tree.AddSubtype(ObjectTypeIds.ProgramStateMachineType, ObjectTypeIds.FiniteStateMachineType);
tree.AddSubtype(ObjectTypeIds.BaseEventType, ObjectTypeIds.BaseObjectType);
tree.AddSubtype(ObjectTypeIds.TransitionEventType, ObjectTypeIds.BaseEventType);
tree.AddSubtype(ObjectTypeIds.ProgramTransitionEventType, ObjectTypeIds.TransitionEventType);
tree.AddSubtype(ObjectTypeIds.AuditEventType, ObjectTypeIds.BaseEventType);
tree.AddSubtype(ObjectTypeIds.AuditUpdateMethodEventType, ObjectTypeIds.AuditEventType);
tree.AddSubtype(ObjectTypeIds.AuditUpdateStateEventType, ObjectTypeIds.AuditUpdateMethodEventType);
tree.AddSubtype(
ObjectTypeIds.AuditProgramTransitionEventType, ObjectTypeIds.AuditUpdateStateEventType);
var provider = new ControlledProvider();
ProgramDefinition definition = CreateDefinition(
provider, creatable, deletable, initialState, autoDelete, maxInstances, nodeFactory);
var manager = new ProgramNodeManager(
server.Object,
new ApplicationConfiguration { ServerConfiguration = new ServerConfiguration() },
[definition],
addNodesTypes: addNodes ? [ProgramTypeIds.ManagedProgramType] : default);
Mock.Get(server.Object.NodeManager).SetupGet(value => value.AsyncNodeManagers).Returns([manager]);
var harness = new Harness(manager, provider, queues, clock);
try
{
await manager.CreateAddressSpaceAsync(new Dictionary>())
.ConfigureAwait(false);
return harness;
}
catch
{
await harness.DisposeAsync().ConfigureAwait(false);
throw;
}
}
public QualifiedName Name(string name)
{
return new QualifiedName(name, Manager.NamespaceIndex);
}
public ValueTask CreateAsync(string name)
{
return Manager.CreateInvocationAsync(ProgramTypeIds.ManagedProgramType, Name(name));
}
public AddNodesItem AddItem(string name)
{
return new AddNodesItem
{
ParentNodeId = Manager.RootId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
BrowseName = Name(name),
NodeClass = NodeClass.Object,
TypeDefinition = ProgramTypeIds.ManagedProgramType
};
}
public async ValueTask DisposeAsync()
{
await Manager.DisposeAsync().ConfigureAwait(false);
queues.Dispose();
}
private static OperationContext CreateOperationContext()
{
var session = new Mock();
session.SetupGet(value => value.EffectiveIdentity).Returns(new Mock().Object);
session.SetupGet(value => value.PreferredLocales).Returns([]);
return new OperationContext(new RequestHeader(), null!, RequestType.AddNodes,
RequestLifetime.None, session.Object);
}
}
private sealed class ControlledProvider : IProgramProvider
{
public bool FailNext { get; set; }
public TaskCompletionSource? AdmissionStarted { get; set; }
public TaskCompletionSource? ReleaseAdmission { get; set; }
public int CreateCount { get; private set; }
public TimeProvider? LastTimeProvider { get; private set; }
public List Executions { get; } = [];
public async ValueTask CreateAsync(
ProgramInvocationContext context,
CancellationToken cancellationToken = default)
{
CreateCount++;
LastTimeProvider = context.TimeProvider;
AdmissionStarted?.TrySetResult(true);
if (ReleaseAdmission is { } release)
{
await release.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
if (FailNext)
{
FailNext = false;
throw new ServiceResultException(StatusCodes.BadResourceUnavailable);
}
var execution = new ControlledExecution();
Executions.Add(execution);
return execution;
}
}
private sealed class ControlledExecution : IProgramExecution
{
public int ControlCount { get; private set; }
public int DisposeCount { get; private set; }
public TaskCompletionSource Disposed { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public ValueTask ControlAsync(
ProgramControlContext context,
CancellationToken cancellationToken = default)
{
ControlCount++;
return new ValueTask(new ProgramControlResult(ServiceResult.Good, default));
}
public async IAsyncEnumerable ObserveAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (ProgramExecutionUpdate update in
m_updates.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
public ValueTask DisposeAsync()
{
DisposeCount++;
m_updates.Writer.TryComplete();
Disposed.TrySetResult(true);
return default;
}
private readonly Channel m_updates =
Channel.CreateUnbounded();
}
}
}