mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
9b034f8291
# 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>
2400 lines
111 KiB
C#
2400 lines
111 KiB
C#
/* ========================================================================
|
||
* 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 });
|
||
}
|
||
}
|