Merge develop into packet authentication policy

This commit is contained in:
Benjamin Faershtein
2026-07-19 15:33:16 -07:00
244 changed files with 10266 additions and 1139 deletions
+1 -1
View File
@@ -1 +1 @@
30
37
+7
View File
@@ -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; }
+207 -9
View File
@@ -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());
}
+448
View File
@@ -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() {}
+103
View File
@@ -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() {}
}
+100
View File
@@ -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() {}
}
+3 -2
View File
@@ -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;
}
+40
View File
@@ -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);
+64
View File
@@ -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() {}
+60 -5
View File
@@ -291,6 +291,58 @@ static bool signedEncodingFits(const meshtastic_Data *d)
return encodedDataSize(&copy) + 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);
+223
View File
@@ -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() {}
+9 -2
View File
@@ -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());
}
+64 -6
View File
@@ -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());
+180
View File
@@ -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() {}
}
+505
View File
@@ -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);
+88 -4
View File
@@ -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());
}
+55
View File
@@ -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() {}