test(acl): pin TranslateBrowsePath survivor order through the compaction
This commit is contained in:
+156
@@ -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<ExpandedNodeId></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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user