228 lines
9.8 KiB
C#
228 lines
9.8 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
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();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 5. Reachable-vs-failed classification (offline — no controller needed)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The exception path classifies by the SAME rule as the status path: a controller that answered
|
|
/// "tag does not exist" is reachable (the answer every Test Connect gets on a device with no
|
|
/// authored tags); a session or transport failure is not.
|
|
/// </summary>
|
|
/// <param name="status">The status the transport threw on.</param>
|
|
/// <param name="expectedReachable">Whether the controller should be reported reachable.</param>
|
|
[Theory]
|
|
[InlineData(AbStatus.NotFound, true)]
|
|
[InlineData(AbStatus.Timeout, false)]
|
|
[InlineData(AbStatus.BadConnection, false)]
|
|
[InlineData(AbStatus.BadReply, false)]
|
|
[InlineData(AbStatus.Partial, false)]
|
|
[InlineData(AbStatus.Abort, false)]
|
|
public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(AbStatus status, bool expectedReachable)
|
|
{
|
|
var ex = new AbTransportException(status, status.ToString());
|
|
|
|
AbCipDriverProbe.IsReachableException(ex).ShouldBe(expectedReachable);
|
|
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(expectedReachable);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A message that names no status at all stays UNreachable rather than defaulting green — the
|
|
/// conservative half, and the one an over-eager "treat anything we cannot parse as reachable"
|
|
/// fix would break.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable()
|
|
=> AbCipDriverProbe.IsReachableException(
|
|
new AbTransportException(AbStatus.BadConnection, "something the wrapper has never said")).ShouldBeFalse();
|
|
|
|
/// <summary>A fake runtime reporting NotFound is reachable ("probe tag not found").</summary>
|
|
[Fact]
|
|
public async Task Injected_factory_NotFound_status_is_reported_reachable()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
var factory = new FakeAbCipTagFactory
|
|
{
|
|
Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound },
|
|
};
|
|
|
|
var result = await new AbCipDriverProbe(factory).ProbeAsync(
|
|
$"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}",
|
|
TimeSpan.FromSeconds(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Message!.ShouldContain("probe tag not found");
|
|
}
|
|
|
|
/// <summary>
|
|
/// T21: the probe builds its handle from the family profile, so a CompactLogix device with no
|
|
/// ConnectionSize opens Large (4002) like libplctag; an explicit device ConnectionSize still wins.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("", 4002)]
|
|
[InlineData(",\"connectionSize\":504", 504)]
|
|
public async Task CompactLogix_probe_uses_the_profile_default_unless_the_device_overrides_it(string extra, int expected)
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
var factory = new FakeAbCipTagFactory();
|
|
|
|
await new AbCipDriverProbe(factory).ProbeAsync(
|
|
$"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\",\"plcFamily\":\"CompactLogix\"{extra}}}]}}",
|
|
TimeSpan.FromSeconds(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
factory.Tags.Values.ShouldHaveSingleItem().CreationParams.ConnectionSize.ShouldBe(expected);
|
|
}
|
|
}
|