Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
T
Joseph Doherty 1784eedd3f fix(opcua): exempt OnTimedUnshelve from the client AlarmAck gate (system-initiated)
The SDK fires OnTimedUnshelve with the node manager's system context (no
session, no user identity) when a TimedShelve duration expires. Routing
through the shared HandleAlarmCommand hit the AlarmAck gate and returned
BadUserAccessDenied, leaving the alarm permanently shelved.

Replace the delegated HandleAlarmCommand call with an inline lambda that
bypasses the client gate, extracts the AlarmId the same way, and routes an
Unshelve command so the engine clears its shelve state. The manual-client
Unshelve path via OnShelve(shelving:false) remains gated.

Update the AlarmCommandRouterTests OnTimedUnshelve test to use a real
system context (no UserIdentity) — reproducing the actual SDK invocation
path — and assert Good, AlarmId, Operation==Unshelve, User==empty.

Add a doc note to AlarmCommand.Operation that Enable/Disable are in the
vocabulary but not yet wired at the node-manager seam.
2026-06-11 06:16:30 -04:00

376 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 fires with the SDK's system context (no session, no user identity) —
/// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be
/// Good, the router must be invoked exactly once with Operation == "Unshelve" and User == empty,
/// and no UnshelveAtUtc is carried.</summary>
[Fact]
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve()
{
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();
// Reproduce the real SDK path: system context has no session and no UserIdentity — exactly what
// the SDK's internal timer fires the callback with when a TimedShelve duration expires.
var ctx = new ServerSystemContext(server.CurrentInstance); // no UserIdentity set
var result = condition.OnTimedUnshelve!(ctx, condition);
result.ShouldBe(ServiceResult.Good);
captured.Count.ShouldBe(1);
var cmd = captured[0];
cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier
cmd.Operation.ShouldBe("Unshelve");
cmd.User.ShouldBe(string.Empty); // no client principal — system-initiated
cmd.UnshelveAtUtc.ShouldBeNull();
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 */ }
}
}
}