Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs
T
Joseph Doherty 489664d105 fix(acl): folder event verdict invalidation reaches orphaned items, decides on the live instance, skips ACL-off; alarm-bearing reference map (#826 review)
1. Orphaned subscriptions (session closed without deleting subs) have Session == null, so the public
   MonitoredNode2.InvalidatePermissionCacheForSession never reached their items: a cached allow survived a
   deploy, the moved alarm's events were queued and delivered on TransferSubscriptions. Each notifier's
   cache is now cleared whole through the SDK's private MonitoredNode2.InvalidatePermissionCaches
   (reflection, guard-tested; fallback = per-session drop + Remove/Add of session-less items).
2. The verdict is decided on the condition LIVE under the event's NodeId, not the event's own instance:
   the SDK key has no instance identity, so a replaced instance's late event cached a deny its successor
   inherited until the next invalidation.
3. Invalidation is skipped while enforcement is off (forced across an on/off authorizer transition) and is
   one O(cache) clear per node instead of a whole-cache scan per item.
4. TagReferencingEquipment carries alarm-bearing raw tags only (TagConfigIntent.Alarm); the snapshot adopts
   an OrdinalIgnoreCase set instead of copying it again.

Tests (each fails with its fix reverted): FolderEventVerdictInvalidationTests (orphan + deploy + transfer;
replaced instance's late event; ACL-off skip; SDK private-method guard), DeploymentArtifactScopePathTests
(alarm-bearing projection).
2026-11-11 02:25:24 -05:00

189 lines
10 KiB
C#

using Opc.Ua;
using Opc.Ua.Client;
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.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Review of Gitea #826: the SDK memoizes each folder event item's per-event verdict per
/// <c>(item, event type, SourceNode)</c>, so the per-event decision is only as fresh as its invalidation. These pin
/// the three ways it went stale or cost too much: an ORPHANED subscription (no session to invalidate by), a stale
/// DENY left by a replaced instance's late event, and invalidation running with enforcement off.
/// </summary>
public sealed class FolderEventVerdictInvalidationTests
{
private const string EqA = ServerEventRig.EqA;
private const string EqB = ServerEventRig.EqB;
/// <summary>
/// A subscription orphaned by its session (closed without deleting its subscriptions) keeps its items on the
/// folder fan-out with NO session. A deployment that drops EqA's reference must still reach those items: the
/// native alarm's events raised while orphaned are not queued to the EqA item, so a TransferSubscriptions to a
/// new session delivers them only through EqB (positive control on the same subscription and event).
/// </summary>
[Fact]
public async Task An_orphaned_folder_item_does_not_keep_a_stale_allow_across_a_deploy_and_a_transfer()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Folder826Orphan", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
NodeAcl[] grants = [KeyEquipmentGrant(EqA), KeyEquipmentGrant(EqB)];
var state = rig.Enforce(true, [ServerEventRig.Key()], grants);
using var first = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(first, events, ct);
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqA));
var onB = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqB));
await subscription.ApplyChangesAsync(ct);
onA.Status.Error.ShouldBeNull();
// Seed the SDK's cache with an ALLOW for the EqA item.
rig.Project(native: true, State("t1"));
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "t1") && events.Has(onB, "t1"));
// Orphan the subscription: a raw CloseSession with deleteSubscriptions=false, so the subscription and its items
// stay on the server with no session. The raw service leaves the client session OBJECT alive (Session.CloseAsync
// would dispose it), as a reconnecting client's is, so the high-level transfer can take the subscription over.
var orphaned = new SubscriptionCollection(first.Subscriptions);
await ((ISessionClient)first).CloseSessionAsync(null, deleteSubscriptions: false, ct);
// The deployment drops EqA's reference; the alarm reports while the subscription has no session.
Deploy(state, "rev-2", grants, EqB);
rig.Project(native: true, State("t2"));
using var second = await rig.ConnectAsync(anonymous: false, ct);
(await second.TransferSubscriptionsAsync(orphaned, sendInitialValues: false, ct)).ShouldBeTrue();
rig.Project(native: true, State("t3"));
await RecordedEvents.WaitUntilAsync(() => events.Has(onB, "t2") && events.Has(onB, "t3"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
events.Has(onA, "t2").ShouldBeFalse("an event raised while orphaned must not be queued on a stale allow");
events.Has(onA, "t3").ShouldBeFalse();
await second.CloseAsync(ct);
}
/// <summary>
/// A surgical replace of a scripted condition under the SAME NodeId (remove + re-materialise, the folder item
/// survives), with the old instance's last event still waiting in the SDK's per-folder queue when the apply's
/// invalidation runs. That late event must not leave a cached DENY the new instance inherits: the new
/// instance's events keep reaching the folder subscriber.
/// </summary>
[Fact]
public async Task A_replaced_instances_late_event_does_not_leave_a_cached_deny_for_the_new_instance()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Folder826Replace", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseScripted();
rig.MaterialiseNative(); // also wired into EqA: its event is the deterministic blocker below
rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
using var session = await rig.ConnectAsync(anonymous: true, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
await subscription.ApplyChangesAsync(ct);
onA.Status.Error.ShouldBeNull();
rig.Project(native: false, State("s1"));
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s1"));
// Holding the node-manager lock parks EqA's SDK delivery task: the native event "n1" is queued first and its
// verdict (a cache miss after the first invalidation) needs the lock in the inherited role check — its node is
// never removed, so that check always takes it. The OLD scripted instance's "s2", queued behind it on the same
// single-reader queue, is therefore decided only after the replace and the apply's invalidation.
lock (rig.Nm.Lock)
{
rig.Nm.CompleteAddressSpaceApply(); // forget the cached allows, so both verdicts must be computed
rig.Project(native: true, State("n1"));
rig.Project(native: false, State("s2"));
((ISurgicalAddressSpaceSink)rig.Sink)
.RemoveAlarmConditionNode(ServerEventRig.ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue();
rig.MaterialiseScripted();
rig.Nm.CompleteAddressSpaceApply();
}
rig.Project(native: false, State("s3"));
// The new instance's event arrives (a cached deny would withhold it until the next invalidation), and so does
// the old instance's late one — decided on the live instance, exactly as the live one's would be.
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s3") && events.Has(onA, "s2"));
await session.CloseAsync(ct);
}
/// <summary>
/// Review of #826 (perf): with enforcement OFF the folder verdicts are the SDK's own role check, so nothing
/// invalidates them — not the revalidation tick, not a deploy, not the end of an apply. Positive control: the
/// same calls invalidate once enforcement is on.
/// </summary>
[Fact]
public async Task Folder_verdicts_are_not_invalidated_while_enforcement_is_off()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Folder826AclOff", encrypted: false, ct);
rig.MaterialiseNative();
var state = rig.Enforce(false, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
using var session = await rig.ConnectAsync(anonymous: true, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
await subscription.ApplyChangesAsync(ct);
var before = rig.Nm.FolderEventVerdictInvalidations;
rig.Nm.RevalidateMonitoredItems();
rig.Nm.CompleteAddressSpaceApply();
state.Apply(ServerEventRig.Cluster, "rev-off-2", [], [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)],
ServerEventRig.ScopePaths());
rig.Nm.FolderEventVerdictInvalidations.ShouldBe(before, "enforcement off: nothing to invalidate");
rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
var enforced = rig.Nm.FolderEventVerdictInvalidations;
enforced.ShouldBeGreaterThan(before, "switching enforcement on invalidates the verdicts decided while off");
rig.Nm.CompleteAddressSpaceApply();
rig.Nm.FolderEventVerdictInvalidations.ShouldBeGreaterThan(enforced);
await session.CloseAsync(ct);
}
/// <summary>
/// The node-wide invalidation rests on a PRIVATE method of the pinned SDK (<c>MonitoredNode2.InvalidatePermissionCaches</c>).
/// If an SDK upgrade drops it the node manager silently falls back to the weaker supported route — this fails
/// first, so the upgrade is looked at.
/// </summary>
[Fact]
public void The_sdks_node_wide_permission_cache_invalidation_is_still_resolvable()
{
OtOpcUaNodeManager.InvalidateNodePermissionCaches.ShouldNotBeNull(
"MonitoredNode2.InvalidatePermissionCaches() is gone — re-check InvalidateFolderEventVerdicts against the new SDK");
}
private static AlarmConditionSnapshot State(string message) =>
new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message);
private static void Deploy(DataPlaneAuthorityState state, string revision, NodeAcl[] grants, params string[] referencing) =>
state.Apply(
ServerEventRig.Cluster, revision, [ServerEventRig.Key()], grants, ServerEventRig.ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(),
new Dictionary<string, IReadOnlySet<string>> { [ServerEventRig.RawAlarmPath] = referencing.ToHashSet() });
private static NodeAcl KeyEquipmentGrant(string equipment) => new()
{
NodeAclId = $"acl-key-{equipment}",
ClusterId = ServerEventRig.Cluster,
Principal = ServerEventRig.KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = $"eq-{equipment}",
PermissionFlags = NodePermissions.ReadOnly,
};
}