using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// Review 01 L2, the half the wire tests (StaleMonitoredNodeWireTests) cannot reach: an item the
/// ACL revalidation pass has STOOD DOWN is on no SDK fan-out list, so the tombstone a removal runs must find
/// it through the revalidation map. Otherwise it keeps a component-cache reference to the dead node — and
/// the cache hands that node to the next item created on the NodeId.
///
public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
{
private const string Cluster = "SITE-A";
private const string Principal = "svc";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{Guid.NewGuid():N}");
/// A surgical remove tells a stood-down item and drops it from the revalidation map.
[Fact]
public async Task A_surgical_remove_tells_and_forgets_a_stood_down_item()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var (item, nodeId) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
nm.RemoveVariableNode("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw).ShouldBeTrue();
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
nm.LiveItemCount.ShouldBe(0, "the item's node is gone; the map must not keep re-authorizing it");
nm.IsNodeMonitored(nodeId).ShouldBeFalse();
}
finally { await host.DisposeAsync(); }
}
/// A rebuild sweeps every stood-down item.
[Fact]
public async Task A_rebuild_tells_and_forgets_every_stood_down_item()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
nm.RebuildAddressSpace();
nm.CompleteAddressSpaceApply();
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
nm.LiveItemCount.ShouldBe(0);
}
finally { await host.DisposeAsync(); }
}
///
/// Positive control for the sweep: a stood-down item on a SURVIVING node is left alone by a surgical
/// remove of a sibling, and the next permitted pass restores it.
///
[Fact]
public async Task A_stood_down_item_on_a_surviving_node_is_untouched_and_still_restorable()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/Modbus/dev1/Other", null, "Other", "Float", writable: false, realm: AddressSpaceRealm.Raw);
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
nm.RemoveVariableNode("Plant/Modbus/dev1/Other", AddressSpaceRealm.Raw).ShouldBeTrue();
item.Statuses.ShouldNotContain((StatusCode)StatusCodes.BadNodeIdUnknown);
nm.LiveItemCount.ShouldBe(1);
// Grant restored ⇒ the pass re-attaches it (a second stand-down proves the restore happened).
nm.Authorizer = Authorizer(permits: true);
nm.RevalidateMonitoredItems();
nm.Authorizer = Authorizer(permits: false);
nm.RevalidateMonitoredItems();
item.Statuses.Count(s => s == StatusCodes.BadUserAccessDenied).ShouldBe(2);
}
finally { await host.DisposeAsync(); }
}
/// Registers a stub item on and stands it down through a denying pass.
private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath)
{
nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw);
var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
var item = new StubItem(handle, Identity());
nm.TrackLiveMonitoredItem(handle, item);
nm.Authorizer = Authorizer(permits: false);
nm.RevalidateMonitoredItems();
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: the item really was stood down");
await Task.CompletedTask;
return (item, nodeId);
}
private static DataPlaneAuthorizer Authorizer(bool permits)
{
var state = new DataPlaneAuthorityState();
var grants = permits
? new[]
{
new NodeAcl
{
NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal,
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Subscribe,
},
}
: [];
state.Apply(Cluster, "rev-1", [], grants, new Dictionary>());
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
}
private static RoleCarryingUserIdentity Identity() =>
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.StaleTest",
ApplicationUri = $"urn:OtOpcUa.StaleTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
/// The minimum of a sampled item the removal path touches: its handle, identity and value queue.
private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem
{
public List Statuses { get; } = [];
public IUserIdentity EffectiveIdentity => identity;
public object ManagerHandle => handle;
public NodeId NodeId => handle.NodeId;
public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode);
public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false);
public void Dispose() { }
public INodeManager NodeManager => throw new NotSupportedException();
public ISession Session => throw new NotSupportedException();
public uint Id => throw new NotSupportedException();
public uint SubscriptionId => throw new NotSupportedException();
public bool IsDurable => throw new NotSupportedException();
public uint ClientHandle => throw new NotSupportedException();
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public int MonitoredItemType => throw new NotSupportedException();
public bool IsReadyToPublish => throw new NotSupportedException();
public bool IsReadyToTrigger { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public bool IsResendData => throw new NotSupportedException();
public MonitoringMode MonitoringMode => throw new NotSupportedException();
public double SamplingInterval => throw new NotSupportedException();
public DataChangeFilter DataChangeFilter => throw new NotSupportedException();
public uint AttributeId => throw new NotSupportedException();
public NumericRange IndexRange => throw new NotSupportedException();
public QualifiedName DataEncoding => throw new NotSupportedException();
public bool AlwaysReportUpdates { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public DiagnosticsMasks DiagnosticsMasks => throw new NotSupportedException();
public uint QueueSize => throw new NotSupportedException();
public double MinimumSamplingInterval => throw new NotSupportedException();
public int TimeToNextSample => throw new NotSupportedException();
public void SetupResendDataTrigger() => throw new NotSupportedException();
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
public bool Publish(OperationContext context, Queue notifications, Queue diagnostics, uint maxNotificationsPerPublish, ILogger logger) => throw new NotSupportedException();
public void SetSemanticsChanged() => throw new NotSupportedException();
public void SetStructureChanged() => throw new NotSupportedException();
public bool SamplingIntervalExpired() => throw new NotSupportedException();
public ReadValueId GetReadValueId() => throw new NotSupportedException();
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException();
}
}