mirror of
https://github.com/meshtastic/firmware.git
synced 2026-11-06 14:24:20 -05:00
* Lock: give Portduino a real mutex instead of the empty fallback Lock.cpp has a FreeRTOS implementation and an empty one, and Portduino takes the empty one: every lock() and unlock() on a Linux build is a no-op, so concurrency::Lock protects nothing there. TrafficManagementModule's cacheLock and SPILock are both built on it, and native meshtasticd runs the radio and the API on separate threads. Add a pthread implementation under ARCH_PORTDUINO. The timed lock(uint32_t) blocks rather than returning early, because there is no portable timed pthread_mutex_lock across Linux and macOS and returning true without acquiring would leave a caller such as SPILock unlocking a mutex it never took. Targets that have neither FreeRTOS nor pthreads, such as STM32WL, keep the existing empty implementation byte for byte. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 * test: create spiLock in the shared setup, which NodeDB needs and no test had Making Portduino's Lock real turns a latent null dereference into a crash. spiLock is a bare pointer that initSPI() fills in, and only main.cpp calls that, so in a test binary it stays null. NodeDB's constructor reaches it through loadFromDisk(), and while Lock::lock() was an empty function the call never touched `this`, so 23 suites have been calling a method on a null pointer and getting away with it. With a pthread mutex behind it the same call reads through the null pointer and takes SIGSEGV at offset 0x10, which is what test_phone_api_config_dump, test_muted_source, test_nodeinfo_send_window and test_module_config hit. Create it once in initializeTestEnvironment(), which every affected suite calls as the first statement of setup(), before any of them constructs a NodeDB. The guard is the idiom test_xmodem and test_nodedb_identity_hygiene already use; theirs stay correct and become no-ops. test_safefile called initSPI() bare right after the harness, which would now trip its assert, so that call goes away. No firmware behaviour changes: main.cpp still calls initSPI() exactly once, and nothing outside the test harness is touched. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 * test: create cryptLock where a suite reaches it without building a Router Second instance of the same latent null dereference the previous commit fixed for spiLock. cryptLock is a bare pointer that Router's constructor creates (Router.cpp:246); AdminModule::setPassKey takes a LockGuard on it, and a suite that exercises an admin path without standing up a Router leaves it null. While Lock::lock() was empty on Portduino the guard never touched `this`; with a pthread mutex it reads through null, which is test_tak_config's SIGSEGV in handleGetModuleConfig. It cannot go in initializeTestEnvironment() the way spiLock did, because Router asserts cryptLock is unset before allocating its own, so creating it for every suite would break the ones that do build a Router. It is a named helper instead, testEnsureCryptLock(), called by the six suites that reach a cryptLock path with no Router: test_ack_proof, test_admin_session_repro, test_fuzz_packets, test_hop_scaling, test_module_config and test_tak_config. The three that define setup() twice behind a PKI #if get the call only in the branch that compiles the tests in. No firmware behaviour changes; nothing outside test/ is touched. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 * test: create cryptLock in the harness instead of chasing suites Router's constructor asserted cryptLock was unset, then allocated it. That assert is why ten suites carry a mock-router destructor whose only job is to delete the global and null it so the next router can be built. While Lock::lock() was an empty function on Portduino a null cryptLock cost nothing, so those null windows were invisible; with a real mutex, anything reaching perhapsDecode() or the ack-proof paths after one of those destructors runs dereferences null. The fix is the idiom already on the next line of the same constructor, which routingAuthCacheLock has used all along: reuse the lock if one exists. Nothing in src/ ever deleted cryptLock, so a Router that finds one is finding the process's only one. initializeTestEnvironment() can then create it for every suite, the way it now does for spiLock, and the ten teardowns and the per-suite helper from the previous commit all go away. Replaces the six testEnsureCryptLock() call sites with one creation point, and removes the null windows in test_admin_radio, test_mesh_beacon, test_mesh_module, test_mqtt, test_nexthop_routing, test_nodeinfo_send_window, test_traffic_management, test_event_channel_phone_api, test_event_channel_router and test_phone_api_config_dump. Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 --------- Co-authored-by: Claude <noreply@anthropic.com>
716 lines
31 KiB
C++
716 lines
31 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 MAX_IDLE_DRAIN_READS = 8; // replay phases left to drain after config_complete_id
|
|
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
|
|
{
|
|
public:
|
|
unsigned dataNotifications = 0; // transport wake-ups, i.e. "come and read"
|
|
|
|
protected:
|
|
bool checkIsConnected() override { return true; }
|
|
void onNowHasData(uint32_t fromRadioNum) override { dataNotifications++; }
|
|
};
|
|
|
|
/// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue.
|
|
class TestRouter : public Router
|
|
{
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// What the first region set does to a live node: a minted key moves my_node_num with it.
|
|
void mintIdentity()
|
|
{
|
|
config.security.public_key.size = 32;
|
|
memset(config.security.public_key.bytes, 0x5A, sizeof(config.security.public_key.bytes));
|
|
TEST_ASSERT_TRUE_MESSAGE(nodeDB->createNewIdentity(), "identity did not move");
|
|
}
|
|
|
|
/// Read until available() reports idle; false if it never does within the cap.
|
|
bool drainToIdle()
|
|
{
|
|
uint8_t buf[meshtastic_FromRadio_size];
|
|
for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS; i++) {
|
|
if (!api->available())
|
|
return true;
|
|
api->getFromRadio(buf);
|
|
}
|
|
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);
|
|
|
|
// This nonce skips my_info entirely, so the trailing replay must not produce one either.
|
|
for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS && api->available(); i++) {
|
|
meshtastic_FromRadio trailing;
|
|
if (readOneFromRadio(trailing))
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE(meshtastic_FromRadio_my_info_tag, trailing.which_payload_variant,
|
|
"nodes-only sync must not emit my_info");
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
|
|
TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle: available() stuck true");
|
|
uint8_t buf[meshtastic_FromRadio_size];
|
|
TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf));
|
|
}
|
|
|
|
// A packet queued while the client is still consuming config is not announced by onNotify().
|
|
// Once config completes, re-arm the transport so the queued packet cannot sleep until later traffic.
|
|
void test_packet_queued_mid_sync_is_announced_after_complete()
|
|
{
|
|
startHandshake(FULL_DUMP_NONCE);
|
|
api->dataNotifications = 0;
|
|
|
|
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
|
|
packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
|
packet.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
|
packet.from = SEEDED_NODE_A;
|
|
packet.to = NODENUM_BROADCAST;
|
|
packet.id = 0xA11CE001;
|
|
meshtastic_MeshPacket *queued = packetPool.allocCopy(packet);
|
|
TEST_ASSERT_NOT_NULL(queued);
|
|
service->sendToPhone(queued);
|
|
|
|
service->loop();
|
|
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api->dataNotifications, "mid-sync packet should not interrupt the config dump");
|
|
|
|
DumpTranscript t;
|
|
TEST_ASSERT_TRUE(drainUntilComplete(t));
|
|
const unsigned notificationsAtComplete = api->dataNotifications;
|
|
service->loop();
|
|
TEST_ASSERT_GREATER_THAN_UINT_MESSAGE(notificationsAtComplete, api->dataNotifications,
|
|
"config completion must re-announce packets queued during sync");
|
|
|
|
meshtastic_FromRadio msg;
|
|
TEST_ASSERT_TRUE(readOneFromRadio(msg));
|
|
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_packet_tag, msg.which_payload_variant);
|
|
TEST_ASSERT_EQUAL_UINT32(packet.id, msg.packet.id);
|
|
}
|
|
|
|
// The first region set moves my_node_num live (NodeDB::createNewIdentity()) with no reboot to force a
|
|
// re-handshake, so the stream must re-announce my_info - exactly once - on its own.
|
|
void test_node_num_change_resends_my_info()
|
|
{
|
|
startHandshake(FULL_DUMP_NONCE);
|
|
DumpTranscript t;
|
|
TEST_ASSERT_TRUE(drainUntilComplete(t));
|
|
TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle");
|
|
|
|
const uint32_t handshakeNodeNum = nodeDB->getNodeNum();
|
|
const unsigned notificationsBefore = api->dataNotifications;
|
|
|
|
mintIdentity();
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE(handshakeNodeNum, nodeDB->getNodeNum(), "node num did not actually change");
|
|
|
|
service->loop(); // delivers the fromNum notify that arms the re-announce
|
|
TEST_ASSERT_GREATER_THAN_UINT_MESSAGE(notificationsBefore, api->dataNotifications,
|
|
"transport was never woken to come and read");
|
|
|
|
meshtastic_FromRadio msg;
|
|
TEST_ASSERT_TRUE(readOneFromRadio(msg));
|
|
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant,
|
|
"a renumber must be announced as my_info");
|
|
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num);
|
|
|
|
// One-shot: the stream falls back to live traffic and nothing repeats.
|
|
TEST_ASSERT_FALSE_MESSAGE(api->available(), "my_info resend repeated after the client was told");
|
|
}
|
|
|
|
// The same move landing mid-sync: my_info is already out with the old number and there is no
|
|
// steady state to fall back from, so the dump restarts and carries the new one.
|
|
void test_node_num_change_mid_dump_restarts_sync()
|
|
{
|
|
startHandshake(FULL_DUMP_NONCE);
|
|
|
|
meshtastic_FromRadio msg;
|
|
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS; i++)
|
|
TEST_ASSERT_TRUE(readOneFromRadio(msg)); // through the channels, my_info long since sent
|
|
|
|
mintIdentity();
|
|
service->loop();
|
|
|
|
TEST_ASSERT_TRUE(readOneFromRadio(msg));
|
|
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant,
|
|
"a mid-sync renumber must restart the dump");
|
|
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num);
|
|
|
|
// Everything after the my_info already read must arrive again, in order and complete.
|
|
DumpTranscript t;
|
|
TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted dump never completed");
|
|
const pb_size_t expectedPrefix[] = {meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag,
|
|
meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag};
|
|
TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX - 1, sizeof(expectedPrefix) / sizeof(expectedPrefix[0]));
|
|
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX - 1; i++)
|
|
TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "restarted dump changed the header sequence");
|
|
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
|
|
TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(), "restarted dump lost part of the config");
|
|
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
|
|
TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size()); // our own record; this test seeds no remotes
|
|
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
|
|
TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, t.completeId);
|
|
}
|
|
|
|
// Same move during a nodes-only sync: the self record it already sent is gone from the DB, so that
|
|
// dump restarts too, even though this nonce never carries a my_info to be stale.
|
|
void test_node_num_change_mid_nodes_only_restarts_sync()
|
|
{
|
|
seedRemoteNode(SEEDED_NODE_A);
|
|
startHandshake(SPECIAL_NONCE_ONLY_NODES);
|
|
|
|
meshtastic_FromRadio msg;
|
|
TEST_ASSERT_TRUE(readOneFromRadio(msg));
|
|
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant);
|
|
const uint32_t staleSelf = msg.node_info.num;
|
|
|
|
mintIdentity();
|
|
service->loop();
|
|
|
|
DumpTranscript t;
|
|
TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted nodes-only dump never completed");
|
|
TEST_ASSERT_EQUAL_UINT_MESSAGE(nodeDB->getNumMeshNodes(), t.nodeNums.size(), "restart must resend every node");
|
|
TEST_ASSERT_NOT_EQUAL_MESSAGE(staleSelf, t.nodeNums[0], "restart must carry the new self record");
|
|
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
|
|
TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId);
|
|
}
|
|
|
|
} // 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();
|
|
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 = nullptr;
|
|
delete testNodeDB;
|
|
testNodeDB = nullptr;
|
|
delete mockService;
|
|
mockService = nullptr;
|
|
heartbeatReceived = false;
|
|
|
|
service = savedState->service;
|
|
router = savedState->router;
|
|
nodeDB = savedState->nodeDB;
|
|
cryptLock = savedState->cryptLock;
|
|
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);
|
|
RUN_TEST(test_packet_queued_mid_sync_is_announced_after_complete);
|
|
RUN_TEST(test_node_num_change_resends_my_info);
|
|
RUN_TEST(test_node_num_change_mid_dump_restarts_sync);
|
|
RUN_TEST(test_node_num_change_mid_nodes_only_restarts_sync);
|
|
|
|
exit(UNITY_END());
|
|
}
|
|
|
|
void loop() {}
|