using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Unit tests for . Uses an in-process /// 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. /// [Trait("Category", "Unit")] public sealed class OpcUaClientDriverProbeTests { private readonly OpcUaClientDriverProbe _probe = new(); // ── 1. Invalid JSON ────────────────────────────────────────────────────────── /// /// Invalid JSON returns Ok=false with a message containing "invalid". /// [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 ─────────────────────────────────────────── /// /// Config JSON that resolves to no endpoint URL returns Ok=false with a message /// indicating no host/port was found. /// [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 ──────────────────────────────────────────────── /// /// Pointing at a TCP port that is not open returns Ok=false with a "Connect failed" /// message from the socket layer. /// [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) ──────────────────────────── /// /// 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". /// [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); } } }