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()
{