fcdc44f9f7
- 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
155 lines
7.7 KiB
C#
155 lines
7.7 KiB
C#
using Moq;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit-only coverage of <see cref="OpcUaClientDriverBrowser"/>'s pre-connect
|
|
/// validation. These tests do not require a live OPC UA endpoint and are safe to
|
|
/// run without the opc-plc Docker fixture.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientDriverBrowserTests
|
|
{
|
|
private readonly OpcUaClientDriverBrowser _sut = new();
|
|
|
|
/// <summary>The DriverType key must match the AdminUI's persisted value.</summary>
|
|
[Fact]
|
|
public void DriverType_is_OpcUaClient() => _sut.DriverType.ShouldBe("OpcUaClient");
|
|
|
|
/// <summary>An empty endpoint must fail fast with a clear EndpointUrl-mentioning message.</summary>
|
|
[Fact]
|
|
public async Task OpenAsync_with_empty_endpoint_throws()
|
|
{
|
|
var json = """{"EndpointUrl":"","EndpointUrls":[]}""";
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
|
|
ex.Message.ShouldContain("EndpointUrl");
|
|
}
|
|
|
|
/// <summary>A JSON literal that deserializes to null must fail fast.</summary>
|
|
[Fact]
|
|
public async Task OpenAsync_with_null_json_throws()
|
|
{
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => _sut.OpenAsync("null", TestContext.Current.CancellationToken));
|
|
ex.Message.ShouldContain("null");
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":2}""";
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
|
|
ex.Message.ShouldContain("Certificate");
|
|
}
|
|
|
|
// ---- Driver.OpcUaClient.Browser-003: JsonStringEnumConverter must be present ----
|
|
|
|
/// <summary>
|
|
/// AdminUI emits enum-valued driver-config fields (SecurityPolicy, SecurityMode,
|
|
/// AuthType) as their <b>string</b> names (e.g.
|
|
/// <c>"SecurityPolicy":"Basic256Sha256"</c>). Without a
|
|
/// <see cref="System.Text.Json.Serialization.JsonStringEnumConverter"/> on the browser's
|
|
/// <c>JsonSerializerOptions</c> the deserialization throws a
|
|
/// <see cref="System.Text.Json.JsonException"/> before the browser's own validation can
|
|
/// run, giving the caller a confusing parse error instead of the expected
|
|
/// domain-specific message.
|
|
///
|
|
/// This test passes a JSON blob with <c>"AuthType":"Certificate"</c> (string form) and
|
|
/// asserts that the browser's validation fires — i.e. deserialization succeeds and the
|
|
/// resulting <see cref="InvalidOperationException"/> mentions "Certificate". Before the
|
|
/// fix, a raw <see cref="System.Text.Json.JsonException"/> surfaces instead.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OpenAsync_with_string_enum_AuthType_deserializes_correctly()
|
|
{
|
|
// "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-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.
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
|
|
// The browser's own guard must fire (not a JsonException from the deserializer).
|
|
ex.Message.ShouldContain("Certificate");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Companion to <see cref="OpenAsync_with_string_enum_AuthType_deserializes_correctly"/>:
|
|
/// confirms that <see cref="System.Text.Json.Serialization.JsonStringEnumConverter"/>
|
|
/// still accepts the integer/numeric ordinal form that the live-fixture tests currently
|
|
/// rely on, so no regression is introduced for existing numeric-authored configs.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OpenAsync_with_numeric_enum_AuthType_still_works()
|
|
{
|
|
// "AuthType":2 — numeric ordinal form (OpcUaAuthType.Certificate == 2).
|
|
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":2}""";
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
|
|
// JsonStringEnumConverter accepts both string and numeric ordinal forms.
|
|
ex.Message.ShouldContain("Certificate");
|
|
}
|
|
|
|
// ---- Driver.OpcUaClient.Browser-001: AttributesAsync must refresh LastUsedUtc ----
|
|
|
|
/// <summary>
|
|
/// <see cref="IBrowseSession.LastUsedUtc"/> must be updated on every call
|
|
/// including <see cref="OpcUaClientBrowseSession.AttributesAsync"/>, which the
|
|
/// <see cref="BrowseSessionReaper"/> uses for idle eviction. Before the fix
|
|
/// the method returned immediately without touching the property, causing a
|
|
/// session that only received <c>AttributesAsync</c> calls to be evicted early.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AttributesAsync_updates_LastUsedUtc()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
|
|
// Arrange: build a minimal OpcUaClientBrowseSession without a live server.
|
|
// 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);
|
|
|
|
var before = sut.LastUsedUtc;
|
|
|
|
// Introduce a small delay so clock advances at least one tick.
|
|
await Task.Delay(5, ct);
|
|
|
|
// Act
|
|
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
|
|
// (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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #676: a config still carrying the deleted <c>reconnectPeriod</c> key must deserialise — the browser's own
|
|
/// validation (here: Certificate auth refused) is what fires, not a JSON error.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OpenAsync_with_a_legacy_reconnectPeriod_key_still_parses()
|
|
{
|
|
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","reconnectPeriod":"00:00:05","AuthType":"Certificate"}""";
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
|
|
ex.Message.ShouldContain("Certificate");
|
|
}
|
|
}
|