diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 6c2b4cc..670153c 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -41,6 +41,7 @@ + diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md index f179fd7..83393a5 100644 --- a/docs/drivers/OpcUaClient-Test-Fixture.md +++ b/docs/drivers/OpcUaClient-Test-Fixture.md @@ -3,24 +3,55 @@ Coverage map + gap inventory for the OPC UA Client (gateway / aggregation) driver. -**TL;DR: there is no integration fixture.** Tests mock the OPC UA SDK's -`Session` + `Subscription` types directly; there is no upstream OPC UA -server standup in CI. The irony is not lost — this repo *is* an OPC UA -server, and the integration fixtures for `OpcUaApplicationHost` -(`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` + -`OpcUaEquipmentWalkerIntegrationTests.cs`) stand up the server-side stack -end-to-end. The client-side driver could in principle wire against one of -those, but doesn't today. +**TL;DR:** Wire-level coverage now exists via +[opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc) — Microsoft +Industrial IoT's OPC UA PLC simulator running in Docker (task #215). Real +Secure Channel, real Session, real MonitoredItem exchange against an +independent server implementation. Unit tests still carry the exhaustive +capability matrix (cert auth / security policies / reconnect / failover / +attribute mapping). Gaps remaining: upstream-server-specific quirks +(historian aggregates, typed ConditionType events, SDK-publish-queue edge +behavior under load) — opc-plc uses the same OPCFoundation stack internally +so fully-independent-stack coverage needs `open62541/open62541` as a second +image (follow-up). ## What the fixture is -Nothing at the integration layer. -`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is unit-only. Tests -inject fakes through the driver's construction path; the +**Integration layer** (task #215): +`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` stands up +`mcr.microsoft.com/iotedge/opc-plc:2.14.10` via `Docker/docker-compose.yml` +on `opc.tcp://localhost:50000`. `OpcPlcFixture` probes the port at +collection init + skips tests with a clear message when the container's +not running (matches the Modbus/pymodbus + S7/python-snap7 skip pattern). +Docker is the launcher — no PowerShell wrapper needed because opc-plc +ships pre-containerized. Compose-file flags: `--ut` (unsecured transport +advertised), `--aa` (auto-accept client certs — opc-plc's cert trust store +resets on each spin-up), `--alm` (alarm simulation for IAlarmSource +follow-up coverage), `--pn=50000` (port). + +**Unit layer**: +`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is still the primary +coverage. Tests inject fakes through the driver's construction path; the OPCFoundation.NetStandard `Session` surface is wrapped behind an interface the tests mock. -## What it actually covers (unit only) +## What it actually covers + +### Integration (opc-plc Docker, task #215) + +- `OpcUaClientSmokeTests.Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack` — + full Secure Channel + Session + `ns=3;s=StepUp` Read round-trip +- `OpcUaClientSmokeTests.Client_reads_batch_of_varied_types_from_live_simulator` — + batch Read of UInt32 / Int32 / Boolean; asserts `bool`-specific Variant + decoding to catch a common attribute-mapping regression +- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` — + real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every + 100 ms); asserts `OnDataChange` fires within 3 s of subscribe + +Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` + +`IHostConnectivityProbe` (via the Secure Channel exchange). + +### Unit The surface is broad because `OpcUaClientDriver` is the richest-capability driver in the fleet (it's a gateway for another OPC UA server, so it diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/README.md new file mode 100644 index 0000000..cb7059d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/README.md @@ -0,0 +1,103 @@ +# opc-plc Docker fixture + +[Microsoft Industrial IoT's opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc) +— pinned Docker image that stands up an OPC UA server at +`opc.tcp://localhost:50000` with step-up counters, random nodes, alarm +simulation, and other canonical simulated shapes. Replaces the PowerShell +launcher pattern used by the Modbus / S7 fixtures — Docker is the launcher +here since opc-plc ships pre-containerized. + +| File | Purpose | +|---|---| +| [`docker-compose.yml`](docker-compose.yml) | Service definition for `otopcua-opc-plc` — image pin, port map, command flags. | +| (this file) | How to run it. | + +## Install + +Docker Desktop (Windows) or the docker CLI + daemon (Linux/macOS). Per +`CLAUDE.md` Phase 1 decision #134 the dev box already has Docker Desktop +configured with the WSL 2 backend — nothing new to install. + +## Run + +From the repo root: + +```powershell +docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests\Docker\docker-compose.yml up +``` + +Or from this folder: + +```powershell +docker compose up +``` + +First run pulls the image (~250 MB). Startup takes ~5-10 seconds; the +healthcheck in the compose file surfaces ready state in `docker ps`. + +To run detached (CI pattern): + +```powershell +docker compose up -d +``` + +Stop with `docker compose down` (removes the container) or `docker compose stop` +(keeps it for fast restart). + +## Endpoint + +- Default: `opc.tcp://localhost:50000` +- Override by setting `OPCUA_SIM_ENDPOINT` before `dotnet test` — e.g. point + at a real OPC UA server in the lab, or at a different Docker host. + +## What opc-plc advertises + +Command flags in `docker-compose.yml` enable: + +- `--pn=50000` — OPC UA endpoint on port 50000 +- `--ut` — unsecured transport endpoint advertised (SecurityPolicy=None). + Secured policies are still on the endpoint list; `--ut` just adds an + unsecured option. +- `--aa` — auto-accept client certs (opc-plc's cert trust store lives + inside the container + resets each spin-up, so without this the driver's + first contact would be rejected). +- `--alm` — alarm simulation enabled; opc-plc publishes + `TripAlarmType`, `ExclusiveDeviationAlarmType`, + `NonExclusiveLevelAlarmType`, and `DialogConditionType` events. + +Not turned on (but available via compose-file tweaks): + +- `--daa` — disable anonymous auth; forces username or cert tokens. Flip + on when username-auth / cert-auth smoke tests land. +- `--fn` / `--fr` / `--ft` — fast-node variants (100 / 1 000 / 10 000 Hz + update rates) for subscription-stress coverage. Not needed for smoke. +- `--sn` / `--sr` — slow-node / special-shape coverage. + +## Run the integration tests + +In a separate shell, with the simulator running: + +```powershell +cd C:\Users\dohertj2\Desktop\lmxopcua +dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests +``` + +Tests auto-skip with a clear `SkipReason` when `localhost:50000` isn't +reachable within 2 seconds (`OpcPlcFixture`). + +## Known limitations + +opc-plc uses the OPCFoundation.NetStandard stack internally — same as +our driver. That means bugs common to the stack itself are **not** caught +by this fixture; the follow-up to add `open62541/open62541` as a second +independent-stack image (task tracked in #215's follow-ups) would close +that. + +See [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md) +for the full coverage map + what's still trusted from field deployments. + +## References + +- [opc-plc GitHub](https://github.com/Azure-Samples/iot-edge-opc-plc) +- [mcr.microsoft.com/iotedge/opc-plc tags](https://mcr.microsoft.com/v2/iotedge/opc-plc/tags/list) +- [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 0000000..18849ce --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,45 @@ +# opc-plc — OPC UA PLC simulator from Microsoft Industrial IoT. +# https://github.com/Azure-Samples/iot-edge-opc-plc +# +# Why pinned: MCR tags only go forward; keeping the suite reproducible means +# we test against a known feature surface. Bump deliberately alongside a +# driver-side change that needs the newer image. +services: + opc-plc: + image: mcr.microsoft.com/iotedge/opc-plc:2.14.10 + container_name: otopcua-opc-plc + restart: "no" + ports: + - "50000:50000" + command: + # --pn: Bind port 50000 (opc-plc default; matches fixture default) + # --ut: Advertise an Unsecured transport endpoint (SecurityPolicy=None). + # Tests that need signed/encrypted endpoints pick those off the + # negotiated endpoint list separately — opc-plc always advertises + # the secure policies even with --ut on. + # --aa: Auto-accept client certs. Tests wouldn't otherwise survive the + # first contact because opc-plc's cert trust store lives inside + # the container + resets each spin-up. + # --daa: Disable anonymous auth — forces the driver to go through the + # Anonymous user-token policy negotiation rather than opc-plc's + # "no auth required" short-circuit. Would flip to username/cert + # if we needed that coverage. + # Commented out for first-pass smoke; flip on when the cert-auth + # and username-auth smoke tests land. + # --alm: Turn on alarm simulation (TripAlarm / ExclusiveDeviation / + # NonExclusiveLevel / DialogCondition). Closes the IAlarmSource + # gap the OpcUaClient-Test-Fixture doc calls out. + - "--pn=50000" + - "--ut" + - "--aa" + - "--alm" + # - "--daa" + healthcheck: + # opc-plc doesn't expose an HTTP health endpoint by default; use a TCP + # probe via a shell the base image ships with. The fixture does its own + # TCP probe but healthcheck surfaces status in `docker ps` for humans. + test: ["CMD-SHELL", "netstat -an | grep -q ':50000.*LISTEN' || exit 1"] + interval: 5s + timeout: 2s + retries: 10 + start_period: 10s diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs new file mode 100644 index 0000000..856a12a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs @@ -0,0 +1,97 @@ +using System.Net.Sockets; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// Reachability probe for an opc-plc simulator (Microsoft Industrial IoT's +/// OPC UA PLC from mcr.microsoft.com/iotedge/opc-plc) or any real OPC UA +/// server the OPCUA_SIM_ENDPOINT env var points at. Parses +/// OPCUA_SIM_ENDPOINT (default opc.tcp://localhost:50000), +/// TCP-connects to the resolved host:port at collection init, and records a +/// on failure. Tests call Assert.Skip on that, so +/// `dotnet test` stays green when Docker isn't running the simulator — mirrors the +/// / Snap7ServerFixture pattern. +/// +/// +/// +/// Why opc-plc over loopback against our own server — (1) independent +/// cert chain + user-token handling catches interop bugs loopback can't; +/// (2) built-in alarm ConditionType + history simulation gives +/// + +/// coverage without a custom +/// driver fake; (3) pinned image tag fixes the test surface in a way our own +/// evolving server wouldn't. Follow-up: add open62541/open62541 as a +/// second image once this lands, for fully-independent-stack interop. +/// +/// +/// Endpoint URL contract: parser strips the opc.tcp:// scheme + resolves +/// host + port for the liveness probe only. The real test session always +/// dials the full endpoint URL via +/// so cert negotiation + security-policy selection run end-to-end. +/// +/// +public sealed class OpcPlcFixture : IAsyncDisposable +{ + private const string DefaultEndpoint = "opc.tcp://localhost:50000"; + private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT"; + + /// Full opc.tcp://host:port URL the driver session should connect to. + public string EndpointUrl { get; } + public string Host { get; } + public int Port { get; } + public string? SkipReason { get; } + + public OpcPlcFixture() + { + EndpointUrl = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint; + + (Host, Port) = ParseHostPort(EndpointUrl); + + try + { + using var client = new TcpClient(AddressFamily.InterNetwork); + var task = client.ConnectAsync( + System.Net.Dns.GetHostAddresses(Host) + .FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) + ?? System.Net.IPAddress.Loopback, + Port); + if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected) + { + SkipReason = $"opc-plc simulator at {Host}:{Port} did not accept a TCP connection within 2s. " + + $"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}."; + } + } + catch (Exception ex) + { + SkipReason = $"opc-plc simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " + + $"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}."; + } + } + + /// + /// Parse "opc.tcp://host:port[/path]" → (host, port). Defaults to port 4840 + /// (OPC UA standard) when the URL omits the port, but opc-plc's default is + /// 50000 so DefaultEndpoint carries it explicitly. + /// + private static (string Host, int Port) ParseHostPort(string endpointUrl) + { + const string scheme = "opc.tcp://"; + var body = endpointUrl.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) + ? endpointUrl[scheme.Length..] + : endpointUrl; + var slash = body.IndexOf('/'); + if (slash >= 0) body = body[..slash]; + var colon = body.IndexOf(':'); + if (colon < 0) return (body, 4840); + var host = body[..colon]; + return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, 4840); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +[Xunit.CollectionDefinition(Name)] +public sealed class OpcPlcCollection : Xunit.ICollectionFixture +{ + public const string Name = "OpcPlc"; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs new file mode 100644 index 0000000..d57b07d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs @@ -0,0 +1,38 @@ +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// Driver-side configuration + well-known opc-plc node identifiers that the smoke +/// tests address. Node IDs are stable across opc-plc releases — the simulator +/// guarantees the same ns=3;s=... names shipped since v1.0. If a release +/// bump breaks these, the fixture's pinned image tag needs a coordinated bump. +/// +public static class OpcPlcProfile +{ + /// opc-plc monotonically-increasing UInt32; ticks once per second under default opts. + public const string StepUp = "ns=3;s=StepUp"; + + /// opc-plc random Int32 node; new value ~every 100ms. + public const string RandomSignedInt32 = "ns=3;s=RandomSignedInt32"; + + /// opc-plc alternating boolean; flips every second. + public const string AlternatingBoolean = "ns=3;s=AlternatingBoolean"; + + /// opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests. + public const string FastUInt1 = "ns=3;s=FastUInt1"; + + public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new() + { + EndpointUrl = endpointUrl, + SecurityPolicy = OpcUaSecurityPolicy.None, + SecurityMode = OpcUaSecurityMode.None, + AuthType = OpcUaAuthType.Anonymous, + // opc-plc auto-accepts client certs (--aa) but we still present one; trust the + // server's cert back since the simulator regenerates it each container spin-up + // and there's no meaningful chain to validate against. + AutoAcceptCertificates = true, + Timeout = TimeSpan.FromSeconds(10), + SessionTimeout = TimeSpan.FromSeconds(30), + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs new file mode 100644 index 0000000..ab11c64 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs @@ -0,0 +1,92 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// End-to-end smoke against a live opc-plc (task #215). Drives the real +/// OPC UA Secure Channel + Session + MonitoredItem exchange — no mocks. Every +/// test here proves a capability surface that loopback against our own server +/// couldn't exercise cleanly: real cert negotiation, real endpoint descriptions, +/// real simulated nodes that change without a write. +/// +[Collection(OpcPlcCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "opc-plc")] +public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim) +{ + [Fact] + public async Task Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); + await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-read"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var snapshots = await drv.ReadAsync( + [OpcPlcProfile.StepUp], TestContext.Current.CancellationToken); + + snapshots.Count.ShouldBe(1); + snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end"); + snapshots[0].Value.ShouldNotBeNull("StepUp always has a current value"); + } + + [Fact] + public async Task Client_reads_batch_of_varied_types_from_live_simulator() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); + await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-batch"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var snapshots = await drv.ReadAsync( + [OpcPlcProfile.StepUp, OpcPlcProfile.RandomSignedInt32, OpcPlcProfile.AlternatingBoolean], + TestContext.Current.CancellationToken); + + snapshots.Count.ShouldBe(3); + foreach (var s in snapshots) + { + s.StatusCode.ShouldBe(0u); + s.Value.ShouldNotBeNull(); + } + // AlternatingBoolean should decode as a bool specifically — catches a common + // attribute-mapping regression where the driver stringifies variant values. + snapshots[2].Value.ShouldBeOfType(); + } + + [Fact] + public async Task Client_subscribe_receives_StepUp_data_changes_from_live_server() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); + await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-sub"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var observed = new List(); + var gate = new SemaphoreSlim(0); + drv.OnDataChange += (_, e) => + { + lock (observed) observed.Add(e); + gate.Release(); + }; + + var handle = await drv.SubscribeAsync( + [OpcPlcProfile.FastUInt1], TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + + // FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver. + // Wait up to 3 s to tolerate container warm-up + first-publish delay. + var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + got.ShouldBeTrue("opc-plc FastUInt1 must publish at least one data change within 3s"); + + int observedCount; + lock (observed) observedCount = observed.Count; + observedCount.ShouldBeGreaterThan(0); + + await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj new file mode 100644 index 0000000..c2e5bd7 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + +