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

575 lines
24 KiB
C++

// PhoneAPI::getFromRadio() config-dump sequence, asserted on decoded FromRadio protobufs: the
// order client apps depend on, the heartbeat preempt, SPECIAL_NONCE_ONLY_* jumps, mid-dump
// restart, and the post-complete drain reaching idle.
#include "MeshTypes.h"
#include "TestUtil.h"
#include <unity.h>
#include "Channels.h"
#include "CryptoEngine.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PhoneAPI.h"
#include "Router.h"
#include "mesh-pb-constants.h"
#include "meshtastic/admin.pb.h"
#include <cstdio>
#include <cstring>
#include <vector>
// File-scope flag in PhoneAPI.cpp: set by a client heartbeat, cleared by the queueStatus reply.
extern bool heartbeatReceived;
namespace
{
constexpr uint32_t FULL_DUMP_NONCE = 0x51C0FFEE;
constexpr uint32_t SECOND_NONCE = 0x0DDBA11;
constexpr NodeNum SEEDED_NODE_A = 0x00000A01;
constexpr NodeNum SEEDED_NODE_B = 0x00000A02;
constexpr unsigned NUM_SINGLETON_PREFIX = 5; // my_info, deviceuiConfig, own node_info, metadata, region_presets
constexpr unsigned NUM_CONFIG_MESSAGES = _meshtastic_AdminMessage_ConfigType_MAX + 1;
constexpr unsigned NUM_MODULE_CONFIG_MESSAGES = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1;
// STATE_SEND_CONFIG iterates config_state over the AdminMessage ConfigType enum but emits
// Config oneof tags: a proto bump that grows one without the other makes a config message
// carry inner variant 0. The static_asserts turn that drift into a compile error here.
const pb_size_t kExpectedConfigVariants[] = {
meshtastic_Config_device_tag, meshtastic_Config_position_tag, meshtastic_Config_power_tag,
meshtastic_Config_network_tag, meshtastic_Config_display_tag, meshtastic_Config_lora_tag,
meshtastic_Config_bluetooth_tag, meshtastic_Config_security_tag, meshtastic_Config_sessionkey_tag,
meshtastic_Config_device_ui_tag,
};
static_assert(sizeof(kExpectedConfigVariants) / sizeof(kExpectedConfigVariants[0]) == NUM_CONFIG_MESSAGES,
"AdminMessage ConfigType enum and Config oneof diverged - update PhoneAPI's STATE_SEND_CONFIG and this list");
const pb_size_t kExpectedModuleConfigVariants[] = {
meshtastic_ModuleConfig_mqtt_tag,
meshtastic_ModuleConfig_serial_tag,
meshtastic_ModuleConfig_external_notification_tag,
meshtastic_ModuleConfig_store_forward_tag,
meshtastic_ModuleConfig_range_test_tag,
meshtastic_ModuleConfig_telemetry_tag,
meshtastic_ModuleConfig_canned_message_tag,
meshtastic_ModuleConfig_audio_tag,
meshtastic_ModuleConfig_remote_hardware_tag,
meshtastic_ModuleConfig_neighbor_info_tag,
meshtastic_ModuleConfig_ambient_lighting_tag,
meshtastic_ModuleConfig_detection_sensor_tag,
meshtastic_ModuleConfig_paxcounter_tag,
meshtastic_ModuleConfig_statusmessage_tag,
meshtastic_ModuleConfig_traffic_management_tag,
meshtastic_ModuleConfig_tak_tag,
#if !MESHTASTIC_EXCLUDE_BEACON
meshtastic_ModuleConfig_mesh_beacon_tag,
#else
0, // beacon compiled out: the slot still ships, as an empty ModuleConfig
#endif
};
static_assert(sizeof(kExpectedModuleConfigVariants) / sizeof(kExpectedModuleConfigVariants[0]) == NUM_MODULE_CONFIG_MESSAGES,
"AdminMessage ModuleConfigType enum and ModuleConfig oneof diverged - update STATE_SEND_MODULECONFIG and this "
"list");
/// PhoneAPI over a permanently-connected fake transport.
class PhoneAPITestShim : public PhoneAPI
{
protected:
bool checkIsConnected() override { return true; }
};
/// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue.
class TestRouter : public Router
{
public:
// Router's ctor allocated the global cryptLock; nothing else frees it.
~TestRouter()
{
delete cryptLock;
cryptLock = nullptr;
}
};
// Saved-global fixture, template test_event_channel_phone_api. Restored in tearDown() rather
// than by RAII because a failed TEST_ASSERT longjmps out of the test without running destructors.
struct GlobalState {
MeshService *service;
Router *router;
NodeDB *nodeDB;
concurrency::Lock *cryptLock;
meshtastic_MyNodeInfo myNodeInfo;
Channels channels;
meshtastic_ChannelFile channelFile;
meshtastic_LocalConfig config;
meshtastic_LocalModuleConfig moduleConfig;
meshtastic_DeviceState deviceState;
};
GlobalState *savedState = nullptr;
MeshService *mockService = nullptr;
TestRouter *testRouter = nullptr;
NodeDB *testNodeDB = nullptr;
PhoneAPITestShim *api = nullptr;
/// Give every channel slot a distinct index so the dump's 0..7 ordering is observable.
void configureTestChannels()
{
channelFile = meshtastic_ChannelFile_init_default;
channelFile.channels_count = MAX_NUM_CHANNELS;
for (pb_size_t i = 0; i < MAX_NUM_CHANNELS; i++) {
channelFile.channels[i].index = (int8_t)i;
channelFile.channels[i].has_settings = true;
channelFile.channels[i].role = i == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY;
}
channels.onConfigChanged();
}
/// Create a remote node in the scratch NodeDB the way received traffic would.
void seedRemoteNode(NodeNum num)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p.from = num;
p.to = NODENUM_BROADCAST;
nodeDB->updateFrom(p);
}
bool sendToRadio(const meshtastic_ToRadio &message)
{
uint8_t encoded[meshtastic_ToRadio_size] = {};
const size_t encodedSize =
pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ToRadio_msg, const_cast<meshtastic_ToRadio *>(&message));
TEST_ASSERT_GREATER_THAN_UINT(0, encodedSize);
return api->handleToRadio(encoded, encodedSize);
}
void startHandshake(uint32_t nonce)
{
meshtastic_ToRadio request = meshtastic_ToRadio_init_zero;
request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag;
request.want_config_id = nonce;
sendToRadio(request);
}
void sendPlainHeartbeat()
{
meshtastic_ToRadio hb = meshtastic_ToRadio_init_zero;
hb.which_payload_variant = meshtastic_ToRadio_heartbeat_tag;
hb.heartbeat = meshtastic_Heartbeat_init_zero; // nonce 0 = plain keepalive, expects a queueStatus reply
sendToRadio(hb);
}
/// One decoded FromRadio pulled off the wire; zero-length reads return false.
bool readOneFromRadio(meshtastic_FromRadio &out)
{
uint8_t buf[meshtastic_FromRadio_size];
const size_t len = api->getFromRadio(buf);
if (len == 0)
return false;
out = meshtastic_FromRadio_init_zero;
TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(buf, len, &meshtastic_FromRadio_msg, &out),
"device emitted an undecodable FromRadio");
return true;
}
/// Everything the dump emitted, in order, as decoded facts rather than internals.
struct DumpTranscript {
std::vector<pb_size_t> variants; // outer which_payload_variant per message
std::vector<pb_size_t> configVariants; // inner variant of each FromRadio.config
std::vector<pb_size_t> moduleConfigVariants; // inner variant of each FromRadio.moduleConfig
std::vector<int> channelIndices;
std::vector<uint32_t> nodeNums;
unsigned fileInfoCount = 0;
unsigned queueStatusCount = 0;
uint32_t completeId = 0;
bool sawComplete = false;
};
/// Pull messages until config_complete_id; false if the stream stalls or overruns the cap.
bool drainUntilComplete(DumpTranscript &t, unsigned maxMessages = 600)
{
for (unsigned i = 0; i < maxMessages; i++) {
meshtastic_FromRadio msg;
if (!readOneFromRadio(msg))
return false;
t.variants.push_back(msg.which_payload_variant);
switch (msg.which_payload_variant) {
case meshtastic_FromRadio_config_tag:
t.configVariants.push_back(msg.config.which_payload_variant);
break;
case meshtastic_FromRadio_moduleConfig_tag:
t.moduleConfigVariants.push_back(msg.moduleConfig.which_payload_variant);
break;
case meshtastic_FromRadio_channel_tag:
t.channelIndices.push_back(msg.channel.index);
break;
case meshtastic_FromRadio_node_info_tag:
t.nodeNums.push_back(msg.node_info.num);
break;
case meshtastic_FromRadio_fileInfo_tag:
t.fileInfoCount++;
break;
case meshtastic_FromRadio_queueStatus_tag:
t.queueStatusCount++;
break;
case meshtastic_FromRadio_config_complete_id_tag:
t.completeId = msg.config_complete_id;
t.sawComplete = true;
return true;
default:
break;
}
}
return false;
}
unsigned countVariant(const DumpTranscript &t, pb_size_t tag)
{
unsigned n = 0;
for (pb_size_t v : t.variants)
if (v == tag)
n++;
return n;
}
/// Assert the two non-self node records are the seeded pair (DB iteration order not pinned).
void assertSeededPair(uint32_t first, uint32_t second)
{
const bool inOrder = first == SEEDED_NODE_A && second == SEEDED_NODE_B;
const bool swapped = first == SEEDED_NODE_B && second == SEEDED_NODE_A;
TEST_ASSERT_TRUE_MESSAGE(inOrder || swapped, "other node_infos are not the seeded pair");
}
// --- Tests ---
// The full documented sequence, section by section, ending in the nonce echo. Also pins the
// channel section: exactly MAX_NUM_CHANNELS messages, indices 0..7 in order, between
// region_presets and the first config.
void test_full_want_config_dump_emits_documented_sequence()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "dump stalled before config_complete_id");
const pb_size_t expectedPrefix[NUM_SINGLETON_PREFIX] = {
meshtastic_FromRadio_my_info_tag, meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag,
meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag};
TEST_ASSERT_GREATER_OR_EQUAL_UINT(NUM_SINGLETON_PREFIX, t.variants.size());
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX; i++)
TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "header sequence changed");
// Bound the raw indexing below: header + channels + configs + moduleConfigs + 2 seeded
// node_infos + complete is the minimum a full dump can be.
TEST_ASSERT_GREATER_OR_EQUAL_UINT(
NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 3, t.variants.size());
// Channel section: contiguous, complete, ordered.
const size_t channelStart = NUM_SINGLETON_PREFIX;
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) {
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_channel_tag, t.variants[channelStart + i]);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)i, t.channelIndices[i], "channels must arrive as indices 0..7 in order");
}
const size_t configStart = channelStart + MAX_NUM_CHANNELS;
for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++)
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_tag, t.variants[configStart + i]);
const size_t moduleStart = configStart + NUM_CONFIG_MESSAGES;
for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++)
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_moduleConfig_tag, t.variants[moduleStart + i]);
// Other node_infos follow the module configs; the own record was already sent in the header.
const size_t nodesStart = moduleStart + NUM_MODULE_CONFIG_MESSAGES;
TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size());
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
assertSeededPair(t.nodeNums[1], t.nodeNums[2]);
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart]);
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart + 1]);
// Everything between the node_infos and the completion id is file manifest (count is
// whatever the sandbox filesystem holds, so only the position is asserted).
for (size_t i = nodesStart + 2; i + 1 < t.variants.size(); i++)
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_fileInfo_tag, t.variants[i]);
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_complete_id_tag, t.variants.back());
TEST_ASSERT_EQUAL_UINT32_MESSAGE(FULL_DUMP_NONCE, t.completeId, "config_complete_id must echo the request nonce");
// Singletons exactly once, and no stray preempts.
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_my_info_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_metadata_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_region_presets_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag));
TEST_ASSERT_EQUAL_UINT(0, t.queueStatusCount);
TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 2 +
t.fileInfoCount + 1,
t.variants.size());
}
// Guards the ConfigType-enum-to-oneof-tag iteration: a desync emits a config message whose
// inner variant is 0, which every phone app decodes as an empty Config.
void test_config_section_inner_variants_match_config_type_enum()
{
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size());
for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) {
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, t.configVariants[i],
"config with inner variant 0: ConfigType enum drifted from the Config oneof");
TEST_ASSERT_EQUAL_UINT(kExpectedConfigVariants[i], t.configVariants[i]);
}
}
// Same closed-set guard for the module config section (the drift class already happened once,
// for statusmessage).
void test_module_config_section_inner_variants_match_module_config_type_enum()
{
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) {
if (kExpectedModuleConfigVariants[i] != 0)
TEST_ASSERT_NOT_EQUAL_MESSAGE(
0, t.moduleConfigVariants[i],
"moduleConfig with inner variant 0: ModuleConfigType enum drifted from the ModuleConfig oneof");
TEST_ASSERT_EQUAL_UINT(kExpectedModuleConfigVariants[i], t.moduleConfigVariants[i]);
}
}
// SPECIAL_NONCE_ONLY_NODES jumps straight to the node stream: own record, others, completion -
// no headers, channels, configs, or manifest.
void test_only_nodes_nonce_sends_nodes_then_complete()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(SPECIAL_NONCE_ONLY_NODES);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(4, t.variants.size()); // own + 2 seeded + complete
TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size());
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
assertSeededPair(t.nodeNums[1], t.nodeNums[2]);
TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId);
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_my_info_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_metadata_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_region_presets_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_channel_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_config_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_moduleConfig_tag));
TEST_ASSERT_EQUAL_UINT(0, t.fileInfoCount);
}
// SPECIAL_NONCE_ONLY_CONFIG delivers the full config but skips the non-self node DB, and must
// not arm the post-complete satellite replay.
void test_only_config_nonce_skips_other_nodeinfos()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(SPECIAL_NONCE_ONLY_CONFIG);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_node_info_tag)); // own record only
TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size());
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, countVariant(t, meshtastic_FromRadio_channel_tag));
TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size());
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_CONFIG, t.completeId);
// ONLY_CONFIG skips node/satellite sync entirely: the stream must be idle immediately.
uint8_t buf[meshtastic_FromRadio_size];
TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf));
TEST_ASSERT_FALSE(api->available());
}
// A keepalive heartbeat mid-dump preempts exactly one read with a queueStatus, then the dump
// resumes where it left off; the flag self-clears so nothing repeats or restarts.
void test_heartbeat_mid_dump_preempts_once_then_resumes()
{
startHandshake(FULL_DUMP_NONCE);
// Pull the first three header messages, leaving the machine about to send metadata.
meshtastic_FromRadio msg;
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant);
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_deviceuiConfig_tag, msg.which_payload_variant);
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant);
sendPlainHeartbeat();
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_queueStatus_tag, msg.which_payload_variant,
"heartbeat must be answered with a queueStatus before the dump continues");
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_metadata_tag, msg.which_payload_variant,
"dump must resume exactly where the heartbeat preempted it");
DumpTranscript rest;
TEST_ASSERT_TRUE(drainUntilComplete(rest));
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, rest.queueStatusCount, "heartbeat flag must self-clear after one reply");
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, countVariant(rest, meshtastic_FromRadio_my_info_tag),
"heartbeat must not restart the dump");
TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, rest.completeId);
}
// Disconnect mid-dump, then a fresh handshake: the machine restarts from my_info with the new
// nonce and every section is delivered exactly once.
void test_close_mid_dump_then_reconnect_restarts_clean()
{
seedRemoteNode(SEEDED_NODE_A);
startHandshake(FULL_DUMP_NONCE);
meshtastic_FromRadio msg;
for (unsigned i = 0; i < 5; i++)
TEST_ASSERT_TRUE(readOneFromRadio(msg));
api->close();
TEST_ASSERT_FALSE(api->isConnected());
uint8_t buf[meshtastic_FromRadio_size];
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api->getFromRadio(buf), "a closed connection must emit nothing");
startHandshake(SECOND_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, t.variants[0]);
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size());
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag));
TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId);
}
// A new want_config while a dump is in flight (no disconnect) also restarts the machine, and
// stale mid-section progress must not leak into the new dump.
void test_rehandshake_mid_dump_restarts_from_my_info()
{
startHandshake(FULL_DUMP_NONCE);
// Read into the middle of the config section (5 headers + 8 channels + 7 configs).
meshtastic_FromRadio msg;
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + 7; i++)
TEST_ASSERT_TRUE(readOneFromRadio(msg));
startHandshake(SECOND_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, t.variants[0], "re-handshake must restart from my_info");
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++)
TEST_ASSERT_EQUAL_INT((int)i, t.channelIndices[i]);
TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(),
"stale config_state leaked into the restarted dump");
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId);
}
// After config_complete_id the trailing satellite replay must reach idle in bounded reads - a
// drain loop keyed on available() must terminate (the infinite-drain regression class).
void test_dump_reaches_idle_after_complete()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
uint8_t buf[meshtastic_FromRadio_size];
bool idle = false;
for (unsigned i = 0; i < 8 && !idle; i++) {
if (!api->available())
idle = true;
else
api->getFromRadio(buf); // replay drain: empty phases must advance toward idle
}
TEST_ASSERT_TRUE_MESSAGE(idle, "post-complete drain never went idle: available() stuck true");
TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf));
}
} // namespace
void setUp(void)
{
savedState =
new GlobalState{service, router, nodeDB, cryptLock, myNodeInfo, channels, channelFile, config, moduleConfig, devicestate};
service = mockService = new MeshService();
// A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous
// test's vector (the decode callback appends, it does not clear), so reset it first.
nodeDatabase.version = 0;
nodeDatabase.nodes.clear();
nodeDB = testNodeDB = new NodeDB();
configureTestChannels();
cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own.
router = testRouter = new TestRouter();
api = new PhoneAPITestShim();
heartbeatReceived = false;
}
void tearDown(void)
{
delete api; // dtor runs close(), which still needs the mock service installed
api = nullptr;
delete testRouter; // ~TestRouter() deletes the cryptLock its ctor allocated
testRouter = nullptr;
delete testNodeDB;
testNodeDB = nullptr;
delete mockService;
mockService = nullptr;
heartbeatReceived = false;
service = savedState->service;
router = savedState->router;
nodeDB = savedState->nodeDB;
cryptLock = savedState->cryptLock; // ~TestRouter() nulled it; hand the saved router its own back
myNodeInfo = savedState->myNodeInfo;
channels = savedState->channels;
channelFile = savedState->channelFile;
config = savedState->config;
moduleConfig = savedState->moduleConfig;
devicestate = savedState->deviceState;
delete savedState;
savedState = nullptr;
}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
printf("\n=== want_config dump sequence ===\n");
RUN_TEST(test_full_want_config_dump_emits_documented_sequence);
RUN_TEST(test_config_section_inner_variants_match_config_type_enum);
RUN_TEST(test_module_config_section_inner_variants_match_module_config_type_enum);
printf("\n=== special nonces ===\n");
RUN_TEST(test_only_nodes_nonce_sends_nodes_then_complete);
RUN_TEST(test_only_config_nonce_skips_other_nodeinfos);
printf("\n=== preemption and restart ===\n");
RUN_TEST(test_heartbeat_mid_dump_preempts_once_then_resumes);
RUN_TEST(test_close_mid_dump_then_reconnect_restarts_clean);
RUN_TEST(test_rehandshake_mid_dump_restarts_from_my_info);
RUN_TEST(test_dump_reaches_idle_after_complete);
exit(UNITY_END());
}
void loop() {}