fa811feb3d
v2-ci / unit-tests (push) Waiting to run
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
320 lines
18 KiB
C#
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,
|
|
};
|
|
}
|