fccffd0a0f
- 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.
161 lines
6.6 KiB
C#
161 lines
6.6 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="OpcUaClientDriverProbe"/>. Uses an in-process
|
|
/// <see cref="TcpListener"/> on 127.0.0.1 to exercise the accept-then-close negative path
|
|
/// (non-OPC-UA TCP server). The happy path (real OPC UA endpoints) is covered live.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientDriverProbeTests
|
|
{
|
|
private readonly OpcUaClientDriverProbe _probe = new();
|
|
|
|
// ── 1. Invalid JSON ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Invalid JSON returns Ok=false with a message containing "invalid".
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task InvalidJson_returns_false_with_invalid_message()
|
|
{
|
|
var result = await _probe.ProbeAsync(
|
|
"not-json",
|
|
TimeSpan.FromSeconds(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
result.Message!.ShouldContain("invalid", Case.Insensitive);
|
|
}
|
|
|
|
// ── 2. Config with no endpoint URL ───────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Config JSON that resolves to no endpoint URL returns Ok=false with a message
|
|
/// indicating no host/port was found.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NoEndpointUrl_returns_false_with_no_host_port_message()
|
|
{
|
|
// Empty EndpointUrl + empty EndpointUrls → ExtractTarget returns ("", 0, "")
|
|
var result = await _probe.ProbeAsync(
|
|
"""{"EndpointUrl":"","EndpointUrls":[]}""",
|
|
TimeSpan.FromSeconds(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
result.Message!.ShouldContain("no host", Case.Insensitive);
|
|
}
|
|
|
|
// ── 3. Unreachable closed port ────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Pointing at a TCP port that is not open returns Ok=false with a "Connect failed"
|
|
/// message from the socket layer.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ClosedPort_returns_false_with_connect_failed_message()
|
|
{
|
|
// Find a free port by letting the OS assign one, then immediately close the listener
|
|
// so nothing is bound at that port when we probe it.
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
|
|
var configJson = $$"""{"EndpointUrl":"opc.tcp://127.0.0.1:{{port}}"}""";
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var result = await _probe.ProbeAsync(configJson, TimeSpan.FromSeconds(5), cts.Token);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
// SocketException on a closed port emits "Connect failed: ConnectionRefused" (or similar).
|
|
result.Message!.ShouldContain("Connect failed", Case.Insensitive);
|
|
}
|
|
|
|
// ── 4. TCP accept-then-close (non-OPC-UA server) ────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A TCP server that accepts but immediately closes the connection (simulating a
|
|
/// non-OPC-UA process on the target port) causes the GetEndpoints handshake to fail.
|
|
/// The result must be Ok=false and the message must contain "handshake failed".
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NonOpcUaTcpServer_returns_false_with_handshake_failed_message()
|
|
{
|
|
// Start a listener that accepts the connection and immediately closes it, simulating
|
|
// a non-OPC UA TCP service (e.g. an HTTP server or an SSH daemon) on the target port.
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
|
|
var testCt = TestContext.Current.CancellationToken;
|
|
|
|
// Accept in background — immediately close the socket so the OPC UA client gets EOF.
|
|
var acceptTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
using var client = await listener.AcceptTcpClientAsync(testCt);
|
|
// Close immediately — the OPC UA handshake will get an EOF / broken pipe.
|
|
}
|
|
catch
|
|
{
|
|
// Listener may be stopped before a second accept; ignore.
|
|
}
|
|
finally
|
|
{
|
|
listener.Stop();
|
|
}
|
|
}, testCt);
|
|
|
|
try
|
|
{
|
|
var configJson = $$"""{"EndpointUrl":"opc.tcp://127.0.0.1:{{port}}"}""";
|
|
|
|
// Use a generous timeout so the test is not fragile on a slow CI box, but short
|
|
// enough to not block the suite for long if something goes wrong.
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var result = await _probe.ProbeAsync(configJson, TimeSpan.FromSeconds(10), cts.Token);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
result.Message!.ShouldContain("handshake failed", Case.Insensitive);
|
|
}
|
|
finally
|
|
{
|
|
await acceptTask.WaitAsync(TimeSpan.FromSeconds(5), testCt);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #676: a stored config still carrying the deleted <c>reconnectPeriod</c> key must reach the connect phase, not
|
|
/// fail as invalid JSON — the probe parses with the same skip-unknown-members options as the factory.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_config_still_carrying_reconnectPeriod_is_probed_not_rejected()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
|
|
var result = await _probe.ProbeAsync(
|
|
$$"""{"EndpointUrl":"opc.tcp://127.0.0.1:{{port}}","reconnectPeriod":"00:00:05"}""",
|
|
TimeSpan.FromSeconds(5),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
var message = result.Message.ShouldNotBeNull();
|
|
message.ShouldNotContain("invalid", Case.Insensitive);
|
|
message.ShouldContain("Connect failed", Case.Insensitive);
|
|
}
|
|
}
|