Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverProbeTests.cs
T
Joseph Doherty fdc9473819 fix(twincat): Test Connect probe no longer dials the AmsNetId as an IP (#863)
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
2026-11-11 12:13:55 -05:00

181 lines
7.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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");
}
}