test(acl): pin TranslateBrowsePath survivor order through the compaction

This commit is contained in:
Joseph Doherty
2026-08-16 04:29:27 -04:00
parent b6275f439c
commit aad7583b79
@@ -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(); }
}
/// <summary>
/// <see cref="OtOpcUaNodeManager.TranslateBrowsePath"/> compacts its <c>targetIds</c> with the exact
/// same forward two-pointer algorithm as <see cref="Browse_filtering_preserves_survivor_order"/>
/// above, over the appended range <c>[before, targetIds.Count)</c> — but PR 7's review (commit
/// <c>541a223b</c>) 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.
/// </summary>
/// <remarks>
/// <para>
/// The SDK's own <c>TranslateBrowsePath</c> resolves one <see cref="RelativePathElement"/>
/// against however many of the source's children share its <c>TargetName</c> — ordinarily
/// exactly one, since <see cref="OtOpcUaNodeManager.EnsureVariable"/> 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
/// <see cref="FanOutTargetIdList"/>: it lets the SDK's one real match through to its single
/// <c>Add</c> call, then substitutes the six ids the test actually wants filtered. What is
/// under test — the compaction that runs AFTER <c>base.TranslateBrowsePath</c> returns — sees
/// exactly the same <c>IList&lt;ExpandedNodeId&gt;</c> either way; only how it got populated
/// differs.
/// </para>
/// <para>
/// 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 <c>before</c>
/// would re-examine and remove it.
/// </para>
/// </remarks>
[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<NodeId>();
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<NodeId>();
nm.TranslateBrowsePath(
context, nm.GetManagerHandle(deviceId), relativePath, targetIds, unresolvedTargetIds);
var granted = new[] { children[1], children[3], children[5] };
var expected = new List<ExpandedNodeId> { 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(); }
}
/// <summary>
/// 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();
}
/// <summary>
/// Target-id sink for <see cref="TranslateBrowsePath_filtering_preserves_survivor_order"/>: seeds a
/// pre-existing (pre-<c>before</c>) range untouched, then substitutes a FIXED batch of ids for
/// whatever single real match the SDK's own path resolution adds via its one <c>Add</c> 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.
/// </summary>
private sealed class FanOutTargetIdList : IList<ExpandedNodeId>
{
private readonly List<ExpandedNodeId> _inner;
private readonly IReadOnlyList<ExpandedNodeId> _fanOut;
private bool _fanned;
public FanOutTargetIdList(IEnumerable<ExpandedNodeId> seed, IReadOnlyList<ExpandedNodeId> fanOut)
{
_inner = new List<ExpandedNodeId>(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<ExpandedNodeId> 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);
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{