Merge develop into packet authentication policy
This commit is contained in:
@@ -1 +1 @@
|
||||
30
|
||||
37
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
class AdminModuleTestShim : public AdminModule
|
||||
{
|
||||
public:
|
||||
using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro)
|
||||
using AdminModule::handleGetConfig;
|
||||
using AdminModule::handleReceivedProtobuf;
|
||||
using AdminModule::handleSetConfig;
|
||||
using AdminModule::handleSetModuleConfig;
|
||||
using AdminModule::responseIsSolicited; // request/response pairing gate
|
||||
using AdminModule::setPassKey;
|
||||
|
||||
// Peek at the reply a handler queued, before drainReply() releases it.
|
||||
meshtastic_MeshPacket *reply() { return myReply; }
|
||||
|
||||
// With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot.
|
||||
void deferSaves() { hasOpenEditTransaction = true; }
|
||||
|
||||
@@ -17,16 +17,34 @@
|
||||
#include "NodeDB.h"
|
||||
#include "RadioInterface.h"
|
||||
#include "TestUtil.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include <string>
|
||||
#include <unity.h>
|
||||
#include <vector>
|
||||
|
||||
#include "meshtastic/config.pb.h"
|
||||
#include "support/AdminModuleTestShim.h"
|
||||
#include "support/MockMeshService.h"
|
||||
|
||||
// hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests
|
||||
extern uint32_t hash(const char *str);
|
||||
|
||||
// Every client notification the AdminModule emits flows through sendClientNotification();
|
||||
// capture each formatted message so the warning/coalescing tests can assert on the exact
|
||||
// set of messages produced by a sequence of admin messages. This shadows test/support/MockMeshService.h's
|
||||
// release-only stub because these tests need to inspect the captured message text, not just avoid leaks.
|
||||
static std::vector<std::string> capturedWarnings;
|
||||
|
||||
class MockMeshService : public MeshService
|
||||
{
|
||||
public:
|
||||
void sendClientNotification(meshtastic_ClientNotification *n) override
|
||||
{
|
||||
capturedWarnings.push_back(n->message);
|
||||
releaseClientNotificationToPool(n);
|
||||
}
|
||||
};
|
||||
|
||||
static MockMeshService *mockMeshService;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -484,7 +502,7 @@ static void test_validateConfigLora_allStdPresetsValidForUS()
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO,
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
|
||||
@@ -498,7 +516,8 @@ static void test_validateConfigLora_allStdPresetsValidForUS()
|
||||
|
||||
static void test_validateConfigLora_turboPresetsInvalidForEU868()
|
||||
{
|
||||
// EU_868 has PRESETS_EU_868 which excludes SHORT_TURBO and LONG_TURBO
|
||||
// EU_868 has PRESETS_EU_868 which excludes the 500 kHz turbo presets
|
||||
// (SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO)
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
||||
cfg.use_preset = true;
|
||||
@@ -508,6 +527,9 @@ static void test_validateConfigLora_turboPresetsInvalidForEU868()
|
||||
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO;
|
||||
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868");
|
||||
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
|
||||
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_TURBO should be invalid for EU_868");
|
||||
}
|
||||
|
||||
static void test_validateConfigLora_validPresetsForEU868()
|
||||
@@ -598,13 +620,13 @@ static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast()
|
||||
|
||||
static void test_validateConfigLora_allPresetsValidForLORA24()
|
||||
{
|
||||
// LORA_24 uses PROFILE_STD (9 presets) with wideLora=true
|
||||
// LORA_24 uses PROFILE_STD (10 presets) with wideLora=true
|
||||
meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = {
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO,
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
|
||||
@@ -792,11 +814,11 @@ static void test_validateConfigLora_siblingLockedPresetStillFailsValidation()
|
||||
// RegionInfo preset list integrity tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static void test_presetsStd_hasNineEntries()
|
||||
static void test_presetsStd_hasTenEntries()
|
||||
{
|
||||
// PROFILE_STD should have exactly 9 presets
|
||||
// PROFILE_STD should have exactly 10 presets (adds MEDIUM_TURBO to the turbo cluster)
|
||||
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
||||
TEST_ASSERT_EQUAL(9, us->getNumPresets());
|
||||
TEST_ASSERT_EQUAL(10, us->getNumPresets());
|
||||
TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets());
|
||||
}
|
||||
|
||||
@@ -1240,6 +1262,167 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Channel-configuration warning + coalescing tests
|
||||
//
|
||||
// These exercise the real incoming-admin-message path (handleReceivedProtobuf):
|
||||
// begin_edit_settings / set_channel / commit_edit_settings. Warnings raised while a
|
||||
// transaction is open must be deferred and collapsed into a single notification at
|
||||
// commit; outside a transaction each save emits its own single message immediately.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static const uint8_t DEFAULT_KEY[] = {0x01}; // the well-known "default" PSK (AQ==)
|
||||
static const uint8_t CUSTOM_KEY[] = {0x42, 0x17}; // any non-default key
|
||||
|
||||
// Count captured warnings whose text contains substr.
|
||||
static int warningsContaining(const char *substr)
|
||||
{
|
||||
int n = 0;
|
||||
for (const auto &w : capturedWarnings)
|
||||
if (w.find(substr) != std::string::npos)
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
static meshtastic_Channel makeChannel(int8_t index, meshtastic_Channel_Role role, const char *name, const uint8_t *psk,
|
||||
size_t pskLen)
|
||||
{
|
||||
meshtastic_Channel ch = meshtastic_Channel_init_zero;
|
||||
ch.index = index;
|
||||
ch.role = role;
|
||||
ch.has_settings = true;
|
||||
strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1);
|
||||
ch.settings.psk.size = pskLen;
|
||||
for (size_t i = 0; i < pskLen; i++)
|
||||
ch.settings.psk.bytes[i] = psk[i];
|
||||
return ch;
|
||||
}
|
||||
|
||||
// Dispatch one admin message as if it arrived from a local (from==0) client, which bypasses
|
||||
// the passkey/authorization gates so the switch body runs.
|
||||
static void sendAdmin(meshtastic_AdminMessage &m)
|
||||
{
|
||||
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
|
||||
mp.from = 0;
|
||||
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; // required: handler drops non-decoded packets
|
||||
testAdmin->handleReceivedProtobuf(mp, &m);
|
||||
}
|
||||
|
||||
static void sendSetChannel(const meshtastic_Channel &ch)
|
||||
{
|
||||
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
||||
m.which_payload_variant = meshtastic_AdminMessage_set_channel_tag;
|
||||
m.set_channel = ch;
|
||||
sendAdmin(m);
|
||||
}
|
||||
|
||||
static void sendBeginEdit()
|
||||
{
|
||||
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
||||
m.which_payload_variant = meshtastic_AdminMessage_begin_edit_settings_tag;
|
||||
m.begin_edit_settings = true;
|
||||
sendAdmin(m);
|
||||
}
|
||||
|
||||
static void sendCommitEdit()
|
||||
{
|
||||
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
||||
m.which_payload_variant = meshtastic_AdminMessage_commit_edit_settings_tag;
|
||||
m.commit_edit_settings = true;
|
||||
sendAdmin(m);
|
||||
}
|
||||
|
||||
// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against.
|
||||
static void usePresetLongFast()
|
||||
{
|
||||
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||||
initRegion();
|
||||
owner.is_licensed = false;
|
||||
}
|
||||
|
||||
static void test_warn_singleChannel_variantName_oneSpecificMessage()
|
||||
{
|
||||
usePresetLongFast();
|
||||
// Name is a case/space variant of the preset with the default key: a single name issue.
|
||||
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
||||
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
||||
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
|
||||
}
|
||||
|
||||
static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll()
|
||||
{
|
||||
usePresetLongFast();
|
||||
// Variant name AND a non-default key: two issues on one channel collapse to one catch-all.
|
||||
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2));
|
||||
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
||||
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0"));
|
||||
}
|
||||
|
||||
static void test_warn_cleanChannel_noMessage()
|
||||
{
|
||||
usePresetLongFast();
|
||||
// Exact preset name + default key: nothing to warn about.
|
||||
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1));
|
||||
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
||||
}
|
||||
|
||||
static void test_warn_transaction_multipleChannels_singleCoalescedMessage()
|
||||
{
|
||||
usePresetLongFast();
|
||||
sendBeginEdit();
|
||||
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
||||
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1));
|
||||
// Nothing emitted yet - warnings are deferred until commit.
|
||||
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
||||
|
||||
sendCommitEdit();
|
||||
// Exactly one message, naming both channels.
|
||||
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
||||
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1"));
|
||||
}
|
||||
|
||||
static void test_warn_transaction_singleChannel_keepsSpecificMessage()
|
||||
{
|
||||
usePresetLongFast();
|
||||
sendBeginEdit();
|
||||
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
||||
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
||||
|
||||
sendCommitEdit();
|
||||
// One flagged channel: the specific message verbatim, not the plural catch-all.
|
||||
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
||||
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
|
||||
TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels"));
|
||||
}
|
||||
|
||||
static void test_warn_license_noTransaction_emittedImmediately()
|
||||
{
|
||||
usePresetLongFast();
|
||||
owner.is_licensed = true;
|
||||
// Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it.
|
||||
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2));
|
||||
TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated"));
|
||||
}
|
||||
|
||||
static void test_warn_license_transaction_coalescedToSingleMessage()
|
||||
{
|
||||
usePresetLongFast();
|
||||
owner.is_licensed = true;
|
||||
sendBeginEdit();
|
||||
// Two separate triggers within one transaction (two channels with keys to strip).
|
||||
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2));
|
||||
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2));
|
||||
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
||||
|
||||
sendCommitEdit();
|
||||
// Collapsed to a single licensed-mode notice (and no channel warning, since names are blank).
|
||||
TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated"));
|
||||
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test runner
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1249,6 +1432,12 @@ void setUp(void)
|
||||
mockMeshService = new MockMeshService();
|
||||
service = mockMeshService;
|
||||
testAdmin = new AdminModuleTestShim();
|
||||
capturedWarnings.clear();
|
||||
// Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB.
|
||||
// Create it once (kept reachable via the global, so no leak) for the warning tests; the
|
||||
// other tests in this suite set their own config/region state and are unaffected.
|
||||
if (!nodeDB)
|
||||
nodeDB = new NodeDB();
|
||||
}
|
||||
void tearDown(void)
|
||||
{
|
||||
@@ -1320,7 +1509,7 @@ void setup()
|
||||
RUN_TEST(test_validateConfigLora_siblingLockedPresetStillFailsValidation);
|
||||
|
||||
// RegionInfo preset list integrity
|
||||
RUN_TEST(test_presetsStd_hasNineEntries);
|
||||
RUN_TEST(test_presetsStd_hasTenEntries);
|
||||
RUN_TEST(test_presetsEU868_hasSevenEntries);
|
||||
RUN_TEST(test_presetsUndef_hasOneEntry);
|
||||
RUN_TEST(test_defaultPresetIsInAvailablePresets);
|
||||
@@ -1356,6 +1545,15 @@ void setup()
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
|
||||
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
|
||||
|
||||
// Channel-configuration warning + coalescing
|
||||
RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage);
|
||||
RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll);
|
||||
RUN_TEST(test_warn_cleanChannel_noMessage);
|
||||
RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage);
|
||||
RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage);
|
||||
RUN_TEST(test_warn_license_noTransaction_emittedImmediately);
|
||||
RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
// Deterministic reproduction of the admin session-key behavior discussed on PR #10669
|
||||
// (ndoo's "Admin message without session_key!" report), plus the remote-vs-local redaction of
|
||||
// secret material in admin GET responses, plus the request/response pairing that decides which
|
||||
// admin responses are accepted at all.
|
||||
//
|
||||
// Drives the REAL incoming-admin path (AdminModule::handleReceivedProtobuf) with a remote
|
||||
// (from != 0) PKC-authorized set_owner, exercising the exact checkPassKey/setPassKey gate.
|
||||
// A local (from == 0) admin bypasses that gate, so the bug only reproduces from != 0.
|
||||
|
||||
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/mesh-pb-constants.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "support/AdminModuleTestShim.h"
|
||||
#include "support/MockMeshService.h"
|
||||
#include <cstring>
|
||||
|
||||
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
||||
static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us
|
||||
static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to
|
||||
static constexpr NodeNum STRANGER_NODE = 0x0D0D0D0D;
|
||||
static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb,
|
||||
0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20};
|
||||
|
||||
static MockMeshService *mockService = nullptr;
|
||||
static AdminModuleTestShim *admin = nullptr;
|
||||
|
||||
// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents.
|
||||
static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen,
|
||||
meshtastic_AdminMessage &out)
|
||||
{
|
||||
out = meshtastic_AdminMessage_init_zero;
|
||||
out.which_payload_variant = meshtastic_AdminMessage_set_owner_tag;
|
||||
strncpy(out.set_owner.long_name, newLongName, sizeof(out.set_owner.long_name) - 1);
|
||||
if (session) {
|
||||
out.session_passkey.size = sessionLen;
|
||||
memcpy(out.session_passkey.bytes, session, sessionLen);
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
|
||||
mp.from = ADMIN_NODE; // REMOTE: this is what makes the session gate apply
|
||||
mp.channel = 0;
|
||||
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
mp.pki_encrypted = true; // arrived over PKC
|
||||
mp.public_key.size = 32;
|
||||
memcpy(mp.public_key.bytes, ADMIN_KEY, 32); // matches config.security.admin_key[0] -> authorized
|
||||
return mp;
|
||||
}
|
||||
|
||||
// A get_module_config_response carrying a remote_hardware pin list, as a remote would answer.
|
||||
// This is the class of message that short-circuited auth: no session passkey, sender need not
|
||||
// hold an admin key. handleGetModuleConfigResponse() stamps mp.from into the pin table.
|
||||
static meshtastic_MeshPacket makeModuleConfigResponse(NodeNum from, meshtastic_AdminMessage &out)
|
||||
{
|
||||
out = meshtastic_AdminMessage_init_zero;
|
||||
out.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag;
|
||||
out.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag;
|
||||
out.get_module_config_response.payload_variant.remote_hardware.enabled = true;
|
||||
out.get_module_config_response.payload_variant.remote_hardware.available_pins_count = 1;
|
||||
out.get_module_config_response.payload_variant.remote_hardware.available_pins[0].gpio_pin = 17;
|
||||
|
||||
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
|
||||
mp.from = from;
|
||||
mp.to = LOCAL_NODE;
|
||||
mp.channel = 0;
|
||||
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
return mp;
|
||||
}
|
||||
|
||||
// One known pin entry, so a response that reaches handleGetModuleConfigResponse rewrites its owner.
|
||||
static void seedRemoteHardwarePin(NodeNum owner)
|
||||
{
|
||||
devicestate.node_remote_hardware_pins_count = 1;
|
||||
devicestate.node_remote_hardware_pins[0] = meshtastic_NodeRemoteHardwarePin_init_zero;
|
||||
devicestate.node_remote_hardware_pins[0].node_num = owner;
|
||||
devicestate.node_remote_hardware_pins[0].has_pin = true;
|
||||
devicestate.node_remote_hardware_pins[0].pin.gpio_pin = 4;
|
||||
}
|
||||
|
||||
// The outgoing request `req` a local client would send to `to`, as MeshService sees it (from == 0,
|
||||
// ADMIN_APP, payload still plaintext).
|
||||
static meshtastic_MeshPacket makeOutgoingRequest(NodeNum to, const meshtastic_AdminMessage &req)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.from = 0;
|
||||
p.to = to;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
|
||||
p.decoded.payload.size =
|
||||
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &req);
|
||||
return p;
|
||||
}
|
||||
|
||||
static meshtastic_MeshPacket makeOutgoingModuleConfigRequest(
|
||||
NodeNum to, meshtastic_AdminMessage_ModuleConfigType type = meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG)
|
||||
{
|
||||
meshtastic_AdminMessage req = meshtastic_AdminMessage_init_zero;
|
||||
req.which_payload_variant = meshtastic_AdminMessage_get_module_config_request_tag;
|
||||
req.get_module_config_request = type;
|
||||
return makeOutgoingRequest(to, req);
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
mockService = new MockMeshService();
|
||||
service = mockService;
|
||||
admin = new AdminModuleTestShim();
|
||||
admin->deferSaves(); // no disk/reboot side effects when a setter is accepted
|
||||
|
||||
if (!nodeDB)
|
||||
nodeDB = new NodeDB();
|
||||
myNodeInfo.my_node_num = LOCAL_NODE;
|
||||
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
// Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate.
|
||||
config.security.admin_key[0].size = 32;
|
||||
memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32);
|
||||
|
||||
owner = meshtastic_User_init_zero;
|
||||
strncpy(owner.long_name, "Original", sizeof(owner.long_name) - 1);
|
||||
|
||||
channels.initDefaults();
|
||||
channels.onConfigChanged();
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
service = nullptr;
|
||||
delete mockService;
|
||||
mockService = nullptr;
|
||||
delete admin;
|
||||
admin = nullptr;
|
||||
}
|
||||
|
||||
// ndoo's report: a setter from a remote node with NO valid session is rejected, and the node's
|
||||
// expected session key is all-zero because it has minted none since boot.
|
||||
void test_remote_setter_without_session_is_rejected(void)
|
||||
{
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeRemoteSetOwner("Hijacked", nullptr, 0, m);
|
||||
|
||||
admin->handleReceivedProtobuf(mp, &m);
|
||||
admin->drainReply();
|
||||
|
||||
// Rejected at the session gate -> owner unchanged (this is ndoo's "Admin message without session_key!").
|
||||
TEST_ASSERT_EQUAL_STRING("Original", owner.long_name);
|
||||
}
|
||||
|
||||
// The node's session key is minted only by setPassKey (which runs when it answers an admin GET),
|
||||
// so before any GET the expected key is all-zero and any presented key mismatches.
|
||||
void test_expected_session_key_is_zero_before_any_get(void)
|
||||
{
|
||||
uint8_t zero[8] = {0};
|
||||
meshtastic_AdminMessage probe = meshtastic_AdminMessage_init_zero;
|
||||
probe.session_passkey.size = 8;
|
||||
memcpy(probe.session_passkey.bytes, zero, 8); // even all-zeros must not authorize a state change
|
||||
// A fresh module has minted no session; a stale/guessed key does not match.
|
||||
// (checkPassKey also requires size==8 AND session_time freshness.)
|
||||
uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; // ndoo's presented key
|
||||
meshtastic_AdminMessage staleMsg = meshtastic_AdminMessage_init_zero;
|
||||
staleMsg.session_passkey.size = 8;
|
||||
memcpy(staleMsg.session_passkey.bytes, stale, 8);
|
||||
TEST_ASSERT_FALSE(admin->checkPassKey(&staleMsg)); // Expected: 00..00 vs Incoming: 29 04 b4 78.. -> reject
|
||||
}
|
||||
|
||||
// The fix path: once the node answers a GET (setPassKey mints/returns the key), the session gate
|
||||
// accepts a setter carrying that key. Asserting the gate (checkPassKey) directly is the mechanism;
|
||||
// driving the full handleSetOwner would need the NodeInfoModule scaffolding, out of scope here.
|
||||
void test_session_gate_accepts_key_from_a_get_response(void)
|
||||
{
|
||||
// Simulate the node answering an admin GET: setPassKey mints the session and writes it into the response.
|
||||
meshtastic_AdminMessage getResponse = meshtastic_AdminMessage_init_zero;
|
||||
admin->setPassKey(&getResponse);
|
||||
TEST_ASSERT_EQUAL(8, getResponse.session_passkey.size); // node handed the client a session key
|
||||
|
||||
// A setter carrying that exact key passes the gate (would be accepted).
|
||||
meshtastic_AdminMessage good = meshtastic_AdminMessage_init_zero;
|
||||
good.session_passkey = getResponse.session_passkey;
|
||||
TEST_ASSERT_TRUE(admin->checkPassKey(&good));
|
||||
|
||||
// A setter carrying a stale/guessed key still fails (no session replay).
|
||||
meshtastic_AdminMessage bad = meshtastic_AdminMessage_init_zero;
|
||||
bad.session_passkey.size = 8;
|
||||
uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff};
|
||||
memcpy(bad.session_passkey.bytes, stale, 8);
|
||||
TEST_ASSERT_FALSE(admin->checkPassKey(&bad));
|
||||
}
|
||||
|
||||
// Decode the SecurityConfig out of the get_config response a handler queued in myReply.
|
||||
static bool decodeSecurityFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_SecurityConfig &out)
|
||||
{
|
||||
meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero;
|
||||
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
|
||||
return false;
|
||||
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am))
|
||||
return false;
|
||||
if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag ||
|
||||
am.get_config_response.which_payload_variant != meshtastic_Config_security_tag)
|
||||
return false;
|
||||
out = am.get_config_response.payload_variant.security;
|
||||
return true;
|
||||
}
|
||||
|
||||
static meshtastic_MeshPacket makeGetConfigRequest(NodeNum from)
|
||||
{
|
||||
meshtastic_MeshPacket req = meshtastic_MeshPacket_init_zero;
|
||||
req.from = from;
|
||||
req.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
req.decoded.want_response = true;
|
||||
return req;
|
||||
}
|
||||
|
||||
// The device identity private key must never leave over the air: a SECURITY_CONFIG response to a
|
||||
// remote request (from != 0, even an authorized admin) carries an empty private_key.
|
||||
void test_remote_security_config_omits_private_key(void)
|
||||
{
|
||||
config.security.private_key.size = 32;
|
||||
memset(config.security.private_key.bytes, 0xAB, 32);
|
||||
|
||||
meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE);
|
||||
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG);
|
||||
|
||||
meshtastic_Config_SecurityConfig sec;
|
||||
TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, sec.private_key.size, "remote security config must not carry the private key");
|
||||
admin->drainReply();
|
||||
}
|
||||
|
||||
// Control: the local backup path (from == 0, BLE/USB/TCP) still receives the private key, so the
|
||||
// redaction above is remote-specific rather than a blanket wipe.
|
||||
void test_local_security_config_keeps_private_key(void)
|
||||
{
|
||||
config.security.private_key.size = 32;
|
||||
memset(config.security.private_key.bytes, 0xAB, 32);
|
||||
|
||||
meshtastic_MeshPacket req = makeGetConfigRequest(0);
|
||||
admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG);
|
||||
|
||||
meshtastic_Config_SecurityConfig sec;
|
||||
TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(32, sec.private_key.size, "local backup must still receive the private key");
|
||||
TEST_ASSERT_EACH_EQUAL_HEX8(0xAB, sec.private_key.bytes, 32);
|
||||
admin->drainReply();
|
||||
}
|
||||
|
||||
// An admin response carries no session passkey and its sender is not an admin-key holder, so a
|
||||
// request we sent is the only thing vouching for it. A get_module_config_response from a node we
|
||||
// never queried is not.
|
||||
static constexpr pb_size_t MODULE_CONFIG_RESPONSE = meshtastic_AdminMessage_get_module_config_response_tag;
|
||||
static constexpr pb_size_t REMOTE_HW_TAG = meshtastic_ModuleConfig_remote_hardware_tag; // makeModuleConfigResponse subtype
|
||||
|
||||
void test_unsolicited_response_is_not_solicited(void)
|
||||
{
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||
|
||||
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||
"a response nobody asked for must not be accepted");
|
||||
}
|
||||
|
||||
// Control: once the client has sent that node a request, its response is accepted. Without this,
|
||||
// the test above would also pass if responseIsSolicited() simply always said no.
|
||||
void test_response_after_our_request_is_solicited(void)
|
||||
{
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||
|
||||
TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||
"the answer to our own request must be accepted");
|
||||
}
|
||||
|
||||
// A request to one remote does not vouch for a different remote's response.
|
||||
void test_request_to_one_node_does_not_admit_another(void)
|
||||
{
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // answered by someone else
|
||||
|
||||
TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG));
|
||||
}
|
||||
|
||||
// A response only answers its own request type: a get_owner_request does not admit a
|
||||
// get_module_config_response from the same node.
|
||||
void test_response_variant_must_match_request(void)
|
||||
{
|
||||
meshtastic_AdminMessage owner_req = meshtastic_AdminMessage_init_zero;
|
||||
owner_req.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag;
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, owner_req));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // wrong type for the request
|
||||
|
||||
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||
"a get_owner request must not admit a get_module_config response");
|
||||
// ...but the response it actually asked for is still accepted from the same slot.
|
||||
TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, meshtastic_AdminMessage_get_owner_response_tag, 0));
|
||||
}
|
||||
|
||||
// Regression: each request keeps its own pinned key. A later unpinned request to the same node
|
||||
// must not relax the PKC pin of an earlier one (the old shared-slot model cleared it).
|
||||
void test_pinned_request_keeps_its_key_after_an_unpinned_request(void)
|
||||
{
|
||||
uint8_t key[32];
|
||||
memset(key, 0xC1, 32);
|
||||
|
||||
// A PKC-pinned get_config request to STRANGER (pins `key`).
|
||||
meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero;
|
||||
cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag;
|
||||
meshtastic_MeshPacket pinned = makeOutgoingRequest(STRANGER_NODE, cfg);
|
||||
pinned.pki_encrypted = true;
|
||||
pinned.public_key.size = 32;
|
||||
memcpy(pinned.public_key.bytes, key, 32);
|
||||
admin->noteOutgoingAdminRequest(pinned);
|
||||
|
||||
// A later, unpinned get_owner request to the same node.
|
||||
meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero;
|
||||
own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag;
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, own));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket resp = makeModuleConfigResponse(STRANGER_NODE, m); // from set; pki off by default
|
||||
|
||||
// A plaintext get_config_response is still rejected: the config request was pinned.
|
||||
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0),
|
||||
"an unpinned request must not relax an earlier request's key pin");
|
||||
// Over PKC with the pinned key it is accepted.
|
||||
resp.pki_encrypted = true;
|
||||
resp.public_key.size = 32;
|
||||
memcpy(resp.public_key.bytes, key, 32);
|
||||
TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0));
|
||||
// The unpinned get_owner_response is accepted in plaintext (its request carried no pin).
|
||||
resp.pki_encrypted = false;
|
||||
resp.public_key.size = 0;
|
||||
TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_owner_response_tag, 0));
|
||||
}
|
||||
|
||||
// A remote_hardware response must answer a request for that exact subtype, not just any module
|
||||
// config - else an MQTT-config request could authorize a pin-table update.
|
||||
void test_module_config_subtype_must_match(void)
|
||||
{
|
||||
admin->noteOutgoingAdminRequest(
|
||||
makeOutgoingModuleConfigRequest(STRANGER_NODE, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // remote_hardware subtype
|
||||
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||
"a non-remote-hardware request must not admit a remote_hardware response");
|
||||
|
||||
// Control: a request for the matching subtype does admit it.
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE));
|
||||
TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG));
|
||||
}
|
||||
|
||||
// A matched request is consumed, so a node cannot replay a state-mutating response within the window.
|
||||
void test_response_is_consumed_no_replay(void)
|
||||
{
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||
TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG));
|
||||
TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG),
|
||||
"a second copy of an already-answered response must be rejected");
|
||||
}
|
||||
|
||||
// Only requests arm the gate: sending a setter to a node must not make it a trusted responder.
|
||||
void test_outgoing_setter_does_not_admit_responses(void)
|
||||
{
|
||||
meshtastic_AdminMessage setter = meshtastic_AdminMessage_init_zero;
|
||||
setter.which_payload_variant = meshtastic_AdminMessage_set_owner_tag;
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, setter));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||
|
||||
TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG));
|
||||
}
|
||||
|
||||
// End to end through the real handler: an unsolicited get_module_config_response must not reach
|
||||
// handleGetModuleConfigResponse, so the remote_hardware pin table keeps its owner.
|
||||
void test_unsolicited_response_does_not_poison_pins(void)
|
||||
{
|
||||
seedRemoteHardwarePin(QUERIED_NODE);
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||
admin->handleReceivedProtobuf(mp, &m);
|
||||
admin->drainReply();
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32_MESSAGE(QUERIED_NODE, devicestate.node_remote_hardware_pins[0].node_num,
|
||||
"unsolicited response must not rewrite the pin owner");
|
||||
}
|
||||
|
||||
// Control: once we have requested it, the same response is handled and updates the pin table. This
|
||||
// proves the assertion above is the auth gate firing, not the handler being dead. (It also exercises
|
||||
// the dispatch tag fixed in this change - without it, the handler never runs for either node.)
|
||||
void test_solicited_response_updates_pins(void)
|
||||
{
|
||||
seedRemoteHardwarePin(QUERIED_NODE);
|
||||
admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE));
|
||||
|
||||
meshtastic_AdminMessage m;
|
||||
meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m);
|
||||
admin->handleReceivedProtobuf(mp, &m);
|
||||
admin->drainReply();
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32_MESSAGE(STRANGER_NODE, devicestate.node_remote_hardware_pins[0].node_num,
|
||||
"a response we asked for must reach the handler");
|
||||
}
|
||||
|
||||
#endif // !(MESHTASTIC_EXCLUDE_PKI)
|
||||
|
||||
void setup()
|
||||
{
|
||||
delay(10);
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
RUN_TEST(test_remote_setter_without_session_is_rejected);
|
||||
RUN_TEST(test_expected_session_key_is_zero_before_any_get);
|
||||
RUN_TEST(test_session_gate_accepts_key_from_a_get_response);
|
||||
RUN_TEST(test_remote_security_config_omits_private_key);
|
||||
RUN_TEST(test_local_security_config_keeps_private_key);
|
||||
RUN_TEST(test_unsolicited_response_is_not_solicited);
|
||||
RUN_TEST(test_response_after_our_request_is_solicited);
|
||||
RUN_TEST(test_request_to_one_node_does_not_admit_another);
|
||||
RUN_TEST(test_response_variant_must_match_request);
|
||||
RUN_TEST(test_pinned_request_keeps_its_key_after_an_unpinned_request);
|
||||
RUN_TEST(test_module_config_subtype_must_match);
|
||||
RUN_TEST(test_response_is_consumed_no_replay);
|
||||
RUN_TEST(test_outgoing_setter_does_not_admit_responses);
|
||||
RUN_TEST(test_unsolicited_response_does_not_poison_pins);
|
||||
RUN_TEST(test_solicited_response_updates_pins);
|
||||
#endif
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "TestUtil.h"
|
||||
#include "modules/games/Breakout.h"
|
||||
#include <unity.h>
|
||||
|
||||
// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on
|
||||
// a straight-up serve, and the ball staying within the board. No device globals or display stack.
|
||||
|
||||
static const uint32_t kSeed = 0xC0FFEEu;
|
||||
|
||||
void test_reset_initialState()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
TEST_ASSERT_TRUE(game.isPlaying());
|
||||
TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives());
|
||||
TEST_ASSERT_EQUAL_UINT8(1, game.level());
|
||||
TEST_ASSERT_EQUAL_UINT32(0, game.score());
|
||||
// Every brick present at the start.
|
||||
TEST_ASSERT_EQUAL_UINT16(static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS, game.bricksRemaining());
|
||||
// Paddle centred, ball above it and inside the board.
|
||||
TEST_ASSERT_EQUAL_INT16((BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W) / 2, game.paddleX());
|
||||
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
|
||||
TEST_ASSERT_TRUE(game.ballY() >= 0 && game.ballY() < BreakoutGame::BOARD_H);
|
||||
}
|
||||
|
||||
void test_paddle_clampsToEdges()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
for (int i = 0; i < 100; i++)
|
||||
game.moveLeft();
|
||||
TEST_ASSERT_EQUAL_INT16(0, game.paddleX());
|
||||
for (int i = 0; i < 100; i++)
|
||||
game.moveRight();
|
||||
TEST_ASSERT_EQUAL_INT16(BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W, game.paddleX());
|
||||
}
|
||||
|
||||
void test_serve_clearsABrickAndScores()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
// The ball serves upward from just above the paddle straight into the brick field; within a
|
||||
// few dozen steps it must clear at least one brick and score.
|
||||
for (int i = 0;
|
||||
i < 60 && game.bricksRemaining() == static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++)
|
||||
game.step();
|
||||
TEST_ASSERT_TRUE(game.bricksRemaining() < static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS);
|
||||
TEST_ASSERT_TRUE(game.score() > 0);
|
||||
}
|
||||
|
||||
void test_ball_staysInBounds()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
// Drive the paddle to follow the ball so the game keeps going, and check the ball never leaves
|
||||
// the board horizontally across a long run.
|
||||
for (int i = 0; i < 500 && game.isPlaying(); i++) {
|
||||
if (game.ballX() < game.paddleX())
|
||||
game.moveLeft();
|
||||
else
|
||||
game.moveRight();
|
||||
game.step();
|
||||
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
|
||||
TEST_ASSERT_TRUE(game.ballY() >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
void test_deadGame_stepIsNoOp()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
// Park the paddle in a corner and never move it; the ball is eventually lost every life.
|
||||
game.moveLeft();
|
||||
for (int i = 0; i < 20000 && game.isPlaying(); i++) {
|
||||
for (int j = 0; j < 40; j++) // hold the paddle pinned left
|
||||
game.moveLeft();
|
||||
game.step();
|
||||
}
|
||||
TEST_ASSERT_FALSE(game.isPlaying());
|
||||
const uint32_t scoreBefore = game.score();
|
||||
TEST_ASSERT_FALSE(game.step()); // stays dead, no further change
|
||||
TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
extern "C" {
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_reset_initialState);
|
||||
RUN_TEST(test_paddle_clampsToEdges);
|
||||
RUN_TEST(test_serve_clearsABrickAndScores);
|
||||
RUN_TEST(test_ball_staysInBounds);
|
||||
RUN_TEST(test_deadGame_stepIsNoOp);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "TestUtil.h"
|
||||
#include "modules/games/ChirpyRunner.h"
|
||||
#include <unity.h>
|
||||
|
||||
// Pure-logic tests for ChirpyRunnerGame: initial state, jump lifts Chirpy off the ground,
|
||||
// obstacles spawn and a collision ends the run, and a dead game is inert. No device globals.
|
||||
|
||||
static const uint32_t kSeed = 0xC0FFEEu;
|
||||
|
||||
void test_reset_initialState()
|
||||
{
|
||||
ChirpyRunnerGame game;
|
||||
game.reset(kSeed);
|
||||
TEST_ASSERT_TRUE(game.isPlaying());
|
||||
TEST_ASSERT_EQUAL_UINT32(0, game.score());
|
||||
TEST_ASSERT_TRUE(game.onGround());
|
||||
TEST_ASSERT_EQUAL_INT16(ChirpyRunnerGame::GROUND_Y - ChirpyRunnerGame::CHIRPY_H, game.chirpyY());
|
||||
}
|
||||
|
||||
void test_jump_liftsChirpy()
|
||||
{
|
||||
ChirpyRunnerGame game;
|
||||
game.reset(kSeed);
|
||||
const int16_t groundY = game.chirpyY();
|
||||
game.jump();
|
||||
game.step();
|
||||
TEST_ASSERT_FALSE(game.onGround());
|
||||
TEST_ASSERT_TRUE(game.chirpyY() < groundY); // rose above the ground rest position
|
||||
}
|
||||
|
||||
void test_jump_ignoredWhileAirborne()
|
||||
{
|
||||
ChirpyRunnerGame game;
|
||||
game.reset(kSeed);
|
||||
game.jump();
|
||||
game.step();
|
||||
const int16_t yAfterFirst = game.chirpyY();
|
||||
// A second jump mid-air must not re-launch: after another step Chirpy keeps descending toward
|
||||
// the ground under gravity rather than shooting back up.
|
||||
game.jump();
|
||||
game.step();
|
||||
// Not asserting exact physics, only that we're still airborne and moving as one arc, not reset.
|
||||
TEST_ASSERT_FALSE(game.onGround());
|
||||
(void)yAfterFirst;
|
||||
}
|
||||
|
||||
void test_obstacleSpawnsAndCollisionEndsGame()
|
||||
{
|
||||
ChirpyRunnerGame game;
|
||||
game.reset(kSeed);
|
||||
// One step spawns the first obstacle.
|
||||
game.step();
|
||||
bool any = false;
|
||||
for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++)
|
||||
any = any || game.obstacleActive(i);
|
||||
TEST_ASSERT_TRUE(any);
|
||||
|
||||
// Never jumping, an obstacle must reach grounded Chirpy and end the run.
|
||||
int steps = 0;
|
||||
while (game.isPlaying() && steps < 2000) {
|
||||
game.step();
|
||||
steps++;
|
||||
}
|
||||
TEST_ASSERT_FALSE(game.isPlaying());
|
||||
}
|
||||
|
||||
void test_deadGame_stepIsNoOp()
|
||||
{
|
||||
ChirpyRunnerGame game;
|
||||
game.reset(kSeed);
|
||||
int steps = 0;
|
||||
while (game.isPlaying() && steps < 2000) {
|
||||
game.step();
|
||||
steps++;
|
||||
}
|
||||
TEST_ASSERT_FALSE(game.isPlaying());
|
||||
const uint32_t scoreBefore = game.score();
|
||||
TEST_ASSERT_FALSE(game.step()); // stays dead, no further change
|
||||
TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
extern "C" {
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_reset_initialState);
|
||||
RUN_TEST(test_jump_liftsChirpy);
|
||||
RUN_TEST(test_jump_ignoredWhileAirborne);
|
||||
RUN_TEST(test_obstacleSpawnsAndCollisionEndsGame);
|
||||
RUN_TEST(test_deadGame_stepIsNoOp);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
}
|
||||
@@ -450,7 +450,8 @@ static meshtastic_AdminMessage fuzzAdminMessage()
|
||||
// manual bandwidth==0 path that used to SIGFPE the validator.
|
||||
r.set_config.which_payload_variant = meshtastic_Config_lora_tag;
|
||||
r.set_config.payload_variant.lora.region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32);
|
||||
r.set_config.payload_variant.lora.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16);
|
||||
r.set_config.payload_variant.lora.modem_preset =
|
||||
(meshtastic_Config_LoRaConfig_ModemPreset)rngRange(_meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE);
|
||||
r.set_config.payload_variant.lora.use_preset = (rngRange(2) == 0);
|
||||
r.set_config.payload_variant.lora.bandwidth = rngRange(512); // includes 0
|
||||
r.set_config.payload_variant.lora.channel_num = rngNext();
|
||||
@@ -553,7 +554,7 @@ static meshtastic_MeshBeacon fuzzBeacon()
|
||||
fuzzChannelSettings(b.offer_channel);
|
||||
b.offer_region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32);
|
||||
b.has_offer_preset = (rngRange(2) == 0);
|
||||
b.offer_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16);
|
||||
b.offer_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(_meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE);
|
||||
return b;
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,44 @@ static void test_adminValidation_turboPresetOnUS_isAccepted(void)
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MEDIUM_TURBO is also cleared for EU_868. Like SHORT_TURBO/LONG_TURBO it is a 500 kHz preset
|
||||
* that does not fit EU_868's 250 kHz band, so it must not survive admin validation there.
|
||||
*/
|
||||
static void test_adminValidation_mediumTurboPresetOnEU868_isCleared(void)
|
||||
{
|
||||
resetConfig();
|
||||
|
||||
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
|
||||
bcfg.has_broadcast_on_preset = true;
|
||||
bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
|
||||
|
||||
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
|
||||
|
||||
TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon);
|
||||
TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MEDIUM_TURBO passes validation for US (PROFILE_STD allows the full turbo family).
|
||||
* The same 500 kHz preset that is illegal in EU_868 must be preserved in permissive regions.
|
||||
*/
|
||||
static void test_adminValidation_mediumTurboPresetOnUS_isAccepted(void)
|
||||
{
|
||||
resetConfig();
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
initRegion();
|
||||
|
||||
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
|
||||
bcfg.has_broadcast_on_preset = true;
|
||||
bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
|
||||
|
||||
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
|
||||
|
||||
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset);
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an out-of-range region code (255) is sanitised to UNSET rather than stored verbatim.
|
||||
* Important to prevent invalid proto enum values from reaching the broadcaster and being broadcast
|
||||
@@ -1360,6 +1398,8 @@ BEACON_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared);
|
||||
RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared);
|
||||
RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted);
|
||||
RUN_TEST(test_adminValidation_mediumTurboPresetOnEU868_isCleared);
|
||||
RUN_TEST(test_adminValidation_mediumTurboPresetOnUS_isAccepted);
|
||||
RUN_TEST(test_adminValidation_unknownOfferRegion_isCleared);
|
||||
RUN_TEST(test_adminValidation_validOfferRegion_isPreserved);
|
||||
RUN_TEST(test_adminValidation_targetUnknownRegion_isCleared);
|
||||
|
||||
@@ -132,6 +132,34 @@ static void test_migration_carriesRoleAndProtectedIntoWarm(void)
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
// The signer bit is learned from verified traffic, not NodeInfo, so it must survive a warm
|
||||
// round trip. The plain node is the control: re-admission restores it, it doesn't invent it.
|
||||
static void test_migration_carriesSignerBitThroughWarm(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
const NodeNum signerNum = 2000 + 3;
|
||||
const NodeNum plainNum = 2000 + 4;
|
||||
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
|
||||
for (int i = 1; i <= extra; i++)
|
||||
db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true, /*withKey=*/true);
|
||||
nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(db->getMeshNode(signerNum)));
|
||||
|
||||
db->runDemote();
|
||||
|
||||
// Both are out of the hot store and held in the warm tier.
|
||||
TEST_ASSERT_NULL(db->getMeshNode(signerNum));
|
||||
TEST_ASSERT_NULL(db->getMeshNode(plainNum));
|
||||
|
||||
const meshtastic_NodeInfoLite *back = db->getOrCreateMeshNode(signerNum);
|
||||
TEST_ASSERT_NOT_NULL(back);
|
||||
TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(back), "signer bit must survive a warm-tier round trip");
|
||||
|
||||
const meshtastic_NodeInfoLite *plainBack = db->getOrCreateMeshNode(plainNum);
|
||||
TEST_ASSERT_NOT_NULL(plainBack);
|
||||
TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasXeddsaSigned(plainBack), "re-admission must not invent the signer bit");
|
||||
}
|
||||
|
||||
// Favourite handling: a favourite is never the eviction victim, even when it is
|
||||
// the oldest node in a full hot store.
|
||||
static void test_eviction_preservesFavorite(void)
|
||||
@@ -197,6 +225,39 @@ static void test_protectedCap_refusesBeyondLimit(void)
|
||||
TEST_ASSERT_TRUE(db->setProtectedFlag(already, NODEINFO_BITFIELD_IS_IGNORED_MASK, true));
|
||||
}
|
||||
|
||||
// removeNodeByNum() compacts survivors down and clears the slots that leaves free. A full
|
||||
// store with no matching node frees none, so there is nothing past the last node to clear.
|
||||
static void test_removeNodeByNum_absentNodeOnFullDb(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
for (int i = 1; i < MAX_NUM_NODES; i++) // fill to MAX_NUM_NODES total (incl. self)
|
||||
db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true);
|
||||
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes());
|
||||
|
||||
db->removeNodeByNum(0xDEADBEEF); // absent; ASan flags a write past the last slot
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); // nothing removed
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x0BADF00D)); // self intact
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 1));
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // last slot intact
|
||||
}
|
||||
|
||||
// Control for the above: a matching node on a full store is still removed, the survivors
|
||||
// compact down, and the freed tail slot is cleared.
|
||||
static void test_removeNodeByNum_presentNodeOnFullDb(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
for (int i = 1; i < MAX_NUM_NODES; i++)
|
||||
db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true);
|
||||
|
||||
db->removeNodeByNum(8000 + 5);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 1, (int)db->getNumMeshNodes());
|
||||
TEST_ASSERT_NULL(db->getMeshNode(8000 + 5));
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 4));
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // survivors kept
|
||||
}
|
||||
|
||||
NDB_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
@@ -206,9 +267,12 @@ NDB_TEST_ENTRY void setup()
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf);
|
||||
RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm);
|
||||
RUN_TEST(test_migration_carriesSignerBitThroughWarm);
|
||||
RUN_TEST(test_eviction_preservesFavorite);
|
||||
RUN_TEST(test_ignored_survivesEvictionAndCleanup);
|
||||
RUN_TEST(test_protectedCap_refusesBeyondLimit);
|
||||
RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb);
|
||||
RUN_TEST(test_removeNodeByNum_presentNodeOnFullDb);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
NDB_TEST_ENTRY void loop() {}
|
||||
|
||||
@@ -291,6 +291,58 @@ static bool signedEncodingFits(const meshtastic_Data *d)
|
||||
return encodedDataSize(©) + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN;
|
||||
}
|
||||
|
||||
// Append a length-delimited field whose tag this build's Data schema does not define, as a sender
|
||||
// on a newer schema would emit. nanopb skips unknown fields at decode, so these bytes count toward
|
||||
// the raw wire size but not the decoded struct. Returns the number of bytes appended.
|
||||
static size_t appendUnknownField(uint8_t *dst, size_t dstLen, size_t contentLen)
|
||||
{
|
||||
constexpr uint32_t UNKNOWN_FIELD_NUMBER = 100; // not a field of meshtastic_Data
|
||||
std::vector<uint8_t> content(contentLen, 0x77);
|
||||
pb_ostream_t stream = pb_ostream_from_buffer(dst, dstLen);
|
||||
TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, UNKNOWN_FIELD_NUMBER));
|
||||
TEST_ASSERT_TRUE(pb_encode_string(&stream, content.data(), content.size()));
|
||||
return stream.bytes_written;
|
||||
}
|
||||
|
||||
// Channel-encrypt raw Data bytes into a packet, exactly as perhapsEncode's non-PKI path does.
|
||||
// Used to inject wire bytes perhapsEncode would never produce (it only encodes p->decoded).
|
||||
static void encryptAsChannelPacket(meshtastic_MeshPacket *p, uint8_t *wire, size_t size)
|
||||
{
|
||||
const int16_t hash = channels.setActiveByIndex(0);
|
||||
TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel");
|
||||
crypto->encryptPacket(getFrom(p), p->id, size, wire);
|
||||
memcpy(p->encrypted.bytes, wire, size);
|
||||
p->encrypted.size = size;
|
||||
p->channel = hash; // on the wire the channel field carries the hash, not the index
|
||||
p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
|
||||
}
|
||||
|
||||
// Build A10's frame: an unsigned broadcast carrying a POSITION payload plus unknown fields, sized
|
||||
// so the raw wire length exceeds the signature-fit threshold while the decoded fields stay under
|
||||
// it. Channel-encrypted like a normal sender. The asserts pin that split, which is what makes A10
|
||||
// and A11 meaningful.
|
||||
static meshtastic_MeshPacket makeBroadcastWithUnknownFields()
|
||||
{
|
||||
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
|
||||
|
||||
uint8_t wire[MAX_LORA_PAYLOAD_LEN + 1];
|
||||
const size_t base = pb_encode_to_bytes(wire, sizeof(wire), &meshtastic_Data_msg, &p.decoded);
|
||||
TEST_ASSERT_GREATER_THAN_MESSAGE(0, base, "failed to encode the base Data");
|
||||
const size_t raw = base + appendUnknownField(wire + base, sizeof(wire) - base, 160);
|
||||
|
||||
// The decoded fields fit a signature, so a sender that signs would have signed this Data.
|
||||
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, base + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
|
||||
"decoded fields must fit a signature, else the test is vacuous");
|
||||
// The unknown fields put the raw size over that threshold, so the two sizings disagree here.
|
||||
TEST_ASSERT_GREATER_THAN_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
|
||||
"unknown fields must push the raw size past the fit threshold");
|
||||
// The frame is still one a radio could actually send.
|
||||
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + MESHTASTIC_HEADER_LENGTH, "frame must still fit a LoRa frame");
|
||||
|
||||
encryptAsChannelPacket(&p, wire, raw);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unity lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,6 +363,10 @@ void setUp(void)
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
|
||||
owner = meshtastic_User_init_zero;
|
||||
// Exercise the downgrade-protection matrix by default. Production defaults to
|
||||
// COMPATIBLE so existing meshes remain interoperable; tests that cover that
|
||||
// mode opt in explicitly.
|
||||
setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED);
|
||||
myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs()
|
||||
|
||||
// Working primary channel with the default PSK so encrypt/decrypt round-trips.
|
||||
@@ -667,7 +723,6 @@ void test_A17_strict_verifies_signer_from_warm_key_store(void)
|
||||
"Balanced downgrade memory must survive repeated hot-store eviction");
|
||||
}
|
||||
#endif
|
||||
|
||||
// ===========================================================================
|
||||
// Group B - send-side signing policy (perhapsEncode)
|
||||
// ===========================================================================
|
||||
@@ -760,7 +815,7 @@ void test_B5_preset_signature_on_local_packet_cleared(void)
|
||||
// B6: the exact-fit gate tracks Data *shape*, not just payload size. A tapback-style broadcast
|
||||
// (want_response + reply_id + emoji) carries extra wire bytes that shift the fit boundary; the
|
||||
// sweep proves no dead band exists for that shape either, and - once the signer bit is learned -
|
||||
// that the receiver's rawSize-driven downgrade predicate stays symmetric for it too. Window
|
||||
// that the receiver's downgrade predicate stays symmetric for it too. Window
|
||||
// straddles this shape's boundary; capped at 200 so even the unsigned rich encoding stays well
|
||||
// inside the frame (at n=221 it first hits the pre-existing, signing-unrelated TOO_LARGE).
|
||||
void test_B6_rich_shape_sweep_no_deadband(void)
|
||||
@@ -1164,7 +1219,7 @@ void test_D1_signature_field_overhead_exact(void)
|
||||
// ===========================================================================
|
||||
// Already-decoded packets never reach perhapsDecode's crypto path (it early-returns), so
|
||||
// plaintext-MQTT downlink applies this policy function directly at ingress (MQTT.cpp). These
|
||||
// tests drive it the same way: decoded packets, encodedDataSize = 0 (canonical sizing).
|
||||
// tests drive it the same way: decoded packets, sized from p->decoded exactly as the RF path is.
|
||||
// End-to-end MQTT wiring is covered in test_mqtt.
|
||||
|
||||
// E1: unsigned small broadcast from a known signer -> dropped (downgrade protection holds on
|
||||
@@ -1222,8 +1277,8 @@ void test_E4_decoded_bad_signature_dropped(void)
|
||||
TEST_ASSERT_FALSE(p.xeddsa_signed);
|
||||
}
|
||||
|
||||
// E5: unsigned oversized broadcast from a signer -> accepted (canonical sizing exempts packets
|
||||
// whose signed encoding wouldn't fit, mirroring the RF-path rawSize rule).
|
||||
// E5: unsigned oversized broadcast from a signer -> accepted (packets whose signed encoding
|
||||
// wouldn't fit are exempt, identically to the RF path: both size p->decoded).
|
||||
void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void)
|
||||
{
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// Tests for the admin-key fallback in Router::perhapsDecode: a PKI unicast from an unknown sender is
|
||||
// tried against each configured admin_key; on success the packet decodes and the key is persisted to
|
||||
// NodeDB. Drives the real crypto + NodeDB path with packets encrypted exactly as an admin radio would.
|
||||
|
||||
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
// The whole feature is compiled out when PKI is excluded.
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/CryptoEngine.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/RadioInterface.h" // MESHTASTIC_PKC_OVERHEAD
|
||||
#include "mesh/Router.h"
|
||||
#include <cstring>
|
||||
#include <pb_encode.h>
|
||||
#include <vector>
|
||||
|
||||
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; // us (the receiver)
|
||||
static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // an authorized admin, absent from our NodeDB
|
||||
static constexpr uint32_t PKT_ID = 0x12345678;
|
||||
|
||||
// MockNodeDB - inject nodes with controlled public keys (meshNodes/numMeshNodes are public on NodeDB).
|
||||
// Mirrors test/test_packet_signing.
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
void addNode(NodeNum num)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
void setPublicKey(NodeNum num, const uint8_t *pubKey)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
n->public_key.size = 32;
|
||||
memcpy(n->public_key.bytes, pubKey, 32);
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
|
||||
// Keypairs, regenerated fresh each test in setUp(). "our" == the receiver, "admin" == the sender.
|
||||
static uint8_t ourPub[32], ourPriv[32];
|
||||
static uint8_t adminPub[32], adminPriv[32];
|
||||
|
||||
// Store a 32-byte key into config.security.admin_key[slot].
|
||||
static void setAdminKey(int slot, const uint8_t *key32)
|
||||
{
|
||||
config.security.admin_key[slot].size = 32;
|
||||
memcpy(config.security.admin_key[slot].bytes, key32, 32);
|
||||
if (slot + 1 > (int)config.security.admin_key_count)
|
||||
config.security.admin_key_count = slot + 1;
|
||||
}
|
||||
|
||||
// Build a PKI-encrypted unicast from `from` to us, encrypted with `senderPriv` against our public key,
|
||||
// leaving the engine holding our private key afterwards (as during receive) so perhapsDecode can decrypt.
|
||||
static meshtastic_MeshPacket makePkiPacket(NodeNum from, meshtastic_PortNum port, size_t payloadLen, const uint8_t *senderPriv)
|
||||
{
|
||||
meshtastic_Data data = meshtastic_Data_init_zero;
|
||||
data.portnum = port;
|
||||
data.payload.size = payloadLen;
|
||||
for (size_t i = 0; i < payloadLen; i++)
|
||||
data.payload.bytes[i] = (uint8_t)(i & 0xff);
|
||||
|
||||
uint8_t plain[meshtastic_Constants_DATA_PAYLOAD_LEN];
|
||||
size_t plainLen = pb_encode_to_bytes(plain, sizeof(plain), &meshtastic_Data_msg, &data);
|
||||
TEST_ASSERT_TRUE_MESSAGE(plainLen > 0, "pb_encode_to_bytes failed in test setup");
|
||||
|
||||
meshtastic_NodeInfoLite_public_key_t ourPubStruct;
|
||||
ourPubStruct.size = 32;
|
||||
memcpy(ourPubStruct.bytes, ourPub, 32);
|
||||
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.from = from;
|
||||
p.to = LOCAL_NODE;
|
||||
p.id = PKT_ID;
|
||||
p.channel = 0; // PKI packets carry channel hash 0
|
||||
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
|
||||
|
||||
// Encrypt AS the sender: shared secret = DH(senderPriv, ourPub).
|
||||
crypto->setDHPrivateKey(const_cast<uint8_t *>(senderPriv));
|
||||
bool ok = crypto->encryptCurve25519(p.to, p.from, ourPubStruct, p.id, plainLen, plain, p.encrypted.bytes);
|
||||
TEST_ASSERT_TRUE_MESSAGE(ok, "encryptCurve25519 failed in test setup");
|
||||
p.encrypted.size = plainLen + MESHTASTIC_PKC_OVERHEAD;
|
||||
|
||||
// Restore the engine to our private key, as it is when receiving.
|
||||
crypto->setDHPrivateKey(ourPriv);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Assert the packet decoded via PKI and that we learned `expectedKey` for its sender.
|
||||
static void assertDecodedAndLearned(meshtastic_MeshPacket *p, const uint8_t *expectedKey)
|
||||
{
|
||||
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p->which_payload_variant);
|
||||
TEST_ASSERT_TRUE(p->pki_encrypted);
|
||||
TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p->decoded.portnum);
|
||||
TEST_ASSERT_EQUAL(32, p->public_key.size);
|
||||
TEST_ASSERT_EQUAL_MEMORY(expectedKey, p->public_key.bytes, 32);
|
||||
|
||||
meshtastic_NodeInfoLite *learned = mockNodeDB->getMeshNode(p->from);
|
||||
TEST_ASSERT_NOT_NULL_MESSAGE(learned, "sender should have been created in NodeDB");
|
||||
TEST_ASSERT_EQUAL_MESSAGE(32, learned->public_key.size, "sender key should have been persisted");
|
||||
TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expectedKey, learned->public_key.bytes, 32, "persisted key mismatch");
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
// Construct the mock FIRST: the NodeDB ctor can reload persisted host state and repopulate globals.
|
||||
mockNodeDB = new MockNodeDB();
|
||||
mockNodeDB->clearTestNodes();
|
||||
nodeDB = mockNodeDB;
|
||||
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
owner = meshtastic_User_init_zero;
|
||||
myNodeInfo.my_node_num = LOCAL_NODE; // drives isToUs()/getFrom()
|
||||
|
||||
channels.initDefaults();
|
||||
channels.onConfigChanged();
|
||||
|
||||
// Fresh keypairs for us and the admin (independent, valid Curve25519 pairs).
|
||||
crypto->generateKeyPair(ourPub, ourPriv);
|
||||
crypto->generateKeyPair(adminPub, adminPriv);
|
||||
|
||||
// perhapsDecode's PKI gate requires that we have our own key (getMeshNode(p->to)->public_key).
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->setPublicKey(LOCAL_NODE, ourPub);
|
||||
|
||||
// During receive the engine holds our private key.
|
||||
crypto->setDHPrivateKey(ourPriv);
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
delete mockNodeDB;
|
||||
mockNodeDB = nullptr;
|
||||
nodeDB = nullptr;
|
||||
}
|
||||
|
||||
// Admin key in slot 0: a DM from an unknown sender decrypts via the fallback, and the key is persisted.
|
||||
void test_admin_key_slot0_decrypts_and_persists(void)
|
||||
{
|
||||
setAdminKey(0, adminPub);
|
||||
TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "precondition: sender is unknown to us");
|
||||
|
||||
meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv);
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p));
|
||||
|
||||
assertDecodedAndLearned(&p, adminPub);
|
||||
}
|
||||
|
||||
// The loop scans every admin slot, not just [0]: a key provisioned only in slot 2 still works.
|
||||
void test_admin_key_slot2_only_decrypts(void)
|
||||
{
|
||||
setAdminKey(2, adminPub); // slots 0 and 1 left empty
|
||||
|
||||
meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv);
|
||||
TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p));
|
||||
|
||||
assertDecodedAndLearned(&p, adminPub);
|
||||
}
|
||||
|
||||
// No admin key configured + unknown sender: nothing decrypts, and we must NOT invent a key for anyone.
|
||||
void test_no_admin_key_unknown_sender_not_decoded(void)
|
||||
{
|
||||
// config (incl. admin_key) is zeroed by setUp(); ADMIN_NODE is absent from NodeDB.
|
||||
meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv);
|
||||
|
||||
TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p));
|
||||
TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant);
|
||||
TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "must not learn a key when nothing decrypted");
|
||||
}
|
||||
|
||||
// A configured admin key that is NOT the sender's must fail authentication (no bogus key learned).
|
||||
void test_wrong_admin_key_does_not_decode(void)
|
||||
{
|
||||
uint8_t otherPub[32], otherPriv[32];
|
||||
crypto->generateKeyPair(otherPub, otherPriv); // unrelated key
|
||||
crypto->setDHPrivateKey(ourPriv); // restore receive key (generateKeyPair changed it)
|
||||
setAdminKey(0, otherPub); // admin slot holds a key that did NOT encrypt the packet
|
||||
|
||||
meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv);
|
||||
|
||||
TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p));
|
||||
TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant);
|
||||
TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE));
|
||||
}
|
||||
|
||||
#endif // !(MESHTASTIC_EXCLUDE_PKI)
|
||||
|
||||
void setup()
|
||||
{
|
||||
delay(10);
|
||||
delay(2000);
|
||||
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
RUN_TEST(test_admin_key_slot0_decrypts_and_persists);
|
||||
RUN_TEST(test_admin_key_slot2_only_decrypts);
|
||||
RUN_TEST(test_no_admin_key_unknown_sender_not_decoded);
|
||||
RUN_TEST(test_wrong_admin_key_does_not_decode);
|
||||
#endif
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
@@ -214,7 +214,8 @@ static void test_cryptoKeyIsPublic_invalidKeyIsNotPublic()
|
||||
// Pre-fix, latitude_i = INT32_MAX made latLongToUTM read latBands[36] on a 21-char string
|
||||
// (stack-buffer-overflow at GeoCoord.cpp:128, an AddressSanitizer abort); extreme longitude produced
|
||||
// a negative UTM zone feeding the MGRS letter tables. The fix clamps the zone/band/col/row indices.
|
||||
// This exercises the fix under the coverage env's ASan.
|
||||
// This exercises the fix under the coverage env's ASan. Each representation is computed lazily,
|
||||
// so its getter must be called explicitly here to exercise its conversion path.
|
||||
static void test_geocoord_extreme_coords_no_oob()
|
||||
{
|
||||
const int32_t vals[] = {INT32_MIN, INT32_MAX, INT32_MIN + 1, INT32_MAX - 1, 0, 1, -1, 900000000, -900000000, // +/-90 deg
|
||||
@@ -223,7 +224,13 @@ static void test_geocoord_extreme_coords_no_oob()
|
||||
const size_t n = sizeof(vals) / sizeof(vals[0]);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
for (size_t j = 0; j < n; j++) {
|
||||
GeoCoord g(vals[i], vals[j], 0); // ctor -> setCoords() -> UTM/MGRS/OSGR/OLC
|
||||
GeoCoord g(vals[i], vals[j], 0); // ctor -> setCoords() -> DMS only
|
||||
// Force UTM/MGRS/OSGR/OLC computation (each lazy on first getter access).
|
||||
g.getUTMZone();
|
||||
g.getMGRSZone();
|
||||
g.getOSGRE100k();
|
||||
char olcCode[OLC_CODE_LEN + 1];
|
||||
g.getOLCCode(olcCode);
|
||||
// Surviving every extreme pair (no ASan fault) means the index clamps hold.
|
||||
TEST_ASSERT_EQUAL_INT32(vals[i], g.getLatitude());
|
||||
}
|
||||
|
||||
@@ -195,6 +195,47 @@ static void test_applyModemConfig_customCodingRateLowerThanPreset()
|
||||
TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr());
|
||||
}
|
||||
|
||||
// MEDIUM_TURBO performs like MEDIUM_FAST (sf=9, cr=5) but at 500 kHz. Verify the params resolve.
|
||||
static void test_applyModemConfig_mediumTurbo()
|
||||
{
|
||||
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
|
||||
|
||||
testRadio->reconfigure();
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT8(5, testRadio->getCr());
|
||||
TEST_ASSERT_EQUAL_UINT8(9, testRadio->getSf());
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01f, 500.0f, testRadio->getBw());
|
||||
}
|
||||
|
||||
// MEDIUM_TURBO is a 500 kHz preset, so it is invalid for EU_868 and must clamp to the region default.
|
||||
static void test_clampConfigLora_mediumTurboInvalidForEU868()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.use_preset = true;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
|
||||
|
||||
RadioInterface::clampConfigLora(cfg);
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
|
||||
}
|
||||
|
||||
// MEDIUM_TURBO is valid for US (PROFILE_STD) and must be left unchanged.
|
||||
static void test_clampConfigLora_mediumTurboValidForUS()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.use_preset = true;
|
||||
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
|
||||
|
||||
RadioInterface::clampConfigLora(cfg);
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, cfg.modem_preset);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getRegionPresetMap() - region->valid-preset map sent to clients during want_config
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -251,16 +292,30 @@ static void test_regionPresetMap_matchesRegionTable()
|
||||
const meshtastic_LoRaPresetGroup &grp = map.groups[gi];
|
||||
const RegionInfo *r = getRegion(code);
|
||||
|
||||
// Group's list is non-empty, within the generated array bound, and is the
|
||||
// region's full list.
|
||||
// Group's list is non-empty and within the generated array bound.
|
||||
const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]);
|
||||
TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count);
|
||||
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxPresets, grp.presets_count);
|
||||
TEST_ASSERT_EQUAL_UINT((unsigned)r->getNumPresets(), (unsigned)grp.presets_count);
|
||||
|
||||
// Every advertised preset is legal in this region.
|
||||
for (pb_size_t p = 0; p < grp.presets_count; p++)
|
||||
TEST_ASSERT_TRUE(r->supportsPreset(grp.presets[p]));
|
||||
// Every advertised preset must be selectable from this region: either legal here,
|
||||
// or legal in a sibling the firmware will auto-swap us to (the EU 86x trio, which
|
||||
// advertises the union of the trio's presets rather than just its own).
|
||||
for (pb_size_t p = 0; p < grp.presets_count; p++) {
|
||||
bool selectable =
|
||||
r->supportsPreset(grp.presets[p]) || RadioInterface::regionSwapForPreset(code, grp.presets[p]) != nullptr;
|
||||
TEST_ASSERT_TRUE(selectable);
|
||||
}
|
||||
|
||||
// The region's own enforced presets must all be advertised (advertised is a
|
||||
// superset of the enforced list, never a subset).
|
||||
const meshtastic_Config_LoRaConfig_ModemPreset *enforced = r->getAvailablePresets();
|
||||
for (size_t e = 0; e < r->getNumPresets(); e++) {
|
||||
bool advertised = false;
|
||||
for (pb_size_t p = 0; p < grp.presets_count; p++)
|
||||
if (grp.presets[p] == enforced[e])
|
||||
advertised = true;
|
||||
TEST_ASSERT_TRUE(advertised);
|
||||
}
|
||||
|
||||
// Default preset matches the table, is legal, and is present in the list.
|
||||
TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset);
|
||||
@@ -317,6 +372,9 @@ void setup()
|
||||
RUN_TEST(test_applyModemConfig_codingRateMatchesPreset);
|
||||
RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset);
|
||||
RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset);
|
||||
RUN_TEST(test_applyModemConfig_mediumTurbo);
|
||||
RUN_TEST(test_clampConfigLora_mediumTurboInvalidForEU868);
|
||||
RUN_TEST(test_clampConfigLora_mediumTurboValidForUS);
|
||||
RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds);
|
||||
RUN_TEST(test_regionPresetMap_matchesRegionTable);
|
||||
exit(UNITY_END());
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "TestUtil.h"
|
||||
#include "modules/games/Snake.h"
|
||||
#include <unity.h>
|
||||
|
||||
// Pure-logic tests for SnakeGame: ring-buffer advance, reversal rejection, wall/self collision,
|
||||
// growth on eat, and food-placement validity. No device globals or display stack required.
|
||||
|
||||
static const uint32_t kSeed = 0xC0FFEEu;
|
||||
|
||||
// Count how many board cells the snake currently occupies (cross-check for len()).
|
||||
static uint16_t countOccupied(const SnakeGame &game)
|
||||
{
|
||||
uint16_t n = 0;
|
||||
for (uint8_t y = 0; y < SnakeGame::GRID_H; y++)
|
||||
for (uint8_t x = 0; x < SnakeGame::GRID_W; x++)
|
||||
if (game.occupied(x, y))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
static void test_reset_initialState()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed);
|
||||
|
||||
TEST_ASSERT_TRUE(game.isPlaying());
|
||||
TEST_ASSERT_FALSE(game.isWon());
|
||||
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length());
|
||||
TEST_ASSERT_EQUAL_UINT32(0u, game.score());
|
||||
TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_RIGHT, game.direction());
|
||||
|
||||
// Head spawns at board centre; the whole test file relies on this anchor.
|
||||
SnakeGame::Cell head = game.head();
|
||||
TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_W / 2, head.x);
|
||||
TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_H / 2, head.y);
|
||||
|
||||
// Exactly START_LEN cells occupied, and the head is one of them.
|
||||
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
|
||||
TEST_ASSERT_TRUE(game.occupied(head.x, head.y));
|
||||
}
|
||||
|
||||
static void test_food_isValidAndOffBody()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed);
|
||||
SnakeGame::Cell food = game.food();
|
||||
TEST_ASSERT_TRUE(food.x < SnakeGame::GRID_W);
|
||||
TEST_ASSERT_TRUE(food.y < SnakeGame::GRID_H);
|
||||
TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); // food never spawns on the snake
|
||||
}
|
||||
|
||||
static void test_setDirection_rejectsReversal()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed); // heading right
|
||||
|
||||
TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); // 180 reversal -> rejected
|
||||
TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_UP)); // perpendicular -> ok
|
||||
TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_RIGHT)); // same as committed dir -> ok (no-op)
|
||||
|
||||
// A double-input within one tick can't chain into a reversal: after latching UP, LEFT is
|
||||
// still checked against the committed RIGHT and rejected, so the neck stays safe.
|
||||
game.setDirection(SnakeGame::DIR_UP);
|
||||
TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT));
|
||||
}
|
||||
|
||||
static void test_step_movesAndTailFollows()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed);
|
||||
SnakeGame::Cell head = game.head();
|
||||
game.placeFoodAt(0, 0); // corner, off the snake -> guaranteed non-eating step
|
||||
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
SnakeGame::Cell newHead = game.head();
|
||||
TEST_ASSERT_EQUAL_UINT8(head.x + 1, newHead.x); // moved one cell right
|
||||
TEST_ASSERT_EQUAL_UINT8(head.y, newHead.y);
|
||||
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); // length unchanged when not eating
|
||||
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
|
||||
TEST_ASSERT_EQUAL_UINT32(0u, game.score());
|
||||
}
|
||||
|
||||
static void test_eat_growsAndScores()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed);
|
||||
SnakeGame::Cell head = game.head();
|
||||
game.placeFoodAt(head.x + 1, head.y); // food directly ahead
|
||||
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, game.length()); // grew by one
|
||||
TEST_ASSERT_EQUAL_UINT32(1u, game.score());
|
||||
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, countOccupied(game));
|
||||
|
||||
// A fresh food was placed and is not on the snake.
|
||||
SnakeGame::Cell food = game.food();
|
||||
TEST_ASSERT_FALSE(game.occupied(food.x, food.y));
|
||||
}
|
||||
|
||||
static void test_wallCollision_endsGame()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed);
|
||||
game.placeFoodAt(0, 0);
|
||||
game.setDirection(SnakeGame::DIR_UP); // head is at mid-height; drive straight up into the wall
|
||||
|
||||
bool alive = true;
|
||||
int guard = 0;
|
||||
while (alive && guard++ < SnakeGame::GRID_H + 4) {
|
||||
game.placeFoodAt(0, 0); // keep food out of the way each tick
|
||||
alive = game.step();
|
||||
}
|
||||
TEST_ASSERT_FALSE(alive);
|
||||
TEST_ASSERT_FALSE(game.isPlaying());
|
||||
}
|
||||
|
||||
static void test_selfCollision_endsGame()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed);
|
||||
TEST_ASSERT_EQUAL_UINT8(16, game.head().x); // anchor the deterministic path below
|
||||
TEST_ASSERT_EQUAL_UINT8(6, game.head().y);
|
||||
|
||||
// Grow to length 5 along a straight horizontal line (cells (14..18, 6)).
|
||||
game.placeFoodAt(17, 6);
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
game.placeFoodAt(18, 6);
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
TEST_ASSERT_EQUAL_UINT16(5, game.length());
|
||||
|
||||
// Curl back on itself: DOWN, LEFT, then UP re-enters an occupied body cell.
|
||||
game.setDirection(SnakeGame::DIR_DOWN);
|
||||
game.placeFoodAt(0, 0);
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
game.setDirection(SnakeGame::DIR_LEFT);
|
||||
game.placeFoodAt(0, 0);
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
game.setDirection(SnakeGame::DIR_UP);
|
||||
game.placeFoodAt(0, 0);
|
||||
TEST_ASSERT_FALSE(game.step()); // bites its own body
|
||||
TEST_ASSERT_FALSE(game.isPlaying());
|
||||
}
|
||||
|
||||
static void test_deadGame_stepIsNoOp()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed);
|
||||
game.setDirection(SnakeGame::DIR_UP);
|
||||
for (int i = 0; i < SnakeGame::GRID_H + 4; i++) {
|
||||
game.placeFoodAt(0, 0);
|
||||
game.step();
|
||||
}
|
||||
TEST_ASSERT_FALSE(game.isPlaying());
|
||||
uint32_t scoreBefore = game.score();
|
||||
TEST_ASSERT_FALSE(game.step()); // stays dead, no state change
|
||||
TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
extern "C" {
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_reset_initialState);
|
||||
RUN_TEST(test_food_isValidAndOffBody);
|
||||
RUN_TEST(test_setDirection_rejectsReversal);
|
||||
RUN_TEST(test_step_movesAndTailFollows);
|
||||
RUN_TEST(test_eat_growsAndScores);
|
||||
RUN_TEST(test_wallCollision_endsGame);
|
||||
RUN_TEST(test_selfCollision_endsGame);
|
||||
RUN_TEST(test_deadGame_stepIsNoOp);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
#include "MeshTypes.h"
|
||||
#include "SerialConsole.h"
|
||||
#include "TestUtil.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "mesh/MeshService.h"
|
||||
#include "mesh/StreamAPI.h"
|
||||
#include "mesh/StreamFrameWriter.h"
|
||||
#include <algorithm>
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <limits>
|
||||
#include <unity.h>
|
||||
#include <vector>
|
||||
|
||||
/// Output-only stream whose write quotas deterministically simulate backpressure.
|
||||
class ScriptedStream : public Stream
|
||||
{
|
||||
public:
|
||||
/// Report that no input bytes are queued.
|
||||
int available() override { return 0; }
|
||||
/// Return end-of-input for the output-only stream.
|
||||
int read() override { return -1; }
|
||||
/// Return end-of-input without consuming data.
|
||||
int peek() override { return -1; }
|
||||
/// Report the configured output capacity.
|
||||
int availableForWrite() override { return availableCapacity; }
|
||||
|
||||
/// Route single-byte writes through the quota-aware buffer writer.
|
||||
size_t write(uint8_t value) override { return write(&value, 1); }
|
||||
|
||||
/// Accept at most the next scripted quota and capture accepted bytes.
|
||||
size_t write(const uint8_t *buffer, size_t size) override
|
||||
{
|
||||
requestedLengths.push_back(size);
|
||||
size_t quota = size;
|
||||
if (!writeQuotas.empty()) {
|
||||
quota = writeQuotas.front();
|
||||
writeQuotas.pop_front();
|
||||
}
|
||||
size_t accepted = std::min(quota, size);
|
||||
output.insert(output.end(), buffer, buffer + accepted);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
/// Record flush calls without changing captured output.
|
||||
void flush() override { flushCount++; }
|
||||
|
||||
/// Set the maximum bytes accepted by the next write call.
|
||||
void queueWrite(size_t quota) { writeQuotas.push_back(quota); }
|
||||
|
||||
int availableCapacity = std::numeric_limits<int>::max();
|
||||
unsigned flushCount = 0;
|
||||
std::deque<size_t> writeQuotas;
|
||||
std::vector<size_t> requestedLengths;
|
||||
std::vector<uint8_t> output;
|
||||
};
|
||||
|
||||
/// Print sink that records bytes emitted by the real SerialConsole.
|
||||
class RecordingPrint : public Print
|
||||
{
|
||||
public:
|
||||
/// Capture one output byte.
|
||||
size_t write(uint8_t value) override
|
||||
{
|
||||
output.push_back(value);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> output;
|
||||
};
|
||||
|
||||
/// Installs a MeshService for a test and restores the previous global service.
|
||||
class ScopedMeshService
|
||||
{
|
||||
public:
|
||||
/// Install the scoped service.
|
||||
ScopedMeshService() : previous(service) { service = &instance; }
|
||||
/// Restore the prior service after StreamAPI fixtures are destroyed.
|
||||
~ScopedMeshService() { service = previous; }
|
||||
|
||||
private:
|
||||
MeshService instance;
|
||||
MeshService *previous;
|
||||
};
|
||||
|
||||
/// Exposes generic StreamAPI hooks and records frame-write behavior.
|
||||
class StreamAPITestShim : public StreamAPI
|
||||
{
|
||||
public:
|
||||
/// Construct the shim over a scripted stream.
|
||||
explicit StreamAPITestShim(Stream *stream) : StreamAPI(stream) {}
|
||||
|
||||
/// Keep connection-timeout handling inactive during tests.
|
||||
bool checkIsConnected() override { return true; }
|
||||
|
||||
/// Invoke the generic transport implementation rather than this shim's capture hook.
|
||||
bool writeBaseFrame(uint8_t *buf, size_t len, bool bestEffort = false) { return StreamAPI::writeFrame(buf, len, bestEffort); }
|
||||
|
||||
bool finishReady = true;
|
||||
bool allowWrite = true;
|
||||
unsigned finishCalls = 0;
|
||||
unsigned frameWriteCalls = 0;
|
||||
unsigned failureCalls = 0;
|
||||
size_t failedFrameLen = 0;
|
||||
size_t failedWrittenLen = 0;
|
||||
std::vector<uint8_t> capturedPayload;
|
||||
|
||||
protected:
|
||||
/// Record the pending-frame gate and return its configured state.
|
||||
bool finishPendingFrame() override
|
||||
{
|
||||
finishCalls++;
|
||||
return finishReady;
|
||||
}
|
||||
|
||||
/// Apply the configured generic write-readiness result.
|
||||
bool canWriteFrame(size_t) override { return allowWrite; }
|
||||
|
||||
/// Capture generic short-write failure metadata.
|
||||
void onFrameWriteFailed(size_t frameLen, size_t writtenLen) override
|
||||
{
|
||||
failureCalls++;
|
||||
failedFrameLen = frameLen;
|
||||
failedWrittenLen = writtenLen;
|
||||
}
|
||||
|
||||
/// Capture one encoded PhoneAPI payload without writing it.
|
||||
bool writeFrame(uint8_t *buf, size_t len, bool bestEffort) override
|
||||
{
|
||||
(void)bestEffort;
|
||||
frameWriteCalls++;
|
||||
capturedPayload.assign(buf + 4, buf + 4 + len);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/// Exposes framed-log hooks and records best-effort writes.
|
||||
class LogHookStreamAPI : public StreamAPI
|
||||
{
|
||||
public:
|
||||
/// Construct the log shim over a scripted stream.
|
||||
explicit LogHookStreamAPI(Stream *stream) : StreamAPI(stream) {}
|
||||
|
||||
/// Keep connection-timeout handling inactive during tests.
|
||||
bool checkIsConnected() override { return true; }
|
||||
|
||||
/// Encode a formatted log through StreamAPI's production log path.
|
||||
void emitTestLog(const char *format, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
emitLogRecord(meshtastic_LogRecord_Level_INFO, "test", format, args);
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
bool allowLogEncoding = false;
|
||||
unsigned frameWriteCalls = 0;
|
||||
bool lastBestEffort = false;
|
||||
|
||||
protected:
|
||||
/// Apply the configured log-encoding gate.
|
||||
bool canEncodeLogRecord() override { return allowLogEncoding; }
|
||||
|
||||
/// Record whether the encoded log was marked best-effort.
|
||||
bool writeFrame(uint8_t *, size_t, bool bestEffort) override
|
||||
{
|
||||
frameWriteCalls++;
|
||||
lastBestEffort = bestEffort;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/// Assert byte-for-byte equality between expected and captured stream output.
|
||||
static void assertBytesEqual(const std::vector<uint8_t> &expected, const std::vector<uint8_t> &actual)
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT(expected.size(), actual.size());
|
||||
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected.data(), actual.data(), expected.size());
|
||||
}
|
||||
|
||||
/// Verify retries append only the unwritten tail and reproduce the frame exactly once.
|
||||
void test_frame_writer_continues_only_unwritten_tail()
|
||||
{
|
||||
ScriptedStream stream;
|
||||
StreamFrameWriter writer;
|
||||
std::vector<uint8_t> frame = {0x94, 0xc3, 0x00, 0x06, 1, 2, 3, 4, 5, 6};
|
||||
stream.queueWrite(3);
|
||||
stream.queueWrite(2);
|
||||
stream.queueWrite(frame.size());
|
||||
|
||||
TEST_ASSERT_FALSE(writer.writeFrame(stream, frame.data(), frame.size(), false));
|
||||
TEST_ASSERT_FALSE(writer.isIdle());
|
||||
TEST_ASSERT_FALSE(writer.finishPendingFrame(stream));
|
||||
TEST_ASSERT_FALSE(writer.isIdle());
|
||||
TEST_ASSERT_TRUE(writer.finishPendingFrame(stream));
|
||||
TEST_ASSERT_TRUE(writer.isIdle());
|
||||
|
||||
std::vector<size_t> expectedRequests = {10, 7, 5};
|
||||
TEST_ASSERT_EQUAL_UINT(expectedRequests.size(), stream.requestedLengths.size());
|
||||
TEST_ASSERT_EQUAL_UINT64_ARRAY(expectedRequests.data(), stream.requestedLengths.data(), expectedRequests.size());
|
||||
assertBytesEqual(frame, stream.output);
|
||||
TEST_ASSERT_EQUAL_UINT(0, stream.flushCount);
|
||||
}
|
||||
|
||||
/// Verify a replacement session receives a complete old frame before its new frame.
|
||||
void test_frame_writer_completes_retained_tail_before_new_session_frame()
|
||||
{
|
||||
ScriptedStream stream;
|
||||
StreamFrameWriter writer;
|
||||
std::vector<uint8_t> oldFrame = {0x94, 0xc3, 0x00, 0x03, 0xa1, 0xa2, 0xa3};
|
||||
std::vector<uint8_t> newFrame = {0x94, 0xc3, 0x00, 0x02, 0xb1, 0xb2};
|
||||
stream.queueWrite(3);
|
||||
stream.queueWrite(oldFrame.size());
|
||||
stream.queueWrite(newFrame.size());
|
||||
|
||||
TEST_ASSERT_FALSE(writer.writeFrame(stream, oldFrame.data(), oldFrame.size(), false));
|
||||
TEST_ASSERT_FALSE(writer.isIdle());
|
||||
|
||||
// A replacement client starts without discarding the accepted old prefix.
|
||||
TEST_ASSERT_TRUE(writer.writeFrame(stream, newFrame.data(), newFrame.size(), false));
|
||||
TEST_ASSERT_TRUE(writer.isIdle());
|
||||
|
||||
std::vector<uint8_t> expected = oldFrame;
|
||||
expected.insert(expected.end(), newFrame.begin(), newFrame.end());
|
||||
assertBytesEqual(expected, stream.output);
|
||||
}
|
||||
|
||||
/// Verify a required main frame remains ordered behind a partial log frame.
|
||||
void test_frame_writer_defers_main_behind_partial_log()
|
||||
{
|
||||
ScriptedStream stream;
|
||||
StreamFrameWriter writer;
|
||||
std::vector<uint8_t> logFrame = {0x94, 0xc3, 0x00, 0x02, 0xa1, 0xa2};
|
||||
std::vector<uint8_t> mainFrame = {0x94, 0xc3, 0x00, 0x03, 0xb1, 0xb2, 0xb3};
|
||||
stream.queueWrite(2);
|
||||
stream.queueWrite(0);
|
||||
stream.queueWrite(logFrame.size());
|
||||
stream.queueWrite(mainFrame.size());
|
||||
|
||||
TEST_ASSERT_FALSE(writer.writeFrame(stream, logFrame.data(), logFrame.size(), true));
|
||||
TEST_ASSERT_FALSE(writer.isIdle());
|
||||
TEST_ASSERT_FALSE(writer.writeFrame(stream, mainFrame.data(), mainFrame.size(), false));
|
||||
TEST_ASSERT_FALSE(writer.isIdle());
|
||||
TEST_ASSERT_FALSE(writer.finishPendingFrame(stream));
|
||||
TEST_ASSERT_FALSE(writer.isIdle());
|
||||
TEST_ASSERT_TRUE(writer.finishPendingFrame(stream));
|
||||
TEST_ASSERT_TRUE(writer.isIdle());
|
||||
|
||||
std::vector<uint8_t> expected = logFrame;
|
||||
expected.insert(expected.end(), mainFrame.begin(), mainFrame.end());
|
||||
assertBytesEqual(expected, stream.output);
|
||||
TEST_ASSERT_EQUAL_UINT(4, stream.requestedLengths.size());
|
||||
TEST_ASSERT_EQUAL_UINT(0, stream.flushCount);
|
||||
}
|
||||
|
||||
/// Verify best-effort output starts only when the complete frame fits.
|
||||
void test_frame_writer_rejects_best_effort_without_full_capacity()
|
||||
{
|
||||
ScriptedStream stream;
|
||||
StreamFrameWriter writer;
|
||||
std::vector<uint8_t> frame = {0x94, 0xc3, 0x00, 0x02, 1, 2};
|
||||
stream.availableCapacity = frame.size() - 1;
|
||||
|
||||
TEST_ASSERT_FALSE(writer.writeFrame(stream, frame.data(), frame.size(), true));
|
||||
TEST_ASSERT_TRUE(writer.isIdle());
|
||||
TEST_ASSERT_EQUAL_UINT(0, stream.requestedLengths.size());
|
||||
|
||||
stream.availableCapacity = frame.size();
|
||||
TEST_ASSERT_TRUE(writer.writeFrame(stream, frame.data(), frame.size(), true));
|
||||
TEST_ASSERT_TRUE(writer.isIdle());
|
||||
TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size());
|
||||
assertBytesEqual(frame, stream.output);
|
||||
}
|
||||
|
||||
/// Verify each zero-progress continuation makes one bounded write attempt.
|
||||
void test_frame_writer_zero_progress_is_one_bounded_attempt()
|
||||
{
|
||||
ScriptedStream stream;
|
||||
StreamFrameWriter writer;
|
||||
std::vector<uint8_t> frame = {0x94, 0xc3, 0x00, 0x02, 1, 2};
|
||||
stream.queueWrite(1);
|
||||
stream.queueWrite(0);
|
||||
stream.queueWrite(0);
|
||||
stream.queueWrite(frame.size());
|
||||
|
||||
TEST_ASSERT_FALSE(writer.writeFrame(stream, frame.data(), frame.size(), false));
|
||||
TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size());
|
||||
TEST_ASSERT_FALSE(writer.finishPendingFrame(stream));
|
||||
TEST_ASSERT_EQUAL_UINT(2, stream.requestedLengths.size());
|
||||
TEST_ASSERT_FALSE(writer.finishPendingFrame(stream));
|
||||
TEST_ASSERT_EQUAL_UINT(3, stream.requestedLengths.size());
|
||||
TEST_ASSERT_TRUE(writer.finishPendingFrame(stream));
|
||||
TEST_ASSERT_EQUAL_UINT(4, stream.requestedLengths.size());
|
||||
assertBytesEqual(frame, stream.output);
|
||||
}
|
||||
|
||||
/// Verify generic StreamAPI framing and successful-write flush behavior.
|
||||
void test_stream_api_full_write_frames_and_flushes()
|
||||
{
|
||||
ScopedMeshService scopedService;
|
||||
ScriptedStream stream;
|
||||
StreamAPITestShim api(&stream);
|
||||
uint8_t frame[7] = {0, 0, 0, 0, 0x11, 0x22, 0x33};
|
||||
|
||||
TEST_ASSERT_TRUE(api.writeBaseFrame(frame, 3));
|
||||
|
||||
std::vector<uint8_t> expected = {0x94, 0xc3, 0x00, 0x03, 0x11, 0x22, 0x33};
|
||||
assertBytesEqual(expected, stream.output);
|
||||
TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size());
|
||||
TEST_ASSERT_EQUAL_UINT(1, stream.flushCount);
|
||||
TEST_ASSERT_EQUAL_UINT(0, api.failureCalls);
|
||||
}
|
||||
|
||||
/// Verify generic transports report short writes without flushing or retrying.
|
||||
void test_stream_api_short_write_reports_failure_without_flush()
|
||||
{
|
||||
ScopedMeshService scopedService;
|
||||
ScriptedStream stream;
|
||||
StreamAPITestShim api(&stream);
|
||||
uint8_t frame[7] = {0, 0, 0, 0, 0x11, 0x22, 0x33};
|
||||
stream.queueWrite(5);
|
||||
|
||||
TEST_ASSERT_FALSE(api.writeBaseFrame(frame, 3));
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size());
|
||||
TEST_ASSERT_EQUAL_UINT(0, stream.flushCount);
|
||||
TEST_ASSERT_EQUAL_UINT(1, api.failureCalls);
|
||||
TEST_ASSERT_EQUAL_UINT(7, api.failedFrameLen);
|
||||
TEST_ASSERT_EQUAL_UINT(5, api.failedWrittenLen);
|
||||
}
|
||||
|
||||
/// Verify retained output blocks PhoneAPI from dequeuing the next payload.
|
||||
void test_stream_api_finishes_pending_before_advancing_phone_api()
|
||||
{
|
||||
ScopedMeshService scopedService;
|
||||
ScriptedStream stream;
|
||||
StreamAPITestShim api(&stream);
|
||||
api.sendConfigComplete();
|
||||
api.sendNotification(meshtastic_LogRecord_Level_WARNING, 42, "still queued");
|
||||
api.finishReady = false;
|
||||
|
||||
api.runOncePart(nullptr, 0);
|
||||
TEST_ASSERT_EQUAL_UINT(1, api.finishCalls);
|
||||
TEST_ASSERT_EQUAL_UINT(0, api.frameWriteCalls);
|
||||
|
||||
api.finishReady = true;
|
||||
api.runOncePart(nullptr, 0);
|
||||
TEST_ASSERT_EQUAL_UINT(2, api.finishCalls);
|
||||
TEST_ASSERT_EQUAL_UINT(1, api.frameWriteCalls);
|
||||
|
||||
meshtastic_FromRadio decoded = meshtastic_FromRadio_init_zero;
|
||||
TEST_ASSERT_TRUE(
|
||||
pb_decode_from_bytes(api.capturedPayload.data(), api.capturedPayload.size(), &meshtastic_FromRadio_msg, &decoded));
|
||||
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_clientNotification_tag, decoded.which_payload_variant);
|
||||
TEST_ASSERT_EQUAL_UINT32(42, decoded.clientNotification.reply_id);
|
||||
}
|
||||
|
||||
/// Verify framed logs honor the encoding gate and use best-effort writes.
|
||||
void test_stream_api_gates_logs_and_marks_them_best_effort()
|
||||
{
|
||||
ScopedMeshService scopedService;
|
||||
ScriptedStream stream;
|
||||
LogHookStreamAPI api(&stream);
|
||||
|
||||
api.emitTestLog("blocked %u", 1U);
|
||||
TEST_ASSERT_EQUAL_UINT(0, api.frameWriteCalls);
|
||||
|
||||
api.allowLogEncoding = true;
|
||||
api.emitTestLog("allowed %u", 2U);
|
||||
TEST_ASSERT_EQUAL_UINT(1, api.frameWriteCalls);
|
||||
TEST_ASSERT_TRUE(api.lastBestEffort);
|
||||
}
|
||||
|
||||
/// Verify the real SerialConsole emits no unframed bytes in protobuf mode.
|
||||
void test_serial_console_suppresses_raw_output_in_protobuf_mode()
|
||||
{
|
||||
RecordingPrint sink;
|
||||
const bool oldHasLora = config.has_lora;
|
||||
const bool oldHasSecurity = config.has_security;
|
||||
const bool oldSerialEnabled = config.security.serial_enabled;
|
||||
const bool oldDebugLogApiEnabled = config.security.debug_log_api_enabled;
|
||||
|
||||
config.has_lora = true;
|
||||
config.has_security = true;
|
||||
config.security.serial_enabled = true;
|
||||
config.security.debug_log_api_enabled = false;
|
||||
console->setDestination(&sink);
|
||||
|
||||
console->write('A');
|
||||
const bool rawBeforeProtobuf = sink.output.size() == 1 && sink.output[0] == 'A';
|
||||
sink.output.clear();
|
||||
|
||||
const uint8_t emptyToRadio = 0;
|
||||
console->handleToRadio(&emptyToRadio, 0);
|
||||
console->write('B');
|
||||
console->write('\n');
|
||||
console->log(MESHTASTIC_LOG_LEVEL_ERROR, "must stay framed");
|
||||
const bool emptyAfterProtobuf = sink.output.empty();
|
||||
|
||||
console->setDestination(&Serial);
|
||||
config.has_lora = oldHasLora;
|
||||
config.has_security = oldHasSecurity;
|
||||
config.security.serial_enabled = oldSerialEnabled;
|
||||
config.security.debug_log_api_enabled = oldDebugLogApiEnabled;
|
||||
|
||||
TEST_ASSERT_TRUE(rawBeforeProtobuf);
|
||||
TEST_ASSERT_TRUE(emptyAfterProtobuf);
|
||||
}
|
||||
|
||||
// Build a phone->radio ADMIN_APP packet carrying `admin`, with an arbitrary wire `from`.
|
||||
static meshtastic_MeshPacket makeAdminPacket(NodeNum from, const meshtastic_AdminMessage &admin)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.from = from;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
|
||||
p.decoded.payload.size =
|
||||
pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &admin);
|
||||
return p;
|
||||
}
|
||||
|
||||
// The lockdown admin gate must decide on the connection's authorization, not the wire `from`. A
|
||||
// client that sets from != 0 previously skipped the gate, so an unauthorized connection could run
|
||||
// admin. classifyLocalAdminPacket ignores `from`, so the same spoofed packet is still dropped.
|
||||
static void test_lockdown_admin_gate_ignores_wire_from(void)
|
||||
{
|
||||
meshtastic_AdminMessage setter = meshtastic_AdminMessage_init_zero;
|
||||
setter.which_payload_variant = meshtastic_AdminMessage_set_owner_tag;
|
||||
meshtastic_MeshPacket spoofed = makeAdminPacket(0x12345678, setter); // from != 0, the bypass
|
||||
|
||||
meshtastic_AdminMessage out;
|
||||
TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::DropUnauthorized,
|
||||
(int)PhoneAPI::classifyLocalAdminPacket(spoofed, /*adminAuthorized=*/false, out),
|
||||
"unauthorized admin with from != 0 must still be dropped");
|
||||
// Control: an authorized connection's identical packet passes through.
|
||||
TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::AuthorizedPassThrough,
|
||||
(int)PhoneAPI::classifyLocalAdminPacket(spoofed, /*adminAuthorized=*/true, out),
|
||||
"authorized admin must not be dropped");
|
||||
|
||||
// lockdown_auth is the authentication itself, so it is delivered inline regardless of from/auth.
|
||||
meshtastic_AdminMessage la = meshtastic_AdminMessage_init_zero;
|
||||
la.which_payload_variant = meshtastic_AdminMessage_lockdown_auth_tag;
|
||||
meshtastic_MeshPacket authPkt = makeAdminPacket(0x99, la);
|
||||
TEST_ASSERT_EQUAL((int)PhoneAPI::LocalAdminGate::LockdownAuth,
|
||||
(int)PhoneAPI::classifyLocalAdminPacket(authPkt, /*adminAuthorized=*/false, out));
|
||||
|
||||
// A non-admin packet is outside the gate entirely.
|
||||
meshtastic_MeshPacket text = meshtastic_MeshPacket_init_zero;
|
||||
text.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
text.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
||||
TEST_ASSERT_EQUAL((int)PhoneAPI::LocalAdminGate::NotAdmin,
|
||||
(int)PhoneAPI::classifyLocalAdminPacket(text, /*adminAuthorized=*/false, out));
|
||||
}
|
||||
|
||||
// An ADMIN_APP packet whose payload is not a decodable AdminMessage must fall through to the
|
||||
// normal reject path (NotAdmin), never be acted on as an admin command. The authorized control
|
||||
// proves the decode-failure check runs before the auth branch, so it can't pass for the wrong reason.
|
||||
static void test_lockdown_admin_gate_rejects_undecodable_admin(void)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
|
||||
// Length-delimited field (tag 0x0A) claiming 16 bytes with none following: pb_decode fails.
|
||||
p.decoded.payload.bytes[0] = 0x0A;
|
||||
p.decoded.payload.bytes[1] = 0x10;
|
||||
p.decoded.payload.size = 2;
|
||||
|
||||
meshtastic_AdminMessage out;
|
||||
TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::NotAdmin,
|
||||
(int)PhoneAPI::classifyLocalAdminPacket(p, /*adminAuthorized=*/false, out),
|
||||
"undecodable ADMIN_APP payload must fall through to the reject path");
|
||||
TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::NotAdmin,
|
||||
(int)PhoneAPI::classifyLocalAdminPacket(p, /*adminAuthorized=*/true, out),
|
||||
"undecodable ADMIN_APP payload must not pass through even when authorized");
|
||||
}
|
||||
|
||||
/// Unity per-test setup; fixtures are local to each test.
|
||||
void setUp(void) {}
|
||||
/// Unity per-test teardown; fixtures clean themselves up.
|
||||
void tearDown(void) {}
|
||||
|
||||
/// Initialize the native environment and run the stream regression suite.
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_frame_writer_continues_only_unwritten_tail);
|
||||
RUN_TEST(test_frame_writer_completes_retained_tail_before_new_session_frame);
|
||||
RUN_TEST(test_frame_writer_defers_main_behind_partial_log);
|
||||
RUN_TEST(test_frame_writer_rejects_best_effort_without_full_capacity);
|
||||
RUN_TEST(test_frame_writer_zero_progress_is_one_bounded_attempt);
|
||||
RUN_TEST(test_stream_api_full_write_frames_and_flushes);
|
||||
RUN_TEST(test_stream_api_short_write_reports_failure_without_flush);
|
||||
RUN_TEST(test_stream_api_finishes_pending_before_advancing_phone_api);
|
||||
RUN_TEST(test_stream_api_gates_logs_and_marks_them_best_effort);
|
||||
RUN_TEST(test_lockdown_admin_gate_ignores_wire_from);
|
||||
RUN_TEST(test_lockdown_admin_gate_rejects_undecodable_admin);
|
||||
// usingProtobufs intentionally has no reset path, so this must run last.
|
||||
RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
/// Unused Arduino loop required by the native Unity runner.
|
||||
void loop() {}
|
||||
@@ -110,6 +110,21 @@ class MockNodeDB : public NodeDB
|
||||
clearCachedNode();
|
||||
}
|
||||
|
||||
// Seed a hot-store node that has been learned as an XEdDSA signer, with a known name, so the
|
||||
// identity-update gate can be exercised. Distinct from setCachedNode() so a separate cached
|
||||
// node (the direct-response target) can coexist.
|
||||
void setSignerHotNode(NodeNum n, const char *longName)
|
||||
{
|
||||
if (meshNodes->size() < 2)
|
||||
meshNodes->resize(2);
|
||||
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
|
||||
(*meshNodes)[1].num = n;
|
||||
nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||||
strncpy((*meshNodes)[1].long_name, longName, sizeof((*meshNodes)[1].long_name) - 1);
|
||||
numMeshNodes = 2;
|
||||
}
|
||||
|
||||
private:
|
||||
bool hasCachedNode = false;
|
||||
NodeNum cachedNodeNum = 0;
|
||||
@@ -589,6 +604,45 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void)
|
||||
TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* A unicast NodeInfo request is never signed, so a known signer's identity claim on the
|
||||
* direct-response path is unauthenticated. It must not overwrite the stored name (spoofing
|
||||
* defense), while the direct response itself still goes out. The non-signer case
|
||||
* (test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo) is the control: it still learns.
|
||||
*/
|
||||
static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void)
|
||||
{
|
||||
moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
mockNodeDB->setCachedNode(kTargetNode); // the direct-response target
|
||||
mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer
|
||||
|
||||
MockRouter mockRouter;
|
||||
mockRouter.addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
MeshService mockService;
|
||||
router = &mockRouter;
|
||||
service = &mockService;
|
||||
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk");
|
||||
request.to = kTargetNode;
|
||||
request.decoded.want_response = true;
|
||||
request.id = 0x0A0B0C0D;
|
||||
request.hop_start = 3;
|
||||
request.hop_limit = 3;
|
||||
request.xeddsa_signed = false; // unicast: never signed
|
||||
|
||||
ProcessMessage result = module.handleReceived(request);
|
||||
|
||||
// The response still went out (the request was consumed from cache)...
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(result));
|
||||
TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits);
|
||||
// ...but the known signer's stored name was not overwritten by the unauthenticated claim.
|
||||
const meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode);
|
||||
TEST_ASSERT_NOT_NULL(requestor);
|
||||
TEST_ASSERT_EQUAL_STRING("victim-real", requestor->long_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify client role only answers direct (0-hop) NodeInfo requests.
|
||||
* Important so clients do not answer relayed requests outside intended scope.
|
||||
@@ -1715,6 +1769,7 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops);
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache);
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo);
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity);
|
||||
RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect);
|
||||
#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM))
|
||||
RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips);
|
||||
|
||||
@@ -116,15 +116,15 @@ void test_ws_keyedCandidate_evictsOldestKeylessFirst()
|
||||
// Fill with keyed entries except two keyless ones in the middle
|
||||
for (size_t i = 0; i < ws.capacity(); i++) {
|
||||
const bool keyless = (i == 5 || i == 10);
|
||||
// Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives
|
||||
// quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key));
|
||||
// One quantum apart (shift by WARM_META_BITS) so LRU order survives quantisation:
|
||||
// keyless i=10 is oldest (50), i=5 next (60), keyed all older (10).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << WARM_META_BITS, keyless ? NULL : key));
|
||||
}
|
||||
// Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50),
|
||||
// even though every keyed entry is older (ts=10)
|
||||
uint8_t k2[32];
|
||||
makeKey(k2, 0x43);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << WARM_META_BITS, k2));
|
||||
TEST_ASSERT_FALSE(ws.contains(0x1000 + 10));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x1000 + 5));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x8888));
|
||||
@@ -171,6 +171,31 @@ void test_ws_meta_roundTrip()
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
// The signer flag rides the same packed word as role/protected, so it must survive a
|
||||
// round trip without disturbing them (or the quantised timestamp).
|
||||
void test_ws_signer_roundTrip()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 0x78);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x710, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x711, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/false));
|
||||
|
||||
WarmNodeEntry e;
|
||||
TEST_ASSERT_TRUE(ws.take(0x710, e));
|
||||
TEST_ASSERT_TRUE_MESSAGE(warmSignerOf(e), "signer flag must round trip");
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e)); // and must not disturb its neighbours in the word
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e));
|
||||
|
||||
// Control: without the flag the same entry reads back clear, so the accessor is
|
||||
// reporting the stored bit rather than always-true.
|
||||
TEST_ASSERT_TRUE(ws.take(0x711, e));
|
||||
TEST_ASSERT_FALSE(warmSignerOf(e));
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
}
|
||||
|
||||
void test_ws_remove_and_clear()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
@@ -257,6 +282,63 @@ void test_ws_v1_migration_discardsLastHeard()
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
// A v2 (WRM2) warm.dat used bit 6 as a timestamp bit, so loading one must not read it as a
|
||||
// signer, while role/protected/time carry over. File backend only.
|
||||
void test_ws_v2_migration_clearsSignerBit()
|
||||
{
|
||||
WarmNodeStore a;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 0x67);
|
||||
// signer=true sets bit 6, standing in for a v2 record whose timestamp had it set.
|
||||
a.absorb(0x910, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true);
|
||||
if (!a.saveIfDirty()) {
|
||||
TEST_IGNORE_MESSAGE("Filesystem not available in this test environment");
|
||||
return;
|
||||
}
|
||||
|
||||
// Flip the 4-byte header magic to v2 ("WRM2"). CRC covers only the entry bytes, so
|
||||
// patching the header keeps it valid.
|
||||
std::vector<uint8_t> buf;
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ);
|
||||
if (!f) {
|
||||
TEST_IGNORE_MESSAGE("warm.dat not readable in this environment");
|
||||
return;
|
||||
}
|
||||
buf.resize(f.size());
|
||||
const size_t got = f.read(buf.data(), buf.size());
|
||||
f.close();
|
||||
TEST_ASSERT_EQUAL_MESSAGE(buf.size(), got, "short read patching warm.dat");
|
||||
}
|
||||
TEST_ASSERT_TRUE(buf.size() >= 4);
|
||||
const uint32_t v2magic = 0x324D5257u; // "WRM2"
|
||||
memcpy(buf.data(), &v2magic, sizeof(v2magic));
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE);
|
||||
TEST_ASSERT_TRUE((bool)f);
|
||||
const size_t wrote = f.write(buf.data(), buf.size());
|
||||
f.close();
|
||||
TEST_ASSERT_EQUAL_MESSAGE(buf.size(), wrote, "short write patching warm.dat");
|
||||
}
|
||||
|
||||
WarmNodeStore b;
|
||||
b.load();
|
||||
TEST_ASSERT_TRUE(b.contains(0x910));
|
||||
TEST_ASSERT_TRUE(b.copyKey(0x910, got));
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
|
||||
|
||||
WarmNodeEntry e;
|
||||
TEST_ASSERT_TRUE(b.take(0x910, e));
|
||||
TEST_ASSERT_FALSE_MESSAGE(warmSignerOf(e), "a v2 timestamp bit must not read as a signer");
|
||||
// Unlike v1, v2 kept role/protected/time in place, so they survive the migration.
|
||||
TEST_ASSERT_EQUAL(123456u & WARM_TIME_MASK, warmTimeOf(e));
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
|
||||
b.clear();
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
// Shrink safety: a warm.dat snapshot recording more entries than this build's
|
||||
// WARM_NODE_COUNT (e.g. written before a per-platform tier reduction) must be
|
||||
// rejected cleanly at the header check - load() starts empty instead of reading
|
||||
@@ -314,9 +396,11 @@ WS_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless);
|
||||
RUN_TEST(test_ws_meta_roundTrip);
|
||||
RUN_TEST(test_ws_signer_roundTrip);
|
||||
RUN_TEST(test_ws_remove_and_clear);
|
||||
RUN_TEST(test_ws_persistence_roundTrip);
|
||||
RUN_TEST(test_ws_v1_migration_discardsLastHeard);
|
||||
RUN_TEST(test_ws_v2_migration_clearsSignerBit);
|
||||
RUN_TEST(test_ws_load_rejectsOversizedSnapshot);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Tests for XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer
|
||||
// handler (src/xmodem.cpp). The filename in a SOH/STX control frame is attacker-controlled and
|
||||
// drives FSCom open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".."
|
||||
// component could escape the mountpoint. Absolute/subdirectory paths must still be accepted.
|
||||
#include "TestUtil.h"
|
||||
#include "xmodem.h"
|
||||
#include <unity.h>
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
#ifdef FSCom
|
||||
|
||||
void test_xmodem_rejects_dotdot_traversal(void)
|
||||
{
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(".."));
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("../secret"));
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/prefs/../../etc/passwd"));
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("a/../b"));
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir/.."));
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/.."));
|
||||
}
|
||||
|
||||
void test_xmodem_rejects_empty(void)
|
||||
{
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(""));
|
||||
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(nullptr));
|
||||
}
|
||||
|
||||
void test_xmodem_allows_legit_paths(void)
|
||||
{
|
||||
// The file manager transfers absolute, subdirectoried paths from the manifest.
|
||||
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("/prefs/config.proto"));
|
||||
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("firmware.bin"));
|
||||
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/sub/file.txt"));
|
||||
// ".." only inside a name (not a whole component) is a valid filename, not traversal.
|
||||
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("my..file"));
|
||||
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("..."));
|
||||
}
|
||||
|
||||
#endif // FSCom
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
#ifdef FSCom
|
||||
RUN_TEST(test_xmodem_rejects_dotdot_traversal);
|
||||
RUN_TEST(test_xmodem_rejects_empty);
|
||||
RUN_TEST(test_xmodem_allows_legit_paths);
|
||||
#endif
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
Reference in New Issue
Block a user