Files
meshtastic_firmware/test/test_traffic_management/test_main.cpp
T
Tom bca7c0b480 Tom fiddles with the test suite - again (#11517)
* test: make every suite run its own binary, and fail the run when it does not

PlatformIO links every native test program to the one $BUILD_DIR/$PROGNAME path and
attributes Unity output by text alone, never checking that the source file a case came
from belongs to the suite it thinks it ran. Both harnesses had been split into a build
pass (--without-testing) and a run pass (--without-building), and for a non-embedded
platform the run pass never relinks - so all 57 suites executed whichever suite was
linked last, each reporting PASSED under its own name. Introduced for CI in 4906f8a6
and for bin/run-tests.sh in de6b2319; both ran fused, and correctly, before that.

Drop --without-building from both run passes. The --without-testing pass stays as a
warm-up so no single suite absorbs the whole src compile in its reported duration; with
the objects already cached the per-suite step is one test_main.cpp plus a link.

Add bin/check-test-attribution.py, which grades the JUnit reports both harnesses already
produce. It fails on a test case whose source file lies outside the suite that reported
it, and on a suite that was asked to run and produced no cases at all. Wired in three
places: bin/run-tests.sh as a RED verdict ahead of the softer ones, per area in CI so a
mismatch names its area, and once over the merged report so an area that never executed
cannot hide. Suite ownership is matched on whole path segments, so test_mesh does not
claim test_mesh_module, and the -f pattern is resolved against the canonical set rather
than taken as a literal suite name.

* fix(test): pin simradio off for the packet-signing PKI cases

[env:coverage] passes -s to the test binary (74e6723ad, #8251), which sets
portduino_config.force_simradio. wouldEncryptWithPKC() lists !force_simradio among its
preconditions, so perhapsEncode() takes the channel-crypto branch, returns NONE and leaves
pki_encrypted false - failing test_B11_normal_unicast_still_uses_pki and
test_B12_licensed_receiver_does_not_decrypt_pki, both of which assert the production PKI
path. [env:native] passes no such flag, which is the whole of the long-standing
"passes under native, fails under coverage" split; it was never gcov, ASan or a host.

Save and clear the flag in setUp, restore it in tearDown, so the suite asserts the encode
path it is named for under either env's invocation. Same binary, pristine $HOME: 77 tests
0 failures with -s and without, where before -s gave 2 failures.

Whether the unit-test binary should run with -s at all is a separate question - it means CI
exercises the simradio configuration for every suite - and is left alone here.

* fix(router): drive the admin-key fallback budget from the injectable clock

The budget is 8 tokens refilling one per 250ms of wall clock, and
test_admin_key_fallback_is_rate_limited drains it with eight PKI decodes before asserting the
ninth is refused. That gives the drain loop 31ms per iteration, each of which generates a
keypair and does three X25519 operations under gcov and ASan. This box runs them in ~4ms;
a GitHub runner takes ~38ms, so a token refills mid-drain and the packet the test expects to
be blocked decodes. Measured from both runs' own log timestamps, 9.5x apart.

Read the bucket through Time::getMillis() instead of millis(), and have the test set and
advance the virtual clock rather than sleeping. The subtraction was already wrap-correct, so
the deadline guard is unaffected. Restores the clock in tearDown so the rest of the suite is
untouched, and drops ~3s of real sleeping from the run.

* test: declare the event-channel suites' shared state

Both construct a NodeDB, whose constructor persists a default set into an empty prefs
directory, so each writes the five prefs protos. Neither was declared, because until suites
started running their own binaries nothing had ever observed them writing anything.

* test: add a repeat runner for order-independent flakes

A single green run says nothing about a real-time race or a slow-host margin: the rate-limit
budget above passes here with 7x headroom and still fails on a CI runner. Run one suite N
times against a fresh scratch $HOME each time, optionally against CPU contention, and print a
flake rate. Failing runs keep their log and their sandbox; passing runs leave nothing.

Simradio is taken from the env's own test_testing_command, so a stress run reproduces the
real invocation rather than inventing a third one.

* fix(test): keep a native test run off the host's radio

bin/pio-test-isolate.sh sandboxes $HOME, but portduinoSetup() looks for config in
./config.yaml and /etc/meshtasticd/config.yaml - the second absolute, so no $HOME sandbox
can hide it. On a machine running meshtasticd that config selects the real LoRa module and
the run continues into GPIO and SPI setup, so ./bin/run-tests.sh -e native would drive the
developer's own radio without saying so. -e native is also the faster of the two, and the
one reached for when iterating.

[env:coverage] already passes -s, which short-circuits ahead of the config search and returns
before hardware init. Pass it for [env:native] too. That closes the hazard and, incidentally,
makes the two envs invoke the binary identically - they did not, which is the whole of the
long-standing "green locally, red in CI" split.

* test: run every suite with PKC on, and assert it stays that way

force_simradio does two unrelated jobs. It keeps portduinoSetup() off the host's hardware,
which every test run wants, and it makes wouldEncryptWithPKC() return false, which no test
run wants: the encode path under test then falls back to channel crypto and any case
asserting PKI fails, or worse, passes while asserting the wrong thing.

Three suites had each worked this out separately and cleared the flag themselves -
test_admin_session_repro's comment describes the mechanism exactly. Clear it once in
initializeTestEnvironment() instead. By then portduinoSetup() has already skipped the config
search and chosen the simulated radio, and it never reconsults the flag, so clearing it
cannot bring hardware back; the only remaining readers are the PKC gate and an
exit_simulator intercept no test can reach. The per-suite copy added to test_packet_signing
for B11/B12 goes away with it.

Two asserts, because both invariants were true only by inspection:

- No listening sockets. main.cpp's setup()/loop() are compiled out under PIO_UNIT_TESTING, so
  the phone API, MQTT and the web server never start - but nothing checked. A suite that
  pulled in a service binding a port would open one on the developer's machine for the length
  of the run.
- force_simradio still clear, before every test rather than once per suite, since a case that
  restores a struct it snapshotted earlier puts it back and silently disables PKC for
  everything after it. Named per test, so the report points at the case after the culprit.

Both exit rather than TEST_FAIL: they run outside a Unity test frame, and silently repairing
either one would leave the suite that broke it passing. Verified by disabling the clear and
watching the guard fire on the first case instead of reporting two quiet failures.

* test: let the repeat runner vary suite order too

Repeating one binary finds races and slow-host margins; it cannot find state that leaks from
one suite into the next, because only one suite runs. --shuffle drives run-tests.sh --seed
with a fresh seed each iteration and reports which seeds went red, so the shuffle already in
the harness yields a flake rate rather than a single sample. Seeds are printed and replayable.

* fix(test): baseline the environment from whichever runs first

Clearing force_simradio in initializeTestEnvironment() missed the suites that never call it.
test_atak is one, and it also pulls in TestUtil.h, so it got the per-test assert without ever
getting the baseline and aborted on its first case - caught by CI, which is what the assert is
for. test_geocoord_distance, test_meshpacket_serializer and test_utf8 skip the init too, but
include no TestUtil.h at all, so nothing reached them either way.

Move the clear and the socket check into baselineEnvironment(), called from
initializeTestEnvironment() or from the first RUN_TEST, whichever comes first. Suites that
initialise are still asserted from their first case; the rest are baselined at case one and
asserted from case two.

Print the violation on stdout as well as stderr: bin/run-tests.sh filters the program's
stderr, so locally the message vanished and the run reported "exit-time abort (likely
sanitizer)" - the exit code read as a signal number again, with no sign of the real reason.

* test: drop the per-suite simradio exceptions

Three suites had each found that force_simradio disables PKC and cleared it themselves.
initializeTestEnvironment() now clears it once for every suite, so all six sites are dead
code - along with the PortduinoGlue.h include each pulled in for it.

test_event_channel_router's is the one worth removing rather than leaving: it snapshotted the
flag into SavedGlobals and restored it at teardown, which is exactly the shape the per-test
assert exists to catch. Harmless while the snapshot reads false, and a silent PKC-off for
every later case if that ever changed.

The three suites pass unchanged: 54 cases, attribution clean.

* test: tell a deliberate harness abort from a sanitizer fault

A guard in TestUtil.cpp that aborts on purpose - a listening socket, or force_simradio put
back - exits non-zero with no sanitizer report, so it fell through to the exit-time-abort
heuristic and was announced as "RED exit-time abort (tests passed; likely sanitizer)". That
is the same trap as the phantom SIGILL two checks above: a verdict line naming a cause it has
not established, sending the reader after a memory bug that does not exist. It cost hours in
the original investigation and it cost the first read of a test_atak failure today.

Match the FATAL line the guards print on stdout for exactly this purpose, and report the
reason they gave instead of guessing.

* test: say why three suites omit TestUtil.h

They are pure-function - no NodeDB, no router, no sockets, no PKC - so the harness-wide guards
in TestUtil.h would assert conditions they cannot reach, and initializeTestEnvironment()'s RTC
and OSThread setup would pull in portduino globals they otherwise never touch. Suite-level
state cleanliness still applies: bin/pio-test-isolate.sh fingerprints the sandbox from outside
and wraps every suite regardless.

Recorded at the top of each so the omission reads as a decision rather than an oversight - it
looked like the latter when the socket and simradio asserts landed.

* test(traffic): give every case a primary channel

resetTrafficConfig() zeroed channelFile and left channels_count at 0, so the 66 cases that do
not install a channel themselves ran against a device with none. Every router lookup then hit
Channels::getByIndex()'s out-of-range branch and logged, which is 12106 of the suite's 20088
ERROR lines and tests nothing - a real device always has a primary channel, and no case here
asserts channels-unset behaviour.

Install the well-known primary the suite already builds for its precision cases. All 85 pass
unchanged, and the suite's ERROR output drops to 7985, the remainder being decode failures
from test_tm_fuzz_nodenum_blitz's malformed payloads.

* test: budget each suite's LOG_ERROR output

A suite can pass while emitting six figures of ERROR, which buries a real failure and trains
everyone to skim. Count them per suite and grade the count as a second axis, alongside the
CLEAN/DIRTY verdict already computed from the same captured log.

Declared in the same manifest, as a RANGE rather than a ceiling, because for a fuzz suite the
floor is the half that matters: test_fuzz_decode logging ~100k rejections is the suite
working, and the same suite logging none means it stopped feeding malformed input while every
case still passes. Bounds are wide on purpose - they catch a path that has stopped running,
not a drift of a few hundred lines. Undeclared suites get 100, which 50 of 57 already meet.

AMBER, not RED. Three log sites - mesh-pb-constants.cpp:28, Channels.cpp:356, MQTT.cpp:92 -
account for nearly all the remaining volume, and landing this red before they are demoted
would buy exemptions rather than fixes.

* test: canary the attribution check, and run the state self-test in CI

check-test-attribution.py guards against the false green, and nothing guarded the guard. A
checker that has quietly stopped matching looks exactly like a codebase with no problem, which
is how the original went unnoticed for three weeks of green runs.

The canary reproduces the failure deliberately - two suites run with --without-building, so
PlatformIO does not relink and both execute the same leftover binary - and requires the
checker to catch it. It also fails if the reproduction stops reproducing: if PlatformIO ever
relinks per suite under that flag, the reason both harnesses stopped passing it no longer
holds, and the harness should be revisited rather than left on a stale assumption.

bin/test-state-check.sh already existed with fixtures asserting CLEAN/CLEAN/DIRTY/MISSING and
had never run in CI. Wire it in too - the shared-state checker had the same blind spot, and
somebody had already written the test for it.

* fix(ci): run the attribution canary where it cannot clobber the daemon

The canary relinks $BUILD_DIR/$PROGNAME, and in simulator-tests that replaced the daemon
binary with a test suite. The integration test then started it and waited for a listening
socket, which a test binary never opens - by assertion, since initializeTestEnvironment()
now fails a suite that holds one - so the step sat until its 20s timeout and the job exited
124. The canary itself had already passed.

Move it to platformio-tests, where the binary is per-suite already and nothing downstream
needs the daemon, and place it after the coverage capture so its extra runs stay out of the
numbers. The shared-state self-test stays in simulator-tests; it touches no binary.

Fitting failure mode for this branch: one shared program path, two consumers, and the second
one silently getting the first one's build.

* fix(ci): silence the XXE rule on the attribution checker

semgrep blocks xml.etree.ElementTree.parse as XXE-prone. The input here is the JUnit report
PlatformIO wrote moments earlier in the same run, and anything able to plant a hostile report
is already executing its own code in that job, so parsing it defused changes nothing it could
do. defusedxml is in the tree but only under bin/bump_metainfo with its own requirements, and
pulling it onto this path would add an install step to every native test job for no reachable
threat.

Suppressed with a reason at the call site, the same shape as the subprocess-shell-true
suppression in extra_scripts/nrf54l15_linker.py.

* fix(test): address the review findings on the harness guards

Two were real defects rather than style:

- state_count_errors() returned "0\n0" for a log with no ERROR lines, because grep -c prints 0
  and *then* exits 1, so the `|| printf 0` fallback appended a second one. The classifier threw
  a syntax error on it. Dormant only because every suite currently emits at least one ERROR
  line; the planned log-level demotions would have driven most suites to zero and tripped it
  everywhere, looking like the demotions broke the harness.
- check-test-attribution.py returned OK for a report whose cases carry no `file` attribute. It
  cannot prove ownership in that state, so a changed JUnit format would have restored the exact
  false green it exists to catch. Now its own finding, listed and fatal.

The rest: keep the sandbox when an error budget is breached, since that is the one outcome
whose evidence was being deleted; reject a missing or non-numeric option value in
stress-suite.sh instead of running an empty loop and reporting 0/0 as a pass; exit on INT/TERM
rather than cleaning up and carrying on; drive repetitions through pio-test-isolate.sh so a
stress run exercises the real invocation; require the canary to see MISATTRIBUTED rather than
any non-zero exit, so an unreadable report cannot read as a caught mismatch; and check for
listening sockets before every test, since a listener would be opened by the code under test.

resetAdminKeyFallbackBudget() is a new PIO_UNIT_TESTING hook, shaped like the neighbouring
resetRoutingAuthEvaluationCount(). The refill stamp is only meaningful against the clock that
produced it, so a suite switching timebases leaves a stamp from the other one and the next
unsigned subtraction reads as a near-infinite gap - silently refilling the bucket.

Also move the semgrep marker onto its own line: buried mid-sentence in a comment it was
ignored, and the XXE finding stayed blocking.
2026-08-16 11:34:02 +00:00

3411 lines
153 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "MeshTypes.h" // Include BEFORE TestUtil.h - provides HAS_TRAFFIC_MANAGEMENT (via mesh-pb-constants.h)
#include "TestUtil.h"
#include <cstdlib>
#include <unity.h>
#if defined(ARCH_PORTDUINO)
#define TM_TEST_ENTRY extern "C"
#else
#define TM_TEST_ENTRY
#endif
#if HAS_TRAFFIC_MANAGEMENT
#include "airtime.h"
#include "gps/RTC.h"
#include "mesh/CryptoEngine.h"
#include "mesh/Default.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "mesh/Router.h"
#include "modules/TrafficManagementModule.h"
#include "support/DeterministicRng.h" // rngSeed/rngNext/rngRange - shared seeded LCG
#include <climits>
#include <cstring>
#include <memory>
#include <pb_encode.h>
#include <vector>
namespace
{
constexpr NodeNum kLocalNode = 0x11111111;
constexpr NodeNum kRemoteNode = 0x22222222;
constexpr NodeNum kTargetNode = 0x33333333;
// A second, distinct requester. The per-requester direct-response throttle (60 s) is keyed on the
// requesting packet's `from`, so tests that exercise the per-target / fallback / sweep throttles use
// a fresh requester for their "served again" step to avoid the per-requester window masking them.
constexpr NodeNum kRemoteNode2 = 0x44444444;
// INERT - commented out, not deleted. TrafficManagementModule holds no reference to airTime:
// the gating this described went with exhaust_hop_telemetry / exhaust_hop_position, and
// shouldExhaustHops() is now a compare of three members nothing sets. Writing the buckets did not
// work either - the first accessor call takes AirTime's firstTime branch and memsets them, so this
// reported 0%, not 100%. A revived version must fill them via logAirtime(); they are private now.
//
// class ScopedBusyAirTime
// {
// public:
// ScopedBusyAirTime() : previous(airTime)
// {
// busy.logAirtime(RX_ALL_LOG, CHANNEL_UTILIZATION_PERIODS * 10 * 1000); // a full window
// airTime = &busy;
// }
// ~ScopedBusyAirTime() { airTime = previous; }
//
// private:
// AirTime busy;
// AirTime *previous;
// };
class MockNodeDB : public NodeDB
{
public:
meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override
{
if (hasCachedNode && n == cachedNodeNum)
return &cachedNode;
return NodeDB::getMeshNode(n);
}
void clearCachedNode()
{
hasCachedNode = false;
cachedNodeNum = 0;
cachedNode = meshtastic_NodeInfoLite_init_zero;
}
void setCachedNode(NodeNum n)
{
clearCachedNode();
hasCachedNode = true;
cachedNodeNum = n;
cachedNode.num = n;
cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
}
// Role the TMM should see for the cached node (sender-role-aware throttles).
void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; }
// Direct mutable access to the cached node for fine-grained bitfield manipulation in tests.
meshtastic_NodeInfoLite &cachedNodeForTest()
{
hasCachedNode = true;
return cachedNode;
}
// Seed a node into the hot-store buffer at index 1 (index 0 is reserved for
// "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of
// MAX_NUM_NODES slots with `numMeshNodes` as the logical count - we grow the
// buffer if needed and bump the count, never clear()/push_back() (which would
// shrink it and break NodeDB::resetNodes()'s begin()+1..end() fill).
void setHotNode(NodeNum n, uint8_t nextHop)
{
if (meshNodes->size() < 2)
meshNodes->resize(2);
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
(*meshNodes)[1].num = n;
(*meshNodes)[1].next_hop = nextHop;
numMeshNodes = 2;
}
// Seed a full identity (name, 32-byte key of `keyByte`, optional XEdDSA-signed and/or
// manually-verified provenance bits) into the hot-store buffer at index 1, for
// reconcile/seeding tests that iterate getMeshNodeByIndex().
void setHotNodeIdentity(NodeNum n, const char *longName, uint8_t keyByte, bool xeddsaSigned, bool manuallyVerified = false)
{
setHotNode(n, 0);
meshtastic_NodeInfoLite &info = (*meshNodes)[1];
strncpy(info.long_name, longName, sizeof(info.long_name) - 1);
info.public_key.size = 32;
memset(info.public_key.bytes, keyByte, 32);
info.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
if (xeddsaSigned)
info.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
if (manuallyVerified)
info.bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
}
// Evict everything but "self" - simulates the hot DB rolling over. Logical
// count only; the buffer is left intact so the invariant holds.
void rollHotStore()
{
numMeshNodes = 1;
clearCachedNode();
}
// Seed a hot-store node that has been learned as an XEdDSA signer, with a known name, so the
// identity-update gate can be exercised. Distinct from setCachedNode() so a separate cached
// node (the direct-response target) can coexist.
void setSignerHotNode(NodeNum n, const char *longName)
{
if (meshNodes->size() < 2)
meshNodes->resize(2);
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
(*meshNodes)[1].num = n;
nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_USER_MASK, true);
strncpy((*meshNodes)[1].long_name, longName, sizeof((*meshNodes)[1].long_name) - 1);
numMeshNodes = 2;
}
private:
bool hasCachedNode = false;
NodeNum cachedNodeNum = 0;
meshtastic_NodeInfoLite cachedNode = meshtastic_NodeInfoLite_init_zero;
};
class MockRadioInterface : public RadioInterface
{
public:
ErrorCode send(meshtastic_MeshPacket *p) override
{
packetPool.release(p);
return ERRNO_OK;
}
uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override
{
(void)totalPacketLen;
(void)received;
return 0;
}
};
class MockRouter : public Router
{
public:
~MockRouter()
{
// Router allocates a global crypt lock in its constructor.
// Clean it up here so each test can build a fresh mock router.
delete cryptLock;
cryptLock = nullptr;
}
ErrorCode send(meshtastic_MeshPacket *p) override
{
sentPackets.push_back(*p);
packetPool.release(p);
return ERRNO_OK;
}
std::vector<meshtastic_MeshPacket> sentPackets;
};
class TrafficManagementModuleTestShim : public TrafficManagementModule
{
public:
using TrafficManagementModule::alterReceived;
using TrafficManagementModule::dropNodeInfoCacheForTest;
using TrafficManagementModule::flushCache;
using TrafficManagementModule::handleReceived;
using TrafficManagementModule::markKeyXeddsaSignedForTest;
using TrafficManagementModule::nodeInfoCacheCapacityForTest;
using TrafficManagementModule::peekCachedRole;
using TrafficManagementModule::peekNodeInfoFlagsForTest;
using TrafficManagementModule::runOnce;
bool ignoreRequestFlag() const { return ignoreRequest; }
};
MockNodeDB *mockNodeDB = nullptr;
static void installWellKnownPrimaryChannel(); // defined below, next to the other channel fixtures
static void resetTrafficConfig()
{
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
moduleConfig.has_traffic_management = true;
moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero;
config = meshtastic_LocalConfig_init_zero;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
// A real device always has a primary channel; leaving channels_count at 0 made every router
// lookup log "Invalid channel index", 12k lines of it, without testing anything.
installWellKnownPrimaryChannel();
owner.is_licensed = false;
myNodeInfo.my_node_num = kLocalNode;
router = nullptr;
service = nullptr;
mockNodeDB->resetNodes();
mockNodeDB->clearCachedNode();
nodeDB = mockNodeDB;
// Virtual clock base (1 h in, so tick subtraction never underflows). Tests advance time by
// bumping TrafficManagementModule::s_testNowMs instead of sleeping real seconds across a tick.
TrafficManagementModule::s_testNowMs = 3600000;
}
static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST)
{
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
packet.from = from;
packet.to = to;
packet.id = 0x1001;
packet.channel = 0;
packet.hop_start = 3;
packet.hop_limit = 3;
packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
packet.decoded.portnum = port;
packet.decoded.has_bitfield = true;
packet.decoded.bitfield = 0;
return packet;
}
static meshtastic_MeshPacket makeUnknownPacket(NodeNum from, NodeNum to = NODENUM_BROADCAST)
{
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
packet.from = from;
packet.to = to;
packet.id = 0x2001;
packet.channel = 0;
packet.hop_start = 3;
packet.hop_limit = 3;
packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
packet.encrypted.size = 0;
return packet;
}
static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32_t lon, NodeNum to = NODENUM_BROADCAST)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, to);
meshtastic_Position pos = meshtastic_Position_init_zero;
pos.has_latitude_i = true;
pos.has_longitude_i = true;
pos.latitude_i = lat;
pos.longitude_i = lon;
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
return packet;
}
static meshtastic_MeshPacket makePositionPacketWithPrecision(NodeNum from, int32_t lat, int32_t lon, uint32_t precisionBits)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, NODENUM_BROADCAST);
meshtastic_Position pos = meshtastic_Position_init_zero;
pos.has_latitude_i = true;
pos.has_longitude_i = true;
pos.latitude_i = lat;
pos.longitude_i = lon;
pos.precision_bits = precisionBits;
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
return packet;
}
static bool decodePositionPayload(const meshtastic_MeshPacket &packet, meshtastic_Position &out)
{
out = meshtastic_Position_init_zero;
return pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &out);
}
// Primary channel with a well-known single-byte PSK and the (empty -> preset)
// default name, so Channels::isWellKnownChannel(0) is true.
static void installWellKnownPrimaryChannel()
{
channelFile = meshtastic_ChannelFile_init_zero;
channelFile.channels_count = 1;
channelFile.channels[0].index = 0;
channelFile.channels[0].has_settings = true;
channelFile.channels[0].role = meshtastic_Channel_Role_PRIMARY;
channelFile.channels[0].settings.psk.size = 1;
channelFile.channels[0].settings.psk.bytes[0] = 1;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
}
// Install the well-known primary channel AND set a specific position_precision so
// shouldDropPosition() uses that precision ceiling rather than the default fallback.
// precision=0 means "no channel ceiling" and falls back to the firmware default (19 bits).
static void installWellKnownPrimaryChannelWithPrecision(uint32_t precision)
{
installWellKnownPrimaryChannel();
channelFile.channels[0].settings.has_module_settings = true;
channelFile.channels[0].settings.module_settings.position_precision = precision;
}
static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
meshtastic_User user = meshtastic_User_init_zero;
snprintf(user.id, sizeof(user.id), "!%08x", from);
strncpy(user.long_name, longName, sizeof(user.long_name) - 1);
strncpy(user.short_name, shortName, sizeof(user.short_name) - 1);
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
return packet;
}
static meshtastic_MeshPacket makeNodeInfoPacketWithRole(NodeNum from, meshtastic_Config_DeviceConfig_Role role)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
meshtastic_User user = meshtastic_User_init_zero;
snprintf(user.id, sizeof(user.id), "!%08x", from);
strncpy(user.long_name, "rolenode", sizeof(user.long_name) - 1);
strncpy(user.short_name, "rn", sizeof(user.short_name) - 1);
user.role = role;
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
return packet;
}
/**
* Verify the module is a no-op when traffic management is disabled.
* Important so config toggles cannot accidentally change routing behavior.
*/
static void test_tm_moduleDisabled_doesNothing(void)
{
moduleConfig.has_traffic_management = false;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
ProcessMessage result = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(0, stats.packets_inspected);
TEST_ASSERT_EQUAL_UINT32(0, stats.unknown_packet_drops);
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
// The write-through hooks share the disabled gate: with maintenance (sweep + reconcile)
// off, NodeDB commits must not fill the NodeInfo cache either.
uint8_t key[32];
memset(key, 0x42, sizeof(key));
module.onNodeKeyCommitted(kRemoteNode, key, false);
uint8_t out[32];
TEST_ASSERT_FALSE(module.copyPublicKey(kRemoteNode, out, nullptr));
}
/**
* Verify unknown-packet dropping uses N+1 threshold semantics.
* Important to catch off-by-one regressions in drop decisions.
*/
static void test_tm_unknownPackets_dropOnNPlusOne(void)
{
moduleConfig.traffic_management.unknown_packet_threshold = 2;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
ProcessMessage r3 = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops);
TEST_ASSERT_EQUAL_UINT32(3, stats.packets_inspected);
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
}
/**
* Verify duplicate position broadcasts inside the dedup window are dropped.
* Important because this is the primary airtime-saving behavior.
*/
static void test_tm_positionDedup_dropsDuplicateWithinWindow(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_GREATER_THAN_UINT32(0, first.decoded.payload.size);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
}
/**
* Verify changed coordinates are forwarded even with dedup enabled.
* Important so real movement updates are never suppressed as duplicates.
*/
static void test_tm_positionDedup_allowsMovedPosition(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket moved = makePositionPacket(kRemoteNode, 384221234, -1210845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(moved);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
}
/**
* Verify rate limiting drops only after exceeding the configured threshold.
* Important to protect threshold semantics from off-by-one regressions.
*/
static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
{
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 3;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
ProcessMessage r3 = module.handleReceived(packet);
ProcessMessage r4 = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r4));
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
}
/**
* Verify packets sourced from this node bypass dedup and rate limiting.
* Important so local transmissions are not accidentally self-throttled.
*/
static void test_tm_fromUs_bypassesPositionAndRateFilters(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 1;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678);
meshtastic_MeshPacket textPacket = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode);
ProcessMessage p1 = module.handleReceived(positionPacket);
ProcessMessage p2 = module.handleReceived(positionPacket);
ProcessMessage t1 = module.handleReceived(textPacket);
ProcessMessage t2 = module.handleReceived(textPacket);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t2));
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops);
}
/**
* Verify locally addressed packets are never dropped by transit shaping.
* Important so dedup/rate limiting do not suppress end-user delivery.
*/
static void test_tm_localDestination_bypassesTransitFilters(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 1;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode);
meshtastic_MeshPacket position2 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode);
meshtastic_MeshPacket text1 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode);
meshtastic_MeshPacket text2 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode);
ProcessMessage p1 = module.handleReceived(position1);
ProcessMessage p2 = module.handleReceived(position2);
ProcessMessage t1 = module.handleReceived(text1);
ProcessMessage t2 = module.handleReceived(text2);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(p2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(t2));
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops);
}
/**
* Verify router role clamps NodeInfo response hops to router-safe maximum.
* Important so large config values cannot widen response scope unexpectedly.
*/
static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
mockNodeDB->setCachedNode(kTargetNode);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 5;
request.hop_limit = 1; // 4 hops away; router clamp should cap max at 3
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
}
/**
* Verify NodeInfo direct-response success path and reply packet fields.
* Important because this path consumes the request and generates a spoofed cached reply.
*/
static void test_tm_nodeinfo_directResponse_respondsFromCache(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
config.lora.config_ok_to_mqtt = true;
mockNodeDB->setCachedNode(kTargetNode);
// Signed-only replay gate (default) requires the target be a known signer to be served.
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.id = 0x13572468;
request.hop_start = 3;
request.hop_limit = 3; // direct request (0 hops away)
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits);
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front();
TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_NODEINFO_APP, reply.decoded.portnum);
TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from);
TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to);
TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id);
TEST_ASSERT_FALSE(reply.decoded.want_response);
TEST_ASSERT_EQUAL_UINT8(0, reply.hop_limit);
TEST_ASSERT_EQUAL_UINT8(0, reply.hop_start);
TEST_ASSERT_EQUAL_UINT8(mockNodeDB->getLastByteOfNodeNum(kRemoteNode), reply.next_hop);
TEST_ASSERT_TRUE(reply.decoded.has_bitfield);
TEST_ASSERT_EQUAL_UINT8(BITFIELD_OK_TO_MQTT_MASK, reply.decoded.bitfield);
}
/**
* Verify cached direct replies still preserve requester NodeInfo learning.
* Important so consuming the request does not skip NodeDB refresh for observers.
*/
static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->setCachedNode(kTargetNode);
// Signed-only replay gate (default) requires the target be a known signer to be served.
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq");
request.to = kTargetNode;
request.decoded.want_response = true;
request.id = 0x01020304;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
TEST_ASSERT_NOT_NULL(requestor);
TEST_ASSERT_TRUE((requestor->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK) != 0);
TEST_ASSERT_EQUAL_STRING("requester-long", requestor->long_name);
TEST_ASSERT_EQUAL_STRING("rq", requestor->short_name);
TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel);
}
/**
* A unicast NodeInfo request is never signed, so a known signer's identity claim on the
* direct-response path is unauthenticated. It must not overwrite the stored name (spoofing
* defense), while the direct response itself still goes out. The non-signer case
* (test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo) is the control: it still learns.
*/
static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->setCachedNode(kTargetNode); // the direct-response target
// Signed-only replay gate (default) requires the target be a known signer to be served.
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path this test was written for
meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk");
request.to = kTargetNode;
request.decoded.want_response = true;
request.id = 0x0A0B0C0D;
request.hop_start = 3;
request.hop_limit = 3;
request.xeddsa_signed = false; // unicast: never signed
ProcessMessage result = module.handleReceived(request);
// The response still went out (the request was consumed from cache)...
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits);
// ...but the known signer's stored name was not overwritten by the unauthenticated claim.
const meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode);
TEST_ASSERT_NOT_NULL(requestor);
TEST_ASSERT_EQUAL_STRING("victim-real", requestor->long_name);
}
/**
* Verify client role only answers direct (0-hop) NodeInfo requests.
* Important so clients do not answer relayed requests outside intended scope.
*/
static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->setCachedNode(kTargetNode);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 2;
request.hop_limit = 1; // 1 hop away; clients are clamped to max 0
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
}
/**
* Verify the NodeDB-fallback path requires NodeDB for direct NodeInfo responses.
* Important because fallback should only happen through node-wide data when
* the dedicated NodeInfo cache does not exist.
*/
static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
#if TMM_HAS_NODEINFO_CACHE
/**
* Verify the NodeInfo cache can answer requests without NodeDB and that
* shouldRespondToNodeInfo() uses cached bitfield metadata.
*/
static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
config.lora.config_ok_to_mqtt = true;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
observed.decoded.has_bitfield = true;
observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK;
observed.channel = 2;
observed.rx_time = 123456;
observed.has_rx_time = true; // rx_time has explicit presence; mark this synthetic reading as measured
ProcessMessage observedResult = module.handleReceived(observed);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(observedResult));
// Signed-only replay gate (default) requires key-proven provenance to serve.
module.markKeyXeddsaSignedForTest(kTargetNode);
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.id = 0x24681357;
request.channel = 1;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits);
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front();
TEST_ASSERT_TRUE(reply.decoded.has_bitfield);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(BITFIELD_WANT_RESPONSE_MASK | BITFIELD_OK_TO_MQTT_MASK), reply.decoded.bitfield);
TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from);
TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to);
TEST_ASSERT_EQUAL_UINT8(request.channel, reply.channel);
TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id);
}
/**
* Verify NodeInfo cache misses do not fall back to NodeDB.
* Important so the dedicated cache index stays logically separate from
* NodeInfoModule/NodeDB when the cache is available.
*/
static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->setCachedNode(kTargetNode);
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
/**
* Verify a cached NodeInfo is NOT served once it ages past the serve window.
* Important: without this gate a long-gone (or forged) node's cached NodeInfo would be
* spoofed back to requestors indefinitely while the genuine request is suppressed.
*/
static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// Learn a NodeInfo for the target into the NodeInfo cache (broadcast, so it is only cached).
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
module.handleReceived(observed);
// Key-proven so staleness is the sole reason it is not served (isolates the gate under test).
module.markKeyXeddsaSignedForTest(kTargetNode);
// Advance the virtual clock just past the 6 h serve window.
// 6 h + two 3-min observation ticks: guarantees the modular obs-tick age exceeds the
// 120-tick serve window whatever the clock's offset within its current tick.
TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + (2UL * 3UL * 60UL * 1000UL);
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
/**
* Per-target direct-response throttle on the PSRAM cache path: repeated NodeInfo requests for the
* same target yield one spoofed reply per 60 s window - even from a DIFFERENT requester, since the
* target axis is independent of the requester axis - then a reply is allowed again once it elapses.
*/
static void test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg");
module.handleReceived(observed);
// Signed-only replay gate (default) requires key-proven provenance to serve.
module.markKeyXeddsaSignedForTest(kTargetNode);
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
// First request: served.
request.id = 0xAAAA0001;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// Second request within the window, from a DIFFERENT requester so only the per-target axis can
// throttle it: our spoofed TX is suppressed, but the request is NOT consumed - it CONTINUEs into
// normal relay handling so the genuine target can answer itself. (Previously it was black-holed:
// STOPped with nothing sent.) The cache-hit stat counts only replies actually sent.
TrafficManagementModule::s_testNowMs += 5000; // 5 s < 60 s window
request.from = kRemoteNode2;
request.id = 0xAAAA0002;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits);
// Past the per-target window: served again. Back to the original requester, whose own 60 s
// per-requester window from the first reply has also elapsed, so only the per-target release is
// under test here.
TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply
request.from = kRemoteNode;
request.id = 0xAAAA0003;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
#if !(MESHTASTIC_EXCLUDE_PKI)
// Build a NODEINFO_APP broadcast whose User carries a 32-byte public key of `keyByte`.
static meshtastic_MeshPacket makeNodeInfoPacketWithKey(NodeNum from, const char *longName, uint8_t keyByte)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
meshtastic_User user = meshtastic_User_init_zero;
snprintf(user.id, sizeof(user.id), "!%08x", from);
strncpy(user.long_name, longName, sizeof(user.long_name) - 1);
user.public_key.size = 32;
memset(user.public_key.bytes, keyByte, 32);
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
return packet;
}
/**
* Verify the NodeInfo cache pins the first-seen public key: a later NodeInfo for the same
* node carrying a DIFFERENT key is rejected, and the served reply keeps the original key.
* Mirrors NodeDB::updateUser()'s "Public Key mismatch, dropping NodeInfo" protection.
*/
static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode(); // no authoritative NodeDB key -> exercise the cache's own TOFU pin
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// First-seen key (0x11...) is pinned.
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x11));
// Poisoning attempt with a different key (0x22...) must be rejected.
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x22));
// Signed-only replay gate (default) requires key-proven provenance to serve the reply.
module.markKeyXeddsaSignedForTest(kTargetNode);
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// The served reply must still carry the original (pinned) key, not the attacker's.
meshtastic_User served = meshtastic_User_init_zero;
const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front();
TEST_ASSERT_TRUE(
pb_decode_from_bytes(reply.decoded.payload.bytes, reply.decoded.payload.size, &meshtastic_User_msg, &served));
TEST_ASSERT_EQUAL_UINT32(32, served.public_key.size);
TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[0]);
TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[31]);
}
#if WARM_NODE_COUNT > 0
/**
* Verify the NodeInfo cache pin also covers the WARM tier: for a node evicted from the hot
* store whose key lives only in the warm tier (and whose cache slot is empty), a NodeInfo
* carrying a different key must be rejected, and one carrying the warm key accepted.
* Important: with a hot-store-only pin, an attacker could seed this cache with a bogus key
* for a warm-evicted node; the cache's own TOFU pin would then lock the genuine node's
* frames out until the poisoned entry aged away.
*/
static void test_tm_nodeinfo_cache_pinsAgainstWarmTierKey(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode(); // hot store misses...
uint8_t warmKey[32];
memset(warmKey, 0x55, 32);
mockNodeDB->warmStore.clear();
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey); // ...but the warm tier holds the key
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// Poisoning attempt with a key that mismatches the warm tier: must not be cached.
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x66));
uint8_t out[32] = {0};
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, out, nullptr));
// The genuine key (matching the warm tier) is accepted.
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x55));
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_UINT8(0x55, out[0]);
TEST_ASSERT_EQUAL_UINT8(0x55, out[31]);
mockNodeDB->warmStore.clear();
}
/**
* Unsigned-identity gate, warm tier: a verified signer evicted to the warm tier must not be
* impersonatable. An attacker can forge an unsigned NodeInfo carrying the signer's real
* (public!) key - it passes the key pin and would inherit warm XEdDSA-signed provenance - so the
* gate must classify warm-tier signers, not only hot-store ones. A signature-verified frame
* (control) is still learned.
*/
static void test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore(); // hot store misses; only the warm tier knows the signer
uint8_t warmKey[32];
memset(warmKey, 0x5A, 32);
mockNodeDB->warmStore.clear();
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*xeddsaSigned=*/true);
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// Forgery: unsigned NodeInfo with the REAL key (passes the pin) and an attacker name.
meshtastic_MeshPacket forged = makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x5A);
forged.xeddsa_signed = false;
module.handleReceived(forged);
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); // nothing cached
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
// Control: the same identity, signature-verified, is learned.
meshtastic_MeshPacket genuine = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x5A);
genuine.xeddsa_signed = true;
module.handleReceived(genuine);
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_STRING("genuine", out.long_name);
mockNodeDB->warmStore.clear();
}
// Bit positions returned by peekNodeInfoFlagsForTest().
constexpr int kFlagObserved = 1;
constexpr int kFlagMember = 2;
constexpr int kFlagFullUser = 4;
constexpr int kFlagKeyProven = 8; // keyProven() == keyXeddsaSigned | keyManuallyVerified
/**
* Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by
* the maintenance sweep (name + key + key provenance usable via copyUser/copyPublicKey),
* but is NEVER served as a spoofed reply until a genuine NODEINFO frame is heard - seeding
* and retention must not make a silent node look alive.
*/
static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->warmStore.clear();
mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x77, /*xeddsaSigned=*/true);
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity
// Seeded identity is available to the key pool and name rehydration...
uint8_t key[32] = {0};
bool proven = false;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x77, key[0]);
TEST_ASSERT_TRUE(proven); // inherited from the hot store's XEdDSA-signed bit, key-matched
meshtastic_User seeded = meshtastic_User_init_zero;
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, seeded, nullptr));
TEST_ASSERT_EQUAL_STRING("hot-name", seeded.long_name);
// ...but a request for it is NOT answered: never observed, so never servable.
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// A genuinely observed frame (key matches the NodeDB pin) makes it servable. The node is
// a known signer, so per #11035 only a signature-verified frame may drive cache writes -
// mark it as Router-verified, as the real receive path would.
meshtastic_MeshPacket observed = makeNodeInfoPacketWithKey(kTargetNode, "hot-name", 0x77);
observed.xeddsa_signed = true;
module.handleReceived(observed);
request.id = 0xCCCC0002;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
mockNodeDB->rollHotStore();
}
/**
* Reconcile re-seeds manual verification: a hot-store node carrying IS_KEY_MANUALLY_VERIFIED
* (but NOT XEdDSA-signed) is reconciled into the cache with keyProven() set via the manual
* channel alone, so copyPublicKey reports it proven and the replay gate would vouch for it.
* Guards the second provenance channel independently of the XEdDSA path.
*/
static void test_tm_nodeinfo_reconcile_seedsManualVerificationFromHotStore(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->warmStore.clear();
// Manually verified but NOT XEdDSA-signed: isolates the manual provenance channel.
mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x5A, /*xeddsaSigned=*/false,
/*manuallyVerified=*/true);
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity
// keyProven() is set even though keyXeddsaSigned is false - the manual bit alone proves it.
const int flags = module.peekNodeInfoFlagsForTest(kTargetNode);
TEST_ASSERT_TRUE(flags >= 0);
TEST_ASSERT_TRUE(flags & kFlagKeyProven);
// The pubkey pool sees it as proven, sourced from the manual channel.
uint8_t key[32] = {0};
bool proven = false;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]);
TEST_ASSERT_TRUE(proven);
mockNodeDB->rollHotStore();
}
/**
* Reconcile seeding from the warm tier yields a key-only record: usable by copyPublicKey
* (with the warm XEdDSA-signed bit inherited), but never by copyUser - the warm tier keeps no
* names, and a nameless User must not reach name-rehydration.
*/
static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
uint8_t warmKey[32];
memset(warmKey, 0x44, 32);
mockNodeDB->warmStore.clear();
mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*xeddsaSigned=*/true);
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.runOnce();
uint8_t key[32] = {0};
bool proven = false;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x44, key[31]);
TEST_ASSERT_TRUE(proven);
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only record
mockNodeDB->warmStore.clear();
}
/**
* Reconcile must not let a keyless hot-store identity erase a TOFU key this cache already
* learned (the same merge rule as onNodeIdentityCommitted): the hot name is adopted, the
* kept key survives for the copyPublicKey pool - and stays unproven, because the hot signer
* bit vouches only for a NodeDB-supplied key, never the kept TOFU one.
*/
static void test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
mockNodeDB->warmStore.clear();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// TOFU learn while NodeDB knows nothing about the node.
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "tofu-name", 0x33));
uint8_t key[32] = {0};
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
// NodeDB then learns the node with a User but NO key; its signed bit is even set, which
// must vouch for nothing here since NodeDB supplies no key to match it against.
mockNodeDB->setSignerHotNode(kTargetNode, "hot-name");
module.runOnce(); // maintenance sweep -> reconcile adopts the hot identity
bool proven = true;
memset(key, 0, sizeof(key));
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); // TOFU key survived
TEST_ASSERT_EQUAL_UINT8(0x33, key[0]);
TEST_ASSERT_EQUAL_UINT8(0x33, key[31]);
TEST_ASSERT_FALSE(proven);
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_STRING("hot-name", out.long_name); // hot identity adopted
mockNodeDB->rollHotStore();
}
/**
* Write-through hook: an identity committed through NodeDB::updateUser() lands in the
* NodeInfo cache immediately (name + key), without waiting for a maintenance sweep - and
* is still not servable, because the node was never actually heard.
*/
static void test_tm_nodeinfo_updateUserHook_writesThrough(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
mockNodeDB->warmStore.clear();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
trafficManagementModule = &module; // the NodeDB hook reaches the module via the global
meshtastic_User user = meshtastic_User_init_zero;
strncpy(user.long_name, "committed", sizeof(user.long_name) - 1);
user.public_key.size = 32;
memset(user.public_key.bytes, 0x5A, 32);
mockNodeDB->updateUser(kTargetNode, user, 0);
// Immediately visible to the key pool and name rehydration (no sweep has run)...
uint8_t key[32] = {0};
bool proven = true;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]);
TEST_ASSERT_FALSE(proven); // committed TOFU key: no XEdDSA-signed bit on the node yet
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_STRING("committed", out.long_name);
// ...but known-not-heard is never servable.
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// trafficManagementModule and the hot store are reset in tearDown()/setUp().
}
/**
* Full removal: NodeDB::removeNodeByNum() must purge this module's caches too - both the
* NodeInfo identity entry (name/key) and the unified slot (next-hop hint etc.) - or the
* deleted identity would keep feeding the key pool and resurrect on next contact.
*/
static void test_tm_nodeinfo_removeNode_purgesCaches(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
mockNodeDB->warmStore.clear();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global
// Learn an identity (observed frame) and a routing hint for the node.
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37));
module.setNextHop(kTargetNode, 0x42);
uint8_t key[32] = {0};
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
mockNodeDB->removeNodeByNum(kTargetNode);
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
// trafficManagementModule is reset in tearDown().
}
/**
* No timed eviction: a quiet keyed entry survives arbitrarily long (its key keeps feeding
* the pubkey pool via copyPublicKey), while tick saturation stops it being SERVED long
* before that. Slots are reclaimed only by LRU pressure on insert or an explicit purge.
*/
static void test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
mockNodeDB->warmStore.clear();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "quiet", 0x21));
module.markKeyXeddsaSignedForTest(kTargetNode); // isolate: staleness, not the signed gate
// Nine days of silence, swept every three days. The old design would have evicted the
// entry at the 7-day retention TTL; now nothing expires by timer.
for (int i = 0; i < 3; i++) {
TrafficManagementModule::s_testNowMs += 3UL * 24UL * 60UL * 60UL * 1000UL;
module.runOnce();
}
// The key still feeds the pool...
uint8_t key[32] = {0};
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
TEST_ASSERT_EQUAL_UINT8(0x21, key[0]);
// ...but the serve gate saturated long ago: the request propagates unanswered.
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
#endif // WARM_NODE_COUNT > 0
/**
* Feature #2: a key learned from an (unsigned) NodeInfo is served by copyPublicKey() as a
* trust-on-first-use key, so it can extend the encryption pool. keyProven must be false.
*/
static void test_tm_nodeinfo_copyPublicKey_servesTofuKey(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x33));
uint8_t key[32] = {0};
bool proven = true; // must be overwritten to false
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x33, key[0]);
TEST_ASSERT_EQUAL_UINT8(0x33, key[31]);
TEST_ASSERT_FALSE(proven);
}
/**
* Feature #1: a later signature-verified NodeInfo upgrades the cached key's provenance to
* XEdDSA-signed (monotonic), while the key bytes stay pinned.
*/
static void test_tm_nodeinfo_copyPublicKey_upgradesToXeddsaSigned(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// First contact is unsigned -> TOFU.
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44));
uint8_t key[32] = {0};
bool proven = true;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_FALSE(proven);
// A later frame whose signature we verified upgrades provenance.
meshtastic_MeshPacket signed_ni = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44);
signed_ni.xeddsa_signed = true;
module.handleReceived(signed_ni);
proven = false;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_TRUE(proven);
TEST_ASSERT_EQUAL_UINT8(0x44, key[0]); // key unchanged
}
/**
* copyPublicKey() reports a miss (false) for a node we have never cached.
*/
static void test_tm_nodeinfo_copyPublicKey_missReturnsFalse(void)
{
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module;
uint8_t key[32] = {0};
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
}
/**
* copyUser() returns the full cached identity (name + key) for name rehydration, and reports a
* miss for an uncached node.
*/
static void test_tm_nodeinfo_copyUser_returnsCachedIdentity(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "target-long", 0x77));
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_STRING("target-long", out.long_name);
TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size);
TEST_ASSERT_EQUAL_UINT8(0x77, out.public_key.bytes[0]);
// Uncached node -> miss.
meshtastic_User miss = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kRemoteNode, miss, nullptr));
}
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
/**
* Replay gate (cache path): a fresh but trust-on-first-use (never key-proven) cached entry
* is withheld - the reply is suppressed though the entry is fresh.
*/
static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// Cache a fresh but unsigned (TOFU) NodeInfo and do NOT mark it key-proven.
module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg"));
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE
#endif // !MESHTASTIC_EXCLUDE_PKI
/**
* Key-commit hook (ported from tmm-fix-superset): a TOFU learn lands the key in the pool
* without a User payload; manual verification upgrades provenance; a NodeDB-senior rotation
* replaces the key and never inherits the old key's verdict.
*/
static void test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance(void)
{
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module;
uint8_t k[32];
memset(k, 0x99, sizeof(k));
module.onNodeKeyCommitted(kTargetNode, k, false); // TOFU-grade learn
uint8_t key[32] = {0};
bool proven = true;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x99, key[0]);
TEST_ASSERT_FALSE(proven);
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only: nothing to rehydrate
module.onNodeKeyCommitted(kTargetNode, k, true); // manual verification of the same key
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_TRUE(proven);
uint8_t rotated[32];
memset(rotated, 0xAA, sizeof(rotated));
module.onNodeKeyCommitted(kTargetNode, rotated, false); // NodeDB-senior rotation
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0xAA, key[0]);
TEST_ASSERT_FALSE(proven); // rotated key must not inherit the old key's verdict
}
/**
* Tick saturation (ported from tmm-fix-superset): the sweep clears hasObserved once the
* serve window passes, the entry itself persists (no TTL eviction), and a full 256-tick
* wrap of the clock cannot alias a saturated stamp back to "fresh".
*/
static void test_tm_nodeinfo_tickSaturation_sweepClearsObserved(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->warmStore.clear();
mockNodeDB->rollHotStore();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg"));
module.markKeyXeddsaSignedForTest(kTargetNode);
const uint32_t stampMs = TrafficManagementModule::s_testNowMs;
int flags = module.peekNodeInfoFlagsForTest(kTargetNode);
TEST_ASSERT_TRUE(flags >= 0 && (flags & kFlagObserved));
// Past the serve window (plus one tick for granularity): the sweep saturates the stamp
// but keeps the entry.
TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 180000UL + 1000UL;
module.runOnce();
flags = module.peekNodeInfoFlagsForTest(kTargetNode);
TEST_ASSERT_TRUE(flags >= 0);
TEST_ASSERT_FALSE(flags & kFlagObserved);
// Advance to exactly one full uint8 tick period after the original stamp: the raw tick
// age would read ~0 (fresh) if the bit were still trusted. It isn't - the cleared bit,
// not the tick value, is authoritative.
TrafficManagementModule::s_testNowMs = stampMs + 256UL * 180000UL;
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
/**
* Membership marking: the hourly reconcile marks entries whose node exists in NodeDB and
* clears the mark when the node is gone. A plain 60 s sweep does NOT refresh membership
* (that per-entry NodeDB scan was moved into the reconcile), so a passive NodeDB eviction
* lags by up to an hour; the entry itself persists (no TTL) and reconciliation-seeded
* identities stay unservable.
*/
static void test_tm_nodeinfo_reconcileMembershipMarking(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->warmStore.clear();
mockNodeDB->setHotNodeIdentity(kTargetNode, "seeded-name", 0x5C, /*xeddsaSigned=*/false);
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.runOnce(); // boot reconciliation pass seeds the hot identity
int flags = module.peekNodeInfoFlagsForTest(kTargetNode);
TEST_ASSERT_TRUE(flags >= 0);
TEST_ASSERT_TRUE(flags & kFlagMember);
TEST_ASSERT_TRUE(flags & kFlagFullUser);
TEST_ASSERT_FALSE(flags & kFlagObserved);
// Node drops out of NodeDB entirely. Membership is refreshed by the hourly reconcile,
// not the per-minute sweep, so the very next sweep still shows the stale member bit -
// the documented up-to-an-hour lag for passive evictions.
mockNodeDB->rollHotStore();
module.runOnce();
flags = module.peekNodeInfoFlagsForTest(kTargetNode);
TEST_ASSERT_TRUE(flags >= 0);
TEST_ASSERT_TRUE(flags & kFlagMember); // stale by design between reconciles
// After a reconcile interval's worth of sweeps, the mark is cleared.
for (int i = 0; i < 60; i++)
module.runOnce();
flags = module.peekNodeInfoFlagsForTest(kTargetNode);
TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ...
TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member
}
/**
* cacheNodeInfoPacket() normalizes user.id to the packet sender: a payload claiming another
* node's id string must not plant a mismatched id into served replies or rehydrated names.
*/
static void test_tm_nodeinfo_cache_normalizesSpoofedUserId(void)
{
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kTargetNode, NODENUM_BROADCAST);
meshtastic_User user = meshtastic_User_init_zero;
snprintf(user.id, sizeof(user.id), "!%08x", static_cast<unsigned>(kRemoteNode)); // spoofed id
strncpy(user.long_name, "spoofer", sizeof(user.long_name) - 1);
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user);
module.handleReceived(packet);
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
char expected[16];
snprintf(expected, sizeof(expected), "!%08x", static_cast<unsigned>(kTargetNode));
TEST_ASSERT_EQUAL_STRING(expected, out.id);
}
/**
* The write-through hooks share the module-enabled gate with maintenance: while traffic
* management is disabled in moduleConfig, neither hook fills the cache (content and
* maintenance stay keyed to the same condition); re-enabling restores write-through.
*/
static void test_tm_nodeinfo_hooks_noopWhileModuleDisabled(void)
{
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module; // constructed while enabled, so the cache is allocated
moduleConfig.has_traffic_management = false;
uint8_t k[32];
memset(k, 0x6B, sizeof(k));
module.onNodeKeyCommitted(kTargetNode, k, true);
meshtastic_User user = meshtastic_User_init_zero;
strncpy(user.long_name, "disabled", sizeof(user.long_name) - 1);
module.onNodeIdentityCommitted(kTargetNode, user, false);
uint8_t key[32] = {0};
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
// Control: the same hook lands once the module is enabled again.
moduleConfig.has_traffic_management = true;
module.onNodeKeyCommitted(kTargetNode, k, true);
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
TEST_ASSERT_EQUAL_UINT8(0x6B, key[0]);
}
// Build a User for onNodeIdentityCommitted; keyByte < 0 means a keyless commit.
static meshtastic_User makeCommittedUser(const char *longName, int keyByte)
{
meshtastic_User user = meshtastic_User_init_zero;
strncpy(user.long_name, longName, sizeof(user.long_name) - 1);
if (keyByte >= 0) {
user.public_key.size = 32;
memset(user.public_key.bytes, static_cast<uint8_t>(keyByte), 32);
}
return user;
}
/**
* Identity write-through hook, key semantics: a keyless commit keeps an already-learned key
* (NodeDB may commit an unpinned identity); provenance follows the committed key - kept
* while the key is unchanged, granted by signerKnown, and reset by a rotation.
*/
static void test_tm_nodeinfo_identityHook_keySemantics(void)
{
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module;
// TOFU-grade identity commit with key K1.
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("first", 0x51), false);
uint8_t key[32] = {0};
bool proven = true;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x51, key[0]);
TEST_ASSERT_FALSE(proven);
// Keyless commit: the name updates but the learned key must survive, still unproven.
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("renamed", -1), false);
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, &proven));
TEST_ASSERT_EQUAL_STRING("renamed", out.long_name);
TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size);
TEST_ASSERT_EQUAL_UINT8(0x51, out.public_key.bytes[0]);
TEST_ASSERT_FALSE(proven);
// signerKnown commit of the same key grants provenance...
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("proven", 0x51), true);
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_TRUE(proven);
// ...which a later same-key commit without the signer verdict does not revoke...
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("still-proven", 0x51), false);
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_TRUE(proven);
// ...but a rotated key never inherits the old key's verdict.
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("rotated", 0x52), false);
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven));
TEST_ASSERT_EQUAL_UINT8(0x52, key[0]);
TEST_ASSERT_FALSE(proven);
}
/**
* Read gate: copyPublicKey()/copyUser() share the module-enabled gate with the writers, so a
* cache populated while enabled stops feeding PKI resolution and name rehydration the moment
* the module is disabled - and resumes when re-enabled (the entry itself is never freed).
*/
static void test_tm_nodeinfo_reads_gatedWhileModuleDisabled(void)
{
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module;
// Populate while enabled (setUp left has_traffic_management = true).
module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("present", 0x7C), false);
uint8_t key[32] = {0};
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
// Disabled: the frozen entry persists but the accessors refuse to serve it.
moduleConfig.has_traffic_management = false;
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
// Re-enabled: same entry served again (nothing was purged).
moduleConfig.has_traffic_management = true;
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
TEST_ASSERT_EQUAL_UINT8(0x7C, key[0]);
TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_STRING("present", out.long_name);
}
#if !(MESHTASTIC_EXCLUDE_PKI)
// Fill `count` NodeInfo cache slots with keyless observed strangers (eviction tier 0),
// numbered from `baseNode`.
static void fillNodeInfoCacheWithKeylessStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode)
{
for (uint32_t i = 0; i < count; i++)
module.handleReceived(makeNodeInfoPacket(baseNode + i, "filler", "fl"));
}
// Fill `count` NodeInfo cache slots with TOFU-keyed observed strangers (eviction tier 1),
// numbered from `baseNode`, all sharing key byte 0x0F.
static void fillNodeInfoCacheWithTofuStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode)
{
for (uint32_t i = 0; i < count; i++)
module.handleReceived(makeNodeInfoPacketWithKey(baseNode + i, "filler", 0x0F));
}
/**
* Tiered LRU eviction, tier boundaries: with the cache exactly full, a new stranger's insert
* evicts a keyless stranger - never a TOFU-keyed entry, a key-proven entry, or a NodeDB
* member - even though those higher-tier entries are the OLDEST observations in the cache
* (tier outranks recency).
*/
static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void)
{
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
TrafficManagementModuleTestShim module;
constexpr NodeNum kTofu = 0x51000001, kProven = 0x51000002, kMember = 0x51000003, kNewcomer = 0x51000004;
module.handleReceived(makeNodeInfoPacketWithKey(kTofu, "tofu", 0x11));
module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22));
module.markKeyXeddsaSignedForTest(kProven);
uint8_t memberKey[32];
memset(memberKey, 0x33, sizeof(memberKey));
module.onNodeKeyCommitted(kMember, memberKey, false);
// Age the specials by two observation ticks, then fill every remaining slot with
// fresher keyless strangers so the cache is exactly full.
TrafficManagementModule::s_testNowMs += 2UL * 180000UL;
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
fillNodeInfoCacheWithKeylessStrangers(module, cap - 3u, 0x40000000);
// The newcomer's insert must claim a keyless slot.
module.handleReceived(makeNodeInfoPacket(kNewcomer, "newcomer", "nc"));
uint8_t key[32] = {0};
TEST_ASSERT_TRUE(module.copyPublicKey(kTofu, key, nullptr));
TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr));
TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr));
TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0);
}
/**
* Tiered LRU eviction, keyed tiers: in a cache saturated with TOFU-keyed strangers, keyed
* inserts displace TOFU entries while a key-proven stranger and a NodeDB member survive
* (keyless < TOFU < key-proven, +membership).
*/
static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void)
{
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
TrafficManagementModuleTestShim module;
constexpr NodeNum kFillBase = 0x40000000;
constexpr NodeNum kProven = 0x52000001, kMember = 0x52000002, kNew1 = 0x52000003, kNew2 = 0x52000004;
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
fillNodeInfoCacheWithTofuStrangers(module, cap - 2u, kFillBase);
module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22));
module.markKeyXeddsaSignedForTest(kProven);
uint8_t memberKey[32];
memset(memberKey, 0x33, sizeof(memberKey));
module.onNodeKeyCommitted(kMember, memberKey, false);
// Age the saturated cache so each newcomer is fresher than the remaining fillers
// (otherwise the second insert would reclaim the first newcomer's equal-age slot).
TrafficManagementModule::s_testNowMs += 2UL * 180000UL;
module.handleReceived(makeNodeInfoPacketWithKey(kNew1, "new1", 0x44));
module.handleReceived(makeNodeInfoPacketWithKey(kNew2, "new2", 0x55));
uint8_t key[32] = {0};
TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr));
TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr));
TEST_ASSERT_TRUE(module.copyPublicKey(kNew1, key, nullptr));
TEST_ASSERT_TRUE(module.copyPublicKey(kNew2, key, nullptr));
// The two victims were the first TOFU fillers scanned, not the protected entries.
TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr));
TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 1, key, nullptr));
}
/**
* Within-tier LRU: among same-tier entries the stalest observation is evicted first, not
* whichever slot happens to be scanned first.
*/
static void test_tm_nodeinfo_eviction_withinTierStalestLoses(void)
{
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
TrafficManagementModuleTestShim module;
constexpr NodeNum kFillBase = 0x40000000;
constexpr NodeNum kStale = 0x53000001, kNewcomer = 0x53000002;
module.handleReceived(makeNodeInfoPacketWithKey(kStale, "stale", 0x11));
// Everything else is observed two ticks later...
TrafficManagementModule::s_testNowMs += 2UL * 180000UL;
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
fillNodeInfoCacheWithTofuStrangers(module, cap - 1u, kFillBase);
// ...so the newcomer's insert reclaims kStale's slot specifically.
module.handleReceived(makeNodeInfoPacketWithKey(kNewcomer, "newcomer", 0x22));
uint8_t key[32] = {0};
TEST_ASSERT_FALSE(module.copyPublicKey(kStale, key, nullptr));
TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0);
TEST_ASSERT_TRUE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // fresher same-tier entry survives
}
/**
* Member saturation: with every slot holding a NodeDB member, the write-through hooks skip
* rather than churn one member out for another (spareMembers), while the packet path - a
* genuinely observed frame - does evict a member.
*/
static void test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts(void)
{
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
TrafficManagementModuleTestShim module;
constexpr NodeNum kFillBase = 0x40000000;
constexpr NodeNum kExtra = 0x54000001, kObserved = 0x54000002;
const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest();
uint8_t k[32];
memset(k, 0x11, sizeof(k));
for (uint32_t i = 0; i < cap; i++)
module.onNodeKeyCommitted(kFillBase + i, k, false);
// Hook inserts for one more member: skipped rather than evicting an existing member.
uint8_t extraKey[32];
memset(extraKey, 0x22, sizeof(extraKey));
module.onNodeKeyCommitted(kExtra, extraKey, false);
uint8_t key[32] = {0};
TEST_ASSERT_FALSE(module.copyPublicKey(kExtra, key, nullptr));
module.onNodeIdentityCommitted(kExtra, makeCommittedUser("extra", 0x22), false);
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kExtra, out, nullptr));
// The packet path may churn a member: the observed stranger lands (in the first-scanned
// member's slot) and is correctly NOT marked a member itself.
module.handleReceived(makeNodeInfoPacketWithKey(kObserved, "observed", 0x33));
TEST_ASSERT_TRUE(module.copyPublicKey(kObserved, key, nullptr));
const int flags = module.peekNodeInfoFlagsForTest(kObserved);
TEST_ASSERT_TRUE(flags >= 0);
TEST_ASSERT_TRUE(flags & kFlagObserved);
TEST_ASSERT_FALSE(flags & kFlagMember);
TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // the evicted member
}
/**
* Full DB reset: NodeDB::resetNodes() purges this module's caches through purgeAll(), so no
* identity, key, or next-hop hint survives a user-initiated "forget everything".
*/
static void test_tm_nodeinfo_resetNodes_purgesAllCaches(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
mockNodeDB->rollHotStore();
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37));
module.setNextHop(kTargetNode, 0x42);
uint8_t key[32] = {0};
TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr));
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
mockNodeDB->resetNodes();
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
meshtastic_User out = meshtastic_User_init_zero;
TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr));
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
// trafficManagementModule is reset in tearDown().
}
/**
* A NodeInfo advertising OUR OWN public key is impersonating us and must never be cached
* (mirrors NodeDB::updateUser()'s key hygiene for the store that feeds spoofed replies).
*/
static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void)
{
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module;
owner.public_key.size = 32;
memset(owner.public_key.bytes, 0x42, 32);
module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "imposter", 0x42));
TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode));
uint8_t key[32] = {0};
TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr));
// owner.public_key is reset in tearDown() (guaranteed even if an assertion above aborts).
}
#endif // !MESHTASTIC_EXCLUDE_PKI
#endif
/**
* Verify the NodeDB-fallback direct-response path (no NodeInfo cache) refuses to spoof a
* reply for a node NodeDB last heard beyond the serve window, but still answers a fresh one.
*/
static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
// Drive getTime() to a known uptime so sinceLastSeen() is deterministic.
setBootRelativeTimeForUnitTest(1000000);
const uint32_t now = getTime();
mockNodeDB->setCachedNode(kTargetNode);
mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale
// Key-proven so staleness is the sole reason this is not served (isolates the gate under test).
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits);
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
resetRTCStateForTests();
}
/**
* Verify the NodeDB-fallback path still answers when the node was heard recently.
*/
static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
setBootRelativeTimeForUnitTest(1000000);
const uint32_t now = getTime();
mockNodeDB->setCachedNode(kTargetNode);
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // 1 min ago -> fresh
// Signed-only replay gate (default) requires the target be a known signer to be served.
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits);
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
resetRTCStateForTests();
}
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
/**
* Replay gate (fallback path): a manually-verified NodeDB node - IS_KEY_MANUALLY_VERIFIED set,
* but NOT XEdDSA-signed - passes the broadened key-proven gate and is served, mirroring the
* cache path's keyProven() (XEdDSA | manual). Companion to fallbackUnsignedNotServed.
*/
static void test_tm_nodeinfo_directResponse_fallbackManuallyVerifiedServed(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
setBootRelativeTimeForUnitTest(1000000);
const uint32_t now = getTime();
mockNodeDB->setCachedNode(kTargetNode);
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness
// Manually verified, NOT XEdDSA-signed: the key-proven gate must accept the manual channel.
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
resetRTCStateForTests();
}
#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE
/**
* Per-target direct-response throttle on the NodeDB-fallback path (no PSRAM NodeInfo cache). The
* per-target RAM table is not the cache, so it throttles this path identically: a burst for a fresh
* node yields one spoofed reply per 60 s window, even from a different requester, then serves again.
*/
static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
setBootRelativeTimeForUnitTest(1000000);
const uint32_t now = getTime();
TrafficManagementModule::s_testNowMs = 3600000; // known base for the throttle windows
mockNodeDB->setCachedNode(kTargetNode);
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness gate
// Signed-only replay gate (default) requires the target be a known signer to be served.
mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
// First request: served from the NodeDB fallback.
request.id = 0xBBBB0001;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// Second request within the window, from a DIFFERENT requester so only the per-target axis can
// throttle it: our spoofed TX is suppressed, but the request is NOT consumed - it CONTINUEs so
// the genuine target (or another cache-holder) can answer.
TrafficManagementModule::s_testNowMs += 5000; // 5 s < 60 s window
request.from = kRemoteNode2;
request.id = 0xBBBB0002;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_FALSE(module.ignoreRequestFlag());
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// Past the per-target window: served again. Back to the original requester (its 60 s per-requester
// window from the first reply has also elapsed), so only the per-target release is under test.
TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply
request.from = kRemoteNode;
request.id = 0xBBBB0003;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
resetRTCStateForTests();
}
/**
* The per-requester and global-floor axes, isolated from per-target (which has its own cache/fallback
* tests). Both bound spoofed direct replies by the unauthenticated requester address and by total
* airtime; each step keeps the per-target axis fresh (distinct targets) so only the axis under test
* gates the reply:
* - the same requester asking for a DIFFERENT target within 60 s is still throttled (the
* reflector-flood case: the per-target axis alone would not bound this);
* - a fresh requester is served, but only once the 1 s global floor has passed;
* - after the per-requester window elapses, the original requester is served again.
*/
static void test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearCachedNode();
const uint32_t base = 3600000;
TrafficManagementModule::s_testNowMs = base;
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
// Three distinct, freshly-observed, key-proven targets. Using a fresh target on each step keeps
// the per-target axis from ever being the bound here, so the reply is gated only by the axis under
// test: per-requester (step 2) or the global floor (step 4).
constexpr NodeNum kTargetA = 0x33330001, kTargetB = 0x33330002, kTargetC = 0x33330003;
constexpr NodeNum kRemoteNode3 = 0x66666666;
for (NodeNum t : {kTargetA, kTargetB, kTargetC}) {
module.handleReceived(makeNodeInfoPacket(t, "target-long", "tg"));
module.markKeyXeddsaSignedForTest(t);
}
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetA);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
// First reply for this requester: served.
request.id = 0xC0DE0001;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// Same requester, DIFFERENT target, 10 s later: the per-target throttle can't fire (fresh entry),
// but the per-requester window (60 s) still suppresses our TX. Forwarded, not sent.
TrafficManagementModule::s_testNowMs = base + 10000;
request.to = kTargetB;
request.id = 0xC0DE0002;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(1, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// A DIFFERENT requester (fresh slot) is served - the 1 s global floor has long passed.
request.from = kRemoteNode2;
request.id = 0xC0DE0003;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// Yet a third fresh requester in the SAME instant, for a fresh target (so per-target can't be the
// cause), is deferred by the 1 s global airtime floor. Forwarded, not sent.
request.from = kRemoteNode3;
request.to = kTargetC;
request.id = 0xC0DE0004;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(2, static_cast<uint32_t>(mockRouter.sentPackets.size()));
// After the per-requester window elapses, the original requester is served again.
TrafficManagementModule::s_testNowMs = base + 70000;
request.from = kRemoteNode;
request.to = kTargetA;
request.id = 0xC0DE0005;
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(module.handleReceived(request)));
TEST_ASSERT_EQUAL_UINT32(3, static_cast<uint32_t>(mockRouter.sentPackets.size()));
}
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
/**
* Replay gate (fallback path): a fresh NodeDB node that is NOT a known signer is withheld -
* the courtesy reply is suppressed and the genuine request propagates (CONTINUE, no TX).
*/
static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void)
{
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
setBootRelativeTimeForUnitTest(1000000);
const uint32_t now = getTime();
mockNodeDB->setCachedNode(kTargetNode);
mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness
// Deliberately NOT flagged as a signer -> the signed-only gate must withhold the reply.
MockRouter mockRouter;
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
MeshService mockService;
router = &mockRouter;
service = &mockService;
TrafficManagementModuleTestShim module;
module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path
meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode);
request.decoded.want_response = true;
request.hop_start = 3;
request.hop_limit = 3;
ProcessMessage result = module.handleReceived(request);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_EQUAL_UINT32(0, static_cast<uint32_t>(mockRouter.sentPackets.size()));
resetRTCStateForTests();
}
#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE
/**
* Verify relayed telemetry broadcasts are NOT hop-exhausted.
* exhaust_hop_telemetry / exhaust_hop_position have been removed from the config
* as "not suitable right now" - alterReceived must leave hop_limit unchanged.
*/
static void test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged(void)
{
// ScopedBusyAirTime busyChannel; // INERT: the module never reads airTime
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
packet.hop_start = 5;
packet.hop_limit = 3;
module.alterReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit); // unchanged
TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged
TEST_ASSERT_FALSE(module.shouldExhaustHops(packet));
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
}
/**
* Verify alterReceived does not modify unicast or local-origin packets.
* The precision clamp (the only active alterReceived path) only fires for
* broadcast position packets from remote nodes - these should be untouched.
*/
static void test_tm_alterReceived_skipsLocalAndUnicast(void)
{
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode);
unicast.hop_start = 5;
unicast.hop_limit = 3;
module.alterReceived(unicast);
TEST_ASSERT_EQUAL_UINT8(3, unicast.hop_limit);
TEST_ASSERT_FALSE(module.shouldExhaustHops(unicast));
meshtastic_MeshPacket fromUs = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kLocalNode, NODENUM_BROADCAST);
fromUs.hop_start = 5;
fromUs.hop_limit = 3;
module.alterReceived(fromUs);
TEST_ASSERT_EQUAL_UINT8(3, fromUs.hop_limit);
TEST_ASSERT_FALSE(module.shouldExhaustHops(fromUs));
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
}
/**
* Verify position dedup window expires and later duplicates are allowed.
* Important so periodic identical reports can resume after cooldown.
*/
static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void)
{
// 360 s = 1 pos-tick (kPosTimeTickMs); advance the virtual clock past one tick period.
moduleConfig.traffic_management.position_min_interval_secs = 360;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket third = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
TrafficManagementModule::s_testNowMs += 360001; // advance past one 6-min pos-tick (virtual clock)
ProcessMessage r3 = module.handleReceived(third);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify interval=0 disables position deduplication.
* Important because this is an explicit configuration escape hatch.
*/
static void test_tm_positionDedup_intervalZero_neverDrops(void)
{
// position_min_interval_secs=0 disables the drop gate (shouldDropPosition returns false for any packet).
moduleConfig.traffic_management.position_min_interval_secs = 0;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
}
/**
* Verify precision values above 32 fall back to default precision.
* Important so invalid config uses the documented default behavior.
*/
static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void)
{
// Channel precision=99 is out of range; sanitizePositionPrecision falls back to default (19 bits).
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(99);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
}
/**
* Verify the dedup fingerprint does not collapse positions that are distinct at the
* channel's *effective* precision. Dedup only runs on well-known (public) channels,
* where precision is capped at MAX_POSITION_PRECISION_PUBLIC_KEY (15) regardless of the
* channel's configured value - so the requested 32 is clamped to 15. Positions must
* therefore differ in the top 15 bits (>= 2^17 raw units) to read as distinct; here
* they differ by 2^18, well clear of the precision-15 grid, so neither is dropped.
*/
static void test_tm_positionDedup_distinctAtClampedChannelPrecision(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(32); // clamped to 15 on a public channel
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234 + (1 << 18), -1220845678 + (1 << 18));
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
}
/**
* Verify channel precision=0 (no channel ceiling set) falls back to the firmware
* default precision (19 bits / ~90 m cells). Positions more than one default grid
* cell apart must remain distinct, not collapse into one fingerprint.
*/
static void test_tm_positionDedup_precisionZero_channelFallsBackToDefault(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
// precision=0 in the channel → getPositionPrecisionForChannel returns 0 → falls back to default 19 bits.
installWellKnownPrimaryChannelWithPrecision(0);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops);
}
/**
* Verify epoch reset invalidates stale position identity for dedup.
* Important so reset paths cannot leak prior packet identity into new windows.
*/
static void test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterFlush = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
module.flushCache();
ProcessMessage r2 = module.handleReceived(afterFlush);
ProcessMessage r3 = module.handleReceived(duplicate);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify non-position cache state does not make the first fingerprint-0 position look duplicated.
* Important so unified cache entries from other features cannot leak into dedup decisions.
*/
static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 10;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
ProcessMessage seeded = module.handleReceived(telemetry);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(duplicate);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(seeded));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify rate-limit counters reset after the window expires.
* Important so temporary bursts do not cause persistent throttling.
*/
static void test_tm_rateLimit_resetsAfterWindowExpires(void)
{
// 300 s = 1 rate-tick (kRateTimeTickMs); advance the virtual clock past one tick period.
moduleConfig.traffic_management.rate_limit_window_secs = 300;
moduleConfig.traffic_management.rate_limit_max_packets = 1;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
TrafficManagementModule::s_testNowMs += 300001; // advance past one 5-min rate-tick (virtual clock)
ProcessMessage r3 = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
}
/**
* Verify unknown-packet tracking resets after its active window expires.
* Important so old unknown traffic does not trigger delayed drops.
*/
static void test_tm_unknownPackets_resetAfterWindowExpires(void)
{
moduleConfig.traffic_management.unknown_packet_threshold = 1;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
TrafficManagementModule::s_testNowMs += 300001; // advance past 5 unknown-ticks (5 × 60s) (virtual clock)
ProcessMessage r3 = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops);
}
/**
* Verify unknown threshold values above the implementation cap (60) are clamped.
* The counter is a 6-bit field (saturates at 63); threshold is capped at 60 so a
* saturated reading always exceeds it. A config value of 300 should behave as 60.
*/
static void test_tm_unknownPackets_thresholdAbove255_clamps(void)
{
moduleConfig.traffic_management.unknown_packet_threshold = 300;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode);
for (int i = 0; i < 60; i++) {
ProcessMessage result = module.handleReceived(packet);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
}
ProcessMessage dropped = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(dropped));
TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops);
}
/**
* Verify relayed position broadcasts are NOT hop-exhausted.
* exhaust_hop_position has been removed - alterReceived must leave hop_limit unchanged.
*/
static void test_tm_alterReceived_positionBroadcast_hopLimitUnchanged(void)
{
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST);
packet.hop_start = 5;
packet.hop_limit = 2;
module.alterReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_UINT8(2, packet.hop_limit); // unchanged
TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged
TEST_ASSERT_FALSE(module.shouldExhaustHops(packet));
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
}
/**
* Verify alterReceived ignores undecoded/encrypted packets.
* Important so we never mutate packets that were not decoded by this module.
*/
static void test_tm_alterReceived_skipsUndecodedPackets(void)
{
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST);
packet.hop_start = 5;
packet.hop_limit = 3;
module.alterReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start);
TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit);
TEST_ASSERT_FALSE(module.shouldExhaustHops(packet));
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
}
/**
* Verify shouldExhaustHops() always returns false - exhaust_hop_* features are
* removed, so the exhaustRequested flag is never set.
* Guards against accidental re-enablement without updating the flag logic.
*/
static void test_tm_alterReceived_exhaustFlagAlwaysFalse(void)
{
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
telemetry.hop_start = 5;
telemetry.hop_limit = 3;
module.alterReceived(telemetry);
TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry));
meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
ProcessMessage result = module.handleReceived(text);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry));
TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets);
}
/**
* Verify shouldExhaustHops() returns false for any packet regardless of from/id.
* Since exhaust is removed, the from+id scope check is moot - this guards that
* the always-false invariant holds across multiple distinct packets.
*/
static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void)
{
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket p1 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
p1.id = 0x1010;
p1.hop_start = 5;
p1.hop_limit = 3;
module.alterReceived(p1);
meshtastic_MeshPacket p2 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST);
p2.id = 0x2020;
p2.hop_start = 4;
p2.hop_limit = 0;
TEST_ASSERT_FALSE(module.shouldExhaustHops(p1));
TEST_ASSERT_FALSE(module.shouldExhaustHops(p2));
}
/**
* Verify runOnce() returns sleep-forever interval when has_traffic_management is false.
* TMM has no runtime enable flag - the presence bit is the only runtime gate.
*/
static void test_tm_runOnce_disabledReturnsMaxInterval(void)
{
moduleConfig.has_traffic_management = false;
TrafficManagementModuleTestShim module;
int32_t interval = module.runOnce();
TEST_ASSERT_EQUAL_INT32(INT32_MAX, interval);
}
/**
* Verify runOnce() returns the maintenance cadence when enabled.
* Important so periodic cache housekeeping continues at expected interval.
*/
static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void)
{
TrafficManagementModuleTestShim module;
int32_t interval = module.runOnce();
TEST_ASSERT_EQUAL_INT32(60 * 1000, interval);
}
// ---------------------------------------------------------------------------
// Next-hop overflow cache
// ---------------------------------------------------------------------------
/**
* Round-trip set/get of a confirmed next hop, plus the input guards.
*/
static void test_tm_nextHop_setAndGetRoundTrip(void)
{
TrafficManagementModuleTestShim module;
// Unknown node yields no hint.
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
// Store a confirmed hop and read it back.
module.setNextHop(kTargetNode, 0x42);
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
// Zero dest and zero byte are rejected (no spurious entry created).
module.setNextHop(0, 0x42);
module.setNextHop(kRemoteNode, 0);
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kRemoteNode));
// Last-write-wins on re-confirmation.
module.setNextHop(kTargetNode, 0x99);
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
}
/**
* The headline scenario: a node carrying a next hop in the hot NodeInfoLite DB
* is warm-loaded into the TMM cache, then the hot DB is "rolled" (the node ages
* out entirely). The hint must still be served - now exclusively from TMM.
*/
static void test_tm_nextHop_servedAfterNodeDbRoll(void)
{
TrafficManagementModuleTestShim module;
// Seed the hot NodeInfoLite DB with a node that has a confirmed next hop.
mockNodeDB->setHotNode(kTargetNode, 0x42);
// Warm-start the overflow cache from the hot DB.
module.preloadNextHopsFromNodeDB();
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
// Roll the main NodeInfoLite DB: the node is evicted from the hot store.
mockNodeDB->rollHotStore();
TEST_ASSERT_NULL(nodeDB->getMeshNode(kTargetNode)); // gone from the hot store
// Hit is still served - proving it now comes from the TMM overflow cache.
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
}
/**
* Preload must not clobber a freshly-learned (confirmed) hop with a possibly
* stale persisted one from NodeInfoLite.
*/
static void test_tm_nextHop_preloadDoesNotClobberLearned(void)
{
TrafficManagementModuleTestShim module;
// A fresher confirmed hop is already cached.
module.setNextHop(kTargetNode, 0x99);
// The hot DB carries an older next hop for the same node.
mockNodeDB->setHotNode(kTargetNode, 0x42);
module.preloadNextHopsFromNodeDB();
// The freshly-learned hop survives.
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
}
/**
* A pure routing hint (no dedup/rate/unknown state) must survive the maintenance
* sweep - next_hop != 0 keeps the slot alive even though it has no TTL.
*/
static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void)
{
TrafficManagementModuleTestShim module;
module.setNextHop(kTargetNode, 0x42);
// The sweep frees slots whose sub-stores are all empty; next_hop must veto that.
module.runOnce();
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
}
// ---------------------------------------------------------------------------
// Role exceptions: TRACKER and LOST_AND_FOUND
// ---------------------------------------------------------------------------
/**
* Verify TRACKER role caps the dedup window at 1 hour.
* A duplicate position that would normally be blocked for 11 h (default) must
* be forwarded once the 1-hour tracker cap expires.
*/
static void test_tm_trackerRole_capsDedupWindowAtOneHour(void)
{
// Operator interval is 11 h - longer than the tracker cap.
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
installWellKnownPrimaryChannelWithPrecision(16);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(dup); // still within 1-hour cap
// Advance past 1 hour (tracker cap = 3600 s; pos-tick = 360 s → 10 ticks).
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
ProcessMessage r3 = module.handleReceived(afterCap);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify TAK_TRACKER role receives the same 1-hour dedup cap as TRACKER.
* Both roles share the same exception branch; this guards against future divergence.
*/
static void test_tm_takTrackerRole_capsDedupWindowAtOneHour(void)
{
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
installWellKnownPrimaryChannelWithPrecision(16);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TAK_TRACKER);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(dup);
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
ProcessMessage r3 = module.handleReceived(afterCap);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify the tracker role exception survives the node being evicted from BOTH the
* hot and warm NodeDB stores - the TMM unified cache is the third fallback. The
* role is cached on the entry while NodeDB still knows the node; once NodeDB
* forgets it (getNodeRole → CLIENT), the cached role must keep the 1-hour cap
* applied instead of reverting to the 11-hour default interval.
*/
static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void)
{
// Operator interval is 11 h - longer than the tracker cap.
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
installWellKnownPrimaryChannelWithPrecision(16);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
// First packet: NodeDB knows the role; it is cached onto the TMM entry.
ProcessMessage r1 = module.handleReceived(first);
// Node ages out of NodeDB entirely (hot + warm). getNodeRole now returns CLIENT.
mockNodeDB->clearCachedNode();
ProcessMessage r2 = module.handleReceived(dup); // within 1-hour cap - still drop
// Advance past the tracker cap (3600 s) but stay well under the 11-hour default.
// Without the cached-role fallback this would still be inside the 11-hour window
// (CLIENT → no exception) and wrongly drop; with it, the 1-hour cap lets it pass.
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
ProcessMessage r3 = module.handleReceived(afterCap);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify a role change observed via NodeInfo updates the cached role (write-time update),
* so an exception is dropped when a node is demoted from TRACKER back to CLIENT. The role
* is read from the NodeInfo's User payload - the same event that updates NodeDB - not by
* re-scanning NodeDB on the position path.
*/
static void test_tm_roleChange_viaNodeInfo_dropsTrackerException(void)
{
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
installWellKnownPrimaryChannelWithPrecision(16);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
TrafficManagementModuleTestShim module;
// First position seeds the TMM entry with TRACKER (from NodeDB on isNew).
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
// The node demotes to CLIENT and announces it. The NodeInfo refresh must overwrite
// the cached TRACKER role with CLIENT - even though the packet payload, not NodeDB,
// is the source of truth here (NodeDB role left stale on purpose to prove the path).
meshtastic_MeshPacket info = makeNodeInfoPacketWithRole(kRemoteNode, meshtastic_Config_DeviceConfig_Role_CLIENT);
module.handleReceived(info);
// Past the 1-hour tracker cap but within the 11-hour CLIENT interval. With the stale
// TRACKER role this would pass; after the demotion it must drop (full interval applies).
TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1;
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r2 = module.handleReceived(afterCap);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify a cached special (non-CLIENT) role pins the TMM entry through the maintenance
* sweep: once the node's position state has expired and been cleared, the entry - and its
* role - must still survive (role has no TTL), the same way a confirmed next-hop hint does.
*/
static void test_tm_specialRole_pinsEntryThroughSweep(void)
{
// Short position interval → short pos TTL so the sweep clears pos state quickly.
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
TrafficManagementModuleTestShim module;
module.handleReceived(makePositionPacket(kRemoteNode, 374221234, -1220845678));
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode));
// Advance well past the position TTL, then sweep: pos state is cleared but the role
// (no TTL) must keep the entry alive. Without the role pin the entry would be reclaimed
// and peekCachedRole would return -1.
TrafficManagementModule::s_testNowMs += 60UL * 60UL * 1000UL; // 1 h
module.runOnce();
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode));
}
/**
* Verify special-role entries are evicted last. A tracker that is the OLDEST entry must
* survive cache pressure that evicts many newer CLIENT entries, because a cached
* special role marks the entry "preferred" (like a next-hop hint) in victim selection.
*/
static void test_tm_specialRole_evictedLastUnderPressure(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
TrafficManagementModuleTestShim module;
// Oldest entry: a tracker. Seed its role from NodeDB on first position.
const NodeNum tracker = 0xAA0000FF;
mockNodeDB->setCachedNode(tracker);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
module.handleReceived(makePositionPacket(tracker, 100, 200));
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker));
mockNodeDB->clearCachedNode(); // every subsequent filler resolves to CLIENT (unprotected)
// Fill past capacity with newer CLIENT nodes, forcing many evictions. The tracker is
// the stalest entry but is "preferred", so an unprotected client must always be the
// victim instead - the tracker must never be evicted.
for (uint32_t i = 0; i < static_cast<uint32_t>(TRAFFIC_MANAGEMENT_CACHE_SIZE) + 50; i++) {
const NodeNum filler = 0x01000000u + i;
module.handleReceived(makePositionPacket(filler, 300 + static_cast<int>(i), 400 + static_cast<int>(i)));
}
TEST_ASSERT_EQUAL_INT(static_cast<int>(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker));
}
/**
* Verify the tracker cap never lengthens an operator-configured interval shorter
* than the cap default. The cap is a ceiling, not a floor.
*/
static void test_tm_trackerRole_doesNotLengthenShorterOperatorInterval(void)
{
// Operator set 5-minute interval - shorter than the 1-hour tracker cap.
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterShortInterval = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(dup);
// The 300 s operator interval rounds to 1 pos-tick (360 s) - dedup is tick-granular.
// Advance past one full tick to verify the window is 1 tick (not the 10-tick tracker cap).
TrafficManagementModule::s_testNowMs += 360'000UL + 1;
ProcessMessage r3 = module.handleReceived(afterShortInterval);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify LOST_AND_FOUND role caps duplicate-position dedup at ~15 min (2 pos-ticks),
* not the old one-tick fast-announce. A configured 11-hour interval is shortened to the
* 15-min cap; a duplicate one tick later still drops, but one past the 2-tick cap passes.
*/
static void test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes(void)
{
// Long interval that would normally suppress duplicates for 11 h.
moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
installWellKnownPrimaryChannelWithPrecision(16);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND);
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterOneTick = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(dup); // same tick - drop
// One pos-tick later: STILL within the 15-min (~2-tick) cap, unlike the old 1-tick exception.
// (360 s = kPosTimeTickMs, kept as a literal because the constant is private to the module.)
TrafficManagementModule::s_testNowMs += 360'000UL + 1;
ProcessMessage r3 = module.handleReceived(afterOneTick); // still drop
// Jump past the full cap (>= 2 ticks since the last packet): now it passes.
TrafficManagementModule::s_testNowMs += 2 * 360'000UL;
ProcessMessage r4 = module.handleReceived(afterCap);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r3));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r4));
TEST_ASSERT_EQUAL_UINT32(2, stats.position_dedup_drops);
}
/**
* Verify a node with unknown role (absent from NodeDB) is not granted any role
* exception: the full configured interval applies, so a duplicate inside that
* window is dropped exactly as for an ordinary CLIENT.
*/
static void test_tm_unknownRole_noDbEntry_appliesFullInterval(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
// No cached node - getMeshNode returns nullptr for kRemoteNode.
mockNodeDB->clearCachedNode();
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket afterShort = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(dup); // within 300-s window - must drop
// The 300 s operator interval rounds to 1 pos-tick (360 s) - dedup is tick-granular.
// Advance past one full tick to confirm the packet passes without any tracker exception.
TrafficManagementModule::s_testNowMs += 360'000UL + 1;
ProcessMessage r3 = module.handleReceived(afterShort);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify a node present in NodeDB but without user info (HAS_USER bit clear)
* is not granted a role exception: it falls back to CLIENT and the full
* configured interval applies.
*/
static void test_tm_unknownRole_noUserBit_appliesFullInterval(void)
{
moduleConfig.traffic_management.position_min_interval_secs = 300;
installWellKnownPrimaryChannelWithPrecision(16);
// Node is in NodeDB but the HAS_USER bit is NOT set - role must be ignored.
mockNodeDB->setCachedNode(kRemoteNode);
// Clear the HAS_USER bit that setCachedNode sets, leaving role at CLIENT default.
mockNodeDB->cachedNodeForTest().bitfield &= ~NODEINFO_BITFIELD_HAS_USER_MASK;
mockNodeDB->cachedNodeForTest().role = meshtastic_Config_DeviceConfig_Role_TRACKER;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(dup); // within 300-s window - must drop
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops);
}
/**
* Verify a LOST_AND_FOUND origin now GETS the relayed precision clamp - the
* anti-dox exemption was removed, so a relayed position more precise than the
* channel setting is clamped down to the channel ceiling like any other node's.
*/
static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void)
{
// Set channel precision ceiling to 13 bits. Must be <= MAX_POSITION_PRECISION_PUBLIC_KEY
// (15) - well-known channels have a public PSK (size==1), so getPositionPrecisionForChannel
// clamps any value above 15 via usesPublicKey().
installWellKnownPrimaryChannelWithPrecision(13);
mockNodeDB->setCachedNode(kRemoteNode);
mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND);
TrafficManagementModuleTestShim module;
// Full-precision packet - 32 bits - exceeds the channel cap.
const uint32_t fullPrecision = 32;
meshtastic_MeshPacket packet = makePositionPacketWithPrecision(kRemoteNode, 374221234, -1220845678, fullPrecision);
packet.hop_start = 3;
packet.hop_limit = 2; // relayed (hop_limit < hop_start) so clamp logic applies
module.alterReceived(packet);
meshtastic_Position out;
TEST_ASSERT_TRUE(decodePositionPayload(packet, out));
// Clamped to channel ceiling (13 bits) - lost-and-found is no longer exempt.
// Note: precision must be <= MAX_POSITION_PRECISION_PUBLIC_KEY (15); well-known
// channels always have a public PSK so getPositionPrecisionForChannel caps at 15.
TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits);
}
// ---------------------------------------------------------------------------
// Fuzz - crafted-nodenum blitz of the unified cache
// ---------------------------------------------------------------------------
// Floods handleReceived/alterReceived with crafted packets over a tiny node pool so the fixed-size
// cache churns hard while the virtual clock sweeps the rate/unknown/position windows; no crash, counters bounded.
static constexpr uint64_t FUZZ_SEED = 0x00D07E5701ULL;
static void test_tm_fuzz_nodenum_blitz(void)
{
printf(" seed=0x%llx\n", (unsigned long long)FUZZ_SEED);
rngSeed(FUZZ_SEED);
// Activate the cache-tracked windows (the crafted-nodenum target). The nodeinfo direct-response
// path (nodeinfo_direct_response_max_hops) is left OFF: it calls service->sendToMesh, and driving
// that at fuzz volume needs a fully-wired MeshService/phone queue this fixture doesn't provide - the
// deterministic test_tm_nodeinfo_directResponse_* tests cover it with single packets instead.
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 3;
moduleConfig.traffic_management.unknown_packet_threshold = 4;
moduleConfig.traffic_management.position_min_interval_secs = 300;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
installWellKnownPrimaryChannel();
static TrafficManagementModuleTestShim module; // static: OSThread-derived (see note in test_fuzz_packets E2)
// Shared boundary pool (0/1/broadcast) plus this suite's well-known nodes.
const NodeNum wellKnown[] = {kLocalNode, kRemoteNode, kTargetNode};
const size_t wellKnownN = sizeof(wellKnown) / sizeof(wellKnown[0]);
const meshtastic_PortNum ports[] = {
meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_POSITION_APP,
meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_ADMIN_APP, meshtastic_PortNum_TELEMETRY_APP,
};
const size_t portsN = sizeof(ports) / sizeof(ports[0]);
const unsigned ITERS = 30000;
for (unsigned k = 0; k < ITERS; k++) {
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = (rngRange(8) == 0) ? (NodeNum)rngNext() : rngEdgeNodeNum(wellKnown, wellKnownN);
p.to = rngEdgeNodeNum(wellKnown, wellKnownN);
p.id = rngNext();
p.channel = 0;
p.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded
p.hop_limit = (uint8_t)rngRange(8);
p.decoded.want_response = (rngRange(2) == 0);
if (rngRange(5) == 0) {
// Undecoded / unknown packet - exercises the unknown-packet threshold path.
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1);
rngFill(p.encrypted.bytes, p.encrypted.size);
} else {
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = (rngRange(8) == 0) ? (meshtastic_PortNum)rngRange(80) : ports[rngRange(portsN)];
p.decoded.has_bitfield = true;
p.decoded.bitfield = (uint32_t)rngNext();
if (p.decoded.portnum == meshtastic_PortNum_POSITION_APP && rngRange(2)) {
meshtastic_Position pos = meshtastic_Position_init_zero;
pos.has_latitude_i = true;
pos.has_longitude_i = true;
pos.latitude_i = (int32_t)rngNext();
pos.longitude_i = (int32_t)rngNext();
pos.precision_bits = rngRange(40); // includes >32 (the default-precision fallback)
p.decoded.payload.size =
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
} else {
// Random payload bytes: TMM's nested User/Position decode must fail cleanly.
p.decoded.payload.size = rngRange(sizeof(p.decoded.payload.bytes) + 1);
rngFill(p.decoded.payload.bytes, p.decoded.payload.size);
}
}
(void)module.handleReceived(p);
if (rngRange(3) == 0)
module.alterReceived(p);
// Advance the virtual clock so rate / unknown / position windows open and close under churn.
if (rngRange(16) == 0)
TrafficManagementModule::s_testNowMs += (rngRange(120) + 1) * 1000u;
if (rngRange(1024) == 0)
(void)module.runOnce(); // maintenance sweep: cache aging / eviction
}
// The cache never inspected more packets than we fed, and the run reached here without an ASan fault.
TEST_ASSERT_TRUE_MESSAGE(module.getStats().packets_inspected <= ITERS, "packets_inspected overcounted");
}
} // namespace
void setUp(void)
{
resetTrafficConfig();
}
void tearDown(void)
{
// Runs even when a TEST_ASSERT_* aborts a test mid-way (Unity longjmps out, skipping any
// cleanup the test itself does after the assertion), so per-test global state can never
// dangle into later cases. The dangling-pointer path is concrete: a test points the global
// at its stack `module`, and the next setUp()'s resetNodes() would then call
// trafficManagementModule->purgeAll() on a destroyed object.
trafficManagementModule = nullptr;
owner.public_key.size = 0;
memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes));
// Neutralize the RTC fake clock a fallback test may have set (the virtual s_testNowMs is
// already reset by setUp via resetTrafficConfig).
setBootRelativeTimeForUnitTest(0);
}
TM_TEST_ENTRY void setup()
{
delay(10);
delay(2000);
initializeTestEnvironment();
mockNodeDB = new MockNodeDB();
nodeDB = mockNodeDB;
UNITY_BEGIN();
RUN_TEST(test_tm_moduleDisabled_doesNothing);
RUN_TEST(test_tm_unknownPackets_dropOnNPlusOne);
RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow);
RUN_TEST(test_tm_positionDedup_allowsMovedPosition);
RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold);
RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters);
RUN_TEST(test_tm_localDestination_bypassesTransitFilters);
RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops);
RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache);
RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo);
RUN_TEST(test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity);
RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect);
RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips);
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed);
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed);
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow);
RUN_TEST(test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor);
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed);
RUN_TEST(test_tm_nodeinfo_directResponse_fallbackManuallyVerifiedServed);
#endif
#if TMM_HAS_NODEINFO_CACHE
RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield);
RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb);
RUN_TEST(test_tm_nodeinfo_directResponse_psramStaleEntryNotServed);
RUN_TEST(test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow);
#if !(MESHTASTIC_EXCLUDE_PKI)
RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey);
#if WARM_NODE_COUNT > 0
RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey);
RUN_TEST(test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery);
RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes);
RUN_TEST(test_tm_nodeinfo_reconcile_seedsManualVerificationFromHotStore);
RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier);
RUN_TEST(test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity);
RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough);
RUN_TEST(test_tm_nodeinfo_removeNode_purgesCaches);
RUN_TEST(test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives);
#endif
RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey);
RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToXeddsaSigned);
RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse);
RUN_TEST(test_tm_nodeinfo_copyUser_returnsCachedIdentity);
#if TMM_NODEINFO_REPLAY_SIGNED_GATE
RUN_TEST(test_tm_nodeinfo_directResponse_psramUnsignedNotServed);
#endif
#endif
RUN_TEST(test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance);
RUN_TEST(test_tm_nodeinfo_tickSaturation_sweepClearsObserved);
RUN_TEST(test_tm_nodeinfo_reconcileMembershipMarking);
RUN_TEST(test_tm_nodeinfo_cache_normalizesSpoofedUserId);
RUN_TEST(test_tm_nodeinfo_hooks_noopWhileModuleDisabled);
RUN_TEST(test_tm_nodeinfo_identityHook_keySemantics);
RUN_TEST(test_tm_nodeinfo_reads_gatedWhileModuleDisabled);
#if !(MESHTASTIC_EXCLUDE_PKI)
RUN_TEST(test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless);
RUN_TEST(test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember);
RUN_TEST(test_tm_nodeinfo_eviction_withinTierStalestLoses);
RUN_TEST(test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts);
RUN_TEST(test_tm_nodeinfo_resetNodes_purgesAllCaches);
RUN_TEST(test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey);
#endif
#endif
RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged);
RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast);
RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires);
RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops);
RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision);
RUN_TEST(test_tm_positionDedup_distinctAtClampedChannelPrecision);
RUN_TEST(test_tm_positionDedup_precisionZero_channelFallsBackToDefault);
RUN_TEST(test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush);
RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero);
RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires);
RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires);
RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps);
RUN_TEST(test_tm_alterReceived_positionBroadcast_hopLimitUnchanged);
RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets);
RUN_TEST(test_tm_alterReceived_exhaustFlagAlwaysFalse);
RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped);
RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval);
RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval);
RUN_TEST(test_tm_nextHop_setAndGetRoundTrip);
RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll);
RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned);
RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep);
RUN_TEST(test_tm_trackerRole_capsDedupWindowAtOneHour);
RUN_TEST(test_tm_takTrackerRole_capsDedupWindowAtOneHour);
RUN_TEST(test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole);
RUN_TEST(test_tm_roleChange_viaNodeInfo_dropsTrackerException);
RUN_TEST(test_tm_specialRole_pinsEntryThroughSweep);
RUN_TEST(test_tm_specialRole_evictedLastUnderPressure);
RUN_TEST(test_tm_trackerRole_doesNotLengthenShorterOperatorInterval);
RUN_TEST(test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes);
RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp);
RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval);
RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval);
RUN_TEST(test_tm_fuzz_nodenum_blitz);
exit(UNITY_END());
}
TM_TEST_ENTRY void loop() {}
#else
void setUp(void) {}
void tearDown(void) {}
TM_TEST_ENTRY void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
TM_TEST_ENTRY void loop() {}
#endif