fix(core): resolve Low code-review findings (Core-004,008,009,010,011,012)
- Core-004: add ConfigureAwait(false) to DriverHost.RegisterAsync / UnregisterAsync / DisposeAsync. - Core-008: rewrite the BuildAddressSpaceAsync XML doc to correctly name the caller (OpcUaApplicationHost.PopulateAddressSpaces) that owns the per-driver isolation. - Core-009: snapshot DriverResilienceOptions once per non-idempotent write in CapabilityInvoker.ExecuteWriteAsync. - Core-010: switch DriverResilienceOptions.Resolve to TryGetValue with a diagnostic error message when a tier table is missing a capability. - Core-011: add an optional diagnostic callback to PermissionTrieBuilder so production callers can surface scope-path mismatches. - Core-012: correct the stale WedgeDetector ctor summary and add the Reconnecting row to DriverHealthReport's state matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s
|
||||
/// <c>Descend</c> helper:
|
||||
/// <list type="bullet">
|
||||
/// <item>With a <c>scopePaths</c> lookup the row must land at the correct multi-level
|
||||
/// trie node — a deep <see cref="NodeAclScopeKind.UnsLine"/> grant must be visible
|
||||
/// ONLY when the requested scope walks the same namespace/area/line chain.</item>
|
||||
/// <item>Without a <c>scopePaths</c> entry the row falls back to a direct child of
|
||||
/// the namespace root keyed on the row's <c>ScopeId</c>. The builder must surface
|
||||
/// this fallback (warning callback) so callers know a grant was placed where the
|
||||
/// walker can't reach it for production hierarchies — silently dropping the grant
|
||||
/// is the Core-011 production hazard.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionTrieBuilderTests
|
||||
{
|
||||
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
|
||||
new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
||||
GenerationId = 1,
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = group,
|
||||
ScopeKind = scope,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = ns,
|
||||
UnsAreaId = area,
|
||||
UnsLineId = line,
|
||||
EquipmentId = equip,
|
||||
TagId = tag,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
|
||||
{
|
||||
// Scope path mirrors the production hierarchy: namespace → area → line.
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["line-42"] = new(new[] { "ns", "area-1", "line-42" }),
|
||||
};
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||
|
||||
// Walk through the same chain — the grant must be reachable.
|
||||
var matchOnLine = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area-1", "line-42", "eq-A", "tag-A"),
|
||||
["cn=ops"]);
|
||||
matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");
|
||||
|
||||
// A different line under the same area must not pick up the grant.
|
||||
var matchOnOtherLine = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area-1", "line-99", "eq-A", "tag-A"),
|
||||
["cn=ops"]);
|
||||
matchOnOtherLine.ShouldBeEmpty(
|
||||
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
|
||||
{
|
||||
// Fallback path — deterministic tests pass without a scope-path lookup. The row
|
||||
// is placed as a direct child of the trie root keyed by ScopeId.
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
// Root has one child — "line-42".
|
||||
trie.Root.Children.ShouldContainKey("line-42");
|
||||
var node = trie.Root.Children["line-42"];
|
||||
node.Grants.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-011 regression: when a sub-cluster row's ScopeId is not in the supplied
|
||||
/// <c>scopePaths</c>, the fallback diagnostic callback must fire so the caller can
|
||||
/// surface a warning. Silently dropping the grant under the wrong trie level is the
|
||||
/// production hazard the finding flagged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["line-known"] = new(new[] { "ns", "area-1", "line-known" }),
|
||||
};
|
||||
// Row references a line that is NOT in the path lookup.
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read),
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
|
||||
};
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||
|
||||
diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
|
||||
diagnostics[0].ScopeId.ShouldBe("line-orphan");
|
||||
diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
|
||||
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["line-A"] = new(new[] { "ns", "area-1", "line-A" }),
|
||||
["line-B"] = new(new[] { "ns", "area-1", "line-B" }),
|
||||
};
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — no descent
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
|
||||
};
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||
|
||||
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
|
||||
{
|
||||
// No diagnostics callback should fire when scopePaths itself is null — that's the
|
||||
// "deterministic-test fallback" mode, not a production drop.
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
PermissionTrieBuilder.Build("c1", 1, rows, scopePaths: null, diagnostic: diagnostics.Add);
|
||||
|
||||
diagnostics.ShouldBeEmpty(
|
||||
"scopePaths=null is the explicit test-fallback mode and must not emit per-row warnings");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user