eaac65286d
Review 01 L2. The SDK keys its fan-out lists by NodeId and its component cache hands back the CACHED NodeState for a NodeId it already holds, so a rebuild or a surgical remove+re-add left a NEW item on the same NodeId — from any session — bound to the DEAD node: created Good, fed its stale initial value, never again, until every session had deleted its items. Every removal path (RebuildAddressSpace, RemoveVariableNode, RemoveAlarmConditionNode incl. the condition's instance-scoped children, RemoveEquipmentSubtree) now queues BadNodeIdUnknown to the node's data items, drops the MonitoredNodes entry, releases each item's component cache reference and forgets the _liveItems/_eventItems entries. Items the ACL pass stood down (on no fan-out list) are swept by NodeId. A later client delete of an orphaned item is completed by the DeleteMonitoredItem override / the SubscribeToEvents unsubscribe arm instead of answering BadMonitoredItemIdInvalid or NRE-ing in MonitoredNode2.Remove. The revalidation restore re-resolves the node by NodeId and no longer re-attaches to Handle.Node blindly. Timestamps now come from the injected Clock (UtcNow). Tests: StaleMonitoredNodeWireTests (real SDK, three legs: rebuild, surgical remove+re-add, second session; each keeps the old item alive, proves the new item receives a value written after its creation, the old one gets BadNodeIdUnknown, and its delete succeeds — all three fail with the tombstone neutered), NodeManagerStaleMonitoredNodeTests (stood-down item swept on surgical remove and rebuild; sibling removal leaves it restorable). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
198 lines
10 KiB
C#
198 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Review 01 L2, the half the wire tests (<c>StaleMonitoredNodeWireTests</c>) 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.
|
|
/// </summary>
|
|
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}");
|
|
|
|
/// <summary>A surgical remove tells a stood-down item and drops it from the revalidation map.</summary>
|
|
[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(); }
|
|
}
|
|
|
|
/// <summary>A rebuild sweeps every stood-down item.</summary>
|
|
[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(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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(); }
|
|
}
|
|
|
|
/// <summary>Registers a stub item on <paramref name="rawPath"/> and stands it down through a denying pass.</summary>
|
|
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<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
|
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<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
|
}
|
|
|
|
/// <summary>The minimum of a sampled item the removal path touches: its handle, identity and value queue.</summary>
|
|
private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem
|
|
{
|
|
public List<StatusCode> 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<MonitoredItemNotification> notifications, Queue<DiagnosticInfo> 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();
|
|
}
|
|
}
|