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(); } }