diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs index 9d3a0d51..e712777e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs @@ -1,3 +1,4 @@ +using System.Collections; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; @@ -347,6 +348,101 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable finally { await host.DisposeAsync(); } } + /// + /// compacts its targetIds with the exact + /// same forward two-pointer algorithm as + /// above, over the appended range [before, targetIds.Count) — but PR 7's review (commit + /// 541a223b) flagged that half of the rewrite had zero coverage anywhere. This pins it the + /// same way: a non-contiguous denial pattern that includes the FIRST appended target (forcing the + /// copy-down path from the very start of the range, not partway through it), plus a sentinel + /// entry sitting BELOW the appended range that must survive an off-by-one walking from index 0. + /// + /// + /// + /// The SDK's own TranslateBrowsePath resolves one + /// against however many of the source's children share its TargetName — ordinarily + /// exactly one, since derives every BrowseName + /// from its (unique) RawPath, so getting six REAL appended targets out of one call would need + /// six duplicate BrowseNames under one folder, which the public address-space surface cannot + /// produce. Instead this drives the production compaction against a + /// : it lets the SDK's one real match through to its single + /// Add call, then substitutes the six ids the test actually wants filtered. What is + /// under test — the compaction that runs AFTER base.TranslateBrowsePath returns — sees + /// exactly the same IList<ExpandedNodeId> either way; only how it got populated + /// differs. + /// + /// + /// The sentinel is a DENIED node (T3) pre-seeded below the appended range, same trick as the + /// Browse test's sentinel: an off-by-one that walks from index 0 rather than from before + /// would re-examine and remove it. + /// + /// + [Fact] + public async Task TranslateBrowsePath_filtering_preserves_survivor_order() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + const string device = "Plant/Modbus/dev2"; + nm.EnsureFolder(device, parentNodeId: null, displayName: "dev2", AddressSpaceRealm.Raw); + + var children = new List(); + for (var ii = 1; ii <= 6; ii++) + { + var rawPath = $"{device}/T{ii}"; + nm.EnsureVariable(rawPath, parentFolderNodeId: device, displayName: rawPath, dataType: "Float", + writable: false, historianTagname: null, realm: AddressSpaceRealm.Raw); + children.Add(nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId); + } + + var deviceId = new NodeId(device, children[0].NamespaceIndex); + + // Grant Browse on the 2nd, 4th and 6th child only — the FIRST target (T1) is denied, and the + // denials (T1, T3, T5) are non-contiguous with the survivors (T2, T4, T6). + nm.Authorizer = TagBrowseAuthorizer($"{device}/T2", $"{device}/T4", $"{device}/T6"); + + var context = new OperationContext( + new RequestHeader(), secureChannelContext: null, RequestType.Browse, Identity()); + + // A relative path that resolves to exactly one REAL match (T1) under the SDK's own path + // resolution — the trigger for the single Add() call FanOutTargetIdList intercepts below. + var relativePath = new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.Organizes, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName($"{device}/T1", children[0].NamespaceIndex), + }; + + // Sentinel: a DENIED node (T3), seeded BELOW the appended range, standing in for a target an + // earlier call already resolved. It must come back untouched. + var sentinel = (ExpandedNodeId)children[2]; + var targetIds = new FanOutTargetIdList([sentinel], children.Select(c => (ExpandedNodeId)c).ToList()); + var unresolvedTargetIds = new List(); + + nm.TranslateBrowsePath( + context, nm.GetManagerHandle(deviceId), relativePath, targetIds, unresolvedTargetIds); + + var granted = new[] { children[1], children[3], children[5] }; + var expected = new List { sentinel } + .Concat(granted.Select(c => (ExpandedNodeId)c)) + .ToList(); + + // Non-vacuity: three of the six appended targets really were dropped. + expected.Count.ShouldBe(4); + + targetIds.ToList().ShouldBe(expected); + + // Belt and braces: each survivor appears exactly once, in original order, and the sentinel is + // intact. + foreach (var id in granted) + targetIds.Count(t => t == (ExpandedNodeId)id).ShouldBe(1, $"{id} survives once"); + targetIds[0].ShouldBe(sentinel); + } + finally { await host.DisposeAsync(); } + } + /// /// Revalidation asks the authorizer once per distinct (session, node), not once per monitored item. /// A client that subscribes to the same tag from several items — one per attribute, or one per @@ -751,6 +847,66 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException(); } + /// + /// Target-id sink for : seeds a + /// pre-existing (pre-before) range untouched, then substitutes a FIXED batch of ids for + /// whatever single real match the SDK's own path resolution adds via its one Add call — + /// that is what turns one real append into the multi-item appended range the compaction under test + /// needs to run over, without forging duplicate BrowseNames anywhere in the address space. + /// + private sealed class FanOutTargetIdList : IList + { + private readonly List _inner; + private readonly IReadOnlyList _fanOut; + private bool _fanned; + + public FanOutTargetIdList(IEnumerable seed, IReadOnlyList fanOut) + { + _inner = new List(seed); + _fanOut = fanOut; + } + + public void Add(ExpandedNodeId item) + { + if (_fanned) + { + _inner.Add(item); + return; + } + + _fanned = true; + _inner.AddRange(_fanOut); + } + + public int Count => _inner.Count; + + public bool IsReadOnly => false; + + public ExpandedNodeId this[int index] + { + get => _inner[index]; + set => _inner[index] = value; + } + + public void Clear() => _inner.Clear(); + + public bool Contains(ExpandedNodeId item) => _inner.Contains(item); + + public void CopyTo(ExpandedNodeId[] array, int arrayIndex) => _inner.CopyTo(array, arrayIndex); + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _inner.GetEnumerator(); + + public int IndexOf(ExpandedNodeId item) => _inner.IndexOf(item); + + public void Insert(int index, ExpandedNodeId item) => _inner.Insert(index, item); + + public bool Remove(ExpandedNodeId item) => _inner.Remove(item); + + public void RemoveAt(int index) => _inner.RemoveAt(index); + } + /// Cleans up the PKI root directory. public void Dispose() {