#include "MeshTypes.h" // Include BEFORE TestUtil.h - provides HAS_TRAFFIC_MANAGEMENT (via mesh-pb-constants.h) #include "TestUtil.h" #include #include #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 #include #include #include #include namespace { constexpr NodeNum kLocalNode = 0x11111111; constexpr NodeNum kRemoteNode = 0x22222222; constexpr NodeNum kTargetNode = 0x33333333; // A second, distinct requester. The per-requester direct-response throttle (60 s) is keyed on the // requesting packet's `from`, so tests that exercise the per-target / fallback / sweep throttles use // a fresh requester for their "served again" step to avoid the per-requester window masking them. constexpr NodeNum kRemoteNode2 = 0x44444444; // Telemetry hop exhaustion is gated on channel congestion (alterReceived checks // airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global // airTime reporting 100% channel utilization for the enclosing scope. class ScopedBusyAirTime { public: ScopedBusyAirTime() : previous(airTime) { for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period airTime = &busy; } ~ScopedBusyAirTime() { airTime = previous; } private: AirTime busy; AirTime *previous; }; class MockNodeDB : public NodeDB { public: meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override { if (hasCachedNode && n == cachedNodeNum) return &cachedNode; return NodeDB::getMeshNode(n); } void clearCachedNode() { hasCachedNode = false; cachedNodeNum = 0; cachedNode = meshtastic_NodeInfoLite_init_zero; } void setCachedNode(NodeNum n) { clearCachedNode(); hasCachedNode = true; cachedNodeNum = n; cachedNode.num = n; cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; } // Role the TMM should see for the cached node (sender-role-aware throttles). void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; } // Direct mutable access to the cached node for fine-grained bitfield manipulation in tests. meshtastic_NodeInfoLite &cachedNodeForTest() { hasCachedNode = true; return cachedNode; } // Seed a node into the hot-store buffer at index 1 (index 0 is reserved for // "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of // MAX_NUM_NODES slots with `numMeshNodes` as the logical count - we grow the // buffer if needed and bump the count, never clear()/push_back() (which would // shrink it and break NodeDB::resetNodes()'s begin()+1..end() fill). void setHotNode(NodeNum n, uint8_t nextHop) { if (meshNodes->size() < 2) meshNodes->resize(2); (*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero; (*meshNodes)[1].num = n; (*meshNodes)[1].next_hop = nextHop; numMeshNodes = 2; } // Seed a full identity (name, 32-byte key of `keyByte`, optional XEdDSA-signed and/or // manually-verified provenance bits) into the hot-store buffer at index 1, for // reconcile/seeding tests that iterate getMeshNodeByIndex(). void setHotNodeIdentity(NodeNum n, const char *longName, uint8_t keyByte, bool xeddsaSigned, bool manuallyVerified = false) { setHotNode(n, 0); meshtastic_NodeInfoLite &info = (*meshNodes)[1]; strncpy(info.long_name, longName, sizeof(info.long_name) - 1); info.public_key.size = 32; memset(info.public_key.bytes, keyByte, 32); info.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; if (xeddsaSigned) info.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; if (manuallyVerified) info.bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; } // Evict everything but "self" - simulates the hot DB rolling over. Logical // count only; the buffer is left intact so the invariant holds. void rollHotStore() { numMeshNodes = 1; clearCachedNode(); } // Seed a hot-store node that has been learned as an XEdDSA signer, with a known name, so the // identity-update gate can be exercised. Distinct from setCachedNode() so a separate cached // node (the direct-response target) can coexist. void setSignerHotNode(NodeNum n, const char *longName) { if (meshNodes->size() < 2) meshNodes->resize(2); (*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero; (*meshNodes)[1].num = n; nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_USER_MASK, true); strncpy((*meshNodes)[1].long_name, longName, sizeof((*meshNodes)[1].long_name) - 1); numMeshNodes = 2; } private: bool hasCachedNode = false; NodeNum cachedNodeNum = 0; meshtastic_NodeInfoLite cachedNode = meshtastic_NodeInfoLite_init_zero; }; class MockRadioInterface : public RadioInterface { public: ErrorCode send(meshtastic_MeshPacket *p) override { packetPool.release(p); return ERRNO_OK; } uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override { (void)totalPacketLen; (void)received; return 0; } }; class MockRouter : public Router { public: ~MockRouter() { // Router allocates a global crypt lock in its constructor. // Clean it up here so each test can build a fresh mock router. delete cryptLock; cryptLock = nullptr; } ErrorCode send(meshtastic_MeshPacket *p) override { sentPackets.push_back(*p); packetPool.release(p); return ERRNO_OK; } std::vector sentPackets; }; class TrafficManagementModuleTestShim : public TrafficManagementModule { public: using TrafficManagementModule::alterReceived; using TrafficManagementModule::dropNodeInfoCacheForTest; using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; using TrafficManagementModule::markKeyXeddsaSignedForTest; using TrafficManagementModule::nodeInfoCacheCapacityForTest; using TrafficManagementModule::peekCachedRole; using TrafficManagementModule::peekNodeInfoFlagsForTest; using TrafficManagementModule::runOnce; bool ignoreRequestFlag() const { return ignoreRequest; } }; MockNodeDB *mockNodeDB = nullptr; static void resetTrafficConfig() { moduleConfig = meshtastic_LocalModuleConfig_init_zero; moduleConfig.has_traffic_management = true; moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; config = meshtastic_LocalConfig_init_zero; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; channelFile = meshtastic_ChannelFile_init_zero; owner.is_licensed = false; myNodeInfo.my_node_num = kLocalNode; router = nullptr; service = nullptr; mockNodeDB->resetNodes(); mockNodeDB->clearCachedNode(); nodeDB = mockNodeDB; // Virtual clock base (1 h in, so tick subtraction never underflows). Tests advance time by // bumping TrafficManagementModule::s_testNowMs instead of sleeping real seconds across a tick. TrafficManagementModule::s_testNowMs = 3600000; } static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST) { meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; packet.from = from; packet.to = to; packet.id = 0x1001; packet.channel = 0; packet.hop_start = 3; packet.hop_limit = 3; packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; packet.decoded.portnum = port; packet.decoded.has_bitfield = true; packet.decoded.bitfield = 0; return packet; } static meshtastic_MeshPacket makeUnknownPacket(NodeNum from, NodeNum to = NODENUM_BROADCAST) { meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; packet.from = from; packet.to = to; packet.id = 0x2001; packet.channel = 0; packet.hop_start = 3; packet.hop_limit = 3; packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; packet.encrypted.size = 0; return packet; } static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32_t lon, NodeNum to = NODENUM_BROADCAST) { meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, to); meshtastic_Position pos = meshtastic_Position_init_zero; pos.has_latitude_i = true; pos.has_longitude_i = true; pos.latitude_i = lat; pos.longitude_i = lon; packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos); return packet; } static meshtastic_MeshPacket makePositionPacketWithPrecision(NodeNum from, int32_t lat, int32_t lon, uint32_t precisionBits) { meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, NODENUM_BROADCAST); meshtastic_Position pos = meshtastic_Position_init_zero; pos.has_latitude_i = true; pos.has_longitude_i = true; pos.latitude_i = lat; pos.longitude_i = lon; pos.precision_bits = precisionBits; packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos); return packet; } static bool decodePositionPayload(const meshtastic_MeshPacket &packet, meshtastic_Position &out) { out = meshtastic_Position_init_zero; return pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &out); } // Primary channel with a well-known single-byte PSK and the (empty -> preset) // default name, so Channels::isWellKnownChannel(0) is true. static void installWellKnownPrimaryChannel() { channelFile = meshtastic_ChannelFile_init_zero; channelFile.channels_count = 1; channelFile.channels[0].index = 0; channelFile.channels[0].has_settings = true; channelFile.channels[0].role = meshtastic_Channel_Role_PRIMARY; channelFile.channels[0].settings.psk.size = 1; channelFile.channels[0].settings.psk.bytes[0] = 1; config.lora.use_preset = true; config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; } // Install the well-known primary channel AND set a specific position_precision so // shouldDropPosition() uses that precision ceiling rather than the default fallback. // precision=0 means "no channel ceiling" and falls back to the firmware default (19 bits). static void installWellKnownPrimaryChannelWithPrecision(uint32_t precision) { installWellKnownPrimaryChannel(); channelFile.channels[0].settings.has_module_settings = true; channelFile.channels[0].settings.module_settings.position_precision = precision; } static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName) { meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); meshtastic_User user = meshtastic_User_init_zero; snprintf(user.id, sizeof(user.id), "!%08x", from); strncpy(user.long_name, longName, sizeof(user.long_name) - 1); strncpy(user.short_name, shortName, sizeof(user.short_name) - 1); packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); return packet; } static meshtastic_MeshPacket makeNodeInfoPacketWithRole(NodeNum from, meshtastic_Config_DeviceConfig_Role role) { meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); meshtastic_User user = meshtastic_User_init_zero; snprintf(user.id, sizeof(user.id), "!%08x", from); strncpy(user.long_name, "rolenode", sizeof(user.long_name) - 1); strncpy(user.short_name, "rn", sizeof(user.short_name) - 1); user.role = role; packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); return packet; } /** * Verify the module is a no-op when traffic management is disabled. * Important so config toggles cannot accidentally change routing behavior. */ static void test_tm_moduleDisabled_doesNothing(void) { moduleConfig.has_traffic_management = false; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); ProcessMessage result = module.handleReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(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(ProcessMessage::CONTINUE), static_cast(p1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(p1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(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(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(ProcessMessage::STOP), static_cast(result)); TEST_ASSERT_TRUE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(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(ProcessMessage::STOP), static_cast(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(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(ProcessMessage::STOP), static_cast(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(ProcessMessage::CONTINUE), static_cast(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(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(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_FALSE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(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(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(ProcessMessage::CONTINUE), static_cast(observedResult)); // Signed-only replay gate (default) requires key-proven provenance to serve. module.markKeyXeddsaSignedForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.id = 0x24681357; request.channel = 1; request.hop_start = 3; request.hop_limit = 3; ProcessMessage result = module.handleReceived(request); meshtastic_TrafficManagementStats stats = module.getStats(); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); TEST_ASSERT_TRUE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); TEST_ASSERT_TRUE(reply.decoded.has_bitfield); TEST_ASSERT_EQUAL_UINT8(static_cast(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(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(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_FALSE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; // Learn a NodeInfo for the target into the NodeInfo cache (broadcast, so it is only cached). meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); module.handleReceived(observed); // Key-proven so staleness is the sole reason it is not served (isolates the gate under test). module.markKeyXeddsaSignedForTest(kTargetNode); // Advance the virtual clock just past the 6 h serve window. // 6 h + two 3-min observation ticks: guarantees the modular obs-tick age exceeds the // 120-tick serve window whatever the clock's offset within its current tick. TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + (2UL * 3UL * 60UL * 1000UL); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; ProcessMessage result = module.handleReceived(request); meshtastic_TrafficManagementStats stats = module.getStats(); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); module.handleReceived(observed); // Signed-only replay gate (default) requires key-proven provenance to serve. module.markKeyXeddsaSignedForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; // First request: served. request.id = 0xAAAA0001; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_FALSE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(2, static_cast(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; // First-seen key (0x11...) is pinned. module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x11)); // Poisoning attempt with a different key (0x22...) must be rejected. module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x22)); // Signed-only replay gate (default) requires key-proven provenance to serve the reply. module.markKeyXeddsaSignedForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; // Poisoning attempt with a key that mismatches the warm tier: must not be cached. module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x66)); uint8_t out[32] = {0}; TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, out, nullptr)); // The genuine key (matching the warm tier) is accepted. module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x55)); TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_UINT8(0x55, out[0]); TEST_ASSERT_EQUAL_UINT8(0x55, out[31]); mockNodeDB->warmStore.clear(); } /** * Unsigned-identity gate, warm tier: a verified signer evicted to the warm tier must not be * impersonatable. An attacker can forge an unsigned NodeInfo carrying the signer's real * (public!) key - it passes the key pin and would inherit warm XEdDSA-signed provenance - so the * gate must classify warm-tier signers, not only hot-store ones. A signature-verified frame * (control) is still learned. */ static void test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); // hot store misses; only the warm tier knows the signer uint8_t warmKey[32]; memset(warmKey, 0x5A, 32); mockNodeDB->warmStore.clear(); mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*xeddsaSigned=*/true); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; // Forgery: unsigned NodeInfo with the REAL key (passes the pin) and an attacker name. meshtastic_MeshPacket forged = makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x5A); forged.xeddsa_signed = false; module.handleReceived(forged); TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); // nothing cached meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // Control: the same identity, signature-verified, is learned. meshtastic_MeshPacket genuine = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x5A); genuine.xeddsa_signed = true; module.handleReceived(genuine); TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_STRING("genuine", out.long_name); mockNodeDB->warmStore.clear(); } // Bit positions returned by peekNodeInfoFlagsForTest(). constexpr int kFlagObserved = 1; constexpr int kFlagMember = 2; constexpr int kFlagFullUser = 4; constexpr int kFlagKeyProven = 8; // keyProven() == keyXeddsaSigned | keyManuallyVerified /** * Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by * the maintenance sweep (name + key + key provenance usable via copyUser/copyPublicKey), * but is NEVER served as a spoofed reply until a genuine NODEINFO frame is heard - seeding * and retention must not make a silent node look alive. */ static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->warmStore.clear(); mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x77, /*xeddsaSigned=*/true); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity // Seeded identity is available to the key pool and name rehydration... uint8_t key[32] = {0}; bool proven = false; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0x77, key[0]); TEST_ASSERT_TRUE(proven); // inherited from the hot store's XEdDSA-signed bit, key-matched meshtastic_User seeded = meshtastic_User_init_zero; TEST_ASSERT_TRUE(module.copyUser(kTargetNode, seeded, nullptr)); TEST_ASSERT_EQUAL_STRING("hot-name", seeded.long_name); // ...but a request for it is NOT answered: never observed, so never servable. meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(0, static_cast(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(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); mockNodeDB->rollHotStore(); } /** * Reconcile re-seeds manual verification: a hot-store node carrying IS_KEY_MANUALLY_VERIFIED * (but NOT XEdDSA-signed) is reconciled into the cache with keyProven() set via the manual * channel alone, so copyPublicKey reports it proven and the replay gate would vouch for it. * Guards the second provenance channel independently of the XEdDSA path. */ static void test_tm_nodeinfo_reconcile_seedsManualVerificationFromHotStore(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->warmStore.clear(); // Manually verified but NOT XEdDSA-signed: isolates the manual provenance channel. mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x5A, /*xeddsaSigned=*/false, /*manuallyVerified=*/true); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity // keyProven() is set even though keyXeddsaSigned is false - the manual bit alone proves it. const int flags = module.peekNodeInfoFlagsForTest(kTargetNode); TEST_ASSERT_TRUE(flags >= 0); TEST_ASSERT_TRUE(flags & kFlagKeyProven); // The pubkey pool sees it as proven, sourced from the manual channel. uint8_t key[32] = {0}; bool proven = false; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]); TEST_ASSERT_TRUE(proven); mockNodeDB->rollHotStore(); } /** * Reconcile seeding from the warm tier yields a key-only record: usable by copyPublicKey * (with the warm XEdDSA-signed bit inherited), but never by copyUser - the warm tier keeps no * names, and a nameless User must not reach name-rehydration. */ static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); uint8_t warmKey[32]; memset(warmKey, 0x44, 32); mockNodeDB->warmStore.clear(); mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*xeddsaSigned=*/true); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(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(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; trafficManagementModule = &module; // the NodeDB hook reaches the module via the global meshtastic_User user = meshtastic_User_init_zero; strncpy(user.long_name, "committed", sizeof(user.long_name) - 1); user.public_key.size = 32; memset(user.public_key.bytes, 0x5A, 32); mockNodeDB->updateUser(kTargetNode, user, 0); // Immediately visible to the key pool and name rehydration (no sweep has run)... uint8_t key[32] = {0}; bool proven = true; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]); TEST_ASSERT_FALSE(proven); // committed TOFU key: no XEdDSA-signed bit on the node yet meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_STRING("committed", out.long_name); // ...but known-not-heard is never servable. meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(0, static_cast(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(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "quiet", 0x21)); module.markKeyXeddsaSignedForTest(kTargetNode); // isolate: staleness, not the signed gate // Nine days of silence, swept every three days. The old design would have evicted the // entry at the 7-day retention TTL; now nothing expires by timer. for (int i = 0; i < 3; i++) { TrafficManagementModule::s_testNowMs += 3UL * 24UL * 60UL * 60UL * 1000UL; module.runOnce(); } // The key still feeds the pool... uint8_t key[32] = {0}; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); TEST_ASSERT_EQUAL_UINT8(0x21, key[0]); // ...but the serve gate saturated long ago: the request propagates unanswered. meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } #endif // WARM_NODE_COUNT > 0 /** * Feature #2: a key learned from an (unsigned) NodeInfo is served by copyPublicKey() as a * trust-on-first-use key, so it can extend the encryption pool. keyProven must be false. */ static void test_tm_nodeinfo_copyPublicKey_servesTofuKey(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x33)); uint8_t key[32] = {0}; bool proven = true; // must be overwritten to false TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0x33, key[0]); TEST_ASSERT_EQUAL_UINT8(0x33, key[31]); TEST_ASSERT_FALSE(proven); } /** * Feature #1: a later signature-verified NodeInfo upgrades the cached key's provenance to * XEdDSA-signed (monotonic), while the key bytes stay pinned. */ static void test_tm_nodeinfo_copyPublicKey_upgradesToXeddsaSigned(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "target-long", 0x77)); meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_STRING("target-long", out.long_name); TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size); TEST_ASSERT_EQUAL_UINT8(0x77, out.public_key.bytes[0]); // Uncached node -> miss. meshtastic_User miss = meshtastic_User_init_zero; TEST_ASSERT_FALSE(module.copyUser(kRemoteNode, miss, nullptr)); } #if TMM_NODEINFO_REPLAY_SIGNED_GATE /** * Replay gate (cache path): a fresh but trust-on-first-use (never key-proven) cached entry * is withheld - the reply is suppressed though the entry is fresh. */ static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; // Cache a fresh but unsigned (TOFU) NodeInfo and do NOT mark it key-proven. module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg")); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; ProcessMessage result = module.handleReceived(request); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } #endif // TMM_NODEINFO_REPLAY_SIGNED_GATE #endif // !MESHTASTIC_EXCLUDE_PKI /** * Key-commit hook (ported from tmm-fix-superset): a TOFU learn lands the key in the pool * without a User payload; manual verification upgrades provenance; a NodeDB-senior rotation * replaces the key and never inherits the old key's verdict. */ static void test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance(void) { mockNodeDB->clearCachedNode(); TrafficManagementModuleTestShim module; uint8_t k[32]; memset(k, 0x99, sizeof(k)); module.onNodeKeyCommitted(kTargetNode, k, false); // TOFU-grade learn uint8_t key[32] = {0}; bool proven = true; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0x99, key[0]); TEST_ASSERT_FALSE(proven); meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only: nothing to rehydrate module.onNodeKeyCommitted(kTargetNode, k, true); // manual verification of the same key TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_TRUE(proven); uint8_t rotated[32]; memset(rotated, 0xAA, sizeof(rotated)); module.onNodeKeyCommitted(kTargetNode, rotated, false); // NodeDB-senior rotation TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0xAA, key[0]); TEST_ASSERT_FALSE(proven); // rotated key must not inherit the old key's verdict } /** * Tick saturation (ported from tmm-fix-superset): the sweep clears hasObserved once the * serve window passes, the entry itself persists (no TTL eviction), and a full 256-tick * wrap of the clock cannot alias a saturated stamp back to "fresh". */ static void test_tm_nodeinfo_tickSaturation_sweepClearsObserved(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->warmStore.clear(); mockNodeDB->rollHotStore(); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg")); module.markKeyXeddsaSignedForTest(kTargetNode); const uint32_t stampMs = TrafficManagementModule::s_testNowMs; int flags = module.peekNodeInfoFlagsForTest(kTargetNode); TEST_ASSERT_TRUE(flags >= 0 && (flags & kFlagObserved)); // Past the serve window (plus one tick for granularity): the sweep saturates the stamp // but keeps the entry. TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 180000UL + 1000UL; module.runOnce(); flags = module.peekNodeInfoFlagsForTest(kTargetNode); TEST_ASSERT_TRUE(flags >= 0); TEST_ASSERT_FALSE(flags & kFlagObserved); // Advance to exactly one full uint8 tick period after the original stamp: the raw tick // age would read ~0 (fresh) if the bit were still trusted. It isn't - the cleared bit, // not the tick value, is authoritative. TrafficManagementModule::s_testNowMs = stampMs + 256UL * 180000UL; meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } /** * Membership marking: the hourly reconcile marks entries whose node exists in NodeDB and * clears the mark when the node is gone. A plain 60 s sweep does NOT refresh membership * (that per-entry NodeDB scan was moved into the reconcile), so a passive NodeDB eviction * lags by up to an hour; the entry itself persists (no TTL) and reconciliation-seeded * identities stay unservable. */ static void test_tm_nodeinfo_reconcileMembershipMarking(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->warmStore.clear(); mockNodeDB->setHotNodeIdentity(kTargetNode, "seeded-name", 0x5C, /*xeddsaSigned=*/false); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(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(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(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(keyByte), 32); } return user; } /** * Identity write-through hook, key semantics: a keyless commit keeps an already-learned key * (NodeDB may commit an unpinned identity); provenance follows the committed key - kept * while the key is unchanged, granted by signerKnown, and reset by a rotation. */ static void test_tm_nodeinfo_identityHook_keySemantics(void) { mockNodeDB->clearCachedNode(); TrafficManagementModuleTestShim module; // TOFU-grade identity commit with key K1. module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("first", 0x51), false); uint8_t key[32] = {0}; bool proven = true; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0x51, key[0]); TEST_ASSERT_FALSE(proven); // Keyless commit: the name updates but the learned key must survive, still unproven. module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("renamed", -1), false); meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, &proven)); TEST_ASSERT_EQUAL_STRING("renamed", out.long_name); TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size); TEST_ASSERT_EQUAL_UINT8(0x51, out.public_key.bytes[0]); TEST_ASSERT_FALSE(proven); // signerKnown commit of the same key grants provenance... module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("proven", 0x51), true); TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_TRUE(proven); // ...which a later same-key commit without the signer verdict does not revoke... module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("still-proven", 0x51), false); TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_TRUE(proven); // ...but a rotated key never inherits the old key's verdict. module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("rotated", 0x52), false); TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); TEST_ASSERT_EQUAL_UINT8(0x52, key[0]); TEST_ASSERT_FALSE(proven); } /** * Read gate: copyPublicKey()/copyUser() share the module-enabled gate with the writers, so a * cache populated while enabled stops feeding PKI resolution and name rehydration the moment * the module is disabled - and resumes when re-enabled (the entry itself is never freed). */ static void test_tm_nodeinfo_reads_gatedWhileModuleDisabled(void) { mockNodeDB->clearCachedNode(); TrafficManagementModuleTestShim module; // Populate while enabled (setUp left has_traffic_management = true). module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("present", 0x7C), false); uint8_t key[32] = {0}; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); // Disabled: the frozen entry persists but the accessors refuse to serve it. moduleConfig.has_traffic_management = false; TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // Re-enabled: same entry served again (nothing was purged). moduleConfig.has_traffic_management = true; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); TEST_ASSERT_EQUAL_UINT8(0x7C, key[0]); TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_STRING("present", out.long_name); } #if !(MESHTASTIC_EXCLUDE_PKI) // Fill `count` NodeInfo cache slots with keyless observed strangers (eviction tier 0), // numbered from `baseNode`. static void fillNodeInfoCacheWithKeylessStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode) { for (uint32_t i = 0; i < count; i++) module.handleReceived(makeNodeInfoPacket(baseNode + i, "filler", "fl")); } // Fill `count` NodeInfo cache slots with TOFU-keyed observed strangers (eviction tier 1), // numbered from `baseNode`, all sharing key byte 0x0F. static void fillNodeInfoCacheWithTofuStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode) { for (uint32_t i = 0; i < count; i++) module.handleReceived(makeNodeInfoPacketWithKey(baseNode + i, "filler", 0x0F)); } /** * Tiered LRU eviction, tier boundaries: with the cache exactly full, a new stranger's insert * evicts a keyless stranger - never a TOFU-keyed entry, a key-proven entry, or a NodeDB * member - even though those higher-tier entries are the OLDEST observations in the cache * (tier outranks recency). */ static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void) { mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); TrafficManagementModuleTestShim module; constexpr NodeNum kTofu = 0x51000001, kProven = 0x51000002, kMember = 0x51000003, kNewcomer = 0x51000004; module.handleReceived(makeNodeInfoPacketWithKey(kTofu, "tofu", 0x11)); module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); module.markKeyXeddsaSignedForTest(kProven); uint8_t memberKey[32]; memset(memberKey, 0x33, sizeof(memberKey)); module.onNodeKeyCommitted(kMember, memberKey, false); // Age the specials by two observation ticks, then fill every remaining slot with // fresher keyless strangers so the cache is exactly full. TrafficManagementModule::s_testNowMs += 2UL * 180000UL; const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); fillNodeInfoCacheWithKeylessStrangers(module, cap - 3u, 0x40000000); // The newcomer's insert must claim a keyless slot. module.handleReceived(makeNodeInfoPacket(kNewcomer, "newcomer", "nc")); uint8_t key[32] = {0}; TEST_ASSERT_TRUE(module.copyPublicKey(kTofu, key, nullptr)); TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); } /** * Tiered LRU eviction, keyed tiers: in a cache saturated with TOFU-keyed strangers, keyed * inserts displace TOFU entries while a key-proven stranger and a NodeDB member survive * (keyless < TOFU < key-proven, +membership). */ static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void) { mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); TrafficManagementModuleTestShim module; constexpr NodeNum kFillBase = 0x40000000; constexpr NodeNum kProven = 0x52000001, kMember = 0x52000002, kNew1 = 0x52000003, kNew2 = 0x52000004; const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); fillNodeInfoCacheWithTofuStrangers(module, cap - 2u, kFillBase); module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); module.markKeyXeddsaSignedForTest(kProven); uint8_t memberKey[32]; memset(memberKey, 0x33, sizeof(memberKey)); module.onNodeKeyCommitted(kMember, memberKey, false); // Age the saturated cache so each newcomer is fresher than the remaining fillers // (otherwise the second insert would reclaim the first newcomer's equal-age slot). TrafficManagementModule::s_testNowMs += 2UL * 180000UL; module.handleReceived(makeNodeInfoPacketWithKey(kNew1, "new1", 0x44)); module.handleReceived(makeNodeInfoPacketWithKey(kNew2, "new2", 0x55)); uint8_t key[32] = {0}; TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); TEST_ASSERT_TRUE(module.copyPublicKey(kNew1, key, nullptr)); TEST_ASSERT_TRUE(module.copyPublicKey(kNew2, key, nullptr)); // The two victims were the first TOFU fillers scanned, not the protected entries. TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 1, key, nullptr)); } /** * Within-tier LRU: among same-tier entries the stalest observation is evicted first, not * whichever slot happens to be scanned first. */ static void test_tm_nodeinfo_eviction_withinTierStalestLoses(void) { mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); TrafficManagementModuleTestShim module; constexpr NodeNum kFillBase = 0x40000000; constexpr NodeNum kStale = 0x53000001, kNewcomer = 0x53000002; module.handleReceived(makeNodeInfoPacketWithKey(kStale, "stale", 0x11)); // Everything else is observed two ticks later... TrafficManagementModule::s_testNowMs += 2UL * 180000UL; const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); fillNodeInfoCacheWithTofuStrangers(module, cap - 1u, kFillBase); // ...so the newcomer's insert reclaims kStale's slot specifically. module.handleReceived(makeNodeInfoPacketWithKey(kNewcomer, "newcomer", 0x22)); uint8_t key[32] = {0}; TEST_ASSERT_FALSE(module.copyPublicKey(kStale, key, nullptr)); TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); TEST_ASSERT_TRUE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // fresher same-tier entry survives } /** * Member saturation: with every slot holding a NodeDB member, the write-through hooks skip * rather than churn one member out for another (spareMembers), while the packet path - a * genuinely observed frame - does evict a member. */ static void test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts(void) { mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); TrafficManagementModuleTestShim module; constexpr NodeNum kFillBase = 0x40000000; constexpr NodeNum kExtra = 0x54000001, kObserved = 0x54000002; const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); uint8_t k[32]; memset(k, 0x11, sizeof(k)); for (uint32_t i = 0; i < cap; i++) module.onNodeKeyCommitted(kFillBase + i, k, false); // Hook inserts for one more member: skipped rather than evicting an existing member. uint8_t extraKey[32]; memset(extraKey, 0x22, sizeof(extraKey)); module.onNodeKeyCommitted(kExtra, extraKey, false); uint8_t key[32] = {0}; TEST_ASSERT_FALSE(module.copyPublicKey(kExtra, key, nullptr)); module.onNodeIdentityCommitted(kExtra, makeCommittedUser("extra", 0x22), false); meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_FALSE(module.copyUser(kExtra, out, nullptr)); // The packet path may churn a member: the observed stranger lands (in the first-scanned // member's slot) and is correctly NOT marked a member itself. module.handleReceived(makeNodeInfoPacketWithKey(kObserved, "observed", 0x33)); TEST_ASSERT_TRUE(module.copyPublicKey(kObserved, key, nullptr)); const int flags = module.peekNodeInfoFlagsForTest(kObserved); TEST_ASSERT_TRUE(flags >= 0); TEST_ASSERT_TRUE(flags & kFlagObserved); TEST_ASSERT_FALSE(flags & kFlagMember); TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // the evicted member } /** * Full DB reset: NodeDB::resetNodes() purges this module's caches through purgeAll(), so no * identity, key, or next-hop hint survives a user-initiated "forget everything". */ static void test_tm_nodeinfo_resetNodes_purgesAllCaches(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); mockNodeDB->rollHotStore(); MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37)); module.setNextHop(kTargetNode, 0x42); uint8_t key[32] = {0}; TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); mockNodeDB->resetNodes(); TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); meshtastic_User out = meshtastic_User_init_zero; TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); // trafficManagementModule is reset in tearDown(). } /** * A NodeInfo advertising OUR OWN public key is impersonating us and must never be cached * (mirrors NodeDB::updateUser()'s key hygiene for the store that feeds spoofed replies). */ static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void) { mockNodeDB->clearCachedNode(); TrafficManagementModuleTestShim module; owner.public_key.size = 32; memset(owner.public_key.bytes, 0x42, 32); module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "imposter", 0x42)); TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); uint8_t key[32] = {0}; TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); // owner.public_key is reset in tearDown() (guaranteed even if an assertion above aborts). } #endif // !MESHTASTIC_EXCLUDE_PKI #endif /** * Verify the NodeDB-fallback direct-response path (no NodeInfo cache) refuses to spoof a * reply for a node NodeDB last heard beyond the serve window, but still answers a fresh one. */ static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; // Drive getTime() to a known uptime so sinceLastSeen() is deterministic. setBootRelativeTimeForUnitTest(1000000); const uint32_t now = getTime(); mockNodeDB->setCachedNode(kTargetNode); mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale // Key-proven so staleness is the sole reason this is not served (isolates the gate under test). mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(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(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(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(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(ProcessMessage::STOP), static_cast(result)); TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); resetRTCStateForTests(); } #if TMM_NODEINFO_REPLAY_SIGNED_GATE /** * Replay gate (fallback path): a manually-verified NodeDB node - IS_KEY_MANUALLY_VERIFIED set, * but NOT XEdDSA-signed - passes the broadened key-proven gate and is served, mirroring the * cache path's keyProven() (XEdDSA | manual). Companion to fallbackUnsignedNotServed. */ static void test_tm_nodeinfo_directResponse_fallbackManuallyVerifiedServed(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; setBootRelativeTimeForUnitTest(1000000); const uint32_t now = getTime(); mockNodeDB->setCachedNode(kTargetNode); mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness // Manually verified, NOT XEdDSA-signed: the key-proven gate must accept the manual channel. mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(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(ProcessMessage::STOP), static_cast(result)); TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); resetRTCStateForTests(); } #endif // TMM_NODEINFO_REPLAY_SIGNED_GATE /** * Per-target direct-response throttle on the NodeDB-fallback path (no PSRAM NodeInfo cache). The * per-target RAM table is not the cache, so it throttles this path identically: a burst for a fresh * node yields one spoofed reply per 60 s window, even from a different requester, then serves again. */ static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; setBootRelativeTimeForUnitTest(1000000); const uint32_t now = getTime(); TrafficManagementModule::s_testNowMs = 3600000; // known base for the throttle windows mockNodeDB->setCachedNode(kTargetNode); mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness gate // Signed-only replay gate (default) requires the target be a known signer to be served. mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(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(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_FALSE(module.ignoreRequestFlag()); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(2, static_cast(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(new MockRadioInterface())); MeshService mockService; router = &mockRouter; service = &mockService; TrafficManagementModuleTestShim module; // Three distinct, freshly-observed, key-proven targets. Using a fresh target on each step keeps // the per-target axis from ever being the bound here, so the reply is gated only by the axis under // test: per-requester (step 2) or the global floor (step 4). constexpr NodeNum kTargetA = 0x33330001, kTargetB = 0x33330002, kTargetC = 0x33330003; constexpr NodeNum kRemoteNode3 = 0x66666666; for (NodeNum t : {kTargetA, kTargetB, kTargetC}) { module.handleReceived(makeNodeInfoPacket(t, "target-long", "tg")); module.markKeyXeddsaSignedForTest(t); } meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetA); request.decoded.want_response = true; request.hop_start = 3; request.hop_limit = 3; // First reply for this requester: served. request.id = 0xC0DE0001; TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(1, static_cast(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(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(2, static_cast(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(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(2, static_cast(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(ProcessMessage::STOP), static_cast(module.handleReceived(request))); TEST_ASSERT_EQUAL_UINT32(3, static_cast(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(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(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_EQUAL_UINT32(0, static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(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(ProcessMessage::CONTINUE), static_cast(seeded)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(result)); } ProcessMessage dropped = module.handleReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(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(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(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(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(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(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(TRAFFIC_MANAGEMENT_CACHE_SIZE) + 50; i++) { const NodeNum filler = 0x01000000u + i; module.handleReceived(makePositionPacket(filler, 300 + static_cast(i), 400 + static_cast(i))); } TEST_ASSERT_EQUAL_INT(static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(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(ProcessMessage::CONTINUE), static_cast(r1)); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); } /** * Verify a LOST_AND_FOUND origin now GETS the relayed precision clamp - the * anti-dox exemption was removed, so a relayed position more precise than the * channel setting is clamped down to the channel ceiling like any other node's. */ static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void) { // Set channel precision ceiling to 13 bits. Must be <= MAX_POSITION_PRECISION_PUBLIC_KEY // (15) - well-known channels have a public PSK (size==1), so getPositionPrecisionForChannel // clamps any value above 15 via usesPublicKey(). installWellKnownPrimaryChannelWithPrecision(13); mockNodeDB->setCachedNode(kRemoteNode); mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND); TrafficManagementModuleTestShim module; // Full-precision packet - 32 bits - exceeds the channel cap. const uint32_t fullPrecision = 32; meshtastic_MeshPacket packet = makePositionPacketWithPrecision(kRemoteNode, 374221234, -1220845678, fullPrecision); packet.hop_start = 3; packet.hop_limit = 2; // relayed (hop_limit < hop_start) so clamp logic applies module.alterReceived(packet); meshtastic_Position out; TEST_ASSERT_TRUE(decodePositionPayload(packet, out)); // Clamped to channel ceiling (13 bits) - lost-and-found is no longer exempt. // Note: precision must be <= MAX_POSITION_PRECISION_PUBLIC_KEY (15); well-known // channels always have a public PSK so getPositionPrecisionForChannel caps at 15. TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits); } // --------------------------------------------------------------------------- // Fuzz - crafted-nodenum blitz of the unified cache // --------------------------------------------------------------------------- // Floods handleReceived/alterReceived with crafted packets over a tiny node pool so the fixed-size // cache churns hard while the virtual clock sweeps the rate/unknown/position windows; no crash, counters bounded. static constexpr uint64_t FUZZ_SEED = 0x00D07E5701ULL; static void test_tm_fuzz_nodenum_blitz(void) { printf(" seed=0x%llx\n", (unsigned long long)FUZZ_SEED); rngSeed(FUZZ_SEED); // Activate the cache-tracked windows (the crafted-nodenum target). The nodeinfo direct-response // path (nodeinfo_direct_response_max_hops) is left OFF: it calls service->sendToMesh, and driving // that at fuzz volume needs a fully-wired MeshService/phone queue this fixture doesn't provide - the // deterministic test_tm_nodeinfo_directResponse_* tests cover it with single packets instead. moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 3; moduleConfig.traffic_management.unknown_packet_threshold = 4; moduleConfig.traffic_management.position_min_interval_secs = 300; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; installWellKnownPrimaryChannel(); static TrafficManagementModuleTestShim module; // static: OSThread-derived (see note in test_fuzz_packets E2) // Shared boundary pool (0/1/broadcast) plus this suite's well-known nodes. const NodeNum wellKnown[] = {kLocalNode, kRemoteNode, kTargetNode}; const size_t wellKnownN = sizeof(wellKnown) / sizeof(wellKnown[0]); const meshtastic_PortNum ports[] = { meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_ADMIN_APP, meshtastic_PortNum_TELEMETRY_APP, }; const size_t portsN = sizeof(ports) / sizeof(ports[0]); const unsigned ITERS = 30000; for (unsigned k = 0; k < ITERS; k++) { meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; p.from = (rngRange(8) == 0) ? (NodeNum)rngNext() : rngEdgeNodeNum(wellKnown, wellKnownN); p.to = rngEdgeNodeNum(wellKnown, wellKnownN); p.id = rngNext(); p.channel = 0; p.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded p.hop_limit = (uint8_t)rngRange(8); p.decoded.want_response = (rngRange(2) == 0); if (rngRange(5) == 0) { // Undecoded / unknown packet - exercises the unknown-packet threshold path. p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1); rngFill(p.encrypted.bytes, p.encrypted.size); } else { p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; p.decoded.portnum = (rngRange(8) == 0) ? (meshtastic_PortNum)rngRange(80) : ports[rngRange(portsN)]; p.decoded.has_bitfield = true; p.decoded.bitfield = (uint32_t)rngNext(); if (p.decoded.portnum == meshtastic_PortNum_POSITION_APP && rngRange(2)) { meshtastic_Position pos = meshtastic_Position_init_zero; pos.has_latitude_i = true; pos.has_longitude_i = true; pos.latitude_i = (int32_t)rngNext(); pos.longitude_i = (int32_t)rngNext(); pos.precision_bits = rngRange(40); // includes >32 (the default-precision fallback) p.decoded.payload.size = pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Position_msg, &pos); } else { // Random payload bytes: TMM's nested User/Position decode must fail cleanly. p.decoded.payload.size = rngRange(sizeof(p.decoded.payload.bytes) + 1); rngFill(p.decoded.payload.bytes, p.decoded.payload.size); } } (void)module.handleReceived(p); if (rngRange(3) == 0) module.alterReceived(p); // Advance the virtual clock so rate / unknown / position windows open and close under churn. if (rngRange(16) == 0) TrafficManagementModule::s_testNowMs += (rngRange(120) + 1) * 1000u; if (rngRange(1024) == 0) (void)module.runOnce(); // maintenance sweep: cache aging / eviction } // The cache never inspected more packets than we fed, and the run reached here without an ASan fault. TEST_ASSERT_TRUE_MESSAGE(module.getStats().packets_inspected <= ITERS, "packets_inspected overcounted"); } } // namespace void setUp(void) { resetTrafficConfig(); } void tearDown(void) { // Runs even when a TEST_ASSERT_* aborts a test mid-way (Unity longjmps out, skipping any // cleanup the test itself does after the assertion), so per-test global state can never // dangle into later cases. The dangling-pointer path is concrete: a test points the global // at its stack `module`, and the next setUp()'s resetNodes() would then call // trafficManagementModule->purgeAll() on a destroyed object. trafficManagementModule = nullptr; owner.public_key.size = 0; memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes)); // Neutralize the RTC fake clock a fallback test may have set (the virtual s_testNowMs is // already reset by setUp via resetTrafficConfig). setBootRelativeTimeForUnitTest(0); } TM_TEST_ENTRY void setup() { delay(10); delay(2000); initializeTestEnvironment(); mockNodeDB = new MockNodeDB(); nodeDB = mockNodeDB; UNITY_BEGIN(); RUN_TEST(test_tm_moduleDisabled_doesNothing); RUN_TEST(test_tm_unknownPackets_dropOnNPlusOne); RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow); RUN_TEST(test_tm_positionDedup_allowsMovedPosition); RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold); RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters); RUN_TEST(test_tm_localDestination_bypassesTransitFilters); RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops); RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache); RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo); RUN_TEST(test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity); RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow); RUN_TEST(test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor); #if TMM_NODEINFO_REPLAY_SIGNED_GATE RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed); RUN_TEST(test_tm_nodeinfo_directResponse_fallbackManuallyVerifiedServed); #endif #if TMM_HAS_NODEINFO_CACHE RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); RUN_TEST(test_tm_nodeinfo_directResponse_psramStaleEntryNotServed); RUN_TEST(test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow); #if !(MESHTASTIC_EXCLUDE_PKI) RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey); #if WARM_NODE_COUNT > 0 RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey); RUN_TEST(test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery); RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes); RUN_TEST(test_tm_nodeinfo_reconcile_seedsManualVerificationFromHotStore); RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier); RUN_TEST(test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity); RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough); RUN_TEST(test_tm_nodeinfo_removeNode_purgesCaches); RUN_TEST(test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives); #endif RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToXeddsaSigned); RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse); RUN_TEST(test_tm_nodeinfo_copyUser_returnsCachedIdentity); #if TMM_NODEINFO_REPLAY_SIGNED_GATE RUN_TEST(test_tm_nodeinfo_directResponse_psramUnsignedNotServed); #endif #endif RUN_TEST(test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance); RUN_TEST(test_tm_nodeinfo_tickSaturation_sweepClearsObserved); RUN_TEST(test_tm_nodeinfo_reconcileMembershipMarking); RUN_TEST(test_tm_nodeinfo_cache_normalizesSpoofedUserId); RUN_TEST(test_tm_nodeinfo_hooks_noopWhileModuleDisabled); RUN_TEST(test_tm_nodeinfo_identityHook_keySemantics); RUN_TEST(test_tm_nodeinfo_reads_gatedWhileModuleDisabled); #if !(MESHTASTIC_EXCLUDE_PKI) RUN_TEST(test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless); RUN_TEST(test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember); RUN_TEST(test_tm_nodeinfo_eviction_withinTierStalestLoses); RUN_TEST(test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts); RUN_TEST(test_tm_nodeinfo_resetNodes_purgesAllCaches); RUN_TEST(test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey); #endif #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); RUN_TEST(test_tm_positionDedup_distinctAtClampedChannelPrecision); RUN_TEST(test_tm_positionDedup_precisionZero_channelFallsBackToDefault); RUN_TEST(test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush); RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero); RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires); RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires); RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps); RUN_TEST(test_tm_alterReceived_positionBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets); RUN_TEST(test_tm_alterReceived_exhaustFlagAlwaysFalse); RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped); RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval); RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval); RUN_TEST(test_tm_nextHop_setAndGetRoundTrip); RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll); RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned); RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep); RUN_TEST(test_tm_trackerRole_capsDedupWindowAtOneHour); RUN_TEST(test_tm_takTrackerRole_capsDedupWindowAtOneHour); RUN_TEST(test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole); RUN_TEST(test_tm_roleChange_viaNodeInfo_dropsTrackerException); RUN_TEST(test_tm_specialRole_pinsEntryThroughSweep); RUN_TEST(test_tm_specialRole_evictedLastUnderPressure); RUN_TEST(test_tm_trackerRole_doesNotLengthenShorterOperatorInterval); RUN_TEST(test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes); RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp); RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval); RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval); RUN_TEST(test_tm_fuzz_nodenum_blitz); exit(UNITY_END()); } TM_TEST_ENTRY void loop() {} #else void setUp(void) {} void tearDown(void) {} TM_TEST_ENTRY void setup() { initializeTestEnvironment(); UNITY_BEGIN(); exit(UNITY_END()); } TM_TEST_ENTRY void loop() {} #endif