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:
@@ -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/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/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
|
||||
|
||||
## v1 archive
|
||||
|
||||
@@ -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
197
docs/v2/phase-7-status.md
Normal 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
|
||||
(A–H, 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` |
|
||||
@@ -12,6 +12,98 @@
|
||||
</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)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
@@ -114,6 +206,41 @@ else if (_equipment.Count > 0)
|
||||
private Equipment _draft = NewBlankDraft();
|
||||
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()
|
||||
{
|
||||
EquipmentId = string.Empty, DriverInstanceId = string.Empty,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
@@ -18,6 +19,118 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db)
|
||||
.OrderBy(e => e.Name)
|
||||
.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) =>
|
||||
db.Equipment.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct);
|
||||
@@ -73,3 +186,14 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db)
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user