Files
Ben MeadorsandClaude Opus 5 83fd62b756 test(native): add 14 suites for routing, persistence, parsing and identity gaps (#11515)
* 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>
2026-08-18 12:41:08 +00:00

397 lines
17 KiB
C++

// NodeDB boot-recovery contract: an undecodable config.proto must freeze identity (no keygen, no
// overwrite), an absent one takes the fresh-install path, and a corrupt nodes.proto does neither.
// The tests are a ladder (state=per-suite): arrange /prefs, then "reboot" a fresh NodeDB.
#include "MeshTypes.h" // Include BEFORE TestUtil.h
#include "TestUtil.h"
#include <unity.h>
#if defined(ARCH_PORTDUINO)
#define NBR_TEST_ENTRY extern "C"
#else
#define NBR_TEST_ENTRY
#endif
#include "FSCommon.h" // defines FSCom; must precede the feature guard below
// The identity-freeze contract only exists where there is a filesystem and boot keygen.
#if defined(FSCom) && !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
#include "mesh/NodeDB.h"
#include "mesh/TypeConversions.h"
#include <ErriezCRC32.h>
#include <cstdio>
#include <cstring>
#include <vector>
// Friend seam declared in NodeDB.h (PIO_UNIT_TESTING): read the private degraded-boot flag.
// Never instantiated - constructing one would run the real boot sequence.
class NodeDBTestShim : public NodeDB
{
public:
static bool decodeFailed(const NodeDB *db) { return db->configDecodeFailed; }
};
namespace
{
// --- Identity baseline captured after a healthy keyed boot ---
uint32_t baseNodeNum = 0;
uint8_t basePublicKey[32];
uint8_t basePrivateKey[32];
char baseLongName[sizeof(meshtastic_User::long_name)];
std::vector<uint8_t> goodConfigBytes; // byte-exact healthy config.proto for restore tests
// --- File helpers (through FSCom so the tests stay agnostic about the mountpoint) ---
bool readFileBytes(const char *path, std::vector<uint8_t> &out)
{
out.clear();
File f = FSCom.open(path, FILE_O_READ);
if (!f)
return false;
uint8_t buf[512];
size_t n;
while ((n = f.read(buf, sizeof(buf))) > 0)
out.insert(out.end(), buf, buf + n);
f.close();
return true;
}
void writeFileBytes(const char *path, const uint8_t *data, size_t len)
{
FSCom.remove(path); // FILE_O_WRITE is append on some backends; start clean
File f = FSCom.open(path, FILE_O_WRITE);
TEST_ASSERT_TRUE_MESSAGE(f, path);
TEST_ASSERT_EQUAL_size_t(len, f.write(data, len));
f.close();
}
// FNV-1a content fingerprint; answers only "did this file change?". 0 == missing file.
uint64_t fileFingerprint(const char *path)
{
std::vector<uint8_t> bytes;
if (!readFileBytes(path, bytes))
return 0;
uint64_t h = 1469598103934665603ULL;
for (uint8_t b : bytes) {
h ^= b;
h *= 1099511628211ULL;
}
return h;
}
// A varint tag of five 0xFF bytes overflows 32 bits, so nanopb fails deterministically.
const uint8_t kGarbage[32] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
// --- Reboot helper ---
// A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous
// boot's vector (the decode callback appends, it does not clear), so reset it first.
void rebootNodeDB()
{
nodeDatabase.version = 0;
nodeDatabase.nodes.clear();
NodeDB *rebooted = new NodeDB();
delete nodeDB;
nodeDB = rebooted;
}
void captureIdentityBaseline()
{
TEST_ASSERT_EQUAL(32, config.security.public_key.size);
TEST_ASSERT_EQUAL(32, config.security.private_key.size);
TEST_ASSERT_EQUAL(32, owner.public_key.size);
baseNodeNum = myNodeInfo.my_node_num;
memcpy(basePublicKey, config.security.public_key.bytes, 32);
memcpy(basePrivateKey, config.security.private_key.bytes, 32);
strncpy(baseLongName, owner.long_name, sizeof(baseLongName));
baseLongName[sizeof(baseLongName) - 1] = '\0';
TEST_ASSERT_TRUE(readFileBytes(configFileName, goodConfigBytes));
TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size());
}
// Persist a set region so boot keygen is unconditionally armed (generateCryptoKeyPair skips
// while region == UNSET unless the portduino sim-radio bypass applies), then reboot into the
// healthy keyed state every later test measures against.
void establishHealthyBaseline()
{
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG));
rebootNodeDB();
// Reboot once more so any boot-time coercion of the freshly saved config (preset clamp)
// has reached its fixpoint on disk before we fingerprint it as the "good" file.
rebootNodeDB();
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
captureIdentityBaseline();
}
void assertIdentityMatchesBaseline()
{
TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num);
TEST_ASSERT_EQUAL(32, config.security.public_key.size);
TEST_ASSERT_EQUAL_MEMORY(basePublicKey, config.security.public_key.bytes, 32);
TEST_ASSERT_EQUAL(32, config.security.private_key.size);
TEST_ASSERT_EQUAL_MEMORY(basePrivateKey, config.security.private_key.bytes, 32);
TEST_ASSERT_EQUAL(32, owner.public_key.size);
TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32);
}
} // namespace
void setUp(void) {}
void tearDown(void) {}
// --- Healthy-boot identity ---
// The #11001 renumber family: a keyed boot must mint NodeNum == crc32(public_key) once, and
// every subsequent reboot must reproduce the same NodeNum, keypair and owner identity.
static void test_firstBoot_establishesKeyedIdentity(void)
{
TEST_MESSAGE("=== First keyed boot mints crc32(pubkey) identity ===");
establishHealthyBaseline();
TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num);
// The minted identity is in the store of record: self entry present, carrying our key.
const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum());
TEST_ASSERT_NOT_NULL(self);
TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self));
TEST_ASSERT_EQUAL(32, self->public_key.size);
TEST_ASSERT_EQUAL_MEMORY(basePublicKey, self->public_key.bytes, 32);
TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB));
}
static void test_healthyReboot_preservesIdentity(void)
{
TEST_MESSAGE("=== Plain reboot: identity byte-identical, config.proto not rewritten ===");
const uint64_t fpBefore = fileFingerprint(configFileName);
TEST_ASSERT_NOT_EQUAL(0, fpBefore);
rebootNodeDB();
assertIdentityMatchesBaseline();
TEST_ASSERT_EQUAL_STRING(baseLongName, owner.long_name);
// A healthy boot has nothing to persist for config: the on-disk file is already the fixpoint.
TEST_ASSERT_EQUAL_UINT64(fpBefore, fileFingerprint(configFileName));
}
// --- Degraded boot: present-but-undecodable config ---
static void test_corruptConfig_freezesIdentity_leavesFileUntouched(void)
{
TEST_MESSAGE("=== Corrupt config.proto: frozen identity, radio silent, file untouched ===");
writeFileBytes(configFileName, kGarbage, sizeof(kGarbage));
const uint64_t fpGarbage = fileFingerprint(configFileName);
TEST_ASSERT_NOT_EQUAL(0, fpGarbage);
rebootNodeDB();
TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB));
// Radio silent until the operator restores a config.
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region);
TEST_ASSERT_FALSE(config.lora.tx_enabled);
// Keygen skipped: no replacement keypair minted into RAM...
TEST_ASSERT_EQUAL(0, config.security.private_key.size);
// ...and the identity carried by devicestate is untouched, so the NodeNum cannot move.
TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num);
TEST_ASSERT_EQUAL(32, owner.public_key.size);
TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32);
// The boot must not have overwritten the (maybe transiently) corrupt file with defaults.
TEST_ASSERT_EQUAL_UINT64(fpGarbage, fileFingerprint(configFileName));
}
// Runs against the still-degraded NodeDB from the previous test: runtime reconfiguration
// (admin set_config -> saveToDisk) must not be permanently blocked by the boot freeze.
static void test_degradedBoot_runtimeConfigSaveStillPersists(void)
{
TEST_MESSAGE("=== Degraded boot: an explicit runtime config save still lands ===");
TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB));
const uint64_t fpGarbage = fileFingerprint(configFileName);
TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG));
TEST_ASSERT_NOT_EQUAL(fpGarbage, fileFingerprint(configFileName));
// What landed is a decodable config again (the degraded-boot defaults).
static meshtastic_LocalConfig scratch;
TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(configFileName, meshtastic_LocalConfig_size,
sizeof(scratch), &meshtastic_LocalConfig_msg, &scratch));
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, scratch.lora.region);
}
static void test_restoredConfig_recoversOriginalIdentity(void)
{
TEST_MESSAGE("=== Good config bytes restored: next boot is normal with the ORIGINAL identity ===");
writeFileBytes(configFileName, goodConfigBytes.data(), goodConfigBytes.size());
rebootNodeDB();
TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB));
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
TEST_ASSERT_TRUE(config.lora.tx_enabled);
assertIdentityMatchesBaseline();
}
// --- Absent config: fresh install, not a freeze ---
static void test_absentConfig_takesFreshInstallPath(void)
{
TEST_MESSAGE("=== Absent config.proto: OTHER_FAILURE -> defaults + fresh keypair ===");
uint8_t previousPublicKey[32];
memcpy(previousPublicKey, basePublicKey, 32);
TEST_ASSERT_TRUE(FSCom.remove(configFileName));
rebootNodeDB();
// No usable contents to protect, so this is NOT the frozen path.
TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB));
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region);
// Re-arm keygen (region gate) and reboot into the replacement identity.
establishHealthyBaseline(); // re-captures the baseline for the remaining tests
// A fresh install mints a new keypair - and with it a new NodeNum, still crc32-derived.
// (This is the flip side of the DECODE_FAILED freeze: with the file genuinely gone there
// is no identity left to preserve.)
TEST_ASSERT_EQUAL(32, config.security.public_key.size);
TEST_ASSERT_TRUE(memcmp(previousPublicKey, config.security.public_key.bytes, 32) != 0);
TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num);
TEST_ASSERT_TRUE(FSCom.exists(configFileName));
}
// --- Freeze is config-scoped ---
static void test_corruptNodesDb_doesNotFreezeIdentity(void)
{
TEST_MESSAGE("=== Corrupt nodes.proto alone: config loads, keygen runs, NodeNum kept ===");
const uint64_t fpConfig = fileFingerprint(configFileName);
writeFileBytes(nodeDatabaseFileName, kGarbage, sizeof(kGarbage));
rebootNodeDB();
TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB));
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
assertIdentityMatchesBaseline();
// The store rebuilt from defaults still contains us.
const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum());
TEST_ASSERT_NOT_NULL(self);
TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self));
TEST_ASSERT_EQUAL_UINT64(fpConfig, fileFingerprint(configFileName));
}
// --- Devicestate-loss owner recovery ---
// The recovery block in loadFromDisk() fires when device.proto decodes but is below
// DEVICESTATE_MIN_VER: identity fields survive (my_node_num is in the decoded struct), the
// defaults overwrite the owner names, and the own-node entry in nodes.proto restores them.
static void test_oldDevicestate_recoversOwnerFromNodeDb(void)
{
TEST_MESSAGE("=== Old-version devicestate: owner names recovered from own NodeDB entry ===");
// Put the recoverable names into the store of record...
strncpy(owner.long_name, "Recovered Owner", sizeof(owner.long_name));
strncpy(owner.short_name, "RCVR", sizeof(owner.short_name));
meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum());
TEST_ASSERT_NOT_NULL(self);
TypeConversions::CopyUserToNodeInfoLite(self, owner);
TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_NODEDATABASE));
// ...then persist a devicestate that is valid but too old, carrying DIFFERENT names, so a
// recovered name can only have come from the nodes.proto entry.
strncpy(owner.long_name, "Stale Devicestate", sizeof(owner.long_name));
strncpy(owner.short_name, "STAL", sizeof(owner.short_name));
devicestate.version = DEVICESTATE_MIN_VER - 1;
TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_DEVICESTATE));
rebootNodeDB();
TEST_ASSERT_EQUAL_STRING("Recovered Owner", owner.long_name);
TEST_ASSERT_EQUAL_STRING("RCVR", owner.short_name);
// Identity survives the devicestate discard: the NodeNum in the old file is carried over
// and keygen re-derives the same crc32(public_key) value.
assertIdentityMatchesBaseline();
// The recovery is re-persisted: the on-disk devicestate is current-version with the
// recovered names, not the stale ones.
static meshtastic_DeviceState saved;
TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(deviceStateFileName, meshtastic_DeviceState_size,
sizeof(saved), &meshtastic_DeviceState_msg, &saved));
TEST_ASSERT_EQUAL(DEVICESTATE_CUR_VER, saved.version);
TEST_ASSERT_EQUAL_STRING("Recovered Owner", saved.owner.long_name);
}
// --- loadProto classification ---
// The wipe cascade lived in the difference between these verdicts: DECODE_FAILED is the only
// protected path, and loadProto never returns NOT_FOUND (an unopenable file is OTHER_FAILURE).
static void test_loadProto_classifiesFailuresDistinctly(void)
{
TEST_MESSAGE("=== loadProto: absent=OTHER_FAILURE, garbage/truncated=DECODE_FAILED ===");
const char *scratchPath = "/prefs/nbr_scratch.proto";
static meshtastic_LocalConfig scratch;
FSCom.remove(scratchPath);
TEST_ASSERT_EQUAL(LoadFileResult::OTHER_FAILURE, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch),
&meshtastic_LocalConfig_msg, &scratch));
writeFileBytes(scratchPath, kGarbage, sizeof(kGarbage));
TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch),
&meshtastic_LocalConfig_msg, &scratch));
// A torn write: a valid encoding minus its final byte always cuts the last field short.
TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size());
writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size() - 1);
TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch),
&meshtastic_LocalConfig_msg, &scratch));
// The unmodified bytes still decode - the failure above was the truncation, nothing else.
writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size());
TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch),
&meshtastic_LocalConfig_msg, &scratch));
FSCom.remove(scratchPath); // leave nothing behind
}
NBR_TEST_ENTRY void setup()
{
initializeTestEnvironment();
nodeDB = new NodeDB(); // first boot on the pristine per-suite sandbox
UNITY_BEGIN();
printf("\n=== Healthy-boot identity ===\n");
RUN_TEST(test_firstBoot_establishesKeyedIdentity);
RUN_TEST(test_healthyReboot_preservesIdentity);
printf("\n=== Degraded boot (corrupt config) ===\n");
RUN_TEST(test_corruptConfig_freezesIdentity_leavesFileUntouched);
RUN_TEST(test_degradedBoot_runtimeConfigSaveStillPersists);
RUN_TEST(test_restoredConfig_recoversOriginalIdentity);
printf("\n=== Fresh install vs freeze scoping ===\n");
RUN_TEST(test_absentConfig_takesFreshInstallPath);
RUN_TEST(test_corruptNodesDb_doesNotFreezeIdentity);
printf("\n=== Devicestate recovery + loadProto classification ===\n");
RUN_TEST(test_oldDevicestate_recoversOwnerFromNodeDb);
RUN_TEST(test_loadProto_classifiesFailuresDistinctly);
exit(UNITY_END());
}
NBR_TEST_ENTRY void loop() {}
#else // !FSCom || PKI excluded
void setUp(void) {}
void tearDown(void) {}
NBR_TEST_ENTRY void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
NBR_TEST_ENTRY void loop() {}
#endif