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,
});
}
}