fix(alarms): surface a refused native alarm ack synchronously, revert one that fails later (lmxopcua#760)
The native-ack path is fire-and-forget after the Part 9 Acknowledge returns, so a Galaxy ack refused for lack of a credential (#749), or rejected by MXAccess, left the client with Good and the condition showing Acknowledged while Galaxy was never acked. Both options from the issue, layered: 1. Synchronous refusal for what the driver can decide without I/O. New optional IAlarmAcknowledgePreflight (Core.Abstractions); GalaxyDriver refuses an anonymous session when the driver has no AlarmAck fallback account. DriverHostActor publishes a condition NodeId -> owning-driver snapshot into NativeAlarmAckPreflightView (lock-free, volatile); the node manager's new NativeAlarmAckPreflight seam, consulted after the authz and Primary gates, returns BadUserAccessDenied with the reason - nothing routed, applied or reported. A keyed session is not pre-checked (its identity lives in the Secrets store, which must not be read under the SDK lock). 2. Revert for everything else. RouteAlarmAck carries the condition NodeId; DriverInstanceActor replies AlarmAckFailed on a refusal, a backend failure, a driver without an alarm source, or an ack dropped while connecting / reconnecting. DriverHostActor re-projects the condition unacknowledged via NativeAlarmProjector.TryRevertAcknowledge, but only while the local ack is still pending - any driver transition projected since then wins. A disabled or unspawned driver is not reverted (unchanged). GatewayGalaxyAlarmAcknowledger now throws DriverOperationRefusedException on a non-zero native hresult (wrong password, no ack permission) instead of only warning, so that case reverts too; as a refusal it neither retries nor trips the breaker. Tests (each fails with its fix mutated out): projector revert/supersede, DriverHostActor refused + failed ack -> unacked AlarmStateUpdate (successful ack control), preflight view snapshot, node-manager preflight unit test, over-the-wire ConditionMethodEventTests (BadUserAccessDenied, no route, no event; positive control), Galaxy preflight + hresult refusal. Docs: Galaxy.md, AlarmTracking.md, ScriptedAlarms.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+50
-2
@@ -87,8 +87,13 @@ public sealed class GalaxyAckCredentialTests
|
||||
logger.AllText.ShouldNotContain(KeyPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A non-zero native code (MXAccess rejected the ack — e.g. a wrong password) warns AND throws a
|
||||
/// <see cref="DriverOperationRefusedException"/> (lmxopcua#760) so the host reverts the OPC UA condition
|
||||
/// instead of leaving it acknowledged; neither the log nor the exception carries the password.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EvaluateReply_native_failure_warns_without_password()
|
||||
public void EvaluateReply_native_failure_warns_and_refuses_without_password()
|
||||
{
|
||||
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
||||
var reply = new AcknowledgeAlarmReply
|
||||
@@ -98,13 +103,34 @@ public sealed class GalaxyAckCredentialTests
|
||||
};
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger);
|
||||
var ex = Should.Throw<DriverOperationRefusedException>(
|
||||
() => GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger));
|
||||
|
||||
ex.Message.ShouldContain("0x80070005");
|
||||
ex.Message.ShouldContain("CORP\\alice");
|
||||
ex.ToString().ShouldNotContain(KeyPassword);
|
||||
logger.Entries.ShouldHaveSingleItem().Level.ShouldBe(LogLevel.Warning);
|
||||
logger.AllText.ShouldContain("CORP\\alice");
|
||||
logger.AllText.ShouldNotContain(KeyPassword);
|
||||
}
|
||||
|
||||
/// <summary>A zero native code is success: nothing logged, nothing thrown.</summary>
|
||||
[Fact]
|
||||
public void EvaluateReply_success_is_silent()
|
||||
{
|
||||
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
||||
var reply = new AcknowledgeAlarmReply
|
||||
{
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Hresult = 0,
|
||||
};
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger);
|
||||
|
||||
logger.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledger_transport_failure_exception_text_has_no_password()
|
||||
{
|
||||
@@ -359,6 +385,28 @@ public sealed class GalaxyAckCredentialTests
|
||||
logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Text.Contains("refused"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — the synchronous pre-check refuses exactly the case decidable without I/O: an anonymous
|
||||
/// session on a driver with no fallback account. A keyed session is let through (its identity lives in the
|
||||
/// Secrets store), and a fallback account makes every session acceptable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Preflight_refuses_only_an_anonymous_session_without_a_fallback()
|
||||
{
|
||||
using var noFallback = NewDriver(Opts(), new CredentialRecordingAcknowledger(), new DictSecretResolver(), new CapturingLogger());
|
||||
var refusal = noFallback.GetAcknowledgeRefusal(apiKeyId: null);
|
||||
refusal.ShouldNotBeNull();
|
||||
refusal.ShouldContain("FallbackUser");
|
||||
refusal.ShouldContain("drv");
|
||||
noFallback.GetAcknowledgeRefusal(apiKeyId: " ").ShouldNotBeNull("a blank key id is no key");
|
||||
noFallback.GetAcknowledgeRefusal(KeyId).ShouldBeNull("a keyed session may have an identity in the Secrets store");
|
||||
|
||||
using var withFallback = NewDriver(
|
||||
Opts() with { AlarmAck = WithFallback() }, new CredentialRecordingAcknowledger(), new DictSecretResolver(), new CapturingLogger());
|
||||
withFallback.GetAcknowledgeRefusal(apiKeyId: null).ShouldBeNull();
|
||||
withFallback.GetAcknowledgeRefusal(KeyId).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Annotate_comment_forms()
|
||||
{
|
||||
|
||||
+57
@@ -493,6 +493,63 @@ public sealed class ConditionMethodEventTests
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — an OPC UA Acknowledge of a NATIVE condition that the owning driver's synchronous pre-check
|
||||
/// refuses (e.g. an anonymous session on a Galaxy driver with no fallback ack account) fails ON THE WIRE with
|
||||
/// <c>BadUserAccessDenied</c> carrying the driver's reason: nothing is routed, applied or reported. Once the
|
||||
/// pre-check has no objection the same call is accepted (the positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_acknowledge_the_driver_would_refuse_is_refused_synchronously()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondNativeAckPreflight", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
var current = PreState("Acknowledge", native: true) with { Message = "base" };
|
||||
WireSimulatedOwners(rig, native: true, () => current, s => current = s);
|
||||
var routed = 0;
|
||||
var driverAck = rig.Nm.NativeAlarmAckRouter!;
|
||||
rig.Nm.NativeAlarmAckRouter = ack => { Interlocked.Increment(ref routed); driverAck(ack); };
|
||||
string? refusal = "no Galaxy ack account is configured for this session";
|
||||
rig.Nm.NativeAlarmAckPreflight = _ => refusal;
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(native: true, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
var condition = ConditionNodeId(session, native: true);
|
||||
|
||||
var refused = await Should.ThrowAsync<ServiceResultException>(
|
||||
CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct));
|
||||
refused.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.ShouldBe(0, "a refused ack reaches no driver");
|
||||
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value
|
||||
.ShouldBeFalse("a refused ack applies nothing");
|
||||
|
||||
// Positive control: with nothing to object to, the same call is accepted, routed and reported.
|
||||
refusal = null;
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
routed.ShouldBe(1);
|
||||
|
||||
rig.Project(native: true, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, "the base transition plus the accepted ack's event; the refusal reported nothing");
|
||||
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledging an acknowledged condition WITHOUT a comment reports nothing, and does not flip a scripted
|
||||
/// condition's Confirmed out of step with its engine. With a comment it reports exactly one event carrying
|
||||
|
||||
@@ -450,6 +450,47 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — when the owning driver's synchronous pre-check refuses the acknowledge, OnAcknowledge
|
||||
/// returns <c>BadUserAccessDenied</c> carrying the driver's reason, and nothing is routed (so the SDK applies
|
||||
/// no state change and no event is reported). The pre-check sees the condition NodeId and the session.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Native_OnAcknowledge_refused_by_preflight_returns_denied_with_reason_and_routes_nothing()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var native = new List<NativeAlarmAck>();
|
||||
var checkedAcks = new List<NativeAlarmAck>();
|
||||
nm.NativeAlarmAckRouter = native.Add;
|
||||
nm.NativeAlarmAckPreflight = ack =>
|
||||
{
|
||||
checkedAcks.Add(ack);
|
||||
return "no Galaxy ack account for an anonymous session";
|
||||
};
|
||||
|
||||
nm.EnsureFolder("eq-nak5", parentNodeId: null, displayName: "Equipment NAK5", realm: AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("alm-nak5", "eq-nak5", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||
var condition = nm.TryGetAlarmCondition("alm-nak5");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "quinn", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.LocalizedText.Text.ShouldBe("no Galaxy ack account for an anonymous session");
|
||||
native.ShouldBeEmpty();
|
||||
checkedAcks.ShouldHaveSingleItem().ConditionNodeId.ShouldBe("alm-nak5");
|
||||
|
||||
// Positive control: the same ack goes through once the pre-check has nothing to object to.
|
||||
nm.NativeAlarmAckPreflight = _ => null;
|
||||
condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack")).ShouldBe(ServiceResult.Good);
|
||||
native.ShouldHaveSingleItem();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>H6c — a SCRIPTED condition's Acknowledge still routes through the scripted
|
||||
/// <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/> (Operation == "Acknowledge"); the native
|
||||
/// router is NOT invoked.</summary>
|
||||
|
||||
+145
-4
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -8,12 +9,14 @@ using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
@@ -144,17 +147,140 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
recorder.Acks.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — the driver REFUSES the acknowledge (no Galaxy ack credential): the host reverts the
|
||||
/// condition the node manager optimistically acknowledged, re-projecting it Active + UNacknowledged with
|
||||
/// its message and severity intact, so the address space stops claiming an ack Galaxy never received.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_refused_ack_reverts_the_condition_to_unacknowledged()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway")
|
||||
{
|
||||
AckFault = new DriverOperationRefusedException("no Galaxy ack credential"),
|
||||
};
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder, publish.Ref);
|
||||
|
||||
RaiseAndExpectUnacked(actor, publish);
|
||||
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
|
||||
var reverted = NextAlarmUpdate(publish);
|
||||
reverted.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
reverted.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
reverted.State.Active.ShouldBeTrue();
|
||||
reverted.State.Acknowledged.ShouldBeFalse();
|
||||
reverted.State.Message.ShouldBe("temperature high");
|
||||
reverted.State.Severity.ShouldBe((ushort)700);
|
||||
recorder.Acks.Count.ShouldBe(1, "the ack did reach the driver, which refused it");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — any backend failure (here: the gateway rejecting the ack) reverts the same way, not just
|
||||
/// a deliberate refusal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_failed_ack_reverts_the_condition_to_unacknowledged()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway")
|
||||
{
|
||||
AckFault = new InvalidOperationException("gateway hresult 0x80070005"),
|
||||
};
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder, publish.Ref);
|
||||
|
||||
RaiseAndExpectUnacked(actor, publish);
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
|
||||
NextAlarmUpdate(publish).State.Acknowledged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — a SUCCESSFUL ack reverts nothing. Positive control: the next driver Clear projects
|
||||
/// inactive + ACKED, which it only can when the local ack stayed in the projector (a revert would have
|
||||
/// left it unacked) — and it must be the very next condition update, so no revert slipped in between.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_successful_ack_is_not_reverted()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder, publish.Ref);
|
||||
|
||||
RaiseAndExpectUnacked(actor, publish);
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
AwaitAssert(() => recorder.Acks.Count.ShouldBe(1), duration: Timeout);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", Evt(AlarmTransitionKind.Clear)));
|
||||
var next = NextAlarmUpdate(publish);
|
||||
next.State.Active.ShouldBeFalse();
|
||||
next.State.Acknowledged.ShouldBeTrue("the successful ack must have survived — no revert");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — the host publishes the condition → owning-driver pre-check snapshot the OPC UA node
|
||||
/// manager uses to refuse an acknowledge synchronously: the driver's own verdict for the condition it
|
||||
/// owns, nothing for an unknown condition.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_ack_preflight_view_answers_with_the_owning_drivers_verdict()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway")
|
||||
{
|
||||
PreflightRefusal = key => key is null ? "anonymous sessions cannot acknowledge" : null,
|
||||
};
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var view = new NativeAlarmAckPreflightView();
|
||||
SpawnHostAndApply(db, deploymentId, recorder, ackPreflightView: view);
|
||||
|
||||
AwaitAssert(() => view.Check(AlarmRawPath, apiKeyId: null).ShouldBe("anonymous sessions cannot acknowledge"),
|
||||
duration: Timeout);
|
||||
view.Check(AlarmRawPath, apiKeyId: "0a1b2c").ShouldBeNull();
|
||||
view.Check("Plant/gw/dev1/does-not-exist", apiKeyId: null).ShouldBeNull();
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(AlarmTransitionKind kind) => new(
|
||||
new StubAlarmHandleForTests(), SourceNodeId: "Temp", ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high", Severity: AlarmSeverity.High, SourceTimestampUtc: DateTime.UtcNow, Kind: kind);
|
||||
|
||||
private void RaiseAndExpectUnacked(IActorRef actor, TestProbe publish)
|
||||
{
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", Evt(AlarmTransitionKind.Raise)));
|
||||
var raised = NextAlarmUpdate(publish);
|
||||
raised.State.Active.ShouldBeTrue();
|
||||
raised.State.Acknowledged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static OpcUaPublishActor.AlarmStateUpdate NextAlarmUpdate(TestProbe publish) =>
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, Timeout);
|
||||
|
||||
private sealed class StubAlarmHandleForTests : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "test-alarm-sub";
|
||||
}
|
||||
|
||||
/// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits
|
||||
/// for the Applied ACK so the apply (and thus the inverse-map build in PushDesiredSubscriptions) has
|
||||
/// completed before the test routes an ack.</summary>
|
||||
private IActorRef SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory,
|
||||
IActorRef? opcUaPublishActor = null, NativeAlarmAckPreflightView? ackPreflightView = null)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
driverFactory: factory,
|
||||
localRoles: new HashSet<string> { "driver" }));
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
opcUaPublishActor: opcUaPublishActor,
|
||||
ackPreflightView: ackPreflightView));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
@@ -248,6 +374,12 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
/// <summary>True once the driver child has reached Connected and opened its alarm subscription.</summary>
|
||||
public bool AlarmsSubscribed => _driver.AlarmsSubscribed;
|
||||
|
||||
/// <summary>When set, every acknowledge is recorded and then fails with this exception.</summary>
|
||||
public Exception? AckFault { init => _driver.AckFault = value; }
|
||||
|
||||
/// <summary>The driver's synchronous pre-check verdict (lmxopcua#760); null ⇒ always proceed.</summary>
|
||||
public Func<string?, string?>? PreflightRefusal { init => _driver.PreflightRefusal = value; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
@@ -261,8 +393,17 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IDriver"/> + <see cref="IAlarmSource"/> that records every acknowledge.</summary>
|
||||
private sealed class RecordingAlarmDriver : IDriver, IAlarmSource
|
||||
private sealed class RecordingAlarmDriver : IDriver, IAlarmSource, IAlarmAcknowledgePreflight
|
||||
{
|
||||
/// <summary>When set, <see cref="AcknowledgeAsync"/> records the request and then throws this.</summary>
|
||||
public Exception? AckFault { get; set; }
|
||||
|
||||
/// <summary>The <see cref="GetAcknowledgeRefusal"/> verdict; null ⇒ always proceed.</summary>
|
||||
public Func<string?, string?>? PreflightRefusal { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetAcknowledgeRefusal(string? apiKeyId) => PreflightRefusal?.Invoke(apiKeyId);
|
||||
|
||||
private readonly ConcurrentQueue<AlarmAcknowledgeRequest> _acks = new();
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId { get; private set; } = string.Empty;
|
||||
@@ -303,7 +444,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var a in acknowledgements) _acks.Enqueue(a);
|
||||
return Task.CompletedTask;
|
||||
return AckFault is { } fault ? Task.FromException(fault) : Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -530,6 +530,62 @@ public class NativeAlarmProjectorTests
|
||||
snap.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — an acknowledge the driver refused reverts the condition: the snapshot keeps the active
|
||||
/// state, message, severity and source quality and is unacknowledged, and the next driver Clear projects
|
||||
/// from the reverted (unacked) prior rather than the optimistic ack.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRevertAcknowledge_reverts_a_pending_local_ack()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.Critical, "boom"));
|
||||
sut.MarkAcknowledged("n1");
|
||||
|
||||
sut.TryRevertAcknowledge("n1", out var reverted).ShouldBeTrue();
|
||||
reverted.ShouldNotBeNull();
|
||||
reverted.Active.ShouldBeTrue();
|
||||
reverted.Acknowledged.ShouldBeFalse();
|
||||
reverted.Severity.ShouldBe((ushort)900);
|
||||
reverted.Message.ShouldBe("boom");
|
||||
reverted.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Clear)).Acknowledged.ShouldBeFalse(
|
||||
"the reverted ack must not come back on the next Clear");
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#760 — a revert happens at most once per local ack, and never without one.</summary>
|
||||
[Fact]
|
||||
public void TryRevertAcknowledge_without_a_pending_ack_does_nothing()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.TryRevertAcknowledge("untracked", out var none).ShouldBeFalse();
|
||||
none.ShouldBeNull();
|
||||
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
sut.TryRevertAcknowledge("n1", out _).ShouldBeFalse("nothing was acknowledged locally");
|
||||
|
||||
sut.MarkAcknowledged("n1");
|
||||
sut.TryRevertAcknowledge("n1", out _).ShouldBeTrue();
|
||||
sut.TryRevertAcknowledge("n1", out _).ShouldBeFalse("a second failure report must not revert twice");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#760 — once a driver transition for the condition has been projected after the local ack, that
|
||||
/// transition is authoritative: a late failure report does not rewrite it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_driver_transition_after_the_ack_supersedes_a_late_failure()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
sut.MarkAcknowledged("n1");
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)).Acknowledged.ShouldBeTrue();
|
||||
|
||||
sut.TryRevertAcknowledge("n1", out var snapshot).ShouldBeFalse();
|
||||
snapshot.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
|
||||
Reference in New Issue
Block a user