Files
firmware/test/test_traffic_management/test_main.cpp
Tom e26b6bfc5a TMM & Warmstore key source naming (#11119)
* one becomes two

* warmstore clarify

* Address PR review: key-provenance terminology consistency

- Log line now says "not key-proven" (gate is XEdDSA OR manual, not just signer)
- Rename markKeySignerProvenForTest -> markKeyXeddsaSignedForTest (sets only the XEdDSA bit)
- Docs + test comments: "signer bit" -> "XEdDSA-signed bit"

clod helped too

* Rename signer-proven -> key-proven for broadened provenance predicate

Address PR #11119 review: the copyPublicKey()/copyUser() out-parameter and
the cache-path replay gate now report entry->keyProven() (XEdDSA-signed OR
manually verified), so the "signerProven" name and "signer-proven" comments
were misleading. Rename the public out-param to keyProven, the local
cachedKeySignerProven to cachedKeyProven, and update coupled callers, log
strings, docs headings, and comments to say "key-proven".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* nitpicks

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:39:39 +00:00

3405 lines
153 KiB
C++
Raw Permalink 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;
// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks
// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global
// airTime reporting 100% channel utilization for the enclosing scope.
class ScopedBusyAirTime
{
public:
ScopedBusyAirTime() : previous(airTime)
{
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++)
busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period
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; // congestion present but exhaust is disabled
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