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:
@@ -25,7 +25,8 @@ out-of-process isolation.
|
|||||||
| Project | Target | Role |
|
| Project | Target | Role |
|
||||||
|---------|--------|------|
|
|---------|--------|------|
|
||||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/` | net10.0 | In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough |
|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/` | net10.0 | In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough |
|
||||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI |
|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI. Its side-panel `AttributesAsync` reads a variable's DataType / ValueRank / UserAccessLevel and maps them through the same `UpstreamAttributeMapping` discovery uses; it authenticates Anonymous, Username **or Certificate** through the driver's own `OpcUaUserIdentityFactory`, resolving `secret:` credentials through the host's secret store (fail-closed) — review 2026-10-21 O-5 (it was a stub, and Certificate was refused) |
|
||||||
|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/` | net10.0 | The SDK-typed layer the driver AND the browser share: `NamespaceMap`, `UpstreamAttributeMapping`, `OpcUaUserIdentityFactory` |
|
||||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/` | net10.0 | Config records + enums bound from `DriverConfig` JSON |
|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/` | net10.0 | Config records + enums bound from `DriverConfig` JSON |
|
||||||
|
|
||||||
## Minimum deployment
|
## Minimum deployment
|
||||||
|
|||||||
+56
-2
@@ -54,10 +54,64 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
// R06:O-5 — reads the node's NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and
|
||||||
|
// maps them through the same UpstreamAttributeMapping the runtime driver's discovery registers with, so the
|
||||||
|
// side-panel shows what a browse-commit would author. An Object (folder) has no value attributes → empty.
|
||||||
|
// Throws ArgumentException when nodeId cannot be resolved against the live session's namespace table.
|
||||||
|
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
|
||||||
|
|
||||||
|
uint[] attributeIds =
|
||||||
|
[
|
||||||
|
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
|
||||||
|
Attributes.UserAccessLevel,
|
||||||
|
];
|
||||||
|
var toRead = new ReadValueIdCollection(attributeIds.Length);
|
||||||
|
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
|
||||||
|
|
||||||
|
DataValueCollection values;
|
||||||
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await _session.ReadAsync(
|
||||||
|
requestHeader: null,
|
||||||
|
maxAge: 0,
|
||||||
|
timestampsToReturn: TimestampsToReturn.Neither,
|
||||||
|
nodesToRead: toRead,
|
||||||
|
ct: cancellationToken).ConfigureAwait(false);
|
||||||
|
values = resp?.Results ?? [];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
LastUsedUtc = DateTime.UtcNow;
|
LastUsedUtc = DateTime.UtcNow;
|
||||||
return Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
|
|
||||||
|
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
|
||||||
|
? values[i] : null;
|
||||||
|
|
||||||
|
var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
|
||||||
|
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
|
||||||
|
|
||||||
|
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
|
||||||
|
var dataType = At(2)?.Value is NodeId dt
|
||||||
|
? UpstreamAttributeMapping.MapDataType(dt)
|
||||||
|
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
|
||||||
|
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
|
||||||
|
var access = At(4)?.Value is byte ab ? ab : (byte)0;
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new AttributeInfo(
|
||||||
|
Name: name,
|
||||||
|
DriverDataType: dataType.ToString(),
|
||||||
|
IsArray: UpstreamAttributeMapping.IsArray(valueRank),
|
||||||
|
SecurityClass: UpstreamAttributeMapping.MapAccessLevel(access).ToString()),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
|
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
|
||||||
|
|||||||
+26
-14
@@ -7,7 +7,8 @@ using Opc.Ua.Client;
|
|||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + OpcUaUserIdentityFactory
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||||
|
|
||||||
@@ -33,12 +34,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
||||||
|
private readonly ISecretResolver? _secretResolver;
|
||||||
|
|
||||||
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
||||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
||||||
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
|
/// <param name="secretResolver">
|
||||||
|
/// The host's shared secret resolver (DI-injected where one is registered), used to resolve
|
||||||
|
/// <c>secret:</c>-prefixed <c>Password</c> / <c>UserCertificatePassword</c> exactly as the runtime driver does.
|
||||||
|
/// Null ⇒ a <c>secret:</c> ref fails closed (it is never sent as the literal).
|
||||||
|
/// </param>
|
||||||
|
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null, ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
||||||
|
_secretResolver = secretResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -58,9 +66,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
if (string.IsNullOrWhiteSpace(endpoint))
|
if (string.IsNullOrWhiteSpace(endpoint))
|
||||||
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
|
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
|
||||||
|
|
||||||
if (opts.AuthType == OpcUaAuthType.Certificate)
|
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
|
||||||
throw new InvalidOperationException(
|
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
|
||||||
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
|
// with its own message.
|
||||||
|
var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (opts.AutoAcceptCertificates)
|
if (opts.AutoAcceptCertificates)
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
@@ -70,7 +79,6 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
endpoint);
|
endpoint);
|
||||||
|
|
||||||
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var identity = BuildBrowseUserIdentity(opts);
|
|
||||||
|
|
||||||
var perEndpointBudget = TimeSpan.FromSeconds(
|
var perEndpointBudget = TimeSpan.FromSeconds(
|
||||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||||
@@ -175,16 +183,20 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
|
/// <summary>
|
||||||
|
/// Build the OPC UA user identity from the form's auth fields — Anonymous, Username or Certificate — through
|
||||||
|
/// the factory the runtime driver uses, after resolving any <c>secret:</c>-prefixed credential (fail-closed).
|
||||||
|
/// </summary>
|
||||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||||
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
|
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||||
|
/// <returns>The identity the browse session activates with.</returns>
|
||||||
|
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
||||||
|
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
||||||
{
|
{
|
||||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
||||||
OpcUaAuthType.Username => new UserIdentity(
|
return OpcUaUserIdentityFactory.Build(resolved);
|
||||||
opts.Username ?? string.Empty,
|
}
|
||||||
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
|
|
||||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
||||||
/// <param name="appConfig">Application configuration used by the discovery client.</param>
|
/// <param name="appConfig">Application configuration used by the discovery client.</param>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ONE place the OpcUaClient user identity is built from <see cref="OpcUaClientDriverOptions"/> — shared by
|
||||||
|
/// the runtime driver's session open and the AdminUI live browse, so a certificate-authenticated upstream is
|
||||||
|
/// browsable with exactly the credentials the deployed driver will present. Includes the <c>secret:</c>
|
||||||
|
/// resolution of the two credential fields (fail-closed).
|
||||||
|
/// </summary>
|
||||||
|
public static class OpcUaUserIdentityFactory
|
||||||
|
{
|
||||||
|
private const string SecretPrefix = "secret:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the user-identity token from options whose credential fields are already resolved plaintext
|
||||||
|
/// (see <see cref="ResolveSecretRefsAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Driver options containing authentication configuration.</param>
|
||||||
|
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
|
||||||
|
public static UserIdentity Build(OpcUaClientDriverOptions options) =>
|
||||||
|
options.AuthType switch
|
||||||
|
{
|
||||||
|
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||||
|
OpcUaAuthType.Username => new UserIdentity(
|
||||||
|
options.Username ?? string.Empty,
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
|
||||||
|
OpcUaAuthType.Certificate => FromCertificate(options),
|
||||||
|
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication certificate loaded from
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>. Fails fast if the path is missing, the file
|
||||||
|
/// doesn't exist, or the certificate lacks a private key (required to sign the user-token challenge).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||||
|
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
||||||
|
public static UserIdentity FromCertificate(OpcUaClientDriverOptions options)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||||
|
if (!File.Exists(options.UserCertificatePath))
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"User certificate not found at '{options.UserCertificatePath}'.",
|
||||||
|
options.UserCertificatePath);
|
||||||
|
|
||||||
|
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
|
||||||
|
// since the legacy X509Certificate2 ctors are marked obsolete on net10.
|
||||||
|
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||||
|
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
|
||||||
|
|
||||||
|
if (!cert.HasPrivateKey)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
|
||||||
|
"the private key is required to sign the OPC UA user-token challenge at session activation.");
|
||||||
|
|
||||||
|
return new UserIdentity(cert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
|
||||||
|
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
|
||||||
|
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
|
||||||
|
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||||
|
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||||
|
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var certPassword = await ResolveFieldAsync(
|
||||||
|
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (ReferenceEquals(password, options.Password)
|
||||||
|
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||||
|
{
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ResolveFieldAsync(
|
||||||
|
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)
|
||||||
|
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = value[SecretPrefix.Length..];
|
||||||
|
if (resolver is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
|
||||||
|
"available here (fail-closed).");
|
||||||
|
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||||
|
return !string.IsNullOrEmpty(resolved)
|
||||||
|
? resolved
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||||
|
"absent from the store (fail-closed).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ONE mapping from an upstream variable's OPC UA attributes (DataType, ValueRank, AccessLevel) to the
|
||||||
|
/// driver-agnostic shape — shared by the runtime driver's discovery enrichment and the AdminUI live browse
|
||||||
|
/// side-panel, so the picker shows exactly what a browse-commit / discovery would register.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpstreamAttributeMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Map an upstream OPC UA built-in DataType NodeId (via <c>DataTypeIds.*</c>) to a
|
||||||
|
/// <see cref="DriverDataType"/>. Unknown / custom types fall through to
|
||||||
|
/// <see cref="DriverDataType.String"/> which is the safest passthrough for
|
||||||
|
/// Variant-wrapped structs + enums + extension objects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
|
||||||
|
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
|
||||||
|
public static DriverDataType MapDataType(NodeId dataType)
|
||||||
|
{
|
||||||
|
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
|
||||||
|
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
|
||||||
|
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
|
||||||
|
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
|
||||||
|
// confuses range/validation logic keyed off DriverDataType).
|
||||||
|
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
|
||||||
|
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
|
||||||
|
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
|
||||||
|
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
|
||||||
|
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
|
||||||
|
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
|
||||||
|
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
|
||||||
|
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
|
||||||
|
if (dataType == DataTypeIds.String) return DriverDataType.String;
|
||||||
|
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
|
||||||
|
return DriverDataType.DateTime;
|
||||||
|
return DriverDataType.String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c> bitmask) to a
|
||||||
|
/// <see cref="SecurityClassification"/>: CurrentWrite-capable variables surface as
|
||||||
|
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
|
||||||
|
/// <returns>The mapped security classification.</returns>
|
||||||
|
public static SecurityClassification MapAccessLevel(byte accessLevel)
|
||||||
|
{
|
||||||
|
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
|
||||||
|
return (accessLevel & CurrentWrite) != 0
|
||||||
|
? SecurityClassification.Operate
|
||||||
|
: SecurityClassification.ViewOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA Part 3 ValueRank: -3 ScalarOrOneDimension, -2 Any, -1 Scalar, 0 OneOrMoreDimensions, 1 OneDimension,
|
||||||
|
/// >1 N dimensions. Anything >= 0 is an array; -3 / -2 are conservatively scalar until the upstream variable
|
||||||
|
/// carries a concrete dimensioned rank.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="valueRank">The ValueRank attribute.</param>
|
||||||
|
/// <returns>Whether the variable is exposed as an array.</returns>
|
||||||
|
public static bool IsArray(int valueRank) => valueRank >= 0;
|
||||||
|
}
|
||||||
+7
-2
@@ -11,8 +11,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<!--
|
<!--
|
||||||
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
|
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
|
||||||
browser both need that is typed in ISession / NodeId / NamespaceTable. Today that is exactly
|
browser both need that is typed in ISession / NodeId / NamespaceTable / UserIdentity: NamespaceMap,
|
||||||
NamespaceMap.
|
UpstreamAttributeMapping (DataType/AccessLevel/ValueRank -> driver shape) and OpcUaUserIdentityFactory
|
||||||
|
(credential secret: resolution + the SDK UserIdentity, incl. certificate auth).
|
||||||
|
|
||||||
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
|
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
|
||||||
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
|
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
|
||||||
@@ -35,6 +36,10 @@
|
|||||||
-->
|
-->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||||
|
<!-- OpcUaUserIdentityFactory: the secret: resolution of the credential fields feeding the SDK UserIdentity is
|
||||||
|
shared by the driver and the AdminUI browse (review 2026-10-21 O-5), so the browse presents exactly the
|
||||||
|
credentials the deployed driver will. Abstractions only (one interface + a name type). -->
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
|
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
|
||||||
|
|||||||
@@ -967,15 +967,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <param name="options">Driver options containing authentication configuration.</param>
|
/// <param name="options">Driver options containing authentication configuration.</param>
|
||||||
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
|
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
|
||||||
internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) =>
|
internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) =>
|
||||||
options.AuthType switch
|
OpcUaUserIdentityFactory.Build(options);
|
||||||
{
|
|
||||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
|
||||||
OpcUaAuthType.Username => new UserIdentity(
|
|
||||||
options.Username ?? string.Empty,
|
|
||||||
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
|
|
||||||
OpcUaAuthType.Certificate => BuildCertificateIdentity(options),
|
|
||||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Open a session against a single endpoint URL. Bounded by
|
/// Open a session against a single endpoint URL. Bounded by
|
||||||
@@ -1095,30 +1087,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">Driver options containing certificate configuration.</param>
|
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||||
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
||||||
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options)
|
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options) =>
|
||||||
{
|
OpcUaUserIdentityFactory.FromCertificate(options);
|
||||||
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
|
||||||
if (!System.IO.File.Exists(options.UserCertificatePath))
|
|
||||||
throw new System.IO.FileNotFoundException(
|
|
||||||
$"User certificate not found at '{options.UserCertificatePath}'.",
|
|
||||||
options.UserCertificatePath);
|
|
||||||
|
|
||||||
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
|
|
||||||
// since the legacy X509Certificate2 ctors are marked obsolete on net10. Passes the
|
|
||||||
// password through verbatim; PEM files with external keys fall back to
|
|
||||||
// LoadCertificateFromFile which picks up the adjacent .key if present.
|
|
||||||
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
|
||||||
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
|
|
||||||
|
|
||||||
if (!cert.HasPrivateKey)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
|
|
||||||
"the private key is required to sign the OPC UA user-token challenge at session activation.");
|
|
||||||
|
|
||||||
return new UserIdentity(cert);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||||
/// <param name="policy">The driver security policy to map.</param>
|
/// <param name="policy">The driver security policy to map.</param>
|
||||||
@@ -1804,7 +1774,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// array dimension). -3 ScalarOrOneDimension and -2 Any are conservatively treated
|
// array dimension). -3 ScalarOrOneDimension and -2 Any are conservatively treated
|
||||||
// as scalar — array-of-one is exposed as scalar to the local address space until
|
// as scalar — array-of-one is exposed as scalar to the local address space until
|
||||||
// the upstream variable carries a concrete dimensioned rank.
|
// the upstream variable carries a concrete dimensioned rank.
|
||||||
var isArray = valueRank >= 0;
|
var isArray = UpstreamAttributeMapping.IsArray(valueRank);
|
||||||
var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0;
|
var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0;
|
||||||
var securityClass = MapAccessLevelToSecurityClass(access);
|
var securityClass = MapAccessLevelToSecurityClass(access);
|
||||||
var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b;
|
var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b;
|
||||||
@@ -1842,26 +1812,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
|
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
|
||||||
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
|
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
|
||||||
internal static DriverDataType MapUpstreamDataType(NodeId dataType)
|
internal static DriverDataType MapUpstreamDataType(NodeId dataType) =>
|
||||||
{
|
UpstreamAttributeMapping.MapDataType(dataType);
|
||||||
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
|
|
||||||
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
|
|
||||||
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
|
|
||||||
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
|
|
||||||
// confuses range/validation logic keyed off DriverDataType).
|
|
||||||
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
|
|
||||||
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
|
|
||||||
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
|
|
||||||
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
|
|
||||||
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
|
|
||||||
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
|
|
||||||
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
|
|
||||||
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
|
|
||||||
if (dataType == DataTypeIds.String) return DriverDataType.String;
|
|
||||||
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
|
|
||||||
return DriverDataType.DateTime;
|
|
||||||
return DriverDataType.String;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c>
|
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c>
|
||||||
@@ -1871,13 +1823,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
|
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
|
||||||
/// <returns>The mapped security classification.</returns>
|
/// <returns>The mapped security classification.</returns>
|
||||||
internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel)
|
internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel) =>
|
||||||
{
|
UpstreamAttributeMapping.MapAccessLevel(accessLevel);
|
||||||
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
|
|
||||||
return (accessLevel & CurrentWrite) != 0
|
|
||||||
? SecurityClassification.Operate
|
|
||||||
: SecurityClassification.ViewOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- ISubscribable ----
|
// ---- ISubscribable ----
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class OpcUaClientSecretResolution
|
internal static class OpcUaClientSecretResolution
|
||||||
{
|
{
|
||||||
private const string SecretPrefix = "secret:";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||||
@@ -37,54 +35,12 @@ internal static class OpcUaClientSecretResolution
|
|||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||||
/// </exception>
|
/// </exception>
|
||||||
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentNullException.ThrowIfNull(resolver);
|
ArgumentNullException.ThrowIfNull(resolver);
|
||||||
|
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
|
||||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
|
||||||
.ConfigureAwait(false);
|
|
||||||
var certPassword = await ResolveFieldAsync(
|
|
||||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Only re-materialize when something actually changed — a `with` on the reference-equal
|
|
||||||
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
|
|
||||||
if (ReferenceEquals(password, options.Password)
|
|
||||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
|
||||||
{
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
|
||||||
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
|
||||||
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
|
||||||
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
|
||||||
/// <param name="resolver">The shared secret resolver.</param>
|
|
||||||
/// <param name="ct">Cancellation token for the async resolution.</param>
|
|
||||||
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
|
||||||
private static async Task<string?> ResolveFieldAsync(
|
|
||||||
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)
|
|
||||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = value[SecretPrefix.Length..];
|
|
||||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
|
||||||
return !string.IsNullOrEmpty(resolved)
|
|
||||||
? resolved
|
|
||||||
: throw new InvalidOperationException(
|
|
||||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
|
||||||
"absent from the store (fail-closed).");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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");
|
ex.Message.ShouldContain("null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Certificate auth is not supported by the browser; the failure message
|
/// <summary>Certificate auth with no certificate configured fails before any network I/O with a message
|
||||||
/// must say so explicitly rather than surfacing a downstream COM/SDK error.
|
/// naming the certificate (review 2026-10-21 O-5: Certificate auth is browsable now; it used to be refused),
|
||||||
/// <c>OpcUaAuthType.Certificate</c> serializes as the numeric value 2 under the
|
/// rather than surfacing a downstream SDK error. Numeric enum form (<c>OpcUaAuthType.Certificate</c> == 2).</summary>
|
||||||
/// browser's default System.Text.Json options (no string-enum converter).</summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenAsync_with_certificate_auth_throws_clear_message()
|
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.
|
// "AuthType":"Certificate" — STRING form, as AdminUI emits.
|
||||||
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
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".
|
// InvalidOperationException with a message that mentions "Certificate".
|
||||||
// Without JsonStringEnumConverter the deserialization itself throws JsonException,
|
// Without JsonStringEnumConverter the deserialization itself throws JsonException,
|
||||||
// which means this Should.ThrowAsync<InvalidOperationException> assertion fails.
|
// which means this Should.ThrowAsync<InvalidOperationException> assertion fails.
|
||||||
@@ -118,8 +117,10 @@ public sealed class OpcUaClientDriverBrowserTests
|
|||||||
var ct = TestContext.Current.CancellationToken;
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
// Arrange: build a minimal OpcUaClientBrowseSession without a live server.
|
// 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);
|
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 nsMap = NamespaceMap.FromTable(new NamespaceTable());
|
||||||
var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
|
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);
|
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
|
// 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();
|
attrs.ShouldBeEmpty();
|
||||||
sut.LastUsedUtc.ShouldBeGreaterThan(before,
|
sut.LastUsedUtc.ShouldBeGreaterThan(before,
|
||||||
"AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract");
|
"AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract");
|
||||||
|
|||||||
Reference in New Issue
Block a user