using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// lmxopcua#834 (follow-up to #815 / #836): the warn-only compatibility window is over. A RUNNING OpcUaClient /// driver whose stored config carries an out-of-namespace credential reference, or a user-certificate path outside /// the node's certificate directory, now refuses it at resolve time in the session open /// (, before any store / environment read and before any endpoint is /// dialled) — exactly as save, deploy, Test Connect and browse already did. /// [Trait("Category", "Unit")] public sealed class OpcUaClientDriverCredentialScopeTests { private const string PlainPassword = "resolved-plaintext"; private static OpcUaClientDriverOptions Username(string password) => new() { EndpointUrl = "opc.tcp://127.0.0.1:1", AuthType = OpcUaAuthType.Username, Username = "plant-user", Password = password, }; /// /// Out-of-namespace values that the pre-#834 running driver resolved with the old semantics: a secret: /// outside opcuaclient/, a case-variant SECRET: prefix, and an env: outside /// OTOPCUA_OPCUACLIENT_ (which it sent verbatim as a literal password). /// [Theory] [InlineData("secret:otopcua/configdb-password")] [InlineData("SECRET:opcuaclient/inst/password")] [InlineData("env:NOT_A_REF")] public async Task A_running_driver_refuses_an_out_of_namespace_password_ref_before_reading_or_dialling(string reference) { var store = new CountingResolver(); using var driver = new OpcUaClientDriver(Username(reference), "ouc-834", secretResolver: store); var opened = 0; driver.OpenSessionForTest = (_, _) => { opened++; throw new InvalidOperationException("no server in this test"); }; var ex = await Should.ThrowAsync( () => driver.InitializeAsync("{}", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("Password"); ex.Message.ShouldNotContain(PlainPassword); store.Calls.ShouldBe(0); opened.ShouldBe(0, "the credential is refused before any endpoint is dialled"); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } /// lmxopcua#836: a fully-qualified (pre-#836) user-certificate path is refused, not loaded. [Fact] public async Task A_running_driver_refuses_an_absolute_user_certificate_path() { var abs = Path.Combine(Path.GetTempPath(), $"ouc-834-{Guid.NewGuid():N}.pfx"); var options = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://127.0.0.1:1", AuthType = OpcUaAuthType.Certificate, UserCertificatePath = abs, UserCertificatePassword = "pw", }; using var driver = new OpcUaClientDriver(options, "ouc-834-cert", secretResolver: new CountingResolver()); var opened = 0; driver.OpenSessionForTest = (_, _) => { opened++; throw new InvalidOperationException("no server in this test"); }; await Should.ThrowAsync( () => driver.InitializeAsync("{}", TestContext.Current.CancellationToken)); opened.ShouldBe(0); } /// Positive control: a namespaced reference resolves and the session open is reached. [Fact] public async Task A_namespaced_password_ref_resolves_and_the_session_open_is_reached() { var store = new CountingResolver(); using var driver = new OpcUaClientDriver( Username("secret:opcuaclient/inst/password"), "ouc-834-ok", secretResolver: store); var opened = 0; driver.OpenSessionForTest = (_, _) => { opened++; throw new InvalidOperationException("no server in this test"); }; await Should.ThrowAsync( () => driver.InitializeAsync("{}", TestContext.Current.CancellationToken)); store.Calls.ShouldBe(1); opened.ShouldBe(1); } private sealed class CountingResolver : ISecretResolver { public int Calls { get; private set; } public Task GetAsync(SecretName name, CancellationToken ct) { Calls++; return Task.FromResult(PlainPassword); } } }