diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index dca77b43..85085bc5 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -115,6 +115,38 @@ that `ConditionClassId` / `ConditionClassName` do too. The two class fields are > a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the > live-path fix above does not change the history path.) +### Condition child nodes are instance-scoped (#629) + +Every child of a materialised condition — `ActiveState`, `ActiveState/Id`, `AckedState/Id`, +`ShelvingState/UnshelveTime`, `Message`, `Severity`, `Retain`, the method nodes, … — carries the NodeId +**`{ConditionId}/{browse path}`** in the condition's own namespace: e.g. +`ns=;s=Plant/Modbus/dev1/temp_hi/ShelvingState/UnshelveTime`, or +`ns=;s=/ActiveState/Id`. So `TranslateBrowsePathsToNodeIds` from the condition +resolves, and a direct Read or data-change subscription on a child returns **this condition's** value. + +Before #629 the children kept ns=0 ids: `Create(…, assignNodeIds: true)` asks the node manager's +`INodeIdFactory.New`, and the SDK default returns the node's current id — the condition type's +instance-declaration id (`i=9189` for UnshelveTime) or an SDK-generated ns=0 number. Every translate of +a child path returned `BadNoMatch`; a Read of a browsed child id was either `BadNodeIdUnknown` or served +from the **type** (UnshelveTime read `0` on a condition timed-shelved for a minute). `OtOpcUaNodeManager.New` +now derives the id. Event delivery was never affected (events copy the instance's fields; clients select +them by type + browse path), and neither were the Part 9 methods — calling them by the type's method id +(`MethodIds.AcknowledgeableConditionType_Acknowledge`, …) still works, because the SDK matches a method on +its declaration id. ⚠️ The ShelvingState methods needed help here: their SDK declaration id is the +`ShelvedStateMachineType`'s (`i=2948`…), and they were only ever found by the `AlarmConditionType_ShelvingState_*` +id (`i=9212`…) because that WAS their NodeId. `New` adopts that original id as the method's +`MethodDeclarationId` (only when it is a real standard-namespace method node, never an SDK-generated +number), so OneShotShelve/TimedShelve/Unshelve by the `AlarmConditionType_ShelvingState_*` id keep working. +Calling them by the `ShelvedStateMachineType_*` id is not supported. + +- **ACL:** the slash-joined id makes a child's ACL scope its condition's scope plus the browse path, so + with `Security:Acl:Enabled` a child is governed exactly like its condition (a grant covering the + condition covers its children; one that does not, denies them). Before #629 a child was in ns=0 and + bypassed the data-plane gate entirely. +- **Removal:** removing or rebuilding a condition forgets its whole subtree, so a removed condition's + children answer `BadNodeIdUnknown`, not their last value. +- Guarded over the wire by `ConditionChildNodeIdTests`. + ### Condition source-data Quality (#477) `ConditionType.Quality` reports the quality of the condition's source data. It was never assigned, and diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 91cca9b0..057b4f49 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1243,7 +1243,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace. UnwireAlarmNotifiers(conditionKey); existing.Parent?.RemoveChild(existing); - PredefinedNodes?.Remove(existing.NodeId); + ForgetPredefinedSubtree(existing); // The materialise tail raises only the new instance's unit (no full reconcile), so give the // dropped instance's back here rather than leave it tracked until the next full reconcile. if (_allEventsMonitoredConditions.Remove(existing)) @@ -2499,6 +2499,80 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 _ => new AlarmConditionState(parent), }; + /// + /// Gitea #629 — the hook NodeState.Create(…, assignNodeIds: true) calls + /// for the node being created and then, top-down, for every child it built from the type's definition. + /// A node that already carries an id in one of our namespaces (the condition root, whose id + /// passes to Create) keeps it; a child is given + /// {parent id}/{BrowseName} in its parent's namespace. + /// + /// + /// + /// The SDK default (CustomNodeManager2.New) returns the node's CURRENT id, which for a child is + /// the condition type's instance-declaration id (i=9189 for ShelvingState/UnshelveTime) or + /// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a + /// TranslateBrowsePathsToNodeIds of any child path returned BadNoMatch, and a Read of a browsed + /// child id either hit BadNodeIdUnknown or was served by the core node manager from the TYPE + /// (UnshelveTime read 0 on a condition timed-shelved for a minute). Event delivery and the Part 9 + /// methods were unaffected — events copy the instance's fields and the SDK resolves a method by its + /// declaration id. + /// + /// + /// The derived id is deterministic and unique per condition: the parent part is the condition's own + /// id (a RawPath in the raw namespace, a ScriptedAlarmId in the UNS namespace) and sibling BrowseNames + /// are unique. It cannot collide with a raw node — a condition's RawPath is a tag, a leaf, so nothing + /// else is ever authored beneath it — and the namespace keeps the two realms apart. It is also + /// slash-joined on purpose: DataPlaneAuthorizer.BuildScope splits the identifier on /, + /// so a child's scope is its condition's scope plus the browse path, and the permission trie's + /// downward inheritance governs a child exactly as it governs its condition. + /// + /// + /// The system context of the create. + /// The node being assigned an id. + /// The id the node is to carry. + public override NodeId New(ISystemContext context, NodeState node) + { + if (OwnsNamespace(node.NodeId)) return node.NodeId; + if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId } + && OwnsNamespace(parentId) + && !QualifiedName.IsNull(node.BrowseName)) + { + // A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId = + // e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR + // MethodDeclarationId. The condition's own methods (Acknowledge, …) carry the right declaration id + // already, over an SDK-generated ns=0 NodeId. The ShelvingState methods do NOT: their declaration id + // is the ShelvedStateMachineType's (i=2948), and they were found only because their NodeId WAS the + // AlarmConditionType instance declaration every client uses. Adopt that id as the declaration before + // it is replaced — but only when it is a real node of the standard address space, never an + // SDK-generated number — or every shelve/unshelve call fails BadMethodInvalid. + if (node is MethodState method + && method.NodeId is { NamespaceIndex: 0 } declarationId + && declarationId != method.MethodDeclarationId + && FindNodeInAddressSpace(declarationId) is MethodState) + { + method.MethodDeclarationId = declarationId; + } + + return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex); + } + return node.NodeId; + } + + /// + /// Gitea #629 — drops a removed condition AND every descendant from PredefinedNodes. + /// AddPredefinedNode indexes the whole subtree, so removing only the root would leave the + /// condition's (now instance-scoped, hence reachable) children answering a direct Read with the dead + /// condition's last value. MUST be called under Lock. + /// + /// The node whose subtree is forgotten. + private void ForgetPredefinedSubtree(NodeState node) + { + PredefinedNodes?.Remove(node.NodeId); + var children = new List(); + node.GetChildren(SystemContext, children); + foreach (var child in children) ForgetPredefinedSubtree(child); + } + /// Promote to and /// register it as a root notifier (idempotent — guarded by ) so the /// folder is a subscribable event source linked under the Server object (Server-object delivery itself is @@ -3123,7 +3197,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 foreach (var alarm in _alarmConditions.Values) { alarm.Parent?.RemoveChild(alarm); - PredefinedNodes?.Remove(alarm.NodeId); + ForgetPredefinedSubtree(alarm); } // v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally // BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the @@ -3249,7 +3323,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // this they would keep a dangling inverse-notifier entry to the removed condition. UnwireAlarmNotifiers(key); condition.Parent?.RemoveChild(condition); - PredefinedNodes?.Remove(condition.NodeId); + ForgetPredefinedSubtree(condition); _nativeAlarmNodeIds.Remove(key); ReconcileAllEventsMonitoring(); e = BuildNodesRemovedModelChange(alarmNodeId, realm); @@ -3302,7 +3376,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (_alarmConditions.TryRemove(id, out var c)) { c.Parent?.RemoveChild(c); - PredefinedNodes?.Remove(c.NodeId); + ForgetPredefinedSubtree(c); } _nativeAlarmNodeIds.Remove(id); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionChildNodeIdTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionChildNodeIdTests.cs new file mode 100644 index 00000000..d46ddf0e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionChildNodeIdTests.cs @@ -0,0 +1,323 @@ +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; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Gitea #629 over the wire — every child of a materialised Part 9 condition (ActiveState/Id, +/// ShelvingState/UnshelveTime, Message, …) carries an instance-scoped NodeId in the condition's own +/// namespace, so a client that translates a child's browse path and then Reads it gets THIS condition's +/// value — not the condition type's ns=0 instance declaration. +/// +/// +/// +/// Before #629 AlarmConditionState.Create(..., assignNodeIds: true) asked the node manager's +/// INodeIdFactory.New for each child's id, and CustomNodeManager2.New returns the node's +/// CURRENT id — the type's instance-declaration id (i=9189 for UnshelveTime). Every condition's +/// children therefore shared those ns=0 ids, a translate returned them, and a Read of one was served +/// by the core node manager from the TYPE, never reaching the condition. +/// +/// +/// Each read asserts a value only the instance holds (projected Active, unacked, a distinguishing +/// message, a timed shelve's remaining time), so a type-node answer fails it. +/// +/// +public sealed class ConditionChildNodeIdTests +{ + private const NodePermissions BrowseRead = NodePermissions.Browse | NodePermissions.Read; + + private static readonly string[][] ChildPaths = + [ + ["ActiveState", "Id"], + ["AckedState", "Id"], + ["EnabledState", "Id"], + ["Severity"], + ["Message"], + ["Retain"], + ["ShelvingState", "UnshelveTime"], + ["ShelvingState", "CurrentState", "Id"], + ]; + + /// + /// Translating a condition child's browse path returns an instance-scoped NodeId in the condition's + /// namespace (derived from the condition id), and a Read of that NodeId returns the instance's value — + /// for a native (raw) and a scripted (UNS) condition, with ACL enforcement on and off. + /// + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task A_condition_child_resolves_to_an_instance_node_that_reads_the_instance_value(bool native, bool aclOn) + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync($"CondChildIds{native}{aclOn}", encrypted: false, ct); + try + { + rig.Enforce(aclOn, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead)); + rig.MaterialiseNative(); + rig.MaterialiseScripted(); + ProjectDistinguishingStates(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var condition = ConditionNodeId(session, native); + var conditionId = (string)condition.Identifier; + + var resolved = await TranslateAsync(session, condition, ChildPaths, ct); + for (var i = 0; i < ChildPaths.Length; i++) + { + var path = string.Join('/', ChildPaths[i]); + resolved[i].NamespaceIndex.ShouldBe(condition.NamespaceIndex, $"{path} lives in the condition's namespace"); + resolved[i].Identifier.ShouldBe($"{conditionId}/{path}", $"{path} is scoped to the condition instance"); + } + + var values = await ReadAsync(session, resolved, ct); + for (var i = 0; i < values.Count; i++) + StatusCode.IsGood(values[i].StatusCode).ShouldBeTrue($"{string.Join('/', ChildPaths[i])}: {values[i].StatusCode}"); + + var expectedMessage = native ? "native-distinct" : "scripted-distinct"; + values[0].Value.ShouldBe(true, "ActiveState/Id — the instance is active"); + values[1].Value.ShouldBe(false, "AckedState/Id — the instance is unacknowledged"); + values[2].Value.ShouldBe(true, "EnabledState/Id"); + values[3].Value.ShouldBe( + rig.Nm.TryGetAlarmCondition(native ? RawAlarmPath : ScriptedAlarmId, native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns)! + .Severity.Value, + "Severity — the instance's mapped severity"); + ((ushort)values[3].Value).ShouldBeGreaterThan((ushort)0); + (values[4].Value as LocalizedText)?.Text.ShouldBe(expectedMessage, "Message"); + values[5].Value.ShouldBe(true, "Retain — an active unacked condition is retained"); + if (native) + { + ((double)values[6].Value).ShouldBe(0d, "UnshelveTime — a native condition is never shelved"); + values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); + } + else + { + ((double)values[6].Value).ShouldBeInRange(50_000d, 60_000d, "UnshelveTime — the instance's timed shelve"); + values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); + } + } + finally { await rig.DisposeAsync(); } + } + + /// + /// Two conditions never share a child NodeId, and a browse of a condition lists its children under + /// the same instance ids the translate returns. + /// + [Fact] + public async Task Children_of_different_conditions_have_distinct_ids_and_browse_agrees_with_translate() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync("CondChildDistinct", encrypted: false, ct); + try + { + rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead)); + rig.MaterialiseNative(); + rig.MaterialiseScripted(); + rig.Sink.MaterialiseAlarmCondition("scripted-eqa-lo", EqA, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + var conditions = new[] + { + ConditionNodeId(session, native: true), ConditionNodeId(session, native: false), new NodeId("scripted-eqa-lo", ns), + }; + + var all = new List(); + foreach (var c in conditions) all.AddRange(await TranslateAsync(session, c, ChildPaths, ct)); + all.Distinct().Count().ShouldBe(all.Count, "no two conditions share a child NodeId"); + all.ShouldAllBe(id => id.NamespaceIndex != 0, "no child resolves to a type's ns=0 instance declaration"); + + // Browse the scripted condition's ShelvingState: the forward references are its instance children. + var shelving = (await TranslateAsync(session, conditions[1], [["ShelvingState"]], ct))[0]; + shelving.ShouldBe(new NodeId($"{ScriptedAlarmId}/ShelvingState", ns)); + var browsed = await BrowseChildrenAsync(session, shelving, ct); + browsed.ShouldContain(new NodeId($"{ScriptedAlarmId}/ShelvingState/UnshelveTime", ns)); + browsed.ShouldContain(new NodeId($"{ScriptedAlarmId}/ShelvingState/CurrentState", ns)); + browsed.ShouldNotContain(VariableIds.AlarmConditionType_ShelvingState_UnshelveTime); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// With ACL enforcement on, a condition child is governed like its condition: a Read grant on the + /// raw device covers the condition's children (allow), and a grant that does not cover the condition + /// denies them — the child neither falls through to an ungated type node nor to deny-all. + /// + [Fact] + public async Task A_condition_child_read_is_governed_by_the_conditions_scope() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync("CondChildAcl", encrypted: false, ct); + try + { + // The key holds Read on the native condition's raw device only; it holds nothing on the scripted + // condition (whose scope is its ScriptedAlarmId, under no granted path). + var deviceGrant = new NodeAcl + { + NodeAclId = "acl-dev1", + ClusterId = ServerEventRig.Cluster, + Principal = KeyPrincipal, + ScopeKind = NodeAclScopeKind.RawDevice, + ScopeId = "dev-1", + PermissionFlags = BrowseRead, + }; + rig.Enforce( + true, + [Key()], + new Dictionary> + { + [new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] = + [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])], + }, + deviceGrant); + rig.MaterialiseNative(); + rig.MaterialiseScripted(); + ProjectDistinguishingStates(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + + var values = await ReadAsync(session, + [ + new NodeId($"{RawAlarmPath}/Message", rawNs), + new NodeId($"{RawAlarmPath}/ActiveState/Id", rawNs), + new NodeId($"{ScriptedAlarmId}/Message", ns), + new NodeId($"{ScriptedAlarmId}/ActiveState/Id", ns), + ], ct); + + StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue($"granted child read: {values[0].StatusCode}"); + (values[0].Value as LocalizedText)?.Text.ShouldBe("native-distinct"); + values[1].Value.ShouldBe(true); + values[2].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child"); + values[3].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child"); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// Removing a condition removes its children too: once instance-scoped they are reachable by NodeId, + /// so a child left indexed would keep answering a Read with the removed condition's last value. + /// + [Fact] + public async Task A_removed_conditions_children_are_no_longer_readable() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync("CondChildRemove", encrypted: false, ct); + try + { + rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead)); + rig.MaterialiseNative(); + rig.MaterialiseScripted(); + ProjectDistinguishingStates(rig); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + var child = new NodeId($"{ScriptedAlarmId}/Message", ns); + var nested = new NodeId($"{ScriptedAlarmId}/ShelvingState/UnshelveTime", ns); + + // Positive control: readable while the condition exists. + var before = await ReadAsync(session, [child, nested], ct); + (before[0].Value as LocalizedText)?.Text.ShouldBe("scripted-distinct"); + StatusCode.IsGood(before[1].StatusCode).ShouldBeTrue(); + + rig.Nm.RemoveAlarmConditionNode(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue(); + + var after = await ReadAsync(session, [child, nested], ct); + after[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown); + after[1].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown); + + // A rebuild forgets them the same way (the native condition's children, here). + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var rawChild = new NodeId($"{RawAlarmPath}/Message", rawNs); + StatusCode.IsGood((await ReadAsync(session, [rawChild], ct))[0].StatusCode).ShouldBeTrue(); + rig.Nm.RebuildAddressSpace(); + (await ReadAsync(session, [rawChild], ct))[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown); + } + finally { await rig.DisposeAsync(); } + } + + // ───────────────────────────── helpers ───────────────────────────── + + /// + /// Native: active, unacked, "native-distinct". Scripted: active, unacked, "scripted-distinct", timed + /// shelved for 60 s — none of which the condition type's instance declarations can hold. + /// + private static void ProjectDistinguishingStates(ServerEventRig rig) + { + rig.Project(native: true, new AlarmConditionSnapshot( + Active: true, Acknowledged: false, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "native-distinct")); + rig.Project(native: false, new AlarmConditionSnapshot( + Active: true, Acknowledged: false, Confirmed: false, Enabled: true, + Shelving: AlarmShelvingKind.Timed, Severity: 300, Message: "scripted-distinct", + UnshelveAtUtc: DateTime.UtcNow.AddSeconds(60))); + } + + private static async Task> TranslateAsync( + ISession session, NodeId start, IReadOnlyList paths, CancellationToken ct) + { + var browsePaths = new BrowsePathCollection(); + foreach (var path in paths) + { + var relative = new RelativePath(); + foreach (var name in path) + { + relative.Elements.Add(new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName(name), + }); + } + browsePaths.Add(new BrowsePath { StartingNode = start, RelativePath = relative }); + } + + var response = await session.TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct); + var results = new List(); + for (var i = 0; i < response.Results.Count; i++) + { + var r = response.Results[i]; + StatusCode.IsGood(r.StatusCode).ShouldBeTrue($"translate {string.Join('/', paths[i])}: {r.StatusCode}"); + r.Targets.Count.ShouldBe(1, $"translate {string.Join('/', paths[i])} has one target"); + results.Add(ExpandedNodeId.ToNodeId(r.Targets[0].TargetId, session.NamespaceUris)); + } + return results; + } + + private static async Task> ReadAsync(ISession session, IReadOnlyList ids, CancellationToken ct) + { + var request = new ReadValueIdCollection(ids.Select(id => new ReadValueId { NodeId = id, AttributeId = Attributes.Value })); + var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, request, ct); + return response.Results; + } + + private static async Task> BrowseChildrenAsync(ISession session, NodeId node, CancellationToken ct) + { + var request = new BrowseDescriptionCollection + { + new BrowseDescription + { + NodeId = node, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + ResultMask = (uint)BrowseResultMask.All, + }, + }; + var response = await session.BrowseAsync(null, null, 0, request, ct); + return response.Results[0].References + .Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris)) + .ToList(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs index d0ebede2..af18bd20 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs @@ -369,9 +369,8 @@ public sealed class ConditionMethodEventTests /// Gitea #597: both paths applied the shelve with shelvingTime 0, which the SDK reads as "the /// longest shelve allowed" — MaxTimeShelved, int.MaxValue ms — so every timed shelve /// advertised ~24.8 days remaining while the engine expired it on time. Read through the event select - /// clause rather than a Read of the child node: the condition's children carry their type's - /// instance-declaration NodeIds (i=9189 for UnshelveTime), so a direct Read reaches the type, not - /// this condition. + /// clause, which is what an alarm client consumes. (A direct Read of the child also works since Gitea + /// #629 gave condition children instance-scoped NodeIds — see ConditionChildNodeIdTests.) /// [Fact] public async Task A_scripted_timed_shelve_reports_the_real_remaining_unshelve_time() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs index f5322bba..1a620b21 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -74,10 +74,16 @@ internal sealed class ServerEventRig : IAsyncDisposable } /// Wires the authorizer over the given keys and grants, enforcing or not. - public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) + public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) => + Enforce(enabled, keys, ScopePaths(), grants); + + /// Wires the authorizer over the given keys, scope paths and grants, enforcing or not. + public DataPlaneAuthorityState Enforce( + bool enabled, ApiKey[] keys, IReadOnlyDictionary> scopePaths, + params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); - state.Apply(Cluster, "rev-1", keys, grants, ScopePaths()); + state.Apply(Cluster, "rev-1", keys, grants, scopePaths); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);