feat(alarms): AlarmAcknowledgeRequest carries OperatorUser; Galaxy/ScriptedAlarmSource honor it [H6b]
This commit is contained in:
@@ -41,10 +41,15 @@ public interface IAlarmSubscriptionHandle
|
||||
}
|
||||
|
||||
/// <summary>One alarm acknowledgement in a batch.</summary>
|
||||
/// <param name="SourceNodeId">Driver-side identifier for the alarm source.</param>
|
||||
/// <param name="ConditionId">Stable id correlating raise / ack / clear of the same condition.</param>
|
||||
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system.</param>
|
||||
/// <param name="OperatorUser">Authenticated principal performing the ack; null on the raw/non-OPC-UA path.</param>
|
||||
public sealed record AlarmAcknowledgeRequest(
|
||||
string SourceNodeId,
|
||||
string ConditionId,
|
||||
string? Comment);
|
||||
string? Comment,
|
||||
string? OperatorUser = null);
|
||||
|
||||
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
|
||||
/// <param name="SubscriptionHandle">Subscription this event belongs to.</param>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,37 @@ public sealed class ScriptedAlarmSourceTests
|
||||
state.LastAckComment.ShouldBe("ack via opcua");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AcknowledgeAsync honors a supplied authenticated principal
|
||||
/// (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>) and falls back to
|
||||
/// <c>"opcua-client"</c> only when none is supplied.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null arguments are rejected.</summary>
|
||||
[Fact]
|
||||
public async Task Null_arguments_rejected()
|
||||
|
||||
@@ -210,6 +210,26 @@ public sealed class GalaxyDriverAlarmSourceTests
|
||||
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AcknowledgeAsync forwards the request's authenticated principal
|
||||
/// (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>) to the acknowledger so the
|
||||
/// gateway records WHO acked, rather than the generic empty-string fallback.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
|
||||
|
||||
Reference in New Issue
Block a user