8e1e73f93c
Event items on an equipment / raw device folder were fed by the SDK's MonitoredNode2 fan-out with no ACL decision, so after a deploy moved a scripted alarm (or dropped the reference projecting a native alarm into an equipment) the OLD folder's subscribers kept receiving it - EventId included - until the rebuild landed, indefinitely after a failed apply. - OtOpcUaNodeManager re-implements INodeManager3 so ValidateEventRolePermissions (the SDK's per-event, per-item hook; base not virtual) adds IsNotifierEventVisibleTo: live condition instance, notifier still a legitimate notifier of it, and the #586/#808 Server-object rule (IsConditionVisibleTo over ConditionVisibilityScopes). - The SDK memoizes that verdict per (item, event type, SourceNode): InvalidateFolderEventVerdicts runs on the authority/authorizer setters, RevalidateMonitoredItems (deploy + timer) and CompleteAddressSpaceApply. - ConditionRefresh on direct items goes through RefreshDirectItems (base loop + the same decision). - New generation field TagReferencingEquipment (RawPath -> EquipmentIds, from the artifact's UnsTagReferences via AclScopePathResolver.ResolveTagRawPath): a native alarm's wired equipment folder only counts (folder delivery AND Server-object scopes) while the current generation still references it. - Secondary #808 edge: UNS variables wired UNS->Raw Organizes by AddReference are tracked and always placed as reference variables (an Area named like its equipment's id no longer narrows them). Tests: UnsFolderScopeWireTests (moved alarm on a folder item + ConditionRefresh + rebuild; area named like equipment id), NativeAlarmFolderEventAclWireTests (dropped reference: folder + Server object + refresh + restore), DeploymentArtifactScopePathTests (projection). Rig/event tests now supply tag references.
118 lines
6.4 KiB
C#
118 lines
6.4 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>
|
|
/// Gitea #826, native variant, over the wire: a native alarm materialises once at its raw tag and is wired as an
|
|
/// extra event notifier into every referencing equipment folder. A deployment that drops one equipment's reference
|
|
/// installs its generation BEFORE the rebuild that unwires the folder (and an apply that fails keeps the old
|
|
/// wiring), so the folder's subscribers used to keep receiving the alarm — EventId included — through the SDK's
|
|
/// per-folder fan-out, which no ACL decision reached, and through the Server object, whose scopes admitted every
|
|
/// wired folder. Both now ask the CURRENT generation whether it still projects the raw tag into that equipment.
|
|
/// </summary>
|
|
public sealed class NativeAlarmFolderEventAclWireTests
|
|
{
|
|
private const string EqA = ServerEventRig.EqA;
|
|
private const string EqB = ServerEventRig.EqB;
|
|
|
|
/// <summary>
|
|
/// Anonymous holds EqA, the key holds EqB, the alarm is wired into both. The generation drops EqA's reference
|
|
/// with the wiring still in place: EqA's folder item and the anonymous Server-object item stop receiving the
|
|
/// alarm (live AND ConditionRefresh), while the key's EqB folder and Server-object items receive the same events
|
|
/// (positive control). Restoring the reference restores EqA's feed with no client action — the SDK's memoized
|
|
/// per-event verdict is invalidated by the generation change.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_native_alarm_stops_reaching_a_folder_whose_reference_the_generation_dropped()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await ServerEventRig.StartAsync("Folder826Native", encrypted: false, ct);
|
|
rig.KeyRoles = [];
|
|
rig.MaterialiseNative();
|
|
NodeAcl[] grants = [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly), KeyEquipmentGrant(EqB)];
|
|
var state = rig.Enforce(true, [ServerEventRig.Key()], grants);
|
|
|
|
using var anonymous = await rig.ConnectAsync(anonymous: true, ct);
|
|
using var keyed = await rig.ConnectAsync(anonymous: false, ct);
|
|
|
|
var anonEvents = new RecordedEvents();
|
|
var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct);
|
|
var onA = RecordedEvents.AddEventItem(anonSub, ServerEventRig.UnsNodeId(anonymous, EqA));
|
|
var anonServer = RecordedEvents.AddEventItem(anonSub, ObjectIds.Server);
|
|
await anonSub.ApplyChangesAsync(ct);
|
|
onA.Status.Error.ShouldBeNull();
|
|
|
|
var keyEvents = new RecordedEvents();
|
|
var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct);
|
|
var onB = RecordedEvents.AddEventItem(keySub, ServerEventRig.UnsNodeId(keyed, EqB));
|
|
var keyServer = RecordedEvents.AddEventItem(keySub, ObjectIds.Server);
|
|
await keySub.ApplyChangesAsync(ct);
|
|
onB.Status.Error.ShouldBeNull();
|
|
|
|
// Baseline: every item receives the alarm while both references are deployed.
|
|
rig.Project(native: true, State("t1"));
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
anonEvents.Has(onA, "t1") && anonEvents.Has(anonServer, "t1")
|
|
&& keyEvents.Has(onB, "t1") && keyEvents.Has(keyServer, "t1"));
|
|
|
|
// The next generation drops EqA's reference; the address space still wires the alarm into EqA.
|
|
Deploy(state, "rev-2", grants, EqB);
|
|
|
|
rig.Project(native: true, State("t2"));
|
|
await RecordedEvents.WaitUntilAsync(() => keyEvents.Has(onB, "t2") && keyEvents.Has(keyServer, "t2"));
|
|
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
|
anonEvents.Has(onA, "t2").ShouldBeFalse("the folder the generation dropped must not receive the alarm");
|
|
anonEvents.Has(anonServer, "t2").ShouldBeFalse("nor may that folder's grant admit it through the Server object");
|
|
|
|
// ConditionRefresh on the stale folder respects the same rule; the still-referenced folder replays it.
|
|
await RefreshAsync(anonymous, anonSub, ct);
|
|
await RefreshAsync(keyed, keySub, ct);
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
anonEvents.HasType(onA, ObjectTypeIds.RefreshEndEventType)
|
|
&& keyEvents.ConditionEvents(onB, "t2").Count == 2);
|
|
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
|
anonEvents.Has(onA, "t2").ShouldBeFalse("a refresh must not replay the alarm to the dropped folder");
|
|
anonEvents.Has(anonServer, "t2").ShouldBeFalse();
|
|
|
|
// Restoring the reference restores the feed on the next event — no client action, no stale cached deny.
|
|
Deploy(state, "rev-3", grants, EqA, EqB);
|
|
rig.Project(native: true, State("t3"));
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
anonEvents.Has(onA, "t3") && anonEvents.Has(anonServer, "t3") && keyEvents.Has(onB, "t3"));
|
|
|
|
await anonymous.CloseAsync(ct);
|
|
await keyed.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,
|
|
};
|
|
|
|
private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) =>
|
|
await session.CallAsync(
|
|
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
|
|
}
|