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; /// /// 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. /// 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(() => 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( () => 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); } /// /// A non-zero native code (MXAccess rejected the ack — e.g. a wrong password) warns AND throws a /// (lmxopcua#760) so the host reverts the OPC UA condition /// instead of leaving it acknowledged; neither the log nor the exception carries the password. /// [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( () => 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); } /// A zero native code is success: nothing logged, nothing thrown. [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(() => 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(() => 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")); } /// /// 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. /// [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(logger), secretResolver: secrets); private sealed class TypedLogger(ILogger inner) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => inner.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => inner.IsEnabled(logLevel); public void Log(LogLevel l, EventId id, TState s, Exception? e, Func 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, ISecretResolver { public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(TryGetValue(name.Value, out var v) ? v : null); } private sealed class ThrowingSecretResolver(string leak) : ISecretResolver { public Task GetAsync(SecretName name, CancellationToken ct) => throw new InvalidOperationException($"store exploded near {leak}"); } }