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(); } } /// /// The shelve methods resolve by the condition's OWN child method ids /// ({condition}/ShelvingState/TimedShelve, …/Unshelve) — the ids a browse of the condition /// returns — whether the call names the condition or its ShelvingState as the object, and each accepted /// call reports its one condition event. /// [Theory] [InlineData(false)] [InlineData(true)] public async Task Shelve_methods_called_by_their_instance_ids_succeed_and_report(bool objectIsShelvingState) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"CondChildMethodIds{objectIsShelvingState}", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods)); rig.MaterialiseScripted(); var current = ConditionMethodEventTests.PreState("TimedShelve", native: false) with { Message = "base" }; ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Project(false, current); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); var condition = ConditionNodeId(session, native: false); var ns = condition.NamespaceIndex; var shelvingState = new NodeId($"{ScriptedAlarmId}/ShelvingState", ns); var target = objectIsShelvingState ? shelvingState : condition; await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/TimedShelve", ns), ct, 60_000d); await rig.DrainProjectionsAsync(); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); events.ConditionEvents(item, "base")[1][RecordedEvents.ShelvingField].Value .ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/Unshelve", ns), ct); await rig.DrainProjectionsAsync(); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3); events.ConditionEvents(item, "base")[2][RecordedEvents.ShelvingField].Value .ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// Pins the #629 trade-off: the shelve methods resolve by the condition's instance ids and by the /// AlarmConditionType_ShelvingState_* ids every client uses (the positive control), but NOT by /// the ShelvedStateMachineType_* ids — those were the methods' SDK declarations, replaced by the /// AlarmConditionType ones when the methods moved off their ns=0 NodeIds. Change this deliberately. /// [Fact] public async Task A_shelve_call_by_the_state_machine_types_method_id_is_not_supported() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("CondChildMethodStateMachineId", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods)); rig.MaterialiseScripted(); var current = ConditionMethodEventTests.PreState("OneShotShelve", native: false); ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s); rig.Project(false, current); using var session = await rig.ConnectAsync(anonymous: false, ct); var condition = ConditionNodeId(session, native: false); var refused = await Should.ThrowAsync( session.CallAsync(condition, MethodIds.ShelvedStateMachineType_OneShotShelve, ct)); refused.StatusCode.ShouldBe(StatusCodes.BadMethodInvalid); // Positive control: the AlarmConditionType declaration id resolves on the same condition. await session.CallAsync(condition, MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, ct); } finally { await rig.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── private const NodePermissions AllMethods = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve | NodePermissions.MethodCall; /// /// 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(); } }