mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-15 15:59:40 -04:00
* test(native): add 14 suites for routing, persistence, parsing and identity gaps Coverage audit of the native test tree; adds the highest-value untested logic as 11 new suites and extends 3 existing ones (200 test functions). New: test_stream_framing, test_nodedb_boot_recovery, test_nodedb_legacy_migration, test_nodedb_v25_roundtrip, test_nodedb_identity_hygiene, test_channel_keys, test_reliable_ack_matrix, test_hop_start_policy, test_routing_response_hops, test_phone_api_config_dump, test_observer. Extended: test_rtc, test_mqtt, test_xmodem. Two source changes the audit produced: - StreamAPI::handleRecStream copied stream->read()'s `cInt < 0` EOF check into the buffer-fed path, where there is no EOF sentinel; with signed char any byte >= 0x80 (START1 is 0x94) aborted the parse. Read the byte as uint8_t directly. Latent on develop (no callers), pinned by test_stream_framing. - Extract the post-decode pre-hop predicate from Router::handleReceived into shouldSkipHandleForPostDecodeHop() (NodeDB.h) so test_hop_start_policy drives the exact expression the router calls. No behavior change. test/state-manifest.tsv declares the suites that construct a NodeDB. Full 68-suite Docker coverage run matches the pre-change baseline. * test(native): address review - harden observer dispatch, trim comments Review follow-ups on the coverage-audit suites: - Observable::notifyObservers() erased list nodes while holding an iterator into them, so an observer that unobserves itself from onNotify corrupted the dispatch. Today the only self-detacher (PhoneAPI::onNotify -> checkConnectionTimeout -> close -> unobserve) survives solely because it returns -1 and aborts the chain before the increment; that unwritten contract is now gone. Removal during a dispatch nulls the entry and the outermost notify sweeps afterwards, which keeps self-detach, next-detach and destruction-during-notify all safe without an allocation. Hoisting the next iterator instead would have inverted the hazard and broken the existing next-detach case. Two regression tests added. - Correct the documented caller of shouldSkipHandleForPostDecodeHop: the call is in Router::dispatchReceived, not handleReceived. - Cast hop fields to unsigned at the %u call site in test_hop_start_policy. - Trim the new suites' file headers to the one-or-two-line rule in AGENTS.md. - Rename eight test functions whose names were exactly `test_` + 35 chars: that is the shape of a Lob API key, so trufflehog flagged them as secrets and failed the Trunk CI check. Full 68-suite Docker coverage run matches the pre-change baseline. * test(native): revert the observer dispatch change, keep the contract test Backs out the notifyObservers() deferred-removal hardening from the previous commit. It was reviewer-driven scope creep: nothing in the coverage audit needed it, no test required it, and it changes dispatch semantics in a header with ~76 observe() call sites on native verification alone. The hazard it addressed is not reachable today. The only observer that unobserves itself from onNotify is PhoneAPI (onNotify -> checkConnectionTimeout -> close -> unobserve), and it returns -1, which aborts the chain before the iterator is advanced past the erased node. test_self_detach_with_abort_during_notify stays: it passes against the unmodified dispatch and pins that the -1 is load-bearing, so a later cleanup that "simplifies" it away goes red. The unsafe variant (self-detach returning 0) is documented in a comment rather than tested, since asserting it would be asserting UB. * fix(serial): recover the frame behind a stray framing marker A byte that failed the START2 check was discarded rather than re-tested as a possible START1, so 0x94 0x94 0xc3 ... lost the real frame: one corrupted byte on a noisy UART silently dropped the frame behind it. Re-test the byte in place instead. Applied to both copies of the receive state machine. readStream() is the one that matters in the field - it is the serial path every phone client uses - while handleRecStream() still has no callers on develop. Strictly widens what the parser accepts; no frame that parsed before parses differently. test_stream_framing covers it on both receive paths, plus a run of stray markers and a START1-then-unrelated-byte resync. This was originally documented as a known gap in the framing suite. Fixing it instead was NomDeTom's call on review: a passing test asserting the bad behavior is what makes it hard to change later, and it is the same defect shape as the signedness fix three functions away. Also: use Throttle::deadlinePassed() in test_reliable_ack_matrix rather than a bare millis() compare, matching the house deadline rule. * test(native): cover the stray-marker resync on the buffer path too The stray-marker fix went into both copies of the receive state machine, but only test_stray_start1_before_frame_still_delivers drove both. The repeated- marker and unrelated-byte cases drove readStream() alone, so a regression in handleRecStream() would have gone unnoticed by two of the three. Verified load-bearing: reverting only the handleRecStream() half of the fix turns test_repeated_stray_start1_before_frame_still_delivers red on the new assertion. test_start1_then_unrelated_byte_resyncs stays green under that mutation by design - its failing byte is 0x00, where both branches reset to 0 - and covers the other half of the ternary. Also drops the stale header on test_stray_start1_before_frame_still_delivers, which still described the gap as pinned-as-is after the fix landed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(native): make the hop-start truth table assert the rows it prints test_truth_table_summary was six TEST_MESSAGE lines and no assertion, so it reported as a case that could not fail - the anti-pattern #11517 names in its unfinished assertion-presence lint, and the one exception to NomDeTom's "no RUN_TEST without an assertion" pass over this PR. The printed row and the checked expectation now come from one struct, so the summary cannot narrate a table the predicates no longer implement. It also covers the consequence columns the per-row tests do not assert together: classifyHopStart, shouldDropPacketForPreHop and shouldSkipHandleForPostDecodeHop for the same packet, with the expectations gated on MESHTASTIC_PREHOP_DROP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
513 lines
19 KiB
C++
513 lines
19 KiB
C++
// Identity hygiene for the remote-identity commit paths in NodeDB: updateUser() key pinning and
|
|
// addFromContact() guards (a keyless contact must never erase a stored key - the #11432 regression).
|
|
#include "MeshTypes.h" // Include BEFORE TestUtil.h
|
|
#include "TestUtil.h"
|
|
#include <unity.h>
|
|
|
|
#if defined(ARCH_PORTDUINO)
|
|
#define IH_TEST_ENTRY extern "C"
|
|
#else
|
|
#define IH_TEST_ENTRY
|
|
#endif
|
|
|
|
#include "FSCommon.h"
|
|
#include "SPILock.h"
|
|
#include "mesh/NodeDB.h"
|
|
#include "support/MockMeshService.h"
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
// Subclass shim: the friend declaration in NodeDB.h grants access to the
|
|
// private state these tests must seed/reset (duplicateWarned latch, warm-tier
|
|
// demotion). Declared at global scope so it matches `friend class NodeDBTestShim`.
|
|
class NodeDBTestShim : public NodeDB
|
|
{
|
|
public:
|
|
void clearHot()
|
|
{
|
|
meshNodes->clear();
|
|
numMeshNodes = 0;
|
|
}
|
|
|
|
// keySeed == 0 means "no stored key"; otherwise a deterministic 32-byte pattern.
|
|
void push(NodeNum num, uint32_t lastHeard, uint8_t keySeed = 0, bool xeddsaSigned = false)
|
|
{
|
|
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
|
n.num = num;
|
|
n.last_heard = lastHeard;
|
|
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
|
if (keySeed) {
|
|
n.public_key.size = 32;
|
|
memset(n.public_key.bytes, keySeed, 32);
|
|
n.public_key.bytes[0] = 0x01; // never all-zero (all-zero == "no key")
|
|
}
|
|
if (xeddsaSigned)
|
|
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
|
meshNodes->push_back(n);
|
|
numMeshNodes = meshNodes->size();
|
|
}
|
|
|
|
// Index 0 is our own node; eviction scans treat it as self.
|
|
void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu); }
|
|
|
|
void resetDuplicateWarned() { duplicateWarned = false; }
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
void runDemote() { demoteOldestHotNodesToWarm(); }
|
|
#endif
|
|
};
|
|
|
|
namespace
|
|
{
|
|
|
|
NodeDBTestShim *db = nullptr;
|
|
MockMeshService *mockService = nullptr;
|
|
|
|
meshtastic_User savedOwner;
|
|
meshtastic_LocalConfig savedConfig;
|
|
|
|
constexpr NodeNum kPeer = 0xE1000001;
|
|
|
|
// Same pattern as NodeDBTestShim::push so a "matching" user key really matches.
|
|
template <typename KeyT> void fillKey(KeyT &k, uint8_t seed)
|
|
{
|
|
k.size = 32;
|
|
memset(k.bytes, seed, 32);
|
|
k.bytes[0] = 0x01;
|
|
}
|
|
|
|
meshtastic_User makeUser(const char *longName, const char *shortName, uint8_t keySeed = 0)
|
|
{
|
|
meshtastic_User u = meshtastic_User_init_zero;
|
|
strncpy(u.long_name, longName, sizeof(u.long_name) - 1);
|
|
strncpy(u.short_name, shortName, sizeof(u.short_name) - 1);
|
|
if (keySeed)
|
|
fillKey(u.public_key, keySeed);
|
|
return u;
|
|
}
|
|
|
|
meshtastic_SharedContact makeContact(NodeNum num, const char *longName, const char *shortName, uint8_t keySeed = 0)
|
|
{
|
|
meshtastic_SharedContact c = meshtastic_SharedContact_init_zero;
|
|
c.node_num = num;
|
|
c.has_user = true;
|
|
c.user = makeUser(longName, shortName, keySeed);
|
|
return c;
|
|
}
|
|
|
|
void assertStoredKeyEquals(NodeNum num, uint8_t seed)
|
|
{
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(num);
|
|
TEST_ASSERT_NOT_NULL(info);
|
|
TEST_ASSERT_EQUAL(32, info->public_key.size);
|
|
uint8_t expected[32];
|
|
memset(expected, seed, 32);
|
|
expected[0] = 0x01;
|
|
TEST_ASSERT_EQUAL_MEMORY(expected, info->public_key.bytes, 32);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// --- addFromContact ---
|
|
|
|
// The #11432 regression: a stored 32-byte key plus a contact with has_user=true
|
|
// but no key must keep the stored key bit-for-bit while still merging the user
|
|
// fields (clients send add_contact before every DM, usually keyless).
|
|
static void test_contact_keyless_preserves_stored_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL"));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); // merge still applied
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(info)); // anti-eviction stamp for normal roles
|
|
}
|
|
|
|
// The guard blocks erasure, not update: a contact carrying a different valid
|
|
// 32-byte key replaces the stored one (the QR contact-sharing flow).
|
|
static void test_contact_new_key_updates_stored_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x77));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x77);
|
|
}
|
|
|
|
// A manually-verified pin refuses the ENTIRE update from a non-verified contact
|
|
// whose key mismatches - name and key both stay untouched.
|
|
static void test_contact_verified_pin_blocks_mismatched_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Mallory", "MA", /*keySeed=*/0x77));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("", info->long_name); // refused wholesale, not just the key
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); // returned before the favorite stamp
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info));
|
|
}
|
|
|
|
// The verified pin also refuses a KEYLESS non-verified contact wholesale (a
|
|
// size mismatch is a key mismatch) - unlike the plain erasure guard below,
|
|
// which merges the user fields and only restores the key.
|
|
static void test_contact_verified_pin_blocks_keyless_unverified(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL")); // keyless, not verified
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name);
|
|
}
|
|
|
|
// A non-verified contact whose key MATCHES the verified pin may still update
|
|
// the user fields; the verified bit survives the merge.
|
|
static void test_contact_verified_pin_allows_matching_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice", info->long_name);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info));
|
|
}
|
|
|
|
// contact.manually_verified sets the bit, and a later plain update (here via
|
|
// updateUser with the pinned key) must not clear it - CopyUserToNodeInfoLite
|
|
// only touches the user-derived bits.
|
|
static void test_contact_manually_verified_bit_survives_updates(void)
|
|
{
|
|
meshtastic_SharedContact c = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42);
|
|
c.manually_verified = true;
|
|
db->addFromContact(c);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(db->getMeshNode(kPeer)));
|
|
|
|
meshtastic_User u = makeUser("Alice2", "A2", /*keySeed=*/0x42);
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u));
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice2", info->long_name);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info));
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
}
|
|
|
|
// should_ignore blocks the contact and drops its satellite data but keeps the
|
|
// stored public key: an ignored peer stays a verifiable identity.
|
|
static void test_contact_should_ignore_blocks_but_keeps_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
meshtastic_PositionLite pos = meshtastic_PositionLite_init_zero;
|
|
pos.latitude_i = 123456789;
|
|
db->nodePositions[kPeer] = pos;
|
|
TEST_ASSERT_TRUE(db->hasNodePosition(kPeer));
|
|
#endif
|
|
|
|
meshtastic_SharedContact c = makeContact(kPeer, "Blocked", "BL"); // keyless on purpose
|
|
c.should_ignore = true;
|
|
db->addFromContact(c);
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_NOT_NULL(info);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(info));
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info));
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
TEST_ASSERT_FALSE(db->hasNodePosition(kPeer));
|
|
#endif
|
|
assertStoredKeyEquals(kPeer, 0x42); // key retained through the keyless ignore contact
|
|
}
|
|
|
|
// CLIENT_BASE must not auto-favorite (is_favorite has special meaning there);
|
|
// the anti-eviction protection is a heard-now stamp instead.
|
|
static void test_contact_client_base_stamps_heard_not_favorite(void)
|
|
{
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_BASE;
|
|
|
|
db->addFromContact(makeContact(kPeer, "Alice", "AL"));
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_NOT_NULL(info);
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info));
|
|
// initializeTestEnvironment() set an NTP-quality RTC, so the stamp lands in last_heard.
|
|
TEST_ASSERT_NOT_EQUAL(0, info->last_heard);
|
|
}
|
|
|
|
// A contact without a user payload must not merge fields or apply should_ignore to an
|
|
// existing node. (getOrCreateMeshNode still runs first, so an unknown num would be
|
|
// admitted as a blank row - that path is not covered here.)
|
|
static void test_contact_without_user_is_noop(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
const size_t countBefore = db->getNumMeshNodes();
|
|
|
|
meshtastic_SharedContact c = meshtastic_SharedContact_init_zero;
|
|
c.node_num = kPeer;
|
|
c.has_user = false;
|
|
c.should_ignore = true;
|
|
db->addFromContact(c);
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(info));
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_UINT(countBefore, db->getNumMeshNodes()); // existing node: no new row admitted
|
|
}
|
|
|
|
// --- updateUser ---
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
|
|
// A pinned 32-byte key is immutable against a NodeInfo carrying a different key.
|
|
static void test_updateuser_pinned_key_blocks_mismatch(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
meshtastic_User u = makeUser("Mallory", "MA", /*keySeed=*/0x77);
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); // dropped wholesale
|
|
}
|
|
|
|
// ...and against a NodeInfo carrying NO key: unlike addFromContact, updateUser
|
|
// drops a keyless update for a pinned node entirely.
|
|
static void test_updateuser_keyless_nodeinfo_dropped_wholesale(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42);
|
|
|
|
meshtastic_User u = makeUser("Alice", "AL");
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name);
|
|
}
|
|
|
|
// First key for a node is accepted (TOFU) and the reach-channel is stamped.
|
|
static void test_updateuser_first_key_accepted(void)
|
|
{
|
|
db->push(kPeer, 1000);
|
|
|
|
meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42);
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u, /*channelIndex=*/3));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_EQUAL_STRING("Alice", info->long_name);
|
|
TEST_ASSERT_EQUAL(3, info->channel);
|
|
}
|
|
|
|
// A remote node advertising OUR public key is refused with exactly one
|
|
// ClientNotification; the duplicateWarned latch silences the second attempt.
|
|
static void test_updateuser_own_key_advert_notifies_once(void)
|
|
{
|
|
fillKey(owner.public_key, 0x5A);
|
|
meshtastic_User u = makeUser("Evil twin", "ET", /*keySeed=*/0x5A);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
TEST_ASSERT_EQUAL(1, mockService->notificationCount);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u));
|
|
TEST_ASSERT_EQUAL(1, mockService->notificationCount); // latched
|
|
}
|
|
|
|
// user.id is always re-derived from the node number, whatever the payload claims.
|
|
static void test_updateuser_id_derived_from_nodenum(void)
|
|
{
|
|
meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42);
|
|
strncpy(u.id, "!deadbeef", sizeof(u.id) - 1);
|
|
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u));
|
|
|
|
char expected[16];
|
|
snprintf(expected, sizeof(expected), "!%08x", (unsigned)kPeer);
|
|
TEST_ASSERT_EQUAL_STRING(expected, u.id);
|
|
}
|
|
|
|
// A known XEdDSA signer's identity only changes via a signed update - even a
|
|
// same-key name change arriving unsigned is refused.
|
|
static void test_updateuser_unsigned_update_refused_for_hot_signer(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true);
|
|
meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42);
|
|
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false));
|
|
TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name);
|
|
|
|
TEST_ASSERT_TRUE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); // signed control
|
|
TEST_ASSERT_EQUAL_STRING("New name", db->getMeshNode(kPeer)->long_name);
|
|
}
|
|
|
|
// The key pin outranks the signature: a signed update still cannot rotate a
|
|
// pinned key (rotation goes through commitRemoteKey's proven paths instead).
|
|
static void test_updateuser_signed_update_cannot_rotate_pinned_key(void)
|
|
{
|
|
db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true);
|
|
|
|
meshtastic_User u = makeUser("Rotated", "RO", /*keySeed=*/0x77);
|
|
TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true));
|
|
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
}
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
// The signer gate runs BEFORE getOrCreateMeshNode, so refusing an unsigned
|
|
// update for a warm-tier signer must not evict a hot node, must not re-admit
|
|
// the signer, and must not consume its warm record.
|
|
static void test_updateuser_warm_signer_refusal_does_not_evict(void)
|
|
{
|
|
const NodeNum signerNum = 0xE2000000 + 3;
|
|
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected demote to warm
|
|
for (int i = 1; i <= extra; i++)
|
|
db->push(0xE2000000 + i, /*lastHeard=*/i, /*keySeed=*/0x42);
|
|
nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
|
|
|
db->runDemote();
|
|
|
|
TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // demoted out of hot
|
|
TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum));
|
|
TEST_ASSERT_TRUE(db->isFull());
|
|
const int hotBefore = (int)db->getNumMeshNodes();
|
|
|
|
meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42);
|
|
TEST_ASSERT_FALSE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/false));
|
|
|
|
TEST_ASSERT_EQUAL_INT(hotBefore, (int)db->getNumMeshNodes());
|
|
TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // not re-admitted
|
|
TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); // warm record intact (take() never ran)
|
|
|
|
// Signed control: the same update signed is accepted and re-admits the
|
|
// signer from warm with its key and signer bit restored.
|
|
TEST_ASSERT_TRUE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/true));
|
|
const meshtastic_NodeInfoLite *back = db->getMeshNode(signerNum);
|
|
TEST_ASSERT_NOT_NULL(back);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(back));
|
|
TEST_ASSERT_EQUAL_STRING("New name", back->long_name);
|
|
assertStoredKeyEquals(signerNum, 0x42);
|
|
}
|
|
#endif // WARM_NODE_COUNT > 0
|
|
|
|
#endif // !(MESHTASTIC_EXCLUDE_PKI)
|
|
|
|
// --- persistence ---
|
|
|
|
// The erasure guard's outcome must survive the disk round trip: after a keyless
|
|
// add_contact against a pinned key, a rebooted NodeDB still holds the full key
|
|
// (pre-#11432 the zeroed key was persisted, breaking DMs until re-exchange).
|
|
static void test_contact_key_guard_survives_reboot(void)
|
|
{
|
|
// saveNodeDatabaseToDisk() skips keyless devices, so give ourselves a key.
|
|
fillKey(owner.public_key, 0x5A);
|
|
|
|
meshtastic_SharedContact keyed = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42);
|
|
keyed.manually_verified = true;
|
|
db->addFromContact(keyed); // persists
|
|
// The keyless pre-DM contact for a verified node also carries manually_verified
|
|
// (a non-verified keyless contact would be refused by the verified pin instead).
|
|
meshtastic_SharedContact keyless = makeContact(kPeer, "Al2", "A2");
|
|
keyless.manually_verified = true;
|
|
db->addFromContact(keyless); // keyless merge; persists the guard result
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
|
|
// A real cold boot starts with a zeroed nodeDatabase global; in-process the decode
|
|
// callback appends on top of the previous boot's rows, so without this reset the
|
|
// lookups below would find the pre-reboot RAM row and the persistence claim is vacuous.
|
|
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;
|
|
|
|
const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer);
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(info, "contact must survive the reload");
|
|
assertStoredKeyEquals(kPeer, 0x42);
|
|
TEST_ASSERT_EQUAL_STRING("Al2", info->long_name);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); // pin survives the reboot too
|
|
}
|
|
|
|
// --- Unity lifecycle ---
|
|
|
|
void setUp(void)
|
|
{
|
|
savedOwner = owner;
|
|
savedConfig = config;
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
owner.public_key.size = 0;
|
|
|
|
mockService = new MockMeshService();
|
|
service = mockService;
|
|
|
|
db->clearHot();
|
|
db->seedSelf();
|
|
db->resetDuplicateWarned();
|
|
}
|
|
|
|
void tearDown(void)
|
|
{
|
|
owner = savedOwner;
|
|
config = savedConfig;
|
|
service = nullptr;
|
|
delete mockService;
|
|
mockService = nullptr;
|
|
}
|
|
|
|
IH_TEST_ENTRY void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
#ifdef FSCom
|
|
// NodeDB and MessageStore bracket their FS writes with spiLock; nothing in the
|
|
// test environment creates it, so do it here (initSPI asserts it only runs once).
|
|
if (!spiLock)
|
|
initSPI();
|
|
#endif
|
|
db = new NodeDBTestShim();
|
|
nodeDB = db;
|
|
|
|
UNITY_BEGIN();
|
|
|
|
printf("\n=== addFromContact guards ===\n");
|
|
RUN_TEST(test_contact_keyless_preserves_stored_key);
|
|
RUN_TEST(test_contact_new_key_updates_stored_key);
|
|
RUN_TEST(test_contact_verified_pin_blocks_mismatched_key);
|
|
RUN_TEST(test_contact_verified_pin_blocks_keyless_unverified);
|
|
RUN_TEST(test_contact_verified_pin_allows_matching_key);
|
|
RUN_TEST(test_contact_manually_verified_bit_survives_updates);
|
|
RUN_TEST(test_contact_should_ignore_blocks_but_keeps_key);
|
|
RUN_TEST(test_contact_client_base_stamps_heard_not_favorite);
|
|
RUN_TEST(test_contact_without_user_is_noop);
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
printf("\n=== updateUser key pinning ===\n");
|
|
RUN_TEST(test_updateuser_pinned_key_blocks_mismatch);
|
|
RUN_TEST(test_updateuser_keyless_nodeinfo_dropped_wholesale);
|
|
RUN_TEST(test_updateuser_first_key_accepted);
|
|
RUN_TEST(test_updateuser_own_key_advert_notifies_once);
|
|
RUN_TEST(test_updateuser_id_derived_from_nodenum);
|
|
RUN_TEST(test_updateuser_unsigned_update_refused_for_hot_signer);
|
|
RUN_TEST(test_updateuser_signed_update_cannot_rotate_pinned_key);
|
|
#if WARM_NODE_COUNT > 0
|
|
RUN_TEST(test_updateuser_warm_signer_refusal_does_not_evict);
|
|
#endif
|
|
#endif
|
|
|
|
printf("\n=== persistence ===\n");
|
|
RUN_TEST(test_contact_key_guard_survives_reboot);
|
|
|
|
exit(UNITY_END());
|
|
}
|
|
IH_TEST_ENTRY void loop() {}
|