Merge #587/#588: one condition event per method-driven transition (native: Ack/AddComment only); model-change + audit events gated per session on i=2253 when ACL is on
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -938,11 +938,20 @@ Two things to know before relying on any of this:
|
||||
the SDK's audit guard (`AuditEventState` only while `Server.Auditing` over SignAndEncrypt/https),
|
||||
because conditions report their Part 9 method audit events through themselves.
|
||||
`ReconcileAllEventsMonitoring` must keep `AreEventsMonitored` raised on every condition while any
|
||||
`i=2253` item exists, because `ConditionState` gates its own state-change and audit reports on
|
||||
`EventsMonitored()`. The protected `SubscribeToEvents` override that no-ops all-events items is what
|
||||
`i=2253` item exists, because `ConditionState` gates its method **audit** reports on
|
||||
`EventsMonitored()` (method-driven state-change events no longer depend on it — #587, below). The
|
||||
protected `SubscribeToEvents` override that no-ops all-events items is what
|
||||
stops the SDK's `AddRootNotifier` (`unsubscribe: true` bug) taking that unit back. Both review
|
||||
findings (review of `75e559f6`). ⚠️ Residual: audit/model-change events the manager reports straight
|
||||
to `Server.ReportEvent` still go to the core Server object, ungated.
|
||||
findings (review of `75e559f6`). ⚠️ The materialise path raises only the **new** condition's unit
|
||||
(`RaiseAllEventsMonitoring`); a full reconcile there made a deploy of N alarms O(N²) under `Lock` with
|
||||
ScadaBridge connected — keep the full reconcile to remove/rebuild/(un)subscribe.
|
||||
**Model-change and audit events are gated too (Gitea #588).** The manager's own server-wide events
|
||||
(`GeneralModelChangeEvent`, the rejected-write `AuditWriteUpdateEvent`) go through
|
||||
`ReportToServerObject`: flag **off** ⇒ `Server.ReportEvent` as before; flag **on** ⇒ delivered per item to
|
||||
`_allEventsItems` (same audience), a model change narrowed to the `Affected` nodes the session may
|
||||
**Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral
|
||||
one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other
|
||||
event type fails closed. Auditing is off by default (never configured in this repo).
|
||||
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
||||
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
||||
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
|
||||
@@ -1092,6 +1101,21 @@ See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` t
|
||||
|
||||
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` LDAP role via `RoleCarryingUserIdentity`; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
|
||||
|
||||
⚠️ **One condition event per accepted method call is OURS, not the SDK's (Gitea #587).** The SDK's own
|
||||
report is gated on `AutoReportStateChanges` (left `false`), so before #587 a client Acknowledge emitted **no**
|
||||
condition event — the old "SDK auto-fires its own event (E2)" comments were wrong. `WireMethodTransitionReporting`
|
||||
wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and,
|
||||
on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection
|
||||
(routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no
|
||||
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real
|
||||
before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips);
|
||||
`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve
|
||||
failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and
|
||||
Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s
|
||||
Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the
|
||||
SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by
|
||||
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
|
||||
|
||||
## Historian / HistoryRead
|
||||
|
||||
**Backend: HistorianGateway (sole historian backend).** As of the gateway-integration cutover, the
|
||||
|
||||
+74
-8
@@ -314,9 +314,30 @@ from the scripted `AlarmCommandRouter`:
|
||||
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
|
||||
`AlarmConditionState` SDK update already committed at step 1).
|
||||
|
||||
Only the **Acknowledge** is routed to the driver. `Confirm` / `AddComment`
|
||||
/ `Shelve` operations on a native condition stay on the scripted
|
||||
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only).
|
||||
Only the **Acknowledge** is routed to the driver. **A native condition
|
||||
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
|
||||
`OneShotShelve` / `TimedShelve` / `Unshelve` and `Enable` / `Disable` return
|
||||
`BadNotSupported` and report no event. There is no upstream path for them and
|
||||
no pair replication of a local state, and the driver projection
|
||||
(`NativeAlarmProjector`: always `Confirmed`, `Unshelved`) would silently undo
|
||||
one at the next transition. `AddComment` stays on the scripted
|
||||
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only) and is
|
||||
applied locally.
|
||||
|
||||
A native condition is **never Unconfirmed**: it starts Confirmed, and because
|
||||
the SDK's own Acknowledge sets `ConfirmedState = false` on any condition type
|
||||
that supports Confirm, the node manager re-asserts `Confirmed = true` (and the
|
||||
projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
|
||||
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
|
||||
is client-visible.
|
||||
|
||||
> **Secondary-node ack flap.** On a node that is not the redundancy Primary,
|
||||
> an OPC UA Acknowledge is applied to the local condition and reported (one
|
||||
> event, Acked), but `DriverHostActor`'s Primary gate drops the route upstream.
|
||||
> The driver never acknowledges, so its next transition for that alarm is
|
||||
> projected with `Acked = false` and the node reverts — with its own event. A
|
||||
> client acking against the Secondary sees acked → unacked. Ack against the
|
||||
> Primary (the higher ServiceLevel) for the ack to stick.
|
||||
|
||||
> **Best-effort during reconnect.** If a native ack arrives while the owning
|
||||
> driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is
|
||||
@@ -371,11 +392,56 @@ hosted service.
|
||||
`OnTimedUnshelve` (the SDK's automatic unshelve timer) bypasses the
|
||||
operator gate — it is system-initiated.
|
||||
|
||||
`WriteAlarmCondition` fires the Part 9 condition event only when the
|
||||
incoming state differs from the node's current live state (delta-gate),
|
||||
preventing the double-emit that would otherwise occur when the SDK
|
||||
auto-applies the acked state and the engine re-projection fires a
|
||||
duplicate event immediately after.
|
||||
### One condition event per method call (Gitea #587)
|
||||
|
||||
Every accepted Part 9 method call — Acknowledge, Confirm, AddComment,
|
||||
OneShotShelve, TimedShelve, Unshelve, Enable, Disable, on a native or a
|
||||
scripted condition — reports **exactly one** condition event carrying the
|
||||
new state, to folder subscribers and (through the ACL) Server-object
|
||||
subscribers alike. A refused call (bad status, e.g. a denied gate, an
|
||||
already-acknowledged condition, Enable/Disable on a native condition)
|
||||
reports nothing.
|
||||
|
||||
- **The SDK does not report it.** `ConditionState.ReportStateChange` is
|
||||
gated on `AutoReportStateChanges`, which this repo leaves `false` — so
|
||||
before #587 a client Acknowledge produced no condition event at all (an
|
||||
older comment claimed "the SDK auto-fires its own event"; it never did).
|
||||
Instead the node manager wraps each method's SDK handler
|
||||
(`WireMethodTransitionReporting`) and, once the handler returns Good,
|
||||
reports the condition itself. That report does not depend on the SDK's
|
||||
`AreEventsMonitored` bookkeeping, which is imprecise for conditions
|
||||
materialised under an already-subscribed folder.
|
||||
- **The SDK's shelve methods never apply shelving state** (they only call
|
||||
`OnShelve`), so the wrapper applies it — with the same
|
||||
`(shelved, oneShot, 0)` mapping the projection uses — before reporting.
|
||||
- **The re-projection does not duplicate it.** The engine's (or driver's)
|
||||
answer to the routed command reaches `WriteAlarmCondition`'s delta-gate,
|
||||
which compares against the node's current state — already the new state —
|
||||
and suppresses. The whole method call runs under the node-manager lock, so
|
||||
a fast re-projection cannot slip in between the SDK applying the state and
|
||||
the report.
|
||||
- **Only a real change is reported.** The condition's state is compared
|
||||
before and after the SDK handler; a call that changed nothing reports
|
||||
nothing. The SDK accepts acknowledging an acknowledged condition and sets
|
||||
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
|
||||
and Retain — silent, and a scripted node never drifts from its engine.
|
||||
AddComment always reports (the comment is the change).
|
||||
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
|
||||
acknowledge → AVEVA" above. The other methods are refused
|
||||
(`BadNotSupported`, no event), and a native ack keeps the condition
|
||||
Confirmed.
|
||||
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
|
||||
the SDK rejects any shelving time above it, so every client TimedShelve
|
||||
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
|
||||
days). The node applies the shelve with no SDK timer of its own; the engine
|
||||
owns the expiry and its Unshelved re-projection reports the one expiry
|
||||
event.
|
||||
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
|
||||
the SDK's, delivered only while the server is auditing over an encrypted
|
||||
channel.
|
||||
|
||||
Engine-driven transitions are unaffected: `WriteAlarmCondition` still fires
|
||||
whenever the incoming state differs from the node's current state.
|
||||
|
||||
### AdminUI path
|
||||
|
||||
|
||||
@@ -352,13 +352,13 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma
|
||||
|
||||
**Scripted vs native conditions — Enable/Disable and Acknowledge:**
|
||||
- **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation.
|
||||
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable` returns `BadNotSupported` immediately — native conditions have no engine-side enable/disable surface.
|
||||
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review).
|
||||
|
||||
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.
|
||||
|
||||
### Delta-gate de-duplication
|
||||
|
||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. This suppresses the double-emit that would otherwise occur when the OPC UA SDK auto-applies the acked state on the node and the engine's re-projection then attempts to fire a duplicate event.
|
||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. a re-ack — reports nothing). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`. The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
|
||||
|
||||
### AdminUI path
|
||||
|
||||
|
||||
+27
-8
@@ -564,18 +564,37 @@ Behaviours worth knowing before enabling it:
|
||||
condition reports through itself (`AuditConditionAcknowledgeEventType`, Comment, Enable, Shelving)
|
||||
reach a Server-object item only while the server is auditing, over SignAndEncrypt or https-binary,
|
||||
which is exactly the SDK's own guard. And while any Server-object item exists, every condition counts as
|
||||
**monitored** (`ConditionState.EventsMonitored()`), so the SDK still reports its method-driven state
|
||||
changes on a server whose only subscribers are Server-object items. Both were caught in review: the
|
||||
**monitored** (`ConditionState.EventsMonitored()`), which gates the SDK's method **audit** events on a
|
||||
server whose only subscribers are Server-object items. Both were caught in review: the
|
||||
first cut queued `i=8944` to every Server-object item with auditing off, and left conditions
|
||||
unmonitored. The second also needed the SDK's `AddRootNotifier` neutralised: its "subscribe existing
|
||||
all-events items with `unsubscribe: true`" bug would otherwise take the unit back from every
|
||||
condition rebuilt under a live item.
|
||||
condition rebuilt under a live item. (Method-driven **state-change** events no longer depend on this
|
||||
bookkeeping at all — the node manager reports them itself, Gitea #587; see `docs/AlarmTracking.md`.
|
||||
The SDK's counter stays imprecise in one known way — a folder subscriber's unit never reaches a
|
||||
condition materialised under that folder later, yet its unsubscribe still lowers it — which can now
|
||||
only cost an audit event, and only while auditing is on.)
|
||||
- Materialising a condition raises only **that** condition's monitored unit; the full reconcile (every
|
||||
condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a
|
||||
live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock.
|
||||
|
||||
Residual, not closed by #586: **audit and model-change events** (`AuditWriteUpdateEvent`,
|
||||
`GeneralModelChangeEvent`, …) are still reported straight to the Server object and reach every
|
||||
Server-object item ungated. They are delivered only when auditing / model-change reporting is on (and
|
||||
audit events only over an encrypted channel), and a node manager has no seam on the core Server
|
||||
object's fan-out to filter them.
|
||||
**Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's
|
||||
own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the
|
||||
`AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where
|
||||
the core Server object queued them to every Server-object item ungated: a session with no grant learned
|
||||
the RawPath / UNS path of every node added, deleted or retyped, and (with auditing on) the written and
|
||||
prior values of a rejected write. With the flag **on**, the node manager now delivers them itself, item by
|
||||
item, to the Server-object items it already holds for #586 (the same audience — only Server-object items
|
||||
ever received them):
|
||||
- A **model-change** event keeps only the changes whose `Affected` node the session may **Browse**
|
||||
(ancestors of a granted node included, as for Browse itself). Every event the server raises today carries
|
||||
one change, so in practice it is delivered whole or not at all; a multi-change event would be delivered
|
||||
as a copy holding only the visible changes, with a neutral `Message` (the original names the nodes).
|
||||
- An **audit write** event needs `Read` on its source node — it carries the node's prior value and the
|
||||
attempted one — on top of the SDK's own guard (server auditing on, SignAndEncrypt or https-binary).
|
||||
Auditing stays **off by default**: this repo never sets `ServerConfiguration.AuditingEnabled`.
|
||||
- Any other server-wide event type is withheld (fail closed); the node manager raises none today.
|
||||
- With the flag **off**, these events go to `Server.ReportEvent` exactly as before.
|
||||
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
|
||||
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
|
||||
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible
|
||||
|
||||
@@ -825,9 +825,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
|
||||
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
|
||||
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
|
||||
// SDK mutated AckedState + auto-fired its own event) NEVER passed through this method, so a
|
||||
// last-written cache would be stale and wrongly report a delta. By the time the engine
|
||||
// re-projects that ack here, the node already holds the acked state → no delta → suppress.
|
||||
// SDK mutated AckedState, then WireMethodTransitionReporting reported the one event for it —
|
||||
// Gitea #587) NEVER passed through this method, so a last-written cache would be stale and
|
||||
// wrongly report a delta. By the time the engine re-projects that ack here, the node already
|
||||
// holds the acked state → no delta → suppress.
|
||||
var current = ReadConditionDelta(condition);
|
||||
var incoming = ToConditionDelta(state, condition);
|
||||
bool fire = ShouldFireConditionEvent(current, incoming);
|
||||
@@ -881,8 +882,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// change (the delta-gate decided above, against the node's pre-projection state). A
|
||||
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
|
||||
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
|
||||
// of a client ack the SDK already applied equals the node's current state → no delta →
|
||||
// suppress, so we don't double-emit (E2 from the SDK + E3 from here). ReportConditionEvent
|
||||
// of a client method call already applied (and already reported, by the method path —
|
||||
// WireMethodTransitionReporting) equals the node's current state → no delta → suppress, so
|
||||
// the transition is reported exactly once. ReportConditionEvent
|
||||
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
|
||||
if (fire)
|
||||
{
|
||||
@@ -970,16 +972,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// materialise) — no branch creation here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Double-emit note (resolved by delta-gate).</b> An inbound client Acknowledge/Confirm
|
||||
/// goes through the SDK's own handler, which (after T18's gate returns Good) applies the acked
|
||||
/// state to the node and auto-fires its own condition event (E2) — directly on the node,
|
||||
/// BYPASSING <see cref="WriteAlarmCondition"/>. The engine then re-projects that same logical
|
||||
/// transition through <see cref="WriteAlarmCondition"/>, which would otherwise fire a second
|
||||
/// event (E3). <see cref="WriteAlarmCondition"/>'s delta-gate suppresses E3: it compares the
|
||||
/// incoming snapshot against the NODE's CURRENT state, and because the SDK has ALREADY
|
||||
/// pre-applied the inbound-ack state, the re-projection is a no-delta no-op (no fire). Genuine
|
||||
/// engine-driven transitions still differ from the node's current state, so they fire here as
|
||||
/// before.
|
||||
/// <b>Method-driven transitions (Gitea #587).</b> An inbound client Acknowledge / Confirm /
|
||||
/// AddComment / shelve / Enable / Disable goes through the SDK's own handler, which (after T18's
|
||||
/// gate returns Good) applies the new state to the node, BYPASSING <see cref="WriteAlarmCondition"/>.
|
||||
/// The SDK reports <b>no</b> event for it — its <c>ReportStateChange</c> is gated on
|
||||
/// <c>AutoReportStateChanges</c>, which stays false — so the method path calls this method itself,
|
||||
/// once, after the handler returns (<see cref="WireMethodTransitionReporting"/>). The engine then
|
||||
/// re-projects that same transition through <see cref="WriteAlarmCondition"/>, whose delta-gate
|
||||
/// compares against the NODE's CURRENT state, finds no change, and suppresses a second event. (An
|
||||
/// earlier comment here claimed the SDK "auto-fires its own event (E2)"; it never did, so a client
|
||||
/// ack produced no event at all.) Genuine engine-driven transitions still differ from the node's
|
||||
/// current state, so they fire here as before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
|
||||
@@ -1046,11 +1049,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
|
||||
/// <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
|
||||
/// <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
|
||||
/// <c>ServiceResult.Good</c>, so the OPC UA SDK itself applies the comment to the node
|
||||
/// and auto-fires the Part 9 comment event (E2) directly to subscribers — BEFORE the
|
||||
/// engine re-projects via <see cref="WriteAlarmCondition"/>. When that re-projection
|
||||
/// arrives here, the delta-gate sees no change in any compared field (the snapshot carries
|
||||
/// no comments list) and correctly suppresses a second event (E3). Force-firing for
|
||||
/// <c>ServiceResult.Good</c>, the OPC UA SDK applies the comment to the node, and the method
|
||||
/// path reports the one Part 9 comment event (<see cref="WireMethodTransitionReporting"/>,
|
||||
/// Gitea #587) — BEFORE the engine re-projects via <see cref="WriteAlarmCondition"/>. When that
|
||||
/// re-projection arrives here, the delta-gate sees no change in any compared field (the snapshot
|
||||
/// carries no comments list) and correctly suppresses a second event. Force-firing for
|
||||
/// <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
|
||||
/// comment path, so suppression never drops a needed event.
|
||||
/// </para>
|
||||
@@ -1201,6 +1204,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
UnwireAlarmNotifiers(conditionKey);
|
||||
existing.Parent?.RemoveChild(existing);
|
||||
PredefinedNodes?.Remove(existing.NodeId);
|
||||
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
|
||||
// dropped instance's back here rather than leave it tracked until the next full reconcile.
|
||||
if (_allEventsMonitoredConditions.Remove(existing))
|
||||
existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
|
||||
}
|
||||
|
||||
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
|
||||
@@ -1276,7 +1283,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
alarm.SetEnableState(SystemContext, true);
|
||||
alarm.SetActiveState(SystemContext, false);
|
||||
alarm.SetAcknowledgedState(SystemContext, true);
|
||||
// A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is
|
||||
// refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector
|
||||
// supplies on every transition, rather than the SDK's default false until the first projection.
|
||||
if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true);
|
||||
alarm.SetSeverity(SystemContext, MapSeverity(severity));
|
||||
// Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's
|
||||
// TimedShelve handler rejects any ShelvingTime above it — so every client TimedShelve failed with
|
||||
// BadShelvingTimeOutOfRange. The bound becomes the longest shelve the SDK's own shelving timer can
|
||||
// represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to).
|
||||
if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0)
|
||||
maxTimeShelved.Value = int.MaxValue;
|
||||
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
|
||||
alarm.Message.Value = new LocalizedText(displayName);
|
||||
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
|
||||
@@ -1286,24 +1303,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
|
||||
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
|
||||
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
|
||||
// SDK applies its node state + auto-fires its own event (E2).
|
||||
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
|
||||
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
|
||||
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
|
||||
// SDK applies its node state. The SDK does NOT report an event for it (its ReportStateChange is
|
||||
// gated on AutoReportStateChanges, which stays false) — the ONE condition event per transition
|
||||
// is ours, raised after the SDK handler returns by WireMethodTransitionReporting (Gitea #587).
|
||||
// The engine's later re-projection of the same transition reaches WriteAlarmCondition's
|
||||
// delta-gate, which finds the node already in that state and suppresses a second event.
|
||||
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
|
||||
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
|
||||
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/
|
||||
// Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
|
||||
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. AddComment stays on the
|
||||
// scripted path even for native conditions (H6c scope is Acknowledge only).
|
||||
// Gitea #587 review: a NATIVE condition supports Acknowledge and AddComment only. Confirm and the
|
||||
// shelve methods return BadNotSupported (like Enable/Disable below) — there is no upstream path for
|
||||
// them, no pair replication of a local state, and the driver projection (Confirmed, Unshelved) would
|
||||
// silently undo one at the next transition.
|
||||
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
||||
IsNativeAlarmNode(alarmNodeId, realm)
|
||||
? HandleNativeAlarmAck(context, condition, comment)
|
||||
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
||||
alarm.OnConfirm = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
||||
IsNativeAlarmNode(alarmNodeId, realm)
|
||||
? new ServiceResult(StatusCodes.BadNotSupported)
|
||||
: HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
||||
alarm.OnAddComment = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
|
||||
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
|
||||
{
|
||||
if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported);
|
||||
|
||||
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
|
||||
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
|
||||
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
|
||||
@@ -1348,6 +1374,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
|
||||
};
|
||||
|
||||
// Gitea #587: exactly one condition event per accepted method call, reported by us once the SDK's
|
||||
// own handler has applied the new state.
|
||||
WireMethodTransitionReporting(alarm, conditionKey);
|
||||
|
||||
// Gitea #586: the condition itself delivers its events to Server-object (all-events)
|
||||
// subscriptions, item by item through the ACL. A condition is never an event notifier, so no
|
||||
// MonitoredNode2 ever claims this single-slot delegate — every event the condition reports, ours
|
||||
@@ -1363,7 +1393,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
AddPredefinedNode(SystemContext, alarm);
|
||||
_alarmConditions[conditionKey] = alarm;
|
||||
ReconcileAllEventsMonitoring();
|
||||
// Only the new condition can be missing its unit here, so raise just that one — a full reconcile
|
||||
// walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object
|
||||
// item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile.
|
||||
RaiseAllEventsMonitoring(alarm);
|
||||
// H6a: record native (driver-fed) conditions so a later task can route their inbound
|
||||
// Acknowledge to the driver rather than the scripted engine.
|
||||
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
|
||||
@@ -1487,6 +1520,148 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_alarmNotifierWiring.Remove(alarmKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps each Part 9 method the SDK wired on <paramref name="alarm"/> (Acknowledge, Confirm, AddComment,
|
||||
/// Enable, Disable, OneShotShelve, TimedShelve, Unshelve) so an accepted call reports exactly one
|
||||
/// condition event carrying the post-call state (Gitea #587).
|
||||
/// </summary>
|
||||
/// <param name="alarm">The freshly created condition; its method children carry the SDK's handlers.</param>
|
||||
/// <param name="conditionKey">Its key in <c>_alarmConditions</c>.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why not <c>AutoReportStateChanges</c>.</b> The SDK's own report from a method handler
|
||||
/// (<c>ConditionState.ReportStateChange</c>) is gated on <c>AutoReportStateChanges</c> — which this
|
||||
/// repo had never set, so a client Acknowledge produced no condition event at all, and the engine's
|
||||
/// re-projection found the node already acked and suppressed its own — and then on
|
||||
/// <c>EventsMonitored()</c>, the <c>AreEventsMonitored</c> counter. That counter is imprecise in the
|
||||
/// SDK: a folder subscriber's unit never reaches a condition materialised under the folder later, yet
|
||||
/// the folder's unsubscribe still lowers it, which can take away the Server-object unit while an
|
||||
/// <c>i=2253</c> item lives (#586 review). And the SDK's shelve handlers report <i>before</i> any
|
||||
/// shelving state is applied (they only call <c>OnShelve</c>), so their event would carry the old
|
||||
/// state and the engine's re-projection would then report a second one. Reporting ourselves, after
|
||||
/// the handler returns, depends on none of that: <see cref="ReportConditionEvent"/> walks the notifier
|
||||
/// graph unconditionally, and Server-object delivery goes through <see cref="OnConditionReportEvent"/>
|
||||
/// and the ACL like every other condition event.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shelving is applied here.</b> The SDK's shelve methods never change <c>ShelvingState</c>
|
||||
/// themselves, so an accepted shelve call applies it — with the same <c>(shelved, oneShot, 0)</c>
|
||||
/// mapping <see cref="WriteAlarmCondition"/> projects — before reporting. The engine's re-projection of
|
||||
/// the shelve then finds no delta. Every other method's state the SDK has already applied.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Exactly once.</b> The whole call runs under <c>Lock</c>. The method handlers hand the command
|
||||
/// to the engine (a fire-and-forget Tell) before the SDK applies the state, so without the lock a fast
|
||||
/// re-projection could take <c>Lock</c> first, see a delta, and report, followed by this report — two
|
||||
/// events. Holding it makes the re-projection wait until the node holds the new state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Native conditions support Acknowledge and AddComment only</b> (Confirm and the shelve methods
|
||||
/// are refused with <c>BadNotSupported</c> before they get here). Their Confirmed and Shelving stay
|
||||
/// owned by the driver projection (<c>NativeAlarmProjector</c>: always Confirmed, Unshelved). The SDK's
|
||||
/// Acknowledge sets <c>Confirmed = false</c> because the condition type supports Confirm, which would
|
||||
/// leave every native alarm Unconfirmed after its first OPC UA ack — ScadaBridge maps
|
||||
/// <c>ConfirmedState</c> straight into its alarm state — so a native ack re-asserts Confirmed and the
|
||||
/// projection's Retain rule (<c>Active || !Acked</c>) before the one report.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Only a real change is reported.</b> The condition's gate-relevant state is read before and after
|
||||
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
|
||||
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
|
||||
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores a
|
||||
/// re-ack) and the call is silent. AddComment always reports: adding a comment is itself the change.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. a denied gate, or a refused method on a native
|
||||
/// condition) reports nothing: there was no transition.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void WireMethodTransitionReporting(AlarmConditionState alarm, RealmKey conditionKey)
|
||||
{
|
||||
alarm.AutoReportStateChanges = false; // the SDK's own report stays off; ours replaces it (see remarks)
|
||||
|
||||
if (alarm.Acknowledge?.OnCall is { } acknowledge)
|
||||
{
|
||||
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
|
||||
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true);
|
||||
}
|
||||
|
||||
WrapComment(alarm.Confirm, alwaysReport: false);
|
||||
WrapComment(alarm.AddComment, alwaysReport: true);
|
||||
WrapGeneric(alarm.Enable, apply: null);
|
||||
WrapGeneric(alarm.Disable, apply: null);
|
||||
|
||||
if (alarm.ShelvingState is { } shelving)
|
||||
{
|
||||
WrapGeneric(shelving.OneShotShelve, () => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: true, shelvingTime: 0));
|
||||
WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0));
|
||||
if (shelving.TimedShelve is { OnCall: { } timed } timedShelve)
|
||||
{
|
||||
// shelvingTime 0, as WriteAlarmCondition projects it: the scripted engine owns the expiry and
|
||||
// re-projects Unshelved when it passes (the SDK's own timer only routes an Unshelve back to it).
|
||||
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
|
||||
() => timed(context, method, objectId, shelvingTime),
|
||||
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: 0));
|
||||
}
|
||||
}
|
||||
|
||||
void WrapComment(AddCommentMethodState? method, bool alwaysReport)
|
||||
{
|
||||
if (method?.OnCall is not { } sdk) return;
|
||||
method.OnCall = (context, m, objectId, eventId, comment) =>
|
||||
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null, alwaysReport: alwaysReport);
|
||||
}
|
||||
|
||||
void WrapGeneric(MethodState? method, Action? apply)
|
||||
{
|
||||
if (method?.OnCallMethod is not { } sdk) return;
|
||||
method.OnCallMethod = (context, m, inputs, outputs) =>
|
||||
ReportAfterMethod(() => sdk(context, m, inputs, outputs), apply);
|
||||
}
|
||||
|
||||
ServiceResult ReportAfterMethod(
|
||||
Func<ServiceResult> sdkHandler, Action? apply, bool isAcknowledge = false, bool alwaysReport = false)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var before = ReadConditionDelta(alarm);
|
||||
var retainBefore = alarm.Retain.Value;
|
||||
|
||||
var result = sdkHandler();
|
||||
// A rebuild may have discarded this instance mid-call; its replacement is not what was called.
|
||||
if (!ServiceResult.IsGood(result)
|
||||
|| !_alarmConditions.TryGetValue(conditionKey, out var live)
|
||||
|| !ReferenceEquals(live, alarm))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
apply?.Invoke();
|
||||
|
||||
if (isAcknowledge && alarm.ConfirmedState is not null)
|
||||
{
|
||||
if (_nativeAlarmNodeIds.Contains(conditionKey))
|
||||
{
|
||||
// Native: no confirm workflow — keep what the driver projection says (see remarks).
|
||||
alarm.SetConfirmedState(SystemContext, true);
|
||||
alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true);
|
||||
}
|
||||
else if (before.Acknowledged)
|
||||
{
|
||||
// Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back.
|
||||
alarm.SetConfirmedState(SystemContext, before.Confirmed);
|
||||
alarm.Retain.Value = retainBefore;
|
||||
}
|
||||
}
|
||||
|
||||
if (alwaysReport || ReadConditionDelta(alarm) != before)
|
||||
ReportConditionEvent(alarm, DateTime.UtcNow);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
|
||||
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
|
||||
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
|
||||
@@ -1513,8 +1688,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
|
||||
/// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
|
||||
/// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its
|
||||
/// event); <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state, and the method path
|
||||
/// reports the one condition event — Gitea #587);<c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||
private ServiceResult HandleAlarmCommand(
|
||||
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
|
||||
{
|
||||
@@ -1534,7 +1709,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
|
||||
AlarmCommandRouter?.Invoke(cmd);
|
||||
|
||||
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
||||
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
||||
// condition event (the SDK reports none of its own — Gitea #587).
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
@@ -1552,7 +1728,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <param name="condition">The condition the Acknowledge targets; its <c>NodeId</c> identifier is the
|
||||
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
|
||||
/// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its event);
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state; the method path reports the event);
|
||||
/// <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
|
||||
{
|
||||
@@ -1571,7 +1747,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
Comment: comment?.Text,
|
||||
OperatorUser: AlarmOperatorName(identity!)));
|
||||
|
||||
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
||||
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
||||
// condition event (the SDK reports none of its own — Gitea #587).
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
@@ -2054,7 +2231,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, auditEvent);
|
||||
ReportToServerObject(auditEvent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2417,7 +2594,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, BuildNodeShapeChangedEvent(variable));
|
||||
ReportToServerObject(BuildNodeShapeChangedEvent(variable));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2461,7 +2638,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent).
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, e);
|
||||
ReportToServerObject(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2647,7 +2824,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, e);
|
||||
ReportToServerObject(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3484,9 +3661,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <c>SubscribeToEvents</c>, which also calls <c>SetAreEventsMonitored(true, includeChildren)</c>
|
||||
/// on the notifier — a counter that propagates to every condition beneath or wired to it. Not
|
||||
/// attaching the items lost that bookkeeping, and a condition reads it: <c>ConditionState</c>
|
||||
/// reports its own method-driven state changes (and their audit events) only while
|
||||
/// <c>EventsMonitored()</c> is true. A server whose only subscribers are Server-object items — the
|
||||
/// common machine-consumer shape — would otherwise report none of them.
|
||||
/// reports its method audit events only while <c>EventsMonitored()</c> is true. A server whose only
|
||||
/// subscribers are Server-object items — the common machine-consumer shape — would otherwise report
|
||||
/// none of them. (The method-driven <i>state-change</i> events no longer depend on this: the SDK
|
||||
/// reports none, and <see cref="WireMethodTransitionReporting"/> reports them unconditionally —
|
||||
/// Gitea #587. That also takes the SDK counter's known imprecision — a folder subscriber's unit never
|
||||
/// reaches a condition materialised under the folder later, yet its unsubscribe still lowers it — off
|
||||
/// the state-change path; it can now cost at most an audit event while auditing is on.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Held per <b>condition</b>, exactly one unit each, rather than per root notifier per item as the
|
||||
@@ -3507,6 +3688,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
: new HashSet<AlarmConditionState>(
|
||||
_alarmConditions.Values, System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
|
||||
AllEventsMonitoringConditionVisits += desired.Count + _allEventsMonitoredConditions.Count;
|
||||
|
||||
foreach (var condition in _allEventsMonitoredConditions.Where(c => !desired.Contains(c)).ToList())
|
||||
{
|
||||
condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
|
||||
@@ -3521,6 +3704,32 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The incremental form of <see cref="ReconcileAllEventsMonitoring"/> for one newly materialised
|
||||
/// condition: raises its unit when a Server-object item exists, touching no other condition. Call under
|
||||
/// <c>Lock</c>.
|
||||
/// </summary>
|
||||
/// <param name="condition">The condition just added to <c>_alarmConditions</c>.</param>
|
||||
/// <remarks>
|
||||
/// Equivalent to the full reconcile on the materialise path, because materialising adds one condition
|
||||
/// and removes none (a same-key kind-swap gives the dropped instance's unit back itself). Kept separate
|
||||
/// so a deploy of N alarms costs N set operations rather than N²/2 (review of Gitea #586).
|
||||
/// </remarks>
|
||||
private void RaiseAllEventsMonitoring(AlarmConditionState condition)
|
||||
{
|
||||
AllEventsMonitoringConditionVisits++;
|
||||
if (!_allEventsItems.IsEmpty && _allEventsMonitoredConditions.Add(condition))
|
||||
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many conditions the Server-object monitored bookkeeping has visited since start — every
|
||||
/// condition a full <see cref="ReconcileAllEventsMonitoring"/> walks, plus one per incremental
|
||||
/// <see cref="RaiseAllEventsMonitoring"/>. Test observability: bounds the bookkeeping cost of a deploy.
|
||||
/// </summary>
|
||||
/// <remarks>Mutated only under <c>Lock</c>.</remarks>
|
||||
internal long AllEventsMonitoringConditionVisits { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -3533,7 +3742,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only condition events bubble through this manager's notifiers; the audit and model-change
|
||||
/// events it raises go to <c>Server.ReportEvent</c> directly and are untouched by this.
|
||||
/// events it raises never reach this, and go through <see cref="ReportToServerObject"/> instead
|
||||
/// (Gitea #588).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected override void OnReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
|
||||
@@ -3608,6 +3818,130 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|| endpoint?.TransportProfileUri == Profiles.HttpsBinaryTransport;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a server-wide event this manager raises — a <c>GeneralModelChangeEvent</c> or an audit event
|
||||
/// — to the Server object's (<c>i=2253</c>) subscribers, through the data-plane ACL when it is enforced
|
||||
/// (Gitea #588).
|
||||
/// </summary>
|
||||
/// <param name="e">The built event.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These events are not condition events, so they never pass a notifier of ours: they went straight
|
||||
/// to <c>Server.ReportEvent</c>, where the core node manager's Server object queues them to every
|
||||
/// all-events item with no ACL decision. A model-change event names the RawPath / UNS path of the
|
||||
/// node it is about, so a session holding no grant learned the names of nodes it could not browse;
|
||||
/// an audit write event would carry the written value to it too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With enforcement on, this manager delivers them itself, item by item, to the all-events items it
|
||||
/// already holds for #586 — the same audience <c>Server.ReportEvent</c> reached, since only Server-object
|
||||
/// items receive what is reported at the Server object. Each item receives only what its session may
|
||||
/// see (<see cref="ServerObjectEventFor"/>); everything else the SDK's Server object did — its audit
|
||||
/// guard, its role-permission check — is kept. With enforcement off the event goes to
|
||||
/// <c>Server.ReportEvent</c> exactly as before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Called outside <c>Lock</c>, like the <c>Server.ReportEvent</c> call it replaces; the decision
|
||||
/// takes no node-manager lock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void ReportToServerObject(BaseEventState e)
|
||||
{
|
||||
if (!AclEnforced)
|
||||
{
|
||||
Server.ReportEvent(SystemContext, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerInternalData.ReportEvent's own guard: no audit event leaves the server while it is not auditing.
|
||||
if (e is AuditEventState && !Server.Auditing) return;
|
||||
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
if (ServerObjectEventFor(item, e) is not { } visible) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
|
||||
item.QueueEvent(visible);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What of a server-wide event <paramref name="item"/>'s session may receive with enforcement on: the
|
||||
/// event itself, a copy narrowed to what it may see, or nothing (Gitea #588).
|
||||
/// </summary>
|
||||
/// <param name="item">A Server-object event item.</param>
|
||||
/// <param name="e">The event.</param>
|
||||
/// <returns>The event to queue, or <see langword="null"/> to withhold it.</returns>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// A <b>model-change</b> event keeps only the changes whose <c>Affected</c> node the session may
|
||||
/// <c>Browse</c> (<see cref="FilterModelChangeFor"/>) — knowing a node exists is exactly what
|
||||
/// Browse grants, ancestors of a granted node included.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// An <b>audit</b> event needs the SDK's own delivery guard (<see cref="IsAuditDeliverableTo"/>)
|
||||
/// <b>and</b> <c>Read</c> on its source node: an <c>AuditWriteUpdateEvent</c> carries the node's
|
||||
/// prior value and the attempted one, which is what <c>Read</c> protects.
|
||||
/// </item>
|
||||
/// <item>Anything else is withheld: this manager reports no other server-wide event type, so an
|
||||
/// unrecognised one fails closed rather than slipping through ungated.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
private BaseEventState? ServerObjectEventFor(IEventMonitoredItem item, BaseEventState e)
|
||||
{
|
||||
var identity = item.EffectiveIdentity as RoleCarryingUserIdentity;
|
||||
return e switch
|
||||
{
|
||||
GeneralModelChangeEventState modelChange => FilterModelChangeFor(identity, modelChange),
|
||||
AuditEventState audit => IsAuditDeliverableTo(item)
|
||||
&& audit.SourceNode?.Value is { IsNullNodeId: false } source
|
||||
&& Permits(identity, OpcUaOperation.Read, source)
|
||||
? audit
|
||||
: null,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A model-change event narrowed to the changes whose affected node <paramref name="identity"/> may
|
||||
/// browse: the event itself when every change is visible, <see langword="null"/> when none is, otherwise
|
||||
/// a copy carrying only the visible ones (Gitea #588).
|
||||
/// </summary>
|
||||
/// <param name="identity">The session's identity.</param>
|
||||
/// <param name="e">The model-change event.</param>
|
||||
/// <returns>The event to deliver, or <see langword="null"/> to withhold it.</returns>
|
||||
/// <remarks>
|
||||
/// Every builder in this manager emits a single change, so in practice the event is delivered whole or
|
||||
/// not at all; the copy keeps a future multi-change event from leaking the hidden names, or from hiding
|
||||
/// the visible ones. The copy keeps the original's <c>EventId</c>, time and source, with a neutral
|
||||
/// <c>Message</c> (the builders' messages name the affected node). A change with no
|
||||
/// <c>Affected</c> node names nothing and is kept.
|
||||
/// </remarks>
|
||||
internal GeneralModelChangeEventState? FilterModelChangeFor(
|
||||
RoleCarryingUserIdentity? identity, GeneralModelChangeEventState e)
|
||||
{
|
||||
var changes = e.Changes?.Value;
|
||||
if (changes is not { Length: > 0 }) return e;
|
||||
|
||||
var visible = changes
|
||||
.Where(c => c.Affected is null || c.Affected.IsNullNodeId || Permits(identity, OpcUaOperation.Browse, c.Affected))
|
||||
.ToArray();
|
||||
if (visible.Length == changes.Length) return e;
|
||||
if (visible.Length == 0) return null;
|
||||
|
||||
// The original Message names the affected node(s), so the copy carries a neutral one.
|
||||
var copy = new GeneralModelChangeEventState(null);
|
||||
copy.Initialize(
|
||||
SystemContext, source: null, (EventSeverity)e.Severity.Value, new LocalizedText($"{visible.Length} node(s) changed"));
|
||||
copy.EventId.Value = e.EventId.Value;
|
||||
copy.Time.Value = e.Time.Value;
|
||||
copy.ReceiveTime.Value = e.ReceiveTime.Value;
|
||||
copy.SetChildValue(SystemContext, BrowseNames.SourceNode, e.SourceNode?.Value ?? ObjectIds.Server, false);
|
||||
copy.SetChildValue(SystemContext, BrowseNames.SourceName, e.SourceName?.Value ?? "Server", false);
|
||||
copy.SetChildValue(SystemContext, BrowseNames.Changes, visible, false);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The nodes whose grant admits a condition to a session: the condition itself, every root-notifier
|
||||
/// folder its events bubble up through, and every equipment folder wired as its extra notifier.
|
||||
|
||||
+5
-4
@@ -650,11 +650,12 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
/// including a condition rebuilt by a redeploy while the item was live.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ConditionState.EventsMonitored()</c> gates whether the SDK reports a condition's method-driven
|
||||
/// state changes at all. The SDK base raised it by attaching the item to every root notifier; #586
|
||||
/// <c>ConditionState.EventsMonitored()</c> gates whether the SDK reports a condition's method audit
|
||||
/// events. The SDK base raised it by attaching the item to every root notifier; #586
|
||||
/// attaches to nothing, so the first cut left it false on a server whose only subscribers are
|
||||
/// Server-object items — the machine-consumer default. Asserted server-side because the consequence on
|
||||
/// the wire depends on a separate, still-open reporting gap.
|
||||
/// Server-object items — the machine-consumer default. Asserted server-side: method-driven state-change
|
||||
/// events no longer depend on it (Gitea #587, <c>ConditionMethodEventTests</c>), and audit events only
|
||||
/// leave the server while it is auditing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_server_object_item_alone_marks_conditions_monitored_until_it_is_deleted()
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #588 over the wire — with <c>Security:Acl:Enabled</c> on, the server-wide events this node manager
|
||||
/// raises (<c>GeneralModelChangeEvent</c>s, <c>AuditWriteUpdateEvent</c>s) reach a Server-object
|
||||
/// (<c>i=2253</c>) item only when its session may see the node the event names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// They used to go straight to <c>Server.ReportEvent</c> and the core node manager's Server object, which
|
||||
/// queues to every all-events item with no ACL decision — so a session with no grant learned, from a
|
||||
/// model-change event, the RawPath of every node added, deleted or retyped under it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The withheld session is granted <see cref="ServerEventRig.EqA"/> only, so the absence of the raw
|
||||
/// event on its item is paired with a positive control <b>on the same item</b>: the sentinel event about
|
||||
/// <see cref="ServerEventRig.EqA"/> is reported after the withheld one, through the same path, and must
|
||||
/// arrive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AclServerObjectModelChangeTests
|
||||
{
|
||||
private const string RawVariable = "Plant/Modbus/dev1/Speed";
|
||||
|
||||
private const NodePermissions See = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe;
|
||||
|
||||
/// <summary>
|
||||
/// A NodeAdded / NodeDeleted / DataTypeChanged model-change event naming a raw node reaches the
|
||||
/// cluster-granted session and not the session granted only an equipment folder — which still receives
|
||||
/// the one naming its own folder.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("NodeAdded")]
|
||||
[InlineData("NodeDeleted")]
|
||||
[InlineData("DataTypeChanged")]
|
||||
public async Task A_model_change_naming_a_node_the_session_cannot_browse_is_withheld(string verb)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"AclModelChange{verb}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See), EquipmentGrant(EqA, See));
|
||||
rig.EnsureEquipment();
|
||||
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: false, AddressSpaceRealm.Raw);
|
||||
|
||||
var (granted, grantedEvents, grantedItem, grantedSubscription) = await ServerObjectAsync(rig, anonymous: false, ct);
|
||||
var (narrow, narrowEvents, narrowItem, narrowSubscription) = await ServerObjectAsync(rig, anonymous: true, ct);
|
||||
|
||||
switch (verb)
|
||||
{
|
||||
case "NodeAdded": rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw); break;
|
||||
case "NodeDeleted": rig.Sink.RemoveVariableNode(RawVariable, AddressSpaceRealm.Raw).ShouldBeTrue(); break;
|
||||
default:
|
||||
rig.Sink.UpdateTagAttributes(RawVariable, false, null, "Double", false, null, AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
break;
|
||||
}
|
||||
|
||||
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
||||
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
grantedEvents.Of(grantedItem).Count(e => NamesEqA(e)) == 1
|
||||
&& narrowEvents.Of(narrowItem).Any(NamesEqA));
|
||||
|
||||
var rawNames = verb == "NodeAdded" ? RawDeviceFolder : RawVariable;
|
||||
grantedEvents.Of(grantedItem).Count(e => Names(e, rawNames)).ShouldBe(1, "the granted control receives the raw change");
|
||||
narrowEvents.Of(narrowItem).Where(e => Names(e, rawNames)).ShouldBeEmpty(
|
||||
"a session that cannot browse the node must not learn its name from a model-change event");
|
||||
narrowEvents.Of(narrowItem).Count.ShouldBe(1, "only the change to its own equipment folder");
|
||||
|
||||
await narrowSubscription.DeleteAsync(true, ct);
|
||||
await grantedSubscription.DeleteAsync(true, ct);
|
||||
narrow.Dispose();
|
||||
granted.Dispose();
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With enforcement off nothing changes: a session holding no grant at all still receives the raw
|
||||
/// model-change event through the Server object, exactly once.
|
||||
/// </summary>
|
||||
/// <remarks>Without this the test above also passes on a build that withholds every model change from everyone.</remarks>
|
||||
[Fact]
|
||||
public async Task With_acl_enforcement_off_every_server_object_item_receives_the_model_change()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("AclModelChangeOff", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See));
|
||||
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
|
||||
var (session, events, item, subscription) = await ServerObjectAsync(rig, anonymous: true, ct);
|
||||
|
||||
rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Of(item).Any(e => Names(e, RawDeviceFolder)));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
events.Of(item).Count(e => Names(e, RawDeviceFolder)).ShouldBe(1);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
session.Dispose();
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With auditing on over an encrypted channel, a rejected device write's <c>AuditWriteUpdateEvent</c> —
|
||||
/// which carries the written value and the node's prior one — reaches a session granted <c>Read</c> on
|
||||
/// the node and not one granted only an equipment folder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Auditing is off by default (<c>ServerConfiguration.AuditingEnabled</c>, which this repo never sets),
|
||||
/// and audit events only ever leave over SignAndEncrypt — so both sessions are encrypted here, or the
|
||||
/// withheld one would be denied by the channel rather than by the ACL and the test would prove nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task An_audit_write_event_reaches_only_sessions_that_may_read_the_node()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("AclAuditWrite", encrypted: true, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(
|
||||
true, [Key()],
|
||||
ClusterGrant(KeyPrincipal, "acl-key", See | NodePermissions.WriteOperate), EquipmentGrant(EqA, See));
|
||||
rig.SetAuditing(true);
|
||||
rig.EnsureEquipment();
|
||||
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: true, AddressSpaceRealm.Raw);
|
||||
rig.Nm.NodeWriteGateway = new RejectingGateway();
|
||||
|
||||
var (granted, grantedEvents, grantedItem, grantedSubscription) =
|
||||
await ServerObjectAsync(rig, anonymous: false, ct, encrypted: true);
|
||||
var (narrow, narrowEvents, narrowItem, narrowSubscription) =
|
||||
await ServerObjectAsync(rig, anonymous: true, ct, encrypted: true);
|
||||
|
||||
var variable = new NodeId(RawVariable, (ushort)granted.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
var write = await granted.WriteAsync(
|
||||
null,
|
||||
[new WriteValue { NodeId = variable, AttributeId = Attributes.Value, Value = new DataValue(new Variant(42)) }],
|
||||
ct);
|
||||
StatusCode.IsGood(write.Results[0]).ShouldBeTrue();
|
||||
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
grantedEvents.HasType(grantedItem, ObjectTypeIds.AuditWriteUpdateEventType));
|
||||
|
||||
// Reported after the audit event through the same delivery, so it queues behind it on the item.
|
||||
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
||||
await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA));
|
||||
|
||||
narrowEvents.HasType(narrowItem, ObjectTypeIds.AuditWriteUpdateEventType).ShouldBeFalse(
|
||||
"a session that may not read the node must not receive its written values through an audit event");
|
||||
|
||||
await narrowSubscription.DeleteAsync(true, ct);
|
||||
await grantedSubscription.DeleteAsync(true, ct);
|
||||
narrow.Dispose();
|
||||
granted.Dispose();
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static async Task<(ISession Session, RecordedEvents Events, MonitoredItem Item, Subscription Subscription)>
|
||||
ServerObjectAsync(ServerEventRig rig, bool anonymous, CancellationToken ct, bool encrypted = false)
|
||||
{
|
||||
var session = await rig.ConnectAsync(anonymous, ct, encrypted);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
item.Status.Error.ShouldBeNull();
|
||||
return (session, events, item, subscription);
|
||||
}
|
||||
|
||||
private static bool NamesEqA(VariantCollection e) => Names(e, EqA);
|
||||
|
||||
/// <summary>Whether a model-change event's <c>Changes</c> name the node whose identifier is <paramref name="id"/>.</summary>
|
||||
private static bool Names(VariantCollection e, string id) =>
|
||||
e[RecordedEvents.ChangesField].Value is ExtensionObject[] changes
|
||||
&& changes.Any(c => c.Body is ModelChangeStructureDataType { Affected: { } affected }
|
||||
&& affected.Identifier as string == id);
|
||||
|
||||
/// <summary>Every device write fails, so the node manager reverts and audits it.</summary>
|
||||
private sealed class RejectingGateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||
Task.FromResult(new NodeWriteOutcome(false, "rejected by the test device"));
|
||||
}
|
||||
}
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #587 over the wire — every accepted Part 9 method call (Acknowledge, Confirm, AddComment,
|
||||
/// OneShotShelve, TimedShelve, Unshelve, Enable, Disable) on a native or scripted condition reaches a
|
||||
/// folder event item and a Server-object (<c>i=2253</c>) item as <b>exactly one</b> condition event
|
||||
/// carrying the new state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Before #587 a client Acknowledge produced no condition event at all: the SDK's own report is gated
|
||||
/// on <c>AutoReportStateChanges</c> (never set here), and the engine's re-projection found the node
|
||||
/// already acknowledged and suppressed its own. The code comments claiming "the SDK auto-fires its own
|
||||
/// event" were wrong — and a fix that only enabled the SDK's report would still double-report a shelve
|
||||
/// (the SDK reports before any shelving state is applied, then the re-projection reports the change).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each case runs the engine's (or, for a native alarm, the driver's) answer to the routed command as
|
||||
/// a re-projection from a pool thread, so "exactly one" covers the duplicate the re-projection could
|
||||
/// add, not only the method path. The count is read after a later <b>sentinel</b> transition arrives on
|
||||
/// the same item, so a late duplicate would already be in the queue ahead of it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ConditionMethodEventTests
|
||||
{
|
||||
private const NodePermissions All =
|
||||
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
||||
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
|
||||
| NodePermissions.MethodCall;
|
||||
|
||||
/// <summary>
|
||||
/// One accepted method call ⇒ one condition event, to the folder item and to the Server-object item,
|
||||
/// with the new state — while a session holding no grant receives none of it through the Server object.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Acknowledge", false, true)]
|
||||
[InlineData("Confirm", false, true)]
|
||||
[InlineData("AddComment", false, true)]
|
||||
[InlineData("OneShotShelve", false, true)]
|
||||
[InlineData("TimedShelve", false, true)]
|
||||
[InlineData("Unshelve", false, true)]
|
||||
[InlineData("Disable", false, true)]
|
||||
[InlineData("Enable", false, true)]
|
||||
// Native conditions support Acknowledge and AddComment only (the rest: A_refused_native_method_…).
|
||||
[InlineData("Acknowledge", true, true)]
|
||||
[InlineData("AddComment", true, true)]
|
||||
// Flag off: the server-wide AlarmAck role admits the call; delivery is ungated.
|
||||
[InlineData("Acknowledge", false, false)]
|
||||
[InlineData("Acknowledge", true, false)]
|
||||
[InlineData("OneShotShelve", false, false)]
|
||||
public async Task An_accepted_method_call_reports_exactly_one_condition_event(string method, bool native, bool aclOn)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"CondMethod{method}{native}{aclOn}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(aclOn, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
|
||||
var current = PreState(method, native);
|
||||
WireSimulatedOwners(rig, native, () => current, s => current = s);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var folderItem = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA));
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
folderItem.Status.Error.ShouldBeNull();
|
||||
|
||||
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var ungrantedEvents = new RecordedEvents();
|
||||
var ungrantedSubscription = await RecordedEvents.SubscribeAsync(ungranted, ungrantedEvents, ct);
|
||||
var ungrantedItem = RecordedEvents.AddEventItem(ungrantedSubscription, ObjectIds.Server);
|
||||
await ungrantedSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
var condition = ConditionNodeId(session, native);
|
||||
|
||||
// Every transition the test drives also moves the simulated owner's state, so its re-projections
|
||||
// carry the node's current message.
|
||||
void ProjectAs(string message)
|
||||
{
|
||||
current = current with { Message = message };
|
||||
rig.Project(native, current);
|
||||
}
|
||||
|
||||
ProjectAs("base");
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base"));
|
||||
|
||||
await CallAsync(session, condition, method, events.LatestEventId(folderItem, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
|
||||
ProjectAs("sentinel");
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel")
|
||||
&& (aclOn || ungrantedEvents.Has(ungrantedItem, "sentinel")));
|
||||
|
||||
foreach (var item in new[] { folderItem, serverItem })
|
||||
{
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, $"the base transition plus exactly one {method} event ({item.StartNodeId})");
|
||||
AssertNewState(method, native, baseEvents[1]);
|
||||
}
|
||||
|
||||
if (aclOn)
|
||||
{
|
||||
ungrantedEvents.Of(ungrantedItem).ShouldBeEmpty(
|
||||
"a session with no grant receives no condition event through the Server object");
|
||||
}
|
||||
else
|
||||
{
|
||||
ungrantedEvents.ConditionEvents(ungrantedItem, "base").Count.ShouldBe(2, "flag off: ungated, still once");
|
||||
}
|
||||
|
||||
await ungrantedSubscription.DeleteAsync(true, ct);
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A native condition supports Acknowledge and AddComment only: Confirm, the shelve methods and
|
||||
/// Disable are refused, report nothing, and leave the condition Confirmed and Unshelved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Native Confirm/Shelve used to be accepted and applied locally — per node, never upstream, never to the
|
||||
/// redundant peer, and undone by the driver projection (a one-shot shelve silently, a timed one never
|
||||
/// expiring). Unshelve on an unshelved condition is refused by the SDK itself before our veto runs.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("Confirm")]
|
||||
[InlineData("OneShotShelve")]
|
||||
[InlineData("TimedShelve")]
|
||||
[InlineData("Unshelve")]
|
||||
[InlineData("Disable")]
|
||||
public async Task A_refused_native_method_reports_no_condition_event(string method)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"CondMethodRefused{method}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
var current = PreState(method, native: true);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var folderItem = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA));
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(native: true, current = current with { Message = "base" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "base"));
|
||||
|
||||
var refused = await Should.ThrowAsync<ServiceResultException>(
|
||||
CallAsync(session, ConditionNodeId(session, native: true), method, events.LatestEventId(serverItem, "base"), ct));
|
||||
if (method != "Unshelve") refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
||||
|
||||
rig.Project(native: true, current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel"));
|
||||
events.ConditionEvents(folderItem, "base").Count.ShouldBe(1);
|
||||
events.ConditionEvents(serverItem, "base").Count.ShouldBe(1);
|
||||
var sentinel = events.ConditionEvents(serverItem, "sentinel")[0];
|
||||
sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialising N conditions while a Server-object item is live costs N monitored-bookkeeping visits,
|
||||
/// not N²/2 — and every one of them still ends up marked monitored (#586 review, performance note).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Materialising_conditions_under_a_live_server_object_item_visits_each_once()
|
||||
{
|
||||
const int n = 40;
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondMonitoredCost", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.EnsureEquipment();
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
var before = rig.Nm.AllEventsMonitoringConditionVisits;
|
||||
for (var i = 0; i < n; i++)
|
||||
rig.Sink.MaterialiseAlarmCondition($"bulk-{i}", EqA, $"bulk {i}", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
||||
|
||||
(rig.Nm.AllEventsMonitoringConditionVisits - before).ShouldBe(n);
|
||||
for (var i = 0; i < n; i++)
|
||||
rig.Nm.ConditionEventsMonitored($"bulk-{i}", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
for (var i = 0; i < n; i++)
|
||||
rig.Nm.ConditionEventsMonitored($"bulk-{i}", AddressSpaceRealm.Uns).ShouldBeFalse(
|
||||
"the last item's delete still gives every unit back through the full reconcile");
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OPC UA Acknowledge on a native condition reports exactly one event with <c>ConfirmedState</c> still
|
||||
/// true, and the condition stays Confirmed through the driver's echo, a clear and a re-activation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK's Acknowledge sets Confirmed false on any condition that supports Confirm, and nothing resets
|
||||
/// it for a native alarm — ScadaBridge maps <c>ConfirmedState</c> into its alarm state, so every native
|
||||
/// alarm would read Unconfirmed after its first HMI ack (review of <c>167a48bf</c>).
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_native_acknowledge_keeps_the_condition_confirmed_through_clear_and_reactivation()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondNativeAckConfirmed", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
rig.Nm.NativeAlarmAckRouter = _ => { };
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(true, NativeState(active: true, acked: false, "base"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, ConditionNodeId(session, true), "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
rig.Project(true, NativeState(active: true, acked: true, "base")); // the driver's echo of the ack
|
||||
rig.Project(true, NativeState(active: false, acked: true, "cleared"));
|
||||
rig.Project(true, NativeState(active: true, acked: false, "reactivated"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "reactivated"));
|
||||
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta");
|
||||
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated")))
|
||||
e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
|
||||
/// reports exactly one unshelve event after the one shelve event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The method path applies the shelve with <c>shelvingTime 0</c>, as the projection does, so the expiry is
|
||||
/// the engine's (computed from the command's <c>UnshelveAtUtc</c>), never the SDK timer's — a node timer
|
||||
/// would only route an Unshelve back to the engine anyway. The stand-in engine here schedules its expiry
|
||||
/// from the real <c>UnshelveAtUtc</c> the method handler computed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondTimedExpiry", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseScripted();
|
||||
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
||||
Task? expiry = null;
|
||||
rig.Nm.AlarmCommandRouter = cmd =>
|
||||
{
|
||||
if (cmd.Operation != "TimedShelve") return;
|
||||
current = current with { Shelving = AlarmShelvingKind.Timed };
|
||||
rig.ProjectLater(native: false, current);
|
||||
var unshelveAt = cmd.UnshelveAtUtc!.Value;
|
||||
expiry = Task.Run(async () =>
|
||||
{
|
||||
var wait = unshelveAt - DateTime.UtcNow;
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait);
|
||||
current = current with { Shelving = AlarmShelvingKind.Unshelved };
|
||||
rig.Project(native: false, current);
|
||||
});
|
||||
};
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 1000);
|
||||
await rig.DrainProjectionsAsync();
|
||||
(expiry is not null).ShouldBeTrue("the TimedShelve reached the engine");
|
||||
await expiry!;
|
||||
|
||||
rig.Project(false, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
|
||||
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
||||
shelving.ShouldBe(
|
||||
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
||||
ObjectIds.ShelvedStateMachineType_Unshelved],
|
||||
"base, then exactly one shelve event, then exactly one expiry event");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledging an acknowledged condition reports nothing, and does not flip a scripted condition's
|
||||
/// Confirmed out of step with its engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK accepts a re-ack and sets Confirmed false again; the engine ignores it. The scripted leg
|
||||
/// acknowledges and confirms first, so the flip would be visible on the sentinel.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_re_acknowledge_reports_nothing_and_changes_nothing(bool native)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"CondReAck{native}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
var current = PreState("Acknowledge", native) with { Message = "base" };
|
||||
WireSimulatedOwners(rig, native, () => current, s => current = s);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
var condition = ConditionNodeId(session, native);
|
||||
rig.Project(native, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
var expected = 2;
|
||||
if (!native)
|
||||
{
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
|
||||
await CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
expected = 3;
|
||||
}
|
||||
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == expected);
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
|
||||
rig.Project(native, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
|
||||
events.ConditionEvents(item, "base").Count.ShouldBe(expected, "the re-ack must report nothing");
|
||||
var sentinel = events.ConditionEvents(item, "sentinel");
|
||||
sentinel.Count.ShouldBe(1);
|
||||
sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static AlarmConditionSnapshot NativeState(bool active, bool acked, string message) => new(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message);
|
||||
|
||||
/// <summary>The condition state a case starts from — one in which its method is accepted.</summary>
|
||||
private static AlarmConditionSnapshot PreState(string method, bool native)
|
||||
{
|
||||
var s = new AlarmConditionSnapshot(
|
||||
Active: true, Acknowledged: false, Confirmed: native, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "pre");
|
||||
if (native) return s; // the driver projection's constants: Confirmed, Unshelved
|
||||
|
||||
return method switch
|
||||
{
|
||||
"Confirm" => s with { Acknowledged = true, Confirmed = false },
|
||||
"Unshelve" => s with { Shelving = AlarmShelvingKind.OneShot },
|
||||
"Enable" => s with { Enabled = false },
|
||||
_ => s,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the scripted engine and the native driver: each routed command comes back as a
|
||||
/// re-projection of the state it produces, from a pool thread.
|
||||
/// </summary>
|
||||
private static void WireSimulatedOwners(
|
||||
ServerEventRig rig, bool native, Func<AlarmConditionSnapshot> current, Action<AlarmConditionSnapshot> set)
|
||||
{
|
||||
rig.Nm.NativeAlarmAckRouter = _ =>
|
||||
{
|
||||
// NativeAlarmProjector's answer to the driver's ack echo: Acked, with its Confirmed/Unshelved constants.
|
||||
var next = current() with { Acknowledged = true, Confirmed = true, Shelving = AlarmShelvingKind.Unshelved };
|
||||
set(next);
|
||||
rig.ProjectLater(native: true, next);
|
||||
};
|
||||
rig.Nm.AlarmCommandRouter = cmd =>
|
||||
{
|
||||
if (native || cmd.AlarmId != ScriptedAlarmId) return; // the engine owns only scripted alarms
|
||||
var s = current();
|
||||
var next = cmd.Operation switch
|
||||
{
|
||||
"Acknowledge" => s with { Acknowledged = true },
|
||||
"Confirm" => s with { Confirmed = true },
|
||||
"OneShotShelve" => s with { Shelving = AlarmShelvingKind.OneShot },
|
||||
"TimedShelve" => s with { Shelving = AlarmShelvingKind.Timed },
|
||||
"Unshelve" => s with { Shelving = AlarmShelvingKind.Unshelved },
|
||||
"Enable" => s with { Enabled = true },
|
||||
"Disable" => s with { Enabled = false },
|
||||
_ => s, // AddComment: no field of the snapshot changes
|
||||
};
|
||||
set(next);
|
||||
rig.ProjectLater(native: false, next);
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertNewState(string method, bool native, VariantCollection e)
|
||||
{
|
||||
// A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so).
|
||||
if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
switch (method)
|
||||
{
|
||||
case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break;
|
||||
case "Confirm": e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); break;
|
||||
case "AddComment": (e[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe("note"); break;
|
||||
case "OneShotShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); break;
|
||||
case "TimedShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); break;
|
||||
case "Unshelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); break;
|
||||
case "Disable": e[RecordedEvents.EnabledField].Value.ShouldBe(false); break;
|
||||
case "Enable": e[RecordedEvents.EnabledField].Value.ShouldBe(true); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(method), method, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the
|
||||
/// condition's method declarations — the shelve methods included); throws on a bad result.
|
||||
/// </summary>
|
||||
private static async Task CallAsync(
|
||||
ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000)
|
||||
{
|
||||
var (methodId, args) = method switch
|
||||
{
|
||||
"Acknowledge" => (MethodIds.AcknowledgeableConditionType_Acknowledge, new object[] { eventId!, new LocalizedText("ack") }),
|
||||
"Confirm" => (MethodIds.AcknowledgeableConditionType_Confirm, [eventId!, new LocalizedText("confirm")]),
|
||||
"AddComment" => (MethodIds.ConditionType_AddComment, [eventId!, new LocalizedText("note")]),
|
||||
"Enable" => (MethodIds.ConditionType_Enable, []),
|
||||
"Disable" => (MethodIds.ConditionType_Disable, []),
|
||||
"OneShotShelve" => (MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, []),
|
||||
"TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [shelvingTimeMs]),
|
||||
"Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null),
|
||||
};
|
||||
await session.CallAsync(condition, methodId, ct, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
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.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// A booted server for the Server-object event tests (Gitea #587 / #588): one native condition on a raw
|
||||
/// device wired to two equipment notifiers, one scripted condition under <see cref="EqA"/>, an API-key
|
||||
/// principal and anonymous, optionally an encrypted endpoint and server auditing.
|
||||
/// </summary>
|
||||
internal sealed class ServerEventRig : IAsyncDisposable
|
||||
{
|
||||
public const string Cluster = "SITE-A";
|
||||
public const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||
public const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi";
|
||||
public const string ScriptedAlarmId = "scripted-eqa-hi";
|
||||
public const string EqA = "EqA";
|
||||
public const string EqB = "EqB";
|
||||
public const string KeyPrincipal = "event-probe";
|
||||
public const string KeyId = "k-server-events";
|
||||
|
||||
private readonly string _pkiRoot;
|
||||
private readonly List<Task> _pending = [];
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private OtOpcUaSdkServer _server = null!;
|
||||
private int _port;
|
||||
|
||||
private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot;
|
||||
|
||||
public OtOpcUaNodeManager Nm => _server.NodeManager!;
|
||||
|
||||
public SdkAddressSpaceSink Sink { get; private set; } = null!;
|
||||
|
||||
public static async Task<ServerEventRig> StartAsync(string name, bool encrypted, CancellationToken ct)
|
||||
{
|
||||
var rig = new ServerEventRig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
|
||||
rig._port = AllocateFreePort();
|
||||
rig._server = new OtOpcUaSdkServer();
|
||||
rig._host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = $"OtOpcUa.{name}",
|
||||
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
|
||||
OpcUaPort = rig._port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = rig._pkiRoot,
|
||||
EnabledSecurityProfiles = encrypted
|
||||
? [OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
|
||||
: [OpcUaSecurityProfile.None],
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance,
|
||||
new KeyPresentingAuthenticator());
|
||||
|
||||
await rig._host.StartAsync(rig._server, ct);
|
||||
rig.Sink = new SdkAddressSpaceSink(rig.Nm);
|
||||
return rig;
|
||||
}
|
||||
|
||||
/// <summary>Wires the authorizer over the given keys and grants, enforcing or not.</summary>
|
||||
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
|
||||
return state;
|
||||
}
|
||||
|
||||
public static ApiKey Key() => new()
|
||||
{
|
||||
ApiKeyId = KeyId,
|
||||
Name = KeyId,
|
||||
SecretHash = "hash",
|
||||
Principal = KeyPrincipal,
|
||||
Enabled = true,
|
||||
CreatedUtc = DateTime.UtcNow.AddDays(-1),
|
||||
};
|
||||
|
||||
public static NodeAcl ClusterGrant(string principal, string id, NodePermissions permissions) => new()
|
||||
{
|
||||
NodeAclId = id,
|
||||
ClusterId = Cluster,
|
||||
Principal = principal,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
PermissionFlags = permissions,
|
||||
};
|
||||
|
||||
public static NodeAcl EquipmentGrant(string equipment, NodePermissions permissions) => new()
|
||||
{
|
||||
NodeAclId = $"acl-{equipment}",
|
||||
ClusterId = Cluster,
|
||||
Principal = OpcUaPrincipals.Anonymous,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = $"eq-{equipment}",
|
||||
PermissionFlags = permissions,
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])],
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])],
|
||||
};
|
||||
|
||||
/// <summary>The native condition, wired as an event source of both equipment folders.</summary>
|
||||
public void MaterialiseNative()
|
||||
{
|
||||
Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
Sink.MaterialiseAlarmCondition(
|
||||
RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
|
||||
EnsureEquipment();
|
||||
Sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, [EqA, EqB], AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
/// <summary>The scripted condition under <see cref="EqA"/>.</summary>
|
||||
public void MaterialiseScripted()
|
||||
{
|
||||
EnsureEquipment();
|
||||
Sink.MaterialiseAlarmCondition(
|
||||
ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false);
|
||||
}
|
||||
|
||||
public void EnsureEquipment()
|
||||
{
|
||||
Sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns);
|
||||
Sink.EnsureFolder(EqB, parentNodeId: null, displayName: "B", realm: AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
/// <summary>Projects <paramref name="state"/> onto a condition, as the engine or a driver would.</summary>
|
||||
public void Project(bool native, AlarmConditionSnapshot state) =>
|
||||
Sink.WriteAlarmCondition(
|
||||
native ? RawAlarmPath : ScriptedAlarmId,
|
||||
state,
|
||||
DateTime.UtcNow,
|
||||
native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns);
|
||||
|
||||
/// <summary>
|
||||
/// Re-projects <paramref name="state"/> from a pool thread, the way the engine's or the driver's answer
|
||||
/// to a routed method call arrives — it contends for the node manager's lock like the real one does.
|
||||
/// </summary>
|
||||
public void ProjectLater(bool native, AlarmConditionSnapshot state)
|
||||
{
|
||||
lock (_pending) _pending.Add(Task.Run(() => Project(native, state)));
|
||||
}
|
||||
|
||||
/// <summary>Waits for every <see cref="ProjectLater"/> re-projection to have run.</summary>
|
||||
public async Task DrainProjectionsAsync()
|
||||
{
|
||||
Task[] pending;
|
||||
lock (_pending) pending = [.. _pending];
|
||||
await Task.WhenAll(pending);
|
||||
}
|
||||
|
||||
/// <summary>The condition's NodeId in the session's namespaces.</summary>
|
||||
public static NodeId ConditionNodeId(ISession session, bool native) => native
|
||||
? new NodeId(RawAlarmPath, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri))
|
||||
: new NodeId(ScriptedAlarmId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri));
|
||||
|
||||
public static NodeId UnsNodeId(ISession session, string id)
|
||||
{
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
ns.ShouldBeGreaterThan((ushort)0);
|
||||
return new NodeId(id, ns);
|
||||
}
|
||||
|
||||
/// <summary>Switches the server's <c>Auditing</c> flag (no configuration seam reaches it in this repo).</summary>
|
||||
public void SetAuditing(bool on)
|
||||
{
|
||||
var server = typeof(Opc.Ua.Server.CustomNodeManager2)
|
||||
.GetProperty("Server", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!
|
||||
.GetValue(Nm)!;
|
||||
server.GetType()
|
||||
.GetProperty("Auditing", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!
|
||||
.GetSetMethod(nonPublic: true)!
|
||||
.Invoke(server, [on]);
|
||||
}
|
||||
|
||||
public async Task<ISession> ConnectAsync(bool anonymous, CancellationToken ct, bool encrypted = false)
|
||||
{
|
||||
var pki = TestClientSecurity.AllocatePkiRoot();
|
||||
var security = TestClientSecurity.Build(pki);
|
||||
security.ApplicationCertificate.SubjectName = "CN=OtOpcUa.ServerEventClient";
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUa.ServerEventClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.ServerEventClient.{Guid.NewGuid():N}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = security,
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
if (encrypted)
|
||||
{
|
||||
#pragma warning disable CS0618 // the telemetry-context overload is not needed for a test client
|
||||
var app = new ApplicationInstance { ApplicationName = appConfig.ApplicationName, ApplicationType = ApplicationType.Client };
|
||||
#pragma warning restore CS0618
|
||||
app.ApplicationConfiguration = appConfig;
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct);
|
||||
}
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", encrypted, DefaultTelemetry.Create(_ => { }), ct);
|
||||
if (encrypted) endpoint!.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
||||
|
||||
var identity = anonymous
|
||||
? new UserIdentity(new AnonymousIdentityToken())
|
||||
: new UserIdentity(new UserNameIdentityToken
|
||||
{
|
||||
UserName = "apikey",
|
||||
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
|
||||
});
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "ServerEventRig",
|
||||
sessionTimeout: 60_000,
|
||||
identity: identity,
|
||||
preferredLocales: null,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _host.DisposeAsync();
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admits <c>apikey</c> as <see cref="KeyPrincipal"/> carrying the two server-wide roles (used only
|
||||
/// while enforcement is off); anonymous falls through to the <c>anonymous</c> principal.
|
||||
/// </summary>
|
||||
private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
||||
Task.FromResult(
|
||||
string.Equals(username, "apikey", StringComparison.Ordinal)
|
||||
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
|
||||
? OpcUaUserAuthResult.AllowKey(
|
||||
KeyPrincipal, [OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.WriteOperate], KeyId)
|
||||
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every event each monitored item received, in order, with the fields the tests select.</summary>
|
||||
internal sealed class RecordedEvents
|
||||
{
|
||||
// The select clauses, in order — see AddEventItem.
|
||||
public const int EventIdField = 0;
|
||||
public const int MessageField = 1;
|
||||
public const int EventTypeField = 2;
|
||||
public const int AckedField = 3;
|
||||
public const int ConfirmedField = 4;
|
||||
public const int EnabledField = 5;
|
||||
public const int ShelvingField = 6;
|
||||
public const int CommentField = 7;
|
||||
public const int ChangesField = 8;
|
||||
public const int SourceNodeField = 9;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly List<(uint Handle, VariantCollection Fields)> _events = [];
|
||||
|
||||
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var e in notification.Events) _events.Add((e.ClientHandle, e.EventFields));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every event <paramref name="item"/> received, in arrival order.</summary>
|
||||
public IReadOnlyList<VariantCollection> Of(MonitoredItem item)
|
||||
{
|
||||
lock (_gate) return _events.Where(e => e.Handle == item.ClientHandle).Select(e => e.Fields).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The condition events (they carry <c>AckedState/Id</c>; audit and refresh events do not).</summary>
|
||||
public IReadOnlyList<VariantCollection> ConditionEvents(MonitoredItem item, string message) =>
|
||||
Of(item).Where(f => f[AckedField].Value is bool && Message(f) == message).ToList();
|
||||
|
||||
public bool Has(MonitoredItem item, string message) => Of(item).Any(f => Message(f) == message);
|
||||
|
||||
public bool HasType(MonitoredItem item, NodeId type) => Of(item).Any(f => f[EventTypeField].Value as NodeId == type);
|
||||
|
||||
public byte[] LatestEventId(MonitoredItem item, string message) =>
|
||||
(byte[])Of(item).Last(f => Message(f) == message && f[AckedField].Value is bool)[EventIdField].Value;
|
||||
|
||||
public static string? Message(VariantCollection f) => (f[MessageField].Value as LocalizedText)?.Text;
|
||||
|
||||
public static async Task<Subscription> SubscribeAsync(ISession session, RecordedEvents events, CancellationToken ct)
|
||||
{
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
subscription.FastEventCallback = events.OnEvents;
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.AckedState, BrowseNames.Id]));
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.ConfirmedState, BrowseNames.Id]));
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.ConditionType, [BrowseNames.EnabledState, BrowseNames.Id]));
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AlarmConditionType, [BrowseNames.ShelvingState, BrowseNames.CurrentState, BrowseNames.Id]));
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Comment);
|
||||
filter.AddSelectClause(ObjectTypeIds.GeneralModelChangeEventType, BrowseNames.Changes);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = source,
|
||||
AttributeId = Attributes.EventNotifier,
|
||||
Filter = filter,
|
||||
QueueSize = 1000,
|
||||
SamplingInterval = 0,
|
||||
};
|
||||
subscription.AddItem(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
public static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
condition().ShouldBeTrue("condition not met within timeout");
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #588 — <see cref="OtOpcUaNodeManager.FilterModelChangeFor"/> narrows a model-change event to the
|
||||
/// changes a session may browse. Every builder in the manager emits one change, so the over-the-wire tests
|
||||
/// only ever see "whole or nothing"; this pins the multi-change copy at its seam.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerModelChangeFilterTests : IDisposable
|
||||
{
|
||||
private const string Cluster = "SITE-A";
|
||||
private const string Principal = "scadabridge";
|
||||
private const string RawFolder = "Plant/Modbus/dev1";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-mcfilter-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>
|
||||
/// A session granted one equipment folder receives a copy carrying only that folder's change — same
|
||||
/// EventId, and a Message that no longer names the hidden node — while a fully granted session receives
|
||||
/// the original and an ungranted one nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_multi_change_event_is_narrowed_to_the_changes_the_session_may_browse()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
sink.EnsureFolder("EqA", null, "A", AddressSpaceRealm.Uns);
|
||||
sink.EnsureFolder(RawFolder, null, "dev1", AddressSpaceRealm.Raw);
|
||||
|
||||
var e = nm.BuildNodesAddedModelChange("EqA", AddressSpaceRealm.Uns);
|
||||
var eqA = e.Changes.Value[0];
|
||||
var raw = nm.BuildNodesAddedModelChange(RawFolder, AddressSpaceRealm.Raw).Changes.Value[0];
|
||||
e.Changes.Value = [eqA, raw];
|
||||
e.Message.Value = new LocalizedText($"Nodes added under {eqA.Affected} and {raw.Affected}");
|
||||
|
||||
nm.Authorizer = Authorizer(
|
||||
new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-eqa", ClusterId = Cluster, Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = "eq-EqA",
|
||||
PermissionFlags = NodePermissions.Browse,
|
||||
},
|
||||
new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-all", ClusterId = Cluster, Principal = "everything",
|
||||
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Browse,
|
||||
});
|
||||
|
||||
var narrowed = nm.FilterModelChangeFor(Identity(Principal), e).ShouldNotBeNull();
|
||||
narrowed.ShouldNotBeSameAs(e);
|
||||
narrowed.Changes.Value.Select(c => c.Affected).ShouldBe([eqA.Affected]);
|
||||
narrowed.EventId.Value.ShouldBe(e.EventId.Value);
|
||||
narrowed.Message.Value.Text.ShouldNotContain(RawFolder);
|
||||
|
||||
nm.FilterModelChangeFor(Identity("everything"), e).ShouldBeSameAs(e);
|
||||
nm.FilterModelChangeFor(Identity("nobody"), e).ShouldBeNull();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private static DataPlaneAuthorizer Authorizer(params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", [], grants,
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, "eq-EqA")] = [new NodeAclPath([NodeAclRealms.Uns, "EqA"])],
|
||||
});
|
||||
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static RoleCarryingUserIdentity Identity(string principal) =>
|
||||
new(new UserNameIdentityToken { UserName = "apikey" }, [], principal, apiKeyId: null);
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.ModelChangeFilterTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.ModelChangeFilterTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch (IOException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user