From 1be0fb5a29acd58a42fd766ab166a867f5bde166 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Apr 2026 15:28:19 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206.2=20Stream=20C.12=20=E2=80=94=20lock?= =?UTF-8?q?=20in=20ScopePathIndexBuilder=20semantics=20with=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/v2/v2-release-readiness.md | 2 +- .../ScopePathIndexBuilderTests.cs | 148 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index bca3373..6a1bc85 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -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) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs new file mode 100644 index 0000000..f703848 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScopePathIndexBuilderTests.cs @@ -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; + +/// +/// Unit tests for — the ADR-001 Task B builder that +/// produces the full-path index consumed by +/// in its indexed mode. +/// +[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(() => + 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? areas = null, + IReadOnlyList? lines = null, + IReadOnlyList? equipment = null, + IReadOnlyList? 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, + }; +}