// Round-trip fidelity of the v25 slim NodeDB persistence cycle: snr_q4 quantization and its // HAS_SNR sentinel, satellite-map projection/rehydration and eviction, the keyless-device write // skip, and resetNodes() compaction. Each test saves, cold-boots a real NodeDB, and reads back. #include "MeshTypes.h" // BEFORE TestUtil.h - provides MAX_SATELLITE_NODES via mesh-pb-constants.h #include "TestUtil.h" #include #if defined(ARCH_PORTDUINO) #define NDBR_TEST_ENTRY extern "C" #else #define NDBR_TEST_ENTRY #endif #include "FSCommon.h" // This is a disk round-trip suite; without a filesystem there is nothing to pin. #if defined(FSCom) #include "mesh/NodeDB.h" #include #include #include #include // Friend declared in NodeDB.h (PIO_UNIT_TESTING): exposes the private save path so // the tests drive exactly the gate under test, without saveToDisk()'s format-retry. class NodeDBTestShim : public NodeDB { public: bool saveDatabase() { return saveNodeDatabaseToDisk(); } }; namespace { NodeDBTestShim *db = nullptr; /// Simulate a process restart. A real cold boot starts with a zeroed nodeDatabase /// global; in-process the decode callback would append on top of the previous /// boot's rows, duplicating every node. void coldBoot() { if (db) { delete db; db = nullptr; nodeDB = nullptr; } nodeDatabase.version = 0; nodeDatabase.nodes.clear(); nodeDatabase.positions.clear(); nodeDatabase.telemetry.clear(); nodeDatabase.environment.clear(); nodeDatabase.status.clear(); db = new NodeDBTestShim(); nodeDB = db; } meshtastic_User makeUser(uint32_t num, uint8_t seed) { meshtastic_User u = meshtastic_User_init_zero; snprintf(u.id, sizeof(u.id), "!%08x", num); snprintf(u.long_name, sizeof(u.long_name), "Node %02X", seed); snprintf(u.short_name, sizeof(u.short_name), "N%02X", seed); u.hw_model = meshtastic_HardwareModel_TBEAM; u.role = meshtastic_Config_DeviceConfig_Role_CLIENT; u.public_key.size = 32; for (int i = 0; i < 32; i++) u.public_key.bytes[i] = (uint8_t)(i ^ seed ^ 0x5A); return u; } /// Give the node a user so it survives the next boot's cleanupMeshDB() purge - /// userless, non-ignored rows are dropped on load, which is itself part of the cycle. meshtastic_NodeInfoLite *addUserNode(uint32_t num, uint8_t seed, uint8_t channelIndex = 0) { meshtastic_User u = makeUser(num, seed); nodeDB->updateUser(num, u, channelIndex); meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(num); TEST_ASSERT_NOT_NULL_MESSAGE(info, "updateUser must admit the node"); return info; } /// A packet as the real over-the-air RX path shapes it: decoded, TRANSPORT_LORA, /// modern-sender bitfield, rx_time and rx_rssi present. meshtastic_MeshPacket makeRxPacket(uint32_t from) { meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; mp.from = from; mp.to = nodeDB->getNodeNum(); mp.id = 0x1000u + (from & 0xFFFu); mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; mp.decoded.has_bitfield = true; // modern sender: hop_start is trustworthy mp.has_rx_time = true; mp.rx_time = 1700000000; mp.hop_start = 3; mp.hop_limit = 3; mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; mp.has_rx_rssi = true; mp.rx_rssi = -80; return mp; } void heardOverLoRa(uint32_t from, float snr) { meshtastic_MeshPacket mp = makeRxPacket(from); mp.rx_snr = snr; nodeDB->updateFrom(mp); } meshtastic_StatusMessage makeStatus(const char *text) { meshtastic_StatusMessage st = meshtastic_StatusMessage_init_zero; snprintf(st.status, sizeof(st.status), "%s", text); return st; } /// A header row as a saved nodes.proto carries it. HAS_USER matters: cleanupMeshDB /// purges a userless row on load and erases its satellites with it. meshtastic_NodeInfoLite craftedOwner(NodeNum num, uint32_t lastHeard) { meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; n.num = num; n.last_heard = lastHeard; n.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; return n; } meshtastic_NodePositionEntry craftedPosition(NodeNum num, int32_t lat) { meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; e.num = num; e.has_position = true; e.position.latitude_i = lat; e.position.time = 1000 + (uint32_t)lat; return e; } bool readFileBytes(const char *path, std::vector &out) { auto f = FSCom.open(path, FILE_O_READ); if (!f) return false; out.resize(f.size()); if (!out.empty() && f.read(out.data(), out.size()) != out.size()) { f.close(); return false; } f.close(); return true; } void decodeNodesFile(meshtastic_NodeDatabase &out) { // _init_zero brace-inits the embedded std::vector via its (size_type) ctor, // so callers pass a default-constructed struct; decode targets are disarmed in // steady state, so satellite entries land in the struct's own vectors - this // reads the on-disk projection directly. TEST_ASSERT_EQUAL_MESSAGE(LoadFileResult::LOAD_SUCCESS, db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), &meshtastic_NodeDatabase_msg, &out), "nodes.proto must decode"); } void assertTempVectorsEmpty(const char *when) { TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.positions.empty(), when); TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.telemetry.empty(), when); TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.environment.empty(), when); TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.status.empty(), when); } void clearAllSatellites() { auto wipe = [](const std::vector &nums) { for (NodeNum n : nums) nodeDB->eraseNodeSatellites(n); }; wipe(nodeDB->snapshotPositionNodeNums(0)); wipe(nodeDB->snapshotTelemetryNodeNums(0)); wipe(nodeDB->snapshotEnvironmentNodeNums(0)); wipe(nodeDB->snapshotStatusNodeNums(0)); } } // namespace void setUp(void) {} void tearDown(void) {} // --- Environment preconditions --- // Every persistence leg depends on boot keygen having produced an owner key // (keyless devices deliberately skip the nodes.proto write - tested below). static void test_identityReady_saveUnlocked(void) { TEST_ASSERT_EQUAL_MESSAGE(32, owner.public_key.size, "boot keygen did not run - this suite needs an owner key"); TEST_ASSERT_NOT_NULL(db->getMeshNode(db->getNodeNum())); } // --- updateFrom SNR admission gates (in-RAM policy feeding the persisted bit) --- static void test_updateFrom_snrTransportGates(void) { const uint32_t A = 0x52000001, B = 0x52000002, C = 0x52000003; // Genuine RF reception of a 0 dB packet: stored, and HAS_SNR says so. heardOverLoRa(A, 0.0f); const meshtastic_NodeInfoLite *na = db->getMeshNode(A); TEST_ASSERT_NOT_NULL(na); TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(na), "a measured 0 dB must be recorded as known"); TEST_ASSERT_EQUAL_FLOAT(0.0f, na->snr); // Broker-delivered MQTT packet: rx_snr is not our measurement, never recorded. meshtastic_MeshPacket mp = makeRxPacket(B); mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; mp.via_mqtt = true; mp.rx_snr = 7.5f; nodeDB->updateFrom(mp); const meshtastic_NodeInfoLite *nb = db->getMeshNode(B); TEST_ASSERT_NOT_NULL(nb); TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nb), "MQTT-transport SNR must not be recorded"); TEST_ASSERT_EQUAL_FLOAT(0.0f, nb->snr); TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nb)); // TRANSPORT_LORA without has_rx_rssi (the PhoneAPI-replay shape): not recorded. mp = makeRxPacket(C); mp.has_rx_rssi = false; mp.rx_rssi = 0; mp.rx_snr = 6.0f; nodeDB->updateFrom(mp); const meshtastic_NodeInfoLite *nc = db->getMeshNode(C); TEST_ASSERT_NOT_NULL(nc); TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nc), "replay-shaped packets must not mint a measurement"); TEST_ASSERT_EQUAL_FLOAT(0.0f, nc->snr); // An MQTT-origin packet a gateway rebroadcast onto LoRa: we measured that one. mp = makeRxPacket(B); mp.via_mqtt = true; mp.rx_snr = -3.5f; nodeDB->updateFrom(mp); nb = db->getMeshNode(B); TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nb)); TEST_ASSERT_EQUAL_FLOAT(-3.5f, nb->snr); } // --- snr_q4 quantization + HAS_SNR sentinel through a real save/boot cycle --- static void test_snrQuantization_roundTripsThroughDisk(void) { const uint32_t N1 = 0x53000001; // |SNR| < 0.25 dB: rounds to -1, not truncated to the sentinel const uint32_t N2 = 0x53000002; // measured 0.0 dB: the #11271 sentinel collision const uint32_t N3 = 0x53000003; // rounds TO 0 yet stays a known measurement const uint32_t N4 = 0x53000004; // legacy record: snr set, HAS_SNR clear (compat branch) const uint32_t N5 = 0x53000005; // never measured const uint32_t N6 = 0x53000006; // plain quantization: 7.9 -> 32/4 = 8.0 addUserNode(N1, 0x01); heardOverLoRa(N1, -0.2f); addUserNode(N2, 0x02); heardOverLoRa(N2, 0.0f); addUserNode(N3, 0x03); heardOverLoRa(N3, 0.1f); meshtastic_NodeInfoLite *legacy = addUserNode(N4, 0x04); legacy->snr = 3.0f; // pre-HAS_SNR store shape: value present, bit clear addUserNode(N5, 0x05); addUserNode(N6, 0x06); heardOverLoRa(N6, 7.9f); TEST_ASSERT_TRUE(db->saveDatabase()); coldBoot(); const meshtastic_NodeInfoLite *n = db->getMeshNode(N1); TEST_ASSERT_NOT_NULL(n); TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); TEST_ASSERT_EQUAL_FLOAT_MESSAGE(-0.25f, n->snr, "lroundf(-0.8) = -1 -> -0.25 dB (rounding, not truncation)"); n = db->getMeshNode(N2); TEST_ASSERT_NOT_NULL(n); TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a genuine 0 dB reading must come back as known, not unknown"); TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); n = db->getMeshNode(N3); TEST_ASSERT_NOT_NULL(n); TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a measurement that quantizes to 0 is still a measurement"); TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); n = db->getMeshNode(N4); TEST_ASSERT_NOT_NULL(n); TEST_ASSERT_FALSE(nodeInfoLiteHasSnr(n)); TEST_ASSERT_EQUAL_FLOAT_MESSAGE(3.0f, n->snr, "legacy snr_q4 without the bit must decode via the compat branch"); n = db->getMeshNode(N5); TEST_ASSERT_NOT_NULL(n); TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(n), "snr_q4 = 0 with the bit clear is unambiguously unknown"); TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); n = db->getMeshNode(N6); TEST_ASSERT_NOT_NULL(n); TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); TEST_ASSERT_EQUAL_FLOAT(8.0f, n->snr); } // --- Full header + satellite-map projection/rehydration cycle --- static void test_fullRoundTrip_headerAndSatelliteFidelity(void) { const uint32_t P = 0x54000001; // position const uint32_t T = 0x54000002; // device telemetry const uint32_t E = 0x54000003; // environment + status const uint32_t M = 0x54000004; // bitfield bools + hops addUserNode(P, 0x11, /*channelIndex=*/2); heardOverLoRa(P, 5.5f); #if !MESHTASTIC_EXCLUDE_POSITIONDB meshtastic_Position pos = meshtastic_Position_init_zero; pos.latitude_i = 375000000; pos.longitude_i = -1219876543; pos.altitude = 123; pos.time = 1700000200; pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; pos.precision_bits = 32; nodeDB->updatePosition(P, pos); #endif addUserNode(T, 0x12); #if !MESHTASTIC_EXCLUDE_TELEMETRYDB meshtastic_Telemetry tel = meshtastic_Telemetry_init_zero; tel.which_variant = meshtastic_Telemetry_device_metrics_tag; tel.variant.device_metrics.has_battery_level = true; tel.variant.device_metrics.battery_level = 87; tel.variant.device_metrics.has_voltage = true; tel.variant.device_metrics.voltage = 3.7f; tel.variant.device_metrics.has_channel_utilization = true; tel.variant.device_metrics.channel_utilization = 12.5f; tel.variant.device_metrics.has_air_util_tx = true; tel.variant.device_metrics.air_util_tx = 1.5f; tel.variant.device_metrics.has_uptime_seconds = true; tel.variant.device_metrics.uptime_seconds = 3600; nodeDB->updateTelemetry(T, tel); #endif addUserNode(E, 0x13); #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB meshtastic_Telemetry env = meshtastic_Telemetry_init_zero; env.which_variant = meshtastic_Telemetry_environment_metrics_tag; env.variant.environment_metrics.has_temperature = true; env.variant.environment_metrics.temperature = 21.5f; env.variant.environment_metrics.has_relative_humidity = true; env.variant.environment_metrics.relative_humidity = 40.5f; env.variant.environment_metrics.has_barometric_pressure = true; env.variant.environment_metrics.barometric_pressure = 1013.25f; nodeDB->updateTelemetry(E, env); #endif #if !MESHTASTIC_EXCLUDE_STATUSDB nodeDB->setNodeStatus(E, makeStatus("on the tower")); #endif meshtastic_NodeInfoLite *m = addUserNode(M, 0x14); meshtastic_MeshPacket mp = makeRxPacket(M); mp.via_mqtt = true; // gateway rebroadcast: bit stored, SNR still ours mp.hop_start = 5; mp.hop_limit = 2; // hops_away = 3 mp.rx_snr = 2.0f; nodeDB->updateFrom(mp); m = db->getMeshNode(M); nodeInfoLiteSetBit(m, NODEINFO_BITFIELD_IS_MUTED_MASK, true); TEST_ASSERT_TRUE(db->saveDatabase()); assertTempVectorsEmpty("temp vectors must be cleared after the save projection"); coldBoot(); assertTempVectorsEmpty("armed decode must route entries into the maps, not the temp vectors"); // Header fidelity const meshtastic_NodeInfoLite *np = db->getMeshNode(P); TEST_ASSERT_NOT_NULL(np); TEST_ASSERT_EQUAL_STRING("Node 11", np->long_name); TEST_ASSERT_EQUAL_STRING("N11", np->short_name); TEST_ASSERT_EQUAL(meshtastic_HardwareModel_TBEAM, np->hw_model); TEST_ASSERT_EQUAL_UINT8(2, np->channel); TEST_ASSERT_EQUAL_UINT32(1700000000, np->last_heard); TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(np)); TEST_ASSERT_EQUAL_FLOAT(5.5f, np->snr); meshtastic_User expected = makeUser(P, 0x11); TEST_ASSERT_EQUAL(32, np->public_key.size); TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expected.public_key.bytes, np->public_key.bytes, 32, "public key must survive byte-identical"); const meshtastic_NodeInfoLite *nm = db->getMeshNode(M); TEST_ASSERT_NOT_NULL(nm); TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nm)); TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nm)); TEST_ASSERT_TRUE(nm->has_hops_away); TEST_ASSERT_EQUAL_UINT8(3, nm->hops_away); TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nm)); TEST_ASSERT_EQUAL_FLOAT(2.0f, nm->snr); // Satellite rehydration - identical values, and only where they were written. #if !MESHTASTIC_EXCLUDE_POSITIONDB meshtastic_PositionLite gotPos; TEST_ASSERT_TRUE(db->copyNodePosition(P, gotPos)); TEST_ASSERT_EQUAL_INT32(375000000, gotPos.latitude_i); TEST_ASSERT_EQUAL_INT32(-1219876543, gotPos.longitude_i); TEST_ASSERT_EQUAL_INT32(123, gotPos.altitude); TEST_ASSERT_EQUAL_UINT32(1700000200, gotPos.time); TEST_ASSERT_EQUAL(meshtastic_Position_LocSource_LOC_INTERNAL, gotPos.location_source); TEST_ASSERT_EQUAL_UINT32(32, gotPos.precision_bits); TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(T), "no position was ever written for T"); #endif #if !MESHTASTIC_EXCLUDE_TELEMETRYDB meshtastic_DeviceMetrics gotDm; TEST_ASSERT_TRUE(db->copyNodeTelemetry(T, gotDm)); TEST_ASSERT_TRUE(gotDm.has_battery_level); TEST_ASSERT_EQUAL_UINT32(87, gotDm.battery_level); TEST_ASSERT_TRUE(gotDm.has_voltage); TEST_ASSERT_EQUAL_FLOAT(3.7f, gotDm.voltage); TEST_ASSERT_TRUE(gotDm.has_channel_utilization); TEST_ASSERT_EQUAL_FLOAT(12.5f, gotDm.channel_utilization); TEST_ASSERT_TRUE(gotDm.has_air_util_tx); TEST_ASSERT_EQUAL_FLOAT(1.5f, gotDm.air_util_tx); TEST_ASSERT_TRUE(gotDm.has_uptime_seconds); TEST_ASSERT_EQUAL_UINT32(3600, gotDm.uptime_seconds); TEST_ASSERT_FALSE(db->hasNodeTelemetry(P)); #endif #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB meshtastic_EnvironmentMetrics gotEnv; TEST_ASSERT_TRUE(db->copyNodeEnvironment(E, gotEnv)); TEST_ASSERT_TRUE(gotEnv.has_temperature); TEST_ASSERT_EQUAL_FLOAT(21.5f, gotEnv.temperature); TEST_ASSERT_TRUE(gotEnv.has_relative_humidity); TEST_ASSERT_EQUAL_FLOAT(40.5f, gotEnv.relative_humidity); TEST_ASSERT_TRUE(gotEnv.has_barometric_pressure); TEST_ASSERT_EQUAL_FLOAT(1013.25f, gotEnv.barometric_pressure); #endif #if !MESHTASTIC_EXCLUDE_STATUSDB meshtastic_StatusMessage gotSt; TEST_ASSERT_TRUE(db->copyNodeStatus(E, gotSt)); TEST_ASSERT_EQUAL_STRING("on the tower", gotSt.status); TEST_ASSERT_FALSE(db->hasNodeStatus(P)); #endif } // --- Keyless-save skip (part of the PKI-DM key-amnesia diagnosis) --- static void test_keylessDevice_skipsNodesProtoWrite(void) { #if MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI TEST_IGNORE_MESSAGE("keyless-save gate compiled out on this build"); #else std::vector before; TEST_ASSERT_TRUE_MESSAGE(readFileBytes(nodeDatabaseFileName, before), "nodes.proto must exist before the gate check"); const meshtastic_User_public_key_t savedKey = owner.public_key; const bool savedLicensed = owner.is_licensed; owner.public_key.size = 0; owner.is_licensed = false; // Returning success on the skip matters: a false here would propagate into // saveToDisk()'s fsFormat() whole-FS wipe. TEST_ASSERT_TRUE_MESSAGE(db->saveDatabase(), "keyless save must report success"); std::vector after; TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); TEST_ASSERT_TRUE_MESSAGE(before == after, "keyless save must leave nodes.proto byte-identical"); owner.public_key = savedKey; owner.is_licensed = savedLicensed; // Control: with the key restored, the same call writes. addUserNode(0x55000001, 0x55); TEST_ASSERT_TRUE(db->saveDatabase()); TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); TEST_ASSERT_FALSE_MESSAGE(before == after, "keyed save must rewrite nodes.proto"); #endif } // --- Live satellite-cap eviction policy --- #if !MESHTASTIC_EXCLUDE_STATUSDB static void test_satelliteCap_evictionPolicy(void) { if ((size_t)MAX_NUM_NODES < (size_t)MAX_SATELLITE_NODES + 8) TEST_IGNORE_MESSAGE("hot cap too small to own a full satellite map on this build"); clearAllSatellites(); TEST_ASSERT_EQUAL_UINT(0, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); const NodeNum self = nodeDB->getNodeNum(); meshtastic_NodeInfoLite *selfRow = nodeDB->getOrCreateMeshNode(self); TEST_ASSERT_NOT_NULL(selfRow); selfRow->last_heard = 0; // stalest possible: only the identity exemption can protect it nodeDB->setNodeStatus(self, makeStatus("self")); // Fill to exactly the cap with hot-owned entries; owner i heard at 1000+i. const size_t owners = (size_t)MAX_SATELLITE_NODES - 1; const NodeNum ownerBase = 0x60000000u; for (size_t i = 0; i < owners; i++) { meshtastic_NodeInfoLite *info = nodeDB->getOrCreateMeshNode(ownerBase + i); TEST_ASSERT_NOT_NULL(info); info->last_heard = 1000 + (uint32_t)i; nodeDB->setNodeStatus(ownerBase + i, makeStatus("owned")); } TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); // (a) At cap, a new entry evicts the stalest-by-owner victim - never self, // even though self ranks stalest of all. const NodeNum orphan1 = 0x60FFFF01u; nodeDB->setNodeStatus(orphan1, makeStatus("new")); TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(self), "self must never be evicted"); TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(ownerBase + 0), "stalest owner must be the victim"); TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); TEST_ASSERT_TRUE(db->hasNodeStatus(orphan1)); TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); // (b) Orphans (owner absent from the hot store) are evicted before any owner, // however stale the owner: orphan1 (recency 0) loses to owner1 (1001). const NodeNum orphan2 = 0x60FFFF02u; nodeDB->setNodeStatus(orphan2, makeStatus("new2")); TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(orphan1), "orphan must be evicted before any owned entry"); TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); TEST_ASSERT_TRUE(db->hasNodeStatus(orphan2)); TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); // (c) Updating an existing key at cap must not evict anything. nodeDB->setNodeStatus(ownerBase + 1, makeStatus("updated")); TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(orphan2), "update-in-place must not trigger eviction"); meshtastic_StatusMessage got; TEST_ASSERT_TRUE(db->copyNodeStatus(ownerBase + 1, got)); TEST_ASSERT_EQUAL_STRING("updated", got.status); } #endif // !MESHTASTIC_EXCLUDE_STATUSDB // --- Boot-time trim of an over-cap nodes.proto (capacity downgrade / foreign file) --- #if !MESHTASTIC_EXCLUDE_POSITIONDB static void test_bootTrim_overCapSatellitesHealedOnDisk(void) { const size_t overBy = 10; const NodeNum base = 0x70000000u; // Craft a v25 nodes.proto whose position store exceeds this build's cap, as a // larger-cap build (or a peer backup) would leave behind. Every entry has a hot // owner, so the boot orphan sweep keeps all of them and only the cap trims. meshtastic_NodeDatabase crafted{}; crafted.version = DEVICESTATE_CUR_VER; for (size_t i = 0; i < (size_t)MAX_SATELLITE_NODES + overBy; i++) { const NodeNum num = base + (uint32_t)i; crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i)); crafted.positions.push_back(craftedPosition(num, (int32_t)(1000 + i))); } size_t craftedSize = 0; TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); TEST_ASSERT_TRUE(db->saveProto(nodeDatabaseFileName, craftedSize, &meshtastic_NodeDatabase_msg, &crafted, false)); coldBoot(); // Trimmed in RAM to exactly the cap, stalest owner first. TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotPositionNodeNums(0).size()); TEST_ASSERT_TRUE(db->hasNodePosition(base + (uint32_t)MAX_SATELLITE_NODES + (uint32_t)overBy - 1)); TEST_ASSERT_FALSE(db->hasNodePosition(base)); // And healed on disk: nodeDBSelfCare rewrote the store once during the boot. meshtastic_NodeDatabase reloaded{}; decodeNodesFile(reloaded); size_t persisted = 0; for (const auto &e : reloaded.positions) if (e.has_position) persisted++; TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)MAX_SATELLITE_NODES, (unsigned)persisted, "boot must rewrite the over-cap store trimmed"); } #endif // !MESHTASTIC_EXCLUDE_POSITIONDB // --- Boot-time heal of a nodes.proto carrying unowned satellite entries --- #if !MESHTASTIC_EXCLUDE_POSITIONDB // Guards #11798: satellite entries whose key names no hot node are dropped on boot and the // healed store is rewritten once; keys that cannot name a node (0, NODENUM_BROADCAST) are // refused at decode. static void test_bootHeal_unownedSatellitesDropped(void) { const NodeNum ownedBase = 0x72000000u; const NodeNum orphanBase = 0x73000000u; const size_t owned = 5; const size_t orphans = 6; meshtastic_NodeDatabase crafted{}; crafted.version = DEVICESTATE_CUR_VER; for (size_t i = 0; i < owned; i++) { const NodeNum num = ownedBase + (uint32_t)i; crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i)); crafted.positions.push_back(craftedPosition(num, (int32_t)(100 + i))); } for (size_t i = 0; i < orphans; i++) crafted.positions.push_back(craftedPosition(orphanBase + (uint32_t)i, (int32_t)(200 + i))); // Keys no NodeNum derivation can produce; these must never reach the map. crafted.positions.push_back(craftedPosition(0, 300)); crafted.positions.push_back(craftedPosition(NODENUM_BROADCAST, 301)); size_t craftedSize = 0; TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); TEST_ASSERT_TRUE(db->saveProto(nodeDatabaseFileName, craftedSize, &meshtastic_NodeDatabase_msg, &crafted, false)); coldBoot(); // In RAM: every owned entry kept, every unowned one gone. for (size_t i = 0; i < owned; i++) TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(ownedBase + (uint32_t)i), "hot-owned entry must survive the sweep"); for (size_t i = 0; i < orphans; i++) TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(orphanBase + (uint32_t)i), "orphan must be swept on boot"); TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(0), "key 0 must be refused at decode"); TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(NODENUM_BROADCAST), "broadcast key must be refused at decode"); // And healed on disk: nodeDBSelfCare rewrote the store once during the boot. meshtastic_NodeDatabase reloaded{}; decodeNodesFile(reloaded); size_t persisted = 0; for (const auto &e : reloaded.positions) { if (!e.has_position) continue; TEST_ASSERT_TRUE_MESSAGE(e.num >= ownedBase && e.num < ownedBase + owned, "healed store must contain only owned entries"); persisted++; } TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)owned, (unsigned)persisted, "boot must rewrite the store without the orphans"); } #endif // !MESHTASTIC_EXCLUDE_POSITIONDB // --- resetNodes(keepFavorites): no ghost rows above numMeshNodes --- static void test_resetNodesKeepFavorites_compactsWithoutGhostRows(void) { const uint32_t F1 = 0x71000001, F2 = 0x71000002, F3 = 0x71000003, F4 = 0x71000004; addUserNode(F1, 0x21); addUserNode(F2, 0x22); addUserNode(F3, 0x23); addUserNode(F4, 0x24); TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F2)); TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F4)); #if !MESHTASTIC_EXCLUDE_POSITIONDB meshtastic_Position pos = meshtastic_Position_init_zero; pos.latitude_i = 111; pos.longitude_i = 222; nodeDB->updatePosition(F1, pos); nodeDB->updatePosition(F2, pos); #endif nodeDB->resetNodes(/*keepFavorites=*/true); // RAM: self + the two favorites, compacted into contiguous low slots. TEST_ASSERT_EQUAL_INT(3, (int)nodeDB->getNumMeshNodes()); TEST_ASSERT_NULL(db->getMeshNode(F1)); TEST_ASSERT_NULL(db->getMeshNode(F3)); const meshtastic_NodeInfoLite *f2 = db->getMeshNode(F2); const meshtastic_NodeInfoLite *f4 = db->getMeshNode(F4); TEST_ASSERT_NOT_NULL(f2); TEST_ASSERT_NOT_NULL(f4); TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f2)); TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f4)); #if !MESHTASTIC_EXCLUDE_POSITIONDB TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(F1), "non-favorite satellites must be dropped"); TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(F2), "favorite satellites must survive"); #endif // Disk: resetNodes saved; the serialized store must carry the favorites in // the low slots and NOTHING above numMeshNodes - a zeroed-in-place favorite // would be invisible to every scan yet still serialized (the ghost bug). meshtastic_NodeDatabase reloaded{}; decodeNodesFile(reloaded); TEST_ASSERT_TRUE(reloaded.nodes.size() >= 3); size_t liveRows = 0; bool sawF2 = false, sawF4 = false, sawSelf = false; for (size_t i = 0; i < reloaded.nodes.size(); i++) { const meshtastic_NodeInfoLite &row = reloaded.nodes[i]; if (row.num == 0) continue; liveRows++; TEST_ASSERT_TRUE_MESSAGE(i < 3, "live row serialized above numMeshNodes: a ghost entry"); if (row.num == F2) sawF2 = true; if (row.num == F4) sawF4 = true; if (row.num == nodeDB->getNodeNum()) sawSelf = true; } TEST_ASSERT_EQUAL_UINT(3, (unsigned)liveRows); TEST_ASSERT_TRUE(sawSelf); TEST_ASSERT_TRUE(sawF2); TEST_ASSERT_TRUE(sawF4); } NDBR_TEST_ENTRY void setup() { initializeTestEnvironment(); #if defined(ARCH_PORTDUINO) // The stalest-owner eviction case needs hot capacity above the satellite cap // (the real large-flash topology). Set before the first NodeDB so every boot // in this suite sees one consistent cap. portduino_config.MaxNodes = (int)MAX_SATELLITE_NODES + 50; #endif // First boot on the empty sandbox: installs defaults, runs keygen, and // persists the base config files every later cold boot reloads. coldBoot(); #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) // Boot keygen is region-gated on real radios (simradio bypasses the gate); // if this environment blocked it, set a region and mint the identity now so // the persistence legs run instead of cascading off a locked save. if (owner.public_key.size != 32) { config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; nodeDB->generateCryptoKeyPair(nullptr); } #endif UNITY_BEGIN(); printf("\n=== Preconditions ===\n"); RUN_TEST(test_identityReady_saveUnlocked); printf("\n=== updateFrom SNR gates ===\n"); RUN_TEST(test_updateFrom_snrTransportGates); printf("\n=== snr_q4 + HAS_SNR round trip ===\n"); RUN_TEST(test_snrQuantization_roundTripsThroughDisk); printf("\n=== Satellite projection/rehydration ===\n"); RUN_TEST(test_fullRoundTrip_headerAndSatelliteFidelity); printf("\n=== Keyless-save gate ===\n"); RUN_TEST(test_keylessDevice_skipsNodesProtoWrite); printf("\n=== Satellite caps ===\n"); #if !MESHTASTIC_EXCLUDE_STATUSDB RUN_TEST(test_satelliteCap_evictionPolicy); #endif #if !MESHTASTIC_EXCLUDE_POSITIONDB RUN_TEST(test_bootTrim_overCapSatellitesHealedOnDisk); RUN_TEST(test_bootHeal_unownedSatellitesDropped); #endif printf("\n=== resetNodes ghost rows ===\n"); RUN_TEST(test_resetNodesKeepFavorites_compactsWithoutGhostRows); exit(UNITY_END()); } NDBR_TEST_ENTRY void loop() {} #else // !FSCom - no filesystem, nothing to round-trip void setUp(void) {} void tearDown(void) {} NDBR_TEST_ENTRY void setup() { initializeTestEnvironment(); UNITY_BEGIN(); exit(UNITY_END()); } NDBR_TEST_ENTRY void loop() {} #endif