a224fd6391
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control, set a credential field to any reference the node can resolve (env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>, Galaxy file:<any path>) and click Test Connect / live browse; the node resolved the value and sent it to that host. One shared rule in Core.Abstractions (CredentialReferences + DriverCredentialPolicy) generalises the #803 Heidenhain namespace: - Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... | legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged. - OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged. - Heidenhain adopts the shared parser/resolver (namespace unchanged). Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate (CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test Connect (AdminOperationsActor, before any probe), live browse (BrowserSessionService, before any browser) and again at resolve time in every driver/browser (fail closed, before any store/env read). Co-Authored-By: Claude <noreply@anthropic.com>
100 lines
4.2 KiB
C#
100 lines
4.2 KiB
C#
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 R06:O-5 — the AdminUI browse refused <c>Certificate</c> auth "in v1", so a
|
|
/// certificate-authenticated upstream could not be browsed at all. The browse now builds its identity through
|
|
/// the same <see cref="OpcUaUserIdentityFactory"/> the runtime driver uses (incl. the <c>secret:</c> resolution
|
|
/// of the credential fields, fail-closed), so it presents exactly the credentials the deployed driver will.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientBrowserCertificateAuthTests
|
|
{
|
|
private sealed class OneSecret(string name, string value) : ISecretResolver
|
|
{
|
|
public Task<string?> GetAsync(SecretName secretName, CancellationToken ct) =>
|
|
Task.FromResult<string?>(secretName.Value == name ? value : null);
|
|
}
|
|
|
|
private static string WritePfx(string password)
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest("CN=OpcUaClientBrowserCertAuthTests", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
|
var path = Path.Combine(Path.GetTempPath(), $"opcua-browse-cert-{Guid.NewGuid():N}.pfx");
|
|
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, password));
|
|
return path;
|
|
}
|
|
|
|
/// <summary>Certificate auth is no longer refused: a missing cert path fails on the certificate itself.</summary>
|
|
[Fact]
|
|
public async Task OpenAsync_with_certificate_auth_fails_on_the_certificate_not_a_refusal()
|
|
{
|
|
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => new OpcUaClientDriverBrowser().OpenAsync(json, TestContext.Current.CancellationToken));
|
|
|
|
ex.Message.ShouldContain("UserCertificatePath");
|
|
ex.Message.ShouldNotContain("does not support");
|
|
}
|
|
|
|
/// <summary>A valid PFX whose password is a <c>secret:</c> ref resolves through the injected store.</summary>
|
|
[Fact]
|
|
public async Task A_certificate_identity_is_built_with_its_secret_password_resolved()
|
|
{
|
|
var path = WritePfx("s3cret-pw");
|
|
try
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
AuthType = OpcUaAuthType.Certificate,
|
|
UserCertificatePath = path,
|
|
UserCertificatePassword = "secret:opcuaclient/browse-cert",
|
|
};
|
|
|
|
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
|
opts, new OneSecret("opcuaclient/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
|
|
|
|
identity.TokenType.ShouldBe(UserTokenType.Certificate);
|
|
}
|
|
finally
|
|
{
|
|
try { File.Delete(path); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>With no secret store, a <c>secret:</c> ref fails closed instead of being sent as the literal.</summary>
|
|
[Fact]
|
|
public async Task A_secret_ref_with_no_secret_store_fails_closed()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
AuthType = OpcUaAuthType.Username,
|
|
Username = "op",
|
|
Password = "secret:opcuaclient/pw",
|
|
};
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken));
|
|
ex.Message.ShouldContain("fail-closed");
|
|
}
|
|
|
|
/// <summary>Positive control: a literal password is used as-is (no store needed).</summary>
|
|
[Fact]
|
|
public async Task A_literal_username_password_needs_no_store()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Username, Username = "op", Password = "pw" };
|
|
|
|
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken);
|
|
|
|
identity.TokenType.ShouldBe(UserTokenType.UserName);
|
|
}
|
|
}
|