Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
T
Joseph Doherty 1806af54f0 fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports
- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:06:40 -04:00

238 lines
12 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Phase 5 live verification that the real protocol-handshake Test-Connect probes actually
/// discriminate a speaking device from a merely-TCP-reachable one. Each probe is exercised
/// DIRECTLY (no cluster harness / SQL needed) against the shared docker-host sims, skip-gated
/// on reachability so <c>dotnet test</c> stays clean on a machine without fixture access.
///
/// <para>The decisive assertions are the cross-protocol RED cases: pointing a probe at a
/// DIFFERENT protocol's open port (which accepts TCP but does not speak the probe's protocol)
/// must now read <c>Ok = false</c> — the exact false-green bug Phase 5 fixes. Before Phase 5
/// every one of these read a false-healthy green.</para>
///
/// <para>S7 (<c>:1102</c>) and AbCip (<c>:44818</c>) happy-path verification skips unless those
/// fixtures are up (<c>lmxopcua-fix up s7 s7_1500</c> / <c>up abcip controllogix</c>); they are
/// unit-proven + code-reviewed. AbLegacy / TwinCAT / FOCAS have no rig target and are
/// unit-proven + degrade-guarded only (see <c>docs/drivers/TestConnectProbes.md</c>).</para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Phase", "5-probes")]
public sealed class DriverProbeHandshakeE2eTests
{
private const string DockerHost = "10.100.0.35";
private const int ModbusPort = 5020; // pymodbus sim — speaks Modbus
private const int OpcUaPort = 50000; // opc-plc — speaks OPC UA
private const int S7Port = 1102;
private const int AbCipPort = 44818;
private const string GalaxyHost = "10.100.0.48";
private const int GalaxyPort = 5120; // mxaccessgw — speaks gRPC
// Local docker-dev rig (on the dev host): a REAL OPC UA server + a real non-OPC-UA server.
private const int LocalOpcUaPort = 4840; // central-1 OtOpcUa OPC UA server — speaks OPC UA
private const int LocalSqlPort = 14330; // SQL Server — accepts TCP, speaks neither OPC UA nor gRPC
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static void SkipUnless(string host, int port)
{
// Generous timeout: the first connect from a cold test process (JIT + DNS warmup) can
// exceed the 500 ms default, and these targets may be a VPN hop away.
if (!DockerFixtureAvailability.IsReachable(host, port, 3000))
Assert.Skip($"Fixture {host}:{port} unreachable — skipping live handshake check.");
}
// ---- Modbus : FC03 handshake ----
[Fact]
public async Task Modbus_Green_AgainstModbusSim()
{
SkipUnless(DockerHost, ModbusPort);
var result = await new ModbusDriverProbe().ProbeAsync(
$"{{\"Host\":\"{DockerHost}\",\"Port\":{ModbusPort}}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("Modbus FC03");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task Modbus_Red_AgainstNonModbusPort()
{
// The OPC UA port accepts TCP but does not speak Modbus — must NOT read green.
SkipUnless(DockerHost, OpcUaPort);
var result = await new ModbusDriverProbe().ProbeAsync(
$"{{\"Host\":\"{DockerHost}\",\"Port\":{OpcUaPort}}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A non-Modbus TCP server must not pass the FC03 handshake.");
}
// ---- OpcUaClient : GetEndpoints handshake ----
[Fact]
public async Task OpcUaClient_Green_AgainstOpcPlc()
{
SkipUnless(DockerHost, OpcUaPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://{DockerHost}:{OpcUaPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("OPC UA");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task OpcUaClient_Red_AgainstNonOpcUaPort()
{
// The Modbus port accepts TCP but does not speak OPC UA — must NOT read green.
SkipUnless(DockerHost, ModbusPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://{DockerHost}:{ModbusPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A non-OPC-UA TCP server must not pass the GetEndpoints handshake.");
}
// ---- Galaxy : gRPC ping (auth-rejection = reachable) ----
[Fact]
public async Task Galaxy_Green_AgainstGateway()
{
SkipUnless(GalaxyHost, GalaxyPort);
// No API key supplied — an Unauthenticated reply still proves a live mxaccessgw gRPC server.
// UseTls:false matches the dev gateway's http2-cleartext endpoint (mirrors the dev config).
var result = await new GalaxyDriverProbe().ProbeAsync(
$"{{\"Gateway\":{{\"Endpoint\":\"http://{GalaxyHost}:{GalaxyPort}\",\"UseTls\":false}}}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task Galaxy_Red_AgainstNonGrpcPort()
{
// The Modbus port accepts TCP but does not speak gRPC — must NOT read green.
SkipUnless(DockerHost, ModbusPort);
var result = await new GalaxyDriverProbe().ProbeAsync(
$"{{\"Gateway\":{{\"Endpoint\":\"http://{DockerHost}:{ModbusPort}\",\"UseTls\":false}}}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A non-gRPC TCP server must not pass the gateway gRPC handshake.");
}
// ---- Local docker-dev rig: real OPC UA server (central-1) vs a real non-OPC-UA server ----
[Fact]
public async Task OpcUaClient_Green_AgainstLocalOtOpcUaServer()
{
SkipUnless("127.0.0.1", LocalOpcUaPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://127.0.0.1:{LocalOpcUaPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("OPC UA");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task OpcUaClient_Red_AgainstLocalNonOpcUaServer()
{
// SQL Server accepts TCP but does not speak OPC UA — the false-green bug Phase 5 fixes.
SkipUnless("127.0.0.1", LocalSqlPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://127.0.0.1:{LocalSqlPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A SQL Server (non-OPC-UA) must not pass the GetEndpoints handshake.");
}
[Fact]
public async Task Modbus_Red_AgainstLocalNonModbusServer()
{
// SQL Server accepts TCP but does not speak Modbus.
SkipUnless("127.0.0.1", LocalSqlPort);
var result = await new ModbusDriverProbe().ProbeAsync(
$"{{\"Host\":\"127.0.0.1\",\"Port\":{LocalSqlPort}}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A SQL Server (non-Modbus) must not pass the FC03 handshake.");
}
// ---- S7 : Plc.OpenAsync handshake (skips unless the sim fixture is up) ----
[Fact]
public async Task S7_Green_AgainstSim()
{
SkipUnless(DockerHost, S7Port);
var result = await new S7DriverProbe().ProbeAsync(
$"{{\"Host\":\"{DockerHost}\",\"Port\":{S7Port},\"CpuType\":\"S71500\",\"Rack\":0,\"Slot\":1}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("S7 connected");
}
// ---- AbCip : libplctag CIP session handshake (skips unless the sim fixture is up) ----
[Theory]
[InlineData("Native")]
[InlineData("Libplctag")]
public async Task AbCip_Green_AgainstSim(string transport)
{
SkipUnless(DockerHost, AbCipPort);
// Probe an explicit tag that the ab_server ControlLogix sim actually defines
// (`TestDINT:DINT[1]`). The no-tags fallback is exercised separately, below.
// ⚠️ The config must use the v3 `RawTags` shape — `[{RawPath, TagConfig, WriteIdempotent,
// DeviceName}]`, where the Logix tag path lives inside the TagConfig blob as `tagPath`. This
// test passed the retired v2 `Tags: [{DeviceHostAddress, TagPath}]` shape until 2026-08-01, so
// AbCipDriverOptions.RawTags bound EMPTY, the probe fell through to its `@raw_cpu_type` system-tag
// fallback, and ab_server answered ErrorBadParam. Which is to say it spent that whole time
// exercising the one path the comment above says it does not exercise — and reporting it as a
// failure of the fixture rather than of the config shape.
var result = await new AbCipDriverProbe().ProbeAsync(
// Run on BOTH transports (Task 22 review r1 I3): Native is the production default, and the
// probe's green messages are transport-neutral strings, so each row asserts the same text.
$"{{\"Transport\":\"{transport}\",\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," +
$"\"RawTags\":[{{\"RawPath\":\"Plant/AbCip/dev1/TestDINT\"," +
$"\"TagConfig\":\"{{\\\"tagPath\\\":\\\"TestDINT\\\",\\\"dataType\\\":\\\"DInt\\\"}}\"," +
$"\"WriteIdempotent\":false,\"DeviceName\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}",
Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
// EXACT, not ShouldContain. The probe's two green messages are "CIP session OK" (the tag was
// read) and "CIP session OK (controller reachable; probe tag not found)" (the fallback), and
// `ShouldContain` matches both — so it would have gone green again the moment the config shape
// was merely well-formed enough to reach the controller, whether or not the authored tag was
// ever resolved. Reading the authored tag is the entire point of this test.
result.Message.ShouldBe("CIP session OK");
}
/// <summary>
/// Test Connect on a device with NO authored tags — which is every AB CIP device at the moment
/// an operator first presses the button — must report the controller as reachable.
/// </summary>
/// <remarks>
/// The probe falls back to a name no controller defines, so the green answer arrives as a CIP
/// "tag does not exist" (0x04 → libplctag <c>ErrorNotFound</c>), which
/// <c>AbCipDriverProbe.IsReachableStatus</c> classifies as reachable. Until 2026-08-01 that
/// classification had never been exercised against any backend: <c>ab_server</c> answered an
/// unresolvable tag path with 0x03 (<c>ErrorBadParam</c>) where a Logix controller answers 0x04,
/// so the fixture reported a healthy sim as a failed handshake and the discrepancy was written
/// down as a fixture limitation rather than fixed. It is now fixed in the vendored patch —
/// <c>Docker/patches/0002-ab_server-answer-tag-not-found-with-cip-0x04.patch</c> — so this test
/// needs the fixture rebuilt from that patch, not merely running.
/// </remarks>
[Theory]
[InlineData("Native")]
[InlineData("Libplctag")]
public async Task AbCip_WithNoAuthoredTags_ReportsTheControllerReachable(string transport)
{
SkipUnless(DockerHost, AbCipPort);
var result = await new AbCipDriverProbe().ProbeAsync(
// Both transports (Task 22 review r1 I3): native maps CIP 0x04 to AbStatus.NotFound exactly as
// libplctag's ErrorNotFound does, so the reachable classification is the same.
$"{{\"Transport\":\"{transport}\",\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}],\"RawTags\":[]}}",
Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
// EXACT for the same reason as the test above, and here it carries the extra weight of proving
// WHICH green this is: the fallback's "probe tag not found", not a tag that was actually read.
result.Message.ShouldBe("CIP session OK (controller reachable; probe tag not found)");
}
}