using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// G-2b — pins , the Layer-B /// arm that resolves secret:-prefixed /// and through the shared /// just before the async session-open consumes them. The /// secret: arm is fail-closed — an absent secret throws rather than leaving the /// secret: literal in place (which would be sent verbatim as the password). /// Non-secret literals and null/empty pass through unchanged. /// public sealed class OpcUaClientSecretResolutionTests { private const string KnownPwName = "opcuaclient/inst/password"; private const string KnownPwValue = "resolved-password-plaintext"; private const string KnownCertPwName = "opcuaclient/inst/cert-pw"; private const string KnownCertPwValue = "resolved-cert-pw-plaintext"; /// A fake resolver: returns known values for two known names, null otherwise. private static ISecretResolver FakeResolver() => new StubSecretResolver(); /// (a) A secret: Password resolves to the store's plaintext. [Fact] public async Task Secret_password_resolves_to_store_plaintext() { var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" }; var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( options, FakeResolver(), CancellationToken.None); resolved.Password.ShouldBe(KnownPwValue); } /// (b) A secret: UserCertificatePassword resolves to the store's plaintext. [Fact] public async Task Secret_cert_password_resolves_to_store_plaintext() { var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" }; var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( options, FakeResolver(), CancellationToken.None); resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue); } /// (c) A non-secret literal Password and a null field pass through unchanged. [Fact] public async Task Literal_and_null_pass_through_unchanged() { var options = new OpcUaClientDriverOptions { Password = "plainpw", UserCertificatePassword = null, }; var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( options, FakeResolver(), CancellationToken.None); resolved.Password.ShouldBe("plainpw"); resolved.UserCertificatePassword.ShouldBeNull(); } /// (d) An unknown secret: ref (resolver returns null) is fail-closed — it throws /// and never leaves the secret: literal in place. [Fact] public async Task Unknown_secret_ref_is_fail_closed() { var options = new OpcUaClientDriverOptions { Password = "secret:opcuaclient/inst/absent" }; var ex = await Should.ThrowAsync(() => OpcUaClientSecretResolution.ResolveSecretRefsAsync( options, FakeResolver(), CancellationToken.None)); ex.Message.ShouldContain("Password"); ex.Message.ShouldContain("opcuaclient/inst/absent"); } /// /// lmxopcua#815: the password is sent to the operator-authored endpoint, so a reference outside the /// OpcUaClient namespace throws BEFORE the store or the environment is read — it is never resolved, never sent. /// [Theory] [InlineData("secret:otopcua/historian/api-key")] [InlineData("secret:galaxy/inst1/apikey")] [InlineData("secret:OT-MESH-PSK-MAIN")] [InlineData("env:ConnectionStrings__ConfigDb")] [InlineData("env:ServerHistorian__ApiKey")] [InlineData("Secret:opcuaclient/inst/password")] [InlineData("file:appsettings.json")] public async Task Out_of_namespace_reference_is_refused_without_reading(string reference) { var store = new CountingResolver(); var envReads = 0; foreach (var options in new[] { new OpcUaClientDriverOptions { Password = reference }, new OpcUaClientDriverOptions { UserCertificatePassword = reference }, }) { var ex = await Should.ThrowAsync(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync( options, store, CancellationToken.None, _ => { envReads++; return "LEAKED"; })); ex.Message.ShouldContain("secret:opcuaclient/"); ex.Message.ShouldNotContain("LEAKED"); } store.Calls.ShouldBe(0); envReads.ShouldBe(0); } /// A namespaced environment reference resolves. [Fact] public async Task Namespaced_environment_reference_resolves() { var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync( new OpcUaClientDriverOptions { Password = "env:OTOPCUA_OPCUACLIENT_PLANT_PW" }, FakeResolver(), CancellationToken.None, n => n == "OTOPCUA_OPCUACLIENT_PLANT_PW" ? "pw" : null); resolved.Password.ShouldBe("pw"); } /// /// lmxopcua#815 warn-only compatibility (deprecated): the RUNNING driver's session open keeps the old semantics /// for an out-of-namespace value — a case-insensitive secret: resolves, anything else is a literal — and /// warns once; the browser (no tracker) refuses the same value (see the theory above). /// [Fact] public async Task The_running_driver_keeps_old_semantics_for_a_legacy_value_and_warns() { var warnings = new List(); var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add); var legacyOptions = new OpcUaClientDriverOptions { Password = $"SECRET:{KnownPwName}", // pre-#815: case-insensitive secret: resolved UserCertificatePassword = "env:NOT_A_REF", // pre-#815: only secret: resolved — a literal password }; for (var i = 0; i < 3; i++) { var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync( legacyOptions, FakeResolver(), CancellationToken.None, tracker); resolved.Password.ShouldBe(KnownPwValue); resolved.UserCertificatePassword.ShouldBe("env:NOT_A_REF"); } warnings.Count.ShouldBe(2); // one per field, rate-limited across the three session opens warnings.ShouldAllBe(w => !w.Contains(KnownPwValue)); tracker.Diagnostics.Count.ShouldBe(2); } private sealed class CountingResolver : ISecretResolver { public int Calls { get; private set; } public Task GetAsync(SecretName name, CancellationToken ct) { Calls++; return Task.FromResult("LEAKED"); } } /// A fake resolver: returns known values for two known names, null otherwise. private sealed class StubSecretResolver : ISecretResolver { /// public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(name.Value switch { KnownPwName => KnownPwValue, KnownCertPwName => KnownCertPwValue, _ => null, }); } }