fdc9473819
TwinCATDriverProbe ran a first-phase TCP check against the AmsNetId's first four octets on the AMS port (801/851). An AmsNetId is not an IP and the AMS port is not a TCP port, so Test Connect timed out against every real runtime (Z35474: dialled 5.118.56.93, an unrelated public host). The probe now opens no socket of its own: it takes the driver's path, AdsClient.Connect + ReadStateAsync through the local AMS router, bounded by the probe budget (Task.Run + WaitAsync), and classifies failures as no local AMS router / no route or target unreachable / timeout / other. The degrade-to-green 'TCP reachability only' outcome is gone - with no TCP check nothing was contacted, so a missing router is reported red. Tests: the regression test listens on the AmsNetId-derived endpoint and asserts no connection arrives (fails on the old probe); classification and budget tests drive the handshake through an internal seam. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
181 lines
7.8 KiB
C#
181 lines
7.8 KiB
C#
// Unit tests for TwinCATDriverProbe — the ADS ReadState Test-Connect probe (#863).
|
||
//
|
||
// The probe takes exactly the driver's path: AdsClient.Connect(netId, port) through the LOCAL AMS
|
||
// router, then one ReadStateAsync. It must never open a socket of its own, and never to a host derived
|
||
// from the AMS Net ID (the old first-phase check dialled the Net ID's first four octets on the AMS
|
||
// port — an unrelated host, often a public one, on a port ADS does not use — so Test Connect timed out
|
||
// against every real runtime). The classification and budget tests drive the handshake through the
|
||
// internal seam; the no-dial and no-router tests use the real AdsClient on a box with no AMS router.
|
||
|
||
using System.Diagnostics;
|
||
using System.Net;
|
||
using System.Net.Sockets;
|
||
using Shouldly;
|
||
using TwinCAT.Ads;
|
||
using Xunit;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||
|
||
[Trait("Category", "Unit")]
|
||
public sealed class TwinCATDriverProbeTests
|
||
{
|
||
private const string Z35474Like = "{\"devices\":[{\"hostAddress\":\"ads://5.118.56.93.1.1:851\"}]}";
|
||
|
||
private static TwinCATDriverProbe ProbeWith(Func<TwinCATAmsAddress, TimeSpan, CancellationToken, Task<AdsState>> handshake)
|
||
=> new(handshake);
|
||
|
||
// ---- config validation ----------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
|
||
{
|
||
var result = await new TwinCATDriverProbe().ProbeAsync(
|
||
"not-valid-json{{{", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||
|
||
result.Ok.ShouldBeFalse();
|
||
result.Message!.ToLowerInvariant().ShouldContain("invalid");
|
||
result.Latency.ShouldBeNull();
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("{\"devices\":[]}")]
|
||
[InlineData("{\"devices\":[{\"hostAddress\":\"not-an-ads-url\"}]}")]
|
||
public async Task NoUsableDevice_Returns_OkFalse_WithNoHostPortMessage(string json)
|
||
{
|
||
var called = false;
|
||
var result = await ProbeWith((_, _, _) => { called = true; return Task.FromResult(AdsState.Run); })
|
||
.ProbeAsync(json, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||
|
||
result.Ok.ShouldBeFalse();
|
||
result.Message!.ShouldContain("no host/port");
|
||
called.ShouldBeFalse();
|
||
}
|
||
|
||
// ---- #863: never dial a host derived from the AMS Net ID --------------------------------
|
||
|
||
/// <summary>
|
||
/// Regression for #863. A listener sits on exactly the endpoint the old probe dialled
|
||
/// (Net ID octets 1–4 as IPv4, AMS port as TCP port). The real probe must never connect to
|
||
/// it — the old TCP pre-check connected here first, so this fails on the old behaviour.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Probe_NeverConnects_ToAmsNetIdDerivedEndpoint()
|
||
{
|
||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||
listener.Start();
|
||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||
try
|
||
{
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||
cts.CancelAfter(TimeSpan.FromSeconds(10));
|
||
await new TwinCATDriverProbe().ProbeAsync(
|
||
$"{{\"devices\":[{{\"hostAddress\":\"ads://127.0.0.1.1.1:{port}\"}}]}}",
|
||
TimeSpan.FromSeconds(3), cts.Token);
|
||
|
||
listener.Pending().ShouldBeFalse(
|
||
"the probe opened a TCP connection to the AmsNetId-derived address — it must only use the local AMS router");
|
||
}
|
||
finally
|
||
{
|
||
listener.Stop();
|
||
}
|
||
}
|
||
|
||
/// <summary>The handshake receives the AMS address unchanged — the probe resolves no host itself.</summary>
|
||
[Fact]
|
||
public async Task Handshake_IsHandedTheAmsAddress_Unchanged()
|
||
{
|
||
TwinCATAmsAddress? seen = null;
|
||
var result = await ProbeWith((t, _, _) => { seen = t; return Task.FromResult(AdsState.Run); })
|
||
.ProbeAsync(Z35474Like, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||
|
||
result.Ok.ShouldBeTrue();
|
||
seen.ShouldBe(new TwinCATAmsAddress("5.118.56.93.1.1", 851));
|
||
}
|
||
|
||
// ---- outcome classification -------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public async Task ReadStateOk_Returns_OkTrue_WithStateAndLatency()
|
||
{
|
||
var result = await ProbeWith((_, _, _) => Task.FromResult(AdsState.Run))
|
||
.ProbeAsync(Z35474Like, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||
|
||
result.Ok.ShouldBeTrue();
|
||
result.Message.ShouldBe("ADS state: Run");
|
||
result.Latency.ShouldNotBeNull();
|
||
}
|
||
|
||
/// <summary>
|
||
/// On a box with no AMS router the real AdsClient cannot connect. That is a RED naming the
|
||
/// missing router — no longer a green "TCP reachability only", since nothing was contacted.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task NoLocalRouter_RealClient_Returns_OkFalse_NamingTheRouter()
|
||
{
|
||
var result = await new TwinCATDriverProbe().ProbeAsync(
|
||
Z35474Like, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||
|
||
result.Ok.ShouldBeFalse();
|
||
result.Message!.ShouldContain("No local AMS router");
|
||
result.Message!.ShouldContain("not contacted");
|
||
result.Latency.ShouldBeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task AmsServerException_Classified_AsNoLocalRouter()
|
||
{
|
||
var result = await ProbeWith((_, _, _) => throw new global::TwinCAT.Ams.AmsServerException("Connect to Server failed!"))
|
||
.ProbeAsync(Z35474Like, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||
|
||
result.Ok.ShouldBeFalse();
|
||
result.Message!.ShouldContain("No local AMS router");
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(AdsErrorCode.ClientPortNotOpen, "No local AMS router")]
|
||
[InlineData(AdsErrorCode.TargetMachineNotFound, "No route to 5.118.56.93.1.1:851 or target unreachable")]
|
||
[InlineData(AdsErrorCode.TargetPortNotFound, "No route to 5.118.56.93.1.1:851 or target unreachable")]
|
||
[InlineData(AdsErrorCode.WSA_HostUnreachable, "No route to 5.118.56.93.1.1:851 or target unreachable")]
|
||
[InlineData(AdsErrorCode.ClientSyncTimeOut, "timed out")]
|
||
[InlineData(AdsErrorCode.DeviceInvalidGroup, "ADS handshake with 5.118.56.93.1.1:851 failed")]
|
||
public async Task AdsErrorCodes_AreClassified(AdsErrorCode code, string expected)
|
||
{
|
||
var result = await ProbeWith((_, _, _) => throw new AdsErrorException("x", code))
|
||
.ProbeAsync(Z35474Like, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||
|
||
result.Ok.ShouldBeFalse();
|
||
result.Message!.ShouldContain(expected);
|
||
result.Message!.ShouldContain(code.ToString());
|
||
result.Latency.ShouldBeNull();
|
||
}
|
||
|
||
// ---- time budget --------------------------------------------------------------------------
|
||
|
||
/// <summary>A handshake that never returns (and ignores its token) is cut off at the budget.</summary>
|
||
[Fact]
|
||
public async Task HangingHandshake_IsBoundedByTimeout()
|
||
{
|
||
var never = new TaskCompletionSource<AdsState>();
|
||
var sw = Stopwatch.StartNew();
|
||
var result = await ProbeWith((_, _, _) => never.Task)
|
||
.ProbeAsync(Z35474Like, TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||
sw.Stop();
|
||
|
||
result.Ok.ShouldBeFalse();
|
||
result.Message!.ShouldContain("timed out after 1s");
|
||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task CallerCancellation_Returns_TimedOut()
|
||
{
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
||
var result = await ProbeWith(async (_, _, ct) => { await Task.Delay(Timeout.Infinite, ct); return AdsState.Run; })
|
||
.ProbeAsync(Z35474Like, TimeSpan.FromSeconds(10), cts.Token);
|
||
|
||
result.Ok.ShouldBeFalse();
|
||
result.Message!.ShouldContain("Probe timed out after 10s");
|
||
}
|
||
}
|