d0052d9fed
Create(assignNodeIds: true) asked CustomNodeManager2.New, whose default returns the
node's current id, so every condition child kept the type's ns=0 instance-declaration id
(or an SDK-generated ns=0 number): translate of a child path returned BadNoMatch and a
Read of a browsed child id was BadNodeIdUnknown or served from the TYPE (UnshelveTime 0
on a condition timed-shelved for a minute), bypassing the data-plane ACL.
OtOpcUaNodeManager.New now derives {conditionId}/{browsePath} in the condition's
namespace, so the ACL scope of a child is its condition's scope plus the path. The
ShelvingState methods adopt their original AlarmConditionType_ShelvingState_* id as
MethodDeclarationId so clients calling them by that id still resolve. Condition removal
and rebuild forget the whole PredefinedNodes subtree so a removed condition's children
stop answering.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
324 lines
16 KiB
C#
324 lines
16 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;
|
|
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(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
/// <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();
|
|
}
|
|
}
|