Files
firmware/test
Tom ee76117835 fix(router): relay opaque packets in CORE_PORTNUMS_ONLY (#11844)
* fix(router): relay opaque packets per rebroadcast_mode, not only in ALL

d6b12ea3f (#10967) moved undecryptable packets onto relayOpaquePacket(), which
relays only in ALL and ALL_SKIP_DECODING. A PKI unicast between two other
nodes - remote admin, a DM, key verification - is opaque to a relay, so a
router in CORE_PORTNUMS_ONLY (the ROUTER role default) stopped carrying any
of it, and KNOWN_ONLY / LOCAL_ONLY lost the rule 6eabbaf43 added in 2024 that
relays a PKI-shaped unicast with one known party. The old rule was still in
RoutingModule::handleReceivedProtobuf, unreachable: encrypted packets no
longer reach modules.

opaqueRelayAllowedByMode() gives each mode an explicit opaque rule:
ALL / ALL_SKIP_DECODING / CORE_PORTNUMS_ONLY relay (the port list cannot apply
to a packet with no readable port); KNOWN_ONLY / LOCAL_ONLY relay a channel-0
unicast whose sender or destination has a User in NodeDB; NONE relays nothing.
The dead RoutingModule block is removed; its licensed-party check stays for
decoded packets. Nothing here reads packet_signature_policy.

* fix(router): NAK, phone delivery and MQTT uplink for packets we cannot read

Before #10967 an undecryptable packet addressed to us reached RoutingModule,
which handed it to the phone and, via ReliableRouter::sniffReceived, answered
a want_ack unicast from an unknown sender with PKI_UNKNOWN_PUBKEY - the NAK
that makes the sender transmit its NodeInfo so its retry decrypts. A PKI DM
between two other nodes was uplinked to MQTT as ciphertext when encrypted
uplink was on. All three stopped: the gate REJECTs a to-us decode failure
before any module runs, and the pki_encrypted marking in dispatchReceived is
unreachable for opaque ingress.

passesRoutingAuthGate() now treats every DECODE_FAILURE not from us as opaque
(isFromUs stays REJECT, #11544). The opaque branch calls handleOpaqueForUs(),
which NAKs a want_ack unicast to us (PKI_UNKNOWN_PUBKEY when we hold no key
for the sender, NO_CHANNEL otherwise) and queues a frame we had no way to
read - PKI without the sender's key, or a channel hash matching nothing we
hold - straight to the phone via sendToPhone(), bypassing handleFromRadio()
so an unverified sender never touches NodeDB. A matched-and-failed frame
(bad key, tampering, junk) is NAKed but not delivered. uplinkOpaqueUnicast()
restores the MQTT path for channel-0 unicasts not to or from us, gated on
mqtt.enabled and mqtt.encryption_enabled; the dead marking is removed.

* test(packet_signing): pin what a relay does with traffic it cannot read

Group R builds genuine PKI-encrypted packets between two generated
identities and runs them through ingress under every rebroadcast_mode:

  R1  remote admin between two known nodes relays in every mode but NONE
  R2  between strangers: KNOWN_ONLY / LOCAL_ONLY decline, the rest relay
  R3  one known party satisfies KNOWN_ONLY / LOCAL_ONLY
  R4  an unknown-channel broadcast relays in ALL / ALL_SKIP / CORE only
  R5  undecryptable DM to us: one PKI_UNKNOWN_PUBKEY NAK, phone gets the
      frame, nothing relayed, sender not added to NodeDB - in every mode
  R6  the same frame claiming to be from us gets no reaction
  R7  sender key held but wrong: NAK NO_CHANNEL, no phone delivery
  R8  opaque PKI unicast is uplinked only with encrypted MQTT uplink
  R9  unknown-channel broadcast reaches the phone without touching NodeDB
  R10 the relay decision is identical under all three signature policies

C6 no longer lists CORE_PORTNUMS_ONLY as a mode that suppresses opaque relay
and expects the phone to see an unreadable frame; the RoutingModule mock
records the NAK reason.

* test(rebroadcast_mode): give the relay policy its own suite, sharing the ingress harness

test/support/AuthPipelineHarness.h now holds the mock NodeDB, the counting radio /
router / routing-module / module / MQTT, the packet builders (decoded, channel-
encrypted, PKI between two generated identities) and the per-process / per-test
lifecycle that test_packet_signing kept locally.

test_rebroadcast_mode pins what this node carries for others, per
DeviceConfig.rebroadcast_mode: remote admin between two known nodes relays in
every mode but NONE; strangers are declined by KNOWN_ONLY / LOCAL_ONLY and
carried elsewhere; one known party suffices; an unknown-channel broadcast relays
in ALL / ALL_SKIP / CORE only; a licensed node never relays ciphertext and
relays plaintext unless a party is known unlicensed; hop_limit 0, id 0, a
foreign next_hop and CLIENT_MUTE each stop a relay; the signature policy
changes none of it. Registered in state-manifest.tsv and the routing shard.

test_packet_signing keeps what follows from the auth gate's verdict: C9-C11 now
carry want_ack and hops so their "nothing happens" assertions are no longer
vacuous (junk on a held channel relays but never reaches the phone; a legacy
DM and a malformed PKI plaintext to us are NAKed NO_CHANNEL once and nothing
else), and C18-C22 cover the to-us NAK / phone / MQTT outcomes. Comments on the
src side trimmed to the two-line rule.

* fix(router): classify an opaque frame from the decode attempt, not the header

handleOpaqueForUs() re-derived "unreadable" from the wire header and NodeDB,
which disagreed with what perhapsDecode() had just found: a hash-0 broadcast
from a sender whose key we hold read as readable although PKI never applies to
a broadcast, and a pending-key decrypt rejected as malformed read as unreadable
because no stored key existed. Both changed the NAK reason and whether
ciphertext reached the phone.

passesRoutingAuthGate() now hands the attempt's DecodeState out and the handler
takes unreadable = (state == DECODE_OPAQUE). For that to be precise,
perhapsDecode() sets pkiAttempted only when a sender, pending or admin key was
actually tried, and the KNOWN_ONLY short-circuit - which declines before any
attempt - reports OPAQUE for a PKI-shaped unicast to us or an unheld hash and
FAILURE for a held channel. isUnreadableToUs() is gone; Channels::hasHash()
replaces its loop.

* test(support): free the harness AirTime and NodeStatus before restoring the originals

pipelineHarnessDestroy() restored the saved pointers and orphaned the two
objects it had installed. LeakSanitizer reported the NodeStatus as a 160-byte
direct leak and errored test_packet_signing and test_rebroadcast_mode at exit
in the coverage shards while every case passed.

* fix(router): a failed admin-key fallback does not count as a decrypt attempt

Every configured admin key set pkiAttempted, so on a node with any admin key
an unknown sender's DM read as DECODE_FAILURE: NO_CHANNEL instead of
PKI_UNKNOWN_PUBKEY, and withheld from the phone, so the sender never learned to
send its NodeInfo. An admin key that fails says nothing about the sender; only
the sender's own (or pending) key counts. A successful-but-malformed admin
decrypt already returns DECODE_FAILURE directly.

* test(packet_signing): pin decode provenance for admin keys, forged from-us frames, hash-0 broadcasts and KNOWN_ONLY strangers

C18 configures an unrelated admin key so the fallback runs and fails. C19
installs our identity so the forged frame is a real decrypt attempt and asserts
the gate's REJECT before the side effects. C23: a hash-0 broadcast from a keyed
sender on a channel we do not hold is unreadable and reaches the phone. C24:
KNOWN_ONLY declines a stranger on a held channel as matched, so the phone never
sees it, while the same stranger on an unheld channel is unreadable.

* test(support): restore the caller's DH key, model the TX queue and ACK/NAK log, clear per-process state between tests

The ingress harness builds its router, radio, routing module and crypto engine
once per process, so anything they carry decides the next test's outcome. Three
of those carried surfaces were already deciding one.

makePkiUnicastBetween() ended by installing a fresh random DH key, so a caller
that set its own key before building a frame silently lost it. C19 did exactly
that: its REJECT assertion passed because the decrypt failed on a key mismatch,
not because the from-us arm rejected the forgery, and would have stayed green
with that arm deleted. CryptoEngine::private_key is public under
PIO_UNIT_TESTING, so the helper now saves the engine's key and puts it back;
the trap is closed for every caller rather than worked around in one. C19 also
builds the frame before installing our identity and gains a control assertion:
without our key in NodeDB the same frame is OPAQUE_RELAY_ONLY, which is what
makes the REJECT attributable.

The opaque dedup ring survived a whole suite unreset. That was tolerable while
it only gated relay; it is about to gate the NAK, phone delivery and MQTT
uplink too, where a stale (from,id) would silently zero a later test's
expectations instead of failing it. Cleared per test, along with the DH key,
any pending handshake key, and the admin-key fallback budget that C18 drains
six tokens from. resetAdminKeyFallbackBudget() is defined under
!MESHTASTIC_EXCLUDE_PKI but declared unguarded, so the call site is guarded.

installOurIdentity() now marks HAS_USER on our own node, as NodeDB does on a
device. The rebroadcast_mode predicates read that bit, and markOurselvesLicensed()
keeps owner.is_licensed and our NodeDB record in agreement for the same reason:
getLicenseStatus(us) must say Licensed, not NotLicensed.

The radio and routing-module mocks become models rather than counters, since
every suite including this header gets them. The radio holds a real TX queue
that findInTxQueue() consults and that cancelSending()/removePendingTXPacket()
take entries out of, and it records each frame it was handed so a test can
assert a hop limit or relay_node instead of a call count. The routing module
keeps every ACK/NAK with its destination, channel and hop limit, so a second
NAK can be pinned without losing the first; its reset() replaces the six sites
that zeroed ackCalls by hand, which would otherwise desync the log from the
counter. Phone-queue draining moves into the harness for the suites that both
need it.

* refactor(router): the opaque path lives in Router, not NextHopRouter

A pure move. Nothing about handling a frame we cannot read is next-hop
specific: relayOpaquePacket() reads iface, isToUs/isFromUs, the device role,
owner.is_licensed, the last byte of our node number and the packet's own
header, then calls Router::send(). The one thing that held it in the subclass
was the (from,id) dedup ring, and that landed in NextHopRouter next to the
pending and route-health tables by proximity rather than dependency - its
whole purpose is to stay isolated from routing state, which argues for sitting
beside PacketHistory instead.

So the ring, opaqueWasSeenRecently(), relayOpaquePacket() and the
rebroadcast_mode predicate (now Router::opaqueAllowedByMode) move down, and
relayOpaquePacket() stops being virtual: there is one router chain, nothing
else overrode it, and the base implementation returned false to no one. The
alternative was a second virtual to reach the same array from the same caller,
which is what the dedup work that follows would otherwise have needed.

isRebroadcaster() comes along because relayOpaquePacket() needs it and it reads
only config.device - no FloodingRouter state - so it was already misplaced.
capEventRelayHops() moves too, and is now declared for NextHopRouter's own
rebroadcast path rather than being file-static.

* fix(router): apply the packet's own rules to every consumer of an opaque frame

Five rules that pre-#10967 applied to a packet we could not read were left
applying to the relay alone. This puts them back on all four consumers - relay,
NAK, phone, MQTT - which is one change of shape, so it lands as one commit
rather than five: the branch head now computes what is true of the frame once
and every consumer below reads the same answer.

Duplicate suppression. The only dedup sat inside relayOpaquePacket(), behind
its isToUs() early return, so it never saw a frame addressed to us. Three
neighbours rebroadcasting a stranger's want_ack DM to us cost three
PKI_UNKNOWN_PUBKEY NAKs on the air, three encrypted frames queued for the
phone, and at a gateway three publishes of every opaque PKI DM between other
nodes. Before these packets stopped going through PacketHistory,
shouldFilterReceived() ran first and made each of those once per (from,id).
The originator's own retransmission keeps its exemption, and gains the two
rules the decoded path already applies to a repeat: do not queue a second copy
while the first is still in the TX queue, and answer again at hop 0, since only
a direct neighbour ever sees hop_start == hop_limit.

rebroadcast_mode. LOCAL_ONLY and KNOWN_ONLY say the node ignores what it cannot
decrypt; the deleted RoutingModule branch gated phone delivery as well as
relay, and only the relay half was carried over, so a stranger's unknown-channel
ciphertext reached the phone in every mode. The phone now follows the same
predicate. NONE still delivers: it means do not relay, not do not listen.

Licence. A licensed station transmits in the clear and may not answer, or hand
on, traffic to or from a node it knows to be unlicensed - the rule RoutingModule
applies to decoded packets, which the opaque path never got.

NAK reason. PKI_UNKNOWN_PUBKEY claimed a missing key even for a channel-0 frame
too short to have carried PKI overhead. Such a frame was never a candidate, so
the reason is NO_CHANNEL, matching the size test perhapsDecode uses.

Uplink. uplinkOpaqueUnicast() read the header only, so a channel-0 unicast that
matched a held hash-0 channel and failed its AEAD was published to the PKI
topic as ciphertext we never tried to read. It now takes the gate's verdict.

One consequence worth stating: the dedup ring records frames the relay gate
used to reject before reaching it - to us, from us, hop-exhausted, mode-blocked,
and everything on a licensed node - so its 32 slots serve four consumers on
nodes that previously never touched it.

Two clean-ups ride along because the same rules move: ReliableRouter's
sniffReceived() loses its own undecryptable-NAK arms, which radio ingress has
not been able to reach since the auth gate started answering those frames
before handleReceived() (deliverLocal, the only other caller, is always
decoded), and test_rebroadcast_mode stops declaring a warm.dat write it never
makes - no case in it reads a signer back from the warm store.

* test(rebroadcast_mode): declare the warm-store write again

The suite stopped writing warm.dat only until this branch gave it a test that
installs an identity of our own, which puts a key through the warm store. The
declaration was removed on the evidence of a run that predated that test, in
the same commit that added it; the harness caught the undeclared write.

* fix(router): put back the undecodable-NAK arms in ReliableRouter::sniffReceived

Deleted as dead code, and they are not. Radio ingress genuinely cannot reach
them any more - the auth gate answers an unreadable frame in
handleOpaqueForUs() and returns before handleReceived(), and deliverLocal(),
the only other caller, is always decoded - but sniffReceived() has a contract
of its own that test_reliable_ack_matrix drives directly, and a local or
SimRadio caller still arrives with an encrypted packet. CI caught it in the
misc-4 shard.

Restored verbatim, with the reachability noted where the next reader will look
rather than in a commit message nobody greps.

* fix(router): classify a PKI-shaped unicast from key material alone

Addresses the review on #11844.

A channel we hold whose hash is 0 matched every PKI DM on the mesh and
failed every one, and that failure was read as "we tried". One unlucky
1-in-256 channel hash therefore withheld every PKI DM from the phone and
the broker, answered NO_CHANNEL where the sender needs PKI_UNKNOWN_PUBKEY
to recover, and classified our own overheard DMs as a forgery so the
implicit "Delivered to mesh" ACK never fired. Hash 0 on a unicast is the
PKI sentinel, so isPkiShapedUnicast() now decides it in one place, used by
the KNOWN_ONLY short-circuit, the decode provenance and the NAK reason.
The isToUs asymmetry in the short-circuit is gone with it.

Also from the review:

- id 0 cannot be deduped by the (from,id) ring, so an undecryptable
  want_ack DM carrying it drew a NAK and a phone frame on every copy
  heard. Every consumer now declines it, as relay already did; a NAK for
  request_id 0 is unmatchable at the sender anyway.
- The ring records only frames some consumer can act on. Our own
  overheard rebroadcasts and unicasts that can neither be relayed nor
  uplinked were evicting live entries, spending the anti-amplification
  bound #11522 added it for.
- The MQTT uplink applies the licensed-station rule, and deliberately not
  rebroadcast_mode: that setting governs what goes back on the air, and
  MQTT has its own switches for what leaves over IP, which is where the
  pre-#10967 uplink sat.
- gateState is initialised rather than relying on the gate's first
  statement.

Comments on the opaque path trimmed to the two-line rule; the reasoning
lives in the tests, which are exempt.

* ci(size-budget): raise the rak4631 flash budget to 748000

The opaque-relay restore lands at 746,080 bytes on rak4631, 80 bytes over
the previous 746,000 limit. Image ends at 0xDC260, 55 KB clear of the warm
region.

* fix(router): keep #10967's removal of the undecodable-frame NAK

The PKI_UNKNOWN_PUBKEY / NO_CHANNEL NAK for a frame we cannot read is not
restored. Every input to that decision - to, from, id, want_ack, hop_start,
hop_limit - is unauthenticated cleartext, so the NAK is a reflector: one
frame in from a node with no key material, one flooded reply out to
whichever `from` it names, with a hop budget the sender chooses. #10967
removed it as an ACK side effect on purpose; the security review of this PR
shows why, and the relay regression it fixes does not need it.

handleOpaqueForUs() now only delivers to the phone. The originator-retx
re-NAK and its `repeat` plumbing go with the NAK. A to-us DECODE_FAILURE is
REJECT again at the gate, as #10967 had it: nothing on the opaque path acts
on a frame we matched and failed on. ReliableRouter::sniffReceived() is back
to develop byte for byte; its undecodable arms are reachable by local and
SimRadio callers only and stay as they are.

A PKI DM to a node that does not hold the sender's key fails silently, as on
develop; the receiving phone still sees the frame. The sender-side recovery
this NAK used to trigger is the follow-on's problem to solve without a
header-driven reply.

Tests: C10, C11, C18, C20, C26, C27, C29, C31 assert no NAK; C28 (the NAK's
hop budget) is deleted; the matched-failure helper drops its want_ack arm.

* fix(router): do not re-decode a frame the gate already classified; scope capEventRelayHops

handleOpaqueForUs() hands the phone a copy the auth gate has already run
perhapsDecode() on. MeshService::sendToPhone() ran it again, and for a
PKI-shaped DM from a sender whose key we lack that second pass re-enters the
admin-key fallback and spends a second token from a budget the code documents
as global and attacker-facing: the sustained rate halved from 4/s to 2/s on
any node with an admin key configured. sendToPhone() takes an
alreadyClassified flag and skips the decode; nothing else calls it that way.
Nodes with no admin key configured were never affected, since
adminKeyFallbackAllowed() returns before touching the bucket.

test_C34 freezes the clock, configures an unrelated admin key, and pins one
token spent per unreadable frame delivered to the phone.
adminKeyFallbackTokensRemaining() is a PIO_UNIT_TESTING accessor beside
resetAdminKeyFallbackBudget().

ReliableRouter::sniffReceived()'s PKI_UNKNOWN_PUBKEY arm now requires the
frame to be long enough to have carried the PKI overhead, the same shape test
Router's opaque classification applies; a shorter channel-0 frame was never a
PKI candidate, so no key was missing and it gets NO_CHANNEL. Reachable by
local and SimRadio callers; pinned in test_reliable_ack_matrix.

capEventRelayHops() moved out of NextHopRouter.cpp as a file-static and
became a free function at global scope. Both callers are Router subclasses,
so it is now a static member of Router next to isRebroadcaster().

* fix(router): size the opaque dedup ring like PACKETHISTORY_MAX

Every ROUTER relays opaque frames now, not just nodes set to ALL, so the
churn through this ring is what bounds a duplicate storm on the backbone.
32 untimed slots shared by three consumers is thin for that; 128 on every
target but STM32WL, which keeps 32 alongside its 20-entry PacketHistory.
8 B per slot in .bss: +768 B, no flash.

* revert(router): return the branch to develop

* fix(router): relay opaque packets in CORE_PORTNUMS_ONLY

A packet a relay cannot decrypt - a PKI unicast between two other nodes, an
unknown-channel broadcast - is relayed only from its header, and only in the
rebroadcast modes relayOpaquePacket() lists. CORE_PORTNUMS_ONLY was not among
them, so a node in that mode, which is the ROUTER role default, dropped every
such packet. The portnum filter that mode exists for cannot be applied to a
payload the relay cannot read; add the mode to the list.

Fixes #11843.

* test(packet_signing): CORE_PORTNUMS_ONLY carries an opaque frame

C6 listed CORE_PORTNUMS_ONLY among the modes that must suppress an opaque
relay, pinning the behaviour #11843 reports. It now asserts the frame is
relayed in that mode, with the same no-side-effect checks as the ALL case, and
keeps LOCAL_ONLY and NONE as the suppressing modes.
2026-09-16 13:14:47 +00:00
..

Native Unit Tests - Authoring Guide

This directory contains C++ unit tests that run on the host machine via PlatformIO's native environment. Tests use the Unity framework.

Running Tests

Preferred: use bin/run-tests.sh - it defaults to the coverage env, cross-checks the number of suites that actually ran, and emits an unambiguous RED/AMBER/GREEN verdict:

./bin/run-tests.sh                          # all suites
./bin/run-tests.sh -f test_traffic_management  # single suite
./bin/run-tests.sh -f test_traffic_management > /tmp/test_out.txt 2>&1; tail -5 /tmp/test_out.txt

Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER, 3 = FILTERED.

The harness is Linux-only, by choice. bin/run-tests.sh and the per-suite isolation it drives need bash 4+ and GNU coreutils/find (find -printf, md5sum), and the script refuses to start anywhere else rather than degrade quietly - a shared-state check that silently mis-hashes a sandbox still prints a verdict, and that verdict would be worthless. The native-macos PlatformIO env is a build target for meshtasticd, not a test host; the isolation wrapper is registered for env:native and env:coverage only. On macOS or Windows, run the suite in a container: ./bin/test-native-docker.sh.

-f is not a gate. A filtered run can pass while a full run fails, because filtering removes the suites that create the state a later suite trips over. Iterate with -f; gate on a full run.

Sanitizers are per env. coverage (the default) has ASan/LSan; native has none, verified. -e native runs are not sanitized.

A signal name in the output is not a crash. exit(UNITY_END()) returns the failure count and PlatformIO renders it as a signal number (4 -> SIGILL, 5 -> SIGTRAP), reporting the suite [ERRORED]. Match it against the failure count before assuming a fault.

Suite order is randomisable, and reproducible. --shuffle runs the suites in a seeded random order; --seed <n> replays an exact one. The seed defaults to the commit SHA - one order per commit, so a red is replayable and attributable rather than flaky - and is printed at the start of the run and on the RESULT: line. On failure the full order is printed, because for an order-dependent failure the order is the diagnostic. A single green seed is not evidence of order independence; vary it.

./bin/run-tests.sh --shuffle              # seed from HEAD, printed
./bin/run-tests.sh --seed 2855893161      # replay that exact order

Randomisation costs one pio invocation per suite (about 4.7s each), because PlatformIO orders suites by its own directory walk and -f only selects.

Copilot interface note: When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run.

Never add --without-building to a test run. PlatformIO links every native test program to the single $BUILD_DIR/$PROGNAME path and attributes Unity output by text alone, so a run that only builds beforehand executes whichever suite was linked last under every suite's name - all reporting PASSED. Build once with --without-testing to warm the shared src objects if you like; the run itself must still build. bin/check-test-attribution.py grades the JUnit reports for exactly this and is wired into both bin/run-tests.sh (RED) and CI.

Raw pio test (no sanitizers, no verdict logic) - use when you need to override the env or inspect verbose Unity output:

# All test suites
pio test -e native

# Single suite
pio test -e native -f test_your_module

# Verbose (shows build errors in detail)
pio test -e native -f test_your_module -vvv

Never pipe through | tail -N to shorten output. PlatformIO prints build errors at the top of output and test results at the bottom; tail will show stale cached results from a prior successful build while hiding the compile error that caused the current run to fail.

Preferred pattern for raw pio - redirect to file, then grep:

# Redirect all output to a file; grep for errors and results after it exits
pio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
echo "exit: $?"
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
tail -15 /tmp/test_out.txt

Why: piping through | grep line-buffers the output and suppresses all progress until the process exits, making it look hung. The redirect approach lets the build stream normally while still giving you filtered results afterwards.

Viewing verbose test output without truncation (e.g. TEST_MESSAGE group headers):

/tmp/meshtastic-pio-venv/bin/python -m platformio test -e coverage --filter test_mesh_beacon -vv 2>&1 | grep -v "[[:space:]]SKIPPED$"

The -vv flag makes Unity emit INFO: lines from TEST_MESSAGE calls; piping through grep -v SKIPPED removes the noise from platform feature gates while keeping all PASS/FAIL/INFO lines visible.

externally-managed-environment error on Ubuntu/Debian:

If pio test fails immediately with error: externally-managed-environment, the system pio binary is using the OS Python which newer distros lock down. Use PlatformIO's own venv instead:

~/.platformio/penv/bin/python -m platformio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
tail -15 /tmp/test_out.txt

Helper Scripts (Useful Shortcuts)

These wrappers are handy when local host dependencies are missing or when you want repeatable commands.

# Run native tests in Docker (recommended on macOS / non-Linux hosts)
./bin/test-native-docker.sh

# Pass normal PlatformIO test args through to Dockerized test run
./bin/test-native-docker.sh -f test_your_module

# Force Docker image rebuild (after dependency changes)
./bin/test-native-docker.sh --rebuild

# Run simulator integration check (build native first)
pio run -e native && ./bin/test-simulator.sh

# Build and run meshtasticd natively
./bin/native-run.sh

# Build and run under gdbserver on localhost:2345
./bin/native-gdbserver.sh

# Build native release artifact into ./release/
./bin/build-native.sh native

Notes:

  • The repository script name is ./bin/test-simulator.sh (there is no test-native-simulator.sh).
  • ./bin/test-native-docker.sh is the closest match to CI behavior for native tests and avoids host package setup.

System Dependencies (Ubuntu/Debian)

The native build requires several system libraries. Install them all at once:

sudo apt-get install -y \
  libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \
  libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev

See .github/actions/setup-native/action.yml for the canonical list.

Creating a New Test Suite

1. Directory Structure

test/test_your_module/test_main.cpp

One file per suite. No per-test platformio.ini is needed - tests build under the [env:native] environment defined in the root platformio.ini.

2. File Skeleton

#include "MeshTypes.h"      // Include BEFORE TestUtil.h (provides NodeNum, etc.)
#include "TestUtil.h"        // initializeTestEnvironment(), testDelay()
#include <unity.h>

#if YOUR_FEATURE_GUARD       // Same #if guard as the module under test

#include "FSCommon.h"
#include "gps/RTC.h"
#include "mesh/NodeDB.h"
#include "modules/YourModule.h"
#include <cstdio>    // required for printf() - used for blank-line group separators
#include <cstring>
#include <memory>

// --- Test output helpers ---
// printf() writes directly to stdout and appears in -vv output as a plain line (no prefix).
// Use it for blank-line group separators: printf("\n");
// TEST_MESSAGE() emits a "file:line:INFO: <text>" line - visible at -vv and above.
// Use TEST_MSG_FMT for formatted diagnostic lines inside tests.
#define MSG_BUF_LEN 200
#define TEST_MSG_FMT(fmt, ...) do { \
    char _buf[MSG_BUF_LEN]; \
    snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
    TEST_MESSAGE(_buf); \
} while(0)

// --- Tests ---

void test_example()
{
    TEST_MESSAGE("=== Example test ===");
    TEST_ASSERT_TRUE(true);
}

// --- Unity lifecycle ---

void setUp(void) { /* runs before every test */ }
void tearDown(void) { /* runs after every test */ }

void setup()
{
    initializeTestEnvironment();   // MUST call - sets up RTC, OSThread, console
    UNITY_BEGIN();

    printf("\n=== Example group ===\n");           // header line to help find tests

    RUN_TEST(test_example);
    exit(UNITY_END());             // REQUIRED - a bare UNITY_END() leaves the process running
}

void loop() {}

#else // !YOUR_FEATURE_GUARD

void setUp(void) {}
void tearDown(void) {}

void setup()
{
    initializeTestEnvironment();
    UNITY_BEGIN();
    exit(UNITY_END());
}

void loop() {}

#endif

3. Terminate with exit(UNITY_END()), on every branch

A bare UNITY_END() does not end the suite - it ends the reporting. setup() returns, the runtime goes on calling loop(), and the process runs forever. PlatformIO does not notice: it reads the Unity summary off stdout, reports the suite PASSED and moves to the next one, so the run is green while the binary is still resident. Nothing surfaces it, and the leak is one process per suite per run.

The consequences are worse than an idle process:

  • The per-suite sandbox is deleted underneath a live process, so its CLEAN/DIRTY verdict says what the suite had written by the time the harness stopped looking, not what it left behind.
  • .gcda coverage data and LeakSanitizer's report are both flushed by atexit handlers, so a suite that never exits contributes no coverage and gets no leak check - silently.
  • Each survivor pins its own deleted binary on disk (~94 MB), which du cannot see.

So: exit(UNITY_END()) in every setup() branch, including the #else of a feature or architecture guard where the suite does nothing. The empty-suite branch is the easiest one to get wrong, because it looks like there is nothing to clean up.

4. Feature Guard

Wrap the entire test body in the same #if guard the module uses (e.g. #if HAS_VARIABLE_HOPS, #if !MESHTASTIC_EXCLUDE_GPS). When the feature is disabled, the #else branch produces an empty passing suite.

Common Patterns

MockNodeDB

Most module tests need to inject nodes with controlled hop distances and ages:

class MockNodeDB : public NodeDB
{
  public:
    void clearTestNodes()
    {
        testNodes.clear();
        numMeshNodes = 0;
    }

    void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops,
                     uint32_t ageSecs, bool viaMqtt = false)
    {
        meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
        node.num = num;
        node.has_hops_away = hasHops;
        node.hops_away = hopsAway;
        nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
        node.last_heard = getTime() - ageSecs;
        testNodes.push_back(node);
        meshNodes = &testNodes;
        numMeshNodes = testNodes.size();
    }

    std::vector<meshtastic_NodeInfoLite> testNodes;
};

static MockNodeDB *mockNodeDB = nullptr;

Set nodeDB = mockNodeDB; in setUp().

Test Shim (Exposing Protected/Private Members)

Subclass the module under test to make protected methods callable and private members writable:

class YourModuleTestShim : public YourModule
{
  public:
    // Pull protected methods into public scope via using.
    // IMPORTANT: using requires the method to be protected (or public) in the base -
    // friend alone does NOT satisfy this. See pitfall #6.
    using YourModule::runOnce;
    using YourModule::someProtectedMethod;

    // Wrap private members with setter methods (friend grants direct access here).
    void setPrivateField(int x) { privateField = x; }
};

For methods you want to expose via using, use the conditional access-specifier pattern in the header - not plain friend:

// In YourModule.h, inside the class body:
#ifdef PIO_UNIT_TESTING
  protected:
#else
  private:
#endif
    bool someMethod();

For private member variables that a shim setter needs to touch directly, friend is sufficient (no using involved):

// In YourModule.h, inside the class body:
#ifdef PIO_UNIT_TESTING
    friend class YourModuleTestShim;
#endif

Global Singleton Lifecycle

Most modules use a global pointer (extern YourModule *yourModule;). Manage it carefully:

void setUp(void) {
    // ... setup ...
}

void tearDown(void) {
    yourModule = nullptr;   // prevent dangling pointer between tests
}

void test_something() {
    auto shim = std::unique_ptr<YourModuleTestShim>(new YourModuleTestShim());
    yourModule = shim.get();
    // ... test ...
    yourModule = nullptr;
}

Pitfalls and How to Avoid Them

1. Persisted Filesystem State

You are handed a clean sandbox. Declare what you write.

Each suite runs inside its own scratch $HOME (bin/pio-test-isolate.sh), so state cannot reach the next suite. The files in play are wider than module state, and all but the last live under ~/.portduino/default/prefs/:

File Written by
nodes.proto any NodeDB save - including incidental ones from removeNodeByNum(), resetNodes(), nodeDBSelfCare(), and the constructor itself when the file is absent
config.proto, module.proto, channels.proto, device.proto config/channel saves, admin handlers
warm.dat WarmNodeStore::saveIfDirty(), on the node-DB save cadence
transmit_history.dat retransmission tracking
/prefs/<module>.bin per-module saveState()

NodeDB's constructor calls loadFromDisk(), so any suite that constructs one inherits whatever is there.

What you have to do:

  • Nothing, if your suite is self-contained. That is the default and what almost every suite wants.

  • If your suite mutates persisted state on purpose, add a line to test/state-manifest.tsv with a reason:

    test_nodedb_blocked	state=per-suite writes=nodes.proto,warm.dat	saturates the DB to test the protected-node cap
    

    An undeclared write is reported as DIRTY and grades the run AMBER. A declared write that never happens is reported as MISSING - a warning, and a useful one: it catches persistence that silently stopped working.

  • Use state=per-suite only if a test genuinely needs to observe the previous test's write (persistence round-trips, migration ladders). It relaxes per-test checking to the suite boundary, so make it a deliberate choice rather than an accident of setUp().

Deleting your own state in setUp() is still fine and still a good habit for intra-suite isolation - it is just no longer what stands between you and the next suite:

void setUp(void) {
    // ...
#ifdef FSCom
    FSCom.remove("/prefs/your_module.bin");
#endif
}

2. A Shared Fixture Is Not a Fixture

If your suite touches globals the code under test writes - nodeDB, config, owner, devicestate, channelFile - build and restore them in setUp/tearDown for every test, not just the ones that seem to need it. An opt-in fixture that only some tests arm leaves the rest sharing one never-reset object, and "the other tests set their own state and are unaffected" is a claim that quietly stops being true as tests are added.

test/test_admin_radio/test_main.cpp is the worked example:

void setUp(void) {
    // ...
    replaceAdminRadioGlobals();   // saves the globals, installs a fresh NodeDB
}
void tearDown(void) {
    restoreAdminRadioGlobals();   // restores them, deletes the NodeDB, re-runs initRegion()
    // ...
}

A fresh NodeDB per test costs real time (loadFromDisk() plus, when the region is set, key generation) - in that suite roughly 7% of a ~7½-minute run. Pay it. If a test genuinely needs to observe the previous test's state, that is what state=per-suite in test/state-manifest.tsv is for; say so there rather than achieving it by omission.

3. File-Scope Mutable Globals Persist Across Tests

Variables like static uint8_t someDenominator = 8; in the module .cpp file retain mutations from previous tests. This is distinct from member variables - it affects all instances.

Fix: Add a static void resetGlobal() method to the module and call it in setUp().

4. Randomness Breaks Determinism

If the module uses rand() for jitter or similar, test results become non-reproducible.

Fix: Add a static enable/disable flag:

// Module header:
static void setJitter(bool enabled) { s_jitterEnabled = enabled; }

// Test setUp:
YourModule::setJitter(false);

// Test tearDown:
YourModule::setJitter(true);

5. Time-Dependent Logic Produces Zeros

Rolling averages weighted by elapsedMs / ONE_HOUR_MS collapse to zero when tests complete in microseconds. Sample windows, EMA alphas, and interval-based accumulators all suffer from this.

Fix: Expose the timestamp via friend access and simulate realistic elapsed time:

// In test shim:
void setWindowStartMs(uint32_t ms) { windowStartMs = ms; }

// In test:
shim.setWindowStartMs(millis() - 3600000UL);  // pretend 1 hour elapsed

6. Capacity Limits Cause Cascading Failures

Fixed-size data structures (hash sets, ring buffers) overflow when tests inject more data than fits. This triggers early flushes with near-zero time fractions, compounding the time-dependent-zeros problem.

Fix: Simulate multiple realistic time windows rather than one massive burst. Let adaptive mechanisms (if any) self-tune over several rolls.

7. Granting test access to private/protected members

PlatformIO defines PIO_UNIT_TESTING during pio test builds. Several production headers (TransmitHistory.h, CryptoEngine.h, MQTT.h, RTC.h) use this to gate test-only visibility changes. PlatformIO also defines UNIT_TEST in the same builds for backward compatibility, but that spelling is deprecated - always use PIO_UNIT_TESTING in new code. The established pattern for exposing a private method to a test shim without widening production visibility:

#ifdef PIO_UNIT_TESTING
  protected:
#else
  private:
#endif
    bool myMethod();

Critical C++ rule: a using declaration in a derived class (e.g. using Base::myMethod) requires myMethod to be protected or public in the base - friend alone does not satisfy this. Adding friend class TestShim while leaving the method private will still fail to compile. Use the conditional access-specifier pattern above, not friend.

setUp/tearDown Checklist

  • Create and clear MockNodeDB (if needed)
  • Zero global configs: config, moduleConfig, myNodeInfo
  • Set nodeDB = mockNodeDB
  • Delete your own persisted state files (FSCom.remove(...)) for intra-suite isolation - cross-suite isolation is already guaranteed, see Pitfall 1
  • Declare deliberate writes to shared state in test/state-manifest.tsv, with a reason
  • Reset file-scope mutable globals
  • Reset mock clock to a safe base value (e.g. mockTime = ONE_HOUR_MS) - prevents unsigned subtraction underflow in time-dependent logic
  • Disable randomness/jitter flags
  • In tearDown: null the global singleton pointer, restore flags

Test Organization

A well-structured test suite follows this pattern:

  1. Topology/scenario builders - static helper functions that set up specific test conditions
  2. Injection helpers - simulate realistic traffic, time, or event patterns
  3. Scenario tests - each builds a scenario, runs the module, asserts on outcomes
  4. Lifecycle tests - state persistence, startup from blank, restart recovery
  5. Summary test (optional) - emits a scenario table into the log for quick CI review

Not a Unity suite: bin/test-config-check.sh

Portduino YAML validation is tested by driving a built meshtasticd rather than by a Unity suite, because what it asserts - the exit status and printed report of meshtasticd --check, and the fact that a normal run still refuses a bad config - are properties of the process, not of a linkable function. Fixtures live in test/fixtures/portduino-config/ (see the README there); CI runs it in test_native.yml. It is not a test_* directory, so it sits outside the suite count the harness derives from test/.

pio run -e native && ./bin/test-config-check.sh

Existing Test Suites

This table is a description, not an inventory. The canonical suite total is the number of test_* directories under test/, detected on the fly by bin/run-tests.sh on every full run and cross-checked against the suites that actually ran. That derived count is the only number that should be trusted or quoted. Entries below carry per-suite descriptions the count cannot; do not infer completeness from the row count.

Suite Module Under Test
test_admin_radio Admin + LoRa region config
test_fscommon_getfiles Bounded file-manifest walk
test_atak ATAK integration
test_crypto CryptoEngine
test_default Default configuration helpers
test_hop_scaling Hop scaling algorithm
test_http_content_handler HTTP handling
test_mac_from_string MAC address parsing
test_mesh_module Module framework
test_meshpacket_serializer Packet serialization
test_mqtt MQTT integration
test_packet_history Packet history tracking
test_position_precision Position precision helpers
test_radio Radio interface
test_serial Serial communication
test_module_config AdminModule module config
test_tak_config TAK (ATAK) team/role values
test_traffic_management Traffic management
test_transmit_history Retransmission tracking
test_type_conversions NodeDB v25 type conversions
test_utf8 UTF-8 utilities