From ed941c51da0df545423ae6b0b758b71f0ade8086 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 14:11:40 -0400 Subject: [PATCH] feat(alarms): AlarmAcknowledgeRequest carries OperatorUser; Galaxy/ScriptedAlarmSource honor it [H6b] --- .../IAlarmSource.cs | 7 ++++- .../ScriptedAlarmSource.cs | 10 +++--- .../GalaxyDriver.cs | 4 ++- .../ScriptedAlarmSourceTests.cs | 31 +++++++++++++++++++ .../GalaxyDriverAlarmSourceTests.cs | 20 ++++++++++++ 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index 09521fc6..ef967a28 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -41,10 +41,15 @@ public interface IAlarmSubscriptionHandle } /// One alarm acknowledgement in a batch. +/// Driver-side identifier for the alarm source. +/// Stable id correlating raise / ack / clear of the same condition. +/// Operator-supplied comment forwarded to the upstream alarm system. +/// Authenticated principal performing the ack; null on the raw/non-OPC-UA path. public sealed record AlarmAcknowledgeRequest( string SourceNodeId, string ConditionId, - string? Comment); + string? Comment, + string? OperatorUser = null); /// Event payload for . /// Subscription this event belongs to. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs index 684f1d55..eabf3411 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs @@ -68,11 +68,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements)); foreach (var a in acknowledgements) { - // The base interface doesn't carry a user identity — Stream G provides the - // authenticated principal at the OPC UA dispatch layer + proxies through - // the engine's richer AcknowledgeAsync. Here we default to "opcua-client" - // so callers using the raw IAlarmSource still produce an audit entry. - await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken) + // Honor the authenticated principal carried on the request when present + // (the OPC UA dispatch layer / Stream G threads it through). When absent — + // the raw/non-OPC-UA path — default to "opcua-client" so the ack still + // produces an audit entry. + await _engine.AcknowledgeAsync(a.ConditionId, a.OperatorUser ?? "opcua-client", a.Comment, cancellationToken) .ConfigureAwait(false); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index a3f098da..f7a7efbd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -1122,7 +1122,9 @@ public sealed class GalaxyDriver await _alarmAcknowledger.AcknowledgeAsync( alarmFullReference, ack.Comment ?? string.Empty, - operatorUser: string.Empty, // server-side ACL fills this from the OPC UA session + // Honor the authenticated principal carried on the request when present; + // fall back to empty (server-side ACL fills this from the OPC UA session). + operatorUser: ack.OperatorUser ?? string.Empty, cancellationToken).ConfigureAwait(false); } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs index ce499c52..dda21ce5 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs @@ -129,6 +129,37 @@ public sealed class ScriptedAlarmSourceTests state.LastAckComment.ShouldBe("ack via opcua"); } + /// + /// Verifies that AcknowledgeAsync honors a supplied authenticated principal + /// () and falls back to + /// "opcua-client" only when none is supplied. + /// + [Fact] + public async Task ScriptedAlarmSource_uses_supplied_principal_else_default() + { + var (engine, source, up) = await BuildAsync(); + using var _e = engine; + using var _s = source; + + // Supplied principal -> honored. + up.Push("Temp", 150); + await Task.Delay(200); + await source.AcknowledgeAsync([new AlarmAcknowledgeRequest( + "Plant/Line1", "Plant/Line1::HighTemp", "ack as bob", "bob")], + TestContext.Current.CancellationToken); + engine.GetState("Plant/Line1::HighTemp")!.LastAckUser.ShouldBe("bob"); + + // No principal -> default audit identity. + up.Push("Temp", 50); + await Task.Delay(200); + up.Push("Temp", 150); + await Task.Delay(200); + await source.AcknowledgeAsync([new AlarmAcknowledgeRequest( + "Plant/Line1", "Plant/Line1::HighTemp", "ack anon", null)], + TestContext.Current.CancellationToken); + engine.GetState("Plant/Line1::HighTemp")!.LastAckUser.ShouldBe("opcua-client"); + } + /// Verifies that null arguments are rejected. [Fact] public async Task Null_arguments_rejected() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs index 0a9c173e..dd523b0e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmSourceTests.cs @@ -210,6 +210,26 @@ public sealed class GalaxyDriverAlarmSourceTests ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi"); } + /// + /// Verifies that AcknowledgeAsync forwards the request's authenticated principal + /// () to the acknowledger so the + /// gateway records WHO acked, rather than the generic empty-string fallback. + /// + [Fact] + public async Task AcknowledgeAsync_forwards_OperatorUser() + { + var feed = new FakeAlarmFeed(); + var ack = new RecordingAcknowledger(); + using var driver = NewDriver(feed, ack); + + await driver.AcknowledgeAsync( + [new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice")], + CancellationToken.None); + + ack.Calls.ShouldHaveSingleItem(); + ack.Calls[0].Operator.ShouldBe("alice"); + } + /// Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger. [Fact] public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()