Files
meshtastic_firmware/test/test_traffic_management/test_main.cpp
T
Tom f5314148c2 Serialise AirTime behind a lock, and stop handing out its buckets (#11362)
* Copy airtime reports into a caller buffer instead of exposing the array

airtimeReport() returned a pointer into the rotating bucket arrays, so the
caller held a handle to state that logAirtime() and every accessor mutate
underneath it. Copy into a caller-supplied buffer instead, and report failure
for a null buffer, a count past the log depth, or an unknown report type.

ContentHandler owns its buffer and hoists getPeriodsToLog() out of the three
calls that repeated it.

* Cover the AirTime report API and log-dispatch contract

Half of AirTime's surface had no tests: which store each report type feeds,
what airtimeReport() does when misused, how the first sync seeds itself, and
whether calling several entry points in one interval compounds the rotation.

Eighteen tests, asserted through the public API rather than the public bucket
arrays - those arrays are meant to become private, and a test that reads them
would have to be rewritten rather than pinning a contract.

Two of them state a convention that was never written down: the report arrays
are shift-ordered with slot 0 newest, and slot 0 covers only the time since the
last rotation. channelUtilization and utilizationTX use the opposite convention
- a modular ring indexed by uptime phase - and reading one as if it were the
other is a defect that has already happened once.

* Characterise AirTime window decay, TX gates, and sleep behaviour

Thirty-three tests in three kinds. Invariants must hold forever; boundaries pin
off-by-ones a refactor would move; five characterisations encode today's wrong
numbers, each tagged with the phase that will flip it.

Readings are asserted against an event-log oracle - airtime physically on air
inside (now - window, now], computed from a list of completed packets - rather
than against hand-worked constants, so a test states "this matches the
definition" instead of "this looked right when I wrote it".

The characterisations, all measured rather than assumed:
  - the window covers (N-1)p + phase but divides by Np, so a steady 10% load
    reads 8.33% right after a bucket boundary                     -> phase 5
  - the same load sweeps across bucket phase instead of holding    -> phase 5
  - the hour window carries the same defect, 10x smaller           -> phase 5
  - a packet longer than its bucket is credited whole to the bucket
    it completed in, so a saturated LONG_SLOW channel reads >100%  -> phase 4b
  - getSilentMinutes() reads a modular ring as if the index were an
    age, so identical airtime gives different answers by phase     -> phase 6

Two tests needed correcting during the write, both my expectations rather than
the code: a six-bucket ring sheds whole buckets, so a 30s gap drops three of
five survivors and not "half"; and the oracle sees 59 completions in a 60s
window, not 60, because the one on the lower edge is outside it.

Not written: the planned RX_LOG/RX_ALL_LOG disjointness test. That is a
property of the two radio drivers, which choose one or the other per packet -
it is not observable from AirTime, which records what it is told. The
AirTime-side half is already covered by the routing tests.

* Drop write-only and undefined AirTime members

None of this was reachable:

  air_period_tx / air_period_rx   file-scope mirrors of airtimes.periodTX/RX,
                                  accumulated, rotated and memset in lockstep
                                  with them but never read out or serialised.
                                  Orphaned when #2552 re-pointed the writes at
                                  bare globals instead of deleting them.
  lastUtilPeriod, lastUtilPeriodTX  written on every sync, read nowhere
  airtimes.lastPeriodIndex        written on every rotation, read nowhere
  currentPeriodIndex()            computes (secs / 3600) % 8 - a modular-ring
                                  index for the one array that is shift-ordered
                                  rather than a ring. Its only two uses were the
                                  dead field above and a log line. It is the
                                  fossil of the same confusion that makes
                                  getSilentMinutes() wrong.
  UtilizationPercentTX()          declared, never defined
  free logAirtime()/airtimeReport()  declared, never defined; the latter still
                                  carried the array-returning signature the
                                  previous commit removed, so it actively misled

Also fixes the rotation log line, which read currentPeriodIndex() from inside
the loop although the index is advanced before it - on a multi-hour wake it
printed the same final value once per rotation. It now reports which of the
crossed hours is being rotated.

airtimeRotatePeriod() is kept: it has no caller in the tree either, but unlike
the above it is a defined public method, so out-of-tree callers are plausible.

Measured, not estimated: sizeof(AirTime) 464 -> 456 B, plus 64 B of globals, so
-72 B of static RAM. Padding accounts for the difference from the 66 B the plan
predicted by counting declared bytes.

The whole point of writing the tests first: the suite is green here with zero
test changes.

* Document what the AirTime figures measure and how they are stored

Comments only, but four of the things they replace were false.

The header's example analytics claimed RX_ALL_LOG was "all received lora
packets" and offered "RX_ALL_LOG - RX_LOG = other lora radios". Both radio
drivers pick exactly one of the two per packet, so they are disjoint: RX_ALL_LOG
is airtime we could not parse, the subtraction can go negative, and the total is
TX + RX + RX_ALL. Replaced with the actual contract - four inputs, eight
outputs, the window each spans, and the fact that the three thresholds are
hard-coded members rather than the settings they look like.

Names the two storage conventions on their declarations, because mixing them up
is what makes getSilentMinutes() wrong: channelUtilization and utilizationTX are
modular rings indexed by uptime phase, where the oldest bucket is (current + 1)
% N; airtimes.period* is shift-ordered with slot 0 newest, where the index IS an
age and slot 0 is a partial hour.

Defines the measurement as wall time rather than awake time, and says why: a
sleeping node still hears traffic, and per-node redefinition would make two
broadcast readings incomparable. Records that the 60s figure is published to the
mesh at >= 1h cadence, so what other nodes see is a snapshot - at LONG_FAST and
1% occupancy it reads exactly 0 in about 44% of reports - and that the contention
window it feeds moves in 20-percentage-point steps, so small errors never reach
the backoff.

Finally, states that rotation happens on access rather than on the scheduler
tick, names the test that enforces it, and leaves a TODO pointing at the plan
phases that fix the characterised accuracy defects.

* Serialise AirTime behind a lock proven by a private token

Two mechanisms solving different halves. A lock-free inner core (Windows) holds
all state and all logic; it has no lock and no way to reach one, so nesting is
impossible by construction. A private Held token takes the lock in its own
constructor and is the only thing that can be passed where a core method demands
one, so the lock cannot be forgotten either.

The rule is now uniform with no exceptions to remember: every public method
takes the lock once and delegates. In particular isTxAllowed*() lock like
everything else - before the split they could not, because they called the
public accessors and the lock is not recursive. That asymmetry was the foot-gun
the previous design documented in prose and hoped nobody would trip.
getPeriodsToLog()/getSecondsPerPeriod() still take no lock; they return
compile-time constants and touch no state.

channelUtilization[] and utilizationTX[] were public, so the lock was bypassable
at compile time. They move into the private core. Four test sites reached in;
all four now use logAirtime() plus the virtual clock, and no new test seam was
needed. Nothing in src/ was affected.

The re-entry assert is guarded on PIO_UNIT_TESTING, so it exists in test builds
only. The design sketched #ifdef DEBUG, but nothing in this tree defines DEBUG
or NDEBUG, so either spelling ships the assert to every board - and
nrf52_promicro_diy_tcxo has ~128 bytes of headroom under its 0xEA000 warm-store
cap, which the assert's strings and abort path overrun. It would have worked on
hardware, since the check runs in Held's owner initialiser and so precedes the
blocking take; the objection is that abort()ing a live mesh node is a poor trade
for a bug never seen in the field. Native tests are where it earns its keep
anyway: Portduino compiles Lock::lock() to an empty body, so a nested take there
succeeds silently and nothing else would notice.

Also comments out ScopedBusyAirTime in test_traffic_management. It is inert
twice over: the module holds no reference to airTime at all since hop exhaustion
was shelved, and the fixture never worked anyway - writing the buckets on a
fresh AirTime is undone by the first accessor call, which takes the firstTime
branch and memsets them. It reported 0%, not the 100% it claimed. Left in place,
commented, with both reasons recorded.

Cost on the tightest board in the tree, nrf52_promicro_diy_tcxo: the six phases
together add 96 bytes of flash, leaving it 32 bytes clear of the warm-store
guard. RAM is 72 bytes lower from the dead-state removal. Suite green at 47/47,
with test_airtime unedited apart from the added nesting test.

* Count rotations with the loop variable, not a separate tally

LOG_DEBUG compiles to nothing under DEBUG_MUTE, so the counter's only read
disappeared with it and the tally became write-only. It does not warn today -
this build has -Wunused-but-set-variable on, and it fires for other locals, but
not for one that is only initialised and never read - so it was latent rather
than broken: a stricter flag or -Werror would have failed muted builds only.

Using the loop variable removes the class of problem, since the loop condition
reads it, and drops the elapsedAirtimePeriods-- mutation as a side benefit.
Same iteration count, same output.

Found by compiling nrf52_promicro_diy_tcxo with -D DEBUG_MUTE, which is worth
recording for its own sake: muting logs takes that image from 802 784 to
673 416 bytes, 98.5% to 82.6% of flash. Logging is 16% of the largest nrf52
image, and its 32 bytes of warm-store headroom are a logging-verbosity question
rather than a code-size one.

* Tighten the comments added by this branch

Comment-only: with comments stripped, all five files are byte-identical to the
previous commit.

Removed the references to the planning notes. Those documents are working
material and will go stale; the code should not depend on them. The five
CHARACTERISATION tags now describe the defect they pin and stop there, and the
accuracy TODO names the four defects and points at the tests instead of a plan
file.

Also removed, as noise rather than information:
  - comparisons against pre-#11291 behaviour, which nobody reading this needs
  - a comment describing the lock restructure as future work, written before it
    landed
  - speculation ("plausible", "worth pinning so a future...")
  - an aside arguing with an arithmetic slip made while writing the test

Kept the mechanical facts that are slow to re-derive: the two storage orderings
and which array uses which, RX_LOG/RX_ALL_LOG disjointness, the locking rule and
the addSpanned() constraint that protects it, why the re-entry assert is
test-only, and the concrete numbers - (N-1)p + phase, 14 164 ms, the 20 pp
contention-window steps.

Net 16 comment lines out of src/, 33 out of test/.

* Gate the AirTime re-entry check on the host, not on testing

PIO_UNIT_TESTING is injected by PlatformIO purely on BUILD_TYPE, with no
platform check, so it is defined on an on-target `pio test` run too. The
check arms before the lock is taken - a nested take blocks forever, so a
later check would never run - which under preemption false-positives on
legitimate contention and races on its own write.

Derive AIRTIME_REENTRY_CHECK once from PIO_UNIT_TESTING && !HAS_FREE_RTOS
and use it at all three sites. Had the three conditions ever diverged, an
on-target test build would fail to compile on a member the header no
longer declares.

* Log AirTime outside the lock it serialises

DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary
semaphore with no priority inheritance, so holding it across a log call
lets the main thread stall the radio thread in getTxDelayMsec().

Move logAirtime()'s LOG_DEBUG into the shell, after the Held scope
closes; the shell already has both arguments, so nothing has to be
passed back out of the core. isTxAllowed{ChannelUtil,AirUtil} read into
a local under the lock and warn after it. The log bodies are braced
because LOG_DEBUG compiles away under DEBUG_MUTE and a bare `if (x) ;`
trips -Wempty-body.

Fold the two doubled index calls into `+=` while touching the lines.

* Give each airtime report its own buffer

handleReport() reused one array across the three airtimeReport() calls
and ignored the bool. A failed report would have left the previous
type's data in place and emitted it under the next type's key. Build
each through a lambda whose buffer is zeroed per call, so a failure
emits zeros.

Unreachable today - the count is always PERIODS_TO_LOG and the type is
always valid - but the old shape only read as correct by accident.

* Drop a stray semicolon from the inert-guard comment

* Address external review: name the race, tighten the claims and the tests

The header sold the lock as mechanism without naming a second thread, which
invites the reasonable objection that this is a cooperative OSThread codebase.
There is a real race and it is nRF52-only: NRF52Bluetooth registers its ToRadio
write callback with defer == false, so a phone's packet runs handleToRadio ->
sendToMesh -> Router::send on the Bluefruit BLE task, reading
utilizationTXPercent() and getSilentMinutes() while loopTask may be inside
logAirtime(). ESP32 hands BLE work to the main task and does not have it.

Three claims in the header were wrong or overstated:

  - "nesting is impossible by construction" - Windows is a nested class with an
    enclosing class's access rights, and `extern AirTime *airTime` is in the
    same header, so airTime->anyPublicMethod() from inside it is well-formed
    and would hang. Nothing does it; the assert is the backstop. Say that
    instead, because the comment below instructs contributors to add helpers
    to Windows on the strength of the guarantee.
  - "every public method takes the lock exactly once" - two constant accessors
    take none and isTxAllowedAirUtil() takes it zero or one times. State the
    exceptions where the invariant is stated, not only at the definitions.
  - "both radio drivers pick exactly one per packet" - five drop paths log
    neither. At most one. Recorded against plan4 rather than fixed here: it
    changes a telemetry value.

getPeriodsToLog()/getSecondsPerPeriod() become static constexpr, which removes
them from the locking claim structurally and lets ContentHandler size its
buffer and its count from one constant.

Tests:

  - C14's saturated AirTime is installed by a helper and restored in tearDown.
    Unity's TEST_ABORT() is longjmp and does not run destructors of automatic
    objects, so the scoped guard it replaces would leave airTime dangling into
    an abandoned frame on any assertion failure - and the same commit that
    added it removed the tearDown reset that did cover that.
  - test_getSilentMinutes_counts_minutes_until_enough_ages_out asserted only
    `mins <= 60`, which neither return path can violate. The answer is 59.
  - test_backwards_uptime_degrades_safely stepped 600s -> 60s, which leaves
    elapsedAirtimePeriods at 0, so it never reached the hourly-report branch
    its own comment describes. Step by the wrap instead and assert the exact
    figures.
  - test_airtime leaked EU_868 out of the duty-cycle case into every later one,
    and the reentry test's isTxAllowedAirUtil() coverage depended on it.
    Restore the region in tearDown and set it explicitly where it is wanted.
  - Rename that test to what it can actually check: no single method takes the
    lock twice. The calls are sequential, so it cannot catch two methods
    nesting.

* trunk: suppress trufflehog/Lob false positives in test_airtime

* Address CodeRabbit review: the rotate trace, the cap warn, the backoff

Four findings from the CodeRabbit pass. Two were introduced by this branch,
one is a real inconsistency it inherited, one is a naming slip.

The rotate trace was the one that mattered. "Log AirTime outside the lock it
serialises" moved the per-packet lines and the two TX-gate warnings out to the
shell, but missed LOG_DEBUG("Rotate airtimes, crossed hour %u") because it does
not sit in the shell at all: it is inside Windows::syncNow(), the lock-free
core, which by construction only ever runs under Held. Nothing at that line
looks like a lock, which is why it survived.

The exposure is smaller than the review suggests - runOnce() syncs at 1 Hz, so
in steady state this is one line an hour, and the PERIODS_TO_LOG - 1 burst
needs an hour of light sleep with no intervening sync - but a UART write under
a plain binary semaphore with no priority inheritance is exactly what the
comment above logAirtime() says this code does not do. syncNow() now
accumulates crossings in rotationsPendingLog and runOnce() drains it inside the
Held scope, then logs after release. Any caller can cross an hour; only that
thread reports it, so a crossing raised elsewhere is traced at most one tick
late. The `if (rotations > 0)` guard keeps the drained value read under
DEBUG_MUTE, where LOG_DEBUG expands to nothing - the write-only tally that
"Count rotations with the loop variable" removed.

addFromContact()'s favorite fallback stamped silently when the protected cap
refused it. The stamp is new on this branch; the two sibling refusals (ignore,
verify) both emit PROTECTED_CAP_WARN_FMT, so the operator lost the only signal
that the cap was hit on the one path that has a fallback.

lfs_assert() mixed clocks: Throttle read Time::getMillis(), the remainder was
computed from a second, bare millis(). The review's stated failure mode - a
native test overriding the clock - cannot happen, since the hook is behind
PIO_UNIT_TESTING and this file is nRF52-only. The real defect is the second
read: a tick landing on the 20-minute boundary between the check and the
subtraction underflows the remainder into delay(~50 days), on a device that has
just found its flash corrupt. One read, clamped, and preFSBegin() stores from
the same clock.

The eviction test is renamed to
test_eviction_prefersCurrentBootStampOverPost2038Epoch. The finding is right
that it was snake_case, but the suggested testEvictionPrefers... does not match
this file either, which is test_<area>_<camelCase> throughout.

Not taken, both pre-existing and out of scope for a rollover branch:

  - t5s3_epaper's touchResumeAtMs/suppressFromMs read an active suppression as
    inactive if the wake lands in the 1 ms where millis() is 0. Consequence is
    one skipped 150 ms touch-settle window per 49.7-day wrap.
  - NRF52Bluetooth::onPairingPasskey() busy-waits 30 s in a BLE callback. Worth
    saying plainly that this branch makes it more visible: the old
    `millis() < start_time + 30000` overflowed at the wrap and cut the wait
    short, so the correct Throttle form is what lets it run the full 30 s.
    Reworking it into an OSThread is its own change.

Native suite GREEN, 48/48, 672 cases.
2026-08-13 13:12:14 -04:00

3407 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 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;
channelFile = meshtastic_ChannelFile_init_zero;
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