Merge b626-629 (batch #626+): Part 9 condition children get instance-scoped NodeIds ({ConditionId}/{browse path}); shelve methods keep resolving; subtree forgotten on teardown
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -115,6 +115,40 @@ 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=<raw>;s=Plant/Modbus/dev1/temp_hi/ShelvingState/UnshelveTime`, or
|
||||
`ns=<uns>;s=<ScriptedAlarmId>/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` (a fixed set of those three ids — no address-space lookup, which would take other
|
||||
node managers' locks under ours), so OneShotShelve/TimedShelve/Unshelve by the
|
||||
`AlarmConditionType_ShelvingState_*` id keep working, as do calls by the condition's own instance method
|
||||
ids (`{ConditionId}/ShelvingState/TimedShelve`, on the condition or its ShelvingState). Calling them by the
|
||||
`ShelvedStateMachineType_*` id returns `BadMethodInvalid` — a pinned trade-off.
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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,89 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_ => new AlarmConditionState(parent),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #629 — the <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
|
||||
/// the shelve methods by. <see cref="New"/> records them as the methods' <c>MethodDeclarationId</c>
|
||||
/// (the SDK's own is the <c>ShelvedStateMachineType</c>'s), so calls by these ids keep resolving after
|
||||
/// the methods move to instance-scoped NodeIds. Calls by the <c>ShelvedStateMachineType_*</c> ids are
|
||||
/// not supported (pinned by <c>ConditionChildNodeIdTests</c>).
|
||||
/// </summary>
|
||||
private static readonly HashSet<NodeId> ShelvingMethodDeclarationIds =
|
||||
[
|
||||
MethodIds.AlarmConditionType_ShelvingState_TimedShelve,
|
||||
MethodIds.AlarmConditionType_ShelvingState_OneShotShelve,
|
||||
MethodIds.AlarmConditionType_ShelvingState_Unshelve,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #629 — the <see cref="INodeIdFactory"/> hook <c>NodeState.Create(…, assignNodeIds: true)</c> 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
|
||||
/// <see cref="MaterialiseAlarmCondition"/> passes to <c>Create</c>) keeps it; a child is given
|
||||
/// <c>{parent id}/{BrowseName}</c> in its parent's namespace.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The SDK default (<c>CustomNodeManager2.New</c>) returns the node's CURRENT id, which for a child is
|
||||
/// the condition type's instance-declaration id (<c>i=9189</c> for <c>ShelvingState/UnshelveTime</c>) or
|
||||
/// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a
|
||||
/// TranslateBrowsePathsToNodeIds of any child path returned <c>BadNoMatch</c>, and a Read of a browsed
|
||||
/// child id either hit <c>BadNodeIdUnknown</c> or was served by the core node manager from the TYPE
|
||||
/// (UnshelveTime read <c>0</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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: <c>DataPlaneAuthorizer.BuildScope</c> splits the identifier on <c>/</c>,
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">The system context of the create.</param>
|
||||
/// <param name="node">The node being assigned an id.</param>
|
||||
/// <returns>The id the node is to carry.</returns>
|
||||
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, or every shelve/unshelve call fails BadMethodInvalid. A fixed set, deliberately: a
|
||||
// lookup in the standard address space would take other node managers' locks while we hold Lock.
|
||||
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
|
||||
method.MethodDeclarationId = method.NodeId;
|
||||
|
||||
return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
|
||||
}
|
||||
return node.NodeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #629 — drops a removed condition AND every descendant from <c>PredefinedNodes</c>.
|
||||
/// <c>AddPredefinedNode</c> 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 <c>Lock</c>.
|
||||
/// </summary>
|
||||
/// <param name="node">The node whose subtree is forgotten.</param>
|
||||
private void ForgetPredefinedSubtree(NodeState node)
|
||||
{
|
||||
PredefinedNodes?.Remove(node.NodeId);
|
||||
var children = new List<BaseInstanceState>();
|
||||
node.GetChildren(SystemContext, children);
|
||||
foreach (var child in children) ForgetPredefinedSubtree(child);
|
||||
}
|
||||
|
||||
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
||||
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
||||
/// folder is a subscribable event source linked under the Server object (Server-object delivery itself is
|
||||
@@ -3123,7 +3206,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 +3332,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 +3385,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);
|
||||
}
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Before #629 <c>AlarmConditionState.Create(..., assignNodeIds: true)</c> asked the node manager's
|
||||
/// <c>INodeIdFactory.New</c> for each child's id, and <c>CustomNodeManager2.New</c> returns the node's
|
||||
/// CURRENT id — the type's instance-declaration id (<c>i=9189</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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"],
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two conditions never share a child NodeId, and a browse of a condition lists its children under
|
||||
/// the same instance ids the translate returns.
|
||||
/// </summary>
|
||||
[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<NodeId>();
|
||||
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(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[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(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shelve methods resolve by the condition's OWN child method ids
|
||||
/// (<c>{condition}/ShelvingState/TimedShelve</c>, <c>…/Unshelve</c>) — 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.
|
||||
/// </summary>
|
||||
[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(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the #629 trade-off: the shelve methods resolve by the condition's instance ids and by the
|
||||
/// <c>AlarmConditionType_ShelvingState_*</c> ids every client uses (the positive control), but NOT by
|
||||
/// the <c>ShelvedStateMachineType_*</c> ids — those were the methods' SDK declarations, replaced by the
|
||||
/// AlarmConditionType ones when the methods moved off their ns=0 NodeIds. Change this deliberately.
|
||||
/// </summary>
|
||||
[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<ServiceResultException>(
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IReadOnlyList<NodeId>> TranslateAsync(
|
||||
ISession session, NodeId start, IReadOnlyList<string[]> 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<NodeId>();
|
||||
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<IReadOnlyList<DataValue>> ReadAsync(ISession session, IReadOnlyList<NodeId> 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<IReadOnlyList<NodeId>> 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();
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -369,9 +369,8 @@ public sealed class ConditionMethodEventTests
|
||||
/// Gitea #597: both paths applied the shelve with <c>shelvingTime 0</c>, which the SDK reads as "the
|
||||
/// longest shelve allowed" — <c>MaxTimeShelved</c>, <c>int.MaxValue</c> 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 (<c>i=9189</c> 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 <c>ConditionChildNodeIdTests</c>.)
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_scripted_timed_shelve_reports_the_real_remaining_unshelve_time()
|
||||
|
||||
@@ -74,10 +74,16 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Wires the authorizer over the given keys and grants, enforcing or not.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Wires the authorizer over the given keys, scope paths and grants, enforcing or not.</summary>
|
||||
public DataPlaneAuthorityState Enforce(
|
||||
bool enabled, ApiKey[] keys, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> 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);
|
||||
|
||||
Reference in New Issue
Block a user