66927a3faa
Dead config field (user decision: wire them all): the AdminUI form authors
probeTimeoutSeconds ("Admin UI probe timeout", default 5) but AbLegacyDriverProbe
never read it, so Test Connect always ran on the caller's budget. It now
tightens that budget (never raises it), the same rule as the Galaxy probe; a
non-positive value keeps the caller's budget.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
238 lines
10 KiB
C#
238 lines
10 KiB
C#
// Happy-path PCCC handshake is live-verify DEFERRED — no PLC5/SLC sim on the dev rig.
|
|
// The libplctag code path (LibplctagLegacyTagRuntime.InitializeAsync + Status mapping) is
|
|
// verified-by-proxy via the AbCip probe, which exercises the same libplctag native layer.
|
|
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Shouldly;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="AbLegacyDriverProbe"/>. Covers the offline-determinable
|
|
/// failure paths: invalid JSON, missing host/port, malformed host address, an unreachable
|
|
/// (TCP-level rejected) target, and a black-hole target with a short timeout.
|
|
/// The happy path (real PCCC session + <c>Ok=true</c>) requires a live PLC5/SLC
|
|
/// simulator and is deferred — no such sim exists on the dev rig.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbLegacyDriverProbeTests
|
|
{
|
|
private static readonly AbLegacyDriverProbe 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="AbLegacyHostAddress.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" or "Connect failed" message.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutOrConnectFailedMessage()
|
|
{
|
|
// 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();
|
|
}
|
|
|
|
// ---- #660: Test Connect normalises the probe address exactly like the driver's probe loop ----
|
|
|
|
private static async Task<(DriverProbeResult Result, string? SeenName)> ProbeWithFakeAsync(string family, string? probeAddress)
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
try
|
|
{
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
var probe = new AbLegacyDriverProbe(factory);
|
|
var addr = probeAddress is null ? "" : $",\"probe\":{{\"probeAddress\":\"{probeAddress}\"}}";
|
|
var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\",\"plcFamily\":\"{family}\"}}]{addr}}}";
|
|
var result = await probe.ProbeAsync(json, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
return (result, factory.Tags.Keys.SingleOrDefault());
|
|
}
|
|
finally { listener.Stop(); }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Plc5", "I:017", "I:15")]
|
|
[InlineData("Plc5", "O:010/07", "O:8/7")]
|
|
[InlineData("LogixPccc", "I:017", "I:17")]
|
|
[InlineData("Slc500", "I:017", "I:17")]
|
|
[InlineData("MicroLogix", "I:017", "I:17")]
|
|
[InlineData("Slc500", "N7:0", "N7:0")]
|
|
[InlineData("Slc500", null, "S:0")]
|
|
public async Task Probe_address_reaches_the_factory_family_normalised(string family, string? configured, string expected)
|
|
{
|
|
var (result, seen) = await ProbeWithFakeAsync(family, configured);
|
|
result.Ok.ShouldBeTrue(result.Message);
|
|
seen.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Normalised_default_probe_then_composes_with_the_libplctag_translation()
|
|
{
|
|
var (_, seen) = await ProbeWithFakeAsync("Plc5", "I:017");
|
|
LibplctagLegacyName.Translate(seen!).ShouldBe("I1:15");
|
|
var (_, status) = await ProbeWithFakeAsync("Slc500", null);
|
|
LibplctagLegacyName.Translate(status!).ShouldBe("S2:0");
|
|
}
|
|
|
|
private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
|
{
|
|
public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The authored <c>probeTimeoutSeconds</c> ("Admin UI probe timeout") was parsed by nothing — Test Connect ran
|
|
/// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps
|
|
/// the caller's budget (the positive control).
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(",\"probeTimeoutSeconds\":1", "1s")]
|
|
[InlineData(",\"probeTimeoutSeconds\":30", "2s")]
|
|
[InlineData("", "2s")]
|
|
[InlineData(",\"probeTimeoutSeconds\":0", "2s")]
|
|
public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget)
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
try
|
|
{
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) };
|
|
var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}";
|
|
|
|
var result = await new AbLegacyDriverProbe(factory)
|
|
.ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldBe($"Probe timed out after {expectedBudget}.");
|
|
}
|
|
finally { listener.Stop(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception()
|
|
{
|
|
var (result, seen) = await ProbeWithFakeAsync("Slc500", "not-an-address");
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message!.ShouldContain("not-an-address");
|
|
seen.ShouldBeNull();
|
|
}
|
|
}
|