Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs
T
Joseph Doherty 0c08b152c2 feat(probe): AbCip Test-Connect opens a real CIP session (libplctag init)
Replace the bare-TCP-only AbCipDriverProbe with a two-phase check:
Phase 1 keeps the existing TCP preflight; Phase 2 initialises a
LibplctagTagRuntime against the first device to open a real EIP session
and CIP Forward Open, so a live-but-rejecting CIP endpoint reads red
instead of a false-positive green.

Status mapping: ErrorNotFound / ErrorNoMatch / ErrorBadDevice → reachable
(controller answered CIP, probe tag absent); ErrorTimeout / ErrorBadConnection
/ ErrorBadGateway / ErrorWinsock / ErrorOpen / ErrorClose / ErrorRead /
ErrorWrite / ErrorBadReply / ErrorRemoteErr / ErrorPartial / ErrorAbort →
handshake failed. LibPlcTagException message text is used as a secondary
signal for the reachable-exception path. All other statuses default to
handshake-failed (conservative).

Add AbCipDriverProbeTests: invalid JSON, no devices, malformed host address,
closed-port TCP rejection, and black-hole timeout — all offline-determinable.
Happy path + CIP-error path covered live against the CIP sim.
2026-06-16 06:39:46 -04:00

148 lines
6.1 KiB
C#

using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Unit tests for <see cref="AbCipDriverProbe"/>. Covers the three offline-determinable
/// failure paths: invalid JSON, missing host/port, and an unreachable (TCP-level rejected)
/// target. The happy path (real CIP EIP session + <c>Ok=true</c>) and the
/// "controller reachable but probe tag not found" path require a live CIP controller or
/// simulator and are covered in integration tests against the AB-CIP Docker fixture
/// (<c>10.100.0.35:44818</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDriverProbeTests
{
private static readonly AbCipDriverProbe 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 device / no host
// -------------------------------------------------------------------------
/// <summary>
/// A config with an empty Devices list returns Ok=false with a "no host/port" message.
/// </summary>
[Fact]
public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
{
var result = await Probe.ProbeAsync(
"{\"devices\":[]}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("no host/port");
result.Latency.ShouldBeNull();
}
/// <summary>
/// A config whose first device carries a malformed host address (not ab://) returns
/// Ok=false with a "no host/port" message because <see cref="AbCipHostAddress.TryParse"/>
/// returns null for the address.
/// </summary>
[Fact]
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
{
// "not-an-ab-url" is not an ab:// URL — TryParse returns null.
var result = await Probe.ProbeAsync(
"{\"devices\":[{\"hostAddress\":\"not-an-ab-url\"}]}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("no host/port");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 3. Unreachable target — TCP connect fails at Phase 1
// -------------------------------------------------------------------------
/// <summary>
/// A closed port causes <c>ConnectAsync</c> to throw <see cref="SocketException"/>;
/// the probe must return Ok=false with a "Connect failed" message.
/// The port is reserved then released so the OS refuses the connection immediately
/// (no black-hole delay needed).
/// </summary>
[Fact]
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
{
// Reserve an ephemeral port then release it so the port is definitely closed.
var reserved = new TcpListener(IPAddress.Loopback, 0);
reserved.Start();
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
reserved.Stop();
// Use a short timeout — we only need Phase 1 to fail fast.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}";
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(3),
cts.Token);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
// Phase 1 SocketException → "Connect failed: <SocketErrorCode>"
result.Message!.ShouldContain("Connect failed");
result.Latency.ShouldBeNull();
}
/// <summary>
/// A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) causes the
/// probe to time out at Phase 1 when given a very short cancellation window.
/// The probe must return Ok=false with a "timed out" message.
/// </summary>
[Fact]
public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutMessage()
{
// 192.0.2.1 is TEST-NET-1 (RFC 5737) — reserved, non-routable, connection black-holes.
// Use a 1-second cancellation so the test stays fast on any network.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(1));
var configJson = "{\"devices\":[{\"hostAddress\":\"ab://192.0.2.1/1,0\"}]}";
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(1),
cts.Token);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
// Either "timed out" (OperationCanceledException) or "Connect failed" (EHOSTUNREACH on
// some network stacks) — both are acceptable "not reachable" outcomes.
var lower = result.Message!.ToLowerInvariant();
(lower.Contains("timed out") || lower.Contains("connect failed")).ShouldBeTrue(
$"Expected 'timed out' or 'Connect failed' but got: {result.Message}");
result.Latency.ShouldBeNull();
}
}