Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowserCertificateAuthTests.cs
T
Joseph Doherty fcdc44f9f7 feat(opcuaclient-browser): real AttributesAsync + certificate-auth browse (R06:O-5)
- AttributesAsync was a stub returning nothing; it now reads NodeClass /
  DisplayName / DataType / ValueRank / UserAccessLevel in one request and maps them
  through UpstreamAttributeMapping, the same mapping discovery registers with (moved
  into the shared .Session leaf; the driver forwards to it). Objects have no value
  attributes.
- Certificate auth was refused "in v1". The browse now builds its identity through
  OpcUaUserIdentityFactory (shared .Session leaf, the driver forwards to it),
  including the secret: resolution of Password / UserCertificatePassword through the
  host's ISecretResolver (optional ctor param, DI-injected). Fail-closed: a secret:
  ref with no store throws instead of being sent as the literal (the browse used to
  send a secret: Password verbatim). The identity is built before any network I/O.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:33:08 -04:00

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:opcua/browse-cert",
};
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
opts, new OneSecret("opcua/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:opcua/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);
}
}