Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverProbeTests.cs
T
Joseph Doherty 957a63cfdb feat(probe): OpcUaClient Test-Connect does a GetEndpoints discovery handshake
Replace the bare TCP-connect return in OpcUaClientDriverProbe with a real
OPC UA GetEndpoints discovery handshake (mirroring SelectMatchingEndpointAsync
in the driver). TCP preflight still fast-fails closed ports; the handshake
confirms the remote is actually an OPC UA server, so a live-but-rejecting
non-OPC-UA process now reads RED instead of a false-healthy green.
2026-06-16 06:39:27 -04:00

138 lines
5.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);
}
}
}