// Unit tests for the warm ("long-tail") node tier — src/mesh/WarmNodeStore.cpp. // Covers admission/eviction policy (keyed entries outrank keyless), take() // rehydration semantics, and a tolerant persistence round trip. #include "MeshTypes.h" // BEFORE TestUtil.h — provides WARM_NODE_COUNT via mesh-pb-constants.h #include "TestUtil.h" #include #if defined(ARCH_PORTDUINO) #define WS_TEST_ENTRY extern "C" #else #define WS_TEST_ENTRY #endif #if WARM_NODE_COUNT > 0 #include "FSCommon.h" #include "mesh/WarmNodeStore.h" #include #include namespace { void makeKey(uint8_t out[32], uint8_t seed) { memset(out, 0, 32); out[0] = seed; out[31] = seed ^ 0xA5; } } // namespace void setUp(void) {} void tearDown(void) {} void test_ws_absorb_and_copyKey_roundTrip() { WarmNodeStore ws; uint8_t key[32], got[32]; makeKey(key, 7); TEST_ASSERT_TRUE(ws.absorb(0x100, 1000, key)); TEST_ASSERT_TRUE(ws.contains(0x100)); TEST_ASSERT_TRUE(ws.copyKey(0x100, got)); TEST_ASSERT_EQUAL_MEMORY(key, got, 32); TEST_ASSERT_EQUAL(1, ws.count()); } void test_ws_keylessEntry_hasNoKey() { WarmNodeStore ws; uint8_t got[32]; TEST_ASSERT_TRUE(ws.absorb(0x200, 1000, NULL)); TEST_ASSERT_TRUE(ws.contains(0x200)); TEST_ASSERT_FALSE(ws.copyKey(0x200, got)); } void test_ws_absorb_rejectsNodeNumZero() { WarmNodeStore ws; TEST_ASSERT_FALSE(ws.absorb(0, 1000, NULL)); TEST_ASSERT_EQUAL(0, ws.count()); } void test_ws_absorb_updatesExistingEntry() { WarmNodeStore ws; uint8_t key[32], got[32]; makeKey(key, 9); TEST_ASSERT_TRUE(ws.absorb(0x300, 1000, NULL)); TEST_ASSERT_TRUE(ws.absorb(0x300, 2000, key)); // later eviction learned a key TEST_ASSERT_EQUAL(1, ws.count()); TEST_ASSERT_TRUE(ws.copyKey(0x300, got)); TEST_ASSERT_EQUAL_MEMORY(key, got, 32); } void test_ws_take_removesEntry() { WarmNodeStore ws; uint8_t key[32]; makeKey(key, 3); ws.absorb(0x400, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role); WarmNodeEntry e; TEST_ASSERT_TRUE(ws.take(0x400, e)); TEST_ASSERT_EQUAL(0x400, e.num); // last_heard is quantised to the metadata quantum; role/protected ride the low bits. TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e)); TEST_ASSERT_EQUAL(5, warmRoleOf(e)); TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); TEST_ASSERT_EQUAL_MEMORY(key, e.public_key, 32); TEST_ASSERT_FALSE(ws.contains(0x400)); TEST_ASSERT_FALSE(ws.take(0x400, e)); TEST_ASSERT_EQUAL(0, ws.count()); } void test_ws_keylessCandidate_neverEvictsKeyedEntries() { WarmNodeStore ws; uint8_t key[32]; // Fill the store entirely with keyed entries for (size_t i = 0; i < ws.capacity(); i++) { makeKey(key, (uint8_t)i); TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, 100 + i, key)); } TEST_ASSERT_EQUAL(ws.capacity(), ws.count()); // A keyless candidate (even a fresh one) must be rejected TEST_ASSERT_FALSE(ws.absorb(0x9999, 999999, NULL)); TEST_ASSERT_FALSE(ws.contains(0x9999)); } void test_ws_keyedCandidate_evictsOldestKeylessFirst() { WarmNodeStore ws; uint8_t key[32]; makeKey(key, 0x42); // Fill with keyed entries except two keyless ones in the middle for (size_t i = 0; i < ws.capacity(); i++) { const bool keyless = (i == 5 || i == 10); // Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives // quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10). TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key)); } // Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50), // even though every keyed entry is older (ts=10) uint8_t k2[32]; makeKey(k2, 0x43); TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2)); TEST_ASSERT_FALSE(ws.contains(0x1000 + 10)); TEST_ASSERT_TRUE(ws.contains(0x1000 + 5)); TEST_ASSERT_TRUE(ws.contains(0x8888)); } void test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless() { WarmNodeStore ws; uint8_t key[32]; for (size_t i = 0; i < ws.capacity(); i++) { makeKey(key, (uint8_t)i); TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, 1000 + i, key)); // 0x1000 is the oldest } uint8_t k2[32]; makeKey(k2, 0x44); TEST_ASSERT_TRUE(ws.absorb(0x7777, 999999, k2)); TEST_ASSERT_TRUE(ws.contains(0x7777)); TEST_ASSERT_FALSE(ws.contains(0x1000)); // oldest keyed evicted TEST_ASSERT_EQUAL(ws.capacity(), ws.count()); } void test_ws_meta_roundTrip() { WarmNodeStore ws; uint8_t key[32]; makeKey(key, 0x77); // Keyed TRACKER(5)/Role-protected and keyless SENSOR(6)/unprotected. TEST_ASSERT_TRUE(ws.absorb(0x700, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role)); TEST_ASSERT_TRUE(ws.absorb(0x701, 5678, NULL, 6 /* SENSOR */, (uint8_t)WarmProtected::None)); uint8_t role = 0xFF, prot = 0xFF; TEST_ASSERT_TRUE(ws.lookupMeta(0x700, role, prot)); TEST_ASSERT_EQUAL(5, role); TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot); TEST_ASSERT_TRUE(ws.lookupMeta(0x701, role, prot)); TEST_ASSERT_EQUAL(6, role); TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); // Absent node yields false and leaves outputs untouched-by-contract (just check return). TEST_ASSERT_FALSE(ws.lookupMeta(0x999, role, prot)); // Default args still compile (role/protected = 0 = CLIENT/None). TEST_ASSERT_TRUE(ws.absorb(0x702, 9999, NULL)); TEST_ASSERT_TRUE(ws.lookupMeta(0x702, role, prot)); TEST_ASSERT_EQUAL(0, role); TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); } void test_ws_remove_and_clear() { WarmNodeStore ws; ws.absorb(0x500, 1, NULL); ws.absorb(0x501, 2, NULL); ws.remove(0x500); TEST_ASSERT_FALSE(ws.contains(0x500)); TEST_ASSERT_EQUAL(1, ws.count()); ws.clear(); TEST_ASSERT_EQUAL(0, ws.count()); } void test_ws_persistence_roundTrip() { WarmNodeStore a; uint8_t key[32], got[32]; makeKey(key, 0x55); a.absorb(0x600, 4242, key); a.absorb(0x601, 4243, NULL); if (!a.saveIfDirty()) { TEST_IGNORE_MESSAGE("Filesystem not available in this test environment"); return; } WarmNodeStore b; b.load(); TEST_ASSERT_TRUE(b.contains(0x600)); TEST_ASSERT_TRUE(b.contains(0x601)); TEST_ASSERT_TRUE(b.copyKey(0x600, got)); TEST_ASSERT_EQUAL_MEMORY(key, got, 32); // Cleanup so reruns start fresh b.clear(); b.saveIfDirty(); } // Migration: a v1 (WRM1) warm.dat must keep identity + key but discard last_heard // (so its low bits aren't misread as role/protected). File backend only. void test_ws_v1_migration_discardsLastHeard() { WarmNodeStore a; uint8_t key[32], got[32]; makeKey(key, 0x66); a.absorb(0x900, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role); if (!a.saveIfDirty()) { TEST_IGNORE_MESSAGE("Filesystem not available in this test environment"); return; } // Read the whole v2 file, flip the 4-byte header magic to v1 ("WRM1"), write it back. // (CRC covers only the entry bytes, so patching the header magic keeps it valid.) std::vector buf; { auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ); if (!f) { TEST_IGNORE_MESSAGE("warm.dat not readable in this environment"); return; } buf.resize(f.size()); f.read(buf.data(), buf.size()); f.close(); } TEST_ASSERT_TRUE(buf.size() >= 4); const uint32_t v1magic = 0x314D5257u; // "WRM1" memcpy(buf.data(), &v1magic, sizeof(v1magic)); { auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE); TEST_ASSERT_TRUE((bool)f); f.write(buf.data(), buf.size()); f.close(); } WarmNodeStore b; b.load(); TEST_ASSERT_TRUE(b.contains(0x900)); // identity survived migration TEST_ASSERT_TRUE(b.copyKey(0x900, got)); // public key survived TEST_ASSERT_EQUAL_MEMORY(key, got, 32); uint8_t role = 0xFF, prot = 0xFF; TEST_ASSERT_TRUE(b.lookupMeta(0x900, role, prot)); TEST_ASSERT_EQUAL(0, role); // last_heard discarded → role/protected reset TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); b.clear(); b.saveIfDirty(); } WS_TEST_ENTRY void setup() { initializeTestEnvironment(); UNITY_BEGIN(); RUN_TEST(test_ws_absorb_and_copyKey_roundTrip); RUN_TEST(test_ws_keylessEntry_hasNoKey); RUN_TEST(test_ws_absorb_rejectsNodeNumZero); RUN_TEST(test_ws_absorb_updatesExistingEntry); RUN_TEST(test_ws_take_removesEntry); RUN_TEST(test_ws_keylessCandidate_neverEvictsKeyedEntries); RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst); RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless); RUN_TEST(test_ws_meta_roundTrip); RUN_TEST(test_ws_remove_and_clear); RUN_TEST(test_ws_persistence_roundTrip); RUN_TEST(test_ws_v1_migration_discardsLastHeard); exit(UNITY_END()); } WS_TEST_ENTRY void loop() {} #else void setUp(void) {} void tearDown(void) {} WS_TEST_ENTRY void setup() { initializeTestEnvironment(); UNITY_BEGIN(); exit(UNITY_END()); } WS_TEST_ENTRY void loop() {} #endif