/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
namespace Opc.Ua.Server.Tests.NodeManager
{
///
/// Verifies that a sampling group validates the Read permission of an item when it starts
/// sampling it and again when the inputs of the verdict change, instead of on every sample.
///
///
/// Each test changes the permission state from inside the Read of a known sampling cycle,
/// so the assertions do not depend on timing.
///
[TestFixture]
[Category("NodeManager")]
[Category("SamplingGroup")]
public sealed class SamplingGroupPermissionCacheTests
{
private const double kInterval = 50;
///
/// Between revalidations the verdict of the previous cycle is reused: the node manager is
/// asked once when the loop starts, not once per sample.
///
///
/// Non-parallelizable: any NodeState created by a concurrent test with RolePermissions
/// also invalidates the cached verdicts.
///
[Test]
[NonParallelizable]
public async Task VerdictIsReusedBetweenSamplesAsync()
{
using var harness = new Harness();
await harness.StartAsync().ConfigureAwait(false);
await harness.WaitForReadAsync(10).ConfigureAwait(false);
// the loop validated in its first cycle and reused the verdict in cycles 1..8.
Assert.That(harness.ValidationsBeforeRead(10), Is.EqualTo(1));
Assert.That(harness.QueuedStatus(9), Is.EqualTo(StatusCodes.Good));
}
///
/// A permission revoked on the sampled node stops the data changes in the cycle in which
/// the RolePermissions of the node changed: the item receives BadUserAccessDenied.
///
[Test]
public async Task RevokedNodePermissionStopsDataChangesInTheSameCycleAsync()
{
using var harness = new Harness();
var node = new BaseDataVariableState(null);
harness.OnRead(5, () =>
{
harness.Denied = true;
node.RolePermissions =
[
new RolePermissionType { RoleId = ObjectIds.WellKnownRole_Operator, Permissions = 0 }
];
});
await harness.StartAsync().ConfigureAwait(false);
await harness.WaitForReadAsync(7).ConfigureAwait(false);
Assert.That(harness.QueuedStatus(4), Is.EqualTo(StatusCodes.Good));
Assert.That(harness.QueuedStatus(5), Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(harness.QueuedStatus(6), Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
///
/// A change of the default permissions of a namespace revalidates every item.
///
[Test]
public async Task NamespaceDefaultPermissionChangeRevalidatesAsync()
{
using var harness = new Harness();
harness.OnRead(5, () =>
{
harness.Denied = true;
harness.ConfigurationNodeManager.Raise(
m => m.DefaultPermissionsChanged += null, EventArgs.Empty);
});
await harness.StartAsync().ConfigureAwait(false);
await harness.WaitForReadAsync(7).ConfigureAwait(false);
Assert.That(harness.QueuedStatus(4), Is.EqualTo(StatusCodes.Good));
Assert.That(harness.QueuedStatus(5), Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
///
/// A new effective identity of the Session, as after ActivateSession with another user or
/// after a role change refreshed the granted roles, is validated in the next cycle.
///
[Test]
public async Task SessionIdentityChangeRevalidatesInTheNextCycleAsync()
{
using var harness = new Harness();
harness.OnRead(5, () =>
{
harness.Denied = true;
harness.Identity = new UserIdentity();
});
await harness.StartAsync().ConfigureAwait(false);
await harness.WaitForReadAsync(8).ConfigureAwait(false);
// the context of cycle 5 was created before the identity changed.
Assert.That(harness.QueuedStatus(6), Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(harness.QueuedStatus(7), Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
///
/// Without any change signal, an item is still revalidated once per second.
///
[Test]
public async Task VerdictIsRevalidatedPeriodicallyAsync()
{
using var harness = new Harness();
await harness.StartAsync().ConfigureAwait(false);
// 1000 ms / 50 ms: cycle 0 and cycle 20 validate.
await harness.WaitForReadAsync(22).ConfigureAwait(false);
Assert.That(harness.ValidationsBeforeRead(22), Is.GreaterThanOrEqualTo(2));
Assert.That(harness.ValidationsBeforeRead(22), Is.LessThan(21));
}
///
/// A disabled item is neither read nor validated, and is sampled again once it is enabled.
///
[Test]
public async Task DisabledItemIsNotReadAsync()
{
using var harness = new Harness();
harness.OnRead(3, () => harness.Mode = MonitoringMode.Disabled);
await harness.StartAsync().ConfigureAwait(false);
await harness.WaitForReadAsync(3).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromMilliseconds(5 * kInterval)).ConfigureAwait(false);
Assert.That(harness.ReadCount, Is.EqualTo(4), "a disabled item is not read");
harness.Mode = MonitoringMode.Reporting;
await harness.WaitForReadAsync(5).ConfigureAwait(false);
Assert.That(harness.QueuedStatus(5), Is.EqualTo(StatusCodes.Good));
}
///
/// A Read that throws is logged and skipped; the loop keeps sampling in the next cycle.
///
[Test]
public async Task FailedReadDoesNotStopTheLoopAsync()
{
using var harness = new Harness();
harness.OnRead(3, () => throw new InvalidOperationException("read failed"));
await harness.StartAsync().ConfigureAwait(false);
await harness.WaitForReadAsync(5).ConfigureAwait(false);
Assert.That(harness.QueuedStatus(3), Is.Null);
Assert.That(harness.QueuedStatus(4), Is.EqualTo(StatusCodes.Good));
Assert.That(harness.QueuedStatus(5), Is.EqualTo(StatusCodes.Good));
}
///
/// Items of one group that TransferSubscriptions moved to different Sessions are read and
/// permission-checked with the context of their own Session in every cycle.
///
[Test]
public async Task ItemsOfDifferentSessionsAreSampledWithTheirOwnContextAsync()
{
Mock server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
ISession first = CreateSession(1);
ISession second = CreateSession(2);
var validatedSessions = new List();
var validatedLock = new Lock();
var nodeManager = new Mock();
nodeManager.Setup(m => m.ReadAsync(
It.IsAny(),
It.IsAny(),
It.IsAny>(),
It.IsAny>(),
It.IsAny>(),
It.IsAny()))
.Returns, IList,
IList, CancellationToken>(
(_, _, nodesToRead, values, _, _) =>
{
for (int ii = 0; ii < nodesToRead.Count; ii++)
{
nodesToRead[ii].Processed = true;
values[ii] = new DataValue(new Variant(ii));
}
return default;
});
nodeManager.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Returns((context, _, _, _) =>
{
lock (validatedLock)
{
validatedSessions.Add(context.Session);
}
return new ValueTask(ServiceResult.Good);
});
using var context = new OperationContext(
new RequestHeader(), null!, RequestType.CreateMonitoredItems, RequestLifetime.None, first);
using var group = new SamplingGroup(
server.Object,
nodeManager.Object,
[new SamplingRateGroup((int)kInterval, (int)kInterval, 4)],
context,
kInterval);
var firstQueued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var secondQueued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Mock firstItem = CreateItem(1, first, firstQueued);
Mock secondItem = CreateItem(2, second, secondQueued);
Assert.That(group.StartMonitoring(context, firstItem.Object, null, initialValueQueued: true), Is.True);
Assert.That(group.StartMonitoring(context, secondItem.Object, null, initialValueQueued: true), Is.True);
Assert.That(group.ApplyChanges(), Is.False);
await Task.WhenAll(firstQueued.Task, secondQueued.Task)
.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
lock (validatedLock)
{
Assert.That(validatedSessions, Does.Contain(first));
Assert.That(validatedSessions, Does.Contain(second));
}
}
static ISession CreateSession(uint id)
{
var session = new Mock();
session.SetupGet(s => s.Id).Returns(new NodeId(id));
session.SetupGet(s => s.EffectiveIdentity).Returns(new UserIdentity());
session.SetupGet(s => s.PreferredLocales).Returns([]);
return session.Object;
}
static Mock CreateItem(
uint id,
ISession session,
TaskCompletionSource queued)
{
var item = new Mock();
item.SetupGet(m => m.Id).Returns(id);
item.SetupGet(m => m.MonitoredItemType).Returns(MonitoredItemTypeMask.DataChange);
item.SetupGet(m => m.MonitoringMode).Returns(MonitoringMode.Reporting);
item.SetupGet(m => m.SamplingInterval).Returns(kInterval);
item.SetupGet(m => m.Session).Returns(session);
item.Setup(m => m.GetReadValueId()).Returns(() => new ReadValueId
{
NodeId = new NodeId(id, 1),
AttributeId = Attributes.Value
});
item.Setup(m => m.QueueValue(in It.Ref.IsAny, It.IsAny()))
.Callback(() => queued.TrySetResult(true));
return item;
}
}
///
/// Drives a sampling group with one item and records, per Read, the queued value, the
/// number of permission validations so far and whether the item was marked processed.
///
private sealed class Harness : IDisposable
{
public Harness()
{
m_server = DeterministicServerMock.Create(out m_queueFactory);
m_server.SetupGet(s => s.ConfigurationNodeManager).Returns(ConfigurationNodeManager.Object);
var session = new Mock();
session.SetupGet(s => s.Id).Returns(new NodeId(1));
session.SetupGet(s => s.EffectiveIdentity).Returns(() => Identity);
session.SetupGet(s => s.PreferredLocales).Returns([]);
m_session = session.Object;
m_nodeManager.Setup(m => m.ReadAsync(
It.IsAny(),
It.IsAny(),
It.IsAny>(),
It.IsAny>(),
It.IsAny>(),
It.IsAny()))
.Returns, IList,
IList, CancellationToken>(
(_, _, nodesToRead, values, _, _) =>
{
int read;
lock (m_recordsLock)
{
read = m_records.Count;
m_records.Add(new Record
{
ValidationsBefore = Volatile.Read(ref m_validations)
});
}
if (m_onRead.TryGetValue(read, out Action? action))
{
action();
}
if (!nodesToRead[0].Processed)
{
nodesToRead[0].Processed = true;
values[0] = new DataValue(new Variant(read));
}
Volatile.Write(ref m_reads, read + 1);
return default;
});
m_nodeManager.Setup(m => m.ValidateRolePermissionsAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Returns(() =>
{
Interlocked.Increment(ref m_validations);
return new ValueTask(
Denied ? new ServiceResult(StatusCodes.BadUserAccessDenied) : ServiceResult.Good);
});
m_item.SetupGet(m => m.Id).Returns(1);
m_item.SetupGet(m => m.MonitoredItemType).Returns(MonitoredItemTypeMask.DataChange);
m_item.SetupGet(m => m.MonitoringMode).Returns(() => Mode);
m_item.SetupGet(m => m.SamplingInterval).Returns(kInterval);
m_item.SetupGet(m => m.Session).Returns(m_session);
m_item.SetupGet(m => m.ManagerHandle).Returns(m_handle);
m_item.Setup(m => m.GetReadValueId()).Returns(() => new ReadValueId
{
NodeId = new NodeId(1, 1),
AttributeId = Attributes.Value
});
m_item.Setup(m => m.QueueValue(in It.Ref.IsAny, It.IsAny()))
.Callback(new QueueValueCallback((in DataValue value, ServiceResult _) =>
{
lock (m_recordsLock)
{
m_records[^1].Queued = value.StatusCode;
}
}));
}
public Mock ConfigurationNodeManager { get; } = new();
public bool Denied
{
get => Volatile.Read(ref m_denied);
set => Volatile.Write(ref m_denied, value);
}
public IUserIdentity Identity { get; set; } = new UserIdentity();
public MonitoringMode Mode { get; set; } = MonitoringMode.Reporting;
///
/// Runs an action inside the Read of the given cycle, before its values are set.
///
public void OnRead(int cycle, Action action)
{
m_onRead[cycle] = action;
}
public Task StartAsync()
{
m_context = new OperationContext(
new RequestHeader(), null!, RequestType.CreateMonitoredItems, RequestLifetime.None, m_session);
m_group = new SamplingGroup(
m_server.Object,
m_nodeManager.Object,
[new SamplingRateGroup((int)kInterval, (int)kInterval, 4)],
m_context,
kInterval);
// the initial value counts as queued, so every Read is a cycle of the loop.
Assert.That(m_group.StartMonitoring(m_context, m_item.Object, null, initialValueQueued: true), Is.True);
Assert.That(m_group.ApplyChanges(), Is.False);
return Task.CompletedTask;
}
///
/// Waits until the given number of cycles completed their Read and queued their value.
///
public async Task WaitForReadAsync(int reads)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
while (Volatile.Read(ref m_reads) <= reads)
{
await Task.Delay(10, timeout.Token).ConfigureAwait(false);
}
}
public int ValidationsBeforeRead(int cycle)
{
lock (m_recordsLock)
{
return m_records[cycle].ValidationsBefore;
}
}
public StatusCode? QueuedStatus(int cycle)
{
lock (m_recordsLock)
{
return m_records[cycle].Queued;
}
}
public int ReadCount => Volatile.Read(ref m_reads);
public void Dispose()
{
m_group?.Dispose();
m_context?.Dispose();
m_queueFactory.Dispose();
}
private delegate void QueueValueCallback(in DataValue value, ServiceResult error);
private sealed class Record
{
public int ValidationsBefore { get; set; }
public StatusCode? Queued { get; set; }
}
private readonly Mock m_server;
private readonly MonitoredItemQueueFactory m_queueFactory;
private readonly ISession m_session;
private readonly Mock m_nodeManager = new();
private readonly Mock m_item = new();
private readonly object m_handle = new();
private readonly List m_records = [];
private readonly Lock m_recordsLock = new();
private readonly Dictionary m_onRead = [];
private OperationContext? m_context;
private SamplingGroup? m_group;
private int m_validations;
private int m_reads;
private bool m_denied;
}
}
}