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
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-5 — the live picker's side-panel <c>AttributesAsync</c> was a stub returning nothing,
|
||||
/// so the operator saw no DataType / array / access information for an upstream variable. It now reads the
|
||||
/// variable's attributes and maps them through the same <see cref="UpstreamAttributeMapping"/> the runtime
|
||||
/// driver's discovery uses.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientBrowseSessionAttributesTests
|
||||
{
|
||||
private const string Speed = "ns=0;s=Speed";
|
||||
|
||||
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
mock.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(n => n.AttributeId switch
|
||||
{
|
||||
Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)),
|
||||
Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
|
||||
Attributes.DataType => new DataValue(new Variant(dataType)),
|
||||
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
|
||||
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
|
||||
_ => new DataValue(StatusCodes.BadAttributeIdInvalid),
|
||||
})],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static OpcUaClientBrowseSession NewSession(Mock<ISession> mock)
|
||||
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
|
||||
|
||||
/// <summary>A writable Double array variable reports Float64 / array / Operate.</summary>
|
||||
[Fact]
|
||||
public async Task A_variable_reports_its_mapped_data_type_array_shape_and_access()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Double, valueRank: 1, userAccess: 0x03);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
var attrs = await session.AttributesAsync(Speed, TestContext.Current.CancellationToken);
|
||||
|
||||
var a = attrs.ShouldHaveSingleItem();
|
||||
a.Name.ShouldBe("Speed");
|
||||
a.DriverDataType.ShouldBe("Float64");
|
||||
a.IsArray.ShouldBeTrue();
|
||||
a.SecurityClass.ShouldBe("Operate");
|
||||
a.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A read-only scalar Int32 variable reports Int32 / scalar / ViewOnly.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_only_scalar_reports_view_only()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
var a = (await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.DriverDataType.ShouldBe("Int32");
|
||||
a.IsArray.ShouldBeFalse();
|
||||
a.SecurityClass.ShouldBe("ViewOnly");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An Object (folder) has no value attributes to show. The positive control is the variable case above,
|
||||
/// through the same mock and read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_object_node_has_no_attributes()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Object, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
(await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An unresolvable node id is refused like <c>ExpandAsync</c> does.</summary>
|
||||
[Fact]
|
||||
public async Task An_unresolvable_node_id_throws()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(() =>
|
||||
session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -41,10 +41,9 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
ex.Message.ShouldContain("null");
|
||||
}
|
||||
|
||||
/// <summary>Certificate auth is not supported by the browser; the failure message
|
||||
/// must say so explicitly rather than surfacing a downstream COM/SDK error.
|
||||
/// <c>OpcUaAuthType.Certificate</c> serializes as the numeric value 2 under the
|
||||
/// browser's default System.Text.Json options (no string-enum converter).</summary>
|
||||
/// <summary>Certificate auth with no certificate configured fails before any network I/O with a message
|
||||
/// naming the certificate (review 2026-10-21 O-5: Certificate auth is browsable now; it used to be refused),
|
||||
/// rather than surfacing a downstream SDK error. Numeric enum form (<c>OpcUaAuthType.Certificate</c> == 2).</summary>
|
||||
[Fact]
|
||||
public async Task OpenAsync_with_certificate_auth_throws_clear_message()
|
||||
{
|
||||
@@ -76,7 +75,7 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
{
|
||||
// "AuthType":"Certificate" — STRING form, as AdminUI emits.
|
||||
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
||||
// The browser should reach its own Certificate-auth guard and throw
|
||||
// The browser should reach its own Certificate-identity check (no UserCertificatePath) and throw
|
||||
// InvalidOperationException with a message that mentions "Certificate".
|
||||
// Without JsonStringEnumConverter the deserialization itself throws JsonException,
|
||||
// which means this Should.ThrowAsync<InvalidOperationException> assertion fails.
|
||||
@@ -118,8 +117,10 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange: build a minimal OpcUaClientBrowseSession without a live server.
|
||||
// AttributesAsync does not call ISession — MockBehavior.Loose is safe.
|
||||
// A loose mock answers the attribute read with no response, so nothing is known about the node.
|
||||
var mockSession = new Mock<ISession>(MockBehavior.Loose);
|
||||
mockSession.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
mockSession.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
||||
var nsMap = NamespaceMap.FromTable(new NamespaceTable());
|
||||
var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
|
||||
|
||||
@@ -132,7 +133,7 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
var attrs = await sut.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
|
||||
|
||||
// Assert: LastUsedUtc must have been refreshed, and the result must be empty
|
||||
// (OPC UA picker treats variables as leaves, no attribute side-panel).
|
||||
// (a node not known to be a Variable has no value attributes to show).
|
||||
attrs.ShouldBeEmpty();
|
||||
sut.LastUsedUtc.ShouldBeGreaterThan(before,
|
||||
"AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract");
|
||||
|
||||
Reference in New Issue
Block a user