feat(alarms): route inbound Part 9 alarm methods through AlarmAck gate (T18)
Wire the materialised AlarmConditionState method handlers so a client calling Acknowledge/Confirm/Shelve/AddComment is gated on the AlarmAck data-plane role and, when allowed, routed back to the scripted-alarm engine via a new `alarm-commands` DistributedPubSub topic. - Commons: new AlarmCommand DTO (AlarmId/Operation/User/Comment/UnshelveAtUtc). - ScriptedAlarmHostActor: add AlarmCommandsTopic const. - OtOpcUaNodeManager: settable AlarmCommandRouter + wire OnAcknowledge/OnConfirm/ OnAddComment/OnShelve/OnTimedUnshelve. Each resolves the principal off ISessionOperationContext.UserIdentity as RoleCarryingUserIdentity, fails closed (BadUserAccessDenied) when the AlarmAck role is absent or no identity, else maps + routes an AlarmCommand and returns Good. OnShelve discriminates OneShotShelve/ TimedShelve/Unshelve from the SDK flags; TimedShelve expiry = UtcNow + ms. No Akka/IActorRef handle — only the Action<AlarmCommand> delegate. T20 de-dup note left; WriteAlarmCondition untouched. - OpcUaServer.Security: OpcUaDataPlaneRoles.AlarmAck shared const (the role was a bare string everywhere; introduced one symbol for the gate + tests). - OtOpcUaSdkServer: SetAlarmCommandRouter pass-through. - Host: boot wiring publishes each command via mediator.Tell(Publish(...)) using a lazy ActorSystem accessor (mirrors DpsScriptLogPublisher). - Tests: 11 new gate + mapping tests (OpcUaServer.Tests 88->99, all green).
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-level command carried from an inbound OPC UA Part 9 alarm method call
|
||||
/// (Acknowledge / Confirm / Shelve / AddComment …) back to the scripted-alarm engine. The SDK
|
||||
/// node manager builds one of these in its condition method-handler delegates after the
|
||||
/// <c>AlarmAck</c> role gate passes, then the host routes it onto the cluster
|
||||
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it and
|
||||
/// drives the matching <c>Part9StateMachine.Apply*</c> transition. This is a pure DTO — it makes
|
||||
/// no auth decision and holds no SDK/Akka handle.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">
|
||||
/// The alarm's ScriptedAlarmId — equal to the materialised condition node's NodeId identifier
|
||||
/// (T14 aligned the condition NodeId to the ScriptedAlarmId). The engine keys its domain state by
|
||||
/// this id.
|
||||
/// </param>
|
||||
/// <param name="Operation">
|
||||
/// The Part 9 operation, one of: <c>Acknowledge</c>, <c>Confirm</c>, <c>OneShotShelve</c>,
|
||||
/// <c>TimedShelve</c>, <c>Unshelve</c>, <c>Enable</c>, <c>Disable</c>, <c>AddComment</c>. These map
|
||||
/// 1:1 onto the engine's <c>Part9StateMachine.Apply*</c> calls on the consuming side (T19).
|
||||
/// </param>
|
||||
/// <param name="User">The acting user — the authenticated session identity's display/name.</param>
|
||||
/// <param name="Comment">
|
||||
/// The free-text comment supplied with the call (the OPC UA <c>LocalizedText</c> payload's text),
|
||||
/// or <c>null</c> when none was provided.
|
||||
/// </param>
|
||||
/// <param name="UnshelveAtUtc">
|
||||
/// For <c>TimedShelve</c>, the absolute UTC instant the shelve auto-expires
|
||||
/// (<c>DateTime.UtcNow + shelvingTime</c>, where the OPC UA <c>Duration</c> <c>shelvingTime</c> is
|
||||
/// in <b>milliseconds</b>); <c>null</c> for every other operation.
|
||||
/// </param>
|
||||
public sealed record AlarmCommand(
|
||||
string AlarmId,
|
||||
string Operation,
|
||||
string User,
|
||||
string? Comment,
|
||||
DateTime? UnshelveAtUtc);
|
||||
@@ -1,9 +1,12 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
@@ -24,6 +27,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly Func<ActorSystem> _actorSystemAccessor;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OtOpcUaServerHostedService> _logger;
|
||||
|
||||
@@ -37,18 +41,23 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
|
||||
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
|
||||
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
|
||||
/// <param name="actorSystemAccessor">Lazy accessor for the running <see cref="ActorSystem"/>, used to
|
||||
/// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
|
||||
/// lazily (mirroring <c>DpsScriptLogPublisher</c>) so construction never races Akka startup.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
||||
public OtOpcUaServerHostedService(
|
||||
IOptions<OpcUaApplicationHostOptions> options,
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
DeferredServiceLevelPublisher deferredServiceLevel,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
Func<ActorSystem> actorSystemAccessor,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_options = options.Value;
|
||||
_deferredSink = deferredSink;
|
||||
_deferredServiceLevel = deferredServiceLevel;
|
||||
_userAuthenticator = userAuthenticator;
|
||||
_actorSystemAccessor = actorSystemAccessor;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
|
||||
}
|
||||
@@ -88,6 +97,30 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
|
||||
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
|
||||
|
||||
// Wire the reverse-path inbound-alarm-command router: a client Acknowledge/Confirm/Shelve that
|
||||
// passes the node manager's AlarmAck gate publishes the mapped AlarmCommand onto the cluster
|
||||
// `alarm-commands` topic (same DistributedPubSub mediator the `alerts`/`script-logs` topics use).
|
||||
// The Tell is fire-and-forget so the handler — which runs under the SDK's Lock — never blocks.
|
||||
// The mediator is resolved per-publish via the lazy ActorSystem accessor so a transient cluster
|
||||
// condition is tolerated and construction never raced Akka startup.
|
||||
_server.SetAlarmCommandRouter(cmd =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var mediator = DistributedPubSub.Get(_actorSystemAccessor()).Mediator;
|
||||
mediator.Tell(new Publish(ScriptedAlarmHostActor.AlarmCommandsTopic, cmd));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The router runs under the SDK Lock on a server thread; a cluster hiccup must not
|
||||
// escape into the SDK's Call path. Log + drop — the client still gets Good for the
|
||||
// node-state change; the missed command surfaces as a non-applied engine transition.
|
||||
_logger.LogWarning(ex,
|
||||
"OtOpcUaServerHostedService: failed to route inbound alarm command {Operation} for {AlarmId}",
|
||||
cmd.Operation, cmd.AlarmId);
|
||||
}
|
||||
});
|
||||
|
||||
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
||||
if (_server.CurrentInstance is { } serverInternal)
|
||||
{
|
||||
|
||||
@@ -147,6 +147,10 @@ if (hasDriver)
|
||||
builder.Services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
|
||||
builder.Configuration, "OpcUa");
|
||||
|
||||
// Lazy ActorSystem accessor so OtOpcUaServerHostedService can resolve the DistributedPubSub
|
||||
// mediator (for the inbound alarm-command router) without racing Akka startup — same pattern the
|
||||
// DpsScriptLogPublisher above uses. TryAdd so a fused admin+driver node registers it exactly once.
|
||||
builder.Services.TryAddSingleton<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>());
|
||||
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -52,6 +53,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
|
||||
public int AlarmConditionCount => _alarmConditions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
|
||||
/// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
|
||||
/// handler (wired in <see cref="MaterialiseAlarmCondition"/>) gates on the caller's
|
||||
/// <c>AlarmAck</c> role and, when allowed, builds an <see cref="AlarmCommand"/> and invokes this
|
||||
/// delegate. The host sets it at boot to a non-blocking <c>mediator.Tell</c> onto the
|
||||
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it.
|
||||
/// <para>
|
||||
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
|
||||
/// <see cref="Action{AlarmCommand}"/> (no Akka / <c>IActorRef</c> / DI handle). The handler
|
||||
/// delegates run under the manager's <c>Lock</c>; the invoked action MUST be non-blocking
|
||||
/// (a fire-and-forget <c>Tell</c>) so there is no deadlock. Null (the default) makes every
|
||||
/// handler a safe no-op — it still gates + returns, just routes nowhere.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
|
||||
|
||||
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
|
||||
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
||||
@@ -316,6 +334,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
alarm.Message.Value = new LocalizedText(displayName);
|
||||
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
|
||||
|
||||
// T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
|
||||
// AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
|
||||
// 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.
|
||||
// T20: the engine re-projects that same logical transition through WriteAlarmCondition, which
|
||||
// also fires — the resulting double-emit is de-duped in a later task (T20), NOT here.
|
||||
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
||||
alarm.OnConfirm = (context, condition, _, comment) =>
|
||||
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) =>
|
||||
{
|
||||
// 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
|
||||
// Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
|
||||
// shelvingTime is an OPC UA Duration (milliseconds).
|
||||
var (operation, unshelveAt) =
|
||||
!shelving ? ("Unshelve", (DateTime?)null)
|
||||
: oneShot ? ("OneShotShelve", null)
|
||||
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
|
||||
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
|
||||
};
|
||||
// The auto-unshelve timer firing is an unshelve transition driven by the SDK (no client user);
|
||||
// route it as Unshelve so the engine clears its shelve state. Same AlarmAck gate applies.
|
||||
alarm.OnTimedUnshelve = (context, condition) =>
|
||||
HandleAlarmCommand(context, condition, "Unshelve", comment: null, unshelveAt: null);
|
||||
|
||||
parent.AddChild(alarm);
|
||||
|
||||
// Promote the equipment folder to an event notifier + register it as a root notifier so
|
||||
@@ -328,6 +378,49 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
|
||||
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
|
||||
/// (<b>fails closed</b>: a missing identity or a missing role is denied), and on success builds a
|
||||
/// mapped <see cref="AlarmCommand"/> and routes it through <see cref="AlarmCommandRouter"/>.
|
||||
/// </summary>
|
||||
/// <param name="context">The SDK context the handler delegate was invoked with — a
|
||||
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session
|
||||
/// identity. T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>.</param>
|
||||
/// <param name="condition">The condition the method targets; its <c>NodeId</c> identifier is the
|
||||
/// ScriptedAlarmId (T14 aligned them), which becomes <see cref="AlarmCommand.AlarmId"/>.</param>
|
||||
/// <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>
|
||||
private ServiceResult HandleAlarmCommand(
|
||||
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
|
||||
{
|
||||
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
|
||||
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||||
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
||||
// state change and surfaces the status to the client; we never route or mutate.
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
var cmd = new AlarmCommand(
|
||||
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||||
Operation: operation,
|
||||
User: identity.DisplayName ?? string.Empty,
|
||||
Comment: comment?.Text,
|
||||
UnshelveAtUtc: unshelveAt);
|
||||
|
||||
// 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.
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
|
||||
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
|
||||
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -17,6 +18,24 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
|
||||
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
|
||||
|
||||
/// <summary>
|
||||
/// Wire the reverse-path sink for inbound Part 9 alarm method calls onto the created
|
||||
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with a non-blocking
|
||||
/// <c>mediator.Tell</c> that publishes each <see cref="AlarmCommand"/> onto the
|
||||
/// <c>alarm-commands</c> DistributedPubSub topic. No-op (returns <c>false</c>) when the node
|
||||
/// manager has not been created yet, so the caller can detect a too-early call.
|
||||
/// </summary>
|
||||
/// <param name="router">The router invoked by the condition handlers once the <c>AlarmAck</c>
|
||||
/// gate passes; may be <c>null</c> to clear it.</param>
|
||||
/// <returns><c>true</c> when the router was set on a live node manager; <c>false</c> when no node
|
||||
/// manager exists yet.</returns>
|
||||
public bool SetAlarmCommandRouter(Action<AlarmCommand>? router)
|
||||
{
|
||||
if (_otOpcUaNodeManager is null) return false;
|
||||
_otOpcUaNodeManager.AlarmCommandRouter = router;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical string constants for the OPC UA <b>data-plane</b> roles the LDAP group→role map
|
||||
/// produces and <see cref="RoleCarryingUserIdentity.Roles"/> carries onto the session identity.
|
||||
/// These are distinct from the control-plane <c>AdminRole</c> enum (Admin UI capabilities) — the
|
||||
/// two planes share zero runtime code path by design.
|
||||
/// <para>
|
||||
/// Across the codebase these data-plane roles (<c>ReadOnly</c>, <c>WriteOperate</c>,
|
||||
/// <c>WriteTune</c>, <c>WriteConfigure</c>, <c>AlarmAck</c>, …) are used as bare strings
|
||||
/// (they originate as LDAP group names mapped through <c>RoleMapper</c>). T18 introduced this
|
||||
/// single shared const for the one role the inbound alarm-method gate reads, so the gate and
|
||||
/// its tests reference one symbol instead of a re-typed literal. Comparison is case-insensitive
|
||||
/// (the role set is built with <see cref="System.StringComparer.OrdinalIgnoreCase"/>), so the
|
||||
/// gate matches with that comparer too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class OpcUaDataPlaneRoles
|
||||
{
|
||||
/// <summary>The role that grants OPC UA Part 9 alarm acknowledge / confirm / shelve / comment
|
||||
/// authority. A session must carry this role for the inbound alarm-condition method handlers to
|
||||
/// route the command to the engine; absent it, the call is denied with
|
||||
/// <c>BadUserAccessDenied</c>.</summary>
|
||||
public const string AlarmAck = "AlarmAck";
|
||||
}
|
||||
@@ -58,6 +58,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
/// the constant the (retired) <c>ScriptedAlarmActor</c> used so subscribers stay wired.</summary>
|
||||
public const string AlertsTopic = "alerts";
|
||||
|
||||
/// <summary>The cluster DistributedPubSub topic inbound OPC UA Part 9 alarm method calls
|
||||
/// (Acknowledge / Confirm / Shelve / AddComment) are routed onto as <see cref="AlarmCommand"/>s.
|
||||
/// The OPC UA node manager's condition handlers build the command (after the <c>AlarmAck</c> role
|
||||
/// gate); the host's boot wiring publishes it here; T19's engine-side subscriber consumes it.</summary>
|
||||
public const string AlarmCommandsTopic = "alarm-commands";
|
||||
|
||||
/// <summary>Reconcile the loaded alarm set to exactly the enabled subset of <paramref name="Plans"/>:
|
||||
/// builds <see cref="ScriptedAlarmDefinition"/>s (skipping disabled plans), reloads the engine, and
|
||||
/// re-registers mux interest for the union of dependency refs.</summary>
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// T18 — inbound OPC UA Part 9 alarm method handlers. Boots a real <see cref="OtOpcUaSdkServer"/>,
|
||||
/// materialises a condition, sets a capturing <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/>,
|
||||
/// then drives the condition's public SDK handler delegates (<c>OnAcknowledge</c>/<c>OnShelve</c>)
|
||||
/// directly. Verifies (a) the <c>AlarmAck</c> role gate fails closed and (b) the routed
|
||||
/// <see cref="AlarmCommand"/> is mapped correctly.
|
||||
/// </summary>
|
||||
public sealed class AlarmCommandRouterTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-alarmcmd-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>With the <c>AlarmAck</c> role present, OnAcknowledge returns Good and routes exactly one
|
||||
/// correctly-mapped AlarmCommand (AlarmId = ScriptedAlarmId, Operation, User, Comment).</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_with_AlarmAck_returns_good_and_routes_mapped_command()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-1");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnAcknowledge.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "alice", "ReadOnly", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack note"));
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
var cmd = captured[0];
|
||||
cmd.AlarmId.ShouldBe("alm-1"); // == ScriptedAlarmId (condition NodeId identifier)
|
||||
cmd.Operation.ShouldBe("Acknowledge");
|
||||
cmd.User.ShouldBe("alice");
|
||||
cmd.Comment.ShouldBe("ack note");
|
||||
cmd.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Without the <c>AlarmAck</c> role, OnAcknowledge is vetoed (BadUserAccessDenied) and the
|
||||
/// router is NOT invoked — the gate fails closed and never mutates state.</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_without_AlarmAck_returns_denied_and_does_not_route()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
||||
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-2");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "bob", "ReadOnly"); // no AlarmAck
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A null identity (anonymous / no role-carrying identity on the context) is denied and does
|
||||
/// not route — the gate fails closed on a missing principal.</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_with_null_identity_returns_denied_and_does_not_route()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
|
||||
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-3");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// ServerSystemContext with no UserIdentity set ⇒ (context as ISessionOperationContext).UserIdentity is null.
|
||||
var ctx = new ServerSystemContext(server.CurrentInstance);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnConfirm with AlarmAck maps to the Confirm operation.</summary>
|
||||
[Fact]
|
||||
public async Task OnConfirm_with_AlarmAck_routes_confirm_operation()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C");
|
||||
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-c");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnConfirm.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "carol", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: null);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("Confirm");
|
||||
captured[0].Comment.ShouldBeNull(); // null LocalizedText ⇒ null Comment
|
||||
captured[0].User.ShouldBe("carol");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnAddComment with AlarmAck maps to the AddComment operation, carrying the comment text.</summary>
|
||||
[Fact]
|
||||
public async Task OnAddComment_with_AlarmAck_routes_add_comment_operation()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC");
|
||||
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-ac");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnAddComment.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "dave", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnAddComment!(ctx, condition, EventIdBytes(), new LocalizedText("look here"));
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("AddComment");
|
||||
captured[0].Comment.ShouldBe("look here");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve with oneShot=true maps to OneShotShelve with no UnshelveAtUtc.</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_oneshot_routes_one_shot_shelve_with_no_expiry()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1");
|
||||
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s1");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnShelve.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "erin", OpcUaDataPlaneRoles.AlarmAck);
|
||||
// SDK OneShotShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:true, 0.0).
|
||||
var result = condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("OneShotShelve");
|
||||
captured[0].UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve with oneShot=false and shelving=true maps to TimedShelve with UnshelveAtUtc
|
||||
/// derived from the millisecond Duration (UtcNow + ms).</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_timed_routes_timed_shelve_with_ms_derived_expiry()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2");
|
||||
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s2");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "frank", OpcUaDataPlaneRoles.AlarmAck);
|
||||
const double shelvingTimeMs = 60_000.0; // OPC UA Duration = milliseconds
|
||||
var before = DateTime.UtcNow;
|
||||
// SDK TimedShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:false, shelvingTime).
|
||||
var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: shelvingTimeMs);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("TimedShelve");
|
||||
captured[0].UnshelveAtUtc.ShouldNotBeNull();
|
||||
// UtcNow + 60s, bracketed by the before/after capture instants.
|
||||
captured[0].UnshelveAtUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddMilliseconds(shelvingTimeMs));
|
||||
captured[0].UnshelveAtUtc!.Value.ShouldBeLessThanOrEqualTo(after.AddMilliseconds(shelvingTimeMs));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve with shelving=false maps to Unshelve with no UnshelveAtUtc.</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_unshelve_routes_unshelve_operation()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3");
|
||||
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s3");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck);
|
||||
// SDK Unshelve invokes OnShelve(ctx, alarm, shelving:false, oneShot:false, 0.0).
|
||||
var result = condition!.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0.0);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("Unshelve");
|
||||
captured[0].UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve without AlarmAck is vetoed and does not route.</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_without_AlarmAck_returns_denied_and_does_not_route()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4");
|
||||
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s4");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "heidi", "ReadOnly"); // no AlarmAck
|
||||
var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnTimedUnshelve with AlarmAck maps to the Unshelve operation (the timer fired, the alarm
|
||||
/// auto-unshelves).</summary>
|
||||
[Fact]
|
||||
public async Task OnTimedUnshelve_with_AlarmAck_routes_unshelve_operation()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU");
|
||||
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-tu");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnTimedUnshelve.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnTimedUnshelve!(ctx, condition);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("Unshelve");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A null router is a safe no-op: handler still gates + returns Good, just routes nowhere.</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
// No router set (default null).
|
||||
|
||||
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR");
|
||||
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-nr");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "judy", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ok"));
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Builds a <see cref="ServerSystemContext"/> (an <see cref="ISessionOperationContext"/>)
|
||||
/// carrying a <see cref="RoleCarryingUserIdentity"/> with the given name + roles — the exact seam the
|
||||
/// gate reads via <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c>.</summary>
|
||||
private static ServerSystemContext SessionContext(OtOpcUaSdkServer server, string user, params string[] roles)
|
||||
{
|
||||
var identity = new RoleCarryingUserIdentity(
|
||||
new UserNameIdentityToken { UserName = user },
|
||||
roles);
|
||||
return new ServerSystemContext(server.CurrentInstance) { UserIdentity = identity };
|
||||
}
|
||||
|
||||
private static byte[] EventIdBytes() => Guid.NewGuid().ToByteArray();
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AlarmCmdTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.AlarmCmdTest:{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 { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user