Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs
T
Joseph Doherty fccffd0a0f feat(opcuaclient): #676 follow-ups — endpoint rewrite, stall wire test, fast definitive-loss recovery
- Endpoint rewrite: the driver now connects to the configured host AND port (scheme
  match, advertised path kept) instead of the server-advertised URL — fixes NAT/port-map
  upstreams and lets an in-test proxy carry the session (SDK SelectEndpoint rule).
- TcpStallProxy + OpcUaClientDriverStallWireTests: the in-place blip (#670) is now
  wire-tested on loopback — one Bad, frozen read stamp, same session, identical value
  republished Good, a post-recovery change arrives, no source-time regression after
  the SDK's late backlog flush.
- Defect found by that test, fixed: a change published right after a stall rode a
  Publish response the SDK timed out and never republished, leaving the node Good on
  the pre-change value indefinitely. New trailing reconcile read (Timeout +
  KeepAliveInterval after recovery) publishes any Good value newer than the last
  accepted one, through the replay guard's mark.
- IDriverConnectionLossSource (Core.Abstractions): opt-in definitive-loss signal.
  DriverInstanceActor attaches in PreStart / detaches in PostStop and turns a raise
  into a disconnect only while Connected. OpcUaClient raises once per session on
  keep-alive BadSessionIdInvalid/BadSessionClosed/BadSessionNotActivated/
  BadServerHalted (measured: the SDK never re-opens a halted channel) or a dropped
  subscription; ambiguous codes stay with the #527 watchdog.
- Replay guard: one Information summary per recovery when the window closes
  ("dropped N late notifications across M refs"), with a timer so quiet
  subscriptions log it too.
- ReconnectPeriod deleted (options, AdminUI form, docs, tests); stored configs that
  carry reconnectPeriod still parse in the factory, probe and browser.
- Docs: OpcUaClient.md, DriverLifecycle.md, driver-specs.md, CLAUDE.md.
2026-10-11 11:35:52 -04:00

154 lines
7.5 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 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>
[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-auth guard 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.
// AttributesAsync does not call ISession — MockBehavior.Loose is safe.
var mockSession = new Mock<ISession>(MockBehavior.Loose);
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
// (OPC UA picker treats variables as leaves, no attribute side-panel).
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");
}
}