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; /// /// 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 dotnet test stays clean on a machine without fixture access. /// /// 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 Ok = false — the exact false-green bug Phase 5 fixes. Before Phase 5 /// every one of these read a false-healthy green. /// /// S7 (:1102) and AbCip (:44818) happy-path verification skips unless those /// fixtures are up (lmxopcua-fix up s7 s7_1500 / up abcip controllogix); they are /// unit-proven + code-reviewed. AbLegacy / TwinCAT / FOCAS have no rig target and are /// unit-proven + degrade-guarded only (see docs/drivers/TestConnectProbes.md). /// [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"); } /// /// 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. /// /// /// The probe falls back to a name no controller defines, so the green answer arrives as a CIP /// "tag does not exist" (0x04 → libplctag ErrorNotFound), which /// AbCipDriverProbe.IsReachableStatus classifies as reachable. Until 2026-08-01 that /// classification had never been exercised against any backend: ab_server answered an /// unresolvable tag path with 0x03 (ErrorBadParam) 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 — /// Docker/patches/0002-ab_server-answer-tag-not-found-with-cip-0x04.patch — so this test /// needs the fixture rebuilt from that patch, not merely running. /// [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)"); } }