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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user