Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs
T
Joseph Doherty e5a03e3cce
v2-ci / unit-tests (push) Waiting to run
test(acl): pin the fail-closed rule for non-condition events on folder items (#838 item 3)
A folder event item under enforcement withholds an event that names no live
condition; flag off, the SDK's own type + SourceNode check applies. Positive
controls: the same event reaches the item flag-off and a condition event
reaches it flag-on. Mutation-checked: IsNotifierEventVisibleTo returning true
for a non-condition event fails the test.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 08:54:43 -05:00

238 lines
13 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"));
// Before Gitea #855, holding the node-manager lock parked EqA's SDK delivery task, so the OLD scripted instance's
// "s2" was decided (and its verdict cached) only after the replace and the apply's invalidation. Condition events
// are now decided per item at report time, uncached — "s2" is delivered while its instance is live — and this
// pins that the replace still leaves the new instance's events flowing either way.
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");
}
/// <summary>
/// Gitea #838 item 3: an event on a folder that names no live condition (none is produced today — model-change and
/// rejected-write audits go to the Server object) is WITHHELD under enforcement, fail closed, and delivered with
/// the flag off (the SDK's own type + SourceNode check only). Positive controls: the same event reaches the same
/// item with enforcement off, and a condition event reaches it with enforcement on.
/// </summary>
[Fact]
public async Task A_non_condition_event_on_a_folder_is_withheld_under_enforcement_and_delivered_without_it()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Folder838NonCondition", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
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);
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
await subscription.ApplyChangesAsync(ct);
onA.Status.Error.ShouldBeNull();
var folder = rig.Nm.FindPredefinedNode<FolderState>(ServerEventRig.UnsNodeId(session, EqA)).ShouldNotBeNull();
void ReportPlainEvent(string message)
{
var e = new BaseEventState(null);
lock (rig.Nm.Lock)
{
e.Initialize(rig.Nm.SystemContext, folder, EventSeverity.Low, new LocalizedText(message));
folder.ReportEvent(rig.Nm.SystemContext, e);
}
}
ReportPlainEvent("plain-off");
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "plain-off"));
rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
rig.Project(native: true, State("cond-on"));
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "cond-on"));
ReportPlainEvent("plain-on");
rig.Project(native: true, State("cond-on-2"));
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "cond-on-2"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
events.Has(onA, "plain-on").ShouldBeFalse("a non-condition event on a folder fails closed under enforcement");
await session.CloseAsync(ct);
}
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,
};
}