489664d105
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).
189 lines
10 KiB
C#
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,
|
|
};
|
|
}
|