63289d377c
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).
368 lines
16 KiB
C#
368 lines
16 KiB
C#
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 */ }
|
|
}
|
|
}
|
|
}
|