mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-13 06:41:45 -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>
575 lines
24 KiB
C++
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() {}
|