Merge branch 'feat/tasks-12-14-22-23'

Wave 2 of the task-list run:
- #12 harden the Phase 6.2 deferred authz gates (28 new tests; gates already shipped in fb6dd34)
- #14 Phase 6.4 Stream B.5 — five-identifier ranked equipment search
- #22 docs/v2/phase-7-status.md — Phase 7 reconciliation (Phase 7 ~complete, 5 gaps)
- #23 retire docs/v2/lmx-followups.md
This commit is contained in:
Joseph Doherty
2026-05-18 04:42:39 -04:00
7 changed files with 1131 additions and 196 deletions

View File

@@ -98,7 +98,7 @@ Design decisions + phase plans + execution notes. Load-bearing cross-references
- [v2/test-data-sources.md](v2/test-data-sources.md) — integration-test simulator matrix (includes the pinned libplctag `ab_server` version for AB CIP tests) - [v2/test-data-sources.md](v2/test-data-sources.md) — integration-test simulator matrix (includes the pinned libplctag `ab_server` version for AB CIP tests)
- [v2/multi-host-dispatch.md](v2/multi-host-dispatch.md) — per-PLC circuit breakers (Phase 6.1 decision #144) - [v2/multi-host-dispatch.md](v2/multi-host-dispatch.md) — per-PLC circuit breakers (Phase 6.1 decision #144)
- [v2/v2-release-readiness.md](v2/v2-release-readiness.md) — release-readiness tracker - [v2/v2-release-readiness.md](v2/v2-release-readiness.md) — release-readiness tracker
- [v2/lmx-followups.md](v2/lmx-followups.md) — historical Galaxy-bridge follow-ups (pre-PR-7.2) - [v2/phase-7-status.md](v2/phase-7-status.md) — Phase 7 reconciliation: what shipped vs. the plan, and the five remaining gaps
- [v2/implementation/phase-*-*.md](v2/implementation/) — per-phase execution plans with exit-gate evidence - [v2/implementation/phase-*-*.md](v2/implementation/) — per-phase execution plans with exit-gate evidence
## v1 archive ## v1 archive

View File

@@ -1,195 +0,0 @@
# LMX Galaxy bridge — remaining follow-ups
State after PR 19: the Galaxy driver is functionally at v1 parity through the
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
Basic256Sha256 endpoints and alarms are observable through
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
specific before the stack can fully replace the v1 deployment, in
rough priority order.
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` — **DONE (PRs 35 + 38)**
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
(default throwing implementations so existing impls keep compiling), added the
`HistoricalEvent` + `HistoricalEventsResult` records to `Core.Abstractions`,
and implemented both methods in `GalaxyProxyDriver` on top of the PR 10 / PR 11
IPC messages.
PR 38 wired the OPC UA HistoryRead service-handler through
`DriverNodeManager` by overriding `CustomNodeManager2`'s four per-kind hooks —
`HistoryReadRawModified` / `HistoryReadProcessed` / `HistoryReadAtTime` /
`HistoryReadEvents`. Each walks `nodesToProcess`, resolves the driver-side
full reference from `NodeId.Identifier`, dispatches to the right
`IHistoryProvider` method, and populates the paired results + errors lists
(both must be set — the MasterNodeManager merges them and a Good result with
an unset error slot serializes as `BadHistoryOperationUnsupported` on the
wire). Historized variables gain `AccessLevels.HistoryRead` so the stack
dispatches; the driver root folder gains `EventNotifiers.HistoryRead` so
`HistoryReadEvents` can target it.
Aggregate translation uses a small `MapAggregate` helper that handles
`Average` / `Minimum` / `Maximum` / `Total` / `Count` (the enum surface the
driver exposes) and returns null for unsupported aggregates so the handler
can surface `BadAggregateNotSupported`. Raw+Processed+AtTime wrap driver
samples as `HistoryData` in an `ExtensionObject`; Events emits a
`HistoryEvent` with the standard BaseEventType field list (EventId /
SourceName / Message / Severity / Time / ReceiveTime) — custom
`SelectClause` evaluation is an explicit follow-up.
**Tests**:
- `DriverNodeManagerHistoryMappingTests` — 12 unit cases pinning
`MapAggregate`, `BuildHistoryData`, `BuildHistoryEvent`, `ToDataValue`.
- `HistoryReadIntegrationTests` — 5 end-to-end cases drive a real OPC UA
client (`Session.HistoryRead`) against a fake `IHistoryProvider` driver
through the running stack. Covers raw round-trip, processed with Average
aggregate, unsupported aggregate → `BadAggregateNotSupported`, at-time
timestamp forwarding, and events field-list shape.
**Deferred**:
- Continuation-point plumbing via `Session.Save/RestoreHistoryContinuationPoint`.
Driver returns null continuations today so the pass-through is fine.
- Per-`SelectClause` evaluation in HistoryReadEvents — clients that send a
custom field selection currently get the standard BaseEventType layout.
## 2. Write-gating by role — **DONE (PR 26)**
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
`SecurityClassification` → required role (`FreeAccess` → no role required,
`Operate`/`SecuredWrite``WriteOperate`, `Tune``WriteTune`,
`Configure`/`VerifiedWrite``WriteConfigure`, `ViewOnly` → deny regardless).
`DriverNodeManager` caches the classification per variable during discovery and
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
can't write a `Tune` attribute unless it also carries `WriteTune`.
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
that authz stays at the server layer and never delegates to driver-specific auth.
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
Admin host that can reach the shared PKI directory.
Operator actions: Trust (moves `rejected/certs/*.der``trusted/certs/*.der`),
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
each new client handshake, so no explicit reload signal is needed —
operators retry the rejected client's connection after trusting.
Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
deployment default. That's a production-hardening config change, not a code
gap — the Admin UI is now ready to be the trust gate.
## 4. Live-LDAP integration test — **DONE (PR 31)**
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
when the port is unreachable. Covers: valid bind, wrong password, unknown
user, empty credentials, single-group → WriteOperate mapping, multi-group
admin user surfacing all mapped roles.
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
compat) so Active Directory deployments can configure `sAMAccountName` /
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
`docs/security.md` §"Active Directory configuration" for the AD appsettings
snippet.
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
drive a full OPC UA session with username/password, then read an
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
That needs a test-only address-space node and is a separate PR.
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
every dependency a live smoke test needs and produces actionable skip
messages.
PR 37 shipped the live-stack smoke test project structure:
`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects
to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe;
never spawns the Host process) and `LiveStackSmokeTests` covering:
- Fixture initializes successfully (IPC handshake succeeds end-to-end).
- Driver reports `DriverState.Healthy` post-handshake.
- `DiscoverAsync` returns at least one variable from the live Galaxy.
- `GetHostStatuses` reports at least one Platform/AppEngine host.
- `ReadAsync` on a discovered variable round-trips through
Proxy → Host pipe → MXAccess → back without a BadInternalError.
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
registry-stored Environment values (requires elevated test host).
**PR 40** added the write + subscribe facts targeting
`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy
ships under TestMachine_001) — write-then-read with a 5s scan-window poll +
restore-on-finally, and subscribe-then-write asserting both an initial-value
OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell
short-circuit so a developer running from an admin window gets an actionable
skip instead of `UnauthorizedAccessException`.
**Run the live tests** (from a NORMAL non-admin PowerShell):
```powershell
$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests"
```
Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service.
**Remaining for #5 in production-grade form**:
- Confirm the suite passes from a non-elevated shell (operator action).
- Add similar facts for an alarm-source attribute once `TestMachine_001` (or
a sibling) carries a deployed alarm condition — the current dev Galaxy's
TestAttribute isn't alarm-flagged.
## 6. Second driver instance on the same server — **DONE (PR 32)**
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
NamespaceUris, (2) browsing one subtree returns that driver's folder and
does NOT leak the other driver's folder, (3) reads route to the correct
driver — the alpha instance returns 42 while beta returns 99, so a misroute
would surface at the assertion layer.
Deferred: the alarm-event multi-driver parity case (two drivers each raising
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
condition node). Alarm tracking already has its own integration test
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
`IAlarmSource` that's worth its own focused PR.
## 7. Host-status per-AppEngine granularity → Admin UI dashboard — **DONE (PRs 33 + 34)**
**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
indexes (per-cluster drill-down on `NodeId`, stale-row detection on
`LastSeenUtc`).
**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
`BackgroundService` in the Server process that walks every registered
`IHostConnectivityProbe`-capable driver every 10s, calls
`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
groups by cluster, shows four summary cards (Hosts / Running / Stale /
Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
operators see crashed Servers without waiting for a state change.
Deferred as follow-ups:
- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
10s polling is fine for operator-scale use.
- Failure-count column — needs the publisher to track a transition history
per host, not just current-state.
- SignalR fan-out to the Admin page (currently the page polls the DB, not
a hub). The DB-polled version is fine at current cadence but a hub push
would eliminate the 10s race where a new row sits in the DB before the
Admin page notices.

197
docs/v2/phase-7-status.md Normal file
View File

@@ -0,0 +1,197 @@
# Phase 7 Status — Scripting Runtime, Virtual Tags, Scripted Alarms, Historian Sink
> **Reconciliation date**: 2026-05-18
> **Based on**: `docs/v2/implementation/phase-7-scripting-and-alarming.md` (the plan) and
> `docs/v2/implementation/exit-gate-phase-7.md` (the exit-gate audit) cross-checked against
> the actual repository files. See "Evidence sources" at the bottom.
## Summary verdict
**Phase 7 core is fully shipped and the exit gate is closed.** All eight plan streams
(AH, where H = exit gate) plus the three deferred follow-ups (tasks #239 / #240 / #241)
landed before the 2026-04-23 exit-gate audit. The `v2-release-readiness.md` note
"Phase 7 — out of scope for v2 GA" is a stale label: the work shipped after that doc
was last updated. The four `Core.*` Phase 7 projects exist, have tests, and are wired
into the running server. Five targeted gaps remain open (see section below).
---
## Work-item status by plan stream
### Stream A — `Core.Scripting` (Roslyn engine, sandbox, AST inference, logger)
| Plan item | Status | Evidence |
|-----------|--------|----------|
| A.1 — Project scaffold + `ScriptContext` base class (`GetTag` / `SetVirtualTag` / `Logger` / `Now` / `Deadband`) | **Done** | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs`, `ScriptGlobals.cs` |
| A.2 — `DependencyExtractor : CSharpSyntaxWalker` — literal-only path check, `Inputs` + `Outputs` sets | **Done** | `DependencyExtractor.cs`; literal-reject logic exercised by 7 test files in `Core.Scripting.Tests` |
| A.3 — Compile cache keyed on `SHA-256(source)` | **Done** | `CompiledScriptCache.cs` (`ConcurrentDictionary<string, Lazy<ScriptEvaluator<...>>>`) |
| A.4 — Per-evaluation timeout (250 ms default) | **Done** | `TimedScriptEvaluator.cs`; `TimedScriptEvaluatorTests.cs` |
| A.5 — Serilog sink wiring; `scripts-*.log` companion mirror to main log at WARN on ERROR | **Done** | `ScriptLoggerFactory.cs`, `ScriptLogCompanionSink.cs`; `ScriptLogCompanionSinkTests.cs` |
| A.6 — Tests (AST extraction, sandbox escape, exception isolation, timeout, logger binding) | **Done** | `ScriptSandboxTests.cs`, `DependencyExtractorTests.cs`, `CompiledScriptCacheTests.cs`, `ScriptLoggerFactoryTests.cs`, `TimedScriptEvaluatorTests.cs` — 7 test files |
Shipped as PRs #177#179 (63 tests).
### Stream B — Virtual tag engine
| Plan item | Status | Evidence |
|-----------|--------|----------|
| B.1 — `VirtualTagEngine` + `DependencyGraph` | **Done** | `VirtualTagEngine.cs`, `DependencyGraph.cs` |
| B.2 — `ChangeTriggerDispatcher` (subscribe to referenced driver tags via `ITagUpstreamSource`) | **Done** | `VirtualTagEngine.OnUpstreamChange` internal subscriber path |
| B.3 — `TimerTriggerDispatcher` (per-tag `IntervalMs` via timer-wheel) | **Done** | `TimerTriggerScheduler.cs` |
| B.4 — `EvaluationPipeline` (serial, per-tag isolation, `_evalGate` semaphore) | **Done** | `VirtualTagEngine.EvaluateInternalAsync`; `_evalGate SemaphoreSlim(1,1)` |
| B.5 — `IVirtualTagSource` implementing `IReadable` + `ISubscribable` | **Done** | `VirtualTagSource.cs` |
| B.6 — History routing (`IHistoryWriter.Record` when `Historize=true`) | **Partial** | `IHistoryWriter.cs` + `NullHistoryWriter` present; no production writer is wired into the virtual-tag path. `docs/VirtualTags.md` §"Upstream reads + history" explicitly notes: "no production writer is currently wired for virtual tags". Virtual-tag historization is functional at the engine level but has no live sink. |
| B.7 — Tests: dependency graph, cascade, timer, change+timer combined, error propagation, historize | **Done** | `DependencyGraphTests.cs`, `VirtualTagEngineTests.cs`, `TimerTriggerSchedulerTests.cs`, `VirtualTagSourceTests.cs` — 5 test files |
Shipped as PR #180 (36 tests).
### Stream C — Scripted alarm engine + Part 9 state machine + template messages
| Plan item | Status | Evidence |
|-----------|--------|----------|
| C.1 — `ScriptedAlarmEngine` skeleton + alarm config model | **Done** | `ScriptedAlarmEngine.cs`, `ScriptedAlarmDefinition.cs` |
| C.2 — `Part9StateMachine` (Enable/Disable/Active/Ack/Confirm/Shelve/Unshelve/Comment/ShelvingCheck) | **Done** | `Part9StateMachine.cs`; `Part9StateMachineTests.cs` |
| C.3 — Predicate evaluation on input change; activate/clear transitions | **Done** | `ScriptedAlarmEngine.ReevaluateAsync`; `_alarmsReferencing` inverse index |
| C.4 — Startup recovery (`ActiveState` re-derived; Enabled/Ack/Confirm/Shelve loaded from store) | **Done** | `ScriptedAlarmEngine.LoadAsync`; `IAlarmStateStore.LoadAsync` |
| C.5 — Template substitution (`{TagPath}` tokens resolved at emission time) | **Done** | `MessageTemplate.cs`; `MessageTemplateTests.cs` |
| C.6 — OPC UA method binding (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) | **Partial** | Engine methods exist and are tested. `ScriptedAlarmSource.AcknowledgeAsync` defaults the user to `"opcua-client"`. The plan's Stream G wiring of these methods to OPC UA `MethodCall` dispatch on the condition nodes (so OPC UA client method calls reach the engine with the authenticated principal) is noted in the e2e smoke doc as "not yet wired through `DriverNodeManager.MethodCall` dispatch." Operators acknowledge through Admin UI today; the Part 9 method-call path is a follow-up. |
| C.7 — `IAlarmSource` implementation / fan-out registration | **Done** | `ScriptedAlarmSource.cs` |
| C.8 — Tests: all state transitions, startup recovery, template substitution, shelving timer expiry | **Done** | `Part9StateMachineTests.cs`, `ScriptedAlarmEngineTests.cs`, `ScriptedAlarmSourceTests.cs`, `MessageTemplateTests.cs` — 5 test files |
Shipped as PR #181 (47 tests).
### Stream D — Historian alarm sink (SQLite store-and-forward + Wonderware IPC)
| Plan item | Status | Evidence |
|-----------|--------|----------|
| D.1 — `Core.AlarmHistorian` project; `IAlarmHistorianSink`; `SqliteStoreAndForwardSink` (backoff, dead-letter, capacity) | **Done** | `IAlarmHistorianSink.cs`, `SqliteStoreAndForwardSink.cs`; `SqliteStoreAndForwardSinkTests.cs` |
| D.2 — Live-historian smoke against dev box Aveva Historian; document the exact SDK entry point | **Partial** | The smoke (`docs/v2/implementation/phase-7-e2e-smoke.md`) ran but the IPC path via Galaxy.Host to `aahClientManaged` was the original plan. That path changed: the production implementation uses `Driver.Historian.Wonderware.Client` (`WonderwareHistorianClient.WriteBatchAsync`) over a named-pipe sidecar, not Galaxy.Host. There is no separate `docs/v2/historian-alarm-api.md` artifact documenting the SDK entry point as the plan called for; the implementation detail is in `WonderwareHistorianClient.cs` inline. |
| D.3 — `Driver.Galaxy.Shared` contract additions (`HistorianAlarmEventRequest` / `Response` / `ConnectivityStatusNotification`) | **Changed** | The plan routed alarm writes through Galaxy.Host IPC. The shipped implementation uses `Driver.Historian.Wonderware.Client` (a standalone sidecar project) instead. `HistorianAlarmEventRequest` / `HistorianAlarmEventResponse` as named protos never shipped; the equivalent contract is the `AlarmHistorianEventDto` / `WriteAlarmEventsRequest` / `WriteAlarmEventsReply` MessagePack DTOs in `Driver.Historian.Wonderware.Client/Ipc/`. Galaxy.Host still exists as the mxaccessgw entry point but does not carry historian writes. |
| D.4 — `Driver.Galaxy.Host` handler for alarm writes | **Changed** | Not shipped via Galaxy.Host. The sidecar (`Driver.Historian.Wonderware.Client`) is the production path. `IAlarmHistorianWriter` is implemented by `WonderwareHistorianClient`, not by a Galaxy.Host frame handler. |
| D.5 — Drain worker in main server (poll SQLite queue, batch 100 events, exponential backoff) | **Done** | `SqliteStoreAndForwardSink.StartDrainLoop`; backoff ladder 1s → 2s → 5s → 15s → 60s; `Phase7Composer.ResolveHistorianSink` starts it with a 2-second drain cadence |
| D.6 — Per-alarm `HistorizeToAveva` toggle; `AlarmHistorizationPolicy` per source | **Done** | `ScriptedAlarm.HistorizeToAveva` column (default `true`); `Phase7EngineComposer.RouteToHistorianAsync` checks it; Galaxy defaults `false` |
| D.7 — `/alarms/historian` diagnostics view in Admin (queue depth, drain rate, last error, retry dead-lettered) | **Done** | `AlarmsHistorian.razor`; `HistorianDiagnosticsService.cs` |
| D.8 — Tests | **Done** | `SqliteStoreAndForwardSinkTests.cs`; `Phase7ComposerWriterSelectionTests.cs` covers historian-writer resolution |
Shipped as PR #182 (14 tests). Architecture deviated from the plan (Wonderware sidecar instead of Galaxy.Host IPC) but the functional goals are met.
### Stream E — Config DB schema + generation-sealed cache extensions
| Plan item | Status | Evidence |
|-----------|--------|----------|
| E.1 — EF migration for `Script` / `VirtualTag` / `ScriptedAlarm` / `ScriptedAlarmState` tables | **Done** | Migration `20260420231641_AddPhase7ScriptingTables.cs`; entities in `Configuration/Entities/` |
| E.2 — `sp_PublishGeneration` extension (sealed-cache snapshot includes Phase 7 rows) | **Done** | Migration `20260420232000_ExtendComputeGenerationDiffWithPhase7.cs` |
| E.3 — CRUD services: `VirtualTagService`, `ScriptedAlarmService`, `ScriptService`, `ScriptedAlarmStateService` | **Done** | All four exist in `Admin/Services/`; `GetStateAsync` on `ScriptedAlarmService` serves the state query |
| E.4 — Tests: migration up/down; publish atomicity; audit trail | **Done** | `Phase7ServicesTests.cs` (13 tests covering CRUD + hash behavior + harness) |
Shipped as PR #183 (12 tests in configuration; 13 more in Admin.Tests).
### Stream F — Admin UI scripting tab
| Plan item | Status | Evidence |
|-----------|--------|----------|
| F.1 — Monaco editor Razor component (CDN bundle + textarea fallback) | **Done** | `ScriptEditor.razor` (textarea with Monaco JS interop, `otOpcUaScriptEditor.attach`) |
| F.2 — `/virtual-tags` tab (list view, edit pane, dependency preview, publish gate) | **Partial** | The `ScriptsTab.razor` is the single tab covering script CRUD, dependency preview, and harness. There is no separate `/virtual-tags` tab UI — virtual tags are managed through the script service alone; no VirtualTag list/edit form exists in the Admin UI. The per-tag fields (`EquipmentId`, `DataType`, `ChangeTriggered`, `TimerIntervalMs`, `Historize`) are accessible via the `VirtualTagService` backend but have no corresponding UI form. |
| F.3 — `/scripted-alarms` tab (alarm type, severity, message template, `HistorizeToAveva`, detail page with shelve/ack state read-only) | **Partial** | No dedicated scripted-alarms tab razor page exists (confirmed by Glob + Grep searches). Scripted alarm CRUD (`ScriptedAlarmService`) exists as a service but has no Admin UI page. |
| F.4 — Test harness (modal, synthetic inputs, output + logger display) | **Partial** | `ScriptTestHarnessService.cs` is complete and tested. `ScriptsTab.razor` calls `Harness.RunVirtualTagAsync` with zero-value synthetic inputs derived from the extractor. A full interactive input-form modal was not shipped — the harness zeroes all inputs automatically rather than prompting the operator per-tag. |
| F.5 — Script log viewer (SignalR tail of `scripts-*.log` filtered by `ScriptName`, load-more) | **Not started** | No SignalR stream of the scripts log is wired in the Admin UI. The `AlertHub` / `FleetStatusHub` exist but there is no `ScriptLogHub`. |
| F.6 — `/alarms/historian` diagnostics view | **Done** | `AlarmsHistorian.razor` + `HistorianDiagnosticsService.cs` |
| F.7 — Playwright smoke (author calc tag, verify in equipment tree; author alarm, verify in `AlarmsAndConditions`) | **Not started** | `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/` exists but its `UnsTabDragDropE2ETests.cs` is the only Playwright test; no Phase 7 Admin UI playwright scenario. |
Shipped as PR #185 (13 Admin service tests; UI completeness is partial — see gaps section).
### Stream G — Address-space integration
| Plan item | Status | Evidence |
|-----------|--------|----------|
| G.1 — `EquipmentNodeWalker` extension emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables | **Done** | PR #184; `NodeSourceKind` discriminator confirmed in exit gate |
| G.2 — `DriverNodeManager` dispatch routes reads by source; writes to non-Driver rejected with `BadUserAccessDenied` | **Done** | PR #186 follow-up; `OpcUaApplicationHost.SetPhase7Sources` threads `_virtualReadable` + `_scriptedAlarmReadable` into the node manager |
| G.3 — `AlarmTracker` composition (`ScriptedAlarmEngine` registers as additional `IAlarmSource`) | **Done** | `ScriptedAlarmSource` adapts engine to `IAlarmSource`; `Phase7EngineComposer.Compose` wires it |
| G.4 — Tests: mixed equipment folder browsable via Client.CLI; read/subscribe round-trip; alarm transitions in event stream | **Done** | `Phase7ComposerMappingTests.cs`, `Phase7EngineComposerTests.cs`, `ScriptedAlarmReadableTests.cs`, `CachedTagUpstreamSourceTests.cs`, `DriverSubscriptionBridgeTests.cs` — 6 test files in `Server.Tests/Phase7/` |
| OPC UA method binding for alarm Ack/Confirm/Shelve | **Not started** | Noted explicitly in `phase-7-e2e-smoke.md` §"Known limitations": `DriverNodeManager.MethodCall` dispatch for scripted alarm methods is not wired. Engine has the methods; the OPC UA call path does not reach them. |
Shipped across PRs #184 + #186 (5 + 7 tests).
### Stream H — Exit gate
| Plan item | Status | Evidence |
|-----------|--------|----------|
| H.1 — Compliance script real-checks | **Done** | `scripts/compliance/phase-7-compliance.ps1` |
| H.2 — Full solution `dotnet test` baseline | **Done** | Exit gate records ~197 new tests + solution baseline |
| H.3 — `plan.md` Migration Strategy §6 update | **Not verified** | Not explicitly confirmed; minor — the plan doc is not the primary status artifact |
| H.4 — Phase-status memory update | **Done** | Memory updated (see `project_alarms_over_gateway_epic.md` + `project_server_history_alarm_subsystems.md`) |
| H.5 — Merge `v2/phase-7-scripting-and-alarming``v2` | **Done** | All PRs (#177#186) merged |
### Post-gate follow-ups (tasks #239 / #240 / #241)
All three are verified closed in the 2026-04-23 exit-gate audit:
| Task | Item | Status |
|------|------|--------|
| #239 | `SealedBootstrap` composition root — `Phase7Composer.PrepareAsync` + `OpcUaServerService` wiring | **Done** |
| #240 | Live OPC UA e2e smoke — `scripts/e2e/test-phase7-virtualtags.ps1` | **Done** (partial pass: 3/7 stages reach `PASS`; writer/subscribe/alarm stages blocked by live Galaxy attribute activity + Historian SDK environment) |
| #241 | `sp_ComputeGenerationDiff` extension for Script / VirtualTag / ScriptedAlarm diff sections | **Done** — migration `20260420232000_ExtendComputeGenerationDiffWithPhase7` |
---
## What genuinely remains
These are real open items, not issues with the plan reconciliation.
### Gap 1 — OPC UA method-call dispatch for scripted alarm Ack/Confirm/Shelve (Stream G / C.6)
`DriverNodeManager.MethodCall` does not route OPC UA `Acknowledge` / `Confirm` / `OneShotShelve` / `TimedShelve` / `Unshelve` / `AddComment` method invocations to the `ScriptedAlarmEngine`. Operators can acknowledge scripted alarms through the Admin UI today; OPC UA HMI clients expecting to use Part 9 method nodes directly cannot. Explicit in `phase-7-e2e-smoke.md` §"Known limitations".
### Gap 2 — Admin UI: no `/virtual-tags` tab or form (Stream F.2)
`VirtualTagService` CRUD is fully tested but no razor page exposes it. Operators must author virtual tags through direct SQL or Admin API calls. `ScriptsTab.razor` covers script CRUD only; virtual-tag fields (`EquipmentId`, `DataType`, trigger config, `Historize`) have no UI form.
### Gap 3 — Admin UI: no `/scripted-alarms` tab or form (Stream F.3)
`ScriptedAlarmService` CRUD is fully tested but no razor page exists. Only `ScriptsTab.razor` under the cluster detail view is present; there is no `ScriptedAlarmsTab.razor` or equivalent.
### Gap 4 — Script log viewer not shipped (Stream F.5)
The SignalR tail of `scripts-*.log` filtered by `ScriptName` was not implemented. `ScriptsTab.razor` shows script output from the in-process harness but has no live-log panel for production emissions.
### Gap 5 — Virtual-tag historization has no production sink (Stream B.6)
`IHistoryWriter` + `NullHistoryWriter` are present; `VirtualTagEngine` calls `IHistoryWriter.Record` per evaluation when `Historize=true`. `Phase7EngineComposer.Compose` passes `NullHistoryWriter` — no live writer is wired. Virtual-tag values are computed and served correctly but never persisted to any historian. Explicitly documented in `docs/VirtualTags.md` §"Upstream reads + history".
---
## What is definitely done
- All four `Core.*` projects (`Core.Scripting`, `Core.VirtualTags`, `Core.ScriptedAlarms`, `Core.AlarmHistorian`) ship with full implementation and test coverage.
- Roslyn sandbox (allow-list + `ForbiddenTypeAnalyzer` defense-in-depth + 250 ms timeout + per-script Serilog sink + compile cache) is complete.
- Virtual tag engine: dependency graph with iterative Tarjan SCC, topo-sort, change-trigger cascade, timer trigger, `IReadable` + `ISubscribable` adapter, per-tag error isolation.
- Scripted alarm engine: full Part 9 state machine, startup recovery, template substitution, `IAlarmSource` fan-out, 5-second shelving timer, `IAlarmStateStore` (in-memory default; DB-backed via Config DB entities).
- SQLite store-and-forward historian sink: drain loop with exponential backoff, dead-letter retention, bounded capacity, `RetryDeadLettered` operator action.
- Config DB schema: `Script`, `VirtualTag`, `ScriptedAlarm`, `ScriptedAlarmState` tables with EF migrations and generation-diff extension.
- Admin services: `ScriptService`, `VirtualTagService`, `ScriptedAlarmService`, `ScriptTestHarnessService`, `HistorianDiagnosticsService` — all backed by unit tests.
- Admin UI: `ScriptsTab.razor` (Monaco-backed editor, dependency preview, test harness), `AlarmsHistorian.razor` (queue depth, drain state, retry dead-lettered).
- Server-side composition: `Phase7Composer`, `Phase7EngineComposer`, `CachedTagUpstreamSource`, `DriverSubscriptionBridge`, `ScriptedAlarmReadable` — fully wired into `OpcUaServerService` startup sequence before `OpcUaApplicationHost.StartAsync`.
- `EquipmentNodeWalker` emits `NodeSourceKind.Virtual` and `NodeSourceKind.ScriptedAlarm` variables; `DriverNodeManager` dispatches reads and rejects writes to virtual nodes.
- `WonderwareHistorianClient.WriteBatchAsync` implements `IAlarmHistorianWriter` as the alarm-event write path (deviation from plan's Galaxy.Host route, but functionally equivalent).
- Compliance script `scripts/compliance/phase-7-compliance.ps1` and e2e smoke `scripts/e2e/test-phase7-virtualtags.ps1` both present.
---
## Evidence sources
| Source | Path |
|--------|------|
| Phase 7 plan | `docs/v2/implementation/phase-7-scripting-and-alarming.md` |
| Phase 7 exit gate | `docs/v2/implementation/exit-gate-phase-7.md` |
| E2E smoke runbook | `docs/v2/implementation/phase-7-e2e-smoke.md` |
| Virtual tags reference doc | `docs/VirtualTags.md` |
| Scripted alarms reference doc | `docs/ScriptedAlarms.md` |
| `Core.Scripting` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/` |
| `Core.VirtualTags` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/` |
| `Core.ScriptedAlarms` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/` |
| `Core.AlarmHistorian` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/` |
| Server Phase7 composition | `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/` |
| Admin services | `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/Script*.cs`, `VirtualTagService.cs`, `HistorianDiagnosticsService.cs` |
| Admin UI pages | `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor`, `AlarmsHistorian.razor` |
| Historian sidecar writer | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs` |
| EF migrations | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs`, `20260420232000_ExtendComputeGenerationDiffWithPhase7.cs` |

View File

@@ -12,6 +12,98 @@
</div> </div>
</div> </div>
@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@
<section class="panel rise mb-3" style="animation-delay:.02s">
<div class="panel-head">Search equipment</div>
<div class="p-3">
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small mb-1">
Search by ZTag, MachineCode, SAPID, EquipmentId, or EquipmentUuid
</label>
<input class="form-control form-control-sm"
placeholder="e.g. z-001 or MC-42 or SAP-…"
@bind="_searchQuery"
@bind:event="oninput"
@onkeydown="OnSearchKeyDown"/>
</div>
<div class="col-auto">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="fuzzyCheck" @bind="_searchFuzzy"/>
<label class="form-check-label small" for="fuzzyCheck">Fuzzy (substring)</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-secondary" @onclick="RunSearchAsync" disabled="@_searchBusy">Search</button>
@if (_searchHits is not null)
{
<button class="btn btn-sm btn-link ms-1" @onclick="ClearSearch">Clear</button>
}
</div>
</div>
@if (_searchError is not null)
{
<p class="small text-danger mt-2 mb-0">@_searchError</p>
}
</div>
@if (_searchHits is not null)
{
@if (_searchHits.Count == 0)
{
<p class="p-3 text-muted small mb-0">No matches.</p>
}
else
{
<div class="table-wrap" style="max-height: 340px; overflow-y: auto;">
<table class="data-table">
<thead>
<tr>
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
<th style="width:110px">Matched</th><th style="width:80px">Gen</th>
</tr>
</thead>
<tbody>
@foreach (var hit in _searchHits)
{
<tr>
<td><span class="mono">@hit.Equipment.EquipmentId</span></td>
<td>@hit.Equipment.Name</td>
<td>@hit.Equipment.MachineCode</td>
<td>@hit.Equipment.ZTag</td>
<td>@hit.Equipment.SAPID</td>
<td>
@if (hit.MatchedField is not null)
{
var chipClass = hit.Score switch
{
100 => "chip chip-ok",
50 => "chip chip-warn",
_ => "chip chip-idle",
};
<span class="@chipClass">@hit.MatchedField</span>
}
</td>
<td>
@if (hit.IsPublished)
{ <span class="chip chip-ok">pub</span> }
else
{ <span class="chip chip-idle">draft</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="p-2 text-muted small mb-0">
@_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s").
Exact = green, prefix = amber, fuzzy = grey.
Fuzzy matching requires the "Fuzzy" checkbox.
</p>
}
}
</section>
@if (_equipment is null) @if (_equipment is null)
{ {
<p>Loading…</p> <p>Loading…</p>
@@ -114,6 +206,41 @@ else if (_equipment.Count > 0)
private Equipment _draft = NewBlankDraft(); private Equipment _draft = NewBlankDraft();
private string? _error; private string? _error;
// ── Five-identifier search ──────────────────────────────────────────
private string _searchQuery = string.Empty;
private bool _searchFuzzy;
private IReadOnlyList<EquipmentSearchHit>? _searchHits;
private bool _searchBusy;
private string? _searchError;
private async Task RunSearchAsync()
{
_searchError = null;
if (string.IsNullOrWhiteSpace(_searchQuery)) { _searchHits = null; return; }
_searchBusy = true;
try
{
_searchHits = await EquipmentSvc.SearchAsync(
_searchQuery, ClusterId, CancellationToken.None,
maxResults: 50, allowFuzzy: _searchFuzzy);
}
catch (Exception ex) { _searchError = ex.Message; }
finally { _searchBusy = false; }
}
private void ClearSearch()
{
_searchQuery = string.Empty;
_searchHits = null;
_searchError = null;
}
private async Task OnSearchKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") await RunSearchAsync();
}
// ───────────────────────────────────────────────────────────────────
private static Equipment NewBlankDraft() => new() private static Equipment NewBlankDraft() => new()
{ {
EquipmentId = string.Empty, DriverInstanceId = string.Empty, EquipmentId = string.Empty, DriverInstanceId = string.Empty,

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation; using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services; namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
@@ -18,6 +19,118 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db)
.OrderBy(e => e.Name) .OrderBy(e => e.Name)
.ToListAsync(ct); .ToListAsync(ct);
/// <summary>
/// Five-identifier ranked search across a cluster (all draft + published generations).
/// Identifiers: ZTag, MachineCode, SAPID, EquipmentId, EquipmentUuid (decision #117).
/// Scoring: exact match on any identifier = 100, prefix match = 50, fuzzy (opt-in) = 20.
/// Tie-break: Published generation outranks Draft; within same status by Name ascending.
/// Returns at most <paramref name="maxResults"/> rows.
/// </summary>
/// <param name="query">Search term (trimmed; empty returns empty results, not all rows).</param>
/// <param name="clusterId">Cluster to scope the search to.</param>
/// <param name="maxResults">Cap to prevent full-table dumps (default 50).</param>
/// <param name="allowFuzzy">When true, LIKE-prefix suffix matches score 20 (opt-in per spec).</param>
/// <param name="ct">Cancellation token.</param>
public async Task<IReadOnlyList<EquipmentSearchHit>> SearchAsync(
string query,
string clusterId,
CancellationToken ct,
int maxResults = 50,
bool allowFuzzy = false)
{
ArgumentNullException.ThrowIfNull(clusterId);
query = query?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(query))
return [];
// Load candidates from DB — we filter generation to this cluster via the Join.
// The scoring is pure-LINQ post-load because EF InMemory doesn't support CASE WHEN scoring
// and the SQL-provider translation for this small set is acceptable (bounded by cluster).
var candidates = await db.Equipment.AsNoTracking()
.Join(db.ConfigGenerations.AsNoTracking(),
e => e.GenerationId,
g => g.GenerationId,
(e, g) => new { Equipment = e, Generation = g })
.Where(x => x.Generation.ClusterId == clusterId)
.Select(x => new
{
x.Equipment,
IsPublished = x.Generation.Status == GenerationStatus.Published,
})
.ToListAsync(ct)
.ConfigureAwait(false);
var lower = query.ToLowerInvariant();
var scored = candidates
.Select(c =>
{
var (score, matchedField) = ScoreEquipment(c.Equipment, lower, allowFuzzy);
return new
{
c.Equipment,
c.IsPublished,
Score = score,
MatchedField = matchedField,
};
})
.Where(x => x.Score > 0)
// Tie-break: highest score → published before draft → name
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.IsPublished ? 1 : 0)
.ThenBy(x => x.Equipment.Name)
.Take(maxResults)
.Select(x => new EquipmentSearchHit(x.Equipment, x.Score, x.MatchedField, x.IsPublished))
.ToList();
return scored;
}
/// <summary>Score one equipment row against the search term. Returns (score, matchedFieldName).</summary>
private static (int Score, string? MatchedField) ScoreEquipment(Equipment e, string lower, bool allowFuzzy)
{
// Evaluate each identifier in priority order — first exact match wins with score 100.
var identifiers = new (string FieldName, string? Value)[]
{
("ZTag", e.ZTag),
("MachineCode", e.MachineCode),
("SAPID", e.SAPID),
("EquipmentId", e.EquipmentId),
("EquipmentUuid", e.EquipmentUuid == Guid.Empty ? null : e.EquipmentUuid.ToString()),
};
foreach (var (fieldName, value) in identifiers)
{
if (string.IsNullOrEmpty(value)) continue;
var v = value.ToLowerInvariant();
if (v == lower)
return (100, fieldName);
}
// Prefix match — score 50
foreach (var (fieldName, value) in identifiers)
{
if (string.IsNullOrEmpty(value)) continue;
var v = value.ToLowerInvariant();
if (v.StartsWith(lower, StringComparison.Ordinal))
return (50, fieldName);
}
// Fuzzy (substring) match — score 20, opt-in only
if (allowFuzzy)
{
foreach (var (fieldName, value) in identifiers)
{
if (string.IsNullOrEmpty(value)) continue;
var v = value.ToLowerInvariant();
if (v.Contains(lower, StringComparison.Ordinal))
return (20, fieldName);
}
}
return (0, null);
}
public Task<Equipment?> FindAsync(long generationId, string equipmentId, CancellationToken ct) => public Task<Equipment?> FindAsync(long generationId, string equipmentId, CancellationToken ct) =>
db.Equipment.AsNoTracking() db.Equipment.AsNoTracking()
.FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct); .FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct);
@@ -73,3 +186,14 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
/// <summary>One hit from <see cref="EquipmentService.SearchAsync"/>.</summary>
/// <param name="Equipment">The matched equipment row.</param>
/// <param name="Score">Match score: 100 = exact, 50 = prefix, 20 = fuzzy.</param>
/// <param name="MatchedField">Which identifier field produced the highest score.</param>
/// <param name="IsPublished">True when the row is from a published generation (aids tie-break display).</param>
public sealed record EquipmentSearchHit(
Equipment Equipment,
int Score,
string? MatchedField,
bool IsPublished);

View File

@@ -0,0 +1,280 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Unit tests for the Phase 6.4 Stream B.5 five-identifier ranked search.
/// Decision #117 identifiers: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid.
/// Scoring: exact match = 100, prefix = 50, fuzzy (opt-in) = 20.
/// Tie-break: published generation outranks draft.
/// </summary>
[Trait("Category", "Unit")]
public sealed class EquipmentSearchTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
private readonly EquipmentService _svc;
private const string ClusterId = "cluster-1";
private const long DraftGenId = 1L;
private const long PublishedGenId = 2L;
public EquipmentSearchTests()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"eq-search-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(opts);
// Seed two generations — draft + published — for the same cluster.
_db.ConfigGenerations.AddRange(
new ConfigGeneration
{
GenerationId = DraftGenId,
ClusterId = ClusterId,
Status = GenerationStatus.Draft,
CreatedBy = "test",
},
new ConfigGeneration
{
GenerationId = PublishedGenId,
ClusterId = ClusterId,
Status = GenerationStatus.Published,
CreatedBy = "test",
PublishedAt = DateTime.UtcNow,
PublishedBy = "test",
});
_db.SaveChanges();
_svc = new EquipmentService(_db);
}
public void Dispose() => _db.Dispose();
// ── Helpers ──────────────────────────────────────────────────────────
private Equipment AddEquipment(
long generationId,
string name,
string ztag,
string machineCode = "MC",
string sapid = "",
Guid? uuid = null,
string equipmentId = "")
{
var uu = uuid ?? Guid.NewGuid();
var eq = new Equipment
{
EquipmentRowId = Guid.NewGuid(),
GenerationId = generationId,
EquipmentId = string.IsNullOrEmpty(equipmentId) ? $"EQ-{uu:N}"[..14] : equipmentId,
EquipmentUuid = uu,
DriverInstanceId = "drv",
UnsLineId = "line-1",
Name = name,
MachineCode = machineCode,
ZTag = ztag,
SAPID = string.IsNullOrEmpty(sapid) ? null : sapid,
};
_db.Equipment.Add(eq);
_db.SaveChanges();
return eq;
}
// ── Exact-match tests (score 100) ────────────────────────────────────
[Fact]
public async Task ExactMatch_ZTag_Returns_Score100()
{
AddEquipment(DraftGenId, "Oven-A", ztag: "z-001");
var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(1);
hits[0].Score.ShouldBe(100);
hits[0].MatchedField.ShouldBe("ZTag");
hits[0].Equipment.Name.ShouldBe("Oven-A");
}
[Fact]
public async Task ExactMatch_IsCaseInsensitive()
{
AddEquipment(DraftGenId, "Welder-1", ztag: "Z-ABC");
var hits = await _svc.SearchAsync("z-abc", ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(1);
hits[0].Score.ShouldBe(100);
}
[Fact]
public async Task ExactMatch_MachineCode_Returns_Score100()
{
AddEquipment(DraftGenId, "Wrapper", ztag: "z-2", machineCode: "MC-42");
var hits = await _svc.SearchAsync("MC-42", ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(1);
hits[0].Score.ShouldBe(100);
hits[0].MatchedField.ShouldBe("MachineCode");
}
[Fact]
public async Task ExactMatch_SAPID_Returns_Score100()
{
AddEquipment(DraftGenId, "Conveyor", ztag: "z-3", sapid: "SAP-999");
var hits = await _svc.SearchAsync("SAP-999", ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(1);
hits[0].Score.ShouldBe(100);
hits[0].MatchedField.ShouldBe("SAPID");
}
[Fact]
public async Task ExactMatch_EquipmentUuid_Returns_Score100()
{
var uu = Guid.NewGuid();
AddEquipment(DraftGenId, "Robot-A", ztag: "z-4", uuid: uu);
var hits = await _svc.SearchAsync(uu.ToString(), ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(1);
hits[0].Score.ShouldBe(100);
hits[0].MatchedField.ShouldBe("EquipmentUuid");
}
// ── Prefix-match tests (score 50) ────────────────────────────────────
[Fact]
public async Task PrefixMatch_ZTag_Returns_Score50()
{
AddEquipment(DraftGenId, "Press-1", ztag: "z-alpha-001");
var hits = await _svc.SearchAsync("z-alpha", ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(1);
hits[0].Score.ShouldBe(50);
hits[0].MatchedField.ShouldBe("ZTag");
}
[Fact]
public async Task ExactOutranks_Prefix_InResults()
{
// exact: z-001 == "z-001" → score 100
// prefix: z-001x startsWith "z-001" → score 50
AddEquipment(DraftGenId, "Exact-Hit", ztag: "z-001");
AddEquipment(DraftGenId, "Prefix-Hit", ztag: "z-001x");
var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(2);
hits[0].Score.ShouldBe(100);
hits[0].Equipment.Name.ShouldBe("Exact-Hit");
hits[1].Score.ShouldBe(50);
hits[1].Equipment.Name.ShouldBe("Prefix-Hit");
}
// ── Fuzzy-match tests (score 20, opt-in) ─────────────────────────────
[Fact]
public async Task FuzzyMatch_Disabled_DoesNotReturn_SubstringOnly_Hit()
{
AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-INFIX-suffix");
var hits = await _svc.SearchAsync("INFIX", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: false);
hits.ShouldBeEmpty();
}
[Fact]
public async Task FuzzyMatch_Enabled_Returns_Score20()
{
AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-infix-suffix");
var hits = await _svc.SearchAsync("infix", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: true);
hits.Count.ShouldBe(1);
hits[0].Score.ShouldBe(20);
hits[0].MatchedField.ShouldBe("ZTag");
}
// ── Tie-break: published outranks draft ───────────────────────────────
[Fact]
public async Task PublishedGeneration_Outranks_Draft_ForEqualScore()
{
// Same ZTag prefix "mc-" in both draft + published generation.
AddEquipment(DraftGenId, "Draft-Eq", ztag: "mc-001");
AddEquipment(PublishedGenId, "Published-Eq", ztag: "mc-002");
// Both hit prefix match on "mc-" (score 50).
var hits = await _svc.SearchAsync("mc-", ClusterId, TestContext.Current.CancellationToken);
hits.Count.ShouldBe(2);
// Published generation should come first.
hits[0].IsPublished.ShouldBeTrue();
hits[1].IsPublished.ShouldBeFalse();
}
// ── Empty / no-match ─────────────────────────────────────────────────
[Fact]
public async Task EmptyQuery_Returns_EmptyList()
{
AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999");
var hits = await _svc.SearchAsync(" ", ClusterId, TestContext.Current.CancellationToken);
hits.ShouldBeEmpty();
}
[Fact]
public async Task NoMatch_Returns_EmptyList()
{
AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999");
var hits = await _svc.SearchAsync("xyzzy-unknown", ClusterId, TestContext.Current.CancellationToken);
hits.ShouldBeEmpty();
}
// ── Cross-cluster isolation ───────────────────────────────────────────
[Fact]
public async Task Equipment_In_DifferentCluster_NotReturned()
{
// Seed a generation in a different cluster.
_db.ConfigGenerations.Add(new ConfigGeneration
{
GenerationId = 99L,
ClusterId = "cluster-other",
Status = GenerationStatus.Draft,
CreatedBy = "test",
});
_db.SaveChanges();
AddEquipment(99L, "OtherEq", ztag: "z-001");
var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
hits.ShouldBeEmpty("equipment from another cluster must be invisible");
}
// ── MaxResults cap ────────────────────────────────────────────────────
[Fact]
public async Task MaxResults_Limits_Output()
{
for (var i = 0; i < 10; i++)
AddEquipment(DraftGenId, $"Eq-{i}", ztag: $"zprefix-{i:D3}");
var hits = await _svc.SearchAsync("zprefix-", ClusterId, TestContext.Current.CancellationToken, maxResults: 3);
hits.Count.ShouldBe(3);
}
}

View File

@@ -0,0 +1,402 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Task #12 hardening tests for the Phase 6.2 deferred authorization gates —
/// Browse, Subscribe (CreateMonitoredItems), Alarm-acknowledge, and Call.
///
/// Fills the compliance-checklist gaps not covered by the existing per-gate unit
/// tests (<see cref="BrowseGatingTests"/>, <see cref="MonitoredItemGatingTests"/>,
/// <see cref="CallGatingTests"/>):
/// <list type="bullet">
/// <item>Lax-mode fall-through for all four deferred gates</item>
/// <item>Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only
/// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag")</item>
/// <item>AlarmShelve intentional fall-through to Call (documents the ShelvedStateMachine
/// per-instance NodeId limitation noted in the MapCallOperation implementation)</item>
/// <item>Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops</item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DeferredGateHardeningTests
{
private const string Cluster = "c1";
// ======================================================================
// 1. Lax-mode fall-through — deferred gates
// ======================================================================
[Fact]
public void Subscribe_gate_lax_mode_null_identity_keeps_items()
{
// In lax mode a session without LDAP groups must NOT be denied —
// the pre-Phase-6.2 default path runs unchanged.
var items = new List<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []); // lax, no grants
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour — no denial for unauthenticated sessions");
}
[Fact]
public void Subscribe_gate_lax_mode_identity_without_ldap_groups_keeps_items()
{
var items = new List<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []);
// UserIdentity with no LDAP groups — lax gate should not deny
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups");
}
[Fact]
public void Call_gate_lax_mode_null_identity_keeps_calls()
{
var calls = new List<CallMethodRequest>
{
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
};
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []);
DriverNodeManager.GateCallMethodRequests(calls, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour for null identity");
}
[Fact]
public void Call_gate_lax_mode_identity_without_ldap_groups_keeps_calls()
{
var calls = new List<CallMethodRequest>
{
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
};
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []);
DriverNodeManager.GateCallMethodRequests(calls, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups");
}
// ======================================================================
// 2. Flag isolation — Subscribe vs Read
// ======================================================================
[Fact]
public void Subscribe_grant_does_not_imply_Read()
{
// Phase 6.2 compliance: Subscribe and Read are independent flags. A session
// granted only Subscribe should NOT be able to read the current value.
var gate = MakeGate(strict: true, rows:
[
Row("grp-subs", NodePermissions.Subscribe),
]);
var identity = NewIdentity("alice", "grp-subs");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeTrue("Subscribe grant allows CreateMonitoredItems");
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Subscribe grant alone does NOT allow Read");
}
[Fact]
public void Read_grant_does_not_imply_Subscribe()
{
// Read-only sessions can read current values but must not be allowed to subscribe.
// This is a deliberate restriction: a data-centre operator monitoring a dashboard
// via an OPC UA subscription is a different grant tier than "read once on demand".
var gate = MakeGate(strict: true, rows:
[
Row("grp-readonly", NodePermissions.Read),
]);
var identity = NewIdentity("alice", "grp-readonly");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read grant allows Read");
gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeFalse("Read grant alone does NOT allow Subscribe");
}
// ======================================================================
// 3. Flag isolation — HistoryRead vs Read
// "HistoryRead uses its own flag" from Phase 6.2 Compliance Checklist
// ======================================================================
[Fact]
public void Read_grant_without_HistoryRead_denies_history_access()
{
// Phase 6.2 Compliance Checklist: "user with Read but not HistoryRead can read live
// values but gets BadUserAccessDenied on HistoryRead."
var gate = MakeGate(strict: true, rows:
[
Row("grp-read", NodePermissions.Read), // no HistoryRead bit
]);
var identity = NewIdentity("bob", "grp-read");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read granted for current values");
gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeFalse("HistoryRead NOT granted — own flag required");
}
[Fact]
public void HistoryRead_grant_without_Read_denies_current_value_read()
{
// Verify flag isolation in the other direction too — history archivers that can
// pull history should not implicitly get live-read access.
var gate = MakeGate(strict: true, rows:
[
Row("grp-hist", NodePermissions.HistoryRead), // no Read bit
]);
var identity = NewIdentity("carol", "grp-hist");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeTrue("HistoryRead granted for historical values");
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read NOT granted — own flag required");
}
// ======================================================================
// 4. Flag isolation — Alarm bits
// ======================================================================
[Fact]
public void AlarmAcknowledge_grant_does_not_imply_AlarmConfirm()
{
// Each alarm-action bit is distinct — operators can acknowledge without also
// having confirm authority.
var gate = MakeGate(strict: true, rows:
[
Row("grp-ack", NodePermissions.AlarmAcknowledge),
]);
var identity = NewIdentity("dave", "grp-ack");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue();
gate.IsAllowed(identity, OpcUaOperation.AlarmConfirm, scope).ShouldBeFalse("Confirm requires its own flag");
gate.IsAllowed(identity, OpcUaOperation.AlarmShelve, scope).ShouldBeFalse("Shelve requires its own flag");
}
[Fact]
public void Browse_grant_does_not_grant_AlarmAcknowledge()
{
// Browse is granted for hierarchy navigation; it must not cascade to alarm actions.
var gate = MakeGate(strict: true, rows:
[
Row("grp-browse", NodePermissions.Browse),
]);
var identity = NewIdentity("eve", "grp-browse");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue();
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeFalse();
}
// ======================================================================
// 5. AlarmShelve falls through to Call in MapCallOperation
// Documents the ShelvedStateMachine per-instance NodeId limitation.
// ======================================================================
[Fact]
public void MapCallOperation_AlarmShelve_falls_through_to_Call()
{
// AlarmShelve methods on ShelvedStateMachine arrive with per-instance NodeIds
// (not well-known type NodeIds), so they can't be reliably constant-matched.
// MapCallOperation returns OpcUaOperation.Call for any unrecognised method NodeId;
// operators who can Shelve must therefore have NodePermissions.MethodCall granted.
// (This is an intentional design decision documented in the MapCallOperation
// implementation remarks — finer-grained AlarmShelve gating is deferred until
// the method-invocation path also carries a "method-role" annotation.)
var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0);
DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call);
}
[Fact]
public void MethodCall_grant_allows_generic_Call_including_shelve_path()
{
// Users with MethodCall permission can invoke shelve methods because the gate
// maps AlarmShelve back to Call (see MapCallOperation_AlarmShelve_falls_through_to_Call).
var gate = MakeGate(strict: true, rows:
[
Row("grp-eng", NodePermissions.MethodCall),
]);
var identity = NewIdentity("frank", "grp-eng");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeTrue("MethodCall grant covers generic Call");
}
// ======================================================================
// 6. OpcUaOperation → NodePermissions mapping completeness (deferred ops)
// Ensures the TriePermissionEvaluator maps all deferred operations correctly.
// ======================================================================
[Theory]
[InlineData(OpcUaOperation.Browse, NodePermissions.Browse)]
[InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Subscribe)]
[InlineData(OpcUaOperation.TransferSubscriptions,NodePermissions.Subscribe)]
[InlineData(OpcUaOperation.Call, NodePermissions.MethodCall)]
[InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.AlarmAcknowledge)]
[InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.AlarmConfirm)]
[InlineData(OpcUaOperation.AlarmShelve, NodePermissions.AlarmShelve)]
public void Deferred_operation_maps_to_expected_permission_bit(OpcUaOperation op, NodePermissions required)
{
// Phase 6.2 Stream C compliance — every deferred gate operation must map to the
// correct NodePermissions bit in TriePermissionEvaluator. Verifies the full
// round-trip: grant exactly the required bit → IsAllowed returns true; no grant
// → false.
var gate = MakeGate(strict: true, rows: [Row("grp-test", required)]);
var identity = NewIdentity("tester", "grp-test");
var scope = Scope();
gate.IsAllowed(identity, op, scope).ShouldBeTrue(
$"operation {op} should be allowed when {required} bit is granted");
}
[Theory]
[InlineData(OpcUaOperation.Browse, NodePermissions.Read)] // wrong bit
[InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Read)] // wrong bit
[InlineData(OpcUaOperation.Call, NodePermissions.Browse)] // wrong bit
[InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.Browse)] // wrong bit
[InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.Browse)] // wrong bit
[InlineData(OpcUaOperation.AlarmShelve, NodePermissions.Browse)] // wrong bit
public void Deferred_operation_denied_when_wrong_permission_bit_granted(OpcUaOperation op, NodePermissions wrongBit)
{
var gate = MakeGate(strict: true, rows: [Row("grp-wrong", wrongBit)]);
var identity = NewIdentity("tester", "grp-wrong");
var scope = Scope();
gate.IsAllowed(identity, op, scope).ShouldBeFalse(
$"operation {op} must NOT be allowed by the {wrongBit} bit");
}
// ======================================================================
// 7. Mixed multi-group union for deferred gates
// ======================================================================
[Fact]
public void Multi_group_union_for_deferred_gates()
{
// A session belonging to both grp-browse (Browse only) and grp-ack (AlarmAck only)
// should be allowed both Browse and AlarmAcknowledge but not Read or Call.
var gate = MakeGate(strict: true, rows:
[
Row("grp-browse", NodePermissions.Browse),
Row("grp-ack", NodePermissions.AlarmAcknowledge),
]);
var identity = NewIdentity("grace", "grp-browse", "grp-ack");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue("Browse from first group");
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue("AlarmAcknowledge from second group");
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read not granted by either group");
gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeFalse("Call not granted by either group");
}
// ======================================================================
// 8. Strict vs lax for Browse gate (parity with existing BrowseGatingTests)
// ======================================================================
[Fact]
public void Browse_gate_strict_mode_denies_identity_with_ldap_groups_but_no_grant()
{
var refs = new List<ReferenceDescription> { NewRef("c1/area/line/eq/tag1") };
// Identity has groups but no Browse ACL → strict mode must deny
var gate = MakeGate(strict: true, rows: [Row("grp-other", NodePermissions.Read)]);
var resolver = new NodeScopeResolver(Cluster);
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
refs.Count.ShouldBe(0, "strict mode: no Browse grant → reference removed");
}
[Fact]
public void Browse_gate_strict_mode_allows_with_Browse_grant()
{
var refs = new List<ReferenceDescription>
{
NewRef("c1/area/line/eq/tag1"),
NewRef("c1/area/line/eq/tag2"),
};
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.Browse)]);
var resolver = new NodeScopeResolver(Cluster);
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
refs.Count.ShouldBe(2, "strict mode: Browse grant → both references pass through");
}
// ---- helpers -----------------------------------------------------------
private static NodeScope Scope() => new()
{
ClusterId = Cluster,
NamespaceId = "ns",
UnsAreaId = "area",
UnsLineId = "line",
EquipmentId = "eq",
TagId = "tag1",
Kind = NodeHierarchyKind.Equipment,
};
private static NodeAcl Row(string group, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = Guid.NewGuid().ToString(),
GenerationId = 1,
ClusterId = Cluster,
LdapGroup = group,
ScopeKind = NodeAclScopeKind.Cluster,
ScopeId = null,
PermissionFlags = flags,
};
private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows)
{
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, rows));
var evaluator = new TriePermissionEvaluator(cache);
return new AuthorizationGate(evaluator, strictMode: strict);
}
private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups);
private static MonitoredItemCreateRequest NewMonitorRequest(string fullRef) => new()
{
ItemToMonitor = new ReadValueId { NodeId = new NodeId(fullRef, 2) },
};
private static CallMethodRequest NewCall(string objectFullRef, NodeId methodId) => new()
{
ObjectId = new NodeId(objectFullRef, 2),
MethodId = methodId,
};
private static ReferenceDescription NewRef(string fullRef) => new()
{
NodeId = new NodeId(fullRef, 2),
BrowseName = new QualifiedName("browse"),
DisplayName = new LocalizedText("display"),
};
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
{
public FakeIdentity(string name, IReadOnlyList<string> groups)
{
DisplayName = name;
LdapGroups = groups;
}
public new string DisplayName { get; }
public IReadOnlyList<string> LdapGroups { get; }
}
}