Files
Joseph Doherty 9a8336ff6e feat(probe): S7 Test-Connect does a real ISO-on-TCP + S7 setup handshake
Replace bare TCP-connect with a two-phase probe: Phase 1 keeps the
existing SocketException / timeout / generic preflight paths unchanged;
Phase 2 runs Plc.OpenAsync (COTP CR/CC + S7 setup-communication) so a
device that accepts TCP but is not an S7 PLC reads red instead of green.
A linked CTS distinguishes caller cancellation ("timed out") from the
S7netplus internal read-timeout OCE ("handshake failed: timed out").
2026-06-16 06:38:51 -04:00

143 lines
5.8 KiB
C#

using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for <see cref="S7DriverProbe"/>. Uses in-process <see cref="TcpListener"/>
/// instances on 127.0.0.1 to exercise all failure paths without a real PLC.
/// The happy path (real S7 setup-communication exchange) is covered in live integration
/// tests against a python-snap7 simulator.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverProbeTests
{
private static readonly S7DriverProbe Probe = new();
// -------------------------------------------------------------------------
// 1. Invalid JSON
// -------------------------------------------------------------------------
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
[Fact]
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
{
var result = await Probe.ProbeAsync(
"not-valid-json{{{",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ToLowerInvariant().ShouldContain("invalid");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 2. Config with no host
// -------------------------------------------------------------------------
/// <summary>Config that serialises to null host returns Ok=false with a "no host/port" message.</summary>
[Fact]
public async Task NoHost_Returns_OkFalse_WithNoHostPortMessage()
{
// Host defaults to "127.0.0.1" in S7DriverOptions, but an empty string
// is what the spec's "no host" path covers. Provide an empty host explicitly.
var result = await Probe.ProbeAsync(
"{\"host\":\"\",\"port\":102}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("no host/port");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 3. Unreachable / refused port
// -------------------------------------------------------------------------
/// <summary>A closed port returns Ok=false with a "Connect failed" message.</summary>
[Fact]
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
{
// Bind a listener, read the OS-assigned port, then stop it so the port
// is guaranteed closed (no process listening) when the probe runs.
var reserved = new TcpListener(IPAddress.Loopback, 0);
reserved.Start();
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
reserved.Stop();
var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}";
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
// SocketException maps to "Connect failed: <SocketErrorCode>" or similar.
result.Message!.ShouldContain("Connect failed");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 4. TCP accepts then immediately closes (non-S7 server)
// -------------------------------------------------------------------------
/// <summary>
/// A listener that accepts the TCP connection and then immediately closes it without
/// exchanging any bytes simulates a non-S7 server (wrong rack/slot, plain TCP echo,
/// etc.). The probe must return Ok=false with a message containing "handshake failed".
/// A short probe timeout (1 s) ensures the internal S7netplus socket-read timeout
/// fires well within the test wall-clock budget of 10 s.
/// </summary>
[Fact]
public async Task TcpAcceptsThenCloses_Returns_OkFalse_WithHandshakeFailedMessage()
{
// Use a generous outer CTS so the test itself never races with the probe timeout.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
// Accept one connection from the background and immediately close it —
// no S7 bytes exchanged, so OpenAsync must fail.
var serverTask = Task.Run(async () =>
{
try
{
using var client = await listener.AcceptTcpClientAsync(cts.Token);
// Immediately dispose → TCP RST/FIN; the S7 handshake receives nothing.
}
catch (OperationCanceledException) { /* test timed out */ }
}, cts.Token);
try
{
var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}";
// Pass a 1 s probe timeout so S7netplus's internal read-timeout fires quickly,
// and the 10 s outer CTS does NOT cancel first — the OCE will be from the
// internal handshakeCts, routing to the "handshake failed" branch.
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(1),
cts.Token);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("handshake failed");
result.Latency.ShouldBeNull();
}
finally
{
listener.Stop();
try { await serverTask; } catch { /* ignore */ }
}
}
}