feat(secrets): G-2b resolve OpcUaClient secret: Password/UserCertificatePassword (Task 8)
Resolve secret:-prefixed Password + UserCertificatePassword through the shared ISecretResolver, fail-closed on absent, retiring the cleartext-in-DB path. The driver-registry factory is synchronous (Func<string,string,IDriver>), so resolution is done lazily in the async session-open (InitializeAsync, before BuildUserIdentity) rather than at deserialize — mirroring Task 7's Galaxy pattern and matching its re-resolve-on-reconnect behavior. Both consumers (username Password and the certificate-password LoadPkcs12 path via BuildCertificateIdentity) see the resolved connect-scoped options; _options stays raw (secret: refs intact), no long-lived plaintext field. Scope corrections vs the plan (verified against v3): the probe is unauthenticated GetEndpoints-only and never reads either credential, so it is NOT a resolution site (comment added, no dead code); OpcUaClientDriverOptions was a sealed class, converted to sealed record for the with-expression (no positional params → identical JSON; no reference-equality/dict-key/ToString-log usages → no behavior/leak change). ISecretResolver threaded via factory Register/CreateInstance + DriverFactoryBootstrap (real resolver from DI); NullSecretResolver null-object backs test/parse paths only, fail-closed on secret: refs. TDD: 4 helper tests RED→GREEN; 142 OpcUaClient tests pass.
This commit is contained in:
+95
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
|
||||
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
|
||||
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
|
||||
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
|
||||
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
|
||||
/// Non-secret literals and null/empty pass through unchanged.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientSecretResolutionTests
|
||||
{
|
||||
private const string KnownPwName = "opcua/inst/password";
|
||||
private const string KnownPwValue = "resolved-password-plaintext";
|
||||
private const string KnownCertPwName = "opcua/inst/cert-pw";
|
||||
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
|
||||
|
||||
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||
|
||||
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
|
||||
/// and never leaves the <c>secret:</c> literal in place.</summary>
|
||||
[Fact]
|
||||
public async Task Unknown_secret_ref_is_fail_closed()
|
||||
{
|
||||
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||
options, FakeResolver(), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("Password");
|
||||
ex.Message.ShouldContain("opcua/inst/absent");
|
||||
}
|
||||
|
||||
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||
private sealed class StubSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(name.Value switch
|
||||
{
|
||||
KnownPwName => KnownPwValue,
|
||||
KnownCertPwName => KnownCertPwValue,
|
||||
_ => null,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user