* Add explicit presence for MeshPacket.rx_time (arrival time) rx_time is now proto3 optional with a has_rx_time presence bit, matching the rx_rssi treatment. A node with no GPS and no phone connected yet has no time source at all, so a bare 0 was indistinguishable from a genuine 1970-01-01 reading; downstream consumers (replay packets, JSON serialization) now check has_rx_time instead of the value. * Dedupe rx_time stamping into a shared helper; trim a debug log string Extract the repeated haveTime/rx_time/has_rx_time stamp logic (5 call sites across Router.cpp, MeshBeaconModule.cpp, MeshService.cpp) into Router::computeRxTimeStamp()/stampRxTime(). Also shorten the new RTC.cpp LOG_DEBUG string. Saves 48 bytes of flash on rak4631 (measured), no behavior change. * Fix has_rx_rssi presence carried unconditionally through StoreForward replay preparePayload() set has_rx_rssi = true unconditionally on replay, regardless of whether the packet's rx_rssi at store time was a genuine measurement (e.g. MQTT-relayed packets carry no real RSSI). Store the presence bit alongside rx_rssi in PacketHistoryStruct and restore it on replay instead. Flagged by Copilot on #11271 (same root cause the has_rx_time explicit presence work fixes) but never addressed before that PR merged. * Trim comment blocks to the repo's 1-2 line guideline .github/copilot-instructions.md:338 caps code comments at 1-2 lines; several blocks added across the rx_time explicit-presence work ran well past that. Also consolidates Time.cpp's file-level doc comment into Time.h, where the rest of the Time:: API contract already lives. No behavior change. * Add rx_time explicit-presence test coverage - test_meshpacket_serializer: has_rx_time=false fixture plus tests asserting JsonSerialize/JsonSerializeEncrypted emit 0 rather than leaking the millis() placeholder, alongside the has_rx_time=true baseline. - test_stream_api: two tests driving a real PhoneAPI handshake (want_config_id through STATE_SEND_PACKETS) that simulate a phone time-giving transaction arriving before vs. after a queued packet is drained - covering both the reconciled and the ships-with-placeholder-absent paths of MeshService::reconcilePendingRxTimes(). * Fix three correctness issues flagged in review - Time.h: drop the reserved-identifier include guard (_MT_TIME_H); pragma once already covers it, matching convention elsewhere (e.g. RTC.h). - Time.cpp: rebase getMillis64()'s wrap accumulator when the test seam swaps clock sources, so a real<->injected clock jump isn't miscounted as a genuine 32-bit wrap. - NodeInfoModule: the 12h reply-suppression window is a local dedup duration, not a wall-clock reading - switch it to Time::getMillis64() so RTC-quality jumps and replayed packets' stale rx_time can't perturb it. - StoreForwardModule: has_rx_time was derived from *current* RTC quality at replay time rather than stored at capture time, so a history entry saved while time-blind could be misreported as a valid epoch once the clock later improved. Persist the presence bit in PacketHistoryStruct instead. * tryfix CI * post review fixes * more test fixes
3320 lines
149 KiB
C++
3320 lines
149 KiB
C++
#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 signer bit) 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 signer)
|
||
{
|
||
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 (signer)
|
||
info.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_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::markKeySignerProvenForTest;
|
||
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 signer-proven provenance to serve.
|
||
module.markKeySignerProvenForTest(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);
|
||
// Signer-proven so staleness is the sole reason it is not served (isolates the gate under test).
|
||
module.markKeySignerProvenForTest(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 signer-proven provenance to serve.
|
||
module.markKeySignerProvenForTest(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 signer-proven provenance to serve the reply.
|
||
module.markKeySignerProvenForTest(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 signer 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, /*signer=*/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();
|
||
}
|
||
|
||
/**
|
||
* Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by
|
||
* the maintenance sweep (name + key + signer 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, /*signer=*/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 signer 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 seeding from the warm tier yields a key-only record: usable by copyPublicKey
|
||
* (with the warm signer 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, /*signer=*/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 signer 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.markKeySignerProvenForTest(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. signerProven 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
|
||
* signer-proven (monotonic), while the key bytes stay pinned.
|
||
*/
|
||
static void test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven(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 signer-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 signer-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
|
||
|
||
// Bit positions returned by peekNodeInfoFlagsForTest().
|
||
constexpr int kFlagObserved = 1;
|
||
constexpr int kFlagMember = 2;
|
||
constexpr int kFlagFullUser = 4;
|
||
|
||
/**
|
||
* 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.markKeySignerProvenForTest(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, /*signer=*/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 signer-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.markKeySignerProvenForTest(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 signer-proven stranger and a NodeDB member survive
|
||
* (keyless < TOFU < signer-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.markKeySignerProvenForTest(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
|
||
// Signer-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();
|
||
}
|
||
|
||
/**
|
||
* 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, signer-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.markKeySignerProvenForTest(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);
|
||
#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_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_upgradesToSignerProven);
|
||
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
|