Merge branch 'feat/mqtt-sparkplug-driver'
v2-ci / build (push) Successful in 5m36s
v2-ci / unit-tests (push) Failing after 22m40s

# Conflicts:
#	src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
#	src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
This commit is contained in:
Joseph Doherty
2026-07-27 13:31:39 -04:00
109 changed files with 27428 additions and 62 deletions
@@ -23,11 +23,12 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<!-- Core hosts DriverFactoryRegistry; the eight driver assemblies each ship the
<!-- Core hosts DriverFactoryRegistry; the driver assemblies below each ship the
*DriverFactoryExtensions.Register entry point the guard test drives to build the
authoritative registered-factory set (mirrors the Host's DriverFactoryBootstrap). -->
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
@@ -0,0 +1,19 @@
# Fixture credential + TLS material produced by gen-fixture-material.sh.
#
# NEVER commit anything in here: it holds the broker password hash, the fixture CA's
# PRIVATE key and the server's private key. The generator script is the artifact in
# git; its output is regenerated per machine.
secrets/
# Build output of publish-simulator.sh — the `sparkplug-sim` service's bind-mounted app
# directory. Regenerated per machine from the SparkplugSimulator project, exactly like
# secrets/ is regenerated by gen-fixture-material.sh; the SCRIPT is the artifact in git.
simulator/app/
# Belt-and-braces: catch key/cert material written directly into this directory by a
# hand-run openssl command that forgot the secrets/ prefix.
*.key
*.crt
*.csr
*.srl
passwd
@@ -0,0 +1,125 @@
# MQTT integration-test fixture — Eclipse Mosquitto (auth + TLS) + a JSON publisher
# + an optional Sparkplug B edge-node simulator.
#
# PREREQUISITE — run this first, in this directory:
#
# MQTT_FIXTURE_PASSWORD='<pick-one>' ./gen-fixture-material.sh
#
# It writes ./secrets/ (password file + CA + server certificate/key), which is
# gitignored and which both services below mount. Without it the broker will not
# start: there is no anonymous fallback, by design.
#
# Bring up (on the shared Docker host — see infra/README.md §1):
#
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt \
# && MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<same> docker compose up -d'
#
# Endpoints: 10.100.0.35:8883 (TLS + auth) · 10.100.0.35:1883 (plaintext + auth, smoke)
#
# `mosquitto` + `publisher` carry no profile: they are always wanted together (a broker
# with nothing publishing into it tests nothing) and they bind different ports so nothing
# contends. `sparkplug-sim` DOES carry one (`--profile sparkplug`), because it is a
# manual-driving aid rather than a test dependency — see its own banner.
services:
mosquitto:
image: eclipse-mosquitto:2.0.22
container_name: otopcua-mosquitto
restart: unless-stopped
# Every lmxopcua fixture container carries this so the shared Docker host stays
# discoverable with `docker ps --filter label=project=lmxopcua`.
labels:
project: lmxopcua
ports:
- "1883:1883"
- "8883:8883"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- ./secrets:/mosquitto/secrets:ro
healthcheck:
# Authenticated round-trip against the broker's own $SYS tree ($$ escapes the
# compose variable syntax). Proves the password file loaded and the listener
# accepts a real session — a bare port check would go green on a broker that
# rejects every CONNECT.
test:
- CMD-SHELL
- >-
mosquitto_sub -h 127.0.0.1 -p 1883
-u "$$MQTT_FIXTURE_USERNAME" -P "$$MQTT_FIXTURE_PASSWORD"
-t '$$SYS/broker/uptime' -C 1 -W 3
interval: 5s
timeout: 8s
retries: 12
start_period: 5s
environment:
# Consumed only by the healthcheck above; the broker itself reads the hashed
# password file. Supplied by the operator's environment at `docker compose up` —
# never defaulted here, so a missing value fails loudly instead of silently
# provisioning a known-password broker.
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME (must match gen-fixture-material.sh)}
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD (must match gen-fixture-material.sh)}
publisher:
image: eclipse-mosquitto:2.0.22
container_name: otopcua-mqtt-publisher
restart: unless-stopped
labels:
project: lmxopcua
depends_on:
mosquitto:
condition: service_healthy
volumes:
- ./publisher/publish.sh:/publish.sh:ro
environment:
BROKER_HOST: mosquitto
BROKER_PORT: "1883"
TOPIC_PREFIX: ${MQTT_FIXTURE_TOPIC_PREFIX:-otopcua/fixture}
PUBLISH_INTERVAL: ${MQTT_FIXTURE_PUBLISH_INTERVAL:-2}
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME}
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD}
entrypoint: ["/bin/sh", "/publish.sh"]
# ---------------------------------------------------------------------------
# Sparkplug B edge-node simulator — the SAME `SparkplugEdgeNode` engine the live
# tests drive, run standalone (see SparkplugSimulator/Program.cs).
#
# WHAT IT IS FOR, AND WHAT IT IS NOT FOR. It exists so an operator (and the Task-26
# AdminUI `/run` verification) has a live Sparkplug plant to point the address picker
# at: NBIRTH/DBIRTH then N/DDATA every couple of seconds, honouring rebirth NCMDs.
# `SparkplugLiveTests` does NOT use it and must not — the §3.6 matrix needs an edge
# node it can command mid-test ("now reuse that alias", "now skip a seq", "now die"),
# which a container cannot be. Hence the profile: the default `docker compose up`
# leaves it out, and nothing in the test suite depends on it.
#
# PREREQUISITE: `./publish-simulator.sh` — the app directory below is build output,
# gitignored and rsynced like the rest of this folder. Without it the container has
# nothing to run.
# ---------------------------------------------------------------------------
sparkplug-sim:
image: mcr.microsoft.com/dotnet/runtime:10.0
container_name: otopcua-sparkplug-sim
profiles: ["sparkplug"]
restart: unless-stopped
labels:
project: lmxopcua
depends_on:
mosquitto:
condition: service_healthy
volumes:
# Framework-dependent publish output: portable IL, so it runs under the stock
# runtime image whatever this file was rsynced from.
- ./simulator/app:/app:ro
# The fixture CA, so the simulator PINS the broker exactly as the driver does
# rather than trusting anything. The cert's SAN list includes DNS:mosquitto (see
# gen-fixture-material.sh), which is the host name used below.
- ./secrets/ca.crt:/secrets/ca.crt:ro
environment:
MQTT_SIM_HOST: mosquitto
MQTT_SIM_PORT: "8883"
MQTT_SIM_USE_TLS: "true"
MQTT_SIM_CA_CERT: /secrets/ca.crt
MQTT_SIM_GROUP: ${MQTT_SIM_GROUP:-OtOpcUaSim}
MQTT_SIM_NODES: ${MQTT_SIM_NODES:-EdgeA,EdgeB}
MQTT_SIM_INTERVAL_SECONDS: ${MQTT_SIM_INTERVAL_SECONDS:-2}
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME}
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD}
command: ["dotnet", "/app/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.dll"]
@@ -0,0 +1,137 @@
#!/usr/bin/env sh
# ---------------------------------------------------------------------------
# Generates the MQTT fixture's password file + TLS material into ./secrets/.
#
# NOTHING this script writes is committed: ./secrets/ is excluded by the sibling
# .gitignore. The SCRIPT is the artifact in git, the OUTPUT never is. Re-run it on any
# machine that needs the fixture (or on the Docker host); the material is throwaway by
# construction — a self-issued CA, a 2048-bit server key, and whatever password the
# operator supplies in the environment.
#
# Usage:
# MQTT_FIXTURE_PASSWORD='<pick-one>' ./gen-fixture-material.sh
#
# Environment:
# MQTT_FIXTURE_PASSWORD (required) broker password. No default — a committed or
# defaulted password is exactly what the plan's
# "secrets from env, never committed" rule forbids.
# MQTT_FIXTURE_USERNAME broker username (default: otopcua)
# MQTT_FIXTURE_SAN_IPS comma-separated IP SANs for the server certificate
# (default: 10.100.0.35,127.0.0.1)
# MQTT_FIXTURE_SAN_DNS comma-separated DNS SANs
# (default: localhost,mosquitto)
# MQTT_FIXTURE_CERT_CN server certificate CN (default: first IP SAN)
# MQTT_FIXTURE_CERT_DAYS certificate lifetime in days (default: 825)
# MQTT_FIXTURE_SECRETS_DIR output directory (default: <script dir>/secrets)
# MOSQUITTO_IMAGE image used to run mosquitto_passwd
# (default: eclipse-mosquitto:2.0.22)
#
# The SANs matter: MqttConnection pins the TLS target host with
# `.WithTargetHost(options.Host)`, so hostname verification is enforced and the
# certificate MUST carry a SAN matching whatever host the tests dial — the shared
# Docker host's IP literal (10.100.0.35) in the normal case. A cert without the right
# SAN fails the handshake even with a correct CA pin, and the failure looks like a pin
# bug.
#
# Requires: openssl, docker (only for hashing the password — mosquitto 2.0 will not
# read a plaintext password file, and mosquitto_passwd is the only supported way to
# produce its PBKDF2-SHA512 format).
# ---------------------------------------------------------------------------
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT=${MQTT_FIXTURE_SECRETS_DIR:-"$SCRIPT_DIR/secrets"}
USERNAME=${MQTT_FIXTURE_USERNAME:-otopcua}
SAN_IPS=${MQTT_FIXTURE_SAN_IPS:-10.100.0.35,127.0.0.1}
SAN_DNS=${MQTT_FIXTURE_SAN_DNS:-localhost,mosquitto}
DAYS=${MQTT_FIXTURE_CERT_DAYS:-825}
MOSQUITTO_IMAGE=${MOSQUITTO_IMAGE:-eclipse-mosquitto:2.0.22}
if [ -z "${MQTT_FIXTURE_PASSWORD:-}" ]; then
echo "error: MQTT_FIXTURE_PASSWORD is not set." >&2
echo " Supply the broker password in the environment; this script never" >&2
echo " defaults or commits one. Example:" >&2
echo " MQTT_FIXTURE_PASSWORD='\$(openssl rand -base64 18)' $0" >&2
exit 2
fi
command -v openssl >/dev/null 2>&1 || { echo "error: openssl not found on PATH." >&2; exit 2; }
command -v docker >/dev/null 2>&1 || {
echo "error: docker not found on PATH — needed to run mosquitto_passwd." >&2
echo " Run this script on the Docker host (10.100.0.35) instead, or install docker." >&2
exit 2
}
CN=${MQTT_FIXTURE_CERT_CN:-$(printf '%s' "$SAN_IPS" | cut -d, -f1)}
mkdir -p "$OUT"
chmod 0755 "$OUT"
echo "==> generating fixture material into $OUT"
echo " username : $USERNAME"
echo " cert CN : $CN"
echo " IP SANs : $SAN_IPS"
echo " DNS SANs : $SAN_DNS"
# --- CA -------------------------------------------------------------------
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$OUT/ca.key" -out "$OUT/ca.crt" -days "$DAYS" \
-subj "/CN=OtOpcUa MQTT fixture CA/O=OtOpcUa test fixture" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
>/dev/null 2>&1
# --- server leaf ----------------------------------------------------------
# The SAN list is assembled into a temp extfile rather than a process substitution,
# which is a bashism this /bin/sh script cannot use.
EXT=$(mktemp)
trap 'rm -f "$EXT" "$OUT/server.csr" "$OUT/ca.srl"' EXIT
SAN=""
OLD_IFS=$IFS
IFS=,
for ip in $SAN_IPS; do [ -n "$ip" ] && SAN="$SAN,IP:$ip"; done
for dn in $SAN_DNS; do [ -n "$dn" ] && SAN="$SAN,DNS:$dn"; done
IFS=$OLD_IFS
SAN=${SAN#,}
cat >"$EXT" <<EOF
subjectAltName = $SAN
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
EOF
openssl req -newkey rsa:2048 -nodes \
-keyout "$OUT/server.key" -out "$OUT/server.csr" \
-subj "/CN=$CN/O=OtOpcUa test fixture" \
>/dev/null 2>&1
openssl x509 -req -in "$OUT/server.csr" \
-CA "$OUT/ca.crt" -CAkey "$OUT/ca.key" -CAcreateserial \
-out "$OUT/server.crt" -days "$DAYS" -sha256 -extfile "$EXT" \
>/dev/null 2>&1
# --- password file --------------------------------------------------------
# `--user` so the file lands owned by the invoking user rather than by root: the host
# user must be able to chmod it below, and (unlike a root-owned 0600 file) the broker
# container's `mosquitto` user must be able to READ it.
rm -f "$OUT/passwd"
docker run --rm --user "$(id -u):$(id -g)" -v "$OUT":/out "$MOSQUITTO_IMAGE" \
mosquitto_passwd -b -c /out/passwd "$USERNAME" "$MQTT_FIXTURE_PASSWORD" >/dev/null
# mosquitto runs as uid 1883 inside the container and reads these through a read-only
# bind mount, so they must be world-readable. This is a throwaway fixture key by
# construction — see the banner.
chmod 0644 "$OUT/ca.crt" "$OUT/server.crt" "$OUT/server.key" "$OUT/passwd"
chmod 0600 "$OUT/ca.key"
echo "==> done. Files written:"
ls -l "$OUT"
echo
echo "Next:"
echo " 1. Export the same creds for the test suite:"
echo " export MQTT_FIXTURE_USERNAME='$USERNAME'"
echo " export MQTT_FIXTURE_PASSWORD='<the same password>'"
echo " export MQTT_FIXTURE_CA_CERT='$OUT/ca.crt'"
echo " 2. Push this directory to the Docker host and bring the stack up."
@@ -0,0 +1,65 @@
# Eclipse Mosquitto configuration for the OtOpcUa MQTT driver integration fixture.
#
# SECURITY POSTURE — deliberate, and the whole reason this fixture exists.
# `allow_anonymous false` is set GLOBALLY (with per_listener_settings false), so it
# governs BOTH listeners. An anonymous fixture would let the driver's auth and TLS
# code paths — including the CA-pin logic in MqttConnection.ConfigureTls /
# ValidateAgainstPinnedCa — go completely untested, which is exactly backwards: those
# are the paths whose failure mode is silent (a pin that never rejects anything looks
# identical to a pin that works).
#
# The password file and the TLS material are NOT in git. Generate them first with
# `./gen-fixture-material.sh` (see that script's banner); it writes ./secrets/, which
# .gitignore excludes.
# One global security policy rather than per-listener ones: with
# per_listener_settings true, `allow_anonymous`/`password_file` would have to be
# repeated under EVERY listener block, and a listener that forgot them would silently
# fall back to allowing anonymous connections. Global + fail-closed is the safer shape.
per_listener_settings false
allow_anonymous false
password_file /mosquitto/secrets/passwd
# No persistence: the fixture is throwaway, and a persisted retained-message store
# surviving a `docker compose down` would make the retained-seed test's outcome depend
# on a previous run rather than on this one's publisher.
persistence false
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
connection_messages true
# ---------------------------------------------------------------------------
# Plain (non-TLS) listener — smoke only.
#
# Still authenticated (allow_anonymous false above applies here too); it is the
# TRANSPORT that is plaintext, not the access policy. It exists so the subscribe /
# retained-seed / unauthored-topic legs can be exercised without dragging the TLS
# handshake into every assertion, and so an operator can `mosquitto_sub` the fixture
# by hand. Never a model for a deployment.
# ---------------------------------------------------------------------------
listener 1883 0.0.0.0
protocol mqtt
# ---------------------------------------------------------------------------
# TLS listener — the real one.
#
# `require_certificate false`: the driver authenticates with username/password over
# TLS (the shape MqttDriverOptions models today); mutual TLS is not a driver feature
# yet, so demanding a client certificate here would make the fixture untestable rather
# than more secure.
#
# tls_version is deliberately NOT pinned: mosquitto's `tls_version` selects exactly
# one version, so pinning 1.2 would refuse a .NET client that negotiated 1.3. Leaving
# it unset lets OpenSSL negotiate the best mutually-supported version.
# ---------------------------------------------------------------------------
listener 8883 0.0.0.0
protocol mqtt
cafile /mosquitto/secrets/ca.crt
certfile /mosquitto/secrets/server.crt
keyfile /mosquitto/secrets/server.key
require_certificate false
@@ -0,0 +1,41 @@
#!/usr/bin/env sh
# ---------------------------------------------------------------------------
# Publishes the Sparkplug edge-node simulator into ./simulator/app/, which the
# compose file's `sparkplug-sim` service bind-mounts and runs.
#
# WHY A PUBLISHED DIRECTORY RATHER THAN AN IMAGE BUILD. This fixture is deployed by
# rsyncing THIS directory to /opt/otopcua-mqtt on the Docker host (see the compose
# banner and infra/README.md §1) — the host never sees the repo, so a Dockerfile whose
# build context is the solution root could not run there. A framework-dependent publish
# is portable IL: it runs unchanged under the stock dotnet/runtime image on the host,
# whatever this machine's architecture is.
#
# Usage, from a checkout (needs the .NET 10 SDK):
#
# ./publish-simulator.sh
# rsync -av --exclude 'secrets/' ./ dohertj2@10.100.0.35:/opt/otopcua-mqtt/
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt \
# && MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<same> \
# docker compose --profile sparkplug up -d'
#
# ./simulator/app/ is gitignored: it is build output, regenerated per machine, exactly
# like ./secrets/.
# ---------------------------------------------------------------------------
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT="$SCRIPT_DIR/../SparkplugSimulator/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj"
OUT="$SCRIPT_DIR/simulator/app"
command -v dotnet >/dev/null 2>&1 || { echo "error: dotnet SDK not found on PATH." >&2; exit 2; }
[ -f "$PROJECT" ] || { echo "error: simulator project not found at $PROJECT" >&2; exit 2; }
echo "==> publishing $(basename "$PROJECT") -> $OUT"
rm -rf "$OUT"
dotnet publish "$PROJECT" -c Release -o "$OUT" --nologo
echo "==> done:"
ls -1 "$OUT" | head -20
echo
echo "Next: rsync this directory to the Docker host and bring the stack up with"
echo " docker compose --profile sparkplug up -d"
@@ -0,0 +1,67 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# The fixture's JSON publisher. Runs inside a second eclipse-mosquitto container (the
# image already ships mosquitto_pub, so no bespoke image / paho dependency is needed)
# and feeds the broker over the PLAIN listener from inside the compose network —
# authenticated like every other client, since the broker is allow_anonymous false.
#
# Topics (all under $TOPIC_PREFIX, default otopcua/fixture):
#
# retained/seed published EXACTLY ONCE, retain=true, never republished.
# This is what makes the retained-seed test meaningful: a
# subscriber that connects minutes later and still receives a
# value on this topic can only have received the BROKER'S
# RETAINED COPY. On a topic that is also republished every few
# seconds, a retained seed is indistinguishable from a live
# publish that happened to arrive quickly.
# line1/temperature JSON object, retain=true, republished every $PUBLISH_INTERVAL
# line1/counter JSON object, retain=true, monotonically increasing
# line1/state bare scalar text (no JSON), retain=true
# noise/chatter JSON, retain=FALSE — deliberately never authored as a tag.
# A broker carrying traffic the deployment did not author is
# the normal case, and the driver must stay silent about it.
# ---------------------------------------------------------------------------
set -eu
HOST=${BROKER_HOST:-mosquitto}
PORT=${BROKER_PORT:-1883}
PREFIX=${TOPIC_PREFIX:-otopcua/fixture}
INTERVAL=${PUBLISH_INTERVAL:-2}
: "${MQTT_FIXTURE_USERNAME:?publisher needs MQTT_FIXTURE_USERNAME}"
: "${MQTT_FIXTURE_PASSWORD:?publisher needs MQTT_FIXTURE_PASSWORD}"
pub_retained() {
mosquitto_pub -h "$HOST" -p "$PORT" \
-u "$MQTT_FIXTURE_USERNAME" -P "$MQTT_FIXTURE_PASSWORD" \
-q 1 -r -t "$1" -m "$2"
}
pub_volatile() {
mosquitto_pub -h "$HOST" -p "$PORT" \
-u "$MQTT_FIXTURE_USERNAME" -P "$MQTT_FIXTURE_PASSWORD" \
-q 0 -t "$1" -m "$2"
}
echo "publisher: broker=$HOST:$PORT prefix=$PREFIX interval=${INTERVAL}s"
# The one-shot retained seed. Published before the loop starts and never again.
pub_retained "$PREFIX/retained/seed" \
'{"value":42.5,"unit":"C","note":"published once at fixture start; never republished"}'
echo "publisher: retained seed published on $PREFIX/retained/seed"
n=0
while true; do
n=$((n + 1))
temperature=$(awk "BEGIN { printf \"%.2f\", 20 + ($n % 50) / 10 }")
pub_retained "$PREFIX/line1/temperature" \
"{\"value\":$temperature,\"unit\":\"C\",\"seq\":$n}"
pub_retained "$PREFIX/line1/counter" "{\"value\":$n}"
pub_retained "$PREFIX/line1/state" "RUNNING"
# Unauthored chatter — the driver must never materialise a tag for this.
pub_volatile "$PREFIX/noise/chatter" "{\"value\":$n,\"source\":\"chatter\"}"
sleep "$INTERVAL"
done
@@ -0,0 +1,205 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
/// <summary>
/// Environment gate + reachability probe for the Mosquitto fixture defined in
/// <c>Docker/docker-compose.yml</c> (auth + TLS on <c>:8883</c>, authenticated plaintext on
/// <c>:1883</c>, plus a JSON publisher emitting retained messages).
/// <para>
/// <b>Unset env ⇒ skip, never fail.</b> Unlike the Modbus / S7 fixtures — which default to
/// the shared Docker host and probe it unconditionally — this one has <b>no default
/// endpoint</b>: without <c>MQTT_FIXTURE_ENDPOINT</c> the whole suite reports
/// <see cref="NotConfigured"/> and every test calls <c>Assert.Skip</c>. That is deliberate.
/// The fixture needs credentials that are generated per machine and never committed, so
/// "probe a well-known host and hope" would produce auth failures rather than a clean skip
/// on a dev box that never provisioned it. `dotnet test` on macOS with no Docker therefore
/// stays green.
/// </para>
/// </summary>
/// <remarks>
/// <para>
/// Env vars, all read once at fixture construction:
/// </para>
/// <list type="bullet">
/// <item>
/// <c>MQTT_FIXTURE_ENDPOINT</c> — <c>host:port</c> of the <b>TLS</b> listener, e.g.
/// <c>10.100.0.35:8883</c>. <b>The gate</b>: absent ⇒ the whole suite skips.
/// </item>
/// <item>
/// <c>MQTT_FIXTURE_PLAIN_ENDPOINT</c> — <c>host:port</c> of the plaintext listener.
/// Defaults to the TLS host on port 1883.
/// </item>
/// <item>
/// <c>MQTT_FIXTURE_USERNAME</c> / <c>MQTT_FIXTURE_PASSWORD</c> — the broker credentials
/// <c>gen-fixture-material.sh</c> was run with. The password has no default and is
/// never committed; without it the suite skips.
/// </item>
/// <item>
/// <c>MQTT_FIXTURE_CA_CERT</c> — path to the fixture CA (<c>secrets/ca.crt</c>).
/// Optional: supplied, the TLS legs pin to it and the CA-pin test runs; absent, the
/// connect legs fall back to <c>AllowUntrustedServerCertificate</c> and the pin test
/// skips.
/// </item>
/// <item>
/// <c>MQTT_FIXTURE_TOPIC_PREFIX</c> — must match the publisher's. Default
/// <c>otopcua/fixture</c>.
/// </item>
/// </list>
/// <para>
/// The probe is one-shot and its socket is closed immediately: tests open their own real
/// <see cref="MqttConnection"/> / <see cref="MqttDriver"/> sessions, and sharing a socket
/// would serialise them.
/// </para>
/// </remarks>
public sealed class MqttFixture
{
/// <summary>The gate: absent ⇒ the whole suite skips.</summary>
public const string EndpointEnvVar = "MQTT_FIXTURE_ENDPOINT";
/// <summary>Optional override for the plaintext listener; defaults to the TLS host on 1883.</summary>
public const string PlainEndpointEnvVar = "MQTT_FIXTURE_PLAIN_ENDPOINT";
/// <summary>Broker username; defaults to the generator script's own default.</summary>
public const string UsernameEnvVar = "MQTT_FIXTURE_USERNAME";
/// <summary>Broker password. No default — a defaulted fixture password is a committed secret.</summary>
public const string PasswordEnvVar = "MQTT_FIXTURE_PASSWORD";
/// <summary>Path to the fixture CA PEM, for the CA-pin legs.</summary>
public const string CaCertEnvVar = "MQTT_FIXTURE_CA_CERT";
/// <summary>Topic prefix the fixture publisher emits under.</summary>
public const string TopicPrefixEnvVar = "MQTT_FIXTURE_TOPIC_PREFIX";
private const string DefaultUsername = "otopcua";
private const string DefaultTopicPrefix = "otopcua/fixture";
private const int DefaultPlainPort = 1883;
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3);
/// <summary>Initializes a new instance of the <see cref="MqttFixture"/> class.</summary>
public MqttFixture()
{
Username = Environment.GetEnvironmentVariable(UsernameEnvVar) is { Length: > 0 } u ? u : DefaultUsername;
Password = Environment.GetEnvironmentVariable(PasswordEnvVar);
TopicPrefix = Environment.GetEnvironmentVariable(TopicPrefixEnvVar) is { Length: > 0 } p
? p.TrimEnd('/')
: DefaultTopicPrefix;
var caPath = Environment.GetEnvironmentVariable(CaCertEnvVar);
CaCertificatePath = string.IsNullOrWhiteSpace(caPath) ? null : caPath;
var tlsRaw = Environment.GetEnvironmentVariable(EndpointEnvVar);
if (string.IsNullOrWhiteSpace(tlsRaw))
{
SkipReason =
$"Skipped: set {EndpointEnvVar} (e.g. 10.100.0.35:8883) plus {UsernameEnvVar}/{PasswordEnvVar} to run "
+ "the MQTT fixture live suite. Bring the fixture up first — see "
+ "tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml.";
return;
}
(TlsHost, TlsPort) = ParseEndpoint(tlsRaw, defaultPort: 8883);
var plainRaw = Environment.GetEnvironmentVariable(PlainEndpointEnvVar);
(PlainHost, PlainPort) = string.IsNullOrWhiteSpace(plainRaw)
? (TlsHost, DefaultPlainPort)
: ParseEndpoint(plainRaw, DefaultPlainPort);
if (string.IsNullOrEmpty(Password))
{
// A blank password is NOT a valid "anonymous" configuration for this fixture — the
// broker runs allow_anonymous false — so treat it as unconfigured rather than letting
// every test fail with an indistinguishable "not authorized".
SkipReason =
$"Skipped: {EndpointEnvVar} is set but {PasswordEnvVar} is not. The fixture broker runs "
+ "allow_anonymous=false; supply the password gen-fixture-material.sh was run with.";
return;
}
// Probe the TLS listener: it is the endpoint the gate names, and a broker that is up serves
// both listeners from one process.
SkipReason = Probe(TlsHost, TlsPort);
}
/// <summary>Gets the TLS listener host.</summary>
public string TlsHost { get; } = string.Empty;
/// <summary>Gets the TLS listener port.</summary>
public int TlsPort { get; }
/// <summary>Gets the plaintext listener host.</summary>
public string PlainHost { get; } = string.Empty;
/// <summary>Gets the plaintext listener port.</summary>
public int PlainPort { get; }
/// <summary>Gets the broker username.</summary>
public string Username { get; }
/// <summary>Gets the broker password, or <see langword="null"/> when unconfigured.</summary>
public string? Password { get; }
/// <summary>Gets the fixture CA PEM path, or <see langword="null"/> when not supplied.</summary>
public string? CaCertificatePath { get; }
/// <summary>Gets the topic prefix the fixture publisher emits under (no trailing slash).</summary>
public string TopicPrefix { get; }
/// <summary>
/// Gets the reason every test in the suite must skip, or <see langword="null"/> when the
/// fixture is configured and reachable.
/// </summary>
public string? SkipReason { get; }
/// <summary>Gets a value indicating whether the suite must skip.</summary>
public bool NotConfigured => SkipReason is not null;
/// <summary>Composes a fixture topic under <see cref="TopicPrefix"/>.</summary>
/// <param name="suffix">The topic suffix, e.g. <c>line1/temperature</c>.</param>
/// <returns>The full topic.</returns>
public string Topic(string suffix) => $"{TopicPrefix}/{suffix.TrimStart('/')}";
private static (string Host, int Port) ParseEndpoint(string raw, int defaultPort)
{
var parts = raw.Trim().Split(':', 2);
var port = parts.Length == 2 && int.TryParse(parts[1], out var parsed) ? parsed : defaultPort;
return (parts[0], port);
}
private static string? Probe(string host, int port)
{
try
{
using var client = new TcpClient();
var connect = client.ConnectAsync(host, port);
if (!connect.Wait(ProbeTimeout) || !client.Connected)
{
return Unreachable(host, port, $"no TCP connection within {ProbeTimeout.TotalSeconds:0}s");
}
}
catch (Exception ex)
{
return Unreachable(host, port, $"{ex.GetType().Name}: {ex.Message}");
}
return null;
}
private static string Unreachable(string host, int port, string detail) =>
$"Skipped: the MQTT fixture broker at {host}:{port} is configured but unreachable ({detail}). "
+ "Bring the stack up on the Docker host: "
+ "`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && MQTT_FIXTURE_USERNAME=... MQTT_FIXTURE_PASSWORD=... docker compose up -d'`.";
}
/// <summary>
/// Collection definition so the reachability probe runs once per test session rather than once
/// per test — probing a firewalled endpoint per test would burn the probe timeout each time.
/// </summary>
[Xunit.CollectionDefinition(Name)]
public sealed class MqttFixtureCollection : Xunit.ICollectionFixture<MqttFixture>
{
/// <summary>The collection name.</summary>
public const string Name = "MqttFixture";
}
@@ -0,0 +1,610 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using MQTTnet;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
/// <summary>
/// Live validation of the plain-MQTT (P1) driver against the real Mosquitto fixture in
/// <c>Docker/docker-compose.yml</c> — auth + TLS on <c>:8883</c>, authenticated plaintext on
/// <c>:1883</c>, and a publisher emitting retained JSON. Every component exercised here is the
/// production one: <see cref="MqttConnection"/> (broker session, TLS, CA pin),
/// <see cref="MqttSubscriptionManager"/> (SUBSCRIBE, routing, extraction) and
/// <see cref="MqttDriver"/> (the <c>ISubscribable</c>/<c>IReadable</c> surface).
/// <para>
/// <b>Env-gated + skip-clean.</b> Without <c>MQTT_FIXTURE_ENDPOINT</c> (and the credentials)
/// every test calls <c>Assert.Skip</c>, so <c>dotnet test</c> is green on a laptop with no
/// Docker. See <see cref="MqttFixture"/> for the full env-var list.
/// </para>
/// <para>
/// <b>Why these four things.</b> The TLS/auth legs are the first time the Task-3 handshake
/// code meets a real broker — until now the CA-pin path had only ever seen synthetic certs,
/// and its failure mode (a pin that accepts everything) is invisible without a negative
/// control, which is why <see cref="Tls_connect_pinned_to_a_foreign_ca_is_rejected"/> exists
/// alongside the positive. The subscribe leg asserts the published <b>RawPath</b>, the single
/// most defect-prone identity in this plan. And the unauthored-topic leg is written so it
/// cannot pass vacuously — it proves an authored tag was flowing throughout the window in
/// which the unauthored topic produced nothing.
/// </para>
/// </summary>
[Collection(MqttFixtureCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class PlainMqttLiveTests(MqttFixture fixture)
{
/// <summary>OPC UA <c>BadWaitingForInitialData</c> — "no value has ever been observed".</summary>
private const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>OPC UA <c>BadNodeIdUnknown</c> — "that reference is not an authored MQTT tag".</summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>OPC UA <c>Good</c>.</summary>
private const uint Good = 0x00000000u;
/// <summary>
/// Bound on every wait for broker traffic. The fixture publisher emits every 2s by default,
/// so 30s is ~15 publish cycles — generous enough to absorb a slow container start, short
/// enough that a genuinely dark subscription fails rather than hangs (the plan's per-op
/// bounded-deadline rule).
/// </summary>
private static readonly TimeSpan MessageTimeout = TimeSpan.FromSeconds(30);
private readonly MqttFixture _fx = fixture;
// -----------------------------------------------------------------------
// TLS + auth
// -----------------------------------------------------------------------
/// <summary>
/// Connects over the TLS listener with real credentials and asserts the session establishes.
/// Pins to the fixture CA when <c>MQTT_FIXTURE_CA_CERT</c> is supplied — so on a fully
/// provisioned rig this is simultaneously the CA-pin happy path — and falls back to
/// <c>AllowUntrustedServerCertificate</c> otherwise so the auth leg still runs.
/// </summary>
[Fact]
public async Task Tls_auth_connect_succeeds()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
await using var connection = new MqttConnection(TlsOptions(), "mqtt-live-tls");
await connection.ConnectAsync(ct);
connection.IsConnected.ShouldBeTrue("a TLS+auth connect against the fixture broker must establish a session");
connection.State.ShouldBe(MqttConnectionState.Connected);
TestContext.Current.SendDiagnosticMessage(
$"TLS+auth connect to {_fx.TlsHost}:{_fx.TlsPort} succeeded "
+ $"(ca pin: {_fx.CaCertificatePath ?? "<none AllowUntrustedServerCertificate>"}).");
}
/// <summary>
/// The auth negative, and the assertion that gives every other test in this file its meaning:
/// a wrong password must never yield a usable session. The broker runs
/// <c>allow_anonymous false</c>, so if bad credentials produced a session the fixture would be
/// misconfigured and "connected" would prove nothing about auth.
/// <para>
/// <b>LIVE-GATE FINDING (2026-07-24), now FIXED — this test was tightened accordingly.</b>
/// Against real Mosquitto, <see cref="MqttConnection.ConnectAsync"/> originally
/// <b>returned normally</b> for a CONNECT the broker rejected as not-authorized: MQTTnet 5
/// does not throw on an unsuccessful CONNACK, and <c>ConnectCoreAsync</c> then
/// unconditionally published <see cref="MqttConnectionState.Connected"/> and started the
/// reconnect supervisor. Measured at +300 ms with a wrong password:
/// <c>thrown=&lt;none&gt;</c>, <c>IsConnected=False</c>, <c>State=Reconnecting</c>. Broker
/// side: <c>Client … disconnected, not authorised.</c> The operator-visible consequence was
/// that <c>InitializeAsync</c> reported <c>DriverState.Healthy</c> / <c>HostState.Running</c>
/// off a connect that never authenticated, so a deployment carrying the wrong broker
/// password sealed green.
/// </para>
/// <para>
/// <c>ConnectCoreAsync</c> now inspects the CONNACK and raises
/// <see cref="MqttConnectRejectedException"/>, so this asserts the strong form: the
/// rejection is <b>surfaced</b>, classified as unrecoverable, and lands the connection in
/// <see cref="MqttConnectionState.Faulted"/>. The original weak invariant — polled over 5 s
/// so the optimistic <c>Connected</c> window cannot pass it for the wrong reason — is kept
/// underneath it, because "no session is ever established" is the property that actually
/// matters to a consumer and it must hold regardless of how the failure is reported.
/// </para>
/// </summary>
[Fact]
public async Task Tls_connect_with_wrong_password_is_rejected_and_surfaced()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var options = TlsOptions() with { Password = _fx.Password + "-definitely-wrong" };
await using var connection = new MqttConnection(options, "mqtt-live-badpass");
var rejection = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await connection.ConnectAsync(ct));
// Mosquitto answers bad credentials with NotAuthorized (MQTT 5) / 0x05 (3.1.1); either maps to
// the same unrecoverable classification, which is what stops the reconnect supervisor.
rejection.IsUnrecoverable.ShouldBeTrue(
$"a broker that refused the credentials returned {rejection.ResultCode}, classified as retryable");
rejection.Message.ShouldNotContain(options.Password ?? "<unset>", Case.Sensitive);
connection.State.ShouldBe(MqttConnectionState.Faulted);
// Poll rather than sample once: a regression that went back to publishing an optimistic
// Connected would still read "not connected" in the first millisecond. Every sample must show
// no session — a correct password would make IsConnected true within the first sample.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline)
{
connection.IsConnected.ShouldBeFalse(
"a CONNECT the broker rejected as not-authorized must never yield a usable session");
connection.State.ShouldNotBe(
MqttConnectionState.Connected,
"a rejected CONNECT must never advertise a healthy session");
await Task.Delay(100, ct);
}
TestContext.Current.SendDiagnosticMessage(
$"wrong-password connect: {rejection.ResultCode} (unrecoverable={rejection.IsUnrecoverable}), "
+ $"IsConnected={connection.IsConnected}, State={connection.State}.");
}
/// <summary>
/// CA-pin happy path: <c>CaCertificatePath</c> set to the fixture's own CA, with the
/// untrusted-certificate escape hatch explicitly OFF, so the handshake can only succeed by
/// chaining the broker's leaf up to the pinned root through
/// <c>MqttConnection.ValidateAgainstPinnedCa</c>.
/// </summary>
[Fact]
public async Task Tls_connect_pinned_to_fixture_ca_succeeds()
{
SkipIfUnconfigured();
if (_fx.CaCertificatePath is null)
{
Assert.Skip(
$"Skipped: set {MqttFixture.CaCertEnvVar} to the fixture CA (Docker/secrets/ca.crt) to run the CA-pin leg.");
}
File.Exists(_fx.CaCertificatePath).ShouldBeTrue(
$"{MqttFixture.CaCertEnvVar}='{_fx.CaCertificatePath}' must point at the fixture CA PEM");
var ct = TestContext.Current.CancellationToken;
var options = TlsOptions() with
{
CaCertificatePath = _fx.CaCertificatePath,
AllowUntrustedServerCertificate = false,
};
await using var connection = new MqttConnection(options, "mqtt-live-capin");
await connection.ConnectAsync(ct);
connection.IsConnected.ShouldBeTrue("the broker leaf must chain up to the pinned fixture CA");
}
/// <summary>
/// The pin's falsifiability control, and the reason the positive above means anything: pin to
/// a CA the broker's certificate was <b>not</b> issued by and assert the handshake fails. A
/// validator that returned <c>true</c> unconditionally — the silent failure mode for this
/// kind of code — would pass the positive test and fail here.
/// <para>
/// The foreign CA is generated in-process rather than taken from the fixture, so this leg
/// needs no extra rig provisioning and cannot accidentally be handed the real CA.
/// </para>
/// </summary>
[Fact]
public async Task Tls_connect_pinned_to_a_foreign_ca_is_rejected()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var foreignCaPath = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-foreign-ca-{Guid.NewGuid():N}.pem");
await File.WriteAllTextAsync(foreignCaPath, CreateForeignCaPem(), ct);
try
{
var options = TlsOptions() with
{
CaCertificatePath = foreignCaPath,
AllowUntrustedServerCertificate = false,
// The handshake fails fast; no need to sit on the default 15s budget.
ConnectTimeoutSeconds = 10,
};
await using var connection = new MqttConnection(options, "mqtt-live-foreignca");
await Should.ThrowAsync<Exception>(async () => await connection.ConnectAsync(ct));
connection.IsConnected.ShouldBeFalse(
"a broker certificate that does not chain to the pinned CA must be refused — fail closed");
}
finally
{
File.Delete(foreignCaPath);
}
}
// -----------------------------------------------------------------------
// Plain subscribe / read
// -----------------------------------------------------------------------
/// <summary>
/// The identity assertion. Subscribes an authored tag whose RawPath is deliberately unrelated
/// to its MQTT topic, and asserts the driver publishes the change under the <b>RawPath</b> —
/// the v3 driver wire reference <c>DriverHostActor</c> fans out to the raw + UNS NodeIds. A
/// driver that keyed notifications by topic (or by the TagConfig blob, the retired pre-v3
/// shape) would deliver a value that no node in the address space is listening for, and every
/// "did a value arrive" assertion would still pass.
/// </summary>
[Fact]
public async Task Plain_subscribe_delivers_value_under_rawpath()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
const string rawPath = "Fixture/Mqtt/Line1/Temperature";
var tag = JsonTag(rawPath, _fx.Topic("line1/temperature"), "$.value", DriverDataType.Float64);
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-subscribe");
var recorder = new DataChangeRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
var change = await recorder.WaitForAsync(rawPath, MessageTimeout, ct);
change.FullReference.ShouldBe(
rawPath,
"the driver must publish under the tag's RawPath, never its topic or its TagConfig blob");
change.Snapshot.StatusCode.ShouldBe(Good);
change.Snapshot.Value.ShouldBeOfType<double>().ShouldBeInRange(-100d, 1000d);
// ...and the read path serves the same last value under the same key.
var read = await driver.ReadAsync([rawPath], ct);
read.Count.ShouldBe(1);
read[0].StatusCode.ShouldBe(Good);
read[0].Value.ShouldNotBeNull();
}
/// <summary>
/// Retained-message seeding. The fixture publisher emits <c>retained/seed</c> exactly once,
/// at container start, and never republishes it — so a subscriber connecting later can only
/// obtain a value from the broker's retained copy. Asserting the value on a topic that is
/// <i>also</i> republished every couple of seconds would prove nothing: a live publish
/// arriving quickly looks identical to a retained seed.
/// </summary>
[Fact]
public async Task Retained_message_seeds_value_on_subscribe()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
const string rawPath = "Fixture/Mqtt/Retained/Seed";
var tag = JsonTag(rawPath, _fx.Topic("retained/seed"), "$.value", DriverDataType.Float64);
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-retained");
var recorder = new DataChangeRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
var change = await recorder.WaitForAsync(rawPath, MessageTimeout, ct);
change.FullReference.ShouldBe(rawPath);
change.Snapshot.StatusCode.ShouldBe(Good);
change.Snapshot.Value.ShouldBeOfType<double>().ShouldBe(
42.5d,
"the retained seed carries the publisher's one-shot value; anything else means the "
+ "subscription was seeded from somewhere other than the broker's retained store");
}
/// <summary>
/// A chatty broker must not auto-provision. Publishes on a topic no tag was authored for and
/// asserts the driver never raises a notification for it.
/// <para>
/// <b>Deliberately falsifiable.</b> A version of this test that authored nothing would
/// pass vacuously — silence proves nothing if the pipeline is dead. So a real tag is
/// authored and subscribed first, and the test asserts that authored tag kept delivering
/// <i>throughout</i> the window in which the unauthored topic was being published. The
/// only way both assertions hold is if the driver was live and chose to ignore the
/// unauthored traffic.
/// </para>
/// </summary>
[Fact]
public async Task Unauthored_topic_is_silent_while_an_authored_tag_keeps_flowing()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
const string authoredRawPath = "Fixture/Mqtt/Line1/Counter";
const string unauthoredRawPath = "Fixture/Mqtt/Line1/NotAuthored";
// Unique per run so a previous run's retained message can never seed it.
var unauthoredTopic = _fx.Topic($"unauthored/{Guid.NewGuid():N}");
var tag = JsonTag(authoredRawPath, _fx.Topic("line1/counter"), "$.value", DriverDataType.Int64);
await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-unauthored");
var recorder = new DataChangeRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
// Subscribing an unauthored reference alongside the authored one: the driver must say
// BadNodeIdUnknown rather than leaving it forever "waiting for initial data" — and must
// never later turn it Good off broker traffic.
await driver.SubscribeAsync([authoredRawPath, unauthoredRawPath], TimeSpan.FromSeconds(1), ct);
// Proof the pipeline is live BEFORE the unauthored traffic starts.
await recorder.WaitForAsync(authoredRawPath, MessageTimeout, ct);
var beforeCount = recorder.CountFor(authoredRawPath);
// Emit unauthored traffic with our own client — the fixture publisher's own noise topic is
// not enough, because a test must be able to point at the exact messages it caused.
await PublishUnauthoredAsync(unauthoredTopic, messages: 5, ct);
// Let the authored tag tick at least once more while the unauthored messages sit delivered.
await recorder.WaitForCountAsync(authoredRawPath, beforeCount + 1, MessageTimeout, ct);
var seen = recorder.Snapshot();
seen.ShouldAllBe(
e => e.FullReference == authoredRawPath,
"the driver must raise notifications ONLY for authored tags; a broker's other topics are none of its business. "
+ $"Saw: {string.Join(", ", seen.Select(e => e.FullReference).Distinct())}");
var read = await driver.ReadAsync([authoredRawPath, unauthoredRawPath], ct);
read[0].StatusCode.ShouldBe(Good, "the authored tag was flowing throughout — this is the falsifiability control");
read[1].StatusCode.ShouldBeOneOf(
BadNodeIdUnknown,
BadWaitingForInitialData);
read[1].Value.ShouldBeNull("an unauthored reference must never acquire a value from broker traffic");
}
// -----------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------
private void SkipIfUnconfigured()
{
if (_fx.NotConfigured)
{
Assert.Skip(_fx.SkipReason!);
}
}
/// <summary>Driver options for the TLS listener, pinning the fixture CA when one was supplied.</summary>
private MqttDriverOptions TlsOptions(params RawTagEntry[] rawTags) => new()
{
Host = _fx.TlsHost,
Port = _fx.TlsPort,
UseTls = true,
// With no CA to pin, the fixture's self-issued chain cannot validate against the OS trust
// store, so the documented dev escape hatch is the only way the auth leg can run at all.
CaCertificatePath = _fx.CaCertificatePath,
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
Username = _fx.Username,
Password = _fx.Password,
ClientId = $"otopcua-live-{Guid.NewGuid():N}",
ConnectTimeoutSeconds = 15,
Mode = MqttMode.Plain,
Plain = new MqttPlainOptions { DefaultQos = 1 },
RawTags = rawTags,
};
/// <summary>Driver options for the plaintext listener — still authenticated.</summary>
private MqttDriverOptions PlainOptions(params RawTagEntry[] rawTags) => new()
{
Host = _fx.PlainHost,
Port = _fx.PlainPort,
UseTls = false,
Username = _fx.Username,
Password = _fx.Password,
ClientId = $"otopcua-live-{Guid.NewGuid():N}",
ConnectTimeoutSeconds = 15,
Mode = MqttMode.Plain,
Plain = new MqttPlainOptions { DefaultQos = 1 },
RawTags = rawTags,
};
private static RawTagEntry JsonTag(string rawPath, string topic, string jsonPath, DriverDataType dataType) =>
new(
rawPath,
$$"""
{"topic":"{{topic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"{{dataType}}","qos":1,"retainSeed":true}
""",
WriteIdempotent: false);
/// <summary>
/// Publishes on a topic no tag is authored for, with a raw MQTTnet client rather than through
/// the driver (the driver has no write path in P1, and the point is traffic the driver did
/// not originate).
/// </summary>
private async Task PublishUnauthoredAsync(string topic, int messages, CancellationToken ct)
{
using var client = new MqttClientFactory().CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer(_fx.PlainHost, _fx.PlainPort)
.WithCredentials(_fx.Username, _fx.Password)
.WithClientId($"otopcua-live-noise-{Guid.NewGuid():N}")
.WithTimeout(TimeSpan.FromSeconds(10))
.Build();
await client.ConnectAsync(options, ct);
try
{
for (var i = 0; i < messages; i++)
{
await client.PublishStringAsync(
topic,
$$"""{"value":{{i}},"source":"unauthored-live-test"}""",
MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce,
retain: false,
ct);
}
}
finally
{
await client.DisconnectAsync(cancellationToken: ct);
}
}
/// <summary>
/// A self-signed CA the fixture broker's certificate was definitively NOT issued by, as PEM.
/// Generated per call so nothing about it can be shared with — or mistaken for — the real
/// fixture CA.
/// </summary>
private static string CreateForeignCaPem()
{
using var key = RSA.Create(2048);
var request = new CertificateRequest(
"CN=OtOpcUa MQTT foreign CA (negative control)",
key,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddDays(1));
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(certificate.RawData, Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
return builder.ToString();
}
/// <summary>
/// Records every <c>OnDataChange</c> the driver raises and lets a test await a specific
/// RawPath. Attached in the constructor — <b>before</b> the MQTT subscribe — because the
/// retained seed can arrive inside the SUBSCRIBE round-trip, and a handler attached
/// afterwards would miss exactly the message the retained-seed test is about.
/// </summary>
private sealed class DataChangeRecorder
{
private readonly List<DataChangeEventArgs> _events = [];
private readonly Lock _gate = new();
private readonly List<Waiter> _waiters = [];
public DataChangeRecorder(MqttDriver driver) => driver.OnDataChange += OnDataChange;
/// <summary>A point-in-time copy of everything recorded so far.</summary>
/// <returns>The recorded events.</returns>
public IReadOnlyList<DataChangeEventArgs> Snapshot()
{
lock (_gate)
{
return [.. _events];
}
}
/// <summary>How many notifications have been recorded for one RawPath.</summary>
/// <param name="rawPath">The RawPath to count.</param>
/// <returns>The count.</returns>
public int CountFor(string rawPath)
{
lock (_gate)
{
return _events.Count(e => e.FullReference == rawPath);
}
}
/// <summary>Awaits the first notification for <paramref name="rawPath"/>.</summary>
/// <param name="rawPath">The RawPath to await.</param>
/// <param name="timeout">Bound on the wait.</param>
/// <param name="cancellationToken">Caller cancellation.</param>
/// <returns>The first matching notification.</returns>
public Task<DataChangeEventArgs> WaitForAsync(
string rawPath,
TimeSpan timeout,
CancellationToken cancellationToken)
=> WaitForCountAsync(rawPath, count: 1, timeout, cancellationToken);
/// <summary>
/// Awaits the <paramref name="count"/>-th notification for <paramref name="rawPath"/>.
/// Pre-scans what has already been recorded, so a caller that registers the wait after
/// the message arrived is served immediately rather than timing out.
/// </summary>
/// <param name="rawPath">The RawPath to await.</param>
/// <param name="count">How many notifications must have been seen.</param>
/// <param name="timeout">Bound on the wait.</param>
/// <param name="cancellationToken">Caller cancellation.</param>
/// <returns>The <paramref name="count"/>-th matching notification.</returns>
public async Task<DataChangeEventArgs> WaitForCountAsync(
string rawPath,
int count,
TimeSpan timeout,
CancellationToken cancellationToken)
{
Waiter waiter;
lock (_gate)
{
var matches = _events.Where(e => e.FullReference == rawPath).ToList();
if (matches.Count >= count)
{
return matches[count - 1];
}
waiter = new Waiter(rawPath, count);
_waiters.Add(waiter);
}
try
{
return await waiter.Completion.Task.WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
throw new TimeoutException(
$"No notification #{count} for RawPath '{rawPath}' within {timeout.TotalSeconds:0}s. "
+ $"Recorded so far: {DescribeRecorded()}. Is the fixture publisher container running "
+ "(`docker logs otopcua-mqtt-publisher`), and does MQTT_FIXTURE_TOPIC_PREFIX match it?");
}
finally
{
lock (_gate)
{
_waiters.Remove(waiter);
}
}
}
private string DescribeRecorded()
{
var snapshot = Snapshot();
return snapshot.Count == 0
? "(nothing)"
: string.Join(", ", snapshot.GroupBy(e => e.FullReference).Select(g => $"{g.Key}×{g.Count()}"));
}
private void OnDataChange(object? sender, DataChangeEventArgs args)
{
List<Waiter>? satisfied = null;
lock (_gate)
{
_events.Add(args);
foreach (var waiter in _waiters)
{
if (waiter.RawPath != args.FullReference) continue;
if (_events.Count(e => e.FullReference == waiter.RawPath) < waiter.Count) continue;
(satisfied ??= []).Add(waiter);
}
}
// Completed OUTSIDE the lock, and with RunContinuationsAsynchronously below, so a test
// continuation never runs on MQTTnet's dispatcher thread while holding this lock —
// that would stall delivery for every other subscription.
if (satisfied is null) return;
foreach (var waiter in satisfied)
{
waiter.Completion.TrySetResult(args);
}
}
private sealed record Waiter(string RawPath, int Count)
{
public TaskCompletionSource<DataChangeEventArgs> Completion { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
}
@@ -0,0 +1,979 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Text.Json;
using MQTTnet;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
/// <summary>
/// The Sparkplug B live gate: the §3.6 state-machine matrix driven end-to-end over the real
/// Mosquitto fixture, against a real edge node (<see cref="SparkplugEdgeNode"/>) that encodes with
/// the same generated Tahu schema the driver decodes with.
/// <para>
/// <b>What the live gate adds over the offline suite.</b> Every case here already has an
/// offline pin driven through <c>SparkplugIngestor.Dispatch</c> with hand-built decoded
/// payloads. Those prove the <i>logic</i>. They cannot prove the <b>wire</b>: that a negative
/// Int32 really does arrive as an unsigned two's-complement <c>int_value</c>, that a DATA
/// metric really does carry an alias and nothing else, that an NDEATH really is a broker-issued
/// Will with no <c>seq</c>, or that the NCMD this driver encodes is one an independent decoder
/// accepts as a rebirth request. A hand-built <c>SparkplugPayload</c> shaped by the same
/// assumptions as the decoder cannot falsify any of that; a byte stream through a broker can.
/// </para>
/// <para>
/// <b>Env-gated + skip-clean</b>, exactly as <see cref="PlainMqttLiveTests"/>: without
/// <c>MQTT_FIXTURE_ENDPOINT</c> (and credentials) every test calls <c>Assert.Skip</c>. See
/// <see cref="MqttFixture"/> for the env-var list.
/// </para>
/// <para>
/// <b>Every test uses its own Sparkplug group id.</b> The fixture broker is shared and the
/// driver subscribes <c>spBv1.0/{group}/#</c>; a fixed group would let one test's edge node
/// feed another's driver, and — worse — let a previous run's traffic satisfy an assertion.
/// </para>
/// </summary>
[Collection(MqttFixtureCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class SparkplugLiveTests(MqttFixture fixture)
{
/// <summary>OPC UA <c>Good</c>.</summary>
private const uint Good = 0x00000000u;
/// <summary>
/// OPC UA <c>BadCommunicationError</c> — the quality the ingest state machine stamps on a tag
/// whose source announced it is offline (NDEATH/DDEATH).
/// </summary>
private const uint BadCommunicationError = 0x80050000u;
/// <summary>
/// Bound on every wait for broker traffic. Generous enough to absorb a TLS handshake plus a
/// rebirth round-trip; short enough that a genuinely dark path fails rather than hangs.
/// </summary>
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
/// <summary>
/// How long a "this must NOT happen" assertion watches for. Long enough that the message would
/// have arrived (a broker round-trip on this rig is single-digit milliseconds), short enough not
/// to dominate the suite.
/// </summary>
private static readonly TimeSpan NegativeWindow = TimeSpan.FromSeconds(4);
private readonly MqttFixture _fx = fixture;
// -----------------------------------------------------------------------
// birth → data → alias resolve → OnDataChange (+ §3.6 case iv: negative Int32)
// -----------------------------------------------------------------------
/// <summary>
/// The spine of the driver: an NBIRTH/DBIRTH builds the alias table, an alias-only NDATA/DDATA
/// resolves through it, and the value is published under the tag's <b>RawPath</b> — the v3
/// driver wire reference, not the Sparkplug tuple and not the topic.
/// <para>
/// It simultaneously carries <b>§3.6 matrix case (iv), the negative Int32</b>. The metric's
/// value of 1234 travels as <c>int_value = 4294966062</c> because that is what Sparkplug
/// says; a driver that skipped <c>ReinterpretSigned</c> would publish 4294966062 at Good
/// quality — a plausible number, a wrong one, and invisible to any assertion that only
/// checks "a value arrived".
/// </para>
/// </summary>
[Fact]
public async Task Birth_then_alias_only_data_publishes_under_rawpath_with_signed_ints_intact()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("birth");
const string node = "EdgeA";
const string device = "Filler1";
const string tempPath = "Fixture/Sparkplug/EdgeA/Temperature";
const string countPath = "Fixture/Sparkplug/EdgeA/Count";
const string levelPath = "Fixture/Sparkplug/EdgeA/Filler1/Level";
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetNodeMetrics(
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
new SparkplugMetricSeed("Count", 2, DataType.Int32, -42));
sim.SetDeviceMetrics(device, new SparkplugMetricSeed("Level", 10, DataType.Double, 3.5d));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(
group,
Tag(tempPath, group, node, null, "Temperature"),
Tag(countPath, group, node, null, "Count"),
Tag(levelPath, group, node, device, "Level")),
"mqtt-live-sp-birth");
var recorder = new SparkplugRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([tempPath, countPath, levelPath], TimeSpan.FromSeconds(1), ct);
// The birth is a full snapshot, so these are the BIRTH's own values.
var birthTemp = await recorder.WaitAsync(tempPath, v => Equals(v.Value, 21.5f), Timeout, ct);
birthTemp.FullReference.ShouldBe(
tempPath,
"the driver must publish under the tag's RawPath, never the Sparkplug tuple or the topic");
birthTemp.Snapshot.StatusCode.ShouldBe(Good);
await recorder.WaitAsync(countPath, v => Equals(v.Value, -42), Timeout, ct);
await recorder.WaitAsync(levelPath, v => Equals(v.Value, 3.5d), Timeout, ct);
// …and now DATA: alias only, no name, no datatype — the shape a bind-by-alias driver survives
// and a bind-by-name driver must too.
await sim.PublishNodeDataAsync([("Temperature", 33.25f), ("Count", -1234)], ct);
await sim.PublishDeviceDataAsync(device, [("Level", 9.75d)], ct);
var temp = await recorder.WaitAsync(tempPath, v => Equals(v.Value, 33.25f), Timeout, ct);
temp.Snapshot.StatusCode.ShouldBe(Good);
// The predicate names the DATA value, not just "an int arrived": the birth already delivered a
// negative Int32 (42), so a laxer wait would be satisfied by the earlier message and the
// assertion below would compare the wrong notification.
var count = await recorder.WaitAsync(countPath, v => Equals(v.Value, -1234), Timeout, ct);
count.Snapshot.StatusCode.ShouldBe(Good);
count.Snapshot.Value.ShouldBe(
-1234,
"a Sparkplug Int32 rides as two's complement in the UNSIGNED int_value field (1234 goes on "
+ "the wire as 4294966062); without ReinterpretSigned that is what would be published, at "
+ "Good quality");
await recorder.WaitAsync(levelPath, v => Equals(v.Value, 9.75d), Timeout, ct);
// The read path serves the same last values under the same keys.
var read = await driver.ReadAsync([tempPath, countPath, levelPath], ct);
read.Select(r => r.StatusCode).ShouldAllBe(s => s == Good);
read[1].Value.ShouldBe(-1234);
}
// -----------------------------------------------------------------------
// rebirth recovery (§3.6 #5)
// -----------------------------------------------------------------------
/// <summary>
/// Late join: the driver connects to a node that has already birthed and gone quiet, so it holds
/// no alias table at all. It must request a rebirth, and the request must be one a real edge
/// node accepts.
/// <para>
/// <b>This is the NCMD encode/decode symmetry check.</b> The driver's
/// <c>RebirthRequester.Build</c> output is decoded here by the simulator — an independent
/// reader that looks for a Boolean metric literally named <c>Node Control/Rebirth</c> set to
/// true. Offline tests assert the driver's own bytes against the driver's own expectations;
/// only this one proves the command means what the spec says to a party that did not write it.
/// </para>
/// </summary>
[Fact]
public async Task Late_join_requests_a_rebirth_the_edge_node_honours_and_metadata_recovers()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("rebirth");
const string node = "EdgeA";
const string rawPath = "Fixture/Sparkplug/Rebirth/Temperature";
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 7, DataType.Double, 64.5d));
// Connected and listening for NCMD, but DELIBERATELY not birthed: the driver joins a stream it
// has no metadata for, which is exactly the state a restart or a long outage leaves it in.
await sim.ConnectAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
"mqtt-live-sp-rebirth");
var recorder = new SparkplugRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
var honoured = await sim.WaitForRebirthAsync(1, Timeout, ct);
honoured.ShouldBeGreaterThanOrEqualTo(
1,
"the driver must issue a late-join rebirth NCMD on connect (§3.6 #5), and it must be one a "
+ "spec-reading edge node recognises");
var change = await recorder.WaitAsync(rawPath, v => Equals(v.Value, 64.5d), Timeout, ct);
change.Snapshot.StatusCode.ShouldBe(
Good,
"the rebirth's NBIRTH is a full snapshot — its values are what restores the tag");
}
// -----------------------------------------------------------------------
// §3.6 case (i) — alias reuse across a rebirth
// -----------------------------------------------------------------------
/// <summary>
/// <b>The single most dangerous Sparkplug defect, on the wire.</b> An edge node may reassign an
/// alias to a <i>different</i> metric across a rebirth — legal, and something real edge nodes do
/// whenever their metric set changes. A driver that bound its tags to aliases keeps routing by
/// the old mapping and publishes Pressure's value into the Temperature tag: Good quality,
/// entirely plausible values, wrong tag, and nothing anywhere reports an error.
/// <para>
/// The test swaps aliases 5 and 6 between Temperature and Pressure at the rebirth, then
/// publishes a DATA message carrying <b>alias 5 only</b>. Bind-by-name puts it in Pressure;
/// bind-by-alias puts it in Temperature. The assertion checks both tags, because "Pressure
/// got the value" alone would still pass if the driver had fed both.
/// </para>
/// </summary>
[Fact]
public async Task Alias_reused_across_a_rebirth_routes_by_metric_name_not_by_alias()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("alias");
const string node = "EdgeA";
const string tempPath = "Fixture/Sparkplug/Alias/Temperature";
const string pressPath = "Fixture/Sparkplug/Alias/Pressure";
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetNodeMetrics(
new SparkplugMetricSeed("Temperature", 5, DataType.Double, 1d),
new SparkplugMetricSeed("Pressure", 6, DataType.Double, 2d));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(
group,
Tag(tempPath, group, node, null, "Temperature"),
Tag(pressPath, group, node, null, "Pressure")),
"mqtt-live-sp-alias");
var recorder = new SparkplugRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([tempPath, pressPath], TimeSpan.FromSeconds(1), ct);
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 1d), Timeout, ct);
await recorder.WaitAsync(pressPath, v => Equals(v.Value, 2d), Timeout, ct);
// Sanity, pre-swap: alias 5 IS Temperature right now.
await sim.PublishNodeDataAsync([("Temperature", 11d)], ct);
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 11d), Timeout, ct);
// The rebirth: aliases 5 and 6 change hands, and the birth republishes both values.
sim.SetNodeMetrics(
new SparkplugMetricSeed("Temperature", 6, DataType.Double, 100d),
new SparkplugMetricSeed("Pressure", 5, DataType.Double, 200d));
await sim.BirthAsync(ct);
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 100d), Timeout, ct);
await recorder.WaitAsync(pressPath, v => Equals(v.Value, 200d), Timeout, ct);
// …and now the message that discriminates: alias 5, which is Pressure NOW and was Temperature
// a moment ago.
await sim.PublishNodeDataAsync([("Pressure", 777d)], ct);
var pressure = await recorder.WaitAsync(pressPath, v => Equals(v.Value, 777d), Timeout, ct);
pressure.Snapshot.StatusCode.ShouldBe(Good);
// The other half of the assertion. Without it, a driver that fed BOTH tags would pass.
var read = await driver.ReadAsync([tempPath], ct);
read[0].Value.ShouldBe(
100d,
"alias 5 now belongs to Pressure; a driver still routing it to Temperature would show 777 "
+ "here — Good quality, plausible value, wrong tag");
}
// -----------------------------------------------------------------------
// §3.6 case (ii) — a stale-bdSeq NDEATH arriving after the reconnect NBIRTH
// -----------------------------------------------------------------------
/// <summary>
/// An edge node's link drops, it reconnects and births under a new <c>bdSeq</c>, and only
/// <i>then</i> does the broker get round to delivering the old connection's Will. Without the
/// <c>bdSeq</c> tie that stale death marks a live, freshly-born node dead and drives every one
/// of its tags Bad, with nothing coming to correct it.
/// <para>
/// Both arms are asserted, and they must be: a driver that ignored <b>every</b> NDEATH would
/// pass the first half. So the matching-token death follows immediately and must land.
/// </para>
/// </summary>
[Fact]
public async Task Ndeath_with_a_stale_bdseq_is_ignored_and_the_current_one_stales()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("bdseq");
const string node = "EdgeA";
const string rawPath = "Fixture/Sparkplug/BdSeq/Temperature";
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 3, DataType.Double, 10d));
await sim.ConnectAsync(ct); // session 0
sim.BdSeq.ShouldBe(0UL);
await sim.BirthAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
"mqtt-live-sp-bdseq");
var recorder = new SparkplugRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 10d), Timeout, ct);
// The node "reconnects": a clean DISCONNECT (so no Will fires) followed by a new session, whose
// bdSeq is 1, and a fresh birth carrying a distinguishable value.
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 3, DataType.Double, 20d));
await sim.ConnectAsync(ct); // session 1
sim.BdSeq.ShouldBe(1UL);
await sim.BirthAsync(ct);
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 20d), Timeout, ct);
// The old session's Will, delivered late. bdSeq 0 ≠ the live session's 1 ⇒ it must be discarded.
await sim.PublishNodeDeathAsync(bdSeq: 0UL, ct);
var deadline = DateTime.UtcNow + NegativeWindow;
while (DateTime.UtcNow < deadline)
{
var sample = await driver.ReadAsync([rawPath], ct);
sample[0].StatusCode.ShouldBe(
Good,
"an NDEATH carrying a PREVIOUS session's bdSeq is a stale Will; acting on it kills a live node");
await Task.Delay(200, ct);
}
// …and the falsifiability control: the SAME message with the CURRENT token must stale the tag.
// Without this leg, a driver that ignored every death would pass the assertion above.
await sim.PublishNodeDeathAsync(bdSeq: 1UL, ct);
var stale = await recorder.WaitAsync(
rawPath,
v => v.StatusCode == BadCommunicationError,
Timeout,
ct);
stale.Snapshot.Value.ShouldBeNull();
}
// -----------------------------------------------------------------------
// death → STALE, through the real Last-Will path
// -----------------------------------------------------------------------
/// <summary>
/// The realistic death: the edge node's session ends and the <b>broker</b> publishes the NDEATH
/// Will it registered at CONNECT. Nothing in this test publishes the death message — which is
/// the point, because a Will is the only death an edge node that crashed can produce, and it is
/// also the one that carries no <c>seq</c>.
/// <para>
/// The recovery half matters as much: §3.6 #4 says the next birth restores Good, so a driver
/// that latched Bad on death would be wrong in the direction nobody notices until a plant
/// comes back and its tags do not.
/// </para>
/// </summary>
[Fact]
public async Task Broker_published_ndeath_will_stales_the_tags_and_the_next_birth_restores_them()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("death");
const string node = "EdgeA";
const string rawPath = "Fixture/Sparkplug/Death/Temperature";
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 4, DataType.Double, 42d));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
await using var driver = new MqttDriver(
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
"mqtt-live-sp-death");
var recorder = new SparkplugRecorder(driver);
await driver.InitializeAsync(string.Empty, ct);
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 42d), Timeout, ct);
// The broker fires the Will — we publish nothing.
await sim.KillAsync(ct);
var stale = await recorder.WaitAsync(rawPath, v => v.StatusCode == BadCommunicationError, Timeout, ct);
stale.FullReference.ShouldBe(rawPath);
stale.Snapshot.Value.ShouldBeNull("a staled tag must not keep serving the dead node's last value");
// §3.6 #4: the next birth restores Good.
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 4, DataType.Double, 43d));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
var revived = await recorder.WaitAsync(rawPath, v => Equals(v.Value, 43d), Timeout, ct);
revived.Snapshot.StatusCode.ShouldBe(Good);
}
// -----------------------------------------------------------------------
// §3.6 case (iii) — seq wrap 255 → 0, and its falsifiability control
// -----------------------------------------------------------------------
/// <summary>
/// <b>255 → 0 is the sequence continuing, not a gap.</b> Read the wrap as a gap and every edge
/// node is commanded to rebirth once per 256 messages — a busy node would spend its life
/// republishing metadata. This drives a full lap of the counter over a real broker and asserts
/// that <b>no NCMD appears on the wire</b>.
/// <para>
/// <b>Two things make this test able to fail.</b> First, the ingestor is built with
/// <c>rebirthDebounce: TimeSpan.Zero</c>: at the shipped 10 s default a wrap-triggered
/// request would be swallowed by the debounce and the test would pass for the wrong reason.
/// Second, the negative control — a deliberately skipped <c>seq</c> immediately afterwards
/// <b>must</b> produce an NCMD. Silence proves nothing unless noise is also proven.
/// </para>
/// <para>
/// The assertion is made on the wire, by a third MQTT client watching
/// <c>spBv1.0/{group}/NCMD/#</c> — not on a counter inside the driver.
/// </para>
/// </summary>
[Fact]
public async Task Seq_wrap_255_to_0_requests_no_rebirth_but_a_real_gap_does()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("seq");
const string node = "EdgeA";
const string rawPath = "Fixture/Sparkplug/Seq/Counter";
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
// QoS 1 for the 256-message lap: the seq semantics under test are identical at either QoS, and
// a broker-side drop at QoS 0 would surface as a "gap" — a real detection, but a flaky test.
await using var sim = new SparkplugEdgeNode(SimOptions(group, node) with { DataQos = 1 });
sim.SetNodeMetrics(new SparkplugMetricSeed("Counter", 1, DataType.Double, 0d));
await sim.ConnectAsync(ct);
await sim.BirthAsync(ct);
var options = SparkplugOptions(group, Tag(rawPath, group, node, null, "Counter"));
// The production ingest path — AttachTo + EstablishAsync is exactly what MqttDriver does — but
// constructed directly so the rebirth debounce can be taken out of the picture.
await using var connection = new MqttConnection(options, "mqtt-live-sp-seq");
var ingestor = new SparkplugIngestor(
options,
"mqtt-live-sp-seq",
logger: null,
maxPayloadBytes: options.MaxPayloadBytes,
rebirthDebounce: TimeSpan.Zero);
ingestor.Register(options.RawTags);
ingestor.AttachTo(connection);
var recorder = new SparkplugRecorder(ingestor);
await connection.ConnectAsync(ct);
await ingestor.EstablishAsync(ct);
await ingestor.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
// Establish issues the late-join NCMD; let the node answer it and settle before measuring.
await sim.WaitForRebirthAsync(1, Timeout, ct);
await recorder.WaitAsync(rawPath, v => v.StatusCode == Good, Timeout, ct);
await Task.Delay(500, ct);
var beforeWrap = watcher.Count;
// A full lap: the birth left seq at 0, so these carry seq 1…255 and then 0 — the wrap.
for (var i = 1; i <= 256; i++)
{
await sim.PublishNodeDataAsync([("Counter", (double)i)], ct);
}
sim.LastSeq.ShouldBe((byte)0, "256 messages after a birth at seq 0 must land exactly on the wrap");
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 256d), Timeout, ct);
await Task.Delay(500, ct); // any NCMD the wrap provoked would have arrived by now
watcher.Count.ShouldBe(
beforeWrap,
$"a seq wrap 255 → 0 is the stream continuing, not a gap; NCMDs seen: "
+ $"{string.Join(", ", watcher.Topics)}");
// ---- the falsifiability control -------------------------------------------------
// Burn one sequence number without publishing it. That IS a lost message from the host's point
// of view, and it MUST produce a rebirth request — otherwise the silence above proves only that
// this driver never asks for anything.
sim.SkipSeq();
await sim.PublishNodeDataAsync([("Counter", 999d)], ct);
await watcher.WaitForCountAsync(beforeWrap + 1, Timeout, ct);
watcher.Topics.ShouldAllBe(t => t == $"spBv1.0/{group}/NCMD/{node}");
// A gap resynchronizes rather than latching: the message that exposed it still lands.
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 999d), Timeout, ct);
}
// -----------------------------------------------------------------------
// browser: passive window
// -----------------------------------------------------------------------
/// <summary>
/// <b>Opening an address picker against a live plant must publish nothing.</b> The browse
/// session counts its own publishes, and the offline suite asserts that counter — but a counter
/// can only see messages that went through the seam it guards. This asserts the property where
/// it actually matters: on the broker, with an independent client subscribed to the group's NCMD
/// topic while the picker opens, roots, expands every level and reads every attribute.
/// </summary>
[Fact]
public async Task Browser_open_and_full_tree_walk_publish_no_ncmd()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("browse");
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
await using var edgeA = await StartBrowseNodeAsync(group, "EdgeA", withDevice: true, ct);
await using var edgeB = await StartBrowseNodeAsync(group, "EdgeB", withDevice: false, ct);
var browser = new MqttDriverBrowser();
await using var session = await browser.OpenAsync(BrowseConfigJson(group), ct);
// The window is passive, so it can only see births published AFTER it subscribed — Sparkplug
// births are never retained. This is the same round-trip the picker's "Request rebirth" button
// exists for; here the test plays the edge node's part directly, so the browser stays untouched.
await edgeA.BirthAsync(ct);
await edgeB.BirthAsync(ct);
var roots = await WaitForRootAsync(session, group, ct);
roots.ShouldContain(n => n.NodeId == group);
// Walk everything the picker could walk: every level expanded, every node's attributes read.
var visited = await WalkAsync(session, roots, ct);
visited.ShouldContain($"{group}/EdgeA");
visited.ShouldContain($"{group}/EdgeB");
visited.ShouldContain($"{group}/EdgeA/Filler1");
visited.ShouldContain($"{group}/EdgeA::Temperature");
visited.ShouldContain($"{group}/EdgeA/Filler1::Level");
// Give anything the walk might have kicked off time to reach the broker before concluding.
await Task.Delay(NegativeWindow, ct);
watcher.Count.ShouldBe(
0,
"no browse path may publish — an operator opening a picker must not be able to command a "
+ $"running plant. Published: {string.Join(", ", watcher.Topics)}");
edgeA.RebirthCount.ShouldBe(0);
edgeB.RebirthCount.ShouldBe(0);
}
// -----------------------------------------------------------------------
// browser: explicit rebirth, node vs. group scope
// -----------------------------------------------------------------------
/// <summary>
/// The one browse-session member that publishes, and the enumeration it performs: an edge-node
/// scope addresses exactly that node, a group scope fans out to every edge node observed beneath
/// it. Asserted on the wire (which topics were published) and at the far end (which simulated
/// nodes actually honoured a rebirth), because "the call returned 2" says nothing about where
/// the commands went.
/// </summary>
[Fact]
public async Task Browser_request_rebirth_addresses_one_node_or_every_node_in_the_group()
{
SkipIfUnconfigured();
var ct = TestContext.Current.CancellationToken;
var group = NewGroup("rbscope");
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
await using var edgeA = await StartBrowseNodeAsync(group, "EdgeA", withDevice: true, ct);
await using var edgeB = await StartBrowseNodeAsync(group, "EdgeB", withDevice: false, ct);
var browser = new MqttDriverBrowser();
await using var session = await browser.OpenAsync(BrowseConfigJson(group), ct);
var rebirthable = session.ShouldBeAssignableTo<IRebirthCapableBrowseSession>();
await edgeA.BirthAsync(ct);
await edgeB.BirthAsync(ct);
await WaitForRootAsync(session, group, ct);
// Both edge nodes must be in the tree before the group scope is exercised — it enumerates what
// the window OBSERVED, so asserting the fan-out against a half-built tree would prove nothing.
await WaitForChildrenAsync(session, group, expected: 2, ct);
// ---- node scope ----
var nodeTargets = await rebirthable.RequestRebirthAsync($"{group}/EdgeA", ct);
nodeTargets.ShouldBe(1);
await watcher.WaitForCountAsync(1, Timeout, ct);
watcher.Topics.ShouldBe([$"spBv1.0/{group}/NCMD/EdgeA"]);
await edgeA.WaitForRebirthAsync(1, Timeout, ct);
edgeB.RebirthCount.ShouldBe(0, "a node-scoped rebirth must not touch the node next to it");
// ---- group scope ----
var groupTargets = await rebirthable.RequestRebirthAsync(group, ct);
groupTargets.ShouldBe(2);
await watcher.WaitForCountAsync(3, Timeout, ct);
watcher.Topics.ShouldBe(
[
$"spBv1.0/{group}/NCMD/EdgeA",
$"spBv1.0/{group}/NCMD/EdgeA",
$"spBv1.0/{group}/NCMD/EdgeB",
],
"a group scope addresses every observed edge node, once each, in a deterministic order");
await edgeA.WaitForRebirthAsync(2, Timeout, ct);
await edgeB.WaitForRebirthAsync(1, Timeout, ct);
}
// -----------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------
private void SkipIfUnconfigured()
{
if (_fx.NotConfigured)
{
Assert.Skip(_fx.SkipReason!);
}
}
/// <summary>
/// A Sparkplug group id unique to this run. The fixture broker is shared and the driver
/// subscribes the whole group, so a fixed id would let tests — and previous runs — feed each
/// other.
/// </summary>
private static string NewGroup(string label) => $"OtOpcUaLive-{label}-{Guid.NewGuid():N}"[..40];
/// <summary>Driver options for Sparkplug mode over the fixture's TLS listener.</summary>
private MqttDriverOptions SparkplugOptions(string groupId, params RawTagEntry[] rawTags) => new()
{
Host = _fx.TlsHost,
Port = _fx.TlsPort,
UseTls = true,
CaCertificatePath = _fx.CaCertificatePath,
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
Username = _fx.Username,
Password = _fx.Password,
ClientId = $"otopcua-live-sp-{Guid.NewGuid():N}",
ConnectTimeoutSeconds = 15,
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = groupId, RequestRebirthOnGap = true },
RawTags = rawTags,
};
/// <summary>Simulator connection settings, pointed at the same listener with the same credentials.</summary>
private SparkplugEdgeNodeOptions SimOptions(string groupId, string edgeNodeId) => new()
{
Host = _fx.TlsHost,
Port = _fx.TlsPort,
UseTls = true,
CaCertificatePath = _fx.CaCertificatePath,
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
Username = _fx.Username,
Password = _fx.Password,
GroupId = groupId,
EdgeNodeId = edgeNodeId,
};
/// <summary>An authored Sparkplug raw tag — the binding tuple, with the datatype left to the birth.</summary>
private static RawTagEntry Tag(
string rawPath,
string groupId,
string edgeNodeId,
string? deviceId,
string metricName)
{
var device = deviceId is null ? "" : $"""
"deviceId":"{deviceId}",
""";
return new RawTagEntry(
rawPath,
$$"""
{"groupId":"{{groupId}}","edgeNodeId":"{{edgeNodeId}}",{{device}}"metricName":"{{metricName}}"}
""",
WriteIdempotent: false);
}
/// <summary>The browse configuration blob, parsed through the driver's ONE shared JSON options.</summary>
private string BrowseConfigJson(string groupId) =>
JsonSerializer.Serialize(SparkplugOptions(groupId), MqttJson.Options);
/// <summary>Brings up one simulated edge node for the browse legs, connected but not yet birthed.</summary>
private async Task<SparkplugEdgeNode> StartBrowseNodeAsync(
string group,
string node,
bool withDevice,
CancellationToken ct)
{
var sim = new SparkplugEdgeNode(SimOptions(group, node));
sim.SetNodeMetrics(
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
new SparkplugMetricSeed("Count", 2, DataType.Int32, -42));
if (withDevice)
{
sim.SetDeviceMetrics("Filler1", new SparkplugMetricSeed("Level", 10, DataType.Double, 3.5d));
}
await sim.ConnectAsync(ct);
return sim;
}
/// <summary>Polls <c>RootAsync</c> until the observation window has recorded the group.</summary>
private static async Task<IReadOnlyList<BrowseNode>> WaitForRootAsync(
IBrowseSession session,
string group,
CancellationToken ct)
{
var deadline = DateTime.UtcNow + Timeout;
while (DateTime.UtcNow < deadline)
{
var roots = await session.RootAsync(ct);
if (roots.Any(n => n.NodeId == group))
{
return roots;
}
await Task.Delay(200, ct);
}
throw new TimeoutException(
$"The browse window never observed Sparkplug group '{group}'. A birth must be published "
+ "AFTER the window opens — Sparkplug births are not retained.");
}
/// <summary>Polls until a node reports at least <paramref name="expected"/> children.</summary>
private static async Task WaitForChildrenAsync(
IBrowseSession session,
string nodeId,
int expected,
CancellationToken ct)
{
var deadline = DateTime.UtcNow + Timeout;
while (DateTime.UtcNow < deadline)
{
var children = await session.ExpandAsync(nodeId, ct);
if (children.Count >= expected)
{
return;
}
await Task.Delay(200, ct);
}
throw new TimeoutException($"'{nodeId}' never reported {expected} children in the browse window.");
}
/// <summary>Expands every node depth-first and reads every node's attributes; returns the node ids seen.</summary>
private static async Task<List<string>> WalkAsync(
IBrowseSession session,
IReadOnlyList<BrowseNode> level,
CancellationToken ct)
{
var seen = new List<string>();
foreach (var node in level)
{
seen.Add(node.NodeId);
await session.AttributesAsync(node.NodeId, ct);
var children = await session.ExpandAsync(node.NodeId, ct);
if (children.Count > 0)
{
seen.AddRange(await WalkAsync(session, children, ct));
}
}
return seen;
}
// -----------------------------------------------------------------------
// fixtures used by the tests
// -----------------------------------------------------------------------
/// <summary>
/// Records every <c>OnDataChange</c> raised by a driver or an ingestor and lets a test await a
/// specific RawPath reaching a specific state.
/// <para>
/// <b>Awaits a predicate, not merely an arrival.</b> A Sparkplug tag is fed by births,
/// rebirths and data alike, so "a notification arrived for this RawPath" is satisfied by the
/// wrong message roughly half the time; every wait here names the value or quality it is
/// waiting for. It also pre-scans, so a test that registers after the message landed is
/// served rather than timing out.
/// </para>
/// </summary>
private sealed class SparkplugRecorder
{
private readonly List<DataChangeEventArgs> _events = [];
private readonly Lock _gate = new();
public SparkplugRecorder(MqttDriver driver) => driver.OnDataChange += Record;
public SparkplugRecorder(SparkplugIngestor ingestor) => ingestor.OnDataChange += Record;
public async Task<DataChangeEventArgs> WaitAsync(
string rawPath,
Func<DataValueSnapshot, bool> predicate,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow + timeout;
while (true)
{
lock (_gate)
{
foreach (var candidate in _events)
{
if (candidate.FullReference == rawPath && predicate(candidate.Snapshot))
{
return candidate;
}
}
}
if (DateTime.UtcNow >= deadline)
{
throw new TimeoutException(
$"No notification for RawPath '{rawPath}' satisfied the expected state within "
+ $"{timeout.TotalSeconds:0}s. Recorded: {Describe()}");
}
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
private string Describe()
{
lock (_gate)
{
return _events.Count == 0
? "(nothing)"
: string.Join(
" | ",
_events
.GroupBy(e => e.FullReference)
.Select(g =>
$"{g.Key}: [{string.Join(", ", g.Select(e => $"{e.Snapshot.Value ?? "null"}@0x{e.Snapshot.StatusCode:X8}"))}]"));
}
}
private void Record(object? sender, DataChangeEventArgs args)
{
lock (_gate)
{
_events.Add(args);
}
}
}
/// <summary>
/// An independent MQTT client subscribed to <c>spBv1.0/{group}/NCMD/#</c>, recording every
/// command published into the group.
/// <para>
/// <b>This is what makes the passivity assertions mean something.</b> The browse session's
/// own <c>PublishCountForTest</c> can only observe messages that pass through the seam it
/// guards; this observes the broker. If a future change published an NCMD by some other
/// route — a raw client, a Last Will, a stray retained command — the counter would still
/// read zero and this would not.
/// </para>
/// </summary>
private sealed class NcmdWatcher : IAsyncDisposable
{
private readonly IMqttClient _client;
private readonly ConcurrentQueue<string> _topics = new();
private NcmdWatcher(IMqttClient client) => _client = client;
/// <summary>Every NCMD topic observed, in arrival order.</summary>
public IReadOnlyList<string> Topics => [.. _topics];
/// <summary>How many NCMDs have been observed.</summary>
public int Count => _topics.Count;
public static async Task<NcmdWatcher> StartAsync(MqttFixture fixture, string group, CancellationToken ct)
{
var client = new MqttClientFactory().CreateMqttClient();
var watcher = new NcmdWatcher(client);
client.ApplicationMessageReceivedAsync += args =>
{
// Only a genuine rebirth command counts. A malformed or unrelated NCMD would be a
// different defect, and lumping them together would make the count uninterpretable.
if (watcher.IsRebirth(args.ApplicationMessage))
{
watcher._topics.Enqueue(args.ApplicationMessage.Topic);
}
return Task.CompletedTask;
};
var options = new MqttClientOptionsBuilder()
.WithTcpServer(fixture.PlainHost, fixture.PlainPort)
.WithCredentials(fixture.Username, fixture.Password)
.WithClientId($"otopcua-live-ncmdwatch-{Guid.NewGuid():N}")
.WithTimeout(TimeSpan.FromSeconds(10))
.Build();
await client.ConnectAsync(options, ct).ConfigureAwait(false);
await client.SubscribeAsync(
new MqttClientSubscribeOptionsBuilder()
.WithTopicFilter(f => f.WithTopic($"spBv1.0/{group}/NCMD/#"))
.Build(),
ct).ConfigureAwait(false);
return watcher;
}
public async Task WaitForCountAsync(int count, TimeSpan timeout, CancellationToken ct)
{
var deadline = DateTime.UtcNow + timeout;
while (Count < count)
{
if (DateTime.UtcNow >= deadline)
{
throw new TimeoutException(
$"Only {Count} rebirth NCMD(s) reached the broker within {timeout.TotalSeconds:0}s; "
+ $"expected {count}. Seen: {string.Join(", ", Topics)}");
}
await Task.Delay(100, ct).ConfigureAwait(false);
}
}
public async ValueTask DisposeAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
}
catch (Exception)
{
// Teardown is best-effort; the broker may already have gone.
}
_client.Dispose();
}
private bool IsRebirth(MqttApplicationMessage message)
{
var body = message.Payload;
var bytes = body.IsSingleSegment ? body.FirstSpan.ToArray() : body.ToArray();
try
{
var payload = Payload.Parser.ParseFrom(bytes);
return payload.Metrics.Any(m =>
m.Name == SparkplugEdgeNode.RebirthMetricName
&& m.ValueCase == Payload.Types.Metric.ValueOneofCase.BooleanValue
&& m.BooleanValue);
}
catch (Google.Protobuf.InvalidProtocolBufferException)
{
return false;
}
}
}
}
@@ -0,0 +1,114 @@
using Org.Eclipse.Tahu.Protobuf;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
// ---------------------------------------------------------------------------
// Standalone entry point for the `sparkplug-sim` fixture container.
//
// Runs one or more simulated edge nodes against the fixture broker, forever: NBIRTH + DBIRTH once,
// then N/DDATA on a cadence, honouring rebirth NCMDs throughout. It exists so an operator (and Task
// 26's AdminUI `/run` verification) has a live Sparkplug plant to browse, NOT so the live tests have
// one — those drive `SparkplugEdgeNode` in process, because the §3.6 matrix needs an edge node it
// can command mid-test.
//
// Every setting comes from the environment; nothing is defaulted to a credential.
// ---------------------------------------------------------------------------
var host = Env("MQTT_SIM_HOST", "mosquitto");
var port = int.TryParse(Env("MQTT_SIM_PORT", "8883"), out var p) ? p : 8883;
var useTls = !string.Equals(Env("MQTT_SIM_USE_TLS", "true"), "false", StringComparison.OrdinalIgnoreCase);
var group = Env("MQTT_SIM_GROUP", "OtOpcUaSim");
var nodes = Env("MQTT_SIM_NODES", "EdgeA,EdgeB").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var interval = int.TryParse(Env("MQTT_SIM_INTERVAL_SECONDS", "2"), out var i) && i > 0 ? i : 2;
var username = Environment.GetEnvironmentVariable("MQTT_FIXTURE_USERNAME");
var password = Environment.GetEnvironmentVariable("MQTT_FIXTURE_PASSWORD");
var caPath = Environment.GetEnvironmentVariable("MQTT_SIM_CA_CERT");
var allowUntrusted = string.Equals(Env("MQTT_SIM_ALLOW_UNTRUSTED", "false"), "true", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(password))
{
// Same fail-loud posture as the compose file's `${VAR:?}` guards: the broker runs
// allow_anonymous=false, so a blank password would produce an unexplained connect refusal.
Console.Error.WriteLine(
"sparkplug-sim: MQTT_FIXTURE_PASSWORD is not set. The fixture broker runs allow_anonymous=false; "
+ "supply the password gen-fixture-material.sh was run with.");
return 2;
}
using var stopping = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
stopping.Cancel();
};
AppDomain.CurrentDomain.ProcessExit += (_, _) => stopping.Cancel();
Console.WriteLine(
$"sparkplug-sim: broker={host}:{port} tls={useTls} group={group} nodes=[{string.Join(", ", nodes)}] "
+ $"interval={interval}s");
var running = new List<Task>();
var edges = new List<SparkplugEdgeNode>();
foreach (var node in nodes)
{
var edge = new SparkplugEdgeNode(
new SparkplugEdgeNodeOptions
{
Host = host,
Port = port,
UseTls = useTls,
CaCertificatePath = caPath,
AllowUntrustedServerCertificate = allowUntrusted,
Username = username,
Password = password,
GroupId = group,
EdgeNodeId = node,
},
Console.WriteLine);
// A small but genuinely heterogeneous catalog: a float, a double, a NEGATIVE int32 (the two's
// complement case), a bool and a string — plus one device, so the picker's
// Group → EdgeNode → Device → Metric tree has every level populated.
edge.SetNodeMetrics(
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
new SparkplugMetricSeed("Pressure", 2, DataType.Double, 101.325d),
new SparkplugMetricSeed("Count", 3, DataType.Int32, -42),
new SparkplugMetricSeed("Running", 4, DataType.Boolean, true),
new SparkplugMetricSeed("Serial", 5, DataType.String, $"{node}-001"));
edge.SetDeviceMetrics(
"Filler1",
new SparkplugMetricSeed("Temperature", 10, DataType.Float, 55.5f),
new SparkplugMetricSeed("FillCount", 11, DataType.Int64, 1000L),
new SparkplugMetricSeed("Jammed", 12, DataType.Boolean, false));
edges.Add(edge);
}
try
{
foreach (var edge in edges)
{
await edge.ConnectAsync(stopping.Token);
running.Add(edge.RunAsync(TimeSpan.FromSeconds(interval), stopping.Token));
}
await Task.WhenAll(running);
}
catch (OperationCanceledException)
{
Console.WriteLine("sparkplug-sim: stopping.");
}
finally
{
foreach (var edge in edges)
{
await edge.DisposeAsync();
}
}
return 0;
static string Env(string name, string fallback) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : fallback;
@@ -0,0 +1,917 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography.X509Certificates;
using Google.Protobuf;
using MQTTnet;
using MQTTnet.Protocol;
using Org.Eclipse.Tahu.Protobuf;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
/// <summary>
/// A controllable Sparkplug B <b>edge node</b> — the driver's counterparty on a real broker.
/// Registers an NDEATH Last Will at CONNECT, publishes NBIRTH/DBIRTH then N/DDATA, honours a
/// <c>Node Control/Rebirth</c> NCMD, and exposes the §3.6 pathologies (alias reuse across a
/// rebirth, a seq gap, a stale-<c>bdSeq</c> death) as explicit, on-demand operations.
/// </summary>
/// <remarks>
/// <para>
/// <b>Encodes with the generated Tahu types, frames topics independently.</b> The payloads are
/// built from <see cref="Payload"/> — the same generated schema the driver decodes with, which
/// is what makes this fixture a genuine encode/decode symmetry check. The <i>topic</i> strings
/// are composed here from literals rather than through the driver's own
/// <c>SparkplugTopic.Format</c>: a simulator that borrowed the parser's formatter could not
/// detect a bug in it, because both sides of the comparison would be wrong together.
/// </para>
/// <para>
/// <b>What "the way a real edge node does it" means, concretely, and why each detail matters:</b>
/// </para>
/// <list type="bullet">
/// <item>
/// <b>NDEATH is the broker-published Last Will, registered at CONNECT</b> — not a message
/// this class publishes on the way out. It carries a <c>bdSeq</c> metric and <b>no
/// <c>seq</c></b>, because it is not a member of the node's sequence.
/// <see cref="KillAsync"/> makes the broker fire it.
/// </item>
/// <item>
/// <b><c>bdSeq</c> increments once per CONNECT</b> and appears in both the NBIRTH and the
/// NDEATH of that session — that pairing is the only thing that lets a host tell a
/// just-delivered stale will from a real death.
/// </item>
/// <item>
/// <b>NBIRTH restarts <c>seq</c> at 0</b>; DBIRTH/NDATA/DDATA/DDEATH continue it, wrapping
/// 255 → 0.
/// </item>
/// <item>
/// <b>DATA metrics carry an alias and nothing else</b> — no name, no datatype. That is what
/// every real post-birth DATA message looks like, and a driver that binds tags to aliases
/// rather than names passes every test written against a friendlier simulator.
/// </item>
/// <item>
/// <b>Signed integers ride as two's complement in the UNSIGNED proto field.</b> A
/// <c>DataType.Int32</c> metric holding 1234 goes on the wire as <c>int_value =
/// 4294966062</c>. Encoding it "helpfully" as a positive number would silently retire the
/// driver's <c>ReinterpretSigned</c> path from the live gate.
/// </item>
/// </list>
/// <para>
/// Not thread-safe for concurrent publishes: <c>seq</c> is a single counter and interleaving
/// publishes would manufacture the very gaps the tests are measuring. Drive one node from one
/// test at a time; the NCMD-triggered rebirth is serialized against callers by
/// <see cref="_publishGate"/>.
/// </para>
/// </remarks>
public sealed class SparkplugEdgeNode : IAsyncDisposable
{
/// <summary>The well-known Sparkplug node-control metric an NCMD sets to trigger a rebirth.</summary>
public const string RebirthMetricName = "Node Control/Rebirth";
/// <summary>The Sparkplug session-token metric name, carried by NBIRTH and NDEATH.</summary>
public const string BdSeqMetricName = "bdSeq";
private const string Namespace = "spBv1.0";
private readonly SparkplugEdgeNodeOptions _options;
private readonly Action<string>? _log;
private readonly SemaphoreSlim _publishGate = new(1, 1);
/// <summary>Device id → its metric catalog. Ordered so a birth's metric order is deterministic.</summary>
private readonly ConcurrentDictionary<string, IReadOnlyList<SparkplugMetricSeed>> _devices = new(StringComparer.Ordinal);
private IMqttClient? _client;
private IReadOnlyList<SparkplugMetricSeed> _nodeMetrics = [];
private ulong _bdSeq;
private int _sessions;
private byte _seq;
private bool _seqStarted;
private int _rebirthCount;
private int _disposed;
private TaskCompletionSource<int> _rebirthSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Initializes a new simulated edge node. Does no I/O — call <see cref="ConnectAsync"/>.</summary>
/// <param name="options">Broker connection + Sparkplug identity.</param>
/// <param name="log">Optional line sink for the standalone runner's console output.</param>
public SparkplugEdgeNode(SparkplugEdgeNodeOptions options, Action<string>? log = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.GroupId);
ArgumentException.ThrowIfNullOrWhiteSpace(options.EdgeNodeId);
_options = options;
_log = log;
}
/// <summary>The Sparkplug group id.</summary>
public string GroupId => _options.GroupId;
/// <summary>The Sparkplug edge-node id.</summary>
public string EdgeNodeId => _options.EdgeNodeId;
/// <summary>The current session token — incremented on every <see cref="ConnectAsync"/>.</summary>
public ulong BdSeq => Volatile.Read(ref _bdSeq);
/// <summary>The <c>seq</c> of the most recently published sequenced message.</summary>
public byte LastSeq => Volatile.Read(ref _seq);
/// <summary>How many rebirth NCMDs this node has honoured since construction.</summary>
public int RebirthCount => Volatile.Read(ref _rebirthCount);
/// <summary>Whether the underlying MQTT client currently holds a session.</summary>
public bool IsConnected => _client?.IsConnected ?? false;
/// <summary>The device ids this node currently publishes for.</summary>
public IReadOnlyCollection<string> Devices => [.. _devices.Keys];
/// <summary>
/// Replaces this node's own (NBIRTH-level) metric catalog. Takes effect at the next birth —
/// which is exactly how an edge node reassigns an alias across a rebirth.
/// </summary>
/// <param name="metrics">The catalog.</param>
public void SetNodeMetrics(params SparkplugMetricSeed[] metrics) =>
_nodeMetrics = [.. metrics ?? []];
/// <summary>Replaces one device's (DBIRTH-level) metric catalog. Takes effect at the next birth.</summary>
/// <param name="deviceId">The device id.</param>
/// <param name="metrics">The catalog.</param>
public void SetDeviceMetrics(string deviceId, params SparkplugMetricSeed[] metrics)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
_devices[deviceId] = [.. metrics ?? []];
}
/// <summary>
/// Connects to the broker with an NDEATH Last Will registered for this session, and subscribes
/// the node's NCMD topic so a rebirth request can be honoured.
/// </summary>
/// <remarks>
/// <b>The Will is registered here or not at all.</b> MQTT fixes a client's will at CONNECT; a
/// "death message" published later by the client itself is a different thing entirely and would
/// never fire for the case that matters — the node dying without getting to say so.
/// </remarks>
/// <param name="cancellationToken">Cancellation for the connect.</param>
/// <returns>A task that completes when the session is established and NCMD is subscribed.</returns>
public async Task ConnectAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
await DropClientAsync(graceful: true).ConfigureAwait(false);
// A NEW session ⇒ a new session token, starting at 0 and incrementing per CONNECT. Both the
// Will registered below and the next NBIRTH carry it, which is the pairing a host uses to
// discard a previous session's late-delivered will.
Volatile.Write(ref _bdSeq, (ulong)(Interlocked.Increment(ref _sessions) - 1));
var client = new MqttClientFactory().CreateMqttClient();
client.ApplicationMessageReceivedAsync += OnMessageAsync;
var clientId = _options.ClientId is { Length: > 0 } id ? id : $"sim-{Guid.NewGuid():N}";
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(_options.Host, _options.Port)
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
.WithCleanSession(true)
.WithClientId(clientId)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
.WithTimeout(TimeSpan.FromSeconds(_options.TimeoutSeconds))
// NDEATH: QoS 1, retain false, no seq, carries this session's bdSeq. Sparkplug B v3.0 §6.
.WithWillTopic(NodeTopic("NDEATH"))
.WithWillPayload(BuildDeathPayload(CurrentBdSeq()).ToByteArray())
.WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
.WithWillRetain(false);
if (!string.IsNullOrEmpty(_options.Username))
{
builder = builder.WithCredentials(_options.Username, _options.Password ?? string.Empty);
}
builder = builder.WithTlsOptions(ConfigureTls);
_client = client;
await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);
var subscribe = new MqttClientSubscribeOptionsBuilder()
.WithTopicFilter(f => f
.WithTopic(NodeTopic("NCMD"))
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
.Build();
await client.SubscribeAsync(subscribe, cancellationToken).ConfigureAwait(false);
Log($"connected as '{clientId}' (bdSeq {CurrentBdSeq()}), listening on {NodeTopic("NCMD")}");
}
/// <summary>
/// Publishes the node's birth certificate — NBIRTH at <c>seq = 0</c>, then one DBIRTH per
/// device continuing the same sequence.
/// </summary>
/// <param name="cancellationToken">Cancellation for the publishes.</param>
/// <returns>A task that completes when every birth message has been published.</returns>
public async Task BirthAsync(CancellationToken cancellationToken)
{
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await BirthCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_publishGate.Release();
}
}
/// <summary>
/// Publishes an NDATA carrying the named node-level metrics, encoded the way a real edge node
/// does: <b>alias only</b>, resolved from the current birth catalog.
/// </summary>
/// <param name="values">Metric name → new value. A name not in the catalog throws.</param>
/// <param name="cancellationToken">Cancellation for the publish.</param>
/// <returns>A task that completes when the message has been published.</returns>
public Task PublishNodeDataAsync(
IReadOnlyList<(string Metric, object? Value)> values,
CancellationToken cancellationToken) =>
PublishDataAsync(device: null, values, cancellationToken);
/// <summary>Publishes a DDATA for one device — alias only, same sequence as the node's own stream.</summary>
/// <param name="deviceId">The device id.</param>
/// <param name="values">Metric name → new value.</param>
/// <param name="cancellationToken">Cancellation for the publish.</param>
/// <returns>A task that completes when the message has been published.</returns>
public Task PublishDeviceDataAsync(
string deviceId,
IReadOnlyList<(string Metric, object? Value)> values,
CancellationToken cancellationToken) =>
PublishDataAsync(deviceId, values, cancellationToken);
/// <summary>
/// Publishes an NDATA whose metrics carry an alias the current birth does <b>not</b> declare —
/// the "unknown alias" arm of §3.6.
/// </summary>
/// <param name="alias">The undeclared alias.</param>
/// <param name="value">A double value, encoded as <c>double_value</c>.</param>
/// <param name="cancellationToken">Cancellation for the publish.</param>
/// <returns>A task that completes when the message has been published.</returns>
public async Task PublishUnknownAliasDataAsync(ulong alias, double value, CancellationToken cancellationToken)
{
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var payload = NewSequencedPayload();
payload.Metrics.Add(new Payload.Types.Metric
{
Alias = alias,
Timestamp = NowMs(),
DoubleValue = value,
});
await PublishAsync(NodeTopic("NDATA"), payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
}
finally
{
_publishGate.Release();
}
}
/// <summary>
/// Burns <paramref name="count"/> sequence numbers without publishing them — the next message
/// therefore arrives with a <c>seq</c> the host was not expecting. This is how a lost message
/// looks from the receiving end, produced deterministically instead of by hoping for packet loss.
/// </summary>
/// <param name="count">How many sequence numbers to skip; must be at least 1.</param>
public void SkipSeq(int count = 1)
{
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1);
for (var i = 0; i < count; i++)
{
NextSeq();
}
}
/// <summary>
/// Publishes an NDEATH <b>directly</b>, carrying <paramref name="bdSeq"/>. Two uses: a stale
/// token (a previous session's will, delivered late) which a correct host must ignore, and the
/// current token, which it must act on.
/// </summary>
/// <param name="bdSeq">The session token to stamp; <see langword="null"/> uses the current one.</param>
/// <param name="cancellationToken">Cancellation for the publish.</param>
/// <returns>A task that completes when the message has been published.</returns>
/// <remarks>
/// A real NDEATH is broker-issued (see <see cref="KillAsync"/>); this is the deterministic
/// equivalent for the ordering case a test cannot otherwise stage — a will that arrives
/// <i>after</i> the reconnect's NBIRTH.
/// </remarks>
public async Task PublishNodeDeathAsync(ulong? bdSeq, CancellationToken cancellationToken)
{
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// QoS 1 and NO seq — an NDEATH is not a member of the node's sequence.
await PublishAsync(
NodeTopic("NDEATH"),
BuildDeathPayload(bdSeq ?? CurrentBdSeq()),
qos: 1,
cancellationToken).ConfigureAwait(false);
}
finally
{
_publishGate.Release();
}
}
/// <summary>Publishes a DDEATH for one device — sequenced, unlike an NDEATH.</summary>
/// <param name="deviceId">The device id.</param>
/// <param name="cancellationToken">Cancellation for the publish.</param>
/// <returns>A task that completes when the message has been published.</returns>
public async Task PublishDeviceDeathAsync(string deviceId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await PublishAsync(
DeviceTopic("DDEATH", deviceId),
NewSequencedPayload(),
_options.DataQos,
cancellationToken).ConfigureAwait(false);
}
finally
{
_publishGate.Release();
}
}
/// <summary>
/// Ends the session in a way that makes the <b>broker</b> publish this node's registered
/// NDEATH Will — the realistic death path.
/// </summary>
/// <param name="cancellationToken">Cancellation for the disconnect.</param>
/// <returns>A task that completes once the client has gone.</returns>
/// <remarks>
/// Uses the MQTT 5 <c>DISCONNECT</c> reason code <c>0x04 Disconnect with Will Message</c>, which
/// is the protocol's own "I am going away, publish my will" signal. Deliberately chosen over
/// yanking the socket: both produce the will, but only this one does so <i>deterministically</i>
/// and immediately — a killed socket leaves the broker waiting out the keep-alive, which would
/// make the death test depend on a 30-second timer.
/// </remarks>
public async Task KillAsync(CancellationToken cancellationToken)
{
var client = _client;
if (client is null)
{
return;
}
var options = new MqttClientDisconnectOptionsBuilder()
.WithReason(MqttClientDisconnectOptionsReason.DisconnectWithWillMessage)
.Build();
try
{
await client.DisconnectAsync(options, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Log($"kill: disconnect threw ({ex.GetType().Name}); the socket is gone either way");
}
client.ApplicationMessageReceivedAsync -= OnMessageAsync;
client.Dispose();
_client = null;
Log($"killed session (bdSeq {CurrentBdSeq()}); the broker owns the NDEATH now");
}
/// <summary>
/// Awaits the <paramref name="count"/>-th honoured rebirth NCMD. Pre-checks what has already
/// happened, so a caller that registers after the command landed is served immediately.
/// </summary>
/// <param name="count">The rebirth count to wait for.</param>
/// <param name="timeout">Bound on the wait.</param>
/// <param name="cancellationToken">Caller cancellation.</param>
/// <returns>The rebirth count once it has reached <paramref name="count"/>.</returns>
public async Task<int> WaitForRebirthAsync(int count, TimeSpan timeout, CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow + timeout;
while (true)
{
var observed = RebirthCount;
if (observed >= count)
{
return observed;
}
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
throw new TimeoutException(
$"Simulated edge node '{GroupId}/{EdgeNodeId}' honoured {observed} rebirth request(s) "
+ $"within {timeout.TotalSeconds:0}s; expected at least {count}. Is the driver's "
+ "requestRebirthOnGap on, and is the node's NCMD subscription live?");
}
var signal = Volatile.Read(ref _rebirthSignal);
try
{
await signal.Task.WaitAsync(remaining, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
// Loop; the deadline check above produces the diagnostic message.
}
}
}
/// <summary>
/// The standalone (container) loop: birth once, then publish node + device data every
/// <paramref name="interval"/> until cancelled. Rebirth NCMDs are honoured throughout.
/// </summary>
/// <param name="interval">Publish cadence.</param>
/// <param name="cancellationToken">Stops the loop.</param>
/// <returns>A task that completes when the loop is cancelled.</returns>
public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
{
await BirthAsync(cancellationToken).ConfigureAwait(false);
var tick = 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
tick++;
try
{
await PublishNodeDataAsync(
[.. _nodeMetrics
.Where(m => m.Name is not BdSeqMetricName and not RebirthMetricName)
.Select(m => (m.Name, Advance(m, tick)))],
cancellationToken).ConfigureAwait(false);
foreach (var (device, metrics) in _devices)
{
await PublishDeviceDataAsync(
device,
[.. metrics.Select(m => (m.Name, Advance(m, tick)))],
cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log($"publish failed: {ex.GetType().Name}: {ex.Message}");
}
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
await DropClientAsync(graceful: true).ConfigureAwait(false);
_publishGate.Dispose();
}
// ---- publishing ----
private async Task BirthCoreAsync(CancellationToken cancellationToken)
{
// An NBIRTH restarts the sequence: seq = 0, by spec, on the birth itself.
Volatile.Write(ref _seq, 0);
_seqStarted = true;
var nbirth = new Payload { Timestamp = NowMs(), Seq = 0UL };
// bdSeq first, as Tahu's own reference edge node emits it: it is the session token the
// matching NDEATH will carry.
nbirth.Metrics.Add(new Payload.Types.Metric
{
Name = BdSeqMetricName,
Datatype = (uint)DataType.Int64,
Timestamp = NowMs(),
LongValue = CurrentBdSeq(),
});
// Every conformant edge node declares the rebirth control point in its NBIRTH.
nbirth.Metrics.Add(new Payload.Types.Metric
{
Name = RebirthMetricName,
Datatype = (uint)DataType.Boolean,
Timestamp = NowMs(),
BooleanValue = false,
});
foreach (var metric in _nodeMetrics)
{
nbirth.Metrics.Add(EncodeBirthMetric(metric));
}
await PublishAsync(NodeTopic("NBIRTH"), nbirth, _options.DataQos, cancellationToken).ConfigureAwait(false);
foreach (var (device, metrics) in _devices)
{
// A DBIRTH is a sequenced member of the edge node's stream — it does NOT restart seq, and
// it carries no bdSeq.
var dbirth = NewSequencedPayload();
foreach (var metric in metrics)
{
dbirth.Metrics.Add(EncodeBirthMetric(metric));
}
await PublishAsync(DeviceTopic("DBIRTH", device), dbirth, _options.DataQos, cancellationToken)
.ConfigureAwait(false);
}
Log($"birthed (bdSeq {CurrentBdSeq()}, {_nodeMetrics.Count} node metric(s), {_devices.Count} device(s))");
}
private async Task PublishDataAsync(
string? device,
IReadOnlyList<(string Metric, object? Value)> values,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(values);
var catalog = device is null
? _nodeMetrics
: _devices.TryGetValue(device, out var found) ? found : [];
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var payload = NewSequencedPayload();
foreach (var (name, value) in values)
{
var seed = catalog.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.Ordinal))
?? throw new InvalidOperationException(
$"Metric '{name}' is not in the current birth catalog for "
+ $"'{GroupId}/{EdgeNodeId}{(device is null ? "" : "/" + device)}'. A DATA message can only "
+ "carry metrics a birth declared — that is the whole point of the alias table.");
payload.Metrics.Add(EncodeDataMetric(seed.WithValue(value)));
}
var topic = device is null ? NodeTopic("NDATA") : DeviceTopic("DDATA", device);
await PublishAsync(topic, payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
}
finally
{
_publishGate.Release();
}
}
private async Task PublishAsync(string topic, Payload payload, int qos, CancellationToken cancellationToken)
{
var client = _client ?? throw new InvalidOperationException(
$"Simulated edge node '{GroupId}/{EdgeNodeId}' is not connected; call ConnectAsync first.");
var message = new MqttApplicationMessageBuilder()
.WithTopic(topic)
.WithPayload(payload.ToByteArray())
.WithQualityOfServiceLevel((MqttQualityOfServiceLevel)Math.Clamp(qos, 0, 2))
.WithRetainFlag(false) // Sparkplug never retains birth or data messages.
.Build();
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
await client.PublishAsync(message, deadline.Token).ConfigureAwait(false);
}
// ---- inbound NCMD ----
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
{
try
{
if (!IsRebirthCommand(args.ApplicationMessage))
{
return;
}
var honoured = Interlocked.Increment(ref _rebirthCount);
Log($"rebirth NCMD #{honoured} received; republishing metadata");
await _publishGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
try
{
await BirthCoreAsync(CancellationToken.None).ConfigureAwait(false);
}
finally
{
_publishGate.Release();
}
// Swap the signal AFTER the birth is on the wire, so a waiter that wakes on it can
// immediately assert against the republished state rather than racing it.
var previous = Interlocked.Exchange(
ref _rebirthSignal,
new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously));
previous.TrySetResult(honoured);
}
catch (Exception ex)
{
// This runs on MQTTnet's dispatcher thread; a throw here stalls delivery for the client.
Log($"NCMD handling failed: {ex.GetType().Name}: {ex.Message}");
}
}
private bool IsRebirthCommand(MqttApplicationMessage message)
{
if (!string.Equals(message.Topic, NodeTopic("NCMD"), StringComparison.Ordinal))
{
return false;
}
var body = message.Payload;
var bytes = body.IsSingleSegment ? body.FirstSpan.ToArray() : body.ToArray();
Payload decoded;
try
{
decoded = Payload.Parser.ParseFrom(bytes);
}
catch (InvalidProtocolBufferException)
{
Log("NCMD payload was not a Sparkplug payload; ignored");
return false;
}
foreach (var metric in decoded.Metrics)
{
if (string.Equals(metric.Name, RebirthMetricName, StringComparison.Ordinal)
&& metric.ValueCase == Payload.Types.Metric.ValueOneofCase.BooleanValue
&& metric.BooleanValue)
{
return true;
}
}
return false;
}
// ---- encoding ----
/// <summary>
/// A birth metric: name, alias, datatype and value — everything a host needs to build its alias
/// table.
/// </summary>
private static Payload.Types.Metric EncodeBirthMetric(SparkplugMetricSeed seed)
{
var metric = new Payload.Types.Metric
{
Name = seed.Name,
Alias = seed.Alias,
Datatype = (uint)seed.DataType,
Timestamp = NowMs(),
};
ApplyValue(metric, seed.DataType, seed.Value);
return metric;
}
/// <summary>
/// A DATA metric: <b>alias only</b>. No name, no datatype — which is how a real edge node
/// publishes after a birth, and the shape that makes bind-by-name load-bearing on the host.
/// </summary>
private static Payload.Types.Metric EncodeDataMetric(SparkplugMetricSeed seed)
{
var metric = new Payload.Types.Metric
{
Alias = seed.Alias,
Timestamp = NowMs(),
};
ApplyValue(metric, seed.DataType, seed.Value);
return metric;
}
/// <summary>
/// Writes a value into the metric's <c>oneof</c>, in the field Sparkplug's datatype dictates.
/// </summary>
/// <remarks>
/// <b>Signed integers go on the wire as two's complement in the UNSIGNED field</b> — Int8/16/32
/// in <c>int_value</c>, Int64 in <c>long_value</c>. This is the encoding a host has to undo, and
/// writing a negative value any other way would quietly retire that code path from the gate.
/// </remarks>
private static void ApplyValue(Payload.Types.Metric metric, DataType dataType, object? value)
{
if (value is null)
{
// An explicit null, not an omitted value: the two mean different things, and only the
// explicit form says "this metric exists and currently has no value".
metric.IsNull = true;
return;
}
switch (dataType)
{
case DataType.Int8:
metric.IntValue = unchecked((uint)(sbyte)Convert.ToSByte(value, CultureInfo.InvariantCulture));
break;
case DataType.Int16:
metric.IntValue = unchecked((uint)Convert.ToInt16(value, CultureInfo.InvariantCulture));
break;
case DataType.Int32:
metric.IntValue = unchecked((uint)Convert.ToInt32(value, CultureInfo.InvariantCulture));
break;
case DataType.Int64:
metric.LongValue = unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
break;
case DataType.Uint8:
case DataType.Uint16:
case DataType.Uint32:
metric.IntValue = Convert.ToUInt32(value, CultureInfo.InvariantCulture);
break;
case DataType.Uint64:
metric.LongValue = Convert.ToUInt64(value, CultureInfo.InvariantCulture);
break;
case DataType.Float:
metric.FloatValue = Convert.ToSingle(value, CultureInfo.InvariantCulture);
break;
case DataType.Double:
metric.DoubleValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
break;
case DataType.Boolean:
metric.BooleanValue = Convert.ToBoolean(value, CultureInfo.InvariantCulture);
break;
case DataType.DateTime:
metric.LongValue = value is DateTime dt
? (ulong)new DateTimeOffset(dt.ToUniversalTime()).ToUnixTimeMilliseconds()
: Convert.ToUInt64(value, CultureInfo.InvariantCulture);
break;
case DataType.String:
case DataType.Text:
case DataType.Uuid:
metric.StringValue = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
break;
case DataType.Bytes:
case DataType.File:
metric.BytesValue = ByteString.CopyFrom((byte[])value);
break;
default:
throw new NotSupportedException(
$"The simulator does not encode Sparkplug datatype {dataType}; v1 of the driver does "
+ "not consume it either.");
}
}
private Payload NewSequencedPayload()
{
var payload = new Payload { Timestamp = NowMs(), Seq = NextSeq() };
return payload;
}
/// <summary>Advances the wrapping 0255 sequence and returns the value to stamp.</summary>
private ulong NextSeq()
{
if (!_seqStarted)
{
// Nothing has birthed yet: start the stream at 0 rather than at 1, so a data-before-birth
// test still produces a legal-looking stream.
_seqStarted = true;
Volatile.Write(ref _seq, 0);
return 0UL;
}
var next = unchecked((byte)(Volatile.Read(ref _seq) + 1));
Volatile.Write(ref _seq, next);
return next;
}
private Payload BuildDeathPayload(ulong bdSeq)
{
// NO seq: an NDEATH is the broker-published Last Will and is not a member of the node's
// sequence. Adding one here would be the single most plausible-looking way to make every
// death look like a gap on the host.
var payload = new Payload { Timestamp = NowMs() };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = BdSeqMetricName,
Datatype = (uint)DataType.Int64,
Timestamp = NowMs(),
LongValue = bdSeq,
});
return payload;
}
// ---- helpers ----
private ulong CurrentBdSeq() => Volatile.Read(ref _bdSeq);
private string NodeTopic(string type) => $"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}";
private string DeviceTopic(string type, string device) =>
$"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}/{device}";
private static ulong NowMs() => (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
/// <summary>Nudges a seed's value so the standalone loop publishes something that visibly moves.</summary>
private static object? Advance(SparkplugMetricSeed seed, int tick) => seed.DataType switch
{
DataType.Float => 20f + (tick % 50) / 10f,
DataType.Double => 100d + (tick % 100) / 10d,
DataType.Int32 or DataType.Int16 or DataType.Int8 => -tick,
DataType.Int64 => (long)tick,
DataType.Uint8 or DataType.Uint16 or DataType.Uint32 or DataType.Uint64 => (uint)tick,
DataType.Boolean => tick % 2 == 0,
DataType.String or DataType.Text or DataType.Uuid => $"tick-{tick}",
_ => seed.Value,
};
private void ConfigureTls(MqttClientTlsOptionsBuilder tls)
{
if (!_options.UseTls)
{
tls.UseTls(false);
return;
}
tls.UseTls(true).WithTargetHost(_options.Host);
if (_options.AllowUntrustedServerCertificate)
{
tls.WithAllowUntrustedCertificates(true)
.WithIgnoreCertificateChainErrors(true)
.WithCertificateValidationHandler(static _ => true);
return;
}
if (string.IsNullOrWhiteSpace(_options.CaCertificatePath))
{
return; // OS trust store.
}
var path = _options.CaCertificatePath;
var roots = new Lazy<X509Certificate2Collection?>(
() =>
{
try
{
var collection = new X509Certificate2Collection();
collection.ImportFromPemFile(path);
return collection.Count == 0 ? null : collection;
}
catch (Exception)
{
return null;
}
},
LazyThreadSafetyMode.ExecutionAndPublication);
tls.WithCertificateValidationHandler(args =>
{
if (roots.Value is not { Count: > 0 } trusted || args.Certificate is null)
{
return false; // Fail closed, exactly as the driver's own pin does.
}
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.AddRange(trusted);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
var presented = args.Certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
return chain.Build(presented);
});
}
private async Task DropClientAsync(bool graceful)
{
var client = _client;
_client = null;
if (client is null)
{
return;
}
client.ApplicationMessageReceivedAsync -= OnMessageAsync;
if (graceful)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
}
catch (Exception)
{
// The broker may already be gone; teardown is best-effort.
}
}
client.Dispose();
}
private void Log(string message) => _log?.Invoke($"[{GroupId}/{EdgeNodeId}] {message}");
}
@@ -0,0 +1,58 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
/// <summary>
/// Connection + identity settings for one simulated Sparkplug B edge node.
/// </summary>
/// <remarks>
/// Deliberately its own shape rather than a reuse of the driver's <c>MqttDriverOptions</c>: an edge
/// node is the driver's <i>counterparty</i>, and reusing the driver's options record would drag the
/// driver assembly into this project and blur which side of the wire a setting belongs to. The TLS
/// knobs mirror the driver's by name so a rig configured for one is configured for the other.
/// </remarks>
public sealed record SparkplugEdgeNodeOptions
{
/// <summary>Broker hostname or IP.</summary>
public string Host { get; init; } = "localhost";
/// <summary>Broker TCP port.</summary>
public int Port { get; init; } = 8883;
/// <summary>Connect over TLS.</summary>
public bool UseTls { get; init; } = true;
/// <summary>
/// PEM CA file pinning the broker chain. <see langword="null"/>/empty falls back to the OS
/// trust store (or to <see cref="AllowUntrustedServerCertificate"/> when that is set).
/// </summary>
public string? CaCertificatePath { get; init; }
/// <summary>Accept any broker certificate. Fixture escape hatch; never a deployment posture.</summary>
public bool AllowUntrustedServerCertificate { get; init; }
/// <summary>Broker username. The fixture broker runs <c>allow_anonymous false</c>.</summary>
public string? Username { get; init; }
/// <summary>Broker password.</summary>
public string? Password { get; init; }
/// <summary>The Sparkplug group id this node publishes under.</summary>
public string GroupId { get; init; } = "Plant1";
/// <summary>The Sparkplug edge-node id.</summary>
public string EdgeNodeId { get; init; } = "EdgeA";
/// <summary>
/// MQTT client id. Left unset a unique one is generated — two simulated nodes sharing a client
/// id would knock each other off the broker.
/// </summary>
public string? ClientId { get; init; }
/// <summary>Bounded connect/publish deadline, in seconds.</summary>
public int TimeoutSeconds { get; init; } = 15;
/// <summary>
/// QoS for NBIRTH/DBIRTH/NDATA/DDATA/DDEATH. Sparkplug B publishes all of these at QoS 0
/// (only NDEATH — the broker-issued Will — and STATE are QoS 1), which is the default here.
/// </summary>
public int DataQos { get; init; }
}
@@ -0,0 +1,32 @@
using Org.Eclipse.Tahu.Protobuf;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
/// <summary>
/// One metric in a simulated edge node's (or device's) catalog: the name a birth certificate
/// declares, the alias it binds that name to for the life of the birth, the Sparkplug datatype, and
/// the value the birth publishes.
/// </summary>
/// <param name="Name">The stable metric name — what an authored tag binds to.</param>
/// <param name="Alias">
/// The per-birth alias. <b>Mutable across rebirths by design</b>: reassigning an alias to a
/// different metric on a rebirth is legal Sparkplug and is §3.6 matrix case (i), the one that
/// silently mis-routes data in a driver that binds by alias.
/// </param>
/// <param name="DataType">The Sparkplug datatype the birth declares for this metric.</param>
/// <param name="Value">
/// The value the birth publishes. <see langword="null"/> is encoded as an explicit
/// <c>is_null</c> metric rather than as an omitted value — those mean different things on the wire.
/// </param>
public sealed record SparkplugMetricSeed(string Name, ulong Alias, DataType DataType, object? Value)
{
/// <summary>Returns this seed with a different value, leaving name/alias/type alone.</summary>
/// <param name="value">The new value.</param>
/// <returns>The updated seed.</returns>
public SparkplugMetricSeed WithValue(object? value) => this with { Value = value };
/// <summary>Returns this seed with a different alias — the alias-reuse case.</summary>
/// <param name="alias">The new alias.</param>
/// <returns>The updated seed.</returns>
public SparkplugMetricSeed WithAlias(ulong alias) => this with { Alias = alias };
}
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The Sparkplug B edge-node simulator: one engine, two consumers.
1. `SparkplugLiveTests` (the sibling IntegrationTests project) references this project and
drives `SparkplugEdgeNode` IN PROCESS, because the §3.6 matrix needs an edge node that can
be TOLD to reuse an alias, skip a seq, or die — a container cannot be commanded mid-test.
2. `Docker/docker-compose.yml`'s `sparkplug-sim` service runs the very same assembly as a
standalone always-on edge node (see `Program`), so an operator can drive the AdminUI
Sparkplug picker against live birth/data traffic.
It is therefore an Exe (a container entrypoint) that is also referenced as a library. That is
the reason it is a nested project rather than a folder of files in the test project, and the
reason the test project's csproj removes this directory from its default globs.
DEPENDENCIES ARE DELIBERATELY NARROW: `.Contracts` (for the generated Tahu `Payload` types —
the ENCODE side of the encode/decode symmetry this fixture exists to prove) plus MQTTnet. It
does NOT reference the runtime `.Driver` project, on purpose: a simulator that formatted its
topics with the driver's own `SparkplugTopic.Format` could not detect a bug in it, because
both sides of the comparison would be wrong together. The schema is shared (it IS the spec);
the framing logic is written independently here.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MQTTnet"/>
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise wire-level behavior against a real broker — the analyzer's documented intentional
case ("move the suppression into a NoWarn for the test project"). Not a resilience-dispatch
gap. Same suppression as the S7 / AB CIP integration suites. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!--
Direct reference (not merely transitive through the driver) because the tests
publish into the fixture broker with their own raw MQTTnet client — the
unauthored-topic test has to emit traffic the DRIVER never emits.
-->
<PackageReference Include="MQTTnet"/>
</ItemGroup>
<!--
SparkplugSimulator/ is its OWN project (an Exe: it is also the `sparkplug-sim` fixture
container's entrypoint — see its csproj banner), nested here because the live tests are its
other consumer. The SDK's default globs would otherwise compile its sources into this
assembly as well, so they are removed and the project is referenced instead.
-->
<ItemGroup>
<Compile Remove="SparkplugSimulator\**"/>
<None Remove="SparkplugSimulator\**"/>
<EmbeddedResource Remove="SparkplugSimulator\**"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<!--
The address-picker browser, for the Sparkplug browse legs: the passive-window guarantee
("opening a picker against a live plant publishes nothing") is only worth asserting
against a real broker, where the assertion can be made on the WIRE rather than on the
session's own publish counter.
-->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
<ProjectReference Include="SparkplugSimulator\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj"/>
</ItemGroup>
<!--
Docker/ is DELIBERATELY not copied to the output directory (the sibling Modbus /
S7 suites copy theirs because their tests read simulator profile JSONs at
runtime; nothing here does). Copying it would also drag Docker/secrets/ — the
generated broker password hash and the CA private key — into bin/, i.e. out of
the one directory .gitignore protects.
-->
</Project>
@@ -0,0 +1,393 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="AliasTable"/> + <see cref="BirthCache"/> (Task 18) — design doc §3.6
/// invariants #1 and #2.
/// </summary>
/// <remarks>
/// <para>
/// <b>What is actually being pinned here.</b> Sparkplug's alias mechanism exists to shrink DATA
/// payloads: a BIRTH declares <c>name ↔ alias</c> and every subsequent DATA metric carries only
/// the alias. The alias is scoped to <i>one birth</i> — after a rebirth an edge node is free to
/// assign alias 5 to an entirely different metric. If this driver merged alias tables across
/// births, or bound authored tags to aliases rather than to the stable metric name, a rebirth
/// would silently start routing Pressure readings into a Temperature tag: <b>good quality,
/// plausible values, completely wrong</b>. It is invisible without a test that specifically
/// reuses an alias across a rebirth, which is what
/// <see cref="Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias"/> and
/// <see cref="DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName"/> do.
/// </para>
/// <para>
/// The cross-scope half of the same hazard is that a device's alias table can outlive the birth
/// it came from when its <i>edge node</i> re-births — pinned by
/// <see cref="NodeBirth_InvalidatesEveryDeviceUnderThatNode"/>.
/// </para>
/// </remarks>
public sealed class AliasBindingTests
{
// ---- AliasTable: the two load-bearing invariants ----
/// <summary>
/// §3.6 invariant #1/#2, the headline case: a rebirth may point the same alias at a different
/// metric, and the table must report the <b>new</b> metric — i.e. the birth replaced the map
/// rather than merging into it.
/// </summary>
[Fact]
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
table.Resolve(5)!.Name.ShouldBe("Temperature");
// Rebirth: alias 5 now means a DIFFERENT metric.
table.RebuildFromBirth([Birth("Pressure", 5, SparkplugDataType.Float)]);
table.Resolve(5)!.Name.ShouldBe("Pressure"); // wholesale replace, not merge
table.TryResolveByName("Temperature", out _).ShouldBeFalse();
}
/// <summary>
/// §3.6 invariant #2: an alias the new birth did not declare must be <b>gone</b>, not carried
/// forward. A merge would leave alias 1 resolving to a metric the edge node no longer publishes.
/// </summary>
[Fact]
public void RebuildFromBirth_DoesNotMergeStaleAliases()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32)]);
table.RebuildFromBirth([Birth("B", 2, SparkplugDataType.Int32)]);
table.TryResolve(1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
table.TryResolve(2, out _).ShouldBeTrue();
table.Count.ShouldBe(1);
}
/// <summary>
/// The degenerate replace path: a birth carrying no usable metric still <b>clears</b> the table.
/// An implementation that short-circuits on an empty input (or merges) keeps every stale alias
/// alive behind a birth that declared none of them.
/// </summary>
[Fact]
public void RebuildFromBirth_EmptyBirth_ClearsEveryAlias()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32), Birth("B", 2, SparkplugDataType.Int32)]);
table.RebuildFromBirth([]);
table.Count.ShouldBe(0);
table.TryResolve(1, out _).ShouldBeFalse();
table.TryResolve(2, out _).ShouldBeFalse();
table.Metrics.ShouldBeEmpty();
}
/// <summary>
/// The mirror of the headline case: the <i>metric</i> stays put and its alias moves. Binding is
/// by name, so the tag keeps resolving; only the per-birth alias cache changed.
/// </summary>
[Fact]
public void Rebirth_MovesAMetricToADifferentAlias_NameBindingFollowsIt()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
table.RebuildFromBirth([Birth("Temperature", 9, SparkplugDataType.Float)]);
table.TryResolveByName("Temperature", out var byName).ShouldBeTrue();
byName!.Alias.ShouldBe(9ul);
table.Resolve(9)!.Name.ShouldBe("Temperature");
table.TryResolve(5, out _).ShouldBeFalse();
}
// ---- AliasTable: malformed births ----
/// <summary>
/// A BIRTH metric carrying an alias but no name is malformed and is <b>rejected</b>, never
/// stored alias-only: the name is the binding key, so an alias-only entry could only ever
/// resolve to a binding no authored tag can match — or, worse, match a tag whose authored
/// metric name happens to be blank.
/// </summary>
[Fact]
public void BirthMetricWithAnAliasButNoName_IsRejected_NotStoredAliasOnly()
{
var table = new AliasTable();
var result = table.RebuildFromBirth([Birth(null, 5, SparkplugDataType.Float), Birth(" ", 6, SparkplugDataType.Float)]);
result.Accepted.ShouldBe(0);
result.RejectedUnnamed.ShouldBe(2);
result.HasAnomaly.ShouldBeTrue();
table.TryResolve(5, out _).ShouldBeFalse();
table.TryResolve(6, out _).ShouldBeFalse();
}
/// <summary>
/// Aliases are optional in Sparkplug — a publisher may declare a metric by name alone and then
/// send DATA carrying the name. Such a metric is a legitimate binding; it simply never appears
/// in the alias index.
/// </summary>
[Fact]
public void BirthMetricWithNoAlias_IsBindableByName_AndAbsentFromTheAliasIndex()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", null, SparkplugDataType.Float)]);
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
binding!.Alias.ShouldBeNull();
table.Metrics.Count.ShouldBe(1);
}
/// <summary>
/// Two metrics claiming the same alias in one birth is malformed, and any resolution is a coin
/// flip between two different metrics — precisely the mis-route this whole type exists to
/// prevent. The colliding alias is dropped from the alias index (the consumer sees an unknown
/// alias and can request a rebirth) while both metrics stay bindable by their names.
/// </summary>
[Fact]
public void DuplicateAliasInOneBirth_IsUnresolvable_RatherThanGuessed()
{
var table = new AliasTable();
var result = table.RebuildFromBirth(
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Pressure", 5, SparkplugDataType.Float)]);
result.AliasCollisions.ShouldBe(1);
table.TryResolve(5, out _).ShouldBeFalse();
table.TryResolveByName("Temperature", out _).ShouldBeTrue();
table.TryResolveByName("Pressure", out _).ShouldBeTrue();
}
/// <summary>
/// Two metrics sharing a name in one birth: last-wins (the name is the binding key, so evicting
/// it would take the tag dark entirely), reported so the consumer can warn.
/// </summary>
[Fact]
public void DuplicateNameInOneBirth_IsLastWins_AndReported()
{
var table = new AliasTable();
var result = table.RebuildFromBirth(
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Temperature", 6, SparkplugDataType.Double)]);
result.DuplicateNames.ShouldBe(1);
result.Accepted.ShouldBe(1);
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
binding!.Alias.ShouldBe(6ul);
}
/// <summary>
/// A BIRTH metric with no datatype is malformed, but it is <b>kept</b> as
/// <see cref="SparkplugDataType.Unknown"/> rather than rejected: rejecting it makes every DATA
/// message for its alias an unknown-alias event, which the ingest state machine answers with a
/// rebirth request — and the edge node would answer with the same malformed birth, forever. Kept
/// as Unknown, the typed layer refuses the value once (<c>ToDriverDataType()</c> returns null)
/// and nothing storms.
/// </summary>
[Fact]
public void BirthMetricWithNoDatatype_IsKeptAsUnknown_NotRejected()
{
var table = new AliasTable();
var result = table.RebuildFromBirth([Untyped("Temperature", 5)]);
result.Accepted.ShouldBe(1);
result.RejectedUnnamed.ShouldBe(0);
table.Resolve(5)!.DataType.ShouldBe(SparkplugDataType.Unknown);
}
/// <summary>
/// The birth datatype is what makes Sparkplug's two's-complement-in-an-unsigned-field encoding
/// undoable: a DATA metric carries no datatype of its own, so the binding is the only place the
/// signedness is known. Without this, an Int32 metric holding -42 publishes as 4294967254 —
/// plausible, Good quality, invisible.
/// </summary>
[Fact]
public void Binding_Reinterpret_UndoesTwosComplement_UsingTheBirthDatatype()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Setpoint", 5, SparkplugDataType.Int32)]);
// What a DDATA metric for alias 5 carries: the raw unsigned wire field, no datatype.
var raw = unchecked((uint)-42);
table.Resolve(5)!.Reinterpret(raw).ShouldBe(-42);
}
/// <summary>A null birth sequence is a caller bug, not a birth — it must not silently clear the table.</summary>
[Fact]
public void RebuildFromBirth_Null_Throws() =>
Should.Throw<ArgumentNullException>(() => new AliasTable().RebuildFromBirth(null!));
// ---- BirthCache: scoping ----
/// <summary>
/// Sparkplug spec: an NBIRTH means the edge node (re)started, so <b>every</b> DBIRTH previously
/// published under it is invalid until re-published. Keeping a device's alias table across its
/// node's rebirth is the same stale-alias mis-route as invariant #2, one scope up.
/// </summary>
[Fact]
public void NodeBirth_InvalidatesEveryDeviceUnderThatNode()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 7, SparkplugDataType.Float)]);
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Node Control/Rebirth", 1, SparkplugDataType.Boolean)]);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
// The invalidated devices come back with their metric sets so the consumer can fan STALE out
// without a lookup against a table that has already been evicted.
outcome.InvalidatedDevices.Count.ShouldBe(2);
outcome.InvalidatedDevices.SelectMany(d => d.Metrics).Select(m => m.Name)
.ShouldBe(["Temperature", "Speed"], ignoreOrder: true);
}
/// <summary>An NBIRTH is scoped to its own edge node — another node's devices are untouched.</summary>
[Fact]
public void NodeBirth_DoesNotTouchAnotherNodesDevices()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
outcome.InvalidatedDevices.ShouldBeEmpty();
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
}
/// <summary>A DBIRTH replaces exactly one device's table; its siblings and its node keep theirs.</summary>
[Fact]
public void DeviceBirth_ReplacesOnlyThatDevicesTable()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Humidity", 5, SparkplugDataType.Float)]);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Humidity");
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2"))!.Resolve(5)!.Name.ShouldBe("Speed");
cache.Find(new SparkplugScope("Plant1", "EdgeA", null))!.Resolve(1)!.Name.ShouldBe("Uptime");
}
/// <summary>
/// Key completeness. A cache keyed on the device id alone — or on <c>node/device</c> without the
/// group — would let one plant's <c>Filler1</c> answer for another's, which is the same
/// wrong-but-plausible mis-route as an alias collision.
/// </summary>
[Fact]
public void Scopes_AreDistinctAcrossGroupsAndNodesWithTheSameDeviceId()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant2", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
cache.Count.ShouldBe(3);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Temperature");
cache.Find(new SparkplugScope("Plant2", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Pressure");
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Speed");
}
/// <summary>
/// §3.6 invariant #4's data half: an NDEATH takes the node and everything under it out of the
/// cache and hands back the metric sets, which is what the consumer fans STALE out over.
/// </summary>
[Fact]
public void ForgetNode_RemovesNodeAndDevices_AndReturnsTheirMetricsForStaleFanOut()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
var removed = cache.ForgetNode("Plant1", "EdgeA");
removed.SelectMany(r => r.Metrics).Select(m => m.Name).ShouldBe(["Uptime", "Temperature"], ignoreOrder: true);
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
}
/// <summary>A DDEATH takes out exactly one device and leaves its node's own metrics live.</summary>
[Fact]
public void TryForgetDevice_RemovesOnlyThatDevice_AndReturnsItsMetrics()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out var removed).ShouldBeTrue();
removed.Metrics.Select(m => m.Name).ShouldBe(["Temperature"]);
removed.Scope.DeviceId.ShouldBe("Filler1");
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeFalse();
}
/// <summary>
/// After a death the scope is <b>absent</b>, not merely empty — that is what makes DATA arriving
/// before the next birth a data-before-birth event (⇒ rebirth request) rather than a silent
/// unknown-alias drop against a table that still looks alive.
/// </summary>
[Fact]
public void Find_AfterDeath_IsNull_SoDataBeforeRebirthIsNotResolvable()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeTrue();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Count.ShouldBe(0);
}
/// <summary>
/// Invariant #1 end-to-end through the cache: the alias-reuse mis-route must not sneak back in
/// through the scoped path the ingest state machine actually calls.
/// </summary>
[Fact]
public void DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName()
{
var cache = new BirthCache();
var scope = new SparkplugScope("Plant1", "EdgeA", "Filler1");
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
cache.Find(scope)!.Resolve(5)!.Name.ShouldBe("Pressure");
cache.Find(scope)!.TryResolveByName("Temperature", out _).ShouldBeFalse();
}
/// <summary>Builds a BIRTH metric the way <see cref="SparkplugCodec"/> projects one.</summary>
private static SparkplugMetric Birth(string? name, ulong? alias, SparkplugDataType dataType, object? value = null) =>
new(
Name: name,
Alias: alias,
DataType: dataType,
TimestampMs: null,
ValueKind: value is null ? SparkplugValueKind.Absent : SparkplugValueKind.Scalar,
Value: value);
/// <summary>Builds a malformed BIRTH metric that declares no datatype at all.</summary>
private static SparkplugMetric Untyped(string name, ulong alias) =>
new(Name: name, Alias: alias, DataType: null, TimestampMs: null, ValueKind: SparkplugValueKind.Absent, Value: null);
}
@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
public sealed class LastValueCacheTests
{
// OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value
// observed yet" (mirrored by CalculationDriver, VirtualTagEngine, FOCAS, and
// OtOpcUaNodeManager for freshly-materialised / not-yet-evaluated nodes). MQTT is
// subscribe-first, so an unseen RawPath is in exactly that state until its first publish.
private const uint BadWaitingForInitialData = 0x80320000u;
[Fact]
public void Read_UnseenRef_ReturnsPerRefWaitingForInitialData_DoesNotThrow()
{
var cache = new LastValueCache();
var snap = cache.Read("factory/oven/temp");
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
snap.Value.ShouldBeNull();
}
[Fact]
public void Update_ThenRead_ReturnsLastValue()
{
var cache = new LastValueCache();
var now = DateTime.UtcNow;
cache.Update("factory/oven/temp", new DataValueSnapshot(42.0, 0u, now, now));
var snap = cache.Read("factory/oven/temp");
snap.Value.ShouldBe(42.0);
snap.StatusCode.ShouldBe(0u);
}
[Fact]
public void Update_OverwritesPreviousValue_ForSameRawPath()
{
var cache = new LastValueCache();
var t1 = DateTime.UtcNow;
var t2 = t1.AddSeconds(1);
cache.Update("factory/oven/temp", new DataValueSnapshot(1.0, 0u, t1, t1));
cache.Update("factory/oven/temp", new DataValueSnapshot(2.0, 0u, t2, t2));
cache.Read("factory/oven/temp").Value.ShouldBe(2.0);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void Read_NullOrEmptyRawPath_DoesNotThrow_ReturnsWaitingForInitialData(string? rawPath)
{
var cache = new LastValueCache();
var snap = cache.Read(rawPath!);
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
}
[Fact]
public void Update_NullRawPath_DoesNotThrow()
{
var cache = new LastValueCache();
Should.NotThrow(() => cache.Update(null!, new DataValueSnapshot(1.0, 0u, DateTime.UtcNow, DateTime.UtcNow)));
}
[Fact]
public void Read_DistinguishesUnseenFromGenuinelyBadObservedValue()
{
var cache = new LastValueCache();
var now = DateTime.UtcNow;
const uint badDeviceFailure = 0x808B0000u;
cache.Update("factory/oven/temp", new DataValueSnapshot(null, badDeviceFailure, null, now));
var observedBad = cache.Read("factory/oven/temp");
var neverObserved = cache.Read("factory/oven/other");
observedBad.StatusCode.ShouldBe(badDeviceFailure);
neverObserved.StatusCode.ShouldBe(BadWaitingForInitialData);
observedBad.StatusCode.ShouldNotBe(neverObserved.StatusCode);
}
}
@@ -0,0 +1,189 @@
using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT with
/// a CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for MQTTnet to report
/// a real connection — which is what makes a real drop (<see cref="DropAll"/>) drive a real
/// <c>DisconnectedAsync</c> and a real reconnect, rather than a test seam standing in for one.
/// </summary>
/// <remarks>
/// Shared across suites rather than nested in one, because both the connection-level and the
/// driver-level rejection tests need it: the driver-level one is the pin for the actual
/// operator-visible symptom (a rejected CONNECT must not report <c>Healthy</c>).
/// </remarks>
internal sealed class MiniBroker : IDisposable
{
/// <summary>CONNACK "Connection Accepted" (MQTT 3.1.1 return code 0).</summary>
internal const byte ReturnCodeAccepted = 0x00;
/// <summary>CONNACK "Identifier rejected" — MQTTnet maps it to <c>ClientIdentifierNotValid</c>.</summary>
internal const byte ReturnCodeIdentifierRejected = 0x02;
/// <summary>CONNACK "Server unavailable" — MQTTnet maps it to <c>ServerUnavailable</c> (transient).</summary>
internal const byte ReturnCodeServerUnavailable = 0x03;
/// <summary>CONNACK "Bad user name or password" — MQTTnet maps it to <c>BadUserNameOrPassword</c>.</summary>
internal const byte ReturnCodeBadUserNameOrPassword = 0x04;
/// <summary>CONNACK "Not authorized" — MQTTnet maps it to <c>NotAuthorized</c> (unrecoverable).</summary>
internal const byte ReturnCodeNotAuthorized = 0x05;
private static readonly byte[] PingResp = [0xD0, 0x00];
private readonly CancellationTokenSource _cts = new();
private readonly HashSet<TcpClient> _live = [];
private readonly TcpListener _listener;
private int _acceptedCount;
public MiniBroker()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_ = Task.Run(AcceptLoopAsync);
}
public int Port { get; }
/// <summary>When set, TCP is still accepted but CONNECT is never answered (frozen peer).</summary>
public bool Blackhole { get; set; }
/// <summary>
/// The MQTT 3.1.1 return code answered to every CONNECT. Anything but
/// <see cref="ReturnCodeAccepted"/> is a broker that completes the handshake and then
/// <i>refuses</i> — the shape MQTTnet reports as a result rather than an exception, and the one
/// a real Mosquitto with the wrong password produces.
/// </summary>
public byte ConnAckReturnCode { get; set; } = ReturnCodeAccepted;
/// <summary>Total TCP connections accepted since construction — counts reconnect attempts.</summary>
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
/// <summary>
/// Sockets the broker still has open. Drops to zero only once the peer actually closes, so a
/// leaked-but-live client keeps this above zero.
/// </summary>
public int LiveConnections
{
get
{
lock (_live)
{
return _live.Count;
}
}
}
/// <summary>Kills every established session — the "broker went away" event.</summary>
public void DropAll()
{
lock (_live)
{
foreach (var client in _live)
{
try
{
client.Close();
}
catch (Exception)
{
// Already gone.
}
}
_live.Clear();
}
}
public void Dispose()
{
_cts.Cancel();
_listener.Stop();
DropAll();
_cts.Dispose();
}
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token);
Interlocked.Increment(ref _acceptedCount);
lock (_live)
{
_live.Add(client);
}
_ = Task.Run(() => ServeAsync(client));
}
}
catch (Exception)
{
// Listener stopped / token cancelled — the fixture is going away.
}
}
private async Task ServeAsync(TcpClient client)
{
var buffer = new byte[1024];
try
{
var stream = client.GetStream();
while (!_cts.IsCancellationRequested)
{
var read = await stream.ReadAsync(buffer, _cts.Token);
if (read == 0)
{
break; // peer closed — this is how a disposed client stops counting as live
}
if (Blackhole)
{
continue;
}
var packetType = buffer[0] & 0xF0;
if (packetType == 0x10)
{
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
// A real broker closes the socket straight after refusing, which is what drives
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
if (ConnAckReturnCode != ReturnCodeAccepted)
{
break;
}
}
else if (packetType == 0xC0)
{
await stream.WriteAsync(PingResp, _cts.Token);
}
}
}
catch (Exception)
{
// Socket torn down by either side.
}
finally
{
lock (_live)
{
_live.Remove(client);
}
try
{
client.Dispose();
}
catch (Exception)
{
// Already gone.
}
}
}
}
@@ -0,0 +1,895 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers the passive <c>#</c>-observation browse session and the browser that opens it. The
/// session is exercised through its test seam (<c>ObserveTopicForTest</c>) rather than a live
/// broker: the accumulating tree is the whole of the P1 logic, and the MQTTnet plumbing that
/// feeds it is a one-line handler.
/// </summary>
public sealed class MqttBrowseSessionTests
{
// ---------------------------------------------------------------- tree shape
[Fact]
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3/oven/temp");
var root = await s.RootAsync(TestContext.Current.CancellationToken);
root.ShouldContain(n => n.DisplayName == "factory");
var lvl2 = await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
lvl2.ShouldContain(n => n.DisplayName == "line3");
var lvl3 = await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken);
lvl3.ShouldContain(n => n.DisplayName == "oven");
var lvl4 = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken);
var leaf = lvl4.ShouldHaveSingleItem();
leaf.DisplayName.ShouldBe("temp");
leaf.NodeId.ShouldBe("factory/line3/oven/temp");
leaf.Kind.ShouldBe(BrowseNodeKind.Leaf);
leaf.HasChildrenHint.ShouldBeFalse();
}
[Fact]
public async Task ObservedTopics_AccumulateAcrossMessages_AndDoNotDuplicate()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3/oven/temp");
s.ObserveTopicForTest("factory/line3/oven/temp"); // repeat — must not duplicate
s.ObserveTopicForTest("factory/line3/oven/pressure");
s.ObserveTopicForTest("factory/line4/mixer/rpm");
s.ObserveTopicForTest("plant/power");
var root = await s.RootAsync(TestContext.Current.CancellationToken);
root.Select(n => n.DisplayName).ShouldBe(["factory", "plant"]); // sorted, deduped
var lines = await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
lines.Select(n => n.DisplayName).ShouldBe(["line3", "line4"]);
var oven = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken);
oven.Select(n => n.DisplayName).ShouldBe(["pressure", "temp"]);
}
[Fact]
public async Task IntermediateNode_IsFolder_WithChildrenHint()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3/oven/temp");
var root = await s.RootAsync(TestContext.Current.CancellationToken);
var factory = root.ShouldHaveSingleItem();
factory.Kind.ShouldBe(BrowseNodeKind.Folder);
factory.HasChildrenHint.ShouldBeTrue();
}
[Fact]
public async Task ExpandAsync_UnknownNode_ReturnsEmpty()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3");
var children = await s.ExpandAsync("nope/not/here", TestContext.Current.CancellationToken);
children.ShouldBeEmpty();
}
[Fact]
public async Task RootAsync_BeforeAnyTraffic_IsEmpty()
{
// MQTT has no discovery protocol: an observation window that has seen nothing shows nothing.
await using var s = new MqttBrowseSession(MqttMode.Plain);
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
// ---------------------------------------------------------------- read-only guarantee
[Fact]
public async Task BrowseCalls_PublishNothing() // browse is read-only
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3/oven/temp", "23.5");
await s.RootAsync(TestContext.Current.CancellationToken);
await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
await s.AttributesAsync("factory/line3/oven/temp", TestContext.Current.CancellationToken);
s.PublishCountForTest.ShouldBe(0);
}
// ---------------------------------------------------------------- attributes
[Theory]
[InlineData("23.5", nameof(Core.Abstractions.DriverDataType.Float64))]
[InlineData("42", nameof(Core.Abstractions.DriverDataType.Int64))]
[InlineData("true", nameof(Core.Abstractions.DriverDataType.Boolean))]
[InlineData("{\"v\":1}", nameof(Core.Abstractions.DriverDataType.String))]
public async Task AttributesAsync_InfersTypeFromLastPayload(string payload, string expectedType)
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/oven/temp", payload);
var attrs = await s.AttributesAsync("factory/oven/temp", TestContext.Current.CancellationToken);
var a = attrs.ShouldHaveSingleItem();
a.Name.ShouldBe("temp");
a.DriverDataType.ShouldBe(expectedType);
a.IsArray.ShouldBeFalse();
a.IsAlarm.ShouldBeFalse();
a.SecurityClass.ShouldContain(payload); // the last-payload snippet
}
[Fact]
public async Task AttributesAsync_LongPayload_IsTruncatedToASnippet()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("t/long", new string('x', 500));
var a = (await s.AttributesAsync("t/long", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1);
}
[Fact]
public async Task AttributesAsync_UnobservedFolder_ReturnsEmpty()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/oven/temp", "1");
// "factory" is a synthesised path segment — nothing was ever published to it.
(await s.AttributesAsync("factory", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
// ---------------------------------------------------------------- concurrency
[Fact]
public async Task ObserveDuringBrowse_IsThreadSafe()
{
// Messages arrive on MQTTnet's dispatcher thread while the picker calls ExpandAsync.
await using var s = new MqttBrowseSession(MqttMode.Plain);
using var stop = new CancellationTokenSource();
var observed = 0;
var writer = Task.Run(() =>
{
var i = 0;
while (!stop.Token.IsCancellationRequested)
{
s.ObserveTopicForTest($"factory/line{i % 8}/dev{i % 32}/m{i}", i.ToString());
Interlocked.Increment(ref observed);
i++;
}
}, CancellationToken.None);
// The read loop is pure in-memory work and would otherwise finish before the writer thread
// is even scheduled, leaving the two never actually overlapping.
while (Volatile.Read(ref observed) == 0) await Task.Yield();
for (var i = 0; i < 400; i++)
{
await s.RootAsync(TestContext.Current.CancellationToken);
await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken);
}
await stop.CancelAsync();
await writer; // rethrows anything the observer thread hit
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldNotBeEmpty();
}
// ---------------------------------------------------------------- lifetime
[Fact]
public async Task LastUsedUtc_AdvancesOnEveryCall()
{
var s = new MqttBrowseSession(MqttMode.Plain);
var before = s.LastUsedUtc;
await Task.Delay(15, TestContext.Current.CancellationToken);
await s.RootAsync(TestContext.Current.CancellationToken);
s.LastUsedUtc.ShouldBeGreaterThan(before);
var afterRoot = s.LastUsedUtc;
await Task.Delay(15, TestContext.Current.CancellationToken);
await s.ExpandAsync("x", TestContext.Current.CancellationToken);
s.LastUsedUtc.ShouldBeGreaterThan(afterRoot);
await s.DisposeAsync();
}
[Fact]
public async Task DisposeAsync_OnNeverOpenedSession_IsIdempotentAndDoesNotThrow()
{
var s = new MqttBrowseSession(MqttMode.Plain);
await Should.NotThrowAsync(async () => await s.DisposeAsync());
await Should.NotThrowAsync(async () => await s.DisposeAsync());
}
[Fact]
public async Task AfterDispose_BrowseCallsThrowObjectDisposed()
{
var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("a/b");
await s.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
async () => await s.RootAsync(TestContext.Current.CancellationToken));
await Should.ThrowAsync<ObjectDisposedException>(
async () => await s.ExpandAsync("a", TestContext.Current.CancellationToken));
await Should.ThrowAsync<ObjectDisposedException>(
async () => await s.AttributesAsync("a/b", TestContext.Current.CancellationToken));
}
[Fact]
public async Task ObserveAfterDispose_IsIgnored()
{
// A message already in MQTTnet's dispatcher when the reaper disposes the session.
var s = new MqttBrowseSession(MqttMode.Plain);
await s.DisposeAsync();
Should.NotThrow(() => s.ObserveTopicForTest("late/arrival", "1"));
}
// ---------------------------------------------------------------- node cap
[Fact]
public async Task NodeCap_StopsRecording_AndSurfacesATruncationMarker()
{
await using var s = new MqttBrowseSession(MqttMode.Plain, nodeCap: 4);
s.ObserveTopicForTest("a/b/c"); // 3 nodes
s.ObserveTopicForTest("d/e/f"); // would take it past 4
var root = await s.RootAsync(TestContext.Current.CancellationToken);
root.ShouldContain(n => n.NodeId == MqttBrowseSession.TruncatedNodeId);
}
[Fact]
public async Task OversizedTopic_IsRejectedWhole_AndSurfacesAMarker()
{
// A truncated topic is a DIFFERENT topic — the picker would commit it as a tag address.
// Every segment here is 4 chars, well under MaxSegmentChars, so ONLY the whole-topic bound
// can reject it — the segment guard must not be what makes this test pass.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("good/topic", "1");
var manyShortSegments = "huge/" + string.Join('/', Enumerable.Repeat("abc", MqttBrowseSession.MaxTopicChars / 4 + 8));
manyShortSegments.Length.ShouldBeGreaterThan(MqttBrowseSession.MaxTopicChars);
manyShortSegments.Split('/').ShouldAllBe(seg => seg.Length <= MqttBrowseSession.MaxSegmentChars);
s.ObserveTopicForTest(manyShortSegments, "1");
var root = await s.RootAsync(TestContext.Current.CancellationToken);
root.ShouldContain(n => n.NodeId == MqttBrowseSession.OversizedNodeId);
root.ShouldContain(n => n.DisplayName == "good");
root.ShouldNotContain(n => n.DisplayName == "huge"); // no half-built path left behind
}
[Fact]
public async Task OversizedSegment_IsRejectedWhole_EvenWhenTheTopicFits()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("a/" + new string('y', MqttBrowseSession.MaxSegmentChars + 1) + "/b", "1");
s.OversizedRejected.ShouldBeTrue();
(await s.ExpandAsync("a", TestContext.Current.CancellationToken)).ShouldBeEmpty();
var root = await s.RootAsync(TestContext.Current.CancellationToken);
root.ShouldNotContain(n => n.DisplayName == "a");
}
[Fact]
public async Task TopicAtExactlyTheCap_IsAccepted()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest(new string('z', MqttBrowseSession.MaxSegmentChars), "1");
s.OversizedRejected.ShouldBeFalse();
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
}
// ---------------------------------------------------------------- payload bounds
[Fact]
public async Task PayloadBeyondTheInspectionWindow_IsNeverExamined()
{
// The discriminating test for the byte cap: content past PayloadInspectMaxBytes cannot
// influence the result at all. With an unbounded decode the trailing scalar would be found
// and typed Int64, and would show up in the snippet.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest(
"t/tail",
new string(' ', MqttBrowseSession.PayloadInspectMaxBytes + 64) + "42");
var a = (await s.AttributesAsync("t/tail", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
a.SecurityClass.ShouldNotContain("42");
}
[Fact]
public async Task HugePayload_YieldsABoundedSnippetMarkedAsClipped()
{
// A '#' subscription receives multi-MB retained blobs; the node cap never engages for a
// large payload on an already-known topic, so the decode itself must be bounded.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("t/blob", new string('a', 5_000_000));
var a = (await s.AttributesAsync("t/blob", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1);
a.SecurityClass.ShouldEndWith("…");
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
}
[Fact]
public async Task ClippedMultiByteUtf8_IsNotMislabelledAsBinary()
{
// The inspection window can land mid-sequence; backing off to a whole sequence keeps a
// perfectly good UTF-8 payload from being reported as binary.
await using var s = new MqttBrowseSession(MqttMode.Plain);
// 3 bytes per char, so 512 / 3 leaves a partial sequence at the window edge.
s.ObserveTopicForTest("t/utf8", new string('☃', 4000));
var a = (await s.AttributesAsync("t/utf8", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.SecurityClass.ShouldStartWith("☃");
a.SecurityClass.ShouldNotContain("binary");
a.SecurityClass.ShouldNotContain(""); // no replacement char from a split sequence
}
[Fact]
public async Task BinaryPayload_IsReportedAsOpaque_WithItsTrueLength()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
var binary = new byte[2048];
Array.Fill(binary, (byte)0xC0); // never legal UTF-8
s.Observe("t/bin", binary);
var a = (await s.AttributesAsync("t/bin", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.SecurityClass.ShouldBe("(2048 bytes, binary)"); // full length, not the clipped window
}
[Fact]
public async Task EmbeddedControlCharacters_AreScrubbedToSpaces()
{
// Trim() only strips the ends; an embedded newline would break the picker's single-line row.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("t/multiline", "line one\nline two\ttabbed");
var a = (await s.AttributesAsync("t/multiline", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.SecurityClass.ShouldNotContain("\n");
a.SecurityClass.ShouldNotContain("\t");
a.SecurityClass.ShouldNotContain("");
a.SecurityClass.ShouldStartWith("line one line two tabbed");
}
// ---------------------------------------------------------------- the browser
[Fact]
public void Ctor_IsConnectionFree_AndReportsTheMqttDriverType()
{
// The universal-browser CanBrowse pattern constructs a throwaway instance purely to ask
// what driver type it handles — the ctor must never touch the network.
var browser = new MqttDriverBrowser();
browser.DriverType.ShouldBe("Mqtt");
}
[Theory]
[InlineData(null, "#")]
[InlineData("", "#")]
[InlineData(" ", "#")]
[InlineData("factory", "factory/#")]
[InlineData("factory/", "factory/#")]
[InlineData("factory/line3", "factory/line3/#")]
public void BuildRootFilter_ScopesTheWildcardToTheConfiguredPrefix(string? prefix, string expected)
{
var opts = new MqttDriverOptions
{
Mode = MqttMode.Plain,
Plain = prefix is null ? null : new MqttPlainOptions { TopicPrefix = prefix },
};
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
}
[Fact]
public void BrowseClientIdSuffix_IsUnique_AndIsAppliedToTheConnectClientId()
{
// A broker disconnects an existing client when a new one CONNECTs with the same id —
// browsing must never knock the running driver offline.
var a = MqttDriverBrowser.BuildBrowseClientIdSuffix();
var b = MqttDriverBrowser.BuildBrowseClientIdSuffix();
a.ShouldNotBe(b);
a.ShouldStartWith("-browse-");
a.Length.ShouldBe("-browse-".Length + 8);
var opts = new MqttDriverOptions { ClientId = "otopcua-node1" };
MqttConnection.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a);
}
[Fact]
public void BrowseConnect_ForcesACleanSession()
{
// A transient browse identity must not leave queued-message state behind on the broker.
var opts = new MqttDriverOptions { CleanSession = false };
MqttDriverBrowser.ToBrowseOptions(opts).CleanSession.ShouldBeTrue();
}
[Theory]
[InlineData(1, 5)] // clamped up
[InlineData(15, 15)]
[InlineData(600, 30)] // clamped down
public void OpenBudget_IsClampedTo5To30Seconds(int configured, int expected)
{
var opts = new MqttDriverOptions { ConnectTimeoutSeconds = configured };
MqttDriverBrowser.OpenBudget(opts).ShouldBe(TimeSpan.FromSeconds(expected));
}
// ------------------------------------------------------- Sparkplug: the Last-Will landmine
[Fact]
public void MqttDriverOptions_CarryNothingWillShaped_SoABrowseSessionCanNeverFireAnNdeath()
{
// THE landmine (see the marked block on MqttDriverBrowser.ToBrowseOptions). A Sparkplug
// NDEATH is a Last Will: it is published by the BROKER, not by us, so it is invisible to
// PublishCountForTest — and any ungraceful end to a browse session would fire an NDEATH
// under the plant's real edge-node identity. ToBrowseOptions is where a will must be
// stripped; today there is nothing to strip, and this test is what makes that a checked
// fact rather than a claim: adding a will-shaped property anywhere in the options graph
// reddens it and forces the author to handle it in ToBrowseOptions.
static IEnumerable<string> Names(Type t) => t.GetProperties().Select(p => p.Name);
var suspects = Names(typeof(MqttDriverOptions))
.Concat(Names(typeof(MqttSparkplugOptions)))
.Concat(Names(typeof(MqttPlainOptions)))
.Where(n => n.Contains("will", StringComparison.OrdinalIgnoreCase)
|| n.Contains("testament", StringComparison.OrdinalIgnoreCase)
|| n.Contains("death", StringComparison.OrdinalIgnoreCase))
.ToList();
suspects.ShouldBeEmpty();
}
// ------------------------------------------------------- Sparkplug: birth-driven tree
[Fact]
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var groups = await s.RootAsync(TestContext.Current.CancellationToken);
groups.ShouldContain(n => n.DisplayName == "Plant1");
var nodes = await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken);
nodes.ShouldContain(n => n.DisplayName == "EdgeA");
var devices = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
var device = devices.ShouldHaveSingleItem();
device.DisplayName.ShouldBe("Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
var metrics = await s.ExpandAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken);
var metric = metrics.ShouldHaveSingleItem();
metric.DisplayName.ShouldBe("Temperature");
metric.Kind.ShouldBe(BrowseNodeKind.Leaf);
metric.NodeId.ShouldBe("Plant1/EdgeA/Filler1::Temperature");
await s.AttributesAsync(metric.NodeId, TestContext.Current.CancellationToken);
s.PublishCountForTest.ShouldBe(0); // passive — browsing a live plant broker publishes nothing
}
[Fact]
public async Task SparkplugTree_NodeLevelMetrics_HangDirectlyUnderTheEdgeNode()
{
// An NBIRTH metric has no device. It is NOT parked under a synthesised device folder —
// a fake segment would be indistinguishable from a real device of the same name, and the
// authored address tuple carries deviceId = null for exactly these.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", device: null, "Node Control/Rebirth", SparkplugDataType.Boolean);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var underEdge = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
underEdge.Select(n => n.DisplayName).ShouldBe(["Filler1", "Node Control/Rebirth"]);
var device = underEdge.Single(n => n.DisplayName == "Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
// A metric name may itself contain '/'; it stays ONE leaf (the metric name is the tag's
// stable binding key, so a split would invite committing a partial name).
var nodeMetric = underEdge.Single(n => n.DisplayName == "Node Control/Rebirth");
nodeMetric.Kind.ShouldBe(BrowseNodeKind.Leaf);
nodeMetric.NodeId.ShouldBe("Plant1/EdgeA::Node Control/Rebirth");
(await s.ExpandAsync(nodeMetric.NodeId, TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task SparkplugTree_ADeviceAndANodeMetricSharingALabel_AreBothKept()
{
// The two are siblings under the edge node and CAN share a label. They are distinct nodes with
// distinct ids, so neither may displace the other — a tree keyed by label would silently drop
// whichever arrived second, and the picker would offer a folder where a metric should be.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Temp", "Value", SparkplugDataType.Float); // device "Temp"
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Temp", SparkplugDataType.Float); // metric "Temp"
var children = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
children.Count.ShouldBe(2);
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA/Temp" && n.Kind == BrowseNodeKind.Folder);
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA::Temp" && n.Kind == BrowseNodeKind.Leaf);
(await s.AttributesAsync("Plant1/EdgeA::Temp", TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().Name.ShouldBe("Temp");
}
[Fact]
public async Task SparkplugTree_EmptyBirth_StillRendersItsDeviceAsAFolder()
{
// A DBIRTH carrying no metrics leaves a childless device node; it must not masquerade as a
// committable metric leaf.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveEmptyBirthForTest("Plant1", "EdgeA", "Filler1");
var device = (await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
device.DisplayName.ShouldBe("Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
}
[Fact]
public async Task SparkplugObserve_NonBirthMessages_BuildNoTree()
{
// Only NBIRTH/DBIRTH are self-describing. A DDATA metric carries an alias and no name at
// all, so anything built from one would be a tree of unnamed nodes.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveRawForTest("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/Plant1/NCMD/EdgeA", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/Plant1/NDEATH/EdgeA", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/STATE/host1", "ONLINE"u8.ToArray());
s.ObserveRawForTest("some/plain/topic", "23.5"u8.ToArray()); // not Sparkplug at all
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public void SparkplugObserve_GarbageBirthPayload_IsIgnoredAndNeverThrows()
{
// Runs on MQTTnet's dispatcher thread behind an unauthenticated firehose.
var s = new MqttBrowseSession(MqttMode.SparkplugB);
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", [0xFF, 0xFF, 0xFF, 0xFF]));
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", []));
}
// ------------------------------------------------------- Sparkplug: AttributesAsync
[Fact]
public async Task SparkplugAttributes_AreBirthDerived_NotPayloadSniffed()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var a = (await s.AttributesAsync(
"Plant1/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.Name.ShouldBe("Temperature");
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Float32)); // from the birth
a.IsArray.ShouldBeFalse();
a.SecurityClass.ShouldContain("DBIRTH");
a.SecurityClass.ShouldContain("Float");
// The plain-mode payload-snippet machinery must not run: a Sparkplug body is protobuf, and
// sniffing it would report "(N bytes, binary)" for every metric in the plant.
a.SecurityClass.ShouldNotContain("binary");
a.SecurityClass.ShouldNotContain("bytes");
}
[Fact]
public async Task SparkplugAttributes_ArrayMetric_ReportsElementTypePlusIsArray()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Profile", SparkplugDataType.Int32Array);
var a = (await s.AttributesAsync(
"Plant1/EdgeA::Profile", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
a.IsArray.ShouldBeTrue();
}
[Fact]
public async Task SparkplugAttributes_UnsupportedDataType_IsReportedNotDefaulted()
{
// ToDriverDataType() returns null for DataSet/Template/PropertySet/PropertySetList/Unknown.
// A null must never be coerced into a guessed DriverDataType — the operator has to see that
// the metric is not bindable.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Curve", SparkplugDataType.DataSet);
var a = (await s.AttributesAsync(
"Plant1/EdgeA::Curve", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
Enum.TryParse<Core.Abstractions.DriverDataType>(a.DriverDataType, ignoreCase: true, out _)
.ShouldBeFalse(); // not a guessed type — unmappable, and visibly so
a.SecurityClass.ShouldContain("unsupported");
}
[Fact]
public async Task SparkplugAttributes_StateTheBindingTuple_SoTheCommitNeverParsesTheNodeId()
{
// The address of a Sparkplug tag is (group, edgeNode, device?, metric) — NOT the node id. The
// session already holds the decomposition (it parsed the birth topic), so it states it; the
// AdminUI's browse-commit mapper reads these keys and never splits the id.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var a = (await s.AttributesAsync(
"OtOpcUaSim/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.AddressFields.ShouldNotBeNull();
a.AddressFields![MqttTagConfigKeys.GroupId].ShouldBe("OtOpcUaSim");
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
a.AddressFields[MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
a.AddressFields[MqttTagConfigKeys.MetricName].ShouldBe("Temperature");
}
[Fact]
public async Task SparkplugAttributes_NodeLevelMetric_StatesNoDeviceId()
{
// An NBIRTH metric belongs to the edge node itself. An invented/blank device id would be a
// device that does not exist — the same reason the tree hangs it directly under the edge node.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Uptime", SparkplugDataType.Int64);
var a = (await s.AttributesAsync(
"OtOpcUaSim/EdgeA::Uptime", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.AddressFields.ShouldNotBeNull();
a.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
}
[Fact]
public async Task SparkplugAttributes_MetricNameContainingASlash_IsStatedWhole()
{
// 'Node Control/Rebirth' is a canonical Sparkplug metric name. Its node id is
// 'OtOpcUaSim/EdgeA::Node Control/Rebirth' — un-splittable back into the tuple, which is why the
// tuple travels rather than the id.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Node Control/Rebirth", SparkplugDataType.Boolean);
var a = (await s.AttributesAsync(
"OtOpcUaSim/EdgeA::Node Control/Rebirth", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.AddressFields.ShouldNotBeNull();
a.AddressFields![MqttTagConfigKeys.MetricName].ShouldBe("Node Control/Rebirth");
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
}
[Fact]
public async Task SparkplugAttributes_DeviceAndNodeLevelMetricsOfTheSameName_StateDifferentAddresses()
{
// The two are siblings sharing a label ('EdgeA/Temp' the device folder vs 'EdgeA::Temp' the
// node-level metric). Anything that re-derived the address from the label would collapse them.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Temp", SparkplugDataType.Float);
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temp", SparkplugDataType.Float);
var nodeLevel = (await s.AttributesAsync(
"OtOpcUaSim/EdgeA::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
var deviceLevel = (await s.AttributesAsync(
"OtOpcUaSim/EdgeA/Filler1::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
nodeLevel.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
deviceLevel.AddressFields![MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
}
[Fact]
public async Task PlainAttributes_StateTheTopic_AsTheAddress()
{
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("otopcua/fixture/oven/temp", "21.5");
var a = (await s.AttributesAsync(
"otopcua/fixture/oven/temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.AddressFields.ShouldNotBeNull();
a.AddressFields![MqttTagConfigKeys.Topic].ShouldBe("otopcua/fixture/oven/temp");
a.AddressFields.ContainsKey(MqttTagConfigKeys.MetricName).ShouldBeFalse(); // Plain has no metric
}
[Theory]
[InlineData(MqttMode.SparkplugB, true)]
[InlineData(MqttMode.Plain, false)]
public void RebirthAvailable_IsSparkplugOnly(MqttMode mode, bool expected)
{
// The picker draws its Request-rebirth affordance off this. A plain window has no re-announce
// action at all — RequestRebirthAsync refuses one — so offering the button would be a lie.
new MqttBrowseSession(mode).RebirthAvailable.ShouldBe(expected);
}
[Fact]
public async Task SparkplugAttributes_ForAFolderNode_AreEmpty()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
(await s.AttributesAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("Plant1/EdgeA", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
// ------------------------------------------------------- Sparkplug: the one sanctioned publish
[Fact]
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
var published = await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
published.ShouldBe(1);
s.PublishCountForTest.ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA"]);
}
[Fact]
public async Task RequestRebirthAsync_ForANeverSeenEdgeNode_StillPublishes()
{
// A node that has never birthed is the PRIME rebirth target — refusing to address one
// because the observation window never saw it would defeat the whole feature.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
(await s.RequestRebirthAsync("Plant1/EdgeGhost", TestContext.Current.CancellationToken)).ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeGhost"]);
}
[Fact]
public async Task RequestRebirthAsync_FromADeviceOrMetricNodeId_ResolvesUpToItsEdgeNode()
{
// NCMD is node-addressed; there is no device-scoped rebirth. Selecting a metric and
// clicking Request-rebirth must address the metric's OWNING edge node — resolved from the
// tuple stored on the node, never re-parsed out of the id string.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBe(1);
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1::T", TestContext.Current.CancellationToken)).ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeA"]);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_PublishesExactlyOneNcmdPerObservedEdgeNode()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeB", null, "T", SparkplugDataType.Float);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler2", "T", SparkplugDataType.Float); // same node
s.ObserveBirthForTest("Plant2", "EdgeZ", null, "T", SparkplugDataType.Float); // other group
var published = await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken);
published.ShouldBe(2);
s.PublishCountForTest.ShouldBe(2);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeB"]);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_BeyondTheCap_PublishesNothingAtAll()
{
// A 200-node group must not emit 200 NCMDs. The refusal is all-or-nothing: a partial fan-out
// would leave the operator unable to say which half of the plant was asked to rebirth.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
for (var i = 0; i <= MqttBrowseSession.MaxGroupRebirthNodes; i++)
s.ObserveBirthForTest("Plant1", $"Edge{i:D3}", null, "T", SparkplugDataType.Float);
var ex = await Should.ThrowAsync<InvalidOperationException>(
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(MqttBrowseSession.MaxGroupRebirthNodes.ToString());
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_TruncatedBeforeAnyEdgeNode_PublishesNothing()
{
// The node cap can leave a group node with no edge node recorded beneath it. A group that
// resolves to zero targets is refused rather than quietly succeeding with a count of 0 — the
// operator clicked Request-rebirth and is entitled to know nothing was sent.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB, nodeCap: 1);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "T", SparkplugDataType.Float);
s.Truncated.ShouldBeTrue();
(await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
await Should.ThrowAsync<InvalidOperationException>(
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Plant1")] // a group this window has never observed
[InlineData("Plant1/EdgeA/Filler1/Extra")] // unknown, and not resolvable to an edge node
public async Task RequestRebirthAsync_UnusableScope_PublishesNothing(string scope)
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
await Should.ThrowAsync<ArgumentException>(
async () => await s.RequestRebirthAsync(scope, TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_InPlainMode_IsRefused()
{
// P1's guarantee is unconditional: a plain-topic browse window publishes nothing, ever.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/oven/temp", "1");
await Should.ThrowAsync<NotSupportedException>(
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_AfterDispose_Throws()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
await s.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
// ------------------------------------------------------- Sparkplug: the browser
[Fact]
public async Task OpenAsync_SparkplugMode_NoLongerRefuses_ButStillReachesTheBroker()
{
// Task 23 unseals the Sparkplug branch. The open still has to CONNECT, so an unreachable
// broker fails the way any other open does — what must NOT come back is the Task-10
// NotSupportedException seal.
var browser = new MqttDriverBrowser();
const string cfg = """
{"host":"broker.invalid","port":8883,"mode":"SparkplugB","connectTimeoutSeconds":5,
"sparkplug":{"groupId":"Plant1"}}
""";
var ex = await Should.ThrowAsync<Exception>(
async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
ex.ShouldNotBeOfType<NotSupportedException>();
}
[Theory]
[InlineData("Plant1", "spBv1.0/Plant1/#")]
[InlineData("", "spBv1.0/#")] // group not authored yet — discover every group
[InlineData(" ", "spBv1.0/#")]
public void BuildRootFilter_SparkplugMode_ScopesToTheSparkplugNamespace(string groupId, string expected)
{
var opts = new MqttDriverOptions
{
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = groupId },
Plain = new MqttPlainOptions { TopicPrefix = "ignored/in/sparkplug/mode" },
};
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
}
[Fact]
public void BuildRootFilter_SparkplugMode_WithNoSparkplugSection_DiscoversEveryGroup()
{
var opts = new MqttDriverOptions { Mode = MqttMode.SparkplugB, Sparkplug = null };
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe("spBv1.0/#");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
using System.Text.Json;
using MQTTnet;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// The operator-visible half of the refused-CONNACK defect. <see cref="MqttConnectionTests"/>
/// pins that <c>MqttConnection.ConnectAsync</c> raises the rejection; this pins the thing an
/// operator actually sees — that a driver whose broker refused the CONNECT does <b>not</b> seal
/// green.
/// </summary>
/// <remarks>
/// Worth its own suite because the two failures are independent. The connection could classify a
/// rejection perfectly and the driver could still report <c>Healthy</c>, simply by not letting the
/// failure reach <c>InitializeCoreAsync</c>'s catch — which is exactly the shape the Task-13 live
/// fixture found (a wrong broker password produced <c>DriverState.Healthy</c> /
/// <c>HostState.Running</c> on a session that never authenticated).
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MqttDriverConnectRejectionTests
{
/// <summary>
/// THE regression pin. A configured-wrong password must fail the deploy loudly, not produce a
/// healthy driver that will never receive a single value.
/// </summary>
[Fact]
public async Task InitializeAsync_BrokerRefusesTheCredentials_IsNotHealthy()
{
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
var options = BrokerOptions(broker.Port);
await using var driver = new MqttDriver(options, "d", null);
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.NotAuthorized);
var health = driver.GetHealth();
health.State.ShouldNotBe(DriverState.Healthy, "a driver the broker refused must never seal green");
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNullOrWhiteSpace();
health.LastError!.ShouldContain("rejected the credentials");
}
/// <summary>
/// The transient counterpart: still a failed initialize (the driver has no session), but it must
/// not be reported as the same unrecoverable shape — a broker restarting is not a misconfigured
/// deployment.
/// </summary>
[Fact]
public async Task InitializeAsync_BrokerTemporarilyUnavailable_FailsAsTransient()
{
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable };
var options = BrokerOptions(broker.Port);
await using var driver = new MqttDriver(options, "d", null);
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
ex.IsUnrecoverable.ShouldBeFalse();
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
}
/// <summary>
/// The control. Without it, every assertion above would still hold if the driver simply never
/// went healthy against this fake broker — the suite would be green and prove nothing.
/// </summary>
[Fact]
public async Task InitializeAsync_BrokerAcceptsTheConnect_IsHealthy()
{
using var broker = new MiniBroker();
var options = BrokerOptions(broker.Port);
await using var driver = new MqttDriver(options, "d", null);
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private static MqttDriverOptions BrokerOptions(int port) => new()
{
Mode = MqttMode.Plain,
Host = "127.0.0.1",
Port = port,
UseTls = false,
ProtocolVersion = MqttProtocolVersion.V311,
Username = "svc",
Password = "pw",
KeepAliveSeconds = 300,
ConnectTimeoutSeconds = 5,
ReconnectMinBackoffSeconds = 1,
ReconnectMaxBackoffSeconds = 2,
};
private static string Json(MqttDriverOptions options) => JsonSerializer.Serialize(options, MqttJson.Options);
}
@@ -0,0 +1,846 @@
using System.Text;
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Shell-level tests for <see cref="MqttDriver"/>: authored-only discovery, the lifecycle
/// surface (<c>Reinitialize</c> / <c>Shutdown</c> / health / footprint / cache flush), and the
/// <c>IReadable</c> batch contract. Nothing here dials a broker — every test exercises the
/// connection-free half of the driver, which is exactly the half a chatty broker must not be
/// able to influence.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverDiscoveryTests
{
/// <summary>
/// An authored TagConfig blob. <c>dataType</c> uses the real <see cref="DriverDataType"/>
/// member names — there is no <c>Double</c>; the 64-bit float is <c>Float64</c>.
/// </summary>
private static string TagJson(string topic, string dataType = "String", string payloadFormat = "Raw")
=> $$"""{"topic":"{{topic}}","payloadFormat":"{{payloadFormat}}","dataType":"{{dataType}}"}""";
private static RawTagEntry Tag(string rawPath, string topic, string dataType = "String")
=> new(rawPath, TagJson(topic, dataType), WriteIdempotent: false);
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
/// <summary>
/// Options aimed at a definitely-closed port so a connect is <b>refused immediately</b> rather
/// than hanging — the tests that must prove a rebuild actually dials need the dial to fail
/// fast, not to burn a connect deadline.
/// </summary>
private static MqttDriverOptions ClosedPortOptions(params RawTagEntry[] tags) => new()
{
Mode = MqttMode.Plain,
Host = "127.0.0.1",
Port = 1,
UseTls = false,
ConnectTimeoutSeconds = 2,
RawTags = tags,
};
private static MqttDriver ClosedPortDriver(params RawTagEntry[] tags)
=> new(ClosedPortOptions(tags), "d", null);
/// <summary>
/// Serializes a full options record as the reinitialize blob. Round-tripping the whole record
/// (rather than hand-writing a partial JSON object) guarantees that only the field the caller
/// changed differs — a hand-written delta silently omitting a session field would take the
/// rebuild branch for the wrong reason and the test would pass vacuously.
/// </summary>
private static string DeltaJson(MqttDriverOptions options)
=> System.Text.Json.JsonSerializer.Serialize(options, MqttJson.Options);
/// <summary>
/// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a
/// single discovery pass (<see cref="DiscoveryRediscoverPolicy.Once"/>), and no online
/// discovery — the universal browser must never treat a broker's topic traffic as a
/// browsable address space.
/// </summary>
[Fact]
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Count.ShouldBe(1);
b.Variables[0].Info.FullName.ShouldBe("Plant/Mqtt/dev1/Temp");
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
}
/// <summary>
/// The falsifiability pin for authored-only discovery: a message that arrives on a topic no
/// authored tag names must not add anything to the discovered set. A driver that
/// auto-provisioned from broker traffic would grow the address space on every deploy against
/// a chatty broker.
/// </summary>
[Fact]
public async Task DiscoverAsync_UnauthoredBrokerTraffic_DoesNotAppear()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
// Traffic this driver never authored — a neighbouring publisher on the same broker.
driver.Subscriptions.HandleMessage("some/other/topic", Encoding.UTF8.GetBytes("42"), retained: false);
driver.Subscriptions.HandleMessage("f/t/deeper", Encoding.UTF8.GetBytes("42"), retained: false);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered.</summary>
[Fact]
public async Task DiscoverAsync_SkipsUnmappableTagConfig()
{
var driver = PlainDriver(
Tag("Plant/Mqtt/dev1/Good", "f/t"),
new RawTagEntry("Plant/Mqtt/dev1/Bad", "not-json", WriteIdempotent: false));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Good"]);
}
/// <summary>Discovered MQTT variables are read-only — this driver has no <c>IWritable</c> leg.</summary>
[Fact]
public async Task DiscoverAsync_MarksVariablesViewOnly()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t", "Float64"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables[0].Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float64);
}
/// <summary>
/// The falsifiability pin for the batch-read contract: a reference that is not an authored
/// tag degrades to <c>BadWaitingForInitialData</c> in its own slot rather than throwing and
/// failing every other reference in the same batch.
/// </summary>
[Fact]
public async Task ReadAsync_UnknownReference_DoesNotThrow_AndDegradesPerRef()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
var results = await driver.ReadAsync(
["Plant/Mqtt/dev1/Temp", "Plant/Mqtt/dev1/NeverAuthored"],
CancellationToken.None);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
results[1].StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
}
/// <summary>An empty batch is legal and returns an empty result, not an exception.</summary>
[Fact]
public async Task ReadAsync_EmptyBatch_ReturnsEmpty()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var results = await driver.ReadAsync([], CancellationToken.None);
results.ShouldBeEmpty();
}
/// <summary>
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
/// <c>IReadable</c>, so flushing it would silently turn every subscribed node Bad under
/// memory pressure. <c>FlushOptionalCachesAsync</c> must leave it untouched.
/// </summary>
[Fact]
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
}
/// <summary>A malformed reinitialize delta must never fault the driver, and must not lose the running config.</summary>
[Fact]
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{ this is not json", CancellationToken.None));
driver.GetHealth().State.ShouldNotBe(DriverState.Faulted);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>
/// A delta that changes only the authored tag set is applied in place — no teardown, no
/// reconnect — and is applied WHOLESALE: a tag the redeploy dropped stops being discovered.
/// </summary>
[Fact]
public async Task ReinitializeAsync_TagOnlyDelta_AppliedInPlace_Wholesale()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var pressure = System.Text.Json.JsonSerializer.Serialize(TagJson("f/p"));
var flow = System.Text.Json.JsonSerializer.Serialize(TagJson("f/q"));
var delta = $$"""
{
"mode": "Plain",
"rawTags": [
{ "rawPath": "Plant/Mqtt/dev1/Pressure", "tagConfig": {{pressure}}, "writeIdempotent": false },
{ "rawPath": "Plant/Mqtt/dev1/Flow", "tagConfig": {{flow}}, "writeIdempotent": false }
]
}
""";
await driver.ReinitializeAsync(delta, CancellationToken.None);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName)
.OrderBy(x => x, StringComparer.Ordinal)
.ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]);
}
/// <summary>
/// A delta that changes the broker endpoint takes the <b>rebuild</b> branch: it must actually
/// dial the new endpoint (proven here by the refused connect), and a rebuild whose connect
/// fails must land <see cref="DriverState.Faulted"/> rather than letting the failure escape
/// with the health surface still claiming Healthy.
/// </summary>
[Fact]
public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
// Only Host differs from the ctor options — everything else round-trips identically.
var delta = DeltaJson(ClosedPortOptions() with { Host = "127.0.0.2" });
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(delta, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// Pins that <c>IngestIdentity</c> is nested inside <c>SessionIdentity</c>: a delta touching
/// ONLY an ingest setting still rebuilds. Without the nesting it would be judged "same
/// session", applied in place, and the subscription manager that actually reads
/// <c>MaxPayloadBytes</c> would never be rebuilt — a config change that silently does nothing.
/// </summary>
[Fact]
public async Task ReinitializeAsync_IngestOnlyDelta_AlsoRebuilds()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var delta = DeltaJson(ClosedPortOptions() with { MaxPayloadBytes = 4096 });
// Reaching the (refused) connect at all is the proof the rebuild branch was taken; the
// tag-only test above is the control that shows an in-place delta never dials.
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(delta, CancellationToken.None));
}
/// <summary>
/// The falsifiability pin for the orphaned-connection class: <see cref="MqttDriver.DisposeAsync"/>
/// must serialize behind an in-flight lifecycle operation, not race past it.
/// </summary>
/// <remarks>
/// An ungated dispose running while a rebuild holds the gate observes <c>_connection == null</c>
/// (the rebuild has torn the old session down but not yet assigned the new one), does nothing,
/// and sets the disposed flag — after which the rebuild assigns a live connection plus a
/// host-probe loop that no later dispose can reach. Here the driver is parked inside
/// <c>InitializeCoreAsync</c> at the pre-connect hook; the assertion is that dispose does not
/// complete while it is parked, and does complete once it is released.
/// </remarks>
[Fact]
public async Task DisposeAsync_SerializesBehindAnInFlightLifecycleOperation()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
driver.BeforeConnectHookForTests = async _ =>
{
entered.TrySetResult();
await release.Task;
};
var initialize = Task.Run(() => driver.InitializeAsync("", CancellationToken.None));
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var dispose = driver.DisposeAsync().AsTask();
// The gate is held by the parked initialize. Dispose must still be waiting. (The gate wait is
// bounded by ConnectTimeoutSeconds = 2 s, so this 300 ms window is comfortably inside it —
// an ungated dispose returns essentially instantly.)
var raced = await Task.WhenAny(dispose, Task.Delay(TimeSpan.FromMilliseconds(300)));
raced.ShouldNotBe(dispose, "DisposeAsync returned while a lifecycle operation held the gate");
release.SetResult();
await Should.ThrowAsync<Exception>(() => initialize); // connect refused on the closed port
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
dispose.IsCompletedSuccessfully.ShouldBeTrue();
}
/// <summary>Identity is fixed at construction and must match the persisted <c>DriverInstance.DriverType</c>.</summary>
[Fact]
public void Identity_IsMqtt()
{
var driver = PlainDriver();
driver.DriverType.ShouldBe("Mqtt");
driver.DriverInstanceId.ShouldBe("d");
}
/// <summary>A driver that has never been initialized reports Unknown, not Healthy.</summary>
[Fact]
public void GetHealth_BeforeInitialize_IsUnknown()
{
PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Plain mode never signals rediscovery — the authored set only changes by redeploy.</summary>
[Fact]
public async Task Plain_NeverRaisesRediscoveryNeeded()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.DiscoverAsync(new CapturingAddressSpaceBuilder(), CancellationToken.None);
fired.ShouldBe(0);
}
/// <summary>The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.</summary>
[Fact]
public void GetMemoryFootprint_TracksAuthoredTable()
{
PlainDriver().GetMemoryFootprint().ShouldBe(0);
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
}
/// <summary>Shutdown on a never-initialized driver is a no-op, not a null-reference.</summary>
[Fact]
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ShutdownAsync(CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>The single-broker connectivity probe names the configured endpoint.</summary>
[Fact]
public void GetHostStatuses_ReportsTheConfiguredBroker()
{
var driver = new MqttDriver(
new MqttDriverOptions { Mode = MqttMode.Plain, Host = "broker.example", Port = 1883 },
"d",
null);
var statuses = ((IHostConnectivityProbe)driver).GetHostStatuses();
statuses.Count.ShouldBe(1);
statuses[0].HostName.ShouldBe("broker.example:1883");
statuses[0].State.ShouldBe(HostState.Unknown);
}
// =================================================================================
// Task 22 — Sparkplug discovery policy, birth-filled datatypes, rediscovery trigger
// =================================================================================
/// <summary>
/// Sparkplug's discovered shape is not fully known at connect: a tag authored without a
/// <c>dataType</c> takes its type from the birth certificate, which arrives asynchronously.
/// That is precisely the <see cref="DiscoveryRediscoverPolicy.UntilStable"/> contract — the
/// host re-runs discovery until the captured set settles.
/// </summary>
[Fact]
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
=> SparkplugDriver().RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
/// <summary>
/// The mode gate, asserted from the other side: plain MQTT's authored set is fully known
/// synchronously, so it must stay <see cref="DiscoveryRediscoverPolicy.Once"/>. A blanket flip
/// to <c>UntilStable</c> would make every plain deployment re-run discovery on a timer for a
/// tree that can never change.
/// </summary>
[Fact]
public void PlainMode_RediscoverPolicy_StaysOnce()
=> PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
/// <summary>
/// A DBIRTH for an authored device the driver has never held a birth for introduces the
/// metrics' datatypes, so the discovered tree genuinely changed: rediscovery fires.
/// </summary>
[Fact]
public void NewDbirth_FiresOnRediscoveryNeeded()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
fired.ShouldBeGreaterThan(0);
}
/// <summary>
/// <b>The primary anti-storm pin.</b> An edge node that re-births on a timer (or a flapping
/// connection driving the late-join rebirth) republishes the same metric set over and over. If
/// every birth fired rediscovery, each one would trigger an address-space rebuild — expensive
/// and disruptive on a running server, forever, for a tree that never changed.
/// </summary>
[Fact]
public void IdenticalRebirth_DoesNotFireRediscovery()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
// Subscribe only AFTER the first birth so the count below is purely the rebirths'.
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
for (var i = 0; i < 5; i++)
{
FeedNodeBirth(driver, seq: 0, bdSeq: (ulong)(i + 2));
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
}
fired.ShouldBe(0);
}
/// <summary>
/// The other half of the gate: a rebirth that actually changes the metric set (a metric added,
/// removed or renamed) DOES change what discovery can resolve, and must fire.
/// </summary>
[Fact]
public void RebirthWithChangedMetricSet_FiresRediscovery()
{
var driver = SparkplugDriver(
SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")),
SpTag(SpPressPath, SpBlob(SpDevice, "Pressure")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
FeedDeviceBirth(driver, seq: 2, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float));
fired.ShouldBe(1);
}
/// <summary>
/// Metric ORDER is not the metric SET. A birth that lists the same metrics in a different order
/// describes the same address space; firing on it would reintroduce the rebuild storm through
/// the back door, because nothing obliges an edge node to keep its birth order stable.
/// </summary>
[Fact]
public void RebirthReorderingTheSameMetrics_DoesNotFireRediscovery()
{
var driver = SparkplugDriver(
SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")),
SpTag(SpPressPath, SpBlob(SpDevice, "Pressure")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
FeedDeviceBirth(driver, seq: 2, ("Pressure", 6UL, TahuDataType.Float), ("Temperature", 5UL, TahuDataType.Float));
fired.ShouldBe(0);
}
/// <summary>
/// A birth for a device NO authored tag names cannot change <see cref="MqttDriver.DiscoverAsync"/>'s
/// output — discovery replays the authored set. A large plant DBIRTHs devices this deployment
/// never reads on their own schedules; firing for those is a rebuild storm sourced from traffic
/// the configuration has no opinion about.
/// </summary>
[Fact]
public void BirthForAnUnauthoredDevice_DoesNotFireRediscovery()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
FeedNodeBirth(driver);
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
// Same (authored) edge node, a device nothing binds — the ingestor still applies the birth.
FeedDeviceBirth(driver, "Filler99", seq: 1, ("Temperature", 5UL, TahuDataType.Float));
fired.ShouldBe(0);
}
/// <summary>
/// The rediscovery event names the subtree this driver's discovery actually emits (the
/// <c>Mqtt</c> folder) so a consumer scoping a rebuild on it scopes to exactly the tree that
/// changed, and carries the Sparkplug scope in the diagnostic reason.
/// </summary>
[Fact]
public void RediscoveryEvent_ScopeHintIsTheDiscoveryFolder_ReasonNamesTheScope()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
RediscoveryEventArgs? args = null;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, e) => args = e;
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
args.ShouldNotBeNull();
args.ScopeHint.ShouldBe("Mqtt");
args.Reason.ShouldContain($"{SpGroup}/{SpNode}/{SpDevice}");
}
/// <summary>
/// <b>The datatype fill-in.</b> <c>dataType</c> is optional in the Sparkplug tag shape — the
/// birth certificate declares it. Before any birth the tag can only report the record default;
/// once the DBIRTH lands, discovery must re-stream it with the type the birth declared.
/// </summary>
[Fact]
public async Task DiscoverAsync_Sparkplug_UnauthoredDataType_FillsInFromTheBirth()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
var before = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(before, TestContext.Current.CancellationToken);
// The node exists from the first pass — an authored tag is part of the declared configuration,
// not something the plant grants by publishing.
before.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]);
before.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String); // the record default
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var after = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(after, TestContext.Current.CancellationToken);
after.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]);
after.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float32);
}
/// <summary>
/// The authored <c>dataType</c> wins over the birth's — the same precedence the ingest path
/// applies when it coerces a value (<c>DataTypeAuthored ? authored : birth</c>). A discovery
/// surface that disagreed with the publish surface would report a type no value ever arrives as.
/// </summary>
[Fact]
public async Task DiscoverAsync_Sparkplug_AuthoredDataType_WinsOverTheBirth()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Int32")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, TestContext.Current.CancellationToken);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Int32);
}
/// <summary>
/// A birth declaring a Sparkplug type this driver cannot map (DataSet / Template / PropertySet
/// / Unknown) must not blank the tag's type — <c>ToDriverDataType()</c> returns null for those,
/// and discovery falls back to the authored/default type rather than to nothing.
/// </summary>
[Fact]
public async Task DiscoverAsync_Sparkplug_UnsupportedBirthType_FallsBackToTheDefault()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.DataSet));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, TestContext.Current.CancellationToken);
b.Variables.Count.ShouldBe(1);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String);
}
/// <summary>
/// <b>The composition-root line.</b> Task 21 wires the ingestor's <c>OnDataChange</c> to the
/// driver's own in <c>CreateSparkplugIngestor</c>; every other Sparkplug test in the repo drives
/// <see cref="SparkplugIngestor"/> directly and would pass with that lambda deleted. This repo
/// has twice shipped a component correct in isolation and inert in production for exactly that
/// reason (<c>DeferredAddressSpaceSink</c>, <c>GatewayTagProvisioner</c>) — so the re-raise is
/// asserted through <see cref="MqttDriver"/>'s own event, under the RawPath.
/// </summary>
[Fact]
public async Task SparkplugMode_DriverRepublishesIngestDataChanges_UnderTheRawPath()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
object? sender = null;
string? firedRef = null;
object? firedValue = null;
((ISubscribable)driver).OnDataChange += (s, e) =>
{
sender = s;
firedRef = e.FullReference;
firedValue = e.Snapshot.Value;
};
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
FeedDeviceData(driver, seq: 2, (5UL, 21.5f));
firedRef.ShouldBe(SpTempPath);
firedValue.ShouldBe(21.5f);
// The driver re-raises with ITSELF as sender, not the ingestor — DriverHostActor attributes
// publishes to the driver instance.
sender.ShouldBeSameAs(driver);
}
/// <summary>
/// <c>ReadAsync</c> serves from the driver-owned <c>LastValueCache</c>, which the Sparkplug
/// ingestor is handed at construction. If the two ever stopped sharing one instance, every
/// Sparkplug read would answer <c>BadWaitingForInitialData</c> forever while subscriptions
/// looked perfectly healthy.
/// </summary>
[Fact]
public async Task SparkplugMode_ReadAsyncServesTheIngestedValue()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
FeedDeviceData(driver, seq: 2, (5UL, 33.5f));
var results = await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken);
results[0].Value.ShouldBe(33.5f);
results[0].StatusCode.ShouldBe(0x00000000u);
}
/// <summary>
/// Subscribe/unsubscribe dispatch by mode. After an unsubscribe the reference is no longer
/// attributed to a handle, so ingest stops raising for it — while the cache keeps answering, so
/// a read still works. Both halves matter: a driver that kept publishing after unsubscribe
/// would leak notifications into a torn-down monitored item.
/// </summary>
[Fact]
public async Task SparkplugMode_UnsubscribeStopsNotifications_ButNotReads()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
var handle = await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var fired = 0;
((ISubscribable)driver).OnDataChange += (_, _) => Interlocked.Increment(ref fired);
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
FeedDeviceData(driver, seq: 2, (5UL, 7.5f));
fired.ShouldBe(0);
(await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken))[0].Value.ShouldBe(7.5f);
}
/// <summary>
/// The mode gate from the other side — the reviewer's reading of the branching, turned into a
/// test. A Plain-mode driver builds NO Sparkplug ingestor at all, so no Sparkplug traffic can
/// reach it and a Sparkplug-shaped blob (which carries no <c>topic</c>) resolves to nothing.
/// </summary>
[Fact]
public void PlainMode_BuildsNoSparkplugIngestor()
{
var driver = new MqttDriver(
new MqttDriverOptions
{
Mode = MqttMode.Plain,
RawTags = [SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))],
},
"d",
null);
driver.Sparkplug.ShouldBeNull();
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
}
/// <summary>
/// …and the converse: a Sparkplug-mode driver registers into the Sparkplug path, not the plain
/// manager. Registering into both would double-warn on every deploy and, worse, let a plain
/// topic route a Sparkplug tag.
/// </summary>
[Fact]
public void SparkplugMode_RegistersIntoTheSparkplugPathOnly()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
driver.Sparkplug!.TryResolve(SpTempPath, out var def).ShouldBeTrue();
def.MetricName.ShouldBe("Temperature");
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
}
// ---- Sparkplug fixtures ----
private const string SpGroup = "Plant1";
private const string SpNode = "EdgeA";
private const string SpDevice = "Filler1";
private const string SpTempPath = "Plant/Mqtt/spb/Filler1Temp";
private const string SpPressPath = "Plant/Mqtt/spb/Filler1Press";
/// <summary>A Sparkplug tag blob. <c>dataType</c> is omitted unless supplied — it is optional.</summary>
private static string SpBlob(string? device, string metric, string? dataType = null)
{
var deviceKey = device is null ? "" : $"""{'"'}deviceId{'"'}:"{device}",""";
var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """;
return $$"""{"groupId":"{{SpGroup}}","edgeNodeId":"{{SpNode}}",{{deviceKey}}"metricName":"{{metric}}"{{typeKey}}}""";
}
private static RawTagEntry SpTag(string rawPath, string blob) => new(rawPath, blob, WriteIdempotent: false);
private static MqttDriver SparkplugDriver(params RawTagEntry[] tags)
=> new(
new MqttDriverOptions
{
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = SpGroup },
RawTags = tags,
},
"d",
null);
/// <summary>
/// Feeds a real NBIRTH through the real codec + ingest state machine — no broker, and no
/// test-only shortcut on the driver. <c>Dispatch</c> is the method MQTTnet's dispatcher thread
/// reaches, so this exercises the production path end to end.
/// </summary>
private static void FeedNodeBirth(MqttDriver driver, ulong seq = 0, ulong bdSeq = 1)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "bdSeq",
Datatype = (uint)TahuDataType.Uint64,
LongValue = bdSeq,
});
payload.Metrics.Add(SpBirthMetric("Uptime", 7UL, TahuDataType.Int64));
driver.Sparkplug!.Dispatch(
new SparkplugTopic(SparkplugMessageType.NBIRTH, SpGroup, SpNode, null, null),
SparkplugCodec.Decode(payload.ToByteArray()));
}
private static void FeedDeviceBirth(
MqttDriver driver,
ulong seq,
params (string Name, ulong Alias, TahuDataType Type)[] metrics)
=> FeedDeviceBirth(driver, SpDevice, seq, metrics);
private static void FeedDeviceBirth(
MqttDriver driver,
string device,
ulong seq,
params (string Name, ulong Alias, TahuDataType Type)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
foreach (var (name, alias, type) in metrics)
{
payload.Metrics.Add(SpBirthMetric(name, alias, type));
}
driver.Sparkplug!.Dispatch(
new SparkplugTopic(SparkplugMessageType.DBIRTH, SpGroup, SpNode, device, null),
SparkplugCodec.Decode(payload.ToByteArray()));
}
/// <summary>
/// Feeds a real DDATA — alias-only metrics, exactly as a Sparkplug node publishes after a birth.
/// </summary>
private static void FeedDeviceData(MqttDriver driver, ulong seq, params (ulong Alias, float Value)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
foreach (var (alias, value) in metrics)
{
payload.Metrics.Add(new Payload.Types.Metric { Alias = alias, FloatValue = value });
}
driver.Sparkplug!.Dispatch(
new SparkplugTopic(SparkplugMessageType.DDATA, SpGroup, SpNode, SpDevice, null),
SparkplugCodec.Decode(payload.ToByteArray()));
}
/// <summary>
/// A birth metric declaring name/alias/datatype and <b>no value</b> — a birth's job here is to
/// declare the catalog, and these tests assert on the catalog, never on a published value.
/// </summary>
private static Payload.Types.Metric SpBirthMetric(string name, ulong alias, TahuDataType type)
=> new() { Name = name, Alias = alias, Datatype = (uint)type };
// ---- test doubles ----
/// <summary>
/// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this
/// suite: the driver test project does not reference <c>Commons</c>, where the runtime's own
/// capturing builder lives. Mirrors the per-driver <c>RecordingBuilder</c> the AB CIP / FOCAS
/// suites use.
/// </summary>
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
/// <summary>Every folder streamed, in order, across the whole tree.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>Every variable streamed, in order, across the whole tree.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this;
}
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -0,0 +1,151 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Tests for <see cref="MqttDriverFactoryExtensions"/> — the seam that turns a persisted
/// <c>DriverInstance.DriverConfig</c> blob into a live <see cref="MqttDriver"/>. Mirrors
/// <c>OpcUaClientDriverFactoryExtensions</c>' shape (direct deserialization into the options
/// record, no intermediate DTO).
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverFactoryExtensionsTests
{
[Fact]
public void Register_ThenCreate_BuildsMqttDriver()
{
var registry = new DriverFactoryRegistry();
MqttDriverFactoryExtensions.Register(registry);
var factory = registry.TryGet(MqttDriverFactoryExtensions.DriverTypeName);
factory.ShouldNotBeNull();
var driver = factory("d1", """{"host":"h","port":1883,"mode":"Plain"}""");
driver.ShouldBeOfType<MqttDriver>();
}
/// <summary>
/// The factory's registration key and the canonical constant must be the same string. A
/// drift here is invisible at compile time and silently breaks dispatch — the repo's
/// <c>ModbusTcp</c>/<c>Modbus</c> incident.
/// </summary>
[Fact]
public void DriverTypeName_MatchesConstant()
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
/// <summary>
/// Third independent pin on the literal itself: both the constant and the factory name could
/// be renamed together and still agree with each other while disagreeing with every persisted
/// <c>DriverInstance.DriverType</c> row.
/// </summary>
[Fact]
public void DriverTypeName_IsExactlyMqtt()
{
MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt");
DriverTypeNames.Mqtt.ShouldBe("Mqtt");
DriverTypeNames.All.ShouldContain("Mqtt");
}
/// <summary>
/// <c>rawTags</c> is the ONLY source of the driver's discoverable node set (plain MQTT has no
/// browsable address space), so a factory that parsed everything else correctly but dropped
/// this array would produce a driver that connects, subscribes to nothing and materializes
/// nothing — green everywhere, dark in production.
/// </summary>
[Fact]
public async Task CreateInstance_CarriesRawTagsThroughToDiscovery()
{
const string tagConfig = """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""";
var json = JsonSerializer.Serialize(new
{
host = "h",
port = 1883,
mode = "Plain",
rawTags = new[]
{
new { rawPath = "Plant/Mqtt/dev1/Temp", tagConfig, writeIdempotent = false },
},
});
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
var builder = new RecordingBuilder();
await driver.DiscoverAsync(builder, CancellationToken.None);
builder.VariableFullNames.ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>
/// Enum-valued knobs must bind from their string NAMES — the repo's documented systemic
/// enum-serialization bug is an AdminUI-authored config whose enum field one seam can read
/// and another cannot. Removing <see cref="JsonStringEnumConverter"/> from
/// <see cref="MqttJson.Options"/> makes this throw.
/// </summary>
[Fact]
public void CreateInstance_EnumsAuthoredAsNames_Bind()
{
const string json = """
{"host":"h","port":8883,"mode":"SparkplugB","protocolVersion":"V311",
"sparkplug":{"groupId":"Plant1","hostId":"h1"}}
""";
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
driver.ShouldBeOfType<MqttDriver>();
}
/// <summary>
/// Direct pin on the single shared instance: the enum converter is what the test above
/// exercises behaviourally, and this asserts it structurally so a reader can see the one
/// instance every MQTT config seam parses through.
/// </summary>
[Fact]
public void SharedJsonOptions_CarryTheStringEnumConverter()
=> MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter);
[Fact]
public void CreateInstance_BlankConfig_Throws()
=> Should.Throw<ArgumentException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", " "));
[Fact]
public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation()
=> Should.Throw<InvalidOperationException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null"));
/// <summary>
/// Minimal discovery recorder — local to this suite because the driver test project does not
/// reference <c>Commons</c>, where the runtime's capturing builder lives (same reason
/// <c>MqttDriverDiscoveryTests</c> carries its own).
/// </summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<string> VariableFullNames { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
VariableFullNames.Add(attributeInfo.FullName);
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
public sealed class MqttDriverOptionsTests
{
/// <summary>
/// The production instance, not a look-alike copy. A local clone would keep passing after
/// <see cref="MqttJson.Options"/> lost its <c>JsonStringEnumConverter</c> — testing the copy
/// instead of the thing every seam actually parses through.
/// </summary>
private static readonly JsonSerializerOptions J = MqttJson.Options;
[Fact]
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
{
const string json = """
{ "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
"sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
""";
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
o.Mode.ShouldBe(MqttMode.SparkplugB);
o.UseTls.ShouldBeTrue();
o.Sparkplug!.GroupId.ShouldBe("Plant1");
o.Plain.ShouldBeNull();
}
[Fact]
public void Serialize_WritesEnumsAsNames_NotOrdinals()
{
var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
s.ShouldContain("\"Plain\"");
s.ShouldNotContain("\"mode\":0");
}
// Pins the plan's cross-cutting rule ("never ship anonymous/public-broker defaults") as an
// executable assertion — nothing else in this suite fails if UseTls / AllowUntrustedServerCertificate
// (or the §5.1 numeric defaults) get flipped by an unrelated edit.
[Fact]
public void Defaults_SecurityCriticalKnobs_MatchPlan()
{
var o = new MqttDriverOptions();
o.UseTls.ShouldBeTrue();
o.AllowUntrustedServerCertificate.ShouldBeFalse();
o.ConnectTimeoutSeconds.ShouldBe(15);
o.ReconnectMinBackoffSeconds.ShouldBe(1);
o.ReconnectMaxBackoffSeconds.ShouldBe(30);
}
// The production case: an operator-authored config blob that simply doesn't mention TLS must
// never silently deserialize into an insecure instance.
[Fact]
public void Deserialize_PartialJsonOmittingTlsKnobs_KeepsSecureDefaults()
{
const string json = """{ "host":"10.100.0.35","port":8883 }""";
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
o.UseTls.ShouldBeTrue();
o.AllowUntrustedServerCertificate.ShouldBeFalse();
}
[Fact]
public void ToString_DoesNotLeakPassword()
{
var o = new MqttDriverOptions { Password = "hunter2-supersecret" };
var s = o.ToString();
s.ShouldNotContain("hunter2-supersecret");
s.ShouldContain("***");
}
}
@@ -0,0 +1,201 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Tests for <see cref="MqttDriverProbe"/>. Mirrors <c>ModbusDriverProbeTests</c>'
/// shape: a real loopback listener drives server-side behaviour so the probe's CONNECT
/// handshake is exercised against something real rather than mocked.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverProbeTests
{
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
/// <summary>A loopback listener that accepts the TCP connection and then never answers CONNACK —
/// the shape that actually wedges a client. A refused port fails instantly with ECONNREFUSED and
/// would pass whether or not the probe's deadline logic works at all, so the timeout test below
/// needs this rather than a closed port.</summary>
private sealed class BlackholeBroker : IDisposable
{
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly List<TcpClient> _accepted = [];
public BlackholeBroker()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_ = Task.Run(AcceptLoopAsync);
}
public int Port { get; }
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
lock (_accepted)
{
_accepted.Add(client);
}
// Accept the TCP handshake and then say nothing — never a CONNACK.
}
}
catch (Exception)
{
// Listener stopped / cancelled — expected at teardown.
}
}
public void Dispose()
{
_cts.Cancel();
_listener.Stop();
lock (_accepted)
{
foreach (var client in _accepted)
{
client.Dispose();
}
}
_cts.Dispose();
}
}
private static TcpListener StartListener()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
return l;
}
private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port;
[Fact]
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
/// <summary>The plan's own vacuous-pass trap: a closed port fails instantly (ECONNREFUSED)
/// regardless of whether the deadline logic works. Kept as the "refused" leg, not the
/// "deadline" leg — see <see cref="ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang"/>
/// for the real timeout-path exercise.</summary>
[Fact]
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
{
var listener = StartListener();
var port = ListenerPort(listener);
listener.Stop();
var r = await new MqttDriverProbe().ProbeAsync(
$$"""{"host":"127.0.0.1","port":{{port}},"useTls":false,"connectTimeoutSeconds":2}""",
QuickTimeout,
CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
/// <summary>The real timeout-path exercise: a broker that completes the TCP handshake and then
/// never answers CONNACK must still fail at the configured deadline rather than hang.</summary>
[Fact]
public async Task ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
{
using var blackhole = new BlackholeBroker();
var config = $$"""{"host":"127.0.0.1","port":{{blackhole.Port}},"useTls":false,"connectTimeoutSeconds":1}""";
var sw = Stopwatch.StartNew();
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
sw.Stop();
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
r.Message.ShouldContain("timed out", Case.Insensitive);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
[Fact]
public async Task ProbeAsync_InvalidJson_ReturnsFailedResult_NotThrown()
{
var r = await new MqttDriverProbe().ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task ProbeAsync_NoHost_ReturnsFailedResult()
{
var r = await new MqttDriverProbe().ProbeAsync(
"""{"host":"","port":0}""", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
/// <summary>Never leak a configured password into the probe's result message — it surfaces
/// directly in the AdminUI Test-connect UI.</summary>
[Fact]
public async Task ProbeAsync_NeverLeaksPasswordIntoMessage()
{
var listener = StartListener();
var port = ListenerPort(listener);
listener.Stop();
const string secret = "S3cr3tSquirrel!!";
var config = $$"""
{"host":"127.0.0.1","port":{{port}},"useTls":false,"username":"probe-user","password":"{{secret}}","connectTimeoutSeconds":2}
""";
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
r.Message.ShouldNotContain(secret);
}
/// <summary>
/// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
/// <para>
/// <b>The assertions are the whole point.</b> "Ok is false with a non-empty message" is
/// ALSO what a JSON-parse failure produces, so asserting only that would stay green if
/// <c>JsonStringEnumConverter</c> were ever dropped from <see cref="MqttJson.Options"/>
/// — i.e. the exact regression this test exists to catch. So it asserts the probe got
/// PAST the parse (message is not the <c>"Config JSON is invalid"</c> shape) and reached
/// the network (the message names the target endpoint).
/// </para>
/// </summary>
[Fact]
public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork()
{
var listener = StartListener();
var port = ListenerPort(listener);
listener.Stop();
var config = $$"""
{"host":"127.0.0.1","port":{{port}},"useTls":false,"protocolVersion":"V500","connectTimeoutSeconds":2}
""";
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
r.Message.ShouldNotStartWith(
"Config JSON is invalid",
Case.Insensitive,
"the probe never parsed the config — 'V500' was rejected, so the shared JSON options " +
"lost their JsonStringEnumConverter");
r.Message.ShouldNotStartWith("Config JSON deserialized to null");
r.Message.ShouldNotStartWith("Config has no host/port");
// A network-level marker: the probe got as far as dialling the (closed) loopback port.
r.Message.ShouldContain($"127.0.0.1:{port}");
}
}
@@ -0,0 +1,851 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="MqttSubscriptionManager"/> — the Task-6 plain-MQTT ingest path: authored-tag
/// registration keyed by RawPath, topic dedupe + fan-out, payload extraction, the retained seed,
/// and the subscribe/SUBACK contract. Every test runs without a broker: messages are fed straight
/// through <see cref="MqttSubscriptionManager.HandleMessage"/> (the method MQTTnet's dispatcher
/// calls) and the subscribe leg runs against a fake <see cref="IMqttSubscribeTransport"/>.
/// </summary>
public sealed class MqttSubscriptionManagerTests
{
// OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
private const uint Good = 0x00000000u;
private const uint BadDecodingError = 0x80070000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadCommunicationError = 0x80050000u;
private const string OvenTempPath = "Plant/Mqtt/broker1/OvenTemp";
private const string OvenPressPath = "Plant/Mqtt/broker1/OvenPressure";
private const string OvenTopic = "f/oven/temp";
private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false);
private static MqttSubscriptionManager NewManager(IMqttSubscribeTransport? transport = null) =>
new(new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", transport);
// ---------------------------------------------------------------------------------
// Routing + the RawPath identity of the published reference
// ---------------------------------------------------------------------------------
/// <summary>
/// The plan's first snippet, corrected twice: <c>Double</c> is not a
/// <see cref="DriverDataType"/> member (<c>Float64</c> is), and the published reference is
/// asserted to BE the RawPath rather than merely to <i>contain</i> the topic — a topic-keyed
/// publish would satisfy the plan's assertion and be silently dead against
/// <c>DriverHostActor</c>'s RawPath-keyed dual-namespace fan-out.
/// </summary>
[Fact]
public async Task HandleMessage_MatchingTopic_RaisesDataChangeAtJsonPath_UnderRawPath()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
string? gotRef = null;
object? gotVal = null;
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
mgr.HandleMessage(OvenTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
gotVal.ShouldBe(21.5);
gotRef.ShouldBe(OvenTempPath);
gotRef.ShouldNotBe(OvenTopic);
}
/// <summary>
/// A chatty broker must never auto-provision a tag the operator did not author. Deliberately
/// run with a live, subscribed tag present rather than an empty table: an empty table makes
/// the assertion unfalsifiable — a "deliver to every authored tag" implementation would pass it
/// for want of anything to deliver to.
/// </summary>
[Fact]
public async Task HandleMessage_UnauthoredTopic_RaisesNothing()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
fired.ShouldBeFalse();
mgr.Values.Read(OvenTempPath).StatusCode.ShouldNotBe(Good); // and nothing was cached for it either
}
/// <summary>
/// Two tags on ONE topic (different JSONPaths) — the message must reach BOTH. A
/// <c>TryGetValue → first match → return</c> implementation passes every single-tag test and
/// silently starves every tag after the first.
/// </summary>
[Fact]
public async Task HandleMessage_TopicSharedByTwoTags_FansOutToEveryMatchingTag()
{
var mgr = NewManager();
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.temp","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.press","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var seen = new Dictionary<string, object?>();
mgr.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
mgr.HandleMessage(OvenTopic, """{"temp":21.5,"press":1.2}"""u8.ToArray(), retained: false);
seen.Count.ShouldBe(2);
seen[OvenTempPath].ShouldBe(21.5);
seen[OvenPressPath].ShouldBe(1.2);
}
/// <summary>
/// An authored tag nobody subscribed to still caches its value (so a later
/// <c>IReadable.ReadAsync</c> answers) but must not raise <c>OnDataChange</c> — there is no
/// subscription handle to attribute it to.
/// </summary>
[Fact]
public void HandleMessage_AuthoredButUnsubscribedTag_CachesValue_RaisesNothing()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage(OvenTopic, "7.5"u8.ToArray(), retained: false);
fired.ShouldBeFalse();
mgr.Values.Read(OvenTempPath).Value.ShouldBe(7.5);
}
/// <summary>After <c>UnsubscribeAsync</c> the reference stops publishing.</summary>
[Fact]
public async Task UnsubscribeAsync_StopsRaisingForThoseReferences()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var count = 0;
mgr.OnDataChange += (_, _) => count++;
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
await mgr.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
mgr.HandleMessage(OvenTopic, "2"u8.ToArray(), retained: false);
count.ShouldBe(1);
}
// ---------------------------------------------------------------------------------
// Wildcard-authored topics (accepted by the parser, warned at deploy)
// ---------------------------------------------------------------------------------
[Theory]
[InlineData("f/+/temp", "f/oven/temp", true)]
[InlineData("f/+/temp", "f/oven/deep/temp", false)] // '+' is single-level
[InlineData("f/#", "f/oven/deep/temp", true)] // '#' is multi-level
[InlineData("f/#", "g/oven/temp", false)]
public async Task HandleMessage_WildcardAuthoredTopic_UsesRealMqttFilterSemantics(
string authoredTopic, string incomingTopic, bool expectMatch)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{authoredTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage(incomingTopic, "1"u8.ToArray(), retained: false);
fired.ShouldBe(expectMatch);
}
// ---------------------------------------------------------------------------------
// Retained seed
// ---------------------------------------------------------------------------------
[Fact]
public async Task HandleMessage_RetainedMessage_SeedsWhenRetainSeedIsTrue()
{
var mgr = NewManager();
// retainSeed absent ⇒ true (the factory default).
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
object? gotVal = null;
mgr.OnDataChange += (_, e) => gotVal = e.Snapshot.Value;
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true);
gotVal.ShouldBe(3.5);
}
[Fact]
public async Task HandleMessage_RetainedMessage_IgnoredWhenRetainSeedIsFalse()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64","retainSeed":false}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var fired = false;
mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: true);
fired.ShouldBeFalse();
// A live (non-retained) publish on the same tag still lands — retainSeed gates the seed only.
mgr.HandleMessage(OvenTopic, "3.5"u8.ToArray(), retained: false);
fired.ShouldBeTrue();
}
// ---------------------------------------------------------------------------------
// Value extraction — Json / Scalar / Raw, and per-tag degradation
// ---------------------------------------------------------------------------------
[Theory]
[InlineData("$", """21.5""", 21.5)]
[InlineData("$.value", """{"value":21.5}""", 21.5)]
[InlineData("$.a.b", """{"a":{"b":21.5}}""", 21.5)]
[InlineData("$.arr[1]", """{"arr":[1.5,21.5]}""", 21.5)]
[InlineData("$['value']", """{"value":21.5}""", 21.5)]
public async Task HandleMessage_Json_ExtractsSupportedJsonPathShapes(string jsonPath, string payload, double expected)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath.Replace("\\", "\\\\").Replace("\"", "\\\"")}}","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(expected);
}
[Fact]
public async Task HandleMessage_MalformedJsonPayload_PublishesBadDecodingError_NeverThrows()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false));
snap!.StatusCode.ShouldBe(BadDecodingError);
snap.Value.ShouldBeNull();
}
[Theory]
[InlineData("$.missing")]
[InlineData("$..value")] // recursive descent — outside the supported subset
[InlineData("$.arr[9]")]
public async Task HandleMessage_UnresolvableOrUnsupportedJsonPath_PublishesBadDecodingError(string jsonPath)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, """{"value":21.5,"arr":[1]}"""u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(BadDecodingError);
}
[Fact]
public async Task HandleMessage_ValueNotCoercibleToDeclaredDataType_PublishesBadTypeMismatch()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(BadTypeMismatch);
snap.Value.ShouldBeNull();
}
[Theory]
[InlineData("Boolean", "true", true)]
[InlineData("Int32", "42", 42)]
[InlineData("Int64", "42", 42L)]
[InlineData("Float32", "1.5", 1.5f)]
[InlineData("Float64", "1.5", 1.5)]
[InlineData("String", "hello", "hello")]
public async Task HandleMessage_Scalar_ParsesByDeclaredDataType(string dataType, string payload, object expected)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"{{dataType}}"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(expected);
}
/// <summary>
/// <c>Raw</c> means "bytes as-is": the payload is published as its UTF-8 text and the declared
/// <c>dataType</c> is deliberately NOT applied (<c>Scalar</c> is the member that coerces).
/// </summary>
[Fact]
public async Task HandleMessage_Raw_PublishesUtf8TextAndIgnoresDeclaredDataType()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("21.5");
}
[Fact]
public async Task HandleMessage_RawNonUtf8Payload_PublishesBadDecodingError_NeverThrows()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw","dataType":"String"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, new byte[] { 0xC3, 0x28 }, retained: false));
snap!.StatusCode.ShouldBe(BadDecodingError);
}
/// <summary>
/// <c>HandleMessage</c> runs on MQTTnet's dispatcher thread, so nothing it calls — including a
/// misbehaving downstream subscriber — may escape and stall the client's pump.
/// </summary>
[Fact]
public async Task HandleMessage_ThrowingSubscriber_DoesNotEscapeToTheDispatcher()
{
var mgr = NewManager();
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var delivered = new List<string>();
mgr.OnDataChange += (_, e) =>
{
delivered.Add(e.FullReference);
throw new InvalidOperationException("downstream blew up");
};
Should.NotThrow(() => mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false));
// And a throwing subscriber must not starve the tags behind it in the fan-out.
delivered.Count.ShouldBe(2);
}
// ---------------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------------
[Fact]
public void Register_UnmappableTagConfig_IsSkipped_NeverThrows()
{
var mgr = NewManager();
var mapped = 0;
Should.NotThrow(() => mapped = mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, """{"payloadFormat":"Scalar"}"""), // no topic ⇒ rejected by the factory
Tag("Plant/Mqtt/broker1/Junk", "not json at all"),
]));
mapped.ShouldBe(1);
mgr.TryResolve(OvenTempPath, out _).ShouldBeTrue();
mgr.TryResolve(OvenPressPath, out _).ShouldBeFalse();
}
/// <summary>Re-registering replaces the authored table wholesale — a redeploy is not additive.</summary>
[Fact]
public void Register_Twice_ReplacesTheAuthoredTable()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
mgr.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]);
mgr.TryResolve(OvenTempPath, out _).ShouldBeFalse();
mgr.TryResolve(OvenPressPath, out _).ShouldBeTrue();
}
// ---------------------------------------------------------------------------------
// Filter building — one SUBSCRIBE per distinct topic, strongest QoS, retain handling
// ---------------------------------------------------------------------------------
[Fact]
public void BuildFilters_DedupesTopics_TakesStrongestQos_AndSeedsWhenAnyTagWants()
{
var a = new MqttTagDefinition("p/a", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 0, RetainSeed: false);
var b = new MqttTagDefinition("p/b", OvenTopic, MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: 2, RetainSeed: true);
var c = new MqttTagDefinition("p/c", "f/oven/press", MqttPayloadFormat.Scalar, "$", DriverDataType.Float64, Qos: null, RetainSeed: false);
var filters = MqttSubscriptionManager.BuildFilters([a, b, c], defaultQos: 1);
filters.Count.ShouldBe(2);
var oven = filters.Single(f => f.Topic == OvenTopic);
oven.Qos.ShouldBe(2); // strongest wins — nobody gets a weaker guarantee than authored
oven.SeedRetained.ShouldBeTrue();
var press = filters.Single(f => f.Topic == "f/oven/press");
press.Qos.ShouldBe(1); // null ⇒ the driver-level default
press.SeedRetained.ShouldBeFalse();
}
// ---------------------------------------------------------------------------------
// Subscribe / SUBACK contract
// ---------------------------------------------------------------------------------
[Fact]
public async Task SubscribeAsync_IssuesTheFirstSubscribeItself_OneFilterPerDistinctTopic()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(1);
transport.Calls[0].Select(f => f.Topic).ShouldBe([OvenTopic]);
}
[Fact]
public async Task SubscribeAsync_HandleDiagnosticId_NamesModeAndFilters()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
handle.DiagnosticId.ShouldContain("Plain");
handle.DiagnosticId.ShouldContain(OvenTopic);
}
/// <summary>An already-subscribed topic is not re-SUBSCRIBEd by a second overlapping subscription.</summary>
[Fact]
public async Task SubscribeAsync_AlreadySubscribedTopic_IsNotResubscribed()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(1);
}
[Fact]
public async Task SubscribeAsync_UnknownReference_MarksItBadNodeIdUnknown_AndDoesNotThrow()
{
var mgr = NewManager();
mgr.Register([]);
await Should.NotThrowAsync(() =>
mgr.SubscribeAsync(["Plant/Mqtt/broker1/Nope"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(BadNodeIdUnknown);
}
/// <summary>
/// A SUBACK failure on the <c>ISubscribable</c> path degrades the affected references to Bad
/// and returns a handle — it must never hang, and must never throw out of the OPC UA server's
/// subscribe call.
/// </summary>
[Fact]
public async Task SubscribeAsync_SubackFailure_DegradesRefsToBad_ReturnsHandle_DoesNotThrow()
{
var transport = new FakeTransport { Reject = _ => true };
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
// Not wrapped in Should.NotThrowAsync: an escaping exception fails this test on its own, and
// the handle must still come back.
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
handle.ShouldNotBeNull();
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
}
[Fact]
public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_DoesNotThrow()
{
var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") };
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await Should.NotThrowAsync(() =>
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
}
// ---------------------------------------------------------------------------------
// Reconnect re-subscribe
// ---------------------------------------------------------------------------------
[Fact]
public async Task OnReconnectedAsync_ReSubscribesEveryLiveTopic_Idempotently()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(3); // initial + two reconnects
transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]);
transport.Calls[2].Select(f => f.Topic).ShouldBe([OvenTopic]);
}
/// <summary>
/// A total re-subscribe failure THROWS: <see cref="MqttConnection"/>'s documented contract is
/// that a throwing <c>Reconnected</c> subscriber tears the session down and retries under
/// backoff, and a connected-but-deaf client is the one outcome this driver must never serve.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_TotalFailure_Throws_SoTheSessionIsTornDownAndRetried()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Throw = new InvalidOperationException("broker gone");
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
}
/// <summary>
/// A PARTIAL rejection (e.g. one ACL-denied topic) must NOT throw — tearing the session down
/// forever because one of many topics is denied would take the whole driver dark. Only the
/// denied references degrade.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_PartialRejection_DoesNotThrow_OnlyDeniedRefsDegrade()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
transport.Reject = t => t == "f/oven/press";
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError);
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
}
// ---------------------------------------------------------------------------------
// C1 — wildcard-authored tags on the paths that reconstruct state from a FILTER string.
// Delivery matches an incoming topic (concrete); reconnect + degrade key off the subscribed
// filter, which for a wildcard tag is its PATTERN. Reading the concrete-only index there
// silently drops the tag: no exception, no log, last Good value frozen forever.
// ---------------------------------------------------------------------------------
[Fact]
public async Task OnReconnectedAsync_WildcardAuthoredTopic_IsReSubscribed()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(1);
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(2);
transport.Calls[1].Select(f => f.Topic).ShouldBe(["f/+/temp"]);
}
/// <summary>
/// The end-to-end shape of the defect: after a reconnect the wildcard tag must still receive
/// data. A dropped re-subscribe is invisible in every other assertion — the cache keeps its last
/// Good value, so nothing turns Bad.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_WildcardAuthoredTopic_KeepsDeliveringAfterwards()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
// The broker only forwards what it was re-subscribed to; model that by asserting the filter is
// live, then that a matching publish still lands.
transport.Calls[^1].Select(f => f.Topic).ShouldContain("f/+/temp");
object? got = null;
mgr.OnDataChange += (_, e) => got = e.Snapshot.Value;
mgr.HandleMessage("f/oven/temp", "9.5"u8.ToArray(), retained: false);
got.ShouldBe(9.5);
}
/// <summary>
/// A total re-subscribe failure must throw for a wildcard-only driver too. Before the fix the
/// wildcard tag never reached the filter set, so <c>filters.Count == 0</c> took a silent early
/// return and the caller was told everything was fine.
/// </summary>
[Fact]
public async Task OnReconnectedAsync_WildcardOnly_TotalFailure_StillThrows()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
transport.Throw = new InvalidOperationException("broker gone");
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task SubscribeAsync_WildcardAuthoredTopic_SubackRejection_DegradesItsRefs()
{
var transport = new FakeTransport { Reject = _ => true };
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, """{"topic":"f/+/temp","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
// Before the fix this stayed at BadWaitingForInitialData — the broker refused the subscription
// and the tag never learned it was unreachable.
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
}
[Fact]
public async Task OnReconnectedAsync_WildcardAuthoredTopic_PartialRejection_DegradesTheWildcardRefs()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register(
[
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag(OvenPressPath, """{"topic":"f/+/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
]);
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
transport.Reject = t => t == "f/+/press";
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(BadCommunicationError);
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(Good);
}
// ---------------------------------------------------------------------------------
// I2 — bounded decode on the dispatcher thread
// ---------------------------------------------------------------------------------
[Fact]
public async Task HandleMessage_PayloadOverTheCeiling_IsRefusedBeforeDecode()
{
var mgr = new MqttSubscriptionManager(
new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1", maxPayloadBytes: 16);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"String"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, new byte[17], retained: false);
snap!.StatusCode.ShouldBe(BadDecodingError);
// …and a body at the ceiling still flows.
mgr.HandleMessage(OvenTopic, "0123456789ABCDEF"u8.ToArray(), retained: false);
snap.StatusCode.ShouldBe(Good);
}
[Fact]
public void MaxPayloadBytes_NonPositive_FallsBackToTheDefault() =>
new MqttSubscriptionManager(new MqttDriverOptions(), "d", maxPayloadBytes: 0)
.MaxPayloadBytes.ShouldBe(MqttSubscriptionManager.DefaultMaxPayloadBytes);
// ---------------------------------------------------------------------------------
// I3 — integral-valued reals against integer tags
// ---------------------------------------------------------------------------------
[Theory]
[InlineData("Int32", """{"value":5.0}""", 5)]
[InlineData("Int32", """{"value":5}""", 5)]
[InlineData("Int64", """{"value":5.0}""", 5L)]
[InlineData("Int16", """{"value":5.0}""", (short)5)]
[InlineData("UInt32", """{"value":5.0}""", 5u)]
public async Task HandleMessage_Json_IntegralValuedReal_CoercesToIntegerTag(
string dataType, string payload, object expected)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(expected);
}
/// <summary>A genuinely fractional value is still refused — never rounded into a wrong value.</summary>
[Fact]
public async Task HandleMessage_Json_FractionalReal_StillRefusedByIntegerTag()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int32"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, """{"value":5.5}"""u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(BadTypeMismatch);
snap.Value.ShouldBeNull();
}
[Fact]
public async Task HandleMessage_Json_OutOfRangeIntegralReal_IsRefused()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Int16"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, """{"value":99999.0}"""u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(BadTypeMismatch);
}
[Fact]
public async Task HandleMessage_Scalar_IntegralValuedReal_CoercesToIntegerTag()
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Int32"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, "5.0"u8.ToArray(), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(5);
}
// ---------------------------------------------------------------------------------
// I5 — a JSON string holding a number, against a numeric tag (documented as supported)
// ---------------------------------------------------------------------------------
[Theory]
[InlineData("Float64", """{"value":"21.5"}""", 21.5)]
[InlineData("Int32", """{"value":"5"}""", 5)]
[InlineData("Boolean", """{"value":"true"}""", true)]
public async Task HandleMessage_Json_StringHoldingANumber_CoercesToTheNumericTag(
string dataType, string payload, object expected)
{
var mgr = NewManager();
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"{{dataType}}"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
DataValueSnapshot? snap = null;
mgr.OnDataChange += (_, e) => snap = e.Snapshot;
mgr.HandleMessage(OvenTopic, System.Text.Encoding.UTF8.GetBytes(payload), retained: false);
snap!.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe(expected);
}
// ---------------------------------------------------------------------------------
// Stale-state pruning on redeploy
// ---------------------------------------------------------------------------------
[Fact]
public async Task Register_DroppingATag_StopsItsTopicBeingReSubscribedForever()
{
var transport = new FakeTransport();
var mgr = NewManager(transport);
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.Register([]); // redeploy drops the tag
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
transport.Calls.Count.ShouldBe(1); // the initial subscribe only — no zombie re-subscribe
}
/// <summary>Records every subscribe call so dedupe / re-subscribe can be asserted without a broker.</summary>
private sealed class FakeTransport : IMqttSubscribeTransport
{
public List<IReadOnlyList<MqttTopicSubscription>> Calls { get; } = [];
/// <summary>Topics for which the fake SUBACK reports a rejection.</summary>
public Func<string, bool> Reject { get; set; } = _ => false;
/// <summary>When set, the whole subscribe call throws instead of answering.</summary>
public Exception? Throw { get; set; }
public Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
IReadOnlyList<MqttTopicSubscription> filters, CancellationToken cancellationToken)
{
Calls.Add(filters);
if (Throw is not null) throw Throw;
IReadOnlyList<MqttSubscribeOutcome> outcomes =
[.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, !Reject(f.Topic), Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1"))];
return Task.FromResult(outcomes);
}
}
}
@@ -0,0 +1,262 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers the two entry points of <see cref="MqttTagDefinitionFactory"/> and their deliberately
/// different strictness contracts: <c>FromTagConfig</c> is the runtime path (never throws, a
/// malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
/// <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
/// deploy instead of going dark at runtime).
/// </summary>
public sealed class MqttTagDefinitionFactoryTests
{
private const string RawPath = "Plant/Mqtt/oven/Temp";
[Fact]
public void FromTagConfig_PlainJsonBlob_PopulatesTopicAndPath()
{
// NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is
// DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is therefore a
// typo the strict read rejects — pinned by FromTagConfig_TypoedDataType_RejectsStrict.
const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}""";
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
def.Topic.ShouldBe("factory/oven/temp");
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
def.JsonPath.ShouldBe("$.value");
def.DataType.ShouldBe(DriverDataType.Float64);
def.Qos.ShouldBe(1);
}
/// <summary>
/// The v3 identity contract, stated explicitly so a regression back to blob-keying goes red.
/// <see cref="MqttTagDefinition.Name"/> MUST be the RawPath the driver was handed — it is the key
/// <c>EquipmentTagRefResolver</c> looks up, the key <c>OnDataChange</c> publishes under, and the
/// key <c>DriverHostActor</c> fans out to the raw + UNS NodeIds. Keying it by the TagConfig blob
/// (the pre-v3, now-retired shape) would leave the driver silently dead in production while every
/// other test here still passed.
/// </summary>
[Fact]
public void FromTagConfig_DefinitionIdentity_IsTheRawPath_NotTheBlob()
{
const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Raw"}""";
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
def.Name.ShouldBe(RawPath);
def.Name.ShouldNotBe(blob);
}
/// <summary>
/// The identity is whatever RawPath the caller supplies — the factory must never re-derive it
/// from the blob's contents (e.g. from <c>topic</c>), or two tags sharing a topic would collide.
/// </summary>
[Theory]
[InlineData("Plant/Mqtt/oven/Temp")]
[InlineData("SiteA/Mqtt/line3/Oven/Setpoint")]
public void FromTagConfig_UsesSuppliedRawPathVerbatim(string rawPath)
{
MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"shared/topic","payloadFormat":"Raw"}""", rawPath, out var def).ShouldBeTrue();
def.Name.ShouldBe(rawPath);
}
[Fact]
public void FromTagConfig_TypoedPayloadFormat_RejectsStrict()
=> MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void FromTagConfig_TypoedDataType_RejectsStrict()
=> MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Inspect_WildcardTopic_ReturnsWarning()
{
// The blob is otherwise clean (payloadFormat parses, dataType + qos absent), so the wildcard
// check is the ONLY thing that can produce a warning — this cannot pass vacuously.
var warnings = MqttTagDefinitionFactory.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""");
warnings.ShouldNotBeEmpty();
warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive);
}
[Theory]
[InlineData("a/#")]
[InlineData("+/b/c")]
[InlineData("a/+/c")]
public void Inspect_EachWildcardForm_Warns(string topic)
=> MqttTagDefinitionFactory.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
[Fact]
public void FromTagConfig_WildcardTopic_StillParses_WarningIsDeployTimeOnly()
{
// A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the
// designed surface for it. Rejecting it at runtime would turn an authoring mistake into a
// silent BadNodeIdUnknown with no operator-visible cause.
MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"a/+/c","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
def.Topic.ShouldBe("a/+/c");
}
[Fact]
public void FromTagConfig_RawFormat_AppliesDefaults()
{
MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"factory/oven/blob","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw);
def.JsonPath.ShouldBe("$"); // absent ⇒ the root default
def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins
def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message
def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback
}
[Fact]
public void FromTagConfig_ExplicitRetainSeedFalse_IsHonoured()
{
MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", RawPath, out var def).ShouldBeTrue();
def.RetainSeed.ShouldBeFalse();
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void FromTagConfig_EachLegalQos_IsAccepted(int qos)
{
MqttTagDefinitionFactory.FromTagConfig(
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qos}}}""", RawPath, out var def).ShouldBeTrue();
def.Qos.ShouldBe(qos);
}
[Fact]
public void FromTagConfig_PlainBlob_LeavesSparkplugDescriptorFieldsNull()
{
// P2 (Tasks 1526) fills these; a plain-mode blob must leave them unset so a future
// mode discriminator cannot mistake a plain tag for a Sparkplug one.
MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"a/b","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
def.GroupId.ShouldBeNull();
def.EdgeNodeId.ShouldBeNull();
def.DeviceId.ShouldBeNull();
def.MetricName.ShouldBeNull();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
[InlineData("[1,2,3]")] // valid JSON, wrong root kind
[InlineData("{ not json at all")] // unparseable — must not throw
[InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
[InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic
[InlineData("""{"topic":" ","payloadFormat":"Raw"}""")] // whitespace-only topic
public void FromTagConfig_MalformedBlob_ReturnsFalseAndNeverThrows(string? tagConfig)
=> MqttTagDefinitionFactory.FromTagConfig(tagConfig!, RawPath, out _).ShouldBeFalse();
/// <summary>
/// A present-but-invalid <c>qos</c> is rejected in every malformed shape, not just the
/// out-of-range numeric one. Silently absorbing <c>"high"</c> / <c>1.5</c> into the driver-level
/// default would hand the operator a WEAKER delivery guarantee than the one they authored.
/// </summary>
[Theory]
[InlineData("5")] // out of range
[InlineData("-1")] // out of range
[InlineData("\"high\"")] // wrong JSON type
[InlineData("\"1\"")] // stringly-typed number
[InlineData("1.5")] // non-integer
[InlineData("true")] // wrong JSON type
[InlineData("null")] // an explicit null is not "absent"
public void FromTagConfig_MalformedQos_RejectsStrict(string qosToken)
=> MqttTagDefinitionFactory.FromTagConfig(
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Inspect_CleanBlob_ReturnsEmpty()
=> MqttTagDefinitionFactory.Inspect(
"""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":2}""").ShouldBeEmpty();
[Fact]
public void Inspect_TypoedEnums_WarnsPerField()
{
var warnings = MqttTagDefinitionFactory.Inspect(
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""");
warnings.Count.ShouldBe(2);
warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal));
warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal));
}
[Theory]
[InlineData("5")]
[InlineData("\"high\"")]
[InlineData("1.5")]
public void Inspect_MalformedQos_Warns(string qosToken)
{
var warnings = MqttTagDefinitionFactory.Inspect(
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""");
warnings.ShouldHaveSingleItem().ShouldContain("qos", Case.Sensitive);
}
/// <summary>Every warning source fires together — the pass reports all of them, not just the first.</summary>
[Fact]
public void Inspect_MultipleProblems_ReportsAll()
{
var warnings = MqttTagDefinitionFactory.Inspect(
"""{"topic":"a/+/c","payloadFormat":"Jason","dataType":"Double","qos":9}""");
warnings.Count.ShouldBe(4);
}
[Fact]
public void Inspect_UnparseableBlob_Warns()
=> MqttTagDefinitionFactory.Inspect("{ not json at all").ShouldNotBeEmpty();
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("factory/oven/temp")]
[InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
=> MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty();
[Fact]
public void TagConfigKeys_AreTheWireNames_AndAreNotFreeToRename()
{
// MqttTagConfigKeys single-sources the address key names across the producer (the AdminUI
// browse-commit mapper) and the consumer (this factory) — which means a SYMMETRIC rename would
// keep every round-trip test green while silently unbinding every ALREADY-PERSISTED TagConfig
// blob in the config DB. These are wire names; pin them to the literals.
MqttTagConfigKeys.Topic.ShouldBe("topic");
MqttTagConfigKeys.GroupId.ShouldBe("groupId");
MqttTagConfigKeys.EdgeNodeId.ShouldBe("edgeNodeId");
MqttTagConfigKeys.DeviceId.ShouldBe("deviceId");
MqttTagConfigKeys.MetricName.ShouldBe("metricName");
}
[Fact]
public void TagConfigKeys_AreTheKeysTheFactoryActuallyReads()
{
// The pin above is only worth having if the factory really reads THESE keys — a blob written
// entirely from the constants must parse.
var blob = $$"""
{"{{MqttTagConfigKeys.GroupId}}":"G",
"{{MqttTagConfigKeys.EdgeNodeId}}":"E",
"{{MqttTagConfigKeys.DeviceId}}":"D",
"{{MqttTagConfigKeys.MetricName}}":"M"}
""";
MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, RawPath, out var def).ShouldBeTrue();
def.GroupId.ShouldBe("G");
def.EdgeNodeId.ShouldBe("E");
def.DeviceId.ShouldBe("D");
def.MetricName.ShouldBe("M");
MqttTagDefinitionFactory
.FromTagConfig($$"""{"{{MqttTagConfigKeys.Topic}}":"a/b"}""", RawPath, out var plain)
.ShouldBeTrue();
plain.Topic.ShouldBe("a/b");
}
}
@@ -0,0 +1,208 @@
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="RebirthRequester"/> (Task 20) — the NCMD encode + bounded publish seam that
/// Task 21 (ingest gap/unknown-alias/data-before-birth detection) and Task 23 (the browser's
/// operator-triggered scoped rebirth) both call into.
/// </summary>
public sealed class RebirthRequesterTests
{
// -----------------------------------------------------------------------------------------
// Build — pure encode. The plan's own test, verbatim.
// -----------------------------------------------------------------------------------------
[Fact]
public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
{
var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
}
/// <summary>
/// Falsifiability control for the test above: a metric named anything else must NOT satisfy the
/// assertion — proves the <c>ShouldContain</c> predicate is actually discriminating on the name,
/// not vacuously true because <see cref="SparkplugCodec.Decode"/> always returns something.
/// </summary>
[Fact]
public void BuildRebirthNcmd_DoesNotContainAWrongMetricName()
{
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.Metrics.ShouldNotContain(m => m.Name == "Node Control/NotRebirth" && Equals(m.Value, true));
}
/// <summary>
/// Falsifiability control: a boolean <c>false</c> must not satisfy the plan's assertion either —
/// proves the test is checking the metric's <i>value</i>, not merely its presence.
/// </summary>
[Fact]
public void BuildRebirthNcmd_ValueIsTrue_NotFalse()
{
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.Metrics.ShouldNotContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, false));
}
[Fact]
public void Build_DifferentGroupAndNode_ProducesMatchingTopic()
{
var (topic, _) = RebirthRequester.Build("Line7", "Filler42");
topic.ShouldBe("spBv1.0/Line7/NCMD/Filler42");
}
[Theory]
[InlineData(null, "EdgeA")]
[InlineData("", "EdgeA")]
[InlineData("Plant1", null)]
[InlineData("Plant1", "")]
public void Build_MissingGroupOrNode_Throws(string? groupId, string? edgeNodeId)
=> Should.Throw<ArgumentException>(() => RebirthRequester.Build(groupId!, edgeNodeId!));
// -----------------------------------------------------------------------------------------
// Encoded metric shape — datatype + no bogus seq
// -----------------------------------------------------------------------------------------
[Fact]
public void Build_MetricCarriesBooleanDatatype()
{
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
var p = SparkplugCodec.Decode(bytes);
var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName);
metric.DataType.ShouldBe(TahuDataType.Boolean);
}
/// <summary>
/// A host-issued NCMD is not part of the edge node's own outbound sequence stream (Sparkplug
/// §6.4.1) — stamping a <c>seq</c> here would be meaningless at best. See the design rationale
/// on <see cref="RebirthRequester"/>.
/// </summary>
[Fact]
public void Build_PayloadCarriesNoSeq()
{
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.Seq.ShouldBeNull();
}
[Fact]
public void Build_PayloadAndMetricCarryATimestamp()
{
var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.TimestampMs.ShouldNotBeNull();
var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName);
metric.TimestampMs.ShouldNotBeNull();
}
// -----------------------------------------------------------------------------------------
// RequestAsync — the bounded publish seam
// -----------------------------------------------------------------------------------------
private sealed class RecordingTransport : IMqttPublishTransport
{
public int PublishCount { get; private set; }
public string? LastTopic { get; private set; }
public byte[]? LastPayload { get; private set; }
public int LastQos { get; private set; } = -1;
public bool LastRetain { get; private set; }
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
{
PublishCount++;
LastTopic = topic;
LastPayload = payload;
LastQos = qos;
LastRetain = retain;
return Task.CompletedTask;
}
}
[Fact]
public async Task RequestAsync_PublishesExactlyOnce_WithBuiltTopicAndBytes()
{
var transport = new RecordingTransport();
await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);
transport.PublishCount.ShouldBe(1);
transport.LastTopic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
var decoded = SparkplugCodec.Decode(transport.LastPayload!);
decoded.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
}
/// <summary>
/// Retain MUST be false — a retained NCMD replays to the edge node on every future
/// subscribe/reconnect, driving a permanent rebirth loop from one stale command (see the
/// carry-forward landmine remarks on <see cref="RebirthRequester"/>).
/// </summary>
[Fact]
public async Task RequestAsync_NeverRetains()
{
var transport = new RecordingTransport();
await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);
transport.LastRetain.ShouldBeFalse();
}
[Fact]
public async Task RequestAsync_UsesQos0()
{
var transport = new RecordingTransport();
await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken);
transport.LastQos.ShouldBe(0);
}
[Fact]
public async Task RequestAsync_NullTransport_Throws()
=> await Should.ThrowAsync<ArgumentNullException>(
() => RebirthRequester.RequestAsync(null!, "Plant1", "EdgeA", TestContext.Current.CancellationToken));
/// <summary>
/// A transport that hangs forever must not be able to park the caller — the cross-cutting rule
/// every network call in this driver follows (mirrors <c>MqttConnection.SubscribeAsync</c>).
/// </summary>
private sealed class HangingTransport : IMqttPublishTransport
{
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
=> Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
[Fact]
public async Task RequestAsync_BoundedByDeadline_ThrowsRatherThanHangs()
{
var transport = new HangingTransport();
await Should.ThrowAsync<OperationCanceledException>(() => RebirthRequester.RequestAsync(
transport,
"Plant1",
"EdgeA",
TestContext.Current.CancellationToken,
timeout: TimeSpan.FromMilliseconds(50)));
}
[Fact]
public async Task RequestAsync_CallerCancellation_Propagates()
{
var transport = new HangingTransport();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(
() => RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", cts.Token));
}
}
@@ -0,0 +1,474 @@
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="SequenceTracker"/> — design doc §3.6 invariant #3. Two halves, each with a
/// distinct failure mode the tests below pin from both directions:
/// <list type="bullet">
/// <item>
/// <b><c>seq</c> gap detection across the 0255 wrap.</b> Reading 255→0 as a gap
/// rebirth-storms every edge node once per 256 messages; missing a real gap serves stale
/// data at Good quality forever. Both directions are asserted.
/// </item>
/// <item>
/// <b><c>bdSeq</c> birth↔death tie.</b> A late Last-Will NDEATH from a previous session
/// arriving after the new NBIRTH must not kill the freshly-born node.
/// </item>
/// </list>
/// </summary>
public sealed class SequenceTrackerTests
{
// ---------------------------------------------------------------------------------------
// seq — the wrap boundary
// ---------------------------------------------------------------------------------------
/// <summary>
/// The plan's headline case: 255 → 0 is the sequence continuing, not a gap. A tracker that
/// reads the wrap as a gap requests a rebirth every 256 messages, and a busy edge node then
/// spends its life republishing births instead of data.
/// </summary>
[Fact]
public void Sequence_WrapAt255IsContiguous_NotAGap()
{
var s = new SequenceTracker();
s.Accept(254).ShouldBeTrue();
s.Accept(255).ShouldBeTrue();
s.Accept(0).ShouldBeTrue(); // wrap
}
/// <summary>
/// The plan's mirror case: a skipped value is a genuine gap. Missing it means the driver
/// silently keeps serving values it knows are behind the edge node's real state.
/// </summary>
[Fact]
public void Sequence_SkippedValue_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
}
/// <summary>
/// Two-and-a-bit full cycles of the 0255 range report no gap at any point. The pair of
/// single-step tests above can both pass on an off-by-one wrap that only misfires at one
/// specific value; walking every value in the range leaves nowhere for that to hide.
/// </summary>
[Fact]
public void Sequence_FullWrapCycles_NeverReportAGap()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0).ShouldBeTrue();
for (var i = 1; i <= 600; i++)
{
var seq = (ulong)(i % 256);
s.Accept(seq).ShouldBeTrue($"seq {seq} (message {i}) was reported as a gap");
}
}
/// <summary>
/// A gap is reported <b>once</b> and the tracker then resynchronizes onto the observed
/// sequence. Leaving the baseline pinned at the pre-gap value would make every subsequent
/// message report a gap too, turning one lost message into a permanent rebirth storm — the
/// same pathology as a wrong wrap boundary, reached by a different route.
/// </summary>
[Fact]
public void Sequence_GapReportedOnce_ThenResynchronizes()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(13).ShouldBeFalse(); // three messages lost
s.Accept(14).ShouldBeTrue(); // resynchronized onto 13
s.Accept(15).ShouldBeTrue();
}
/// <summary>
/// A repeated sequence number is not contiguous. Sparkplug publishes DATA at QoS 0, so a
/// duplicate is not a broker redelivery — it is a publisher whose stream no longer matches
/// what the driver can reason about, and a rebirth is the cheap correct answer.
/// </summary>
[Fact]
public void Sequence_RepeatedValue_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(10).ShouldBeFalse();
}
/// <summary>A sequence number going backwards is a gap, not a wrap.</summary>
[Fact]
public void Sequence_BackwardJump_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(9).ShouldBeFalse();
}
/// <summary>
/// The first message cannot be a gap — there is no baseline to measure it against — but the
/// tracker must still report that it is <b>not</b> birth-synchronized, because a first
/// message that was not a birth means the driver joined mid-stream and has no alias table.
/// Conflating the two would let a late join look like a healthy synchronized node.
/// </summary>
[Fact]
public void Sequence_FirstMessage_IsNotAGap_ButIsNotBirthSynchronized()
{
var s = new SequenceTracker();
s.IsBirthSynchronized.ShouldBeFalse();
s.LastSeq.ShouldBeNull();
s.Accept(137).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)137);
s.IsBirthSynchronized.ShouldBeFalse("a mid-stream first message is a late join, not a birth");
}
/// <summary>
/// <b>The aliasing test.</b> The codec hands over <c>seq</c> as a <see cref="ulong"/> and
/// deliberately declines to truncate it, so an out-of-range value reaches this type intact. A
/// tracker that masks it into range turns 256 into a perfectly plausible 0 — and with the
/// baseline sitting at 255, that is exactly the value the wrap expects next, so the bogus
/// message would be accepted as contiguous.
/// </summary>
[Fact]
public void Sequence_OutOfRangeSeq_IsRejected_AndDoesNotAliasOntoTheExpectedValue()
{
var s = new SequenceTracker();
s.Accept(255).ShouldBeTrue();
// 256 & 0xFF == 0, and 0 is precisely what follows 255. Masking would return true here.
s.Accept(256).ShouldBeFalse();
}
/// <summary>
/// A rejected out-of-range value does not become the baseline either: the tracker keeps the
/// last credible sequence number, so the following genuine message is still measured against
/// something real.
/// </summary>
[Fact]
public void Sequence_OutOfRangeSeq_DoesNotBecomeTheBaseline()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(ulong.MaxValue).ShouldBeFalse();
s.LastSeq.ShouldBe((byte)10);
s.Accept(11).ShouldBeTrue("the baseline must still be the last credible seq, 10");
}
/// <summary>
/// A message carrying no sequence number at all is a spec violation the tracker cannot place
/// in the stream. It reports a gap (the safe direction — a rebirth resynchronizes everything)
/// and leaves the baseline alone.
/// </summary>
[Fact]
public void Sequence_AbsentSeq_IsGap_AndDoesNotAdvanceTheBaseline()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(null).ShouldBeFalse();
s.LastSeq.ShouldBe((byte)10);
s.Accept(11).ShouldBeTrue();
}
// ---------------------------------------------------------------------------------------
// seq — births restart the sequence
// ---------------------------------------------------------------------------------------
/// <summary>
/// A birth restarts the sequence at 0, so it is never itself a gap however far the previous
/// session had counted. A tracker that ran the birth through the ordinary contiguity check
/// would flag a gap on the very message that just fixed everything, and request a rebirth in
/// response to a birth.
/// </summary>
[Fact]
public void Birth_RestartsTheSequence_SoABirthIsNeverAGap()
{
var s = new SequenceTracker();
s.Accept(200).ShouldBeTrue();
s.AcceptNodeBirth(0).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)0);
s.IsBirthSynchronized.ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
}
/// <summary>
/// A gap detected <i>after</i> a birth is genuine — the birth resets the baseline, it does not
/// suppress detection. Without this the previous test could be satisfied by a tracker that
/// simply stopped checking once a birth arrived.
/// </summary>
[Fact]
public void Birth_ThenSkippedValue_IsStillAGap()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0).ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
s.Accept(3).ShouldBeFalse();
}
/// <summary>
/// A birth whose <c>seq</c> is in range but not the spec-required 0 is adopted as the
/// baseline rather than rejected. Refusing it would leave the tracker unsynchronized against
/// a publisher whose stream is otherwise perfectly followable, and every following message
/// would demand another rebirth.
/// </summary>
[Fact]
public void Birth_WithNonZeroInRangeSeq_IsAdoptedAsTheBaseline()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(7).ShouldBeTrue();
s.LastSeq.ShouldBe((byte)7);
s.IsBirthSynchronized.ShouldBeTrue();
s.Accept(8).ShouldBeTrue();
}
/// <summary>
/// A birth whose <c>seq</c> is out of range establishes no baseline — the same
/// no-masking rule as the data path, applied where it matters most.
/// </summary>
[Fact]
public void Birth_WithOutOfRangeSeq_EstablishesNoBaseline()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(256).ShouldBeFalse();
s.LastSeq.ShouldBeNull();
s.IsBirthSynchronized.ShouldBeFalse();
}
/// <summary>
/// <b>A DBIRTH continues the edge node's sequence — it does not restart it, and it must not be
/// routed through <see cref="SequenceTracker.AcceptNodeBirth"/>.</b> Only the NBIRTH resets
/// <c>seq</c>, and only the NBIRTH/NDEATH carry <c>bdSeq</c>. Sending a DBIRTH to the node-birth
/// entry point would rebase the sequence mid-stream <i>and</i> — having no <c>bdSeq</c> to pass
/// — wipe the session token, at which point a stale Last Will finds nothing to be compared
/// against and kills the live node. This test pins the shape at the tracker's own boundary so
/// the ingest state machine has something to fail against if it wires the call sites the other
/// way round.
/// </summary>
[Fact]
public void DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 7).ShouldBeTrue();
s.Accept(1).ShouldBeTrue(); // the DBIRTH — next in the node's stream, not a restart
s.Accept(2).ShouldBeTrue(); // first DDATA
s.BirthBdSeq.ShouldBe(7UL, "a device birth must not disturb the node's session token");
s.IsDeathForCurrentSession(6).ShouldBeFalse("the stale-will guard must still have a token to compare");
}
/// <summary>
/// <see cref="SequenceTracker.Reset"/> returns the tracker to its virgin state. This is the
/// reconnect seam: after a dropped connection the driver has missed an unknown number of
/// messages, so the baseline it held is no longer evidence of anything.
/// </summary>
[Fact]
public void Reset_ClearsTheBaselineAndTheBirthSynchronizedFlag()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 4).ShouldBeTrue();
s.Accept(1).ShouldBeTrue();
s.Reset();
s.LastSeq.ShouldBeNull();
s.IsBirthSynchronized.ShouldBeFalse();
s.BirthBdSeq.ShouldBeNull();
s.Accept(99).ShouldBeTrue("a virgin tracker cannot detect a gap on its first message");
}
// ---------------------------------------------------------------------------------------
// bdSeq — the birth↔death tie
// ---------------------------------------------------------------------------------------
/// <summary>
/// <b>The case the tie exists for.</b> A node's connection drops, it reconnects and publishes
/// a fresh NBIRTH — and only then does the broker notice the old connection died and deliver
/// its Last Will NDEATH, carrying the <i>previous</i> session's bdSeq. Without the compare,
/// that stale will marks a live, freshly-born node dead and every one of its tags goes Bad.
/// </summary>
[Fact]
public void Death_WithStaleBdSeqArrivingAfterARebirth_DoesNotApply()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 7); // session 7 — the one that is about to die
s.AcceptNodeBirth(0, bdSeq: 8); // session 8 — the node is back, and alive
s.IsDeathForCurrentSession(7).ShouldBeFalse("session 7's will must not kill session 8");
}
/// <summary>A death whose bdSeq matches the current birth is the real thing and applies.</summary>
[Fact]
public void Death_WithMatchingBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 8);
s.IsDeathForCurrentSession(8).ShouldBeTrue();
}
/// <summary>
/// A death arriving before any birth was ever seen applies. The tracker has nothing to
/// disprove it with, and the two failure directions are not symmetric: acting on a death
/// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4), while
/// ignoring a real death leaves a dead node's last values flowing at Good quality forever.
/// </summary>
[Fact]
public void Death_BeforeAnyBirth_Applies()
{
var s = new SequenceTracker();
s.IsDeathForCurrentSession(7).ShouldBeTrue();
}
/// <summary>
/// A death carrying no bdSeq metric cannot be tied to anything, so it applies — same
/// fail-toward-stale reasoning as above.
/// </summary>
[Fact]
public void Death_WithNoBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: 8);
s.IsDeathForCurrentSession(null).ShouldBeTrue();
}
/// <summary>A death applies when the <i>birth</i> was the one that carried no bdSeq.</summary>
[Fact]
public void Death_WhenTheBirthCarriedNoBdSeq_Applies()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(0, bdSeq: null);
s.IsDeathForCurrentSession(7).ShouldBeTrue();
}
/// <summary>
/// A birth records its bdSeq even when its <c>seq</c> was unusable. Session identity and
/// sequence position are independent facts; discarding the tie because the sequence number
/// was garbled would leave the next death unmatchable.
/// </summary>
[Fact]
public void Birth_WithUnusableSeq_StillRecordsItsBdSeq()
{
var s = new SequenceTracker();
s.AcceptNodeBirth(256, bdSeq: 8).ShouldBeFalse();
s.BirthBdSeq.ShouldBe(8UL);
s.IsDeathForCurrentSession(7).ShouldBeFalse();
}
// ---------------------------------------------------------------------------------------
// bdSeq — reading it out of a payload
// ---------------------------------------------------------------------------------------
/// <summary>
/// <c>bdSeq</c> is not a top-level payload field: it rides as a metric named <c>bdSeq</c> in
/// the NBIRTH and the NDEATH.
/// </summary>
[Fact]
public void TryReadBdSeq_ReadsTheBdSeqMetric()
{
var payload = PayloadWith(Metric("bdSeq", DataType.Int64, 8UL));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
bdSeq.ShouldBe(8UL);
}
/// <summary>
/// Sparkplug carries <c>Int64</c> as two's complement in an unsigned field, so a negative
/// bdSeq reaches the projection as an enormous <see cref="ulong"/>. Reading it raw would
/// mint a plausible-looking session token out of nonsense; the reinterpretation is what
/// exposes it as negative so it can be refused.
/// </summary>
[Fact]
public void TryReadBdSeq_NegativeSignedValue_IsRefused_NotReadAsAHugeToken()
{
var payload = PayloadWith(Metric("bdSeq", DataType.Int64, unchecked((ulong)-1L)));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
bdSeq.ShouldBe(0UL);
}
/// <summary>
/// An unsigned bdSeq is read as-is — no reinterpretation applies. (The generated enum member
/// is <c>Uint64</c>, not <c>UInt64</c>: protobuf's C# codegen lowercases the interior of the
/// proto's <c>UInt64</c>.)
/// </summary>
[Fact]
public void TryReadBdSeq_UnsignedValue_IsReadAsIs()
{
var payload = PayloadWith(Metric("bdSeq", DataType.Uint64, 9UL));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
bdSeq.ShouldBe(9UL);
}
/// <summary>A payload that carries no bdSeq metric reports absence rather than a default.</summary>
[Fact]
public void TryReadBdSeq_PayloadWithoutTheMetric_ReportsAbsence()
{
var payload = PayloadWith(Metric("Temperature", DataType.Float, 21.5f));
SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
bdSeq.ShouldBe(0UL);
}
/// <summary>
/// An undecodable payload yields no bdSeq. It presents as metric-less exactly like a valid
/// but empty one, and only <see cref="SparkplugPayload.IsValid"/> tells them apart.
/// </summary>
[Fact]
public void TryReadBdSeq_InvalidPayload_ReportsAbsence()
{
SequenceTracker.TryReadBdSeq(SparkplugPayload.Invalid, out _).ShouldBeFalse();
}
/// <summary>A null payload is a verdict, not an exception — this runs on the dispatcher thread.</summary>
[Fact]
public void TryReadBdSeq_NullPayload_ReportsAbsence_DoesNotThrow()
{
Should.NotThrow(() => SequenceTracker.TryReadBdSeq(null, out _).ShouldBeFalse());
}
/// <summary>
/// A bdSeq metric with no scalar value (explicitly null, or a kind v1 does not support) is
/// not a session token.
/// </summary>
[Fact]
public void TryReadBdSeq_NonScalarMetric_ReportsAbsence()
{
var payload = PayloadWith(new SparkplugMetric("bdSeq", null, DataType.Int64, null, SparkplugValueKind.Null, null));
SequenceTracker.TryReadBdSeq(payload, out _).ShouldBeFalse();
}
private static SparkplugPayload PayloadWith(params SparkplugMetric[] metrics) =>
new(IsValid: true, Seq: 0UL, TimestampMs: 1UL, Metrics: metrics);
private static SparkplugMetric Metric(string name, DataType dataType, object value) =>
new(name, Alias: null, dataType, TimestampMs: null, SparkplugValueKind.Scalar, value);
}
@@ -0,0 +1,392 @@
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="SparkplugCodec"/>: the golden NBIRTH/NDATA vectors decode into the driver-side
/// projection with explicit presence preserved, and no input — malformed, truncated, empty, or
/// simply not Sparkplug — makes the decoder throw.
/// </summary>
public sealed class SparkplugCodecTests
{
/// <summary>
/// The headline case from the plan: a golden NBIRTH yields its sequence number and its metric
/// catalog, with name, alias, datatype and value all surviving.
/// </summary>
[Fact]
public void Decode_GoldenNbirth_ExtractsMetrics()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
payload.IsValid.ShouldBeTrue();
payload.Seq.ShouldBe(0UL);
payload.Metrics.ShouldContain(m =>
m.Name == "Temperature"
&& m.Alias == 5UL
&& m.DataType == DataType.Float
&& Equals(m.Value, 21.5f));
}
/// <summary>
/// <b>Explicit presence, case 1.</b> A Sparkplug NBIRTH is REQUIRED to carry <c>seq = 0</c>, so a
/// decoder that reads absence off a zero value reports "no sequence" for every birth ever
/// published — and the sequence tracker then has no birth to anchor on.
/// </summary>
[Fact]
public void Decode_GoldenNbirth_SeqZero_IsPresentNotAbsent()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
payload.Seq.HasValue.ShouldBeTrue();
payload.Seq!.Value.ShouldBe(0UL);
}
/// <summary>
/// <b>Explicit presence, case 2.</b> A payload that genuinely omits <c>seq</c> must report
/// absence, not zero — the mirror image of the test above, and the pair is what makes the
/// distinction falsifiable rather than incidental.
/// </summary>
[Fact]
public void Decode_PayloadWithoutSeq_ReportsAbsentSeq()
{
var wire = new Payload { Timestamp = 1UL }.ToByteArray();
SparkplugCodec.Decode(wire).Seq.ShouldBeNull();
}
/// <summary>
/// <b>Explicit presence, case 3.</b> Every real Sparkplug DATA metric omits <c>name</c> and
/// <c>datatype</c> and carries only its alias. Reporting an absent name as the empty string
/// would make it indistinguishable from a (malformed) metric genuinely named "".
/// </summary>
[Fact]
public void Decode_GoldenNdata_MetricsCarryAliasOnly_NameAndDataTypeAbsent()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NdataFile));
payload.IsValid.ShouldBeTrue();
payload.Seq.ShouldBe(1UL);
payload.Metrics.Count.ShouldBe(3);
foreach (var metric in payload.Metrics)
{
metric.Name.ShouldBeNull();
metric.DataType.ShouldBeNull();
metric.Alias.HasValue.ShouldBeTrue();
}
payload.Metrics.ShouldContain(m => m.Alias == SparkplugGoldenPayloads.TemperatureAlias
&& Equals(m.Value, 22.25f));
}
/// <summary>
/// <b>Explicit presence, case 4.</b> "The metric's value is null" (<c>is_null</c>) is a different
/// fact from "the metric carried no value field", and both differ from "the value is zero".
/// </summary>
[Fact]
public void Decode_GoldenNbirth_ExplicitNullMetric_IsNullKind()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
var missing = payload.Metrics.Single(m => m.Name == "Missing");
missing.ValueKind.ShouldBe(SparkplugValueKind.Null);
missing.Value.ShouldBeNull();
missing.DataType.ShouldBe(DataType.Float);
}
/// <summary>
/// <b>Explicit presence, case 5 — the one that makes the other four falsifiable.</b> A publisher
/// may legitimately set a metric's name to the empty string, its datatype to <c>Unknown</c> (0)
/// and its timestamp to 0 (the Unix epoch). Those are present-and-default, and the golden
/// vectors alone cannot tell a presence check from a default check because nothing in them sets
/// a field to its own default. This pins the distinction directly: set-to-default must decode as
/// PRESENT, unset must decode as ABSENT, on every field that carries presence.
/// </summary>
[Fact]
public void Decode_FieldsSetToTheirOwnDefault_ArePresent_WhileUnsetFieldsAreAbsent()
{
var payload = new Payload();
payload.Metrics.Add(new Payload.Types.Metric
{
Name = string.Empty,
Alias = 0UL,
Datatype = (uint)DataType.Unknown,
Timestamp = 0UL,
IntValue = 0U,
});
payload.Metrics.Add(new Payload.Types.Metric { StringValue = "unset-everything-else" });
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
var present = decoded.Metrics[0];
present.Name.ShouldBe(string.Empty);
present.Alias.ShouldBe(0UL);
present.DataType.ShouldBe(DataType.Unknown);
present.TimestampMs.ShouldBe(0UL);
var absent = decoded.Metrics[1];
absent.Name.ShouldBeNull();
absent.Alias.ShouldBeNull();
absent.DataType.ShouldBeNull();
absent.TimestampMs.ShouldBeNull();
}
/// <summary>A metric carrying neither a value nor <c>is_null</c> decodes as absent, not null.</summary>
[Fact]
public void Decode_MetricWithNoValueAndNoIsNull_IsAbsentKind()
{
var payload = new Payload { Seq = 4UL };
payload.Metrics.Add(new Payload.Types.Metric { Name = "Bare" });
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
decoded.Metrics.Single().ValueKind.ShouldBe(SparkplugValueKind.Absent);
decoded.Metrics.Single().Value.ShouldBeNull();
}
/// <summary>
/// Every scalar arm of the metric value <c>oneof</c> projects to a CLR value of the wire's own
/// type — nothing is coerced here; coercion against the authored tag type happens downstream.
/// </summary>
[Fact]
public void Decode_GoldenNbirth_ProjectsEveryScalarValueArm()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
var byName = payload.Metrics.Where(m => m.Name is not null).ToDictionary(m => m.Name!, m => m);
byName["bdSeq"].Value.ShouldBe(7UL); // long_value → ulong
byName["Node Control/Rebirth"].Value.ShouldBe(false); // boolean_value → bool
byName["Temperature"].Value.ShouldBe(21.5f); // float_value → float
byName["Pressure"].Value.ShouldBe(101.325d); // double_value → double
byName["Running"].Value.ShouldBe(true);
byName["Serial"].Value.ShouldBe("EDGE-A-001"); // string_value → string
byName["Count"].Value.ShouldBe(unchecked((uint)-42)); // int_value → uint, RAW
}
/// <summary>A <c>bytes</c> metric projects to a byte array rather than a protobuf <c>ByteString</c>.</summary>
[Fact]
public void Decode_BytesMetric_ProjectsToByteArray()
{
var payload = new Payload();
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Blob",
Datatype = (uint)DataType.Bytes,
BytesValue = ByteString.CopyFrom(1, 2, 3),
});
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
decoded.Metrics.Single().Value.ShouldBeOfType<byte[]>().ShouldBe(new byte[] { 1, 2, 3 });
}
/// <summary>
/// <c>DataSet</c> and <c>Template</c> metrics are out of scope for v1. They must decode to an
/// explicit "unsupported" kind — never an exception on the MQTT dispatcher thread, and never a
/// silently-dropped metric that looks identical to a metric that was never published.
/// </summary>
[Fact]
public void Decode_GoldenNbirth_DataSetMetric_IsUnsupported_NotAThrow()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
var config = payload.Metrics.Single(m => m.Name == "Config");
config.ValueKind.ShouldBe(SparkplugValueKind.Unsupported);
config.Value.ShouldBeNull();
config.DataType.ShouldBe(DataType.DataSet);
}
/// <summary>A <c>Template</c> metric takes the same unsupported path as <c>DataSet</c>.</summary>
[Fact]
public void Decode_TemplateMetric_IsUnsupported()
{
var payload = new Payload();
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Motor",
Datatype = (uint)DataType.Template,
TemplateValue = new Payload.Types.Template { Version = "1", IsDefinition = false },
});
SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single().ValueKind
.ShouldBe(SparkplugValueKind.Unsupported);
}
/// <summary>Payload and per-metric timestamps survive as raw Sparkplug epoch milliseconds.</summary>
[Fact]
public void Decode_GoldenVectors_PreserveTimestamps()
{
var nbirth = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
nbirth.TimestampMs.ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs);
nbirth.Metrics.Single(m => m.Name == "Temperature").TimestampMs
.ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs);
// A metric that carried no timestamp of its own reports absence, not the payload's.
nbirth.Metrics.Single(m => m.Name == "Pressure").TimestampMs.ShouldBeNull();
}
/// <summary>
/// Sparkplug carries signed integers up to 32 bits as two's complement in the UNSIGNED
/// <c>int_value</c> field. The codec hands over the raw wire value; this is the helper that
/// turns it back into the number the edge node meant.
/// </summary>
[Theory]
[InlineData(DataType.Int8, -5)]
[InlineData(DataType.Int16, -1234)]
[InlineData(DataType.Int32, -42)]
public void ReinterpretSigned_RecoversNegativeIntegersFromTheUnsignedWireField(DataType datatype, int expected)
{
var raw = unchecked((uint)expected);
var result = SparkplugCodec.ReinterpretSigned(raw, datatype);
Convert.ToInt64(result).ShouldBe(expected);
}
/// <summary>The 64-bit arm reinterprets <c>long_value</c>; the golden NBIRTH's Count is the 32-bit arm.</summary>
[Fact]
public void ReinterpretSigned_HandlesInt64_AndTheGoldenCountMetric()
{
SparkplugCodec.ReinterpretSigned(unchecked((ulong)-9_000_000_000L), DataType.Int64).ShouldBe(-9_000_000_000L);
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
var count = payload.Metrics.Single(m => m.Name == "Count");
SparkplugCodec.ReinterpretSigned(count.Value, count.DataType!.Value)
.ShouldBe(SparkplugGoldenPayloads.CountBirthValue);
}
/// <summary>Unsigned and non-integer datatypes pass through untouched.</summary>
[Fact]
public void ReinterpretSigned_LeavesUnsignedAndNonIntegerValuesAlone()
{
SparkplugCodec.ReinterpretSigned(7U, DataType.Uint32).ShouldBe(7U);
SparkplugCodec.ReinterpretSigned(21.5f, DataType.Float).ShouldBe(21.5f);
SparkplugCodec.ReinterpretSigned("text", DataType.String).ShouldBe("text");
SparkplugCodec.ReinterpretSigned(null, DataType.Int32).ShouldBeNull();
}
/// <summary>
/// A datatype index this build's enum does not define (a future Sparkplug revision, or a
/// misbehaving publisher) is preserved verbatim rather than dropped or coerced to Unknown —
/// the mapping layer decides what to do with it, and it cannot decide about data it never saw.
/// </summary>
[Fact]
public void Decode_UnrecognisedDataTypeIndex_IsPreservedVerbatim()
{
var payload = new Payload();
payload.Metrics.Add(new Payload.Types.Metric { Name = "Future", Datatype = 250U, IntValue = 1U });
var decoded = SparkplugCodec.Decode(payload.ToByteArray());
((int)decoded.Metrics.Single().DataType!.Value).ShouldBe(250);
}
/// <summary>
/// <b>Never throws, case 1.</b> Zero-length input is not "a payload with no metrics" — it is no
/// answer at all, and is reported as invalid so a truncated-to-nothing body cannot pass as a
/// well-formed message with an empty metric list.
/// </summary>
[Fact]
public void TryDecode_EmptyInput_IsInvalid_AndDoesNotThrow()
{
SparkplugCodec.TryDecode([], out var payload).ShouldBeFalse();
payload.IsValid.ShouldBeFalse();
payload.Metrics.ShouldBeEmpty();
payload.Seq.ShouldBeNull();
}
/// <summary>
/// <b>Never throws, case 2.</b> Garbage bytes: a decoder that let an exception escape here would
/// take down the MQTT dispatcher thread — every subscription on the connection, not one tag.
/// </summary>
[Fact]
public void TryDecode_GarbageBytes_NeverThrows()
{
var random = new Random(20260724);
for (var i = 0; i < 2000; i++)
{
var bytes = new byte[random.Next(1, 64)];
random.NextBytes(bytes);
// The verdict is irrelevant — some random byte strings ARE valid protobuf. Not throwing is
// the whole assertion.
Should.NotThrow(() => SparkplugCodec.TryDecode(bytes, out _));
}
}
/// <summary>
/// <b>Never throws, case 3.</b> Every truncation of a real payload — the shape a dropped
/// connection or a size-capped broker actually produces.
/// </summary>
[Fact]
public void TryDecode_EveryTruncationOfAGoldenVector_NeverThrows()
{
foreach (var file in new[] { SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.NdataFile })
{
var full = SparkplugGoldenPayloads.Read(file);
for (var length = 0; length <= full.Length; length++)
{
var prefix = full.AsSpan(0, length).ToArray();
Should.NotThrow(() => SparkplugCodec.TryDecode(prefix, out _));
}
}
}
/// <summary>
/// <b>Never throws, case 4.</b> A valid protobuf message that is not a Sparkplug payload. Protobuf
/// is permissive, so this may well parse (as unknown fields) — what matters is that it neither
/// throws nor invents metrics.
/// </summary>
[Fact]
public void TryDecode_ValidProtobufThatIsNotASparkplugPayload_NeverThrows()
{
// A DataSet message, serialized standalone — legal protobuf, wrong schema.
var alien = new Payload.Types.DataSet { NumOfColumns = 3UL };
alien.Columns.Add("a");
Should.NotThrow(() => SparkplugCodec.TryDecode(alien.ToByteArray(), out _));
}
/// <summary>
/// <b>Never throws, case 5.</b> A deeply nested Template — protobuf's recursion limit rejects it,
/// and the rejection must arrive as a verdict rather than as an exception.
/// </summary>
[Fact]
public void TryDecode_PathologicallyNestedTemplate_NeverThrows()
{
var template = new Payload.Types.Template { Version = "1" };
for (var depth = 0; depth < 200; depth++)
{
var outer = new Payload.Types.Template { Version = "1" };
outer.Metrics.Add(new Payload.Types.Metric { Name = "n", TemplateValue = template });
template = outer;
}
var payload = new Payload();
payload.Metrics.Add(new Payload.Types.Metric { Name = "Deep", TemplateValue = template });
Should.NotThrow(() => SparkplugCodec.TryDecode(payload.ToByteArray(), out _));
}
/// <summary>
/// <see cref="SparkplugCodec.Decode"/> is the convenience shape the plan's call sites use: it
/// never returns <see langword="null"/> and never throws, reporting failure through
/// <see cref="SparkplugPayload.IsValid"/>.
/// </summary>
[Fact]
public void Decode_OnUndecodableInput_ReturnsTheInvalidPayload_RatherThanThrowing()
{
var payload = SparkplugCodec.Decode([]);
payload.ShouldNotBeNull();
payload.IsValid.ShouldBeFalse();
payload.Metrics.ShouldBeEmpty();
}
}
@@ -0,0 +1,127 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Shouldly;
using Xunit;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// <see cref="SparkplugDataTypeExtensions.ToDriverDataType"/> / <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
/// coverage (Task 17), per design doc §3.5. <see cref="ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported"/>
/// is the drift guard: because <c>SparkplugDataType</c> is an alias for the vendored proto's
/// generated enum (not a hand-duplicated copy — see the remarks on
/// <see cref="SparkplugDataTypeExtensions"/>), enumerating it enumerates the live generated member
/// set, so a future Eclipse Tahu proto change that adds/removes a <c>DataType</c> member is caught
/// here automatically rather than needing a second enum kept manually in sync.
/// </summary>
public sealed class SparkplugDataTypeTests
{
/// <summary>Scalar (non-array) datatypes, per the design §3.5 table.</summary>
[Theory]
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)] // widened: no 8-bit DriverDataType member
[InlineData(SparkplugDataType.Int16, DriverDataType.Int16)]
[InlineData(SparkplugDataType.Int32, DriverDataType.Int32)]
[InlineData(SparkplugDataType.Int64, DriverDataType.Int64)]
[InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)] // widened
[InlineData(SparkplugDataType.Uint16, DriverDataType.UInt16)]
[InlineData(SparkplugDataType.Uint32, DriverDataType.UInt32)]
[InlineData(SparkplugDataType.Uint64, DriverDataType.UInt64)]
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
[InlineData(SparkplugDataType.Double, DriverDataType.Float64)] // NOT DriverDataType.Double — no such member
[InlineData(SparkplugDataType.Boolean, DriverDataType.Boolean)]
[InlineData(SparkplugDataType.String, DriverDataType.String)]
[InlineData(SparkplugDataType.DateTime, DriverDataType.DateTime)]
[InlineData(SparkplugDataType.Text, DriverDataType.String)]
[InlineData(SparkplugDataType.Uuid, DriverDataType.String)] // generated name for the proto's `UUID`
[InlineData(SparkplugDataType.Bytes, DriverDataType.String)] // v1 base64/raw fallback
[InlineData(SparkplugDataType.File, DriverDataType.String)] // v1 base64/raw fallback
public void ToDriverDataType_MapsScalarTypes(SparkplugDataType s, DriverDataType d)
=> s.ToDriverDataType().ShouldBe(d);
/// <summary>
/// <c>*Array</c> variants map to their scalar element type; <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
/// carries the "and it's an array" bit, since <see cref="DriverDataType"/> has no array concept
/// of its own (that's an OPC UA ValueRank/ArrayDimensions concern owned further up the stack).
/// </summary>
[Theory]
[InlineData(SparkplugDataType.Int8Array, DriverDataType.Int16)]
[InlineData(SparkplugDataType.Int16Array, DriverDataType.Int16)]
[InlineData(SparkplugDataType.Int32Array, DriverDataType.Int32)]
[InlineData(SparkplugDataType.Int64Array, DriverDataType.Int64)]
[InlineData(SparkplugDataType.Uint8Array, DriverDataType.UInt16)]
[InlineData(SparkplugDataType.Uint16Array, DriverDataType.UInt16)]
[InlineData(SparkplugDataType.Uint32Array, DriverDataType.UInt32)]
[InlineData(SparkplugDataType.Uint64Array, DriverDataType.UInt64)]
[InlineData(SparkplugDataType.FloatArray, DriverDataType.Float32)]
[InlineData(SparkplugDataType.DoubleArray, DriverDataType.Float64)]
[InlineData(SparkplugDataType.BooleanArray, DriverDataType.Boolean)]
[InlineData(SparkplugDataType.StringArray, DriverDataType.String)]
[InlineData(SparkplugDataType.DateTimeArray, DriverDataType.DateTime)]
public void ToDriverDataType_ArrayVariants_MapToElementType_AndFlagIsArray(SparkplugDataType s, DriverDataType d)
{
s.ToDriverDataType().ShouldBe(d);
s.IsSparkplugArray().ShouldBeTrue();
}
[Theory]
[InlineData(SparkplugDataType.Int32)]
[InlineData(SparkplugDataType.Boolean)]
[InlineData(SparkplugDataType.String)]
[InlineData(SparkplugDataType.Bytes)]
public void IsSparkplugArray_ScalarTypes_ReturnsFalse(SparkplugDataType s)
=> s.IsSparkplugArray().ShouldBeFalse();
/// <summary>
/// Design §3.5: "DataSet, Template → unsupported v1". Extended here to the two PropertyValue-only
/// variants and the proto's explicit placeholder — all five must come back <see langword="null"/>,
/// never a guessed <see cref="DriverDataType.String"/>.
/// </summary>
[Theory]
[InlineData(SparkplugDataType.Unknown)]
[InlineData(SparkplugDataType.DataSet)]
[InlineData(SparkplugDataType.Template)]
[InlineData(SparkplugDataType.PropertySet)]
[InlineData(SparkplugDataType.PropertySetList)]
public void ToDriverDataType_UnsupportedTypes_ReturnsNull_NotAGuess(SparkplugDataType s)
=> s.ToDriverDataType().ShouldBeNull();
/// <summary>
/// The drift guard (see class remarks). Enumerates the live generated <c>DataType</c> member
/// set (via the <c>SparkplugDataType</c> alias) and asserts every member is either mapped to a
/// <see cref="DriverDataType"/> or is one of the five documented-unsupported members — nothing
/// silently falls through the map's <c>_ =&gt; null</c> default undetected.
/// </summary>
[Fact]
public void ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported()
{
var unsupported = new HashSet<SparkplugDataType>
{
SparkplugDataType.Unknown,
SparkplugDataType.DataSet,
SparkplugDataType.Template,
SparkplugDataType.PropertySet,
SparkplugDataType.PropertySetList,
};
var allMembers = Enum.GetValues<SparkplugDataType>();
allMembers.Length.ShouldBe(35, "this pins the generated member count Task 15 vendored; a changed count means the proto changed and every assertion below needs re-auditing.");
foreach (var value in allMembers)
{
var mapped = value.ToDriverDataType();
if (unsupported.Contains(value))
{
mapped.ShouldBeNull($"{value} is documented unsupported (design §3.5) and must map to null, not a guessed DriverDataType.");
}
else
{
mapped.ShouldNotBeNull($"{value} has no ToDriverDataType() mapping — every generated DataType member must be mapped or explicitly listed as unsupported.");
}
}
}
}
@@ -0,0 +1,216 @@
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Builds the two Sparkplug-B wire vectors committed under <c>Golden/</c> — an NBIRTH and the
/// NDATA that follows it — and names the files they live in.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a generator instead of bytes captured off a real edge node.</b> A capture would pin
/// one vendor's encoder rather than the schema, could not be regenerated when the vendored
/// proto is re-vendored, and would drag an unaudited third-party payload into the repo. These
/// payloads are hand-built from the generated types, serialized once, and committed; the
/// <c>SparkplugGoldenVectorTests</c> drift guard re-runs this builder on every test run and
/// fails if the committed bytes no longer match, which is what makes the files an actual
/// regression pin rather than decoration.
/// </para>
/// <para>
/// <b>Every field in here is load-bearing.</b> The NBIRTH carries <c>seq = 0</c> (present and
/// zero — the case a <c>Seq != 0</c> presence check gets wrong), a negative Int32 encoded the
/// Sparkplug way (two's complement in an unsigned <c>int_value</c>), an explicit
/// <c>is_null</c> metric, and a <c>DataSet</c> metric that v1 does not support. The NDATA
/// carries metrics with <b>no name and no datatype</b> — alias only — which is how every real
/// Sparkplug DATA message after a birth looks, and the case an "absent means empty string"
/// decoder gets wrong. Do not "tidy" these payloads.
/// </para>
/// </remarks>
internal static class SparkplugGoldenPayloads
{
/// <summary>Repo-relative directory the committed vectors live in, inside the test project.</summary>
public const string Directory = "Golden";
/// <summary>File name of the NBIRTH vector.</summary>
public const string NbirthFile = "nbirth.bin";
/// <summary>File name of the NDATA vector.</summary>
public const string NdataFile = "ndata.bin";
/// <summary>NBIRTH payload timestamp — 2024-07-24T12:00:00Z, in Sparkplug's epoch milliseconds.</summary>
public const ulong NbirthTimestampMs = 1721822400000UL;
/// <summary>NDATA payload timestamp — five seconds after the birth.</summary>
public const ulong NdataTimestampMs = 1721822405000UL;
/// <summary>The alias the NBIRTH binds to <c>Temperature</c>, reused by the NDATA.</summary>
public const ulong TemperatureAlias = 5UL;
/// <summary>The alias the NBIRTH binds to <c>Count</c> — the negative-Int32 case.</summary>
public const ulong CountAlias = 9UL;
/// <summary>The alias the NBIRTH binds to <c>Missing</c> — the explicit-null case.</summary>
public const ulong MissingAlias = 11UL;
/// <summary>The <c>Count</c> metric's value in the NBIRTH — negative, to pin the wire encoding.</summary>
public const int CountBirthValue = -42;
/// <summary>The <c>Count</c> metric's value in the NDATA.</summary>
public const int CountDataValue = -43;
/// <summary>
/// Builds the NBIRTH: <c>seq = 0</c>, a full metric catalog with names, aliases and datatypes.
/// </summary>
/// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
public static Payload BuildNbirth()
{
var payload = new Payload
{
Timestamp = NbirthTimestampMs,
// Present AND zero. Sparkplug REQUIRES an NBIRTH to carry seq = 0, so any decoder that
// infers absence from a zero value reports "no sequence" for every birth it ever sees.
Seq = 0UL,
};
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "bdSeq",
Datatype = (uint)DataType.Uint64,
LongValue = 7UL,
});
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Node Control/Rebirth",
Datatype = (uint)DataType.Boolean,
BooleanValue = false,
});
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Temperature",
Alias = TemperatureAlias,
Timestamp = NbirthTimestampMs,
Datatype = (uint)DataType.Float,
FloatValue = 21.5f,
});
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Pressure",
Alias = 6UL,
Datatype = (uint)DataType.Double,
DoubleValue = 101.325d,
});
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Running",
Alias = 7UL,
Datatype = (uint)DataType.Boolean,
BooleanValue = true,
});
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Serial",
Alias = 8UL,
Datatype = (uint)DataType.String,
StringValue = "EDGE-A-001",
});
// Sparkplug carries every signed integer up to 32 bits in the UNSIGNED int_value field as
// two's complement, so -42 goes on the wire as 4294967254. A decoder that hands the raw
// uint32 straight to a consumer publishes 4294967254 for a tag whose value is -42.
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Count",
Alias = CountAlias,
Datatype = (uint)DataType.Int32,
IntValue = unchecked((uint)CountBirthValue),
});
// DataSet is explicitly out of scope for v1 — present here so "unsupported" is a decoded,
// testable outcome rather than an exception on the MQTT dispatcher thread.
var dataSet = new Payload.Types.DataSet { NumOfColumns = 1UL };
dataSet.Columns.Add("col0");
dataSet.Types_.Add((uint)DataType.Int32);
var row = new Payload.Types.DataSet.Types.Row();
row.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { IntValue = 1U });
dataSet.Rows.Add(row);
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Config",
Alias = 10UL,
Datatype = (uint)DataType.DataSet,
DatasetValue = dataSet,
});
// is_null = true with NO value in the oneof: "this metric exists and its value is null",
// which is a different fact from "this metric carried no value field".
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Missing",
Alias = MissingAlias,
Datatype = (uint)DataType.Float,
IsNull = true,
});
return payload;
}
/// <summary>
/// Builds the NDATA that follows <see cref="BuildNbirth"/>: <c>seq = 1</c> and metrics carrying
/// <b>alias only</b> — no name, no datatype — exactly as a real edge node publishes them.
/// </summary>
/// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
public static Payload BuildNdata()
{
var payload = new Payload
{
Timestamp = NdataTimestampMs,
Seq = 1UL,
};
payload.Metrics.Add(new Payload.Types.Metric
{
Alias = TemperatureAlias,
Timestamp = NdataTimestampMs,
FloatValue = 22.25f,
});
payload.Metrics.Add(new Payload.Types.Metric
{
Alias = CountAlias,
IntValue = unchecked((uint)CountDataValue),
});
payload.Metrics.Add(new Payload.Types.Metric
{
Alias = MissingAlias,
IsNull = true,
});
return payload;
}
/// <summary>Reads a committed vector from the test binary's output directory.</summary>
/// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
/// <returns>The committed wire bytes.</returns>
public static byte[] Read(string fileName) => File.ReadAllBytes(PathTo(fileName));
/// <summary>Resolves a vector's path under the test binary's output directory.</summary>
/// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
/// <returns>The absolute path.</returns>
/// <remarks>
/// Rooted at <see cref="AppContext.BaseDirectory"/> rather than the process working directory:
/// the runner's cwd is not guaranteed to be the output folder, and a relative read that
/// happens to work under one runner is exactly how a fixture stops being copied without
/// anyone noticing.
/// </remarks>
public static string PathTo(string fileName) =>
Path.Combine(AppContext.BaseDirectory, Directory, fileName);
}
@@ -0,0 +1,87 @@
using System.Runtime.CompilerServices;
using Google.Protobuf;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Guards the committed <c>Golden/*.bin</c> Sparkplug-B wire vectors: they must still be exactly
/// what <see cref="SparkplugGoldenPayloads"/> produces, and they must actually reach the test
/// binary's output directory.
/// </summary>
/// <remarks>
/// <para>
/// Without the drift guard the vectors would be write-once decoration — a re-vendored proto,
/// a protoc upgrade, or an edited builder could change the encoding while every decode test
/// kept passing against stale bytes. With it, the committed files are a genuine pin on the
/// wire format the driver is expected to read.
/// </para>
/// <para>
/// <b>To regenerate</b> (only after a deliberate, reviewed change to the vectors):
/// <c>OTOPCUA_SPARKPLUG_GOLDEN_REGEN=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests
/// --filter "FullyQualifiedName~SparkplugGoldenVectorTests"</c>, then review and commit the
/// resulting <c>.bin</c> diff.
/// </para>
/// </remarks>
public sealed class SparkplugGoldenVectorTests
{
private const string RegenEnvVar = "OTOPCUA_SPARKPLUG_GOLDEN_REGEN";
/// <summary>The committed NBIRTH vector is byte-identical to its builder's output.</summary>
[Fact]
public void Nbirth_CommittedBytes_MatchTheBuilder() =>
AssertMatchesBuilder(SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.BuildNbirth().ToByteArray());
/// <summary>The committed NDATA vector is byte-identical to its builder's output.</summary>
[Fact]
public void Ndata_CommittedBytes_MatchTheBuilder() =>
AssertMatchesBuilder(SparkplugGoldenPayloads.NdataFile, SparkplugGoldenPayloads.BuildNdata().ToByteArray());
/// <summary>
/// Both vectors are present in the test binary's output directory — i.e. the csproj really does
/// copy <c>Golden/**</c>. A missing copy item is invisible in source and turns every decode test
/// into a <see cref="FileNotFoundException"/> at runtime.
/// </summary>
[Fact]
public void GoldenVectors_AreCopiedToTheOutputDirectory()
{
File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NbirthFile)).ShouldBeTrue();
File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NdataFile)).ShouldBeTrue();
}
/// <summary>Compares a committed vector to freshly built bytes, regenerating first when asked to.</summary>
/// <param name="fileName">The vector's file name.</param>
/// <param name="built">The bytes the builder produces now.</param>
private static void AssertMatchesBuilder(string fileName, byte[] built)
{
if (Environment.GetEnvironmentVariable(RegenEnvVar) == "1")
{
// Write to BOTH the source tree (so the change can be committed) and the output directory
// (so the assertion below reads what was just written rather than a stale copied file).
var source = Path.Combine(SourceDirectory(), SparkplugGoldenPayloads.Directory, fileName);
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(source)!);
File.WriteAllBytes(source, built);
var output = SparkplugGoldenPayloads.PathTo(fileName);
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(output)!);
File.WriteAllBytes(output, built);
}
var path = SparkplugGoldenPayloads.PathTo(fileName);
File.Exists(path).ShouldBeTrue(
$"Golden vector '{fileName}' is missing from the output directory. If the csproj copy item is "
+ $"intact, regenerate with {RegenEnvVar}=1.");
File.ReadAllBytes(path).ShouldBe(
built,
$"Golden vector '{fileName}' no longer matches SparkplugGoldenPayloads. Either the builder or "
+ $"the vendored proto changed. If the change is intended, regenerate with {RegenEnvVar}=1 and "
+ "commit the .bin diff.");
}
/// <summary>This file's own source directory, resolved at compile time.</summary>
/// <param name="path">Supplied by the compiler; never pass it.</param>
/// <returns>The directory holding the test sources.</returns>
private static string SourceDirectory([CallerFilePath] string path = "") => Path.GetDirectoryName(path)!;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Pins the vendored Eclipse Tahu <c>sparkplug_b</c> protobuf codegen: the generated types exist,
/// land in the namespace the rest of P2 is written against, and carry the Sparkplug-B field numbers
/// on the wire. Nothing here decodes a real payload — that is Task 16 (<c>SparkplugCodec</c> +
/// golden vectors); this suite exists so a broken/renamed/mangled vendored proto fails at the
/// codegen seam rather than as a mysterious decode failure three tasks later.
/// </summary>
public sealed class SparkplugProtoCodegenTests
{
[Fact]
public void GeneratedPayload_RoundTrips()
{
var p = new Payload { Seq = 3 };
p.Metrics.Add(new Payload.Types.Metric { Name = "Temperature", Alias = 5 });
var back = Payload.Parser.ParseFrom(p.ToByteArray());
back.Seq.ShouldBe(3ul);
back.Metrics[0].Name.ShouldBe("Temperature");
back.Metrics[0].Alias.ShouldBe(5ul);
}
/// <summary>
/// Tasks 16 / 20 / 25 name <c>Org.Eclipse.Tahu.Protobuf</c> explicitly, and the namespace is
/// derived (not declared) — protoc PascalCases the proto <c>package</c> because the vendored file
/// carries no <c>option csharp_namespace</c>. Editing the package line to "tidy" it would silently
/// rehome every generated type.
/// </summary>
[Fact]
public void GeneratedTypes_LiveInTheTahuPackageNamespace()
{
typeof(Payload).Namespace.ShouldBe("Org.Eclipse.Tahu.Protobuf");
Payload.Descriptor.File.Package.ShouldBe("org.eclipse.tahu.protobuf");
}
/// <summary>
/// Field numbers are the wire contract with every Sparkplug edge node in the plant; a vendored
/// proto that was hand-edited or reconstructed rather than copied from upstream would most likely
/// drift here first.
/// </summary>
[Fact]
public void PayloadAndMetric_CarrySparkplugBFieldNumbers()
{
Payload.TimestampFieldNumber.ShouldBe(1);
Payload.MetricsFieldNumber.ShouldBe(2);
Payload.SeqFieldNumber.ShouldBe(3);
Payload.UuidFieldNumber.ShouldBe(4);
Payload.BodyFieldNumber.ShouldBe(5);
Payload.Types.Metric.NameFieldNumber.ShouldBe(1);
Payload.Types.Metric.AliasFieldNumber.ShouldBe(2);
Payload.Types.Metric.TimestampFieldNumber.ShouldBe(3);
Payload.Types.Metric.DatatypeFieldNumber.ShouldBe(4);
Payload.Types.Metric.IsNullFieldNumber.ShouldBe(7);
}
/// <summary>
/// Task 17's datatype map keys off these indices. They are Sparkplug-spec constants, not protobuf
/// tags, so they are worth pinning independently of the field numbers above.
/// </summary>
[Fact]
public void DataTypeEnum_MatchesSparkplugSpecIndices()
{
((int)DataType.Int8).ShouldBe(1);
((int)DataType.Int32).ShouldBe(3);
((int)DataType.Uint64).ShouldBe(8);
((int)DataType.Float).ShouldBe(9);
((int)DataType.Double).ShouldBe(10);
((int)DataType.Boolean).ShouldBe(11);
((int)DataType.String).ShouldBe(12);
((int)DataType.DateTime).ShouldBe(13);
((int)DataType.DataSet).ShouldBe(16);
((int)DataType.Template).ShouldBe(19);
((int)DataType.Int32Array).ShouldBe(24);
}
/// <summary>
/// protoc's C# generator RE-CASES enum member names: the proto's <c>UInt64</c> becomes
/// <c>Uint64</c> and <c>UUID</c> becomes <c>Uuid</c> (only the leading character of each
/// underscore-separated word survives as a capital). Task 17's datatype map has to spell them
/// the mangled way, so pin the mangling rather than letting it be rediscovered as a build error.
/// The wire-facing spelling is preserved on <c>OriginalName</c>, which is what
/// <c>DataType.Parser</c>-style JSON round-trips use.
/// </summary>
[Fact]
public void DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling()
{
Enum.GetNames<DataType>().ShouldContain("Uint64");
Enum.GetNames<DataType>().ShouldNotContain("UInt64");
Enum.GetNames<DataType>().ShouldContain("Uuid");
Enum.GetNames<DataType>().ShouldNotContain("UUID");
}
}
@@ -0,0 +1,168 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using Shouldly;
using Xunit;
// Local alias so this file can spell the mapping table the way design doc §3.5 does
// (`SparkplugDataType.Int8`, etc.) — `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` declared in `SparkplugDataType.cs` (Contracts project); that
// `global using` is scoped to the project that declares it and does not cross the ProjectReference
// into this test project, so it is redeclared here, locally, rather than duplicating the enum itself.
// See the remarks on `SparkplugDataTypeExtensions` for the full alias-vs-duplicate rationale.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// <see cref="SparkplugTopic"/> parse/format coverage (Task 17). <see cref="SparkplugTopic.TryParse"/>
/// is fed every topic string a live <c>spBv1.0/{group}/#</c> subscription delivers, so a large
/// share of this suite is "garbage in, <see langword="false"/> out, never a throw" — see
/// <see cref="TryParse_NeverThrows_ForArbitraryInput"/>.
/// </summary>
public sealed class SparkplugTopicTests
{
[Fact]
public void Parse_DeviceData_ExtractsAllSegments()
{
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
t.GroupId.ShouldBe("Plant1");
t.Type.ShouldBe(SparkplugMessageType.DDATA);
t.EdgeNodeId.ShouldBe("EdgeA");
t.DeviceId.ShouldBe("Filler1");
t.HostId.ShouldBeNull();
}
[Fact]
public void Parse_NodeData_HasNoDeviceSegment()
{
var t = SparkplugTopic.Parse("spBv1.0/Plant1/NDATA/EdgeA");
t.GroupId.ShouldBe("Plant1");
t.Type.ShouldBe(SparkplugMessageType.NDATA);
t.EdgeNodeId.ShouldBe("EdgeA");
t.DeviceId.ShouldBeNull();
t.HostId.ShouldBeNull();
}
[Theory]
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA", SparkplugMessageType.NBIRTH)]
[InlineData("spBv1.0/Plant1/NDATA/EdgeA", SparkplugMessageType.NDATA)]
[InlineData("spBv1.0/Plant1/NDEATH/EdgeA", SparkplugMessageType.NDEATH)]
[InlineData("spBv1.0/Plant1/NCMD/EdgeA", SparkplugMessageType.NCMD)]
public void Parse_NodeScopedTypes_Recognised(string topic, SparkplugMessageType expected)
=> SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
[Theory]
[InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1", SparkplugMessageType.DBIRTH)]
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1", SparkplugMessageType.DDATA)]
[InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1", SparkplugMessageType.DDEATH)]
[InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1", SparkplugMessageType.DCMD)]
public void Parse_DeviceScopedTypes_Recognised(string topic, SparkplugMessageType expected)
=> SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
[Fact]
public void Parse_V3StateForm_ExtractsHostId()
{
var t = SparkplugTopic.Parse("spBv1.0/STATE/otopcua-host-1");
t.Type.ShouldBe(SparkplugMessageType.STATE);
t.HostId.ShouldBe("otopcua-host-1");
t.GroupId.ShouldBeNull();
t.EdgeNodeId.ShouldBeNull();
t.DeviceId.ShouldBeNull();
}
[Fact]
public void Parse_LegacyStateForm_ExtractsHostId()
{
// Pre-3.0 peers publish `STATE/{hostId}` with no `spBv1.0` namespace prefix — tolerated on
// receive per design §3.1, even though this driver only ever *emits* the v3.0 form.
var t = SparkplugTopic.Parse("STATE/otopcua-host-1");
t.Type.ShouldBe(SparkplugMessageType.STATE);
t.HostId.ShouldBe("otopcua-host-1");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not/a/sparkplug/topic/at/all/too/many/segments")]
[InlineData("spBv1.0")]
[InlineData("spBv1.0/")]
[InlineData("spBv1.0/Plant1")]
[InlineData("spBv1.0/Plant1/BOGUS/EdgeA")]
[InlineData("spBv1.0/Plant1/NDATA")]
[InlineData("spBv1.0/Plant1/NDATA/EdgeA/UnexpectedDevice")]
[InlineData("spBv1.0/Plant1/DDATA/EdgeA")]
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1/Extra")]
[InlineData("wrong-namespace/Plant1/NDATA/EdgeA")]
[InlineData("spBv1.0//NDATA/EdgeA")]
[InlineData("spBv1.0/Plant1/NDATA/")]
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/")]
[InlineData("spBv1.0/Plant+/NDATA/EdgeA")]
[InlineData("spBv1.0/Plant1/NDATA/Edge#A")]
[InlineData("spBv1.0/STATE")]
[InlineData("spBv1.0/STATE/")]
[InlineData("spBv1.0/STATE/Host/Extra")]
[InlineData("STATE")]
[InlineData("STATE/")]
public void TryParse_NeverThrows_ForArbitraryInput(string? topic)
{
var ex = Record.Exception(() => SparkplugTopic.TryParse(topic, out var result));
ex.ShouldBeNull();
SparkplugTopic.TryParse(topic, out var result2).ShouldBeFalse();
result2.ShouldBeNull();
}
[Fact]
public void Parse_InvalidTopic_ThrowsFormatException()
=> Should.Throw<FormatException>(() => SparkplugTopic.Parse("not-a-sparkplug-topic"));
[Fact]
public void Format_NodeScoped_BuildsExpectedTopic()
=> SparkplugTopic.Format("Plant1", SparkplugMessageType.NCMD, "EdgeA")
.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
[Fact]
public void Format_DeviceScoped_BuildsExpectedTopic()
=> SparkplugTopic.Format("Plant1", SparkplugMessageType.DCMD, "EdgeA", "Filler1")
.ShouldBe("spBv1.0/Plant1/DCMD/EdgeA/Filler1");
[Fact]
public void Format_State_Throws_UseFormatStateInstead()
=> Should.Throw<ArgumentException>(() => SparkplugTopic.Format("Plant1", SparkplugMessageType.STATE, "EdgeA"));
[Fact]
public void FormatState_BuildsV3Topic()
=> SparkplugTopic.FormatState("otopcua-host-1").ShouldBe("spBv1.0/STATE/otopcua-host-1");
[Theory]
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA")]
[InlineData("spBv1.0/Plant1/NDATA/EdgeA")]
[InlineData("spBv1.0/Plant1/NDEATH/EdgeA")]
[InlineData("spBv1.0/Plant1/NCMD/EdgeA")]
[InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1")]
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1")]
[InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1")]
[InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1")]
[InlineData("spBv1.0/STATE/otopcua-host-1")]
public void ToTopicString_RoundTrips(string topic)
=> SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
[Fact]
public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
// Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
=> SparkplugTopic.Parse("STATE/otopcua-host-1").ToTopicString().ShouldBe("spBv1.0/STATE/otopcua-host-1");
// ---- The plan's illustrative datatype-map smoke theory (spelled with the protoc-mangled member
// names: the generated enum has `Uint8`, not `UInt8` — see SparkplugDataTypeTests for the
// full table). Kept here alongside the topic tests per the Task 17 plan's original grouping;
// SparkplugDataTypeTests.cs carries the exhaustive coverage + the completeness/drift guard. ----
[Theory]
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
[InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)]
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
=> s.ToDriverDataType().ShouldBe(d);
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
</ItemGroup>
<!-- The committed Sparkplug-B golden wire vectors. Without this copy item they stay in the source
tree and every SparkplugCodecTests read fails with FileNotFoundException at runtime — the
failure mode SparkplugGoldenVectorTests.GoldenVectors_AreCopiedToTheOutputDirectory pins. -->
<ItemGroup>
<None Update="Golden\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
</Project>
@@ -1,3 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
@@ -7,17 +10,23 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
/// timeout enforcement.</summary>
/// on open, NotFound semantics on unknown tokens, exception swallowing, per-call
/// timeout enforcement, and the DriverOperator gate on the one write-capable member.</summary>
public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser());
NewService(registry, new FakeUniversalDriverBrowser(), browsers);
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal);
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal,
new FakeAuthorizationService(succeeds: true), new FakeAuthenticationStateProvider());
private static BrowserSessionService NewServiceWithAuthz(
BrowseSessionRegistry registry, bool authorized) =>
new([], registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser(),
new FakeAuthorizationService(authorized), new FakeAuthenticationStateProvider());
[Fact]
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
@@ -226,4 +235,130 @@ public sealed class BrowserSessionServiceTests
// Unknown token is a no-op.
await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
}
// ---------------------------------------------------- RequestRebirthAsync: the one write path
[Fact]
public async Task RequestRebirthAsync_WithoutDriverOperator_IsRefusedAndNeverReachesTheSession()
{
// A publishing action that skips authz is a security bug, not a style issue: this one makes a
// live plant broker receive a command. The refusal must happen BEFORE the session is touched.
var registry = new BrowseSessionRegistry();
var session = new FakeRebirthCapableBrowseSession();
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: false);
await Should.ThrowAsync<UnauthorizedAccessException>(
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
session.Scopes.ShouldBeEmpty();
}
[Fact]
public async Task RequestRebirthAsync_WithDriverOperator_DispatchesTheScopeAndReturnsTheCount()
{
var registry = new BrowseSessionRegistry();
var session = new FakeRebirthCapableBrowseSession { Published = 2 };
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: true);
var published = await service.RequestRebirthAsync(session.Token, "Plant1", CancellationToken.None);
published.ShouldBe(2);
session.Scopes.ShouldBe(["Plant1"]);
}
[Fact]
public async Task RequestRebirthAsync_OnANonRebirthCapableSession_IsNotSupported()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: true);
await Should.ThrowAsync<NotSupportedException>(
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
}
[Fact]
public async Task RequestRebirthAsync_UnknownToken_ThrowsBrowseSessionNotFound()
{
var registry = new BrowseSessionRegistry();
var service = NewServiceWithAuthz(registry, authorized: true);
await Should.ThrowAsync<BrowseSessionNotFoundException>(
() => service.RequestRebirthAsync(Guid.NewGuid(), "Plant1/EdgeA", CancellationToken.None));
}
[Fact]
public void CanRequestRebirth_IsTrue_OnlyForASessionThatAdvertisesTheAction()
{
// The picker draws its Request-rebirth affordance off this. A session type CAN re-announce
// (MqttBrowseSession implements the interface) while a given INSTANCE cannot — a plain-MQTT
// window publishes nothing, ever — so a type test alone would draw a button that only throws.
var registry = new BrowseSessionRegistry();
var sparkplug = new FakeRebirthCapableBrowseSession();
var plain = new FakeRebirthCapableBrowseSession { RebirthAvailable = false };
var plainOldSession = new FakeBrowseSession();
registry.Register(sparkplug);
registry.Register(plain);
registry.Register(plainOldSession);
var service = NewServiceWithAuthz(registry, authorized: true);
service.CanRequestRebirth(sparkplug.Token).ShouldBeTrue();
service.CanRequestRebirth(plain.Token).ShouldBeFalse();
service.CanRequestRebirth(plainOldSession.Token).ShouldBeFalse();
service.CanRequestRebirth(Guid.NewGuid()).ShouldBeFalse(); // unknown/reaped token
}
/// <summary>A browse session that records the rebirth scopes it was asked for.</summary>
private sealed class FakeRebirthCapableBrowseSession : IRebirthCapableBrowseSession
{
public Guid Token { get; } = Guid.NewGuid();
public DateTime LastUsedUtc { get; } = DateTime.UtcNow;
public int Published { get; init; } = 1;
/// <summary>Whether this fake session advertises the action (a plain-MQTT window does not).</summary>
public bool RebirthAvailable { get; init; } = true;
public List<string> Scopes { get; } = [];
public Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken)
{
Scopes.Add(scope);
return Task.FromResult(Published);
}
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>An <see cref="IAuthorizationService"/> whose verdict the test dictates.</summary>
private sealed class FakeAuthorizationService(bool succeeds) : IAuthorizationService
{
public Task<AuthorizationResult> AuthorizeAsync(
ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements) =>
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) =>
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
}
/// <summary>A fixed authenticated identity for the circuit under test.</summary>
private sealed class FakeAuthenticationStateProvider : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "tester")], "test"))));
}
}
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -23,15 +24,45 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// </summary>
public sealed class DriverPageJsonConverterTests
{
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly that declares a
/// <c>_jsonOpts</c> config serializer.</summary>
/// <summary>
/// Driver forms whose config serializer does NOT live in a private static <c>_jsonOpts</c> field
/// on the form, mapped to the options instance they actually use. Each entry is an explicit,
/// reviewed exception — the guard below still asserts the converter on whatever instance is
/// named here, so this narrows <em>where</em> the options live, never <em>whether</em> they are
/// checked.
/// <para><c>MqttDriverForm</c>: the MQTT driver deliberately keeps <b>one</b>
/// <see cref="JsonSerializerOptions"/> for all five of its config seams (runtime factory,
/// Test-connect probe, driver re-parse, address-picker browser, this form) in
/// <c>MqttJson.Options</c> — a per-form copy would be a fifth divergent instance, i.e. exactly
/// the bug this whole file guards. The form itself is a shell over the pure
/// <c>MqttDriverFormModel</c>, which serialises through that shared instance.</para>
/// </summary>
private static IReadOnlyDictionary<Type, JsonSerializerOptions> ExternalConfigSerializers { get; } =
new Dictionary<Type, JsonSerializerOptions>
{
[typeof(MqttDriverForm)] = MqttJson.Options,
};
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
private static IReadOnlyList<Type> DriverFormTypes { get; } =
typeof(ModbusDriverForm).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
.Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null)
.OrderBy(t => t.Name)
.ToList();
/// <summary>Resolves a form's config serializer — its own <c>_jsonOpts</c> field, else the
/// explicitly-registered external instance — or <c>null</c> when it has neither.</summary>
/// <param name="formType">A driver form component type.</param>
/// <returns>The options the form serialises config through, or <c>null</c>.</returns>
private static JsonSerializerOptions? ResolveConfigSerializer(Type formType)
{
if (formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is { } field)
{
return (JsonSerializerOptions?)field.GetValue(null);
}
return ExternalConfigSerializers.GetValueOrDefault(formType);
}
/// <summary>xUnit theory source over the driver-form types discovered by reflection.</summary>
public static TheoryData<Type> DriverFormsWithJsonOpts()
{
@@ -48,24 +79,28 @@ public sealed class DriverPageJsonConverterTests
[MemberData(nameof(DriverFormsWithJsonOpts))]
public void Driver_form_json_options_register_string_enum_converter(Type formType)
{
var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
var opts = (JsonSerializerOptions)field!.GetValue(null)!;
var opts = ResolveConfigSerializer(formType);
opts.ShouldNotBeNull(
$"{formType.Name} must serialise config through a private static _jsonOpts field, or be listed in " +
"ExternalConfigSerializers with the shared options instance it uses — otherwise its enum handling " +
"is unguarded.");
opts.Converters.OfType<JsonStringEnumConverter>().ShouldNotBeEmpty(
$"{formType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " +
"enum config fields serialise as numbers and the string-typed driver factory throws on parse.");
$"{formType.Name}'s config serializer must register a JsonStringEnumConverter; otherwise " +
"AdminUI-authored enum config fields serialise as numbers and the string-typed driver factory " +
"throws on parse.");
}
/// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a
/// <c>_jsonOpts</c> field — otherwise a new form that serialised config a different way would slip
/// past the converter guard above. Also a floor check so a rename can't silently shrink the set.</summary>
/// serializer the guard above can reach — otherwise a new form that serialised config a different way
/// would slip past it. Also a floor check so a rename can't silently shrink the set.</summary>
[Fact]
public void Every_driver_form_uses_a_guarded_jsonOpts_serializer()
{
var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
.ToList();
allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet");
DriverFormTypes.Count.ShouldBe(allDriverForms.Count,
"every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
DriverFormTypes.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-form fleet");
var unguarded = DriverFormTypes.Where(t => ResolveConfigSerializer(t) is null).Select(t => t.Name).ToList();
unguarded.ShouldBeEmpty(
"every *DriverForm must expose its config serializer (a _jsonOpts field, or an ExternalConfigSerializers " +
"entry) so the string-enum converter guard covers it");
}
}
@@ -0,0 +1,85 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>
/// Guards the <c>/raw</c> "New driver" picker against driver-type drift.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="RawDriverTypeDialog"/> is the <b>only</b> surface an operator can author a
/// <c>/raw</c> driver from, and its option list is a hand-maintained array — nothing tied it to
/// <see cref="DriverTypeNames"/>. The MQTT P1 live gate found the consequence: the driver shipped
/// complete (contracts, driver, browser, factory + host registration, typed tag editor, 266 green
/// unit tests) and was still <b>unauthorable in production</b>, because nobody added the row to
/// this array. Every offline test passed the whole way — the array is not reachable from any of
/// them, and AdminUI has no bUnit.
/// </para>
/// <para>
/// So this asserts parity by reflection rather than re-listing the drivers: a new
/// <see cref="DriverTypeNames"/> constant that nobody surfaces in the picker fails here instead of
/// at someone's live gate. It reads the private static <c>Types</c> field because that array is
/// the component's own source of truth for the rendered <c>&lt;option&gt;</c> set.
/// </para>
/// </remarks>
public sealed class RawDriverTypeDialogParityTests
{
private static IReadOnlyList<(string Label, string Value)> DialogTypes()
{
var field = typeof(RawDriverTypeDialog).GetField(
"Types",
BindingFlags.NonPublic | BindingFlags.Static);
field.ShouldNotBeNull(
"RawDriverTypeDialog.Types was renamed or removed — this guard reads it by name and " +
"silently guards nothing once it cannot find it.");
var raw = (Array)field!.GetValue(null)!;
return raw.Cast<object>()
.Select(t =>
{
var type = t.GetType();
return ((string)type.GetField("Item1")!.GetValue(t)!,
(string)type.GetField("Item2")!.GetValue(t)!);
})
.ToList();
}
[Fact]
public void Every_declared_driver_type_is_offered_by_the_new_driver_picker()
{
var offered = DialogTypes().Select(t => t.Value).ToHashSet(StringComparer.Ordinal);
var missing = DriverTypeNames.All.Where(n => !offered.Contains(n)).ToList();
missing.ShouldBeEmpty(
$"These driver types are declared in DriverTypeNames but cannot be authored from the " +
$"/raw New-driver picker, so they are unreachable in production: {string.Join(", ", missing)}. " +
"Add a row to RawDriverTypeDialog.Types.");
}
[Fact]
public void The_picker_offers_no_driver_type_that_is_not_declared()
{
var declared = DriverTypeNames.All.ToHashSet(StringComparer.Ordinal);
var unknown = DialogTypes().Select(t => t.Value).Where(v => !declared.Contains(v)).ToList();
unknown.ShouldBeEmpty(
$"The /raw New-driver picker offers driver types with no DriverTypeNames constant, so " +
$"authoring one produces a driver no factory can build: {string.Join(", ", unknown)}.");
}
[Fact]
public void Mqtt_is_authorable_from_the_new_driver_picker()
{
// The specific regression the P1 live gate caught, pinned by name so a future refactor of the
// parity facts above cannot quietly drop MQTT again.
DialogTypes().Select(t => t.Value).ShouldContain(DriverTypeNames.Mqtt);
}
}
@@ -0,0 +1,54 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Guards for the MQTT <b>device</b> model. MQTT holds ONE broker connection per driver, so — like
/// Galaxy — the device carries no endpoint of its own and the model round-trips its DeviceConfig
/// verbatim. The one behaviour it does add is detecting <i>legacy</i> connection keys left on a
/// DeviceConfig by a pre-form (hand-authored / SQL-seeded) deployment, because
/// <c>DriverDeviceConfigMerger</c> merges a sole device's keys UP over the driver's and they would
/// otherwise silently win over what the driver form shows.
/// </summary>
public sealed class MqttDeviceModelTests
{
[Fact]
public void Round_trips_every_key_verbatim()
{
const string inbound = """{"Host":"10.100.0.35","Port":8883,"anything":{"nested":true}}""";
var json = MqttDeviceModel.FromJson(inbound).ToJson();
var o = (JsonObject)JsonNode.Parse(json)!;
o["Host"]!.GetValue<string>().ShouldBe("10.100.0.35");
o["Port"]!.GetValue<int>().ShouldBe(8883);
o["anything"]!["nested"]!.GetValue<bool>().ShouldBeTrue();
}
[Fact]
public void A_new_device_serializes_to_an_empty_object()
=> MqttDeviceModel.FromJson(null).ToJson().ShouldBe("{}");
[Fact]
public void A_malformed_blob_degrades_to_an_empty_object_rather_than_throwing()
=> MqttDeviceModel.FromJson("}{ not json").ToJson().ShouldBe("{}");
[Fact]
public void Validate_always_passes_because_there_is_no_per_device_endpoint()
=> MqttDeviceModel.FromJson("""{"Host":"x"}""").Validate().ShouldBeNull();
[Fact]
public void Legacy_connection_keys_are_reported_case_insensitively()
{
var model = MqttDeviceModel.FromJson("""{"host":"h","PORT":1883,"useTls":false,"unrelated":1}""");
model.LegacyConnectionKeys.ShouldBe(["host", "PORT", "useTls"], ignoreOrder: true);
}
[Fact]
public void An_empty_device_reports_no_legacy_connection_keys()
=> MqttDeviceModel.FromJson("{}").LegacyConnectionKeys.ShouldBeEmpty();
}
@@ -0,0 +1,437 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Round-trip, enum-serialization and validation guards for the MQTT <b>driver</b> config form model.
/// <para>
/// The load-bearing assertion is <see cref="Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options"/>:
/// it deserializes this form's output through <see cref="MqttJson.Options"/> — the exact instance
/// <c>MqttDriverFactoryExtensions.CreateInstance</c> and <c>MqttDriverProbe</c> use — so an
/// AdminUI-authored blob that Test-connect accepts cannot fault the deployed driver. That is this
/// repo's documented systemic driver-enum-serialization bug, reproduced as a guard.
/// </para>
/// </summary>
public sealed class MqttDriverFormModelTests
{
private static JsonObject Parse(string json) => (JsonObject)JsonNode.Parse(json)!;
private static bool HasKeyIgnoringCase(JsonObject o, string name)
=> o.Any(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase));
// --- defaults -------------------------------------------------------------------------------
[Fact]
public void Defaults_match_the_driver_options_secure_posture()
{
var model = MqttDriverFormModel.FromJson(null);
var driverDefaults = new MqttDriverOptions();
model.Port.ShouldBe(driverDefaults.Port);
model.UseTls.ShouldBeTrue();
model.UseTls.ShouldBe(driverDefaults.UseTls);
model.AllowUntrustedServerCertificate.ShouldBeFalse();
model.AllowUntrustedServerCertificate.ShouldBe(driverDefaults.AllowUntrustedServerCertificate);
model.ProtocolVersion.ShouldBe(driverDefaults.ProtocolVersion);
model.Mode.ShouldBe(driverDefaults.Mode);
model.MaxPayloadBytes.ShouldBe(driverDefaults.MaxPayloadBytes);
// The redundancy-pair eviction guard: an unauthored driver leaves clientId unset so MQTTnet
// generates a unique id per connection (P1 live-gate finding).
model.ClientId.ShouldBe("");
}
// --- round-trip -----------------------------------------------------------------------------
[Fact]
public void Round_trip_preserves_every_authored_field()
{
var model = MqttDriverFormModel.FromJson(null);
model.Host = "broker.internal";
model.Port = 1883;
model.ClientId = "otopcua-site-a";
model.Username = "otopcua";
model.Password = "s3cret";
model.UseTls = false;
model.AllowUntrustedServerCertificate = true;
model.CaCertificatePath = "/etc/ssl/ca.pem";
model.ProtocolVersion = MqttProtocolVersion.V311;
model.CleanSession = false;
model.KeepAliveSeconds = 45;
model.ConnectTimeoutSeconds = 9;
model.ReconnectMinBackoffSeconds = 2;
model.ReconnectMaxBackoffSeconds = 60;
model.Mode = MqttMode.Plain;
model.MaxPayloadBytes = 4096;
model.TopicPrefix = "otopcua/fixture/";
model.DefaultQos = 2;
var back = MqttDriverFormModel.FromJson(model.ToJson());
back.Host.ShouldBe("broker.internal");
back.Port.ShouldBe(1883);
back.ClientId.ShouldBe("otopcua-site-a");
back.Username.ShouldBe("otopcua");
back.Password.ShouldBe("s3cret");
back.UseTls.ShouldBeFalse();
back.AllowUntrustedServerCertificate.ShouldBeTrue();
back.CaCertificatePath.ShouldBe("/etc/ssl/ca.pem");
back.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
back.CleanSession.ShouldBeFalse();
back.KeepAliveSeconds.ShouldBe(45);
back.ConnectTimeoutSeconds.ShouldBe(9);
back.ReconnectMinBackoffSeconds.ShouldBe(2);
back.ReconnectMaxBackoffSeconds.ShouldBe(60);
back.Mode.ShouldBe(MqttMode.Plain);
back.MaxPayloadBytes.ShouldBe(4096);
back.TopicPrefix.ShouldBe("otopcua/fixture/");
back.DefaultQos.ShouldBe(2);
}
// --- the factory-parity guard (the point of the whole file) ---------------------------------
[Fact]
public void Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options()
{
var model = MqttDriverFormModel.FromJson(null);
model.Host = "10.100.0.35";
model.Port = 8883;
model.Username = "otopcua";
model.Password = "pw";
model.ProtocolVersion = MqttProtocolVersion.V311;
model.Mode = MqttMode.SparkplugB;
model.ConnectTimeoutSeconds = 7;
model.DefaultQos = 0;
// The exact instance the runtime factory + probe + driver + browser parse through.
var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options);
options.ShouldNotBeNull();
options.Host.ShouldBe("10.100.0.35");
options.Port.ShouldBe(8883);
options.Username.ShouldBe("otopcua");
options.Password.ShouldBe("pw");
options.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
options.Mode.ShouldBe(MqttMode.SparkplugB);
options.ConnectTimeoutSeconds.ShouldBe(7);
options.Plain.ShouldNotBeNull();
options.Plain.DefaultQos.ShouldBe(0);
}
[Fact]
public void Enums_serialize_as_names_never_as_ordinals()
{
var model = MqttDriverFormModel.FromJson(null);
model.Mode = MqttMode.SparkplugB;
model.ProtocolVersion = MqttProtocolVersion.V311;
var json = model.ToJson();
json.ShouldContain("\"Mode\":\"SparkplugB\"");
json.ShouldContain("\"ProtocolVersion\":\"V311\"");
// The historical bug: a numerically-serialized enum that the string-typed runtime binder faults on.
json.ShouldNotContain("\"Mode\":1", Case.Sensitive);
json.ShouldNotContain("\"ProtocolVersion\":0", Case.Sensitive);
}
[Fact]
public void An_ordinal_only_reader_cannot_bind_the_blob_which_proves_the_enums_are_names()
{
// The string-match-free half of the enum pin, and a falsifiability control for the test above.
// A JsonSerializerOptions WITHOUT a JsonStringEnumConverter can read a NUMERIC enum but not a
// named one — so if this form ever regressed to writing ordinals (the historical driver bug),
// this deserialize would start SUCCEEDING and the test would fail. Deliberately independent of
// MqttJson.Options, because a round-trip through one shared instance is symmetric and would
// happily pass on ordinals at both ends.
var model = MqttDriverFormModel.FromJson(null);
model.Mode = MqttMode.SparkplugB;
model.ProtocolVersion = MqttProtocolVersion.V311;
var ordinalOnlyReader = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
Should.Throw<JsonException>(
() => JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), ordinalOnlyReader));
}
// --- blob hygiene ---------------------------------------------------------------------------
[Fact]
public void RawTags_are_never_written_into_the_driver_config()
{
// The deploy artifact injects RawTags via DriverDeviceConfigMerger; a form-authored copy would
// be dead weight at best and a stale shadow at worst.
const string inbound = """{"Host":"h","rawTags":[{"RawPath":"a/b","TagConfig":"{}"}]}""";
var json = MqttDriverFormModel.FromJson(inbound).ToJson();
HasKeyIgnoringCase(Parse(json), "RawTags").ShouldBeFalse();
}
[Fact]
public void Unknown_keys_and_the_sparkplug_subobject_survive_a_load_save()
{
const string inbound = """
{"Host":"h","sparkplug":{"GroupId":"G1","HostId":"H1","ActAsPrimaryHost":true},"aFutureKey":42}
""";
var json = MqttDriverFormModel.FromJson(inbound).ToJson();
var o = Parse(json);
// The blob has no Mode key, so it is Plain — and in Plain mode this form does not author the
// Sparkplug sub-object at all, so it must come back byte-identical.
o["sparkplug"].ShouldNotBeNull();
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("G1");
o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue<bool>().ShouldBeTrue();
o["aFutureKey"]!.GetValue<int>().ShouldBe(42);
}
// --- Sparkplug B authoring -------------------------------------------------------------------
//
// These pin the defect the P2 live gate found: MqttDriverForm shipped its Sparkplug branch as a
// "not implemented yet" placeholder, so once Sparkplug ingest landed the group id — the driver's
// ENTIRE subscription filter, spBv1.0/{GroupId}/# — could not be authored from the AdminUI at all.
// The driver deployed connected, Healthy, and ingesting nothing.
[Fact]
public void Sparkplug_mode_round_trips_the_group_id_through_a_load_save_load()
{
var authored = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
authored.Mode = MqttMode.SparkplugB;
authored.SparkplugGroupId = "OtOpcUaSim";
authored.SparkplugRequestRebirthOnGap = true;
var reloaded = MqttDriverFormModel.FromJson(authored.ToJson());
reloaded.Mode.ShouldBe(MqttMode.SparkplugB);
reloaded.SparkplugGroupId.ShouldBe("OtOpcUaSim");
reloaded.SparkplugRequestRebirthOnGap.ShouldBeTrue();
}
[Fact]
public void Sparkplug_mode_emits_a_group_id_the_driver_options_actually_bind()
{
// The whole point of the round-trip: what this form writes must deserialize back through the
// SAME shared MqttJson.Options the runtime factory uses, or the driver sees a null group.
var authored = MqttDriverFormModel.FromJson(null);
authored.Mode = MqttMode.SparkplugB;
authored.SparkplugGroupId = "OtOpcUaSim";
var options = JsonSerializer.Deserialize<MqttDriverOptions>(authored.ToJson(), MqttJson.Options)!;
options.Mode.ShouldBe(MqttMode.SparkplugB);
options.Sparkplug.ShouldNotBeNull();
options.Sparkplug!.GroupId.ShouldBe("OtOpcUaSim");
}
[Fact]
public void Sparkplug_authoring_merges_over_the_subobject_rather_than_replacing_it()
{
// Same preserve-what-you-do-not-author discipline the top-level bag applies: a key a newer
// driver adds INSIDE the sub-object must survive an older AdminUI's save.
const string inbound = """
{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old","aFutureSpbKey":7}}
""";
var model = MqttDriverFormModel.FromJson(inbound);
model.SparkplugGroupId = "New";
var o = Parse(model.ToJson());
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
o["sparkplug"]!["aFutureSpbKey"]!.GetValue<int>().ShouldBe(7);
}
[Fact]
public void A_camelCase_sparkplug_key_is_merged_not_duplicated()
{
const string inbound = """{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old"}}""";
var model = MqttDriverFormModel.FromJson(inbound);
model.SparkplugGroupId = "New";
var o = Parse(model.ToJson());
o.Count(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
}
[Fact]
public void Plain_mode_never_writes_a_sparkplug_subobject_onto_a_blob_that_had_none()
{
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
model.SparkplugGroupId = "TypedThenSwitchedAway"; // Mode is still Plain.
HasKeyIgnoringCase(Parse(model.ToJson()), "Sparkplug").ShouldBeFalse();
}
[Fact]
public void A_blank_group_id_is_refused_in_sparkplug_mode_only()
{
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
// Plain does not care — it binds by topic.
model.Validate().ShouldBeNull();
model.Mode = MqttMode.SparkplugB;
model.Validate().ShouldNotBeNull();
model.Validate()!.ShouldContain("Sparkplug group ID is required");
}
[Theory]
[InlineData("Plant/1")]
[InlineData("Plant+1")]
[InlineData("Plant#1")]
public void A_group_id_carrying_a_topic_metacharacter_is_refused(string groupId)
{
// It is a literal segment inside spBv1.0/{GroupId}/#, so '/' silently widens the filter and
// '+'/'#' are not even legal mid-segment. Mirrors MqttTagConfigModel's tag-side rule.
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
model.Mode = MqttMode.SparkplugB;
model.SparkplugGroupId = groupId;
model.Validate()!.ShouldContain("cannot appear in an MQTT topic segment");
}
[Fact]
public void A_camelCase_inbound_key_is_replaced_not_duplicated()
{
// MqttJson.Options is PropertyNameCaseInsensitive, so a hand-edited camelCase blob binds; the
// form must overwrite that key rather than leave "host" and "Host" fighting in one object.
const string inbound = """{"host":"old","port":1111}""";
var model = MqttDriverFormModel.FromJson(inbound);
model.Host.ShouldBe("old");
model.Port.ShouldBe(1111);
model.Host = "new";
var o = Parse(model.ToJson());
o.Count(p => string.Equals(p.Key, "Host", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
JsonSerializer.Deserialize<MqttDriverOptions>(o.ToJsonString(), MqttJson.Options)!.Host.ShouldBe("new");
}
[Fact]
public void Blank_optional_strings_are_omitted_so_the_driver_defaults_apply()
{
var json = MqttDriverFormModel.FromJson(null).ToJson();
var options = JsonSerializer.Deserialize<MqttDriverOptions>(json, MqttJson.Options)!;
options.ClientId.ShouldBeNull();
options.Username.ShouldBeNull();
options.CaCertificatePath.ShouldBeNull();
}
[Fact]
public void The_merged_deploy_blob_keeps_the_form_connection_and_gains_the_artifact_RawTags()
{
// The real deploy seam: DriverDeviceConfigMerger folds this form's DriverConfig together with the
// (empty) MQTT DeviceConfig and injects the authored raw tags. MQTT authors its connection on the
// DRIVER precisely so this survives 0, 1 or N devices.
var model = MqttDriverFormModel.FromJson(null);
model.Host = "broker.internal";
model.Port = 1883;
model.UseTls = false;
model.Mode = MqttMode.Plain;
model.DefaultQos = 2;
var rawTags = new[]
{
new RawTagEntry("Plant/Mqtt/Dev1/Temp", """{"topic":"a/b"}""", WriteIdempotent: false, DeviceName: "Dev1"),
};
foreach (var devices in new[]
{
Array.Empty<DriverDeviceConfigMerger.DeviceRow>(),
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}")],
[new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}"), new DriverDeviceConfigMerger.DeviceRow("Dev2", "{}")],
})
{
var merged = DriverDeviceConfigMerger.Merge(model.ToJson(), devices, rawTags);
var options = JsonSerializer.Deserialize<MqttDriverOptions>(merged, MqttJson.Options)!;
options.Host.ShouldBe("broker.internal", $"device count {devices.Length}");
options.Port.ShouldBe(1883, $"device count {devices.Length}");
options.UseTls.ShouldBeFalse($"device count {devices.Length}");
options.Plain!.DefaultQos.ShouldBe(2, $"device count {devices.Length}");
options.RawTags.Count.ShouldBe(1, $"device count {devices.Length}");
options.RawTags[0].RawPath.ShouldBe("Plant/Mqtt/Dev1/Temp");
}
}
// --- validation -----------------------------------------------------------------------------
[Fact]
public void Validate_accepts_a_default_model()
=> MqttDriverFormModel.FromJson(null).Validate().ShouldBeNull();
[Theory]
[InlineData("Host", "")]
[InlineData("Port", 0)]
[InlineData("Port", 70000)]
[InlineData("KeepAliveSeconds", 0)]
[InlineData("ConnectTimeoutSeconds", 0)]
[InlineData("ReconnectMinBackoffSeconds", 0)]
[InlineData("ReconnectMaxBackoffSeconds", -1)]
[InlineData("MaxPayloadBytes", 0)]
[InlineData("DefaultQos", 3)]
[InlineData("DefaultQos", -1)]
public void Validate_rejects_an_out_of_range_field(string field, object value)
{
var model = MqttDriverFormModel.FromJson(null);
typeof(MqttDriverFormModel).GetProperty(field)!.SetValue(model, value);
model.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_rejects_a_max_backoff_below_the_min()
{
var model = MqttDriverFormModel.FromJson(null);
model.ReconnectMinBackoffSeconds = 30;
model.ReconnectMaxBackoffSeconds = 5;
model.Validate().ShouldNotBeNull();
}
[Fact]
public void An_ignored_validation_error_still_cannot_produce_a_driver_bricking_blob()
{
// Documented finding: `timeoutSeconds: 0` was an operator-authorable driver-brick. Validate()
// reports it; the serializer clamps it, so even a saved-anyway blob stays inside the driver's
// own [Range] bounds.
var model = MqttDriverFormModel.FromJson(null);
model.ConnectTimeoutSeconds = 0;
model.KeepAliveSeconds = -5;
model.ReconnectMinBackoffSeconds = 0;
model.ReconnectMaxBackoffSeconds = -3;
model.MaxPayloadBytes = 0;
model.Port = 99999;
model.DefaultQos = 9;
var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options)!;
options.ConnectTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
options.KeepAliveSeconds.ShouldBeGreaterThanOrEqualTo(1);
options.ReconnectMinBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
options.ReconnectMaxBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
options.MaxPayloadBytes.ShouldBeGreaterThanOrEqualTo(1);
options.Port.ShouldBeInRange(1, 65535);
options.Plain!.DefaultQos.ShouldBeInRange(0, 2);
}
[Fact]
public void A_malformed_inbound_blob_degrades_to_defaults_rather_than_throwing()
{
var model = MqttDriverFormModel.FromJson("}{ not json");
model.Host.ShouldBe(new MqttDriverOptions().Host);
model.UseTls.ShouldBeTrue();
}
}
@@ -0,0 +1,431 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
// Typed AdminUI editor model for the Mqtt driver — Plain (topic-bound) and Sparkplug B (birth/alias
// ingest) modes. The model is a thin typed view over a preserved JsonObject key bag: every key the
// editor does not expose (history intent, array config, alarm objects) must survive a load→save
// untouched.
//
// The authoritative consumers of the produced blob are MqttTagDefinitionFactory.FromTagConfig (Plain)
// and .FromSparkplugTagConfig (Sparkplug B) (Driver.Mqtt.Contracts) — the key names + strictness rules
// asserted here are those factories', not this editor's invention. TagConfig carries NO identity under
// v3 (the tag's RawPath is its identity), so there is deliberately no FullName key — in EITHER mode.
public sealed class MqttTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
[InlineData("not json at all")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = MqttTagConfigModel.FromJson(json);
m.Mode.ShouldBe(MqttMode.Plain);
m.Topic.ShouldBe("");
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
// An UNAUTHORED tag seeds the document-root path so the common "no extraction needed" case is
// visible and one click away — it is a guide, not a requirement (blank is accepted, see below).
m.JsonPath.ShouldBe("$");
m.DataType.ShouldBe(DriverDataType.String);
m.Qos.ShouldBeNull();
m.RetainSeed.ShouldBeNull();
}
[Fact]
public void FromJson_reads_every_plain_field()
{
var m = MqttTagConfigModel.FromJson(
"""{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1,"retainSeed":false}""");
m.Mode.ShouldBe(MqttMode.Plain);
m.Topic.ShouldBe("factory/oven/temp");
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
m.JsonPath.ShouldBe("$.value");
m.DataType.ShouldBe(DriverDataType.Float64);
m.Qos.ShouldBe(1);
m.RetainSeed.ShouldBe(false);
}
[Fact]
public void Round_trip_preserves_every_plain_field()
{
var m = new MqttTagConfigModel
{
Topic = "line3/press",
PayloadFormat = MqttPayloadFormat.Scalar,
JsonPath = "$.a.b",
DataType = DriverDataType.Int32,
Qos = 2,
RetainSeed = true,
};
var m2 = MqttTagConfigModel.FromJson(m.ToJson());
m2.Topic.ShouldBe("line3/press");
m2.PayloadFormat.ShouldBe(MqttPayloadFormat.Scalar);
m2.JsonPath.ShouldBe("$.a.b");
m2.DataType.ShouldBe(DriverDataType.Int32);
m2.Qos.ShouldBe(2);
m2.RetainSeed.ShouldBe(true);
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_names_and_no_identity_key()
{
var json = new MqttTagConfigModel
{
Topic = "a/b",
PayloadFormat = MqttPayloadFormat.Raw,
DataType = DriverDataType.Boolean,
}.ToJson();
json.ShouldContain("\"topic\":\"a/b\"");
// Enums MUST serialize as names — the driver factory reads them strictly by name, and an
// ordinal would be rejected outright (the systemic enum-serialization trap).
json.ShouldContain("\"payloadFormat\":\"Raw\"");
json.ShouldContain("\"dataType\":\"Boolean\"");
json.ShouldNotContain("\"payloadFormat\":1");
json.ShouldNotContain("\"dataType\":0");
// TagConfig carries no identity under v3 — the tag's RawPath is the driver-side key.
json.ShouldNotContain("FullName", Case.Sensitive);
}
[Fact]
public void ToJson_omits_absent_optional_keys()
{
var json = new MqttTagConfigModel { Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
// Absent stays absent so the driver's own defaults (qos ⇒ DefaultQos, retainSeed ⇒ true,
// jsonPath ⇒ "$") apply, rather than this editor freezing them into the blob.
json.ShouldNotContain("qos");
json.ShouldNotContain("retainSeed");
json.ShouldNotContain("jsonPath");
}
[Fact]
public void ToJson_omits_a_blank_topic()
{
// Toggling the shape dropdown on an untouched tag must not leave a stray "topic":"" behind.
var json = new MqttTagConfigModel { PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
json.ShouldNotContain("topic");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = MqttTagConfigModel.FromJson(
"""
{"topic":"a/b","payloadFormat":"Raw","dataType":"String",
"isHistorized":true,"historianTagname":"Plant.A.B",
"array":{"valueRank":1,"dimensions":[4]},
"alarm":{"type":"Hi","limit":90.5},
"someFutureScalar":"keep me"}
""").ToJson();
var o = JsonNode.Parse(json)!.AsObject();
// History intent is authored by a different seam (TagHistorizeConfig) — dropping it here would
// silently un-historize the tag on the next edit.
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
o["historianTagname"]!.GetValue<string>().ShouldBe("Plant.A.B");
// Nested objects/arrays must survive structurally, not just as scalars.
o["array"]!["valueRank"]!.GetValue<int>().ShouldBe(1);
o["array"]!["dimensions"]!.AsArray()[0]!.GetValue<int>().ShouldBe(4);
o["alarm"]!["limit"]!.GetValue<double>().ShouldBe(90.5);
o["someFutureScalar"]!.GetValue<string>().ShouldBe("keep me");
}
[Fact]
public void FromJson_then_ToJson_preserves_sparkplug_descriptor_keys()
{
var json = MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
var o = JsonNode.Parse(json)!.AsObject();
o["groupId"]!.GetValue<string>().ShouldBe("Plant1");
o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
o["deviceId"]!.GetValue<string>().ShouldBe("D1");
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
// TagConfig carries no identity under v3 even for a Sparkplug tag — see the corrections in the
// Task 24 brief: FullName is a retired pre-v3 concept, not something this editor re-introduces.
json.ShouldNotContain("FullName", Case.Sensitive);
}
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
//
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
// required, deviceId optional, dataType/qos read strictly but only when present. See the model's
// Validate() remarks for the one place this editor is deliberately STRICTER than the factory
// (illegal characters in the id segments) and why metricName is exempt from that rule.
[Fact]
public void Validate_SparkplugMissingMetricName_Fails()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Validate_SparkplugMissingGroupId_Fails()
=> MqttTagConfigModel.FromJson("""{"edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Validate_SparkplugMissingEdgeNodeId_Fails()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","metricName":"Temperature"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Validate_SparkplugValid_ReturnsNull()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
.Validate().ShouldBeNull();
// deviceId is genuinely optional per the factory (absent ⇒ a node-level metric) — a Sparkplug tag
// must validate cleanly without one.
[Fact]
public void Validate_SparkplugValidWithoutDeviceId_ReturnsNull()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.Validate().ShouldBeNull();
// dataType is OPTIONAL in Sparkplug mode (the birth certificate declares it) — unlike Plain mode,
// an absent dataType is not an error.
[Fact]
public void Validate_SparkplugAbsentDataType_ReturnsNull()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.Validate().ShouldBeNull();
// ...but a PRESENT, typo'd dataType is still rejected — same strict-when-present rule as Plain,
// and the same DriverDataType vocabulary (not the raw Sparkplug wire enum).
[Fact]
public void Validate_SparkplugTypoedDataType_Fails()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","dataType":"Double"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Theory]
[InlineData("3")]
[InlineData("-1")]
public void Validate_SparkplugInvalidQos_Fails(string qosLiteral)
=> MqttTagConfigModel.FromJson(
$$"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","qos":{{qosLiteral}}}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
// Editor-stricter-than-factory: group/edge-node/device ids are literal MQTT topic segments — a
// decoded incoming id can never contain '/', '+', or '#', so an authored one that does could never
// bind. Covers all three id fields plus both illegal characters.
[Theory]
[InlineData("groupId", "Plant1/A")]
[InlineData("groupId", "Plant+1")]
[InlineData("groupId", "Plant#1")]
[InlineData("edgeNodeId", "Edge/A")]
[InlineData("edgeNodeId", "Edge+A")]
[InlineData("deviceId", "Dev/1")]
[InlineData("deviceId", "Dev#1")]
public void Validate_SparkplugIllegalCharacterInIdSegment_Fails(string field, string value)
{
var fields = new Dictionary<string, string>
{
["groupId"] = "Plant1",
["edgeNodeId"] = "EdgeA",
["deviceId"] = "Filler1",
["metricName"] = "Temperature",
};
fields[field] = value;
var json = System.Text.Json.JsonSerializer.Serialize(fields);
MqttTagConfigModel.FromJson(json).Validate().ShouldNotBeNullOrWhiteSpace();
}
// metricName is explicitly EXEMPT from the id-segment character rule — it is not a topic segment
// (it comes from the payload, not the topic), and Sparkplug's own canonical examples use '/' in
// metric names (e.g. "Node Control/Rebirth"). Over-rejecting here would block legitimate authoring.
[Fact]
public void Validate_SparkplugMetricNameWithSlash_IsAccepted()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
.Validate().ShouldBeNull();
// ── ToJson: Sparkplug dataType override (Task 24) ───────────────────────────────────────────
[Fact]
public void ToJson_SparkplugWithoutOverride_OmitsDataType()
{
var json = MqttTagConfigModel
.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.ToJson();
json.ShouldNotContain("dataType");
}
[Fact]
public void ToJson_Sparkplug_RoundTripsDescriptorFieldsAndDataTypeOverride()
{
var json = MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
.ToJson();
json.ShouldContain("\"groupId\":\"Plant1\"");
json.ShouldContain("\"edgeNodeId\":\"EdgeA\"");
json.ShouldContain("\"deviceId\":\"Filler1\"");
json.ShouldContain("\"metricName\":\"Temperature\"");
json.ShouldContain("\"dataType\":\"Float32\"");
json.ShouldNotContain("FullName", Case.Sensitive);
}
// A SparkplugB tag has NO 'mode' key to persist — it survives a save→reopen purely because the
// descriptor keys it writes re-infer the mode on the next load.
[Fact]
public void FromJson_then_ToJson_then_FromJson_SparkplugMode_SurvivesReopen()
{
var m = MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""");
m.Mode.ShouldBe(MqttMode.SparkplugB);
var json = m.ToJson();
json.ShouldNotContain("\"mode\"");
MqttTagConfigModel.FromJson(json).Mode.ShouldBe(MqttMode.SparkplugB);
}
[Fact]
public void FromJson_infers_SparkplugB_mode_from_the_descriptor_keys()
=> MqttTagConfigModel
.FromJson("""{"groupId":"Plant1","edgeNodeId":"E1","metricName":"Temp"}""")
.Mode.ShouldBe(MqttMode.SparkplugB);
// ── Validate: Plain rules ────────────────────────────────────────────────────────────────────
[Fact]
public void Validate_ValidPlain_ReturnsNull()
=> MqttTagConfigModel.FromJson(
"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String"}""")
.Validate().ShouldBeNull();
[Fact]
public void Validate_MissingTopic_Fails()
=> MqttTagConfigModel.FromJson("""{"payloadFormat":"Raw","dataType":"String"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Theory]
[InlineData("a/+/c")]
[InlineData("a/#")]
public void Validate_PlainWildcardTopic_Fails(string topic)
=> MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
// A blank jsonPath under a Json payload is ACCEPTED — the runtime defaults it to the document root
// ("$"), which is the real "publisher puts a bare JSON scalar on the topic" case. Rejecting it would
// make the editor refuse what the driver accepts, and would block CSV-import batches (this validator
// also gates RawManualTagEntryModal's review grid) over a sane default.
[Fact]
public void Validate_JsonWithoutPath_IsAccepted()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
.Validate().ShouldBeNull();
// …and the key stays ABSENT through a load→save, so the driver applies its own default rather than
// this editor freezing "$" into an already-deployed blob.
[Fact]
public void ToJson_leaves_an_existing_blank_jsonPath_absent()
{
var json = MqttTagConfigModel
.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
.ToJson();
json.ShouldNotContain("jsonPath");
}
// The seed applies ONLY to an unauthored tag (no topic AND no jsonPath) — a brand-new tag emits an
// explicit "$" once the operator touches anything, which is the guided half of the deal.
[Fact]
public void ToJson_seeds_the_root_jsonPath_on_a_fresh_tag()
{
var m = MqttTagConfigModel.FromJson(null);
m.Topic = "a/b";
m.ToJson().ShouldContain("\"jsonPath\":\"$\"");
}
[Fact]
public void Validate_JsonWithPath_ReturnsNull()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
.Validate().ShouldBeNull();
[Fact]
public void Validate_NonJsonFormat_DoesNotRequireJsonPath()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Scalar","dataType":"Float64"}""")
.Validate().ShouldBeNull();
[Theory]
[InlineData("3")]
[InlineData("-1")]
[InlineData("\"high\"")]
[InlineData("1.5")]
public void Validate_InvalidQos_Fails(string qosLiteral)
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qosLiteral}}}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void Validate_LegalQos_ReturnsNull(int qos)
=> MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qos}}}""")
.Validate().ShouldBeNull();
// The driver factory reads payloadFormat/dataType STRICTLY — a typo is rejected outright
// (BadNodeIdUnknown), never defaulted. The editor must not let such a blob past save while
// silently retyping it, so Validate reads the ORIGINAL key bag, not the defaulted typed field.
[Fact]
public void Validate_TypoedPayloadFormat_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Jason","dataType":"String"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Validate_TypoedDataType_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
// Once the editor has round-tripped the blob the typo is gone (ToJson rewrites the key with the
// typed field's name), so the strict check above can never wedge a tag the editor itself produced.
// NB a payloadFormat typo repairs to the Json default, which then legitimately demands a jsonPath —
// so the typo-repair claim is isolated here on dataType, whose default carries no follow-on rule.
[Fact]
public void Validate_AfterEditorRoundTrip_ClearsATypoedEnum()
{
var typoed = """{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""";
MqttTagConfigModel.FromJson(typoed).Validate().ShouldNotBeNullOrWhiteSpace(); // pre-condition
var repaired = MqttTagConfigModel.FromJson(typoed).ToJson();
repaired.ShouldContain("\"dataType\":\"String\"");
MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull();
}
// A well-formed Sparkplug tag is NOT subject to the Plain rules — it has no topic, and applying the
// Plain "topic is required" check would reject every Sparkplug tag.
[Fact]
public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
=> MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
.Validate().ShouldBeNull();
// ── Dispatch registration ────────────────────────────────────────────────────────────────────
[Fact]
public void TagConfigEditorMap_resolves_the_Mqtt_editor()
=> TagConfigEditorMap.Resolve("Mqtt").ShouldBe(
typeof(AdminUI.Components.Shared.Uns.TagEditors.MqttTagConfigEditor));
[Fact]
public void TagConfigValidator_dispatches_Mqtt()
=> TagConfigValidator.Validate("Mqtt", """{"payloadFormat":"Raw"}""").ShouldNotBeNullOrWhiteSpace();
}
@@ -5,6 +5,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
@@ -96,6 +97,204 @@ public sealed class RawBrowseCommitMapperTests
JsonNode.Parse(json)!.AsObject()["address"]!.GetValue<string>().ShouldBe("40001");
}
// ---- BuildTagConfig: MQTT (the address is a DESCRIPTOR, not a single reference string) --------
/// <summary>The Sparkplug address a browse session states for a metric under a device.</summary>
/// <param name="metricName">The metric name to state.</param>
/// <returns>The stated address fields.</returns>
private static Dictionary<string, string> SparkplugFields(string metricName = "Temperature") => new()
{
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
[MqttTagConfigKeys.DeviceId] = "Filler1",
[MqttTagConfigKeys.MetricName] = metricName,
};
[Fact]
public void BuildTagConfig_Mqtt_plain_writes_the_topic_key_the_factory_actually_reads()
{
// The generic "address" fallback produced a blob MqttTagDefinitionFactory cannot read at all —
// a browse-committed tag that deploys clean and then reports BadNodeIdUnknown forever.
var json = RawBrowseCommitMapper.BuildTagConfig(
"Mqtt",
"otopcua/fixture/oven/temp",
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
}
[Fact]
public void BuildTagConfig_Mqtt_plain_falls_back_to_the_node_id_when_no_field_was_stated()
{
// A Plain browse node id IS the topic, so the fallback is exact — unlike Sparkplug, where the
// tuple is unrecoverable and DescribeUncommittableLeaf refuses instead.
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "otopcua/fixture/oven/temp");
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
}
[Fact]
public void BuildTagConfig_Mqtt_sparkplug_writes_the_binding_tuple_from_the_stated_fields()
{
var json = RawBrowseCommitMapper.BuildTagConfig(
"Mqtt", "OtOpcUaSim/EdgeA/Filler1::Temperature", SparkplugFields());
var o = JsonNode.Parse(json)!.AsObject();
o["groupId"]!.GetValue<string>().ShouldBe("OtOpcUaSim");
o["edgeNodeId"]!.GetValue<string>().ShouldBe("EdgeA");
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
o.ContainsKey("topic").ShouldBeFalse(); // a Sparkplug tag has no per-tag topic
o.ContainsKey("address").ShouldBeFalse(); // and never the generic fallback key
}
[Fact]
public void BuildTagConfig_Mqtt_sparkplug_node_level_metric_omits_deviceId_entirely()
{
var fields = SparkplugFields();
fields.Remove(MqttTagConfigKeys.DeviceId);
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "OtOpcUaSim/EdgeA::Temperature", fields);
JsonNode.Parse(json)!.AsObject().ContainsKey("deviceId").ShouldBeFalse();
}
[Fact]
public void BuildTagConfig_Mqtt_sparkplug_takes_the_metric_name_from_the_fields_not_the_node_id()
{
// THE case the whole seam exists for: a metric name containing '/' makes the node id
// '{group}/{edge}/{device}::{metric}' un-splittable — any parse would bind the wrong metric.
var json = RawBrowseCommitMapper.BuildTagConfig(
"Mqtt",
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
SparkplugFields("Node Control/Rebirth"));
var o = JsonNode.Parse(json)!.AsObject();
o["metricName"]!.GetValue<string>().ShouldBe("Node Control/Rebirth");
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
}
[Fact]
public void BuildTagConfig_Mqtt_sparkplug_survives_a_group_id_containing_the_metric_separator()
{
// A group id is an arbitrary MQTT topic segment and MAY contain '::'. Splitting the node id on
// the first '::' would read the group as 'odd' and the metric as 'group/EdgeA::Temperature'.
var fields = SparkplugFields();
fields[MqttTagConfigKeys.GroupId] = "odd::group";
var json = RawBrowseCommitMapper.BuildTagConfig(
"Mqtt", "odd::group/EdgeA/Filler1::Temperature", fields);
var o = JsonNode.Parse(json)!.AsObject();
o["groupId"]!.GetValue<string>().ShouldBe("odd::group");
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
}
[Fact]
public void BuildTagConfig_Mqtt_sparkplug_blob_deserializes_through_the_REAL_driver_factory()
{
// The round trip that would have caught the defect: the committed blob is fed to the factory the
// deployed driver actually uses. Anything else is a test of the mapper agreeing with itself.
var json = RawBrowseCommitMapper.BuildTagConfig(
"Mqtt",
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
SparkplugFields("Node Control/Rebirth"));
MqttTagDefinitionFactory
.FromSparkplugTagConfig(json, "Plant/Mqtt/dev1/Rebirth", out var def)
.ShouldBeTrue();
def.Name.ShouldBe("Plant/Mqtt/dev1/Rebirth"); // v3: identity is the RawPath
def.GroupId.ShouldBe("OtOpcUaSim");
def.EdgeNodeId.ShouldBe("EdgeA");
def.DeviceId.ShouldBe("Filler1");
def.MetricName.ShouldBe("Node Control/Rebirth");
def.DataTypeAuthored.ShouldBeFalse(); // no dataType key ⇒ take the birth's declared type
}
[Fact]
public void BuildTagConfig_Mqtt_plain_blob_deserializes_through_the_REAL_driver_factory()
{
var json = RawBrowseCommitMapper.BuildTagConfig(
"Mqtt",
"otopcua/fixture/oven/temp",
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
MqttTagDefinitionFactory.FromTagConfig(json, "Plant/Mqtt/dev1/Temp", out var def).ShouldBeTrue();
def.Topic.ShouldBe("otopcua/fixture/oven/temp");
def.Name.ShouldBe("Plant/Mqtt/dev1/Temp");
}
[Fact]
public void The_round_trip_test_is_falsifiable_a_wrong_key_name_is_rejected_by_the_factory()
{
// Control: mutate the emitted key name (what the pre-fix mapper effectively did, writing
// "address") and the factory must REFUSE it. Without this, the round-trip assertions above could
// pass off any blob the factory happened to tolerate.
var wrong = new JsonObject
{
["group"] = "OtOpcUaSim", // not "groupId"
["edgeNodeId"] = "EdgeA",
["metricName"] = "Temperature",
}.ToJsonString();
MqttTagDefinitionFactory.FromSparkplugTagConfig(wrong, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
// And the exact blob the pre-fix generic fallback produced.
var legacy = new JsonObject { ["address"] = "OtOpcUaSim/EdgeA/Filler1::Temperature" }.ToJsonString();
MqttTagDefinitionFactory.FromSparkplugTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
MqttTagDefinitionFactory.FromTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
}
[Fact]
public void DescribeUncommittableLeaf_refuses_an_mqtt_leaf_whose_address_was_never_stated()
{
// No stated address ⇒ no bindable tag. Failing here, in words, is the whole point: the old path
// committed it silently and the operator learned about it as a runtime BadNodeIdUnknown.
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", addressFields: null);
error.ShouldNotBeNull();
error!.ShouldContain("Temperature");
}
[Theory]
[InlineData(MqttTagConfigKeys.Topic, "otopcua/fixture/oven/temp")]
[InlineData(MqttTagConfigKeys.MetricName, "Temperature")]
public void DescribeUncommittableLeaf_accepts_either_stated_mqtt_shape(string key, string value)
=> RawBrowseCommitMapper
.DescribeUncommittableLeaf("Mqtt", "leaf", new Dictionary<string, string> { [key] = value })
.ShouldBeNull();
[Fact]
public void DescribeUncommittableLeaf_never_blocks_a_single_reference_driver()
{
// Every other driver's address IS the node id — they state no fields and must stay committable.
foreach (var driver in new[] { "OpcUaClient", "AbCip", "TwinCAT", "S7", "GalaxyMxGateway", "Modbus" })
RawBrowseCommitMapper.DescribeUncommittableLeaf(driver, "leaf", addressFields: null).ShouldBeNull();
}
[Fact]
public void MapLeaf_flows_the_stated_address_fields_into_the_committed_row()
{
var row = RawBrowseCommitMapper.MapLeaf(
driverType: "Mqtt",
fullName: "OtOpcUaSim/EdgeA/Filler1::Temperature",
browseName: "Temperature",
driverDataType: "Float32",
defaultDataType: "Double",
groupPrefix: null,
folderPath: new[] { "OtOpcUaSim", "EdgeA", "Filler1" },
createGroups: false,
addressFields: SparkplugFields());
row.Tag.Name.ShouldBe("Temperature");
row.Tag.DataType.ShouldBe("Float");
MqttTagDefinitionFactory
.FromSparkplugTagConfig(row.Tag.TagConfig, "Plant/Mqtt/dev1/Temperature", out var def)
.ShouldBeTrue();
def.MetricName.ShouldBe("Temperature");
}
// ---- CombineGroupPath -----------------------------------------------------------------------
[Theory]