Phase 6.2 Stream C.12 — lock in ScopePathIndexBuilder semantics with tests
Closes task #123 (partial — builder semantics unit-tested; production wiring is the new task #133). ScopePathIndexBuilder + NodeScopeResolver indexed mode already exist — they produce a full Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag scope from the published generation's config rows. What was missing: unit coverage of the Build semantics (the only consumers were compile-time references) + explicit acknowledgement in the readiness doc that the gate/resolver aren't yet wired into Program.cs. Tests — 6 cases in ScopePathIndexBuilderTests.cs: - Well-formed content emits full hierarchy. - Tags with null EquipmentId skipped (SystemPlatform-namespace fallback). - Tags with broken Equipment FK skipped (publish-time validation should have caught; builder is defensive). - Equipment with broken Line FK skipped. - Duplicate TagConfig throws InvalidOperationException. - Resolver with index returns full-path scope; un-indexed ref falls through to cluster-only scope (pre-ADR-001 behaviour preserved). Server.Tests 277 → 283. Critical follow-up (task #133): Program.cs still constructs OpcUaApplicationHost WITHOUT authzGate or scopeResolver, so all six dispatch-layer gates (Read, Write, HistoryRead, Browse, CreateMonitoredItems, Call) are currently inert in production. Wiring them up — load NodeAcl + EquipmentNamespaceContent at bootstrap, construct gate + resolver, pass into OpcUaApplicationHost, rebind on generation refresh — is the last Phase 6.2 GA blocker. Docs: v2-release-readiness.md Phase 6.2 Stream C hardening list marks the scope-resolution bullet struck-through with a close-out note that calls out the gate-inert-in-production gap + task #133. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ Remaining Stream C surfaces (hardening, not release-blocking):
|
||||
- ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern).
|
||||
- ~~Alarm Acknowledge / Confirm / Shelve gating.~~ **Partial, 2026-04-24.** Acknowledge + Confirm map to dedicated `OpcUaOperation.AlarmAcknowledge` / `AlarmConfirm` via `MapCallOperation`; Shelve falls through to generic `OpcUaOperation.Call` (needs per-instance method NodeId resolution to distinguish — follow-up).
|
||||
- ~~Call (method invocation) gating.~~ **Closed 2026-04-24.** `DriverNodeManager.Call` override pre-gates each `CallMethodRequest` via `GateCallMethodRequests`. Denied calls return `BadUserAccessDenied` without running the method. Alarm methods map to alarm-specific operation kinds; everything else gates as generic `Call`.
|
||||
- Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.
|
||||
- ~~Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.~~ **Partial, 2026-04-24.** `ScopePathIndexBuilder` + indexed-mode `NodeScopeResolver` exist and are unit-tested — index keys driver-side full-ref → full `Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag` scope. **Critical follow-up (task #133):** Program.cs does not yet construct either the gate or the resolver — all six dispatch-layer gates (Read, Write, HistoryRead, Browse, CreateMonitoredItems, Call) are currently inert in production. Wiring is required before GA.
|
||||
- 3-user integration matrix covering every operation × allow/deny.
|
||||
|
||||
### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ScopePathIndexBuilder"/> — the ADR-001 Task B builder that
|
||||
/// produces the full-path <see cref="NodeScope"/> index consumed by
|
||||
/// <see cref="NodeScopeResolver"/> in its indexed mode.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScopePathIndexBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_emits_full_hierarchy_for_well_formed_content()
|
||||
{
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns-eq", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
equipment: [Equip("eq1", "line1")],
|
||||
tags: [TagRow("tag1", "eq1", tagConfig: "Eq1/Speed")]));
|
||||
|
||||
index.Count.ShouldBe(1);
|
||||
var scope = index["Eq1/Speed"];
|
||||
scope.ClusterId.ShouldBe("c1");
|
||||
scope.NamespaceId.ShouldBe("ns-eq");
|
||||
scope.UnsAreaId.ShouldBe("area1");
|
||||
scope.UnsLineId.ShouldBe("line1");
|
||||
scope.EquipmentId.ShouldBe("eq1");
|
||||
scope.TagId.ShouldBe("Eq1/Speed");
|
||||
scope.Kind.ShouldBe(NodeHierarchyKind.Equipment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_skips_tags_with_null_EquipmentId()
|
||||
{
|
||||
// SystemPlatform-namespace tags (decision #110) — the cluster-only resolver
|
||||
// fallback handles them; no index entry needed.
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns-sp", Content(
|
||||
tags: [TagRow("t", equipmentId: null, tagConfig: "Galaxy.Object.Attr")]));
|
||||
|
||||
index.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_skips_tags_with_broken_Equipment_FK()
|
||||
{
|
||||
// Tag references a missing Equipment row. sp_ValidateDraft should have caught this
|
||||
// at publish; builder skips rather than crashes so startup stays bootable.
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
tags: [TagRow("t", "missing-eq", "missing/Speed")]));
|
||||
|
||||
index.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_skips_equipment_with_broken_line_FK()
|
||||
{
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [], // no lines — equipment's UnsLineId misses
|
||||
equipment: [Equip("eq1", "missing")],
|
||||
tags: [TagRow("t", "eq1", "E/S")]));
|
||||
|
||||
index.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_throws_on_duplicate_TagConfig()
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
equipment: [Equip("eq1", "line1")],
|
||||
tags:
|
||||
[
|
||||
TagRow("t1", "eq1", "E/DUP"),
|
||||
TagRow("t2", "eq1", "E/DUP"),
|
||||
])));
|
||||
|
||||
ex.Message.ShouldContain("Duplicate");
|
||||
ex.Message.ShouldContain("E/DUP");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolver_with_index_returns_full_path_scope()
|
||||
{
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
equipment: [Equip("eq1", "line1")],
|
||||
tags: [TagRow("t", "eq1", "E/Speed")]));
|
||||
var resolver = new NodeScopeResolver("c1", index);
|
||||
|
||||
var resolved = resolver.Resolve("E/Speed");
|
||||
resolved.UnsAreaId.ShouldBe("area1");
|
||||
resolved.UnsLineId.ShouldBe("line1");
|
||||
resolved.EquipmentId.ShouldBe("eq1");
|
||||
|
||||
// Un-indexed ref falls through to cluster-only scope — pre-ADR-001 behaviour preserved.
|
||||
var fallback = resolver.Resolve("Galaxy.Object.Attr");
|
||||
fallback.ClusterId.ShouldBe("c1");
|
||||
fallback.TagId.ShouldBe("Galaxy.Object.Attr");
|
||||
fallback.UnsAreaId.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- fixture helpers ---------------------------------------------------
|
||||
|
||||
private static EquipmentNamespaceContent Content(
|
||||
IReadOnlyList<UnsArea>? areas = null,
|
||||
IReadOnlyList<UnsLine>? lines = null,
|
||||
IReadOnlyList<Equipment>? equipment = null,
|
||||
IReadOnlyList<Tag>? tags = null) =>
|
||||
new(areas ?? [], lines ?? [], equipment ?? [], tags ?? []);
|
||||
|
||||
private static UnsArea Area(string id) => new()
|
||||
{
|
||||
UnsAreaId = id, ClusterId = "c1", Name = $"Area {id}",
|
||||
};
|
||||
|
||||
private static UnsLine Line(string id, string areaId) => new()
|
||||
{
|
||||
UnsLineId = id, UnsAreaId = areaId, Name = $"Line {id}",
|
||||
};
|
||||
|
||||
private static Equipment Equip(string id, string lineId) => new()
|
||||
{
|
||||
EquipmentId = id, UnsLineId = lineId, DriverInstanceId = "drv",
|
||||
Name = $"Eq {id}", MachineCode = $"M{id}", ZTag = id,
|
||||
};
|
||||
|
||||
private static Tag TagRow(string id, string? equipmentId, string tagConfig) => new()
|
||||
{
|
||||
TagId = id, EquipmentId = equipmentId,
|
||||
DriverInstanceId = "drv",
|
||||
Name = id, DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user