Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckCredentialTests.cs
T
Joseph Doherty 8e8d4cdf13 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
2026-11-09 13:12:17 -05:00

466 lines
21 KiB
C#

using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// lmxopcua#749 — operator-authenticated Galaxy ack (mxaccessgw 488649c+): the request carries a real
/// Galaxy account + password resolved from the API key's identity or the driver's fallback account,
/// refuses with neither, and never lets the password reach a log line or exception text.
/// </summary>
public sealed class GalaxyAckCredentialTests
{
private const string KeyId = "0a1b2c3d4e5f6071";
private const string KeyPassword = "key-pw-S3cr3t!";
private const string FallbackPassword = "fallback-pw-S3cr3t!";
// ------------------------------------------------------------------ request mapping
[Fact]
public void BuildRequest_carries_user_domain_and_password()
{
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
var request = GatewayGalaxyAlarmAcknowledger.BuildRequest("Tank01.Level.HiHi", "investigating", credential);
request.AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
request.Comment.ShouldBe("investigating");
request.OperatorUser.ShouldBe("alice");
request.OperatorDomain.ShouldBe("CORP");
request.OperatorPassword.ShouldBe(KeyPassword);
request.ClientCorrelationId.ShouldNotBeNullOrEmpty();
}
[Fact]
public void BuildRequest_allows_empty_password_but_never_empty_user()
{
// Galaxy accounts can have empty passwords (gateway contract) — the field is sent empty.
var request = GatewayGalaxyAlarmAcknowledger.BuildRequest(
"A.B", "c", new GalaxyAckCredential("bob", null, null, GalaxyAckCredentialSource.Fallback));
request.OperatorPassword.ShouldBe(string.Empty);
request.OperatorDomain.ShouldBe(string.Empty);
request.OperatorUser.ShouldBe("bob");
// The credential type itself cannot be built with an empty user — the gateway rejects one.
Should.Throw<ArgumentException>(() => new GalaxyAckCredential(" ", "D", "p", GalaxyAckCredentialSource.Fallback));
}
[Fact]
public void Credential_and_resolution_ToString_omit_the_password()
{
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
credential.ToString().ShouldNotContain(KeyPassword);
credential.ToString().ShouldContain("CORP\\alice");
GalaxyAckCredentialResolution.Resolved(credential).ToString().ShouldNotContain(KeyPassword);
}
// ------------------------------------------------------------------ reply handling / redaction
[Fact]
public void EvaluateReply_protocol_failure_throws_with_password_scrubbed()
{
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
var reply = new AcknowledgeAlarmReply
{
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.InvalidRequest,
// Pathological gateway echo — the scrub is defence in depth over the gateway's contract.
Message = $"auth failed for alice/{KeyPassword}",
},
};
var logger = new CapturingLogger();
var ex = Should.Throw<InvalidOperationException>(
() => GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger));
ex.ToString().ShouldNotContain(KeyPassword);
ex.Message.ShouldContain(GatewayGalaxyAlarmAcknowledger.Redacted);
ex.Message.ShouldContain("CORP\\alice");
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_and_refuses_without_password()
{
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
var reply = new AcknowledgeAlarmReply
{
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = unchecked((int)0x80070005),
};
var logger = new CapturingLogger();
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()
{
var client = MxGatewayClient.Create(new MxGatewayClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1", UriKind.Absolute),
ApiKey = "test-key",
UseTls = false,
ConnectTimeout = TimeSpan.FromMilliseconds(200),
DefaultCallTimeout = TimeSpan.FromMilliseconds(200),
});
await using (client)
{
var logger = new CapturingLogger();
var acknowledger = new GatewayGalaxyAlarmAcknowledger(client, logger);
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
var ex = await Record.ExceptionAsync(
() => acknowledger.AcknowledgeAsync("A.B", "c", credential, CancellationToken.None));
ex.ShouldNotBeNull();
ex.ToString().ShouldNotContain(KeyPassword);
logger.AllText.ShouldNotContain(KeyPassword);
}
}
[Fact]
public void Scrub_replaces_every_occurrence_and_ignores_empty_secret()
{
GatewayGalaxyAlarmAcknowledger.Scrub("a pw b pw", "pw").ShouldBe("a *** b ***");
GatewayGalaxyAlarmAcknowledger.Scrub("unchanged", string.Empty).ShouldBe("unchanged");
GatewayGalaxyAlarmAcknowledger.Scrub(null, "pw").ShouldBe(string.Empty);
}
// ------------------------------------------------------------------ resolver paths
[Fact]
public async Task Resolver_key_mapped_identity_wins_over_fallback()
{
var secrets = new DictSecretResolver
{
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
["otopcua/galaxy/ack-fallback"] = FallbackPassword,
};
var logger = new CapturingLogger();
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, logger);
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
var credential = result.Credential.ShouldNotBeNull();
credential.Source.ShouldBe(GalaxyAckCredentialSource.ApiKey);
credential.User.ShouldBe("alice");
credential.Domain.ShouldBe("CORP");
credential.Password.ShouldBe(KeyPassword);
logger.AllText.ShouldNotContain(KeyPassword);
}
[Fact]
public async Task Resolver_key_without_identity_uses_fallback()
{
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
credential.User.ShouldBe("ack-svc");
credential.Domain.ShouldBe("PLANT");
credential.Password.ShouldBe(FallbackPassword);
}
[Fact]
public async Task Resolver_anonymous_uses_fallback()
{
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
var result = await resolver.ResolveAsync(apiKeyId: null, CancellationToken.None);
result.Credential.ShouldNotBeNull().Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
}
[Theory]
[InlineData(null)]
[InlineData(KeyId)]
public async Task Resolver_refuses_with_no_identity_and_no_fallback(string? apiKeyId)
{
var resolver = new GalaxyAckCredentialResolver(
"drv-x", new GalaxyAlarmAckOptions(), new DictSecretResolver(), new CapturingLogger());
var result = await resolver.ResolveAsync(apiKeyId, CancellationToken.None);
result.Credential.ShouldBeNull();
result.RefusalReason.ShouldNotBeNull();
result.RefusalReason.ShouldContain("drv-x");
result.RefusalReason.ShouldContain("FallbackUser");
}
[Fact]
public async Task Resolver_refuses_key_with_user_but_no_password_even_when_fallback_exists()
{
// A key the admin explicitly mapped must not silently ack as the shared fallback account.
var secrets = new DictSecretResolver
{
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
["otopcua/galaxy/ack-fallback"] = FallbackPassword,
};
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
result.Credential.ShouldBeNull();
result.RefusalReason.ShouldNotBeNull().ShouldContain("no password secret");
}
[Fact]
public async Task Resolver_key_with_empty_password_is_allowed()
{
var secrets = new DictSecretResolver
{
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = string.Empty,
};
var resolver = new GalaxyAckCredentialResolver("drv", new GalaxyAlarmAckOptions(), secrets, new CapturingLogger());
var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
credential.Password.ShouldBe(string.Empty);
credential.Domain.ShouldBe(string.Empty);
}
[Fact]
public async Task Resolver_refuses_literal_fallback_password_without_echoing_it()
{
var logger = new CapturingLogger();
var resolver = new GalaxyAckCredentialResolver(
"drv", new GalaxyAlarmAckOptions("ack-svc", null, FallbackPassword), new DictSecretResolver(), logger);
var result = await resolver.ResolveAsync(null, CancellationToken.None);
result.Credential.ShouldBeNull();
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(FallbackPassword);
logger.AllText.ShouldNotContain(FallbackPassword);
}
[Fact]
public async Task Resolver_refuses_when_fallback_secret_absent()
{
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), new DictSecretResolver(), new CapturingLogger());
var result = await resolver.ResolveAsync(null, CancellationToken.None);
result.Credential.ShouldBeNull();
result.RefusalReason.ShouldNotBeNull().ShouldContain("secret:otopcua/galaxy/ack-fallback");
}
[Theory]
[InlineData("${secret:otopcua/galaxy/ack-fallback}")]
[InlineData("secret:otopcua/galaxy/ack-fallback")]
public async Task Resolver_accepts_both_secret_reference_spellings(string reference)
{
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
var resolver = new GalaxyAckCredentialResolver(
"drv", new GalaxyAlarmAckOptions("ack-svc", null, reference), secrets, new CapturingLogger());
(await resolver.ResolveAsync(null, CancellationToken.None)).Credential.ShouldNotBeNull()
.Password.ShouldBe(FallbackPassword);
}
[Fact]
public async Task Resolver_secret_store_fault_is_a_refusal_not_a_throw()
{
var logger = new CapturingLogger();
var resolver = new GalaxyAckCredentialResolver(
"drv", WithFallback(), new ThrowingSecretResolver(KeyPassword), logger);
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
result.Credential.ShouldBeNull();
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(KeyPassword);
logger.AllText.ShouldNotContain(KeyPassword);
}
[Fact]
public void Identity_secret_names_and_parsing()
{
GalaxyAckIdentitySecrets.UserSecretName("0A1B").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-user");
GalaxyAckIdentitySecrets.PasswordSecretName("0a1b").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-password");
Should.Throw<ArgumentException>(() => GalaxyAckIdentitySecrets.UserSecretName("../x"));
GalaxyAckIdentitySecrets.ParseIdentity("CORP\\alice").ShouldBe(("alice", "CORP"));
GalaxyAckIdentitySecrets.ParseIdentity("alice").ShouldBe(("alice", string.Empty));
GalaxyAckIdentitySecrets.FormatIdentity("alice", "CORP").ShouldBe("CORP\\alice");
GalaxyAckIdentitySecrets.FormatIdentity("alice", " ").ShouldBe("alice");
}
// ------------------------------------------------------------------ driver paths
[Fact]
public async Task Driver_key_mapped_path_sends_the_keys_account()
{
var ack = new CredentialRecordingAcknowledger();
var secrets = new DictSecretResolver
{
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
};
var logger = new CapturingLogger();
using var driver = NewDriver(Opts(), ack, secrets, logger);
await driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None);
var call = ack.Calls.ShouldHaveSingleItem();
call.Credential.User.ShouldBe("alice");
call.Credential.Password.ShouldBe(KeyPassword);
call.Comment.ShouldBe($"cmt [OPC UA: mes-line1, key {KeyId}]");
logger.AllText.ShouldNotContain(KeyPassword);
logger.AllText.ShouldContain("mes-line1");
}
[Fact]
public async Task Driver_fallback_path_sends_the_fallback_account()
{
var ack = new CredentialRecordingAcknowledger();
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
var logger = new CapturingLogger();
using var driver = NewDriver(Opts() with { AlarmAck = WithFallback() }, ack, secrets, logger);
await driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", null, OperatorUser: "anonymous")], CancellationToken.None);
var call = ack.Calls.ShouldHaveSingleItem();
call.Credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
call.Credential.User.ShouldBe("ack-svc");
call.Credential.Password.ShouldBe(FallbackPassword);
call.Comment.ShouldBe("[OPC UA: anonymous]");
logger.AllText.ShouldNotContain(FallbackPassword);
}
[Fact]
public async Task Driver_refused_path_sends_nothing_and_throws_refusal()
{
var ack = new CredentialRecordingAcknowledger();
var logger = new CapturingLogger();
using var driver = NewDriver(Opts(), ack, new DictSecretResolver(), logger);
var ex = await Should.ThrowAsync<DriverOperationRefusedException>(() => driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None));
ack.Calls.ShouldBeEmpty(); // never reaches the gateway — and so never with an empty operator_user
ex.Message.ShouldContain("Tank01.Level.HiHi");
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()
{
GalaxyDriver.AnnotateAckComment("x", "p", "k").ShouldBe("x [OPC UA: p, key k]");
GalaxyDriver.AnnotateAckComment(" ", null, null).ShouldBe("[OPC UA: anonymous]");
GalaxyDriver.AnnotateAckComment(null, "p", null).ShouldBe("[OPC UA: p]");
}
// ------------------------------------------------------------------ helpers
private static GalaxyAlarmAckOptions WithFallback() =>
new("ack-svc", "PLANT", "secret:otopcua/galaxy/ack-fallback");
private static GalaxyDriverOptions Opts() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "dev:key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
private static GalaxyDriver NewDriver(
GalaxyDriverOptions options, IGalaxyAlarmAcknowledger ack, ISecretResolver secrets, ILogger logger) =>
new("drv", options, hierarchySource: null, alarmAcknowledger: ack,
logger: new TypedLogger<GalaxyDriver>(logger), secretResolver: secrets);
private sealed class TypedLogger<T>(ILogger inner) : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => inner.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => inner.IsEnabled(logLevel);
public void Log<TState>(LogLevel l, EventId id, TState s, Exception? e, Func<TState, Exception?, string> f)
=> inner.Log(l, id, s, e, f);
}
private sealed class CredentialRecordingAcknowledger : IGalaxyAlarmAcknowledger
{
public List<(string AlarmRef, string Comment, GalaxyAckCredential Credential)> Calls { get; } = [];
public Task AcknowledgeAsync(
string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
{
Calls.Add((alarmFullReference, comment, credential));
return Task.CompletedTask;
}
}
private sealed class DictSecretResolver : Dictionary<string, string>, ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult(TryGetValue(name.Value, out var v) ? v : null);
}
private sealed class ThrowingSecretResolver(string leak) : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
throw new InvalidOperationException($"store exploded near {leak}");
}
}