Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictFreshnessTests.cs
T
Joseph Doherty fa811feb3d
v2-ci / unit-tests (push) Waiting to run
fix(opcua): drop the reflective folder-event verdict invalidation; deny the SDK channel under enforcement (#878)
The SDK memoizes INodeManager3.ValidateEventRolePermissions per (item, event type,
SourceNode). #826 kept it fresh by reflecting into the private
MonitoredNode2.InvalidatePermissionCaches (plus a detach/re-attach fallback). Since #855
condition events never take that channel, and the search for #878 found nothing
production-raised that does except a condition's SDK method audits, which reach it only
with auditing on, SignAndEncrypt and a SecurityAdmin session (nothing grants that role).

Under enforcement the channel verdict is now a flat deny for items on our nodes, so no
cached verdict depends on a grant, and InvalidateFolderEventVerdicts, the reflection,
the fallback, its counter and the guard test are removed. Method audits still reach
Server-object items, decided per event.

Tests: FolderEventVerdictInvalidationTests -> FolderEventVerdictFreshnessTests; new
revoked-grant test (positive control: a still-granted session) and method-audit test
(SecurityAdmin mapped for the test; positive controls: Server-object item, flag off);
the plain-event test runs each mode on a fresh server. Mutation-checked.

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

320 lines
18 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>
/// Folder event items must see a deployment, a grant removal or a moved alarm on the very next event (Gitea #826).
/// The SDK memoizes the verdict of whatever travels its own per-notifier channel per <c>(item, event type,
/// SourceNode)</c>, and #826 kept those verdicts fresh with a reflective call into a private SDK method. Gitea #878
/// removed that: condition events are decided per item, uncached, by the node manager's own delivery (#855), and
/// under enforcement everything that still rides the channel is denied outright, so no cached verdict depends on a
/// grant. These pin the properties that made the invalidation exist — an ORPHANED subscription, a replaced
/// instance's late event, a revoked grant — plus the channel itself (a condition's method audit, a plain event).
/// </summary>
public sealed class FolderEventVerdictFreshnessTests
{
private const NodePermissions All =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
| NodePermissions.MethodCall;
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>
/// <remarks>
/// Since #855 this holds because the decision is per event and uncached, not because a cache is invalidated
/// (#878: it passed with the invalidation disabled). It is the per-item decision this pins: with the
/// live-notifier check skipped in <c>DeliverToNotifierItems</c>, <c>t2</c>/<c>t3</c> reach the EqA item.
/// </remarks>
[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();
// An ALLOW for the EqA item (before #855 the SDK cached it).
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 (#878 removed the invalidation) — "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();
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>
/// The real security property behind the removed invalidation (Gitea #878): a deployment that revokes a session's
/// grant stops every condition event to it on the very next event — at its folder item AND its Server-object item —
/// while a session whose grant stays keeps receiving the same events (positive control).
/// </summary>
/// <remarks>
/// Mutation-checked on #878. The folder leg has two defences, and fails only when both are removed: the
/// revalidation stand-down (#562, the item loses its folder grant and is detached) and the per-event grant check
/// in <c>DeliverToNotifierItems</c>. The Server-object leg fails when <c>OnConditionReportEvent</c> skips its
/// grant check.
/// </remarks>
[Fact]
public async Task A_revoked_grant_stops_condition_events_to_that_session_while_a_granted_session_keeps_them()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Folder878Revoke", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
var anonymousGrant = ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly);
var state = rig.Enforce(true, [ServerEventRig.Key()], KeyEquipmentGrant(EqA), anonymousGrant);
using var revoked = await rig.ConnectAsync(anonymous: false, ct);
var revokedEvents = new RecordedEvents();
var revokedSubscription = await RecordedEvents.SubscribeAsync(revoked, revokedEvents, ct);
var revokedFolder = RecordedEvents.AddEventItem(revokedSubscription, ServerEventRig.UnsNodeId(revoked, EqA));
var revokedServer = RecordedEvents.AddEventItem(revokedSubscription, ObjectIds.Server);
await revokedSubscription.ApplyChangesAsync(ct);
revokedFolder.Status.Error.ShouldBeNull();
using var kept = await rig.ConnectAsync(anonymous: true, ct);
var keptEvents = new RecordedEvents();
var keptSubscription = await RecordedEvents.SubscribeAsync(kept, keptEvents, ct);
var keptFolder = RecordedEvents.AddEventItem(keptSubscription, ServerEventRig.UnsNodeId(kept, EqA));
var keptServer = RecordedEvents.AddEventItem(keptSubscription, ObjectIds.Server);
await keptSubscription.ApplyChangesAsync(ct);
keptFolder.Status.Error.ShouldBeNull();
rig.Project(native: true, State("t1"));
await RecordedEvents.WaitUntilAsync(() =>
revokedEvents.Has(revokedFolder, "t1") && revokedEvents.Has(revokedServer, "t1")
&& keptEvents.Has(keptFolder, "t1") && keptEvents.Has(keptServer, "t1"));
// The key's grant is gone; the anonymous one stays, and both folders still reference the alarm.
Deploy(state, "rev-2", [anonymousGrant], EqA, EqB);
rig.Project(native: true, State("t2"));
await RecordedEvents.WaitUntilAsync(() => keptEvents.Has(keptFolder, "t2") && keptEvents.Has(keptServer, "t2"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
revokedEvents.Has(revokedFolder, "t2").ShouldBeFalse("a revoked grant must stop the folder item's next event");
revokedEvents.Has(revokedServer, "t2").ShouldBeFalse("a revoked grant must stop the Server-object item's next event");
await kept.CloseAsync(ct);
await revoked.CloseAsync(ct);
}
/// <summary>
/// What still rides the SDK's own per-notifier channel — and so its MEMOIZED verdict — is a condition's SDK-raised
/// Part 9 method audit (Gitea #878). It needs auditing on, SignAndEncrypt, and the one role the SDK's type check
/// admits to audit types, <c>SecurityAdmin</c>, which this test maps to every authenticated session (nothing in
/// the product does). With enforcement ON the folder item never receives it — the channel's verdict is a flat
/// deny, so no grant-dependent verdict is cached and nothing needs invalidating. Positive controls: the same
/// audit reaches the same session's Server-object item (decided per event there), and with enforcement OFF it
/// reaches the folder item, so the channel really does carry it.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task A_condition_method_audit_reaches_a_folder_item_only_with_enforcement_off(bool enforced)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Folder878Audit{enforced}", encrypted: true, ct);
rig.SetAuditing(true);
ServiceResult.IsGood(rig.Server.CurrentInstance.RoleManager.AddIdentity(
ObjectIds.WellKnownRole_SecurityAdmin,
new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.AuthenticatedUser }))
.ShouldBeTrue();
rig.Enforce(enforced, [ServerEventRig.Key()], ServerEventRig.ClusterGrant(ServerEventRig.KeyPrincipal, "acl-key", All));
rig.MaterialiseScripted();
var current = State("base");
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var folderItem = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
folderItem.Status.Error.ShouldBeNull();
rig.Project(native: false, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base"));
await ConditionMethodEventTests.CallAsync(
session, ServerEventRig.ConditionNodeId(session, native: false), "AddComment",
events.LatestEventId(folderItem, "base"), ct);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.AuditConditionCommentEventType));
if (!enforced)
{
await RecordedEvents.WaitUntilAsync(() => events.HasType(folderItem, ObjectTypeIds.AuditConditionCommentEventType));
}
else
{
current = current with { Message = "sentinel" };
rig.Project(native: false, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
events.HasType(folderItem, ObjectTypeIds.AuditConditionCommentEventType).ShouldBeFalse(
"under enforcement nothing on the SDK channel reaches a folder item of ours");
}
await session.CloseAsync(ct);
}
/// <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) rides the SDK channel and 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 shape with enforcement off, and a condition event reaches it with enforcement
/// on. Each mode runs on a fresh server: a verdict the SDK cached while enforcement was off would survive a switch
/// on (#878 — the residual documented on <c>INodeManager3.ValidateEventRolePermissions</c>, which no event the
/// product raises can reach).
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task A_non_condition_event_on_a_folder_is_withheld_under_enforcement_and_delivered_without_it(bool enforced)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Folder838NonCondition{enforced}", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
rig.Enforce(enforced, [], 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();
var plain = new BaseEventState(null);
lock (rig.Nm.Lock)
{
plain.Initialize(rig.Nm.SystemContext, folder, EventSeverity.Low, new LocalizedText("plain"));
folder.ReportEvent(rig.Nm.SystemContext, plain);
}
rig.Project(native: true, State("cond"));
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "cond"));
if (enforced)
{
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
events.Has(onA, "plain").ShouldBeFalse("a non-condition event on a folder fails closed under enforcement");
}
else
{
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "plain"));
}
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,
};
}