Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/MonitoredNode2Tests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] 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.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

2400 lines
111 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.Tests.NodeManager;
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Tests for the MonitoredNode2 class, particularly the event-driven
/// RolePermissions validation caching behaviour.
/// </summary>
[TestFixture]
[Category("MonitoredNode")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class MonitoredNode2Tests
{
[TestCase(false)]
[TestCase(true)]
public void DisposedMonitoredNodeReleasesPermissionNotificationsAndNodeCallbacks(bool eventOnly)
{
var configuration = new Mock<IConfigurationNodeManager>();
var server = new Mock<IServerInternal>();
server.SetupGet(value => value.ConfigurationNodeManager).Returns(configuration.Object);
var node = new BaseObjectState(null) { NodeId = new NodeId(1, 1) };
using var monitored = new MonitoredNode2(Mock.Of<IAsyncNodeManager>(), server.Object, node);
if (eventOnly)
{
monitored.Add(CreateEventMonitoredItemMock(1).Object);
}
else
{
monitored.Add(CreateDataChangeMonitoredItemMock(1, Attributes.Value).Object);
}
monitored.Dispose();
monitored.Dispose();
configuration.VerifyAdd(value => value.DefaultPermissionsChanged += It.IsAny<EventHandler>(), Times.Once);
configuration.VerifyRemove(
value => value.DefaultPermissionsChanged -= It.IsAny<EventHandler>(), Times.Once);
Assert.That(node.OnStateChangedAsync, Is.Null);
Assert.That(node.OnReportEventAsync, Is.Null);
}
[TestCase(false)]
[TestCase(true)]
public async Task InFlightDataPermissionCannotReviveInvalidatedGrantAsync(bool defaultsChanged)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(out MonitoredItemQueueFactory queues);
using (queues)
{
var configuration = new Mock<IConfigurationNodeManager>();
server.SetupGet(value => value.ConfigurationNodeManager).Returns(configuration.Object);
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId(1, 1),
DataType = DataTypeIds.Int32,
Value = 42,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead
};
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int calls = 0;
var nodeManager = new Mock<IAsyncNodeManager>();
nodeManager.Setup(value => value.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(), node.NodeId, PermissionType.Read,
It.IsAny<CancellationToken>()))
.Returns(async (OperationContext _, NodeId _, PermissionType _, CancellationToken ct) =>
{
if (Interlocked.Increment(ref calls) == 1)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
return ServiceResult.Good;
}
return new ServiceResult(StatusCodes.BadUserAccessDenied);
});
NodeId sessionId = new(2, 1);
Mock<IDataChangeMonitoredItem2> item =
CreateDataChangeMonitoredItemMockWithSession(1, Attributes.Value, sessionId);
var monitored = new MonitoredNode2(nodeManager.Object, server.Object, node);
monitored.Add(item.Object);
try
{
await monitored.OnMonitoredNodeChangedAsync(
server.Object.DefaultSystemContext, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
if (defaultsChanged)
{
configuration.Raise(value => value.DefaultPermissionsChanged += null, EventArgs.Empty);
}
else
{
monitored.InvalidatePermissionCacheForSession(sessionId);
}
await monitored.OnMonitoredNodeChangedAsync(
server.Object.DefaultSystemContext, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
}
finally
{
release.TrySetResult(true);
await monitored.DisposeAsync().ConfigureAwait(false);
}
Assert.That(calls, Is.EqualTo(2));
// The revoked grant is never used: only the access denied status is queued.
item.Verify(
value => value.QueueValue(
It.Ref<DataValue>.IsAny,
It.Is<ServiceResult>(r => r.StatusCode != StatusCodes.BadUserAccessDenied)),
Times.Never);
item.Verify(
value => value.QueueValue(
It.Ref<DataValue>.IsAny,
It.Is<ServiceResult>(r => r.StatusCode == StatusCodes.BadUserAccessDenied)),
Times.AtLeastOnce);
}
}
[TestCase(false)]
[TestCase(true)]
public async Task InFlightEventPermissionCannotReviveInvalidatedGrantAsync(bool defaultsChanged)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(out MonitoredItemQueueFactory queues);
using (queues)
{
var configuration = new Mock<IConfigurationNodeManager>();
server.SetupGet(value => value.ConfigurationNodeManager).Returns(configuration.Object);
var node = new BaseObjectState(null) { NodeId = ObjectIds.Server };
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int calls = 0;
var nodeManager = new Mock<IAsyncNodeManager>();
nodeManager.Setup(value => value.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(), It.IsAny<IFilterTarget>(), It.IsAny<CancellationToken>()))
.Returns(async (IEventMonitoredItem _, IFilterTarget _, CancellationToken ct) =>
{
if (Interlocked.Increment(ref calls) == 1)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
return ServiceResult.Good;
}
return new ServiceResult(StatusCodes.BadUserAccessDenied);
});
NodeId sessionId = new(2, 1);
Mock<IEventMonitoredItem> item = CreateEventMonitoredItemMockWithSession(1, sessionId);
var monitored = new MonitoredNode2(nodeManager.Object, server.Object, node);
monitored.Add(item.Object);
var evt = new BaseEventState(null);
evt.EventType = new PropertyState<NodeId>.Implementation<VariantBuilder>(evt)
{
Value = ObjectTypeIds.BaseEventType
};
evt.SourceNode = new PropertyState<NodeId>.Implementation<VariantBuilder>(evt)
{
Value = ObjectIds.Server
};
try
{
await monitored.OnReportEventAsync(server.Object.DefaultSystemContext, node, evt)
.ConfigureAwait(false);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
if (defaultsChanged)
{
configuration.Raise(value => value.DefaultPermissionsChanged += null, EventArgs.Empty);
}
else
{
monitored.InvalidatePermissionCacheForSession(sessionId);
}
await monitored.OnReportEventAsync(server.Object.DefaultSystemContext, node, evt)
.ConfigureAwait(false);
}
finally
{
release.TrySetResult(true);
await monitored.DisposeAsync().ConfigureAwait(false);
}
Assert.That(calls, Is.EqualTo(2));
item.Verify(value => value.QueueEvent(It.IsAny<IFilterTarget>()), Times.Never);
}
}
/// <summary>
/// The synchronous wrappers are obsolete but still shipped, so they
/// stay covered: on the fast path they must enqueue inline without
/// blocking, exactly as the asynchronous entry points do.
/// </summary>
[Test]
public void SynchronousReportEventWrapperEnqueuesInline()
{
var node = new BaseObjectState(null)
{
NodeId = new NodeId("Server", 0),
BrowseName = new QualifiedName("Server", 0)
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(11u);
using var monitoredNode = new MonitoredNode2(
nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
Assert.DoesNotThrow(() =>
#pragma warning disable CS0618 // the wrapper is obsolete but must keep working
monitoredNode.OnReportEvent(context, node, new BaseEventState(null)));
#pragma warning restore CS0618
}
/// <summary>
/// The synchronous change wrapper must likewise complete inline for a
/// node that reads synchronously.
/// </summary>
[Test]
public void SynchronousNodeChangedWrapperEnqueuesInline()
{
var nodeId = new NodeId("syncWrapperNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("syncWrapperNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> itemMock =
CreateDataChangeMonitoredItemMock(12u, Attributes.Value);
using var monitoredNode = new MonitoredNode2(
nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(itemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
Assert.DoesNotThrow(() =>
#pragma warning disable CS0618 // the wrapper is obsolete but must keep working
monitoredNode.OnMonitoredNodeChanged(
context, node, NodeStateChangeMasks.Value));
#pragma warning restore CS0618
}
/// <summary>
/// Verifies that ValidateRolePermissions is called only once when the same node
/// value changes multiple times (permission result is cached).
/// </summary>
[Test]
public async Task OnMonitoredNodeChangedPermissionsCachedValidateCalledOnceAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
// Act – fire value-change notification three times
ISystemContext context = new Mock<ISystemContext>().Object;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Asynchronous disposal is the delivery barrier.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – ValidateRolePermissions should have been called exactly once (cached after first call)
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
nodeId,
PermissionType.Read,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that the permission cache is invalidated when RolePermissions change,
/// causing ValidateRolePermissions to be called again on the next value change.
/// </summary>
[Test]
public async Task OnMonitoredNodeChangedRolePermissionsChangedCacheInvalidatedAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act – first value change populates the cache
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Simulate a RolePermissions change (invalidates cache)
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.RolePermissions).ConfigureAwait(false);
// Second value change should trigger re-validation
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Asynchronous disposal is the delivery barrier.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – ValidateRolePermissions should have been called twice (once before and once after cache invalidation)
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
nodeId,
PermissionType.Read,
It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
/// <summary>
/// Verifies that when ValidateRolePermissions returns a bad result, the value is not
/// queued but the access denied status is (Part 4 5.13.2.1), and the result is cached
/// (so validation is not repeated on every change).
/// </summary>
[Test]
public async Task OnMonitoredNodeChangedPermissionDeniedQueuesStatusAndResultCachedAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(new ServiceResult(StatusCodes.BadUserAccessDenied)));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act – fire value-change notification twice
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Asynchronous disposal is the delivery barrier.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – only the access denied status is queued (Part 4 5.13.2.1), once per change
AssertOnlyStatusQueued(monitoredItemMock, StatusCodes.BadUserAccessDenied, 2);
// And validate was only called once (cached bad result)
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
nodeId,
PermissionType.Read,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that the RolePermissions change mask is set when the RolePermissions
/// property of a NodeState is modified.
/// </summary>
[Test]
public void NodeState_RolePermissionsPropertyChange_SetsRolePermissionsChangeMask()
{
// Arrange & Act
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
RolePermissions = []
};
// Assert – both NonValue and RolePermissions bits must be set
Assert.That(node.ChangeMasks & NodeStateChangeMasks.NonValue, Is.Not.EqualTo(NodeStateChangeMasks.None));
Assert.That(node.ChangeMasks & NodeStateChangeMasks.RolePermissions, Is.Not.EqualTo(NodeStateChangeMasks.None));
}
/// <summary>
/// Verifies that the RolePermissions change mask is set when the UserRolePermissions
/// property of a NodeState is modified.
/// </summary>
[Test]
public void NodeState_UserRolePermissionsPropertyChange_SetsRolePermissionsChangeMask()
{
// Arrange
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
UserRolePermissions = [] // act
};
// Assert – both NonValue and RolePermissions bits must be set
Assert.That(node.ChangeMasks & NodeStateChangeMasks.NonValue, Is.Not.EqualTo(NodeStateChangeMasks.None));
Assert.That(node.ChangeMasks & NodeStateChangeMasks.RolePermissions, Is.Not.EqualTo(NodeStateChangeMasks.None));
}
/// <summary>
/// Verifies that after setting RolePermissions and calling ClearChangeMasks,
/// the OnStateChanged callback is called with NodeStateChangeMasks.RolePermissions set.
/// </summary>
[Test]
public void NodeState_ClearChangeMasks_FiresCallbackWithRolePermissionsMask()
{
// Arrange
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
NodeStateChangeMasks capturedMask = NodeStateChangeMasks.None;
node.OnStateChanged = (_, _, masks) => capturedMask = masks;
ISystemContext contextMock = new Mock<ISystemContext>().Object;
// Act – change RolePermissions and then clear masks
node.RolePermissions = [];
node.ClearChangeMasks(contextMock, false);
// Assert – callback should have received the RolePermissions mask
Assert.That(capturedMask & NodeStateChangeMasks.RolePermissions, Is.Not.EqualTo(NodeStateChangeMasks.None));
}
/// <summary>
/// Verifies that the permission cache is invalidated when the
/// <see cref="IConfigurationNodeManager.DefaultPermissionsChanged"/> event fires,
/// causing <c>ValidateRolePermissions</c> to be called again on the next value change.
/// </summary>
[Test]
public async Task OnMonitoredNodeChangedDefaultPermissionsChangedCacheInvalidatedAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(() => new ValueTask<ServiceResult>(ServiceResult.Good));
// Set up a ConfigurationNodeManager mock that exposes the DefaultPermissionsChanged event
var configNodeManagerMock = new Mock<IConfigurationNodeManager>();
EventHandler? capturedHandler = null;
configNodeManagerMock
.SetupAdd(m => m.DefaultPermissionsChanged += It.IsAny<EventHandler>())
.Callback<EventHandler>(h => capturedHandler = h);
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
serverMock.Setup(s => s.ConfigurationNodeManager).Returns(configNodeManagerMock.Object);
// Signal when QueueValue is called — this guarantees the cache has been populated
// (QueueValue happens after the permission result is cached).
var firstItemProcessed = new AsyncSignal();
Mock<IDataChangeMonitoredItem2> monitoredItemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
monitoredItemMock
.Setup(m => m.QueueValue(It.Ref<DataValue>.IsAny, It.IsAny<ServiceResult>()))
.Callback(firstItemProcessed.Set);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// First value change populates the cache
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Wait until the consumer has fully processed the first notification (cache populated).
await firstItemProcessed.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
// Simulate namespace DefaultPermissionsChanged event firing
Assert.That(capturedHandler, Is.Not.Null, "DefaultPermissionsChanged handler should have been subscribed");
capturedHandler.Invoke(configNodeManagerMock.Object, EventArgs.Empty);
// Second value change should trigger re-validation since cache was cleared
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Asynchronous disposal is the delivery barrier.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – ValidateRolePermissions should have been called twice (once before and once after cache invalidation)
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(It.IsAny<OperationContext>(), nodeId, PermissionType.Read, It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
/// <summary>
/// Verifies that all value changes are delivered to the monitored item in FIFO order
/// when multiple changes are queued before the first one is processed.
/// The semaphore holds up the first validation so that subsequent changes pile up
/// in the channel; once released every change must be delivered – none dropped.
/// </summary>
[Test]
public async Task AllValueChangesNoneDroppedWhenNodeChangesWhileValidationIsBlockedAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = 0
};
// Gate that blocks the first (and only) permission validation call.
using var validationGate = new SemaphoreSlim(0, 1);
var validationStarted = new AsyncSignal();
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns<OperationContext, NodeId, PermissionType, CancellationToken>(
(_, _, _, ct) =>
{
validationStarted.Set();
return new ValueTask<ServiceResult>(
validationGate.WaitAsync(ct).ContinueWith(
_ => ServiceResult.Good,
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.Default));
});
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act – first change: consumer will block on validation
node.Value = 10;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Wait until the consumer has started validation so it is definitely blocked
await validationStarted.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
// While blocked, enqueue two more changes; each call snapshots the value immediately
node.Value = 20;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
node.Value = 30;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Release validation – all three notifications should now be processed in order
validationGate.Release();
// Asynchronous disposal drains the channel before completing.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Extract the DataValue arguments from the recorded QueueValue invocations
var deliveredValues = monitoredItemMock.Invocations
.Where(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue))
.Select(i => ((DataValue)i.Arguments[0]).WrappedValue.AsBoxedObject())
.ToList();
// Assert – every change was delivered, none dropped
Assert.That(deliveredValues, Has.Count.EqualTo(3));
Assert.That(deliveredValues, Is.EqualTo(new object[] { 10, 20, 30 }));
// Validation should have been called only once (result cached for changes 2 and 3)
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
nodeId,
PermissionType.Read,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that each value-change snapshot captures the node value at the time
/// <see cref="MonitoredNode2.OnMonitoredNodeChanged"/> is called (enqueue-time),
/// not at the time the consumer processes the snapshot (dequeue-time).
/// </summary>
[Test]
public async Task ValueChangeSnapshotCapturesNodeValueAtEnqueueTimeNotAtProcessingTimeAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = 0
};
using var validationGate = new SemaphoreSlim(0, 1);
var validationStarted = new AsyncSignal();
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns<OperationContext, NodeId, PermissionType, CancellationToken>(
(_, _, _, ct) =>
{
validationStarted.Set();
return new ValueTask<ServiceResult>(
validationGate.WaitAsync(ct).ContinueWith(
_ => ServiceResult.Good,
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.Default));
});
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Enqueue snapshot with value=100
node.Value = 100;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await validationStarted.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
// Enqueue second snapshot while consumer is blocked; value at enqueue time is 200
node.Value = 200;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Mutate the node value to something else BEFORE releasing the consumer;
// the already-captured snapshots must not be affected
node.Value = 999;
validationGate.Release();
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – snapshots reflect the values at enqueue time (100, 200), not the final value (999)
var deliveredValues = monitoredItemMock.Invocations
.Where(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue))
.Select(i => ((DataValue)i.Arguments[0]).WrappedValue.AsBoxedObject())
.ToList();
Assert.That(deliveredValues, Has.Count.EqualTo(2));
Assert.That(deliveredValues, Is.EqualTo(new object[] { 100, 200 }));
}
/// <summary>
/// Verifies that a monitored IndexRange reports BadIndexRangeNoData when an array shrinks
/// so the range no longer selects any elements.
/// </summary>
[Test]
public async Task IndexRangeBecomesOutOfBoundsQueuesBadIndexRangeNoDataAsync()
{
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.OneDimension,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = s_indexRangeInitialValue
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock =
CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
monitoredItemMock
.Setup(m => m.IndexRange)
.Returns(new NumericRange(2, 4));
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
node.Value = s_indexRangeShrunkValue;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
var deliveredValues = monitoredItemMock.Invocations
.Where(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue))
.Select(i => (DataValue)i.Arguments[0])
.ToList();
Assert.That(deliveredValues, Has.Count.EqualTo(2));
Assert.That(deliveredValues[0].StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(
deliveredValues[0].WrappedValue.TryGetValue(out ArrayOf<int> initialSlice),
Is.True);
Assert.That(initialSlice, Is.EqualTo(s_indexRangeExpectedSlice));
Assert.That(deliveredValues[1].WrappedValue.IsNull, Is.True);
Assert.That(
deliveredValues[1].StatusCode,
Is.EqualTo(StatusCodes.BadIndexRangeNoData));
}
/// <summary>
/// Verifies that when multiple monitored items are registered on the same node,
/// all of them receive every value-change notification.
/// </summary>
[Test]
public async Task MultipleMonitoredItemsAllReceiveEveryValueChangeAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = 0
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> item1Mock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
Mock<IDataChangeMonitoredItem2> item2Mock = CreateDataChangeMonitoredItemMock(2u, Attributes.Value);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(item1Mock.Object);
monitoredNode.Add(item2Mock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act – fire three value changes
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – each item received all three changes
int item1Count = item1Mock.Invocations
.Count(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue));
int item2Count = item2Mock.Invocations
.Count(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue));
Assert.That(item1Count, Is.EqualTo(3), "item1 should have received 3 notifications");
Assert.That(item2Count, Is.EqualTo(3), "item2 should have received 3 notifications");
}
/// <summary>
/// Verifies that non-value attribute changes (e.g. DisplayName) are delivered to the
/// monitored item after the Browse permission the Read service requires for them.
/// </summary>
[Test]
public async Task NonValueAttributeChangeDeliveredAfterBrowsePermissionValidationAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
DisplayName = new LocalizedText("initial")
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
PermissionType.Browse,
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock =
CreateDataChangeMonitoredItemMock(1u, Attributes.DisplayName);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act – fire a non-value attribute change three times
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.NonValue).ConfigureAwait(false);
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.NonValue).ConfigureAwait(false);
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.NonValue).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – value was queued for each change
// (QueueValue is recorded on the base IDataChangeMonitoredItem interface by Moq;
// use Invocations to count it reliably across both interface levels)
int queueCount = monitoredItemMock.Invocations
.Count(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue));
Assert.That(queueCount, Is.EqualTo(3));
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
nodeId,
PermissionType.Browse,
It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
/// <summary>
/// Verifies that a RolePermissions change is not delivered to an item whose owner
/// lacks the ReadRolePermissions permission; the item reports the access denied status.
/// </summary>
[Test]
public async Task RolePermissionsChangeReportsAccessDeniedWithoutReadRolePermissionsAsync()
{
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(
new ServiceResult(StatusCodes.BadUserAccessDenied)));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock =
CreateDataChangeMonitoredItemMock(1u, Attributes.RolePermissions);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
await monitoredNode.OnMonitoredNodeChangedAsync(
context,
node,
NodeStateChangeMasks.NonValue | NodeStateChangeMasks.RolePermissions).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Only the access denied status is reported (Part 4 5.13.2.1), never the attribute value.
AssertOnlyStatusQueued(monitoredItemMock, StatusCodes.BadUserAccessDenied, 1);
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
nodeId,
PermissionType.ReadRolePermissions,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that a failed re-read of a User* attribute in the item owner's context is
/// reported with its error status instead of a Good null value.
/// </summary>
[Test]
public async Task UserAttributeReReadErrorIsQueuedAsync()
{
var nodeId = new NodeId("testNode", 1);
int reads = 0;
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead
};
// First read builds the snapshot, the second is the owner-context re-read.
node.OnReadUserAccessLevel = (ISystemContext _, NodeState _, ref byte _) =>
Interlocked.Increment(ref reads) == 1
? ServiceResult.Good
: new ServiceResult(StatusCodes.BadUserAccessDenied);
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
PermissionType.Browse,
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock =
CreateDataChangeMonitoredItemMock(1u, Attributes.UserAccessLevel);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
await monitoredNode.OnMonitoredNodeChangedAsync(
context, node, NodeStateChangeMasks.NonValue).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
Assert.That(reads, Is.EqualTo(2));
AssertOnlyStatusQueued(monitoredItemMock, StatusCodes.BadUserAccessDenied, 1);
}
private static void AssertOnlyStatusQueued(
Mock<IDataChangeMonitoredItem2> monitoredItemMock,
StatusCode expected,
int expectedCount)
{
var queued = monitoredItemMock.Invocations
.Where(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue))
.Select(i => ((DataValue)i.Arguments[0], (ServiceResult)i.Arguments[1]))
.ToList();
Assert.That(queued, Has.Count.EqualTo(expectedCount));
foreach ((DataValue value, ServiceResult error) in queued)
{
Assert.That(value.StatusCode, Is.EqualTo(expected));
Assert.That(value.WrappedValue.IsNull, Is.True);
Assert.That(error.StatusCode, Is.EqualTo(expected));
}
}
/// <summary>
/// Verifies that value changes queued before a RolePermissions-change notification is
/// processed are not affected by the cache invalidation – only changes processed AFTER
/// the invalidation require a fresh validation call.
/// </summary>
[Test]
public async Task AllValueChangesNoneDroppedWhenRolePermissionsChangeIsInterleavedAndValidationIsBlockedAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = 0
};
// Block the first validation so we can interleave a RolePermissions change
using var firstValidationGate = new SemaphoreSlim(0, 1);
var firstValidationStarted = new AsyncSignal();
int validationCallCount = 0;
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns<OperationContext, NodeId, PermissionType, CancellationToken>(
(_, _, _, ct) =>
{
int callIndex = Interlocked.Increment(ref validationCallCount);
if (callIndex == 1)
{
firstValidationStarted.Set();
return new ValueTask<ServiceResult>(
firstValidationGate.WaitAsync(ct).ContinueWith(
_ => ServiceResult.Good,
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.Default));
}
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IDataChangeMonitoredItem2> monitoredItemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// First value change – consumer blocks on validation
node.Value = 10;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
await firstValidationStarted.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
// While consumer is blocked, enqueue a RolePermissions change followed by another value change
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.RolePermissions).ConfigureAwait(false);
node.Value = 20;
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Release validation
firstValidationGate.Release();
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – both value changes must have been delivered
var deliveredValues = monitoredItemMock.Invocations
.Where(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue))
.Select(i => ((DataValue)i.Arguments[0]).WrappedValue.AsBoxedObject())
.ToList();
Assert.That(deliveredValues, Has.Count.EqualTo(2));
Assert.That(deliveredValues, Is.EqualTo(new object[] { 10, 20 }));
// Validation must have been called twice: once before and once after cache invalidation
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
nodeId,
PermissionType.Read,
It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
// Event handling tests
/// <summary>
/// Verifies that a single event fired on a node is delivered to the registered
/// event monitored item via the channel consumer.
/// </summary>
[Test]
public async Task OnReportEventSingleEventDeliveredToMonitoredItemAsync()
{
// Arrange
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(1u);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
var eventState = new BaseEventState(null);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act
await monitoredNode.OnReportEventAsync(context, node, eventState).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – QueueEvent called exactly once
int queueCount = eventItemMock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(queueCount, Is.EqualTo(1));
}
/// <summary>
/// Verifies that all events fired while validation is blocked are preserved and
/// delivered in order — none are dropped by the bounded channel.
/// </summary>
[Test]
public async Task OnReportEventAllEventsNoneDroppedWhenValidationIsBlockedAsync()
{
// Arrange
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
using var validationGate = new SemaphoreSlim(0, 1);
var validationStarted = new AsyncSignal();
int validationCallCount = 0;
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(() =>
{
validationStarted.Set();
int callIndex = Interlocked.Increment(ref validationCallCount);
if (callIndex == 1)
{
return new ValueTask<ServiceResult>(
validationGate.WaitAsync().ContinueWith(
_ => ServiceResult.Good,
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.Default));
}
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(1u);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act – fire first event; consumer blocks on validation
await monitoredNode.OnReportEventAsync(context, node, new BaseEventState(null)).ConfigureAwait(false);
await validationStarted.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
// While blocked, enqueue two more events
await monitoredNode.OnReportEventAsync(context, node, new BaseEventState(null)).ConfigureAwait(false);
await monitoredNode.OnReportEventAsync(context, node, new BaseEventState(null)).ConfigureAwait(false);
// Release and drain
validationGate.Release();
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – all three events delivered
int queueCount = eventItemMock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(queueCount, Is.EqualTo(3));
}
/// <summary>
/// Verifies that <see cref="OnReportEvent"/> clones event data at enqueue time.
/// Mutating the original event instance after enqueueing must not alter the queued clone.
/// </summary>
[Test]
public async Task OnReportEventEventSnapshotCapturesStateAtEnqueueTimeNotAtProcessingTimeAsync()
{
// Arrange
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
using var validationGate = new SemaphoreSlim(0, 1);
var validationStarted = new AsyncSignal();
int validationCallCount = 0;
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(() =>
{
validationStarted.Set();
int callIndex = Interlocked.Increment(ref validationCallCount);
if (callIndex == 1)
{
return new ValueTask<ServiceResult>(
validationGate.WaitAsync().ContinueWith(
_ => ServiceResult.Good,
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.Default));
}
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
// Capture the IFilterTarget that reaches QueueEvent so we can inspect cloned values.
IFilterTarget? deliveredTarget = null;
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(1u);
eventItemMock
.Setup(m => m.QueueEvent(It.IsAny<IFilterTarget>()))
.Callback<IFilterTarget>(t => deliveredTarget = t);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Create an existing event type and set values before enqueue.
var originalEvent = new BaseEventState(null);
originalEvent.SetChildValue(context, BrowseNames.SourceName, "source-before", false);
originalEvent.SetChildValue(context, BrowseNames.Severity, (ushort)300, false);
await monitoredNode.OnReportEventAsync(context, node, originalEvent).ConfigureAwait(false);
Assert.That(await validationStarted.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false), Is.True);
// Mutate the original after enqueue – the clone must not reflect this change.
originalEvent.SetChildValue(context, BrowseNames.SourceName, "source-after", false);
originalEvent.SetChildValue(context, BrowseNames.Severity, (ushort)900, false);
validationGate.Release();
await monitoredNode.DisposeAsync().ConfigureAwait(false);
Assert.That(deliveredTarget, Is.Not.Null);
var filterContext = new Mock<IFilterContext>();
Variant sourceName = deliveredTarget.GetAttributeValue(
filterContext.Object,
NodeId.Null,
[QualifiedName.From(BrowseNames.SourceName)],
Attributes.Value,
NumericRange.Null);
Variant severity = deliveredTarget.GetAttributeValue(
filterContext.Object,
NodeId.Null,
[QualifiedName.From(BrowseNames.Severity)],
Attributes.Value,
NumericRange.Null);
// Assert cloned values remain the enqueue-time values.
Assert.That(sourceName.AsBoxedObject(), Is.EqualTo("source-before"));
Assert.That(severity.AsBoxedObject(), Is.EqualTo((ushort)300));
}
/// <summary>
/// Verifies that when <see cref="IAsyncNodeManager.ValidateEventRolePermissionsAsync"/>
/// returns a bad result the event is silently dropped and never passed to
/// <see cref="IEventMonitoredItem.QueueEvent"/>.
/// </summary>
[Test]
public async Task OnReportEventPermissionDeniedEventNotQueuedAsync()
{
// Arrange
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(new ServiceResult(StatusCodes.BadUserAccessDenied)));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(1u);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act
await monitoredNode.OnReportEventAsync(context, node, new BaseEventState(null)).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – QueueEvent must never have been called
int queueCount = eventItemMock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(queueCount, Is.Zero);
}
/// <summary>
/// Verifies that audit events are suppressed when <see cref="IServerInternal.Auditing"/>
/// is false – no call to <see cref="IEventMonitoredItem.QueueEvent"/> should be made.
/// </summary>
[Test]
public async Task OnReportEventAuditEventDroppedWhenAuditingDisabledAsync()
{
// Arrange
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
// Auditing is DISABLED on the server
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(1u);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
// Fire an audit event – should be filtered out because Auditing == false
var auditEvent = new AuditEventState(null);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act
await monitoredNode.OnReportEventAsync(context, node, auditEvent).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – nothing queued
int queueCount = eventItemMock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(queueCount, Is.Zero);
}
/// <summary>
/// Verifies that multiple event monitored items registered on the same node all
/// receive every event notification.
/// </summary>
[Test]
public async Task OnReportEventMultipleEventMonitoredItemsAllReceiveEveryEventAsync()
{
// Arrange
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> item1Mock = CreateEventMonitoredItemMock(1u);
Mock<IEventMonitoredItem> item2Mock = CreateEventMonitoredItemMock(2u);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(item1Mock.Object);
monitoredNode.Add(item2Mock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Act – fire three events
await monitoredNode.OnReportEventAsync(context, node, new BaseEventState(null)).ConfigureAwait(false);
await monitoredNode.OnReportEventAsync(context, node, new BaseEventState(null)).ConfigureAwait(false);
await monitoredNode.OnReportEventAsync(context, node, new BaseEventState(null)).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – both items received all three events
int item1Count = item1Mock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
int item2Count = item2Mock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(item1Count, Is.EqualTo(3), "item1 should have received 3 events");
Assert.That(item2Count, Is.EqualTo(3), "item2 should have received 3 events");
}
[Test]
public async Task FailingEventFilterDoesNotDiscardOtherMonitoredItemsAsync()
{
var owner = new Mock<IAsyncNodeManager>();
owner.Setup(value => value.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(), It.IsAny<IFilterTarget>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var server = new Mock<IServerInternal>();
var source = new BaseObjectState(null) { NodeId = new NodeId("EventIsolation", 1) };
using var monitored = new MonitoredNode2(owner.Object, server.Object, source);
monitored.Add(CreateEventMonitoredItemMock(1).Object);
monitored.Add(CreateEventMonitoredItemMock(2).Object);
IEventMonitoredItem[] order = [.. monitored.EventMonitoredItems.Values];
Mock.Get(order[0]).Setup(value => value.QueueEvent(It.IsAny<IFilterTarget>()))
.Callback<IFilterTarget>(filter => throw new ArgumentNullException(nameof(filter)));
await monitored.OnReportEventAsync(
new Mock<ISystemContext>().Object, source, new BaseEventState(null)).ConfigureAwait(false);
await monitored.DisposeAsync().ConfigureAwait(false);
Mock.Get(order[1]).Verify(value => value.QueueEvent(It.IsAny<IFilterTarget>()), Times.Once);
}
/// <summary>
/// Verifies that an event raised by one session is delivered to authorized event
/// monitored items owned by every session.
/// </summary>
[Test]
public async Task OnReportEventSessionContextDoesNotRestrictDeliveryAsync()
{
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("testNode", 1),
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
var sessionAId = new NodeId("sessionA", 1);
var sessionBId = new NodeId("sessionB", 1);
Mock<IEventMonitoredItem> sessionAItem =
CreateEventMonitoredItemMockWithSession(1u, sessionAId);
Mock<IEventMonitoredItem> sessionBItem =
CreateEventMonitoredItemMockWithSession(2u, sessionBId);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(sessionAItem.Object);
monitoredNode.Add(sessionBItem.Object);
var sessionContextMock = new Mock<ISessionSystemContext>();
sessionContextMock.Setup(c => c.SessionId).Returns(sessionBId);
await monitoredNode.OnReportEventAsync(sessionContextMock.Object, node, new BaseEventState(null)).ConfigureAwait(false);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
int sessionACount = sessionAItem.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
int sessionBCount = sessionBItem.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.Multiple(() =>
{
Assert.That(sessionACount, Is.EqualTo(1));
Assert.That(sessionBCount, Is.EqualTo(1));
});
}
/// <summary>
/// Verifies that a node which exposes a genuinely asynchronous value read handler
/// (<c>OnSimpleReadValueAsync</c>) has that handler honored on the change-notification
/// push path: the value delivered to the monitored item is the asynchronously-read value,
/// not the cached synchronous value.
/// </summary>
[Test]
public async Task OnMonitoredNodeChangedAsyncHonorsAsyncReadHandler()
{
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("asyncNode", 1),
BrowseName = new QualifiedName("asyncNode", 1),
DataType = DataTypeIds.Int32,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = 0,
// The asynchronous read handler returns a value that differs from the cached one.
OnSimpleReadValueAsync = (c, n, ct) => new ValueTask<AttributeSimpleReadResult>(
new AttributeSimpleReadResult(ServiceResult.Good, new Variant(777)))
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
var delivered = new AsyncSignal();
Mock<IDataChangeMonitoredItem2> itemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
itemMock
.Setup(m => m.QueueValue(It.Ref<DataValue>.IsAny, It.IsAny<ServiceResult>()))
.Callback(delivered.Set);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(itemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
await monitoredNode
.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value)
.ConfigureAwait(false);
Assert.That(
await delivered.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false),
Is.True,
"the value change was not delivered within the timeout");
await monitoredNode.DisposeAsync().ConfigureAwait(false);
var deliveredValues = itemMock.Invocations
.Where(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue))
.Select(i => ((DataValue)i.Arguments[0]).WrappedValue.AsBoxedObject())
.ToList();
Assert.That(deliveredValues, Has.Count.EqualTo(1));
Assert.That(deliveredValues[0], Is.EqualTo(777));
}
/// <summary>
/// Verifies that the asynchronous change-notification producer does not block a thread while
/// a genuinely asynchronous read handler is in flight: the returned <see cref="ValueTask"/>
/// is not yet completed while the read is pending, and the value is delivered once the read
/// completes.
/// </summary>
[Test]
public async Task OnMonitoredNodeChangedAsyncDoesNotBlockOnSlowAsyncRead()
{
var readStarted = new AsyncSignal();
var releaseRead = new AsyncSignal();
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("slowAsyncNode", 1),
BrowseName = new QualifiedName("slowAsyncNode", 1),
DataType = DataTypeIds.Int32,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = 0,
OnReadValueAsync = async (c, n, range, encoding, ct) =>
{
readStarted.Set();
// The read only completes once the test releases it, so
// the producer cannot be completing synchronously.
await releaseRead.WaitAsync(TimeSpan.FromSeconds(30))
.ConfigureAwait(false);
return new AttributeReadResult(
ServiceResult.Good, new Variant(555), StatusCodes.Good, DateTimeUtc.Now);
}
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
var delivered = new AsyncSignal();
Mock<IDataChangeMonitoredItem2> itemMock = CreateDataChangeMonitoredItemMock(1u, Attributes.Value);
itemMock
.Setup(m => m.QueueValue(It.Ref<DataValue>.IsAny, It.IsAny<ServiceResult>()))
.Callback(delivered.Set);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(itemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
ValueTask produce = monitoredNode.OnMonitoredNodeChangedAsync(
context, node, NodeStateChangeMasks.Value);
Assert.That(await readStarted.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false), Is.True);
Assert.That(
produce.IsCompleted,
Is.False,
"the producer must not block a thread while an asynchronous read is in flight");
releaseRead.Set();
await produce.ConfigureAwait(false);
Assert.That(
await delivered.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false),
Is.True,
"the value change was not delivered within the timeout");
await monitoredNode.DisposeAsync().ConfigureAwait(false);
var deliveredValues = itemMock.Invocations
.Where(i => i.Method.Name == nameof(IDataChangeMonitoredItem2.QueueValue))
.Select(i => ((DataValue)i.Arguments[0]).WrappedValue.AsBoxedObject())
.ToList();
Assert.That(deliveredValues, Has.Count.EqualTo(1));
Assert.That(deliveredValues[0], Is.EqualTo(555));
}
/// <summary>
/// Verifies that an event reported through the asynchronous <c>OnReportEventAsync</c> path is
/// delivered to the registered event monitored item via the channel consumer.
/// </summary>
[Test]
public async Task OnReportEventAsyncDeliversEventToMonitoredItem()
{
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("eventNode", 1),
BrowseName = new QualifiedName("eventNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
var delivered = new AsyncSignal();
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(1u);
eventItemMock
.Setup(m => m.QueueEvent(It.IsAny<IFilterTarget>()))
.Callback(delivered.Set);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
var eventState = new BaseEventState(null);
ISystemContext context = new Mock<ISystemContext>().Object;
await monitoredNode.OnReportEventAsync(context, node, eventState).ConfigureAwait(false);
Assert.That(
await delivered.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false),
Is.True,
"the event was not delivered within the timeout");
await monitoredNode.DisposeAsync().ConfigureAwait(false);
int queueCount = eventItemMock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(queueCount, Is.EqualTo(1));
}
private static Mock<IEventMonitoredItem> CreateEventMonitoredItemMock(uint id)
{
var sessionMock = new Mock<ISession>();
var identityMock = new Mock<IUserIdentity>();
sessionMock.Setup(s => s.EffectiveIdentity).Returns(identityMock.Object);
var eventItemMock = new Mock<IEventMonitoredItem>();
eventItemMock.Setup(m => m.Id).Returns(id);
eventItemMock.Setup(m => m.Session).Returns(sessionMock.Object);
return eventItemMock;
}
private static Mock<IEventMonitoredItem> CreateEventMonitoredItemMockWithSession(
uint id,
NodeId sessionId)
{
var sessionMock = new Mock<ISession>();
var identityMock = new Mock<IUserIdentity>();
sessionMock.Setup(s => s.EffectiveIdentity).Returns(identityMock.Object);
sessionMock.Setup(s => s.Id).Returns(sessionId);
var eventItemMock = new Mock<IEventMonitoredItem>();
eventItemMock.Setup(m => m.Id).Returns(id);
eventItemMock.Setup(m => m.Session).Returns(sessionMock.Object);
return eventItemMock;
}
private static Mock<IDataChangeMonitoredItem2> CreateDataChangeMonitoredItemMock(
uint id,
uint attributeId)
{
var sessionMock = new Mock<ISession>();
var identityMock = new Mock<IUserIdentity>();
sessionMock.Setup(s => s.EffectiveIdentity).Returns(identityMock.Object);
var monitoredItemMock = new Mock<IDataChangeMonitoredItem2>();
monitoredItemMock.Setup(m => m.Id).Returns(id);
monitoredItemMock.Setup(m => m.AttributeId).Returns(attributeId);
monitoredItemMock.Setup(m => m.IndexRange).Returns(NumericRange.Null);
monitoredItemMock.Setup(m => m.DataEncoding).Returns(QualifiedName.Null);
monitoredItemMock.Setup(m => m.Session).Returns(sessionMock.Object);
monitoredItemMock.Setup(m => m.EffectiveIdentity).Returns(identityMock.Object);
return monitoredItemMock;
}
/// <summary>
/// Verifies that <see cref="MonitoredNode2.InvalidatePermissionCacheForSession"/> clears
/// the cached permission result for all monitored items belonging to the specified session,
/// causing <c>ValidateRolePermissions</c> to be called again on the next value change.
/// </summary>
[Test]
public async Task InvalidatePermissionCacheForSessionClearsPermissionCacheForMatchingSessionAsync()
{
// Arrange
var sessionId = new NodeId("session1", 1);
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(() => new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
// Signal when QueueValue is called — this guarantees the cache has been populated.
var firstItemProcessed = new AsyncSignal();
Mock<IDataChangeMonitoredItem2> monitoredItemMock =
CreateDataChangeMonitoredItemMockWithSession(1u, Attributes.Value, sessionId);
monitoredItemMock
.Setup(m => m.QueueValue(It.Ref<DataValue>.IsAny, It.IsAny<ServiceResult>()))
.Callback(firstItemProcessed.Set);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Populate the permission cache with the first value change
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Wait until the consumer has fully processed the first notification (cache populated).
await firstItemProcessed.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
// Act – invalidate permission cache for the session (simulates identity change)
monitoredNode.InvalidatePermissionCacheForSession(sessionId);
// Next value change should trigger re-validation
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Asynchronous disposal is the delivery barrier.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – called twice: once before and once after cache invalidation
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(It.IsAny<OperationContext>(), nodeId, PermissionType.Read, It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
/// <summary>
/// Verifies that <see cref="MonitoredNode2.InvalidatePermissionCacheForSession"/> does NOT
/// clear the permission cache for monitored items belonging to a different session.
/// </summary>
[Test]
public async Task InvalidatePermissionCacheForSessionDoesNotClearCacheForOtherSessionAsync()
{
// Arrange
var sessionId = new NodeId("session1", 1);
var otherSessionId = new NodeId("session2", 1);
var nodeId = new NodeId("testNode", 1);
var node = new BaseDataVariableState(null)
{
NodeId = nodeId,
BrowseName = new QualifiedName("testNode", 1),
DataType = DataTypeIds.Int32
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny<OperationContext>(),
It.IsAny<NodeId>(),
It.IsAny<PermissionType>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
// Signal when QueueValue is called — this guarantees the cache has been populated.
var firstItemProcessed = new AsyncSignal();
// Monitored item belongs to sessionId, not otherSessionId
Mock<IDataChangeMonitoredItem2> monitoredItemMock =
CreateDataChangeMonitoredItemMockWithSession(1u, Attributes.Value, sessionId);
monitoredItemMock
.Setup(m => m.QueueValue(It.Ref<DataValue>.IsAny, It.IsAny<ServiceResult>()))
.Callback(firstItemProcessed.Set);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(monitoredItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
// Populate the cache
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Wait until the consumer has fully processed the first notification (cache populated).
await firstItemProcessed.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
// Act – invalidate for a DIFFERENT session
monitoredNode.InvalidatePermissionCacheForSession(otherSessionId);
// Next value change should still use the cached result
await monitoredNode.OnMonitoredNodeChangedAsync(context, node, NodeStateChangeMasks.Value).ConfigureAwait(false);
// Asynchronous disposal is the delivery barrier.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Assert – ValidateRolePermissions still called only once (cache was not cleared)
nodeManagerMock.Verify(
m => m.ValidateRolePermissionsAsync(It.IsAny<OperationContext>(), nodeId, PermissionType.Read, It.IsAny<CancellationToken>()),
Times.Once);
}
private static Mock<IDataChangeMonitoredItem2> CreateDataChangeMonitoredItemMockWithSession(
uint id,
uint attributeId,
NodeId sessionId)
{
var sessionMock = new Mock<ISession>();
var identityMock = new Mock<IUserIdentity>();
sessionMock.Setup(s => s.EffectiveIdentity).Returns(identityMock.Object);
sessionMock.Setup(s => s.Id).Returns(sessionId);
var monitoredItemMock = new Mock<IDataChangeMonitoredItem2>();
monitoredItemMock.Setup(m => m.Id).Returns(id);
monitoredItemMock.Setup(m => m.AttributeId).Returns(attributeId);
monitoredItemMock.Setup(m => m.IndexRange).Returns(NumericRange.Null);
monitoredItemMock.Setup(m => m.DataEncoding).Returns(QualifiedName.Null);
monitoredItemMock.Setup(m => m.Session).Returns(sessionMock.Object);
monitoredItemMock.Setup(m => m.EffectiveIdentity).Returns(identityMock.Object);
return monitoredItemMock;
}
/// <summary>
/// Verifies that the event permission decision is cached per
/// (item, EventTypeId, SourceNode) tuple so a busy notifier does not
/// re-run <see cref="IAsyncNodeManager.ValidateEventRolePermissionsAsync"/>
/// for each subsequent event with the same identity.
/// </summary>
[Test]
public async Task OnReportEventRepeatedEventsWithSameIdentityPermissionValidatedOnceAsync()
{
var node = new BaseObjectState(null)
{
NodeId = new NodeId("Server", 0),
BrowseName = new QualifiedName("Server", 0)
};
int validationCalls = 0;
var firstValidationSignal = new AsyncSignal();
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(() =>
{
Interlocked.Increment(ref validationCalls);
firstValidationSignal.Set();
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(1u);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
static BaseEventState BuildEvent()
{
var ev = new BaseEventState(null);
ev.EventType = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectTypeIds.GeneralModelChangeEventType };
ev.SourceNode = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectIds.Server };
return ev;
}
await monitoredNode.OnReportEventAsync(context, node, BuildEvent()).ConfigureAwait(false);
Assert.That(await firstValidationSignal.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false), Is.True);
for (int i = 0; i < 4; i++)
{
await monitoredNode.OnReportEventAsync(context, node, BuildEvent()).ConfigureAwait(false);
}
await monitoredNode.DisposeAsync().ConfigureAwait(false);
Assert.That(validationCalls, Is.EqualTo(1));
int queueCount = eventItemMock.Invocations
.Count(i => i.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(queueCount, Is.EqualTo(5));
}
/// <summary>
/// Verifies that removing an event monitored item drops its cached
/// permission verdicts so a re-added item is re-validated.
/// </summary>
[Test]
public async Task RemoveEventMonitoredItemDropsCacheEntriesAsync()
{
var node = new BaseObjectState(null)
{
NodeId = new NodeId("Server", 0),
BrowseName = new QualifiedName("Server", 0)
};
int validationCalls = 0;
var validationSignal = new AsyncSignal();
var queuedSignal = new AsyncSignal();
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(() =>
{
Interlocked.Increment(ref validationCalls);
validationSignal.Set();
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> eventItemMock = CreateEventMonitoredItemMock(7u);
eventItemMock
.Setup(m => m.QueueEvent(It.IsAny<IFilterTarget>()))
.Callback(queuedSignal.Set);
var monitoredNode = new MonitoredNode2(nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(eventItemMock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
static BaseEventState BuildEvent()
{
var ev = new BaseEventState(null);
ev.EventType = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectTypeIds.GeneralModelChangeEventType };
ev.SourceNode = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectIds.Server };
return ev;
}
await monitoredNode.OnReportEventAsync(context, node, BuildEvent()).ConfigureAwait(false);
Assert.That(await validationSignal.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false), Is.True);
// The verdict is written to the permission cache only after the
// validation callback returns, so the validation signal alone can
// resume this test while the consumer is still between the callback
// and that write. Removing the item there drops nothing and the
// stale verdict lands in the cache afterwards, so the second event
// is served from cache, never re-validates and the wait below times
// out. QueueEvent runs after the cache write, which makes it the
// marker that the first event is fully processed.
Assert.That(await queuedSignal.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false), Is.True);
validationSignal.Reset();
monitoredNode.Remove(eventItemMock.Object);
monitoredNode.Add(eventItemMock.Object);
await monitoredNode.OnReportEventAsync(context, node, BuildEvent()).ConfigureAwait(false);
Assert.That(await validationSignal.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false), Is.True);
await monitoredNode.DisposeAsync().ConfigureAwait(false);
Assert.That(validationCalls, Is.EqualTo(2));
}
/// <summary>
/// Verifies that when the MonitoredNode2 is configured for the Server object
/// (ObjectIds.Server), consumer tasks scale up as event monitored items are added
/// and all events are delivered to all monitored items.
/// </summary>
[Test]
public async Task ServerNodeMultipleEventConsumersAllEventsDeliveredAsync()
{
// Use ObjectIds.Server so the Server-node multi-consumer path is activated
var node = new BaseObjectState(null)
{
NodeId = ObjectIds.Server,
BrowseName = new QualifiedName("Server", 0)
};
int validationCalls = 0;
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(() =>
{
Interlocked.Increment(ref validationCalls);
return new ValueTask<ServiceResult>(ServiceResult.Good);
});
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
// Create multiple event monitored items
Mock<IEventMonitoredItem> item1Mock = CreateEventMonitoredItemMock(1u);
Mock<IEventMonitoredItem> item2Mock = CreateEventMonitoredItemMock(2u);
Mock<IEventMonitoredItem> item3Mock = CreateEventMonitoredItemMock(3u);
// Consumer tasks scale up as MIs are added (starts with 1, scales to 3)
var monitoredNode = new MonitoredNode2(
nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(item1Mock.Object);
monitoredNode.Add(item2Mock.Object);
monitoredNode.Add(item3Mock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
static BaseEventState BuildEvent()
{
var ev = new BaseEventState(null);
ev.EventType = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectTypeIds.GeneralModelChangeEventType };
ev.SourceNode = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectIds.Server };
return ev;
}
const int eventCount = 10;
for (int i = 0; i < eventCount; i++)
{
await monitoredNode.OnReportEventAsync(context, node, BuildEvent()).ConfigureAwait(false);
}
// Asynchronous disposal drains the event channel.
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Each event should be delivered to all 3 monitored items
int item1Queued = item1Mock.Invocations
.Count(inv => inv.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
int item2Queued = item2Mock.Invocations
.Count(inv => inv.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
int item3Queued = item3Mock.Invocations
.Count(inv => inv.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(item1Queued + item2Queued + item3Queued, Is.EqualTo(eventCount * 3),
"Every event must be delivered to every monitored item.");
Assert.That(item1Queued, Is.EqualTo(eventCount));
Assert.That(item2Queued, Is.EqualTo(eventCount));
Assert.That(item3Queued, Is.EqualTo(eventCount));
}
/// <summary>
/// Verifies that a non-Server node can opt into multi-consumer handling
/// via the <c>enableMultipleEventConsumers</c> constructor parameter.
/// </summary>
[Test]
public async Task NonServerNodeOptInMultiConsumerAllEventsDeliveredAsync()
{
var node = new BaseObjectState(null)
{
NodeId = new NodeId("CustomNotifier", 2),
BrowseName = new QualifiedName("CustomNotifier", 2)
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(() => new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> item1Mock = CreateEventMonitoredItemMock(1u);
Mock<IEventMonitoredItem> item2Mock = CreateEventMonitoredItemMock(2u);
// Opt in via constructor parameter
var monitoredNode = new MonitoredNode2(
nodeManagerMock.Object, serverMock.Object, node,
enableMultipleEventConsumers: true);
monitoredNode.Add(item1Mock.Object);
monitoredNode.Add(item2Mock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
BaseEventState BuildEvent()
{
var ev = new BaseEventState(null);
ev.EventType = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectTypeIds.GeneralModelChangeEventType };
ev.SourceNode = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = node.NodeId };
return ev;
}
const int eventCount = 10;
for (int i = 0; i < eventCount; i++)
{
await monitoredNode.OnReportEventAsync(context, node, BuildEvent()).ConfigureAwait(false);
}
await monitoredNode.DisposeAsync().ConfigureAwait(false);
int item1Queued = item1Mock.Invocations
.Count(inv => inv.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
int item2Queued = item2Mock.Invocations
.Count(inv => inv.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(item1Queued + item2Queued, Is.EqualTo(eventCount * 2),
"Every event must be delivered to every monitored item.");
Assert.That(item1Queued, Is.EqualTo(eventCount));
Assert.That(item2Queued, Is.EqualTo(eventCount));
}
/// <summary>
/// Verifies that a non-Server node without opt-in does NOT create
/// additional consumer tasks (single-reader channel).
/// </summary>
[Test]
public async Task NonServerNodeNoOptInSingleConsumerAsync()
{
var node = new BaseObjectState(null)
{
NodeId = new NodeId("RegularNode", 2),
BrowseName = new QualifiedName("RegularNode", 2)
};
var nodeManagerMock = new Mock<IAsyncNodeManager>();
nodeManagerMock
.Setup(m => m.ValidateEventRolePermissionsAsync(
It.IsAny<IEventMonitoredItem>(),
It.IsAny<IFilterTarget>(),
It.IsAny<CancellationToken>()))
.Returns(() => new ValueTask<ServiceResult>(ServiceResult.Good));
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Auditing).Returns(false);
Mock<IEventMonitoredItem> item1Mock = CreateEventMonitoredItemMock(1u);
Mock<IEventMonitoredItem> item2Mock = CreateEventMonitoredItemMock(2u);
// Default: no multi-consumer
var monitoredNode = new MonitoredNode2(
nodeManagerMock.Object, serverMock.Object, node);
monitoredNode.Add(item1Mock.Object);
monitoredNode.Add(item2Mock.Object);
ISystemContext context = new Mock<ISystemContext>().Object;
BaseEventState BuildEvent()
{
var ev = new BaseEventState(null);
ev.EventType = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = ObjectTypeIds.GeneralModelChangeEventType };
ev.SourceNode = new PropertyState<NodeId>.Implementation<VariantBuilder>(ev) { Value = node.NodeId };
return ev;
}
const int eventCount = 10;
for (int i = 0; i < eventCount; i++)
{
await monitoredNode.OnReportEventAsync(context, node, BuildEvent()).ConfigureAwait(false);
}
await monitoredNode.DisposeAsync().ConfigureAwait(false);
// Events still delivered to all items (single consumer handles all)
int item1Queued = item1Mock.Invocations
.Count(inv => inv.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
int item2Queued = item2Mock.Invocations
.Count(inv => inv.Method.Name == nameof(IEventMonitoredItem.QueueEvent));
Assert.That(item1Queued + item2Queued, Is.EqualTo(eventCount * 2),
"Every event must be delivered to every monitored item.");
}
/// <summary>
/// An awaitable stand-in for <see cref="ManualResetEventSlim"/>.
/// </summary>
/// <remarks>
/// These tests wait for work performed by a consumer task, which needs
/// a thread-pool thread of its own. Blocking a thread-pool thread to
/// wait for it starves the pool once enough fixtures run in parallel,
/// and the wait then expires rather than the work being late - that is
/// what made <c>Remove_EventMonitoredItem_DropsCacheEntries</c> time
/// out on loaded CI runners and pass on re-run. Awaiting occupies no
/// thread, so the test observes the real timing instead of competing
/// with it.
/// </remarks>
private sealed class AsyncSignal
{
/// <summary>
/// Releases everyone waiting on this signal.
/// </summary>
public void Set()
{
m_source.TrySetResult(true);
}
/// <summary>
/// Arms the signal again for the next wait.
/// </summary>
public void Reset()
{
m_source = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>
/// Waits for the signal, returning false when it does not arrive in
/// time rather than throwing, to match the assertions here.
/// </summary>
public async Task<bool> WaitAsync(TimeSpan timeout)
{
Task signalled = m_source.Task;
using var cts = new CancellationTokenSource();
Task completed = await Task
.WhenAny(signalled, Task.Delay(timeout, cts.Token))
.ConfigureAwait(false);
if (completed == signalled)
{
cts.Cancel();
return true;
}
return false;
}
private TaskCompletionSource<bool> m_source = new(
TaskCreationOptions.RunContinuationsAsynchronously);
}
private static readonly ArrayOf<int> s_indexRangeInitialValue =
new(new[] { 0, 1, 2, 3, 4 });
private static readonly ArrayOf<int> s_indexRangeShrunkValue =
new(new[] { 0, 1 });
private static readonly ArrayOf<int> s_indexRangeExpectedSlice =
new(new[] { 2, 3, 4 });
}
}