# MQTT test fixture Coverage map + gap inventory for the MQTT driver's harness. **TL;DR:** the unit suite (581 tests) carries the mapping, indexing, Sparkplug state-machine and failure-classification logic; the live suite (15 tests) proves the parts only a real broker can prove — TLS, real authentication, CA pinning in both directions, retained-message seeding, that a rejected CONNACK is actually surfaced, and the Sparkplug birth/alias/rebirth/death path against a real edge-node simulator. ## What the fixture is `eclipse-mosquitto:2.0.22` plus a second container of the same image running `publisher/publish.sh` (the image ships `mosquitto_pub`, so no bespoke image is needed), plus — behind the `sparkplug` compose profile — **`otopcua-sparkplug-sim`**, a project-owned C# Sparkplug-B edge-node simulator (`tests/Drivers/….Driver.Mqtt.IntegrationTests/SparkplugSimulator/`). Compose lives at `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/`; the deployed stack is `/opt/otopcua-mqtt` on the shared Docker host. **Two authenticated listeners, no anonymous fallback** (`allow_anonymous false`) — deliberate: an anonymous broker would let a broken auth config pass every test. | Listener | Endpoint | Env var | |---|---|---| | TLS + auth | `10.100.0.35:8883` | `MQTT_FIXTURE_ENDPOINT` | | plaintext + auth | `10.100.0.35:1883` | `MQTT_FIXTURE_PLAIN_ENDPOINT` | `MqttFixture` gates the suite on `MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_USERNAME` / `MQTT_FIXTURE_PASSWORD` (+ `MQTT_FIXTURE_CA_CERT`, `MQTT_FIXTURE_TOPIC_PREFIX`) and **skips cleanly** when they are absent, so the suite is safe to run offline. **Unlike every other fixture it needs generated material first** — `./gen-fixture-material.sh` writes the gitignored `secrets/` (password file, CA, server cert/key). The broker will not start without it. Bring-up and the CA-fetch recipe are in [`infra/README.md`](../../infra/README.md) §3. ### Published topics | Topic | Shape | Retain | Why it exists | |---|---|---|---| | `otopcua/fixture/retained/seed` | `{"value":42.5,…}` | ✅ **published exactly once** | The only way to prove retained *seeding*. On a republished topic a retained seed is indistinguishable from a fast live publish | | `otopcua/fixture/line1/temperature` | `{"value":…,"unit":"C","seq":n}` | ✅ | JSON + JSONPath extraction | | `otopcua/fixture/line1/counter` | `{"value":n}` | ✅ | Monotonic, for ordering | | `otopcua/fixture/line1/state` | `RUNNING` (bare text) | ✅ | Non-JSON `Raw`/`Scalar` payloads | | `otopcua/fixture/noise/chatter` | JSON | ❌ | **Never authored as a tag** — a broker carrying unauthored traffic is the normal case and the driver must stay silent about it | ## What it actually covers The 7 live tests: TLS+auth connect; connect pinned to the fixture CA; connect pinned to a *foreign* CA is **rejected** (the falsifiability control — a pinning test that only ever passes proves nothing); wrong password is rejected **and surfaced** (the MQTTnet-5 silent-`Healthy` defect); plain subscribe→value under a RawPath; retained message seeds a value on subscribe; and an unauthored topic stays silent while an authored tag keeps flowing. ### The Sparkplug simulator Group **`OtOpcUaSim`**; edge nodes **`EdgeA`** and **`EdgeB`**; `EdgeA` additionally publishes device **`Filler1`**. Node metrics `Temperature` (Float), `Pressure` (Double), `Count` (Int32), `Running` (Boolean), `Serial` (String) plus the mandatory `bdSeq` and **`Node Control/Rebirth`** — the last one deliberately carries a `/`, so it exercises the "a metric name is not a topic segment" rule. Device metrics: `Temperature` (Float), `FillCount` (Int64), `Jammed` (Boolean). Cadence ~2 s. It **subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}`** and re-births on a rebirth request, so it exercises the real NCMD round trip rather than simulating it. Births are not retained (per spec), so `docker restart otopcua-sparkplug-sim` is the way to force a fresh birth on demand: ```bash ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose --profile sparkplug up -d' ssh dohertj2@10.100.0.35 'docker restart otopcua-sparkplug-sim' # force a birth ssh dohertj2@10.100.0.35 'docker logs -f otopcua-sparkplug-sim' ``` Live-gated end-to-end (2026-07-24, P1 milestone): deployed on the docker-dev rig against this fixture, both tags resolved and served changing values through OPC UA, and the AdminUI `#`-observation picker rendered the real topic tree. Re-gated for P2 (2026-07-25) against the Sparkplug simulator — see [Mqtt.md](Mqtt.md). ## What it does NOT cover ### 1. Sparkplug primary-host STATE, and write-through The simulator never observes a Host Application `STATE`, because the driver never publishes one (`ActAsPrimaryHost` is unimplemented). Nor is there any DCMD/NCMD **command** coverage: rebirth is the only NCMD the driver ever sends, and `IWritable` is deferred. Sparkplug **seq-gap injection** is unit-proven only — the simulator always publishes a well-formed monotonic `seq`, so no live test drives the gap → rebirth path end-to-end. ### 2. Broker-side failure injection No test kills the broker mid-session, partitions the network, or forces a SUBACK failure against a real broker. Reconnect, backoff and per-topic degradation are **unit-proven only**. ### 3. Wildcard tags against a live broker The by-filter indexing fix that keeps wildcard tags alive across a reconnect is covered by unit tests; no live test authors a `+`/`#` tag and bounces the connection. ### 4. QoS 2 delivery semantics The fixture publishes at QoS 0/1. QoS 2's exactly-once handshake is never exercised end-to-end. ### 5. Scale One publisher, five topics, a ~2 s cadence. Nothing probes the 50 000-node browse cap, the 1 MiB payload cap, or thousands of authored tags. ### 6. Redundant-pair client-id collision Found by hand during the P1 gate (a fixed `clientId` makes pair nodes evict each other), **not** by a test. No automated coverage asserts that two concurrent drivers coexist. ## When to trust the MQTT fixture, when to reach for a real broker | Question | Fixture answers it? | |---|---| | Does TLS + auth work, and does bad auth fail loudly? | ✅ yes | | Is a retained message seeded on subscribe? | ✅ yes | | Does JSONPath extraction + coercion behave? | ✅ yes (unit + live) | | Does the driver ignore unauthored traffic? | ✅ yes | | Does it survive a broker restart / flapping link? | ❌ unit-only | | Does it hold up at plant tag counts? | ❌ no | | Does a Sparkplug birth → alias → DATA → death cycle work? | ✅ yes (live, vs. the simulator) | | Does an NCMD rebirth request actually get answered? | ✅ yes (the simulator subscribes + re-births) | | Sparkplug seq-gap → rebirth | ❌ unit-only (the sim never skips a seq) | | Sparkplug primary-host STATE / DCMD writes | ❌ not implemented | ## Follow-up candidates 1. Broker-restart / SUBACK-failure live tests (closes gap 2). 2. A live wildcard-across-reconnect test (closes gap 3). 3. A concurrent-two-driver test pinning the client-id hazard (closes gap 6). 4. A seq-gap injection switch on the simulator, closing the last unit-only Sparkplug leg. ## Key fixture / config files - `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` - `tests/Drivers/…/Docker/mosquitto.conf` · `Docker/gen-fixture-material.sh` · `Docker/publisher/publish.sh` - `tests/Drivers/…/MqttFixture.cs` · `PlainMqttLiveTests.cs` - [`infra/README.md`](../../infra/README.md) §3 — bring-up, endpoints, CA fetch