Files
firmware/test/test_admin_radio/test_main.cpp
T
Garth Vander HouwenandThomas Göttgens 83198c1cbb fix(pki): reject a restored pre-2.8 low-entropy key at set time, explain the swap (#11686)
* fix(pki): reject a restored pre-2.8 low-entropy key at set time, explain the swap

Restoring/setting a private key is a private-key change: the public key is
*generated* from it. The low-entropy blacklist check in generateCryptoKeyPair
runs against the stored public_key at entry, which is empty on a bare key
restore — so a known pre-2.8 weak key derived from the provided private key was
never caught at set time. It was only detected on the next boot (once the weak
public key had been persisted and re-checked), which looks to the user like
their saved key silently "did not stick", and their node number
(== crc32(public_key)) had quietly changed too.

- NodeDB::generateCryptoKeyPair: in the provided-private-key branch, re-check
  the *derived* public key against LOW_ENTROPY_HASHES. If it matches, replace it
  with a fresh secure keypair and set keyIsLowEntropy so the reason is surfaced.
- AdminModule set-config(security): when the restore path regenerated a rejected
  low-entropy key, send a client warning at set time explaining the key can't be
  restored and the node number changed. Scoped to that branch so a stale flag
  from a boot-time regeneration can't fire on unrelated security sets.

No protobuf changes; reuses the existing ClientNotification warning path.

Signed-off-by: Garth Vander Houwen <garthvh@yahoo.com>

* fix(pki): gate low-entropy restore warning on successful keygen

generateCryptoKeyPair returns false on an unset LoRa region before
resetting keyIsLowEntropy, so the set-time warning could fire on a stale
flag. Capture the return value and require both.

Shorten the rationale comments to two lines each.

* fix(pki): clear key sizes when a restored private key derives nothing

The provided-private-key branch sets private_key.size and public_key.size
to 32 before regeneratePublicKey() runs. On failure it returned false with
both sizes still set, and AdminModule persisted that pair; every later
keygen then re-derived from the same dead key. Clear both on the failure
path so the next keygen mints a fresh identity.

Add test_admin_radio coverage for the set-time restore path: a derived
low-entropy key warns and rotates, a stale keyIsLowEntropy flag with
keygen blocked does not warn, and a failed derivation clears both sizes.

* fix(pki): validate a restored public key that is itself blacklisted

A restore supplying both private_key and public_key reached neither keygen
branch, so a whole pre-2.8 low-entropy pair was accepted and persisted at
set time and only caught on the next boot. Re-derive when the supplied
public key is blacklisted, which routes it through the same rejection and
warning as the bare-private-key restore. A non-blacklisted keypair import
is unaffected.

Install the test crypto stub through a helper and drop it in
restoreAdminRadioGlobals(), so a failed assertion's longjmp cannot leak a
freed engine into later tests.

* fix(pki): only warn about a swapped key when one was actually swapped

keyIsLowEntropy is set from the stored public key at function entry, so a
restore whose supplied public key is blacklisted set it even when keygen
merely re-derived the public key from a private key that was kept. The
warning then claimed a new key had been generated and the node number
changed, which was only half true. Gate it on the private key actually
being replaced.

* fix(pki): re-check a freshly minted keypair against the blacklist

Both mint sites called crypto->generateKeyPair() once and trusted the
result, so an entropy source still producing known-weak keys could persist
another blacklisted identity. Route both through a helper that re-checks
and retries a bounded number of times, then logs if it cannot do better.

Pass the caller's own copy of the private key to generateCryptoKeyPair()
instead of config.security.private_key.bytes, which aliased the memcpy
destination inside it.

* fix(pki): fail keygen when every replacement stays blacklisted

generateBlacklistCheckedKeyPair() logged an error after exhausting its
retries but left the compromised keypair in place and its callers marked
the keygen successful, persisting exactly the identity the check exists to
reject. Return a flag, clear both key sizes on exhaustion, and abort both
callers so the next keygen starts clean.

Match the declaration guard to the definition's, and derive the expected
mint count in the retry test from the configured one.

* refactor(pki): drop the keygen retry loop, fail on the first weak mint

Retrying cannot help: an entropy source that lands on one of the twelve
blacklisted keys is broken, and a second call to it produces the same
result. With real entropy the odds are ~2^-250, so the loop never runs
twice in practice either. Check once and fail, which is the same guarantee
in a third of the code.

* fix(pki): check the derived key on the stored-private-key path too

factory_reset_config keeps the private key and clears the public one, so
the entry check sees no stored key, reports "not low entropy" and takes the
regenerate branch, which adopted whatever it derived. A preserved pre-2.8
key was therefore accepted for a whole boot cycle before the next boot
caught it - the same silent revert this PR exists to remove.

Hoist the post-derive blacklist check into a helper and use it on both
derive paths.

* fix(pki): clear key sizes when stored-private derivation fails too

The stored-private-key path set public_key.size to 32 up front and left it
there when regeneratePublicKey() failed, so config claimed a pair the node
never got - the same defect already fixed on the provided-key path.

Both paths now derive through one helper that clears on failure and vets
the derived key, replacing the separate blacklist-replace helper.

---------

Signed-off-by: Garth Vander Houwen <garthvh@yahoo.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-09-03 11:20:21 +00:00

2696 lines
117 KiB
C++

/**
* Tests for the radio configuration validation and clamping functions
* introduced in the radio_interface_cherrypick branch.
*
* Targets:
* 1. getRegion()
* 2. RadioInterface::validateConfigRegion()
* 3. RadioInterface::validateConfigLora()
* 4. RadioInterface::clampConfigLora()
* 5. RegionInfo preset lists (PRESETS_STD, PRESETS_EU_868, PRESETS_UNDEF)
* 6. Channel spacing calculation (placeholder for future protobuf changes)
*/
#include "Channels.h"
#include "DisplayFormatters.h"
#include "FSCommon.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RadioInterface.h"
#include "TestUtil.h"
#include "graphics/draw/MenuHandler.h"
#include "mesh/Channels.h"
#include "mesh/CryptoEngine.h" // crypto global: the tests swap in a stub engine to drive key derivation
#include "mesh/Router.h" // router global: allocErrorResponse() allocates the reply through it
#include "modules/AdminModule.h"
#include "modules/NodeInfoModule.h"
#include <ErriezCRC32.h> // crc32Buffer(), for the my_node_num == crc32(public_key) invariant
#include <pb_decode.h>
#include <pb_encode.h>
#include <string>
#include <unity.h>
#include <vector>
#include "meshtastic/config.pb.h"
#include "support/AdminModuleTestShim.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;
// -----------------------------------------------------------------------
// getRegion() tests
// -----------------------------------------------------------------------
static void test_getRegion_returnsCorrectRegion_US()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, r->code);
TEST_ASSERT_EQUAL_STRING("US", r->name);
}
static void test_getRegion_returnsCorrectRegion_EU868()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, r->code);
TEST_ASSERT_EQUAL_STRING("EU_868", r->name);
}
static void test_getRegion_returnsCorrectRegion_LORA24()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, r->code);
TEST_ASSERT_TRUE(r->wideLora);
}
static void test_getRegion_unsetCodeReturnsUnsetEntry()
{
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
TEST_ASSERT_EQUAL_STRING("UNSET", r->name);
}
static void test_getRegion_unknownCodeFallsToUnset()
{
// A code not in the table should iterate to the UNSET sentinel
const RegionInfo *r = getRegion((meshtastic_Config_LoRaConfig_RegionCode)255);
TEST_ASSERT_NOT_NULL(r);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
}
// -----------------------------------------------------------------------
// validateConfigRegion() tests
// -----------------------------------------------------------------------
static void test_validateConfigRegion_validRegionReturnsTrue()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
// Ensure owner is not licensed (should not matter for non-licensed-only regions)
devicestate.owner.is_licensed = false;
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
}
static void test_validateConfigRegion_unsetRegionReturnsTrue()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
devicestate.owner.is_licensed = false;
// UNSET region has licensedOnly=false, so should pass
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
}
static void test_validateConfigRegion_unknownCodeReturnsFalse()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)255;
devicestate.owner.is_licensed = false;
// Unknown code is not in the regions table; getRegion() returns the UNSET sentinel,
// whose .code != 255, so validateConfigRegion should reject it.
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
}
static void test_validateConfigRegion_anotherUnknownCodeReturnsFalse()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)99;
devicestate.owner.is_licensed = true;
// Unknown code should be rejected even when owner is licensed.
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
}
// -----------------------------------------------------------------------
// Shadow tables for testing (preset lists → profiles → regions → lookup)
// -----------------------------------------------------------------------
// A minimal preset list with only one entry
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_SINGLE[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
MODEM_PRESET_END,
};
// A preset list that includes all turbo variants only
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_TURBO_ONLY[] = {
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
MODEM_PRESET_END,
};
// A restricted list simulating a hypothetical tight-regulation region
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_RESTRICTED[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE,
MODEM_PRESET_END,
};
// Mirrors PROFILE_STD but with non-zero spacing/padding for testing
static const RegionProfile TEST_PROFILE_SPACED = {
TEST_PRESETS_SINGLE,
/* spacing */ 0.025f,
/* padding */ 0.010f,
/* audioPermitted */ true,
/* licensedOnly */ false,
/* textThrottle */ 0,
/* positionThrottle */ 0,
/* telemetryThrottle */ 0,
};
// A licensed-only profile for testing access control
static const RegionProfile TEST_PROFILE_LICENSED = {
TEST_PRESETS_RESTRICTED,
/* spacing */ 0.0f,
/* padding */ 0.0f,
/* audioPermitted */ false,
/* licensedOnly */ true,
/* textThrottle */ 5,
/* positionThrottle */ 10,
/* telemetryThrottle */ 10,
};
// Turbo-only profile
static const RegionProfile TEST_PROFILE_TURBO = {
TEST_PRESETS_TURBO_ONLY,
/* spacing */ 0.0f,
/* padding */ 0.0f,
/* audioPermitted */ true,
/* licensedOnly */ false,
/* textThrottle */ 0,
/* positionThrottle */ 0,
/* telemetryThrottle */ 0,
};
// A preset list for the preset-hash override slot test (LONG_FAST + MEDIUM_FAST)
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_PRESET_HASH[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
MODEM_PRESET_END,
};
// Profile with overrideSlot = OVERRIDE_SLOT_PRESET_HASH (-1):
// slot selection always uses hash(presetDisplayName), ignoring the primary channel name.
static const RegionProfile TEST_PROFILE_PRESET_HASH = {
TEST_PRESETS_PRESET_HASH,
/* spacing */ 0.0f,
/* padding */ 0.0f,
/* audioPermitted */ true,
/* licensedOnly */ false,
/* textThrottle */ 0,
/* positionThrottle */ 0,
/* telemetryThrottle */ 0,
};
// Standalone test region using US frequencies (26 MHz span → 104 slots at 250 kHz BW)
// Used to verify OVERRIDE_SLOT_PRESET_HASH slot formula; not inserted into testRegions[].
static const RegionInfo TEST_REGION_PRESET_HASH = {
meshtastic_Config_LoRaConfig_RegionCode_US,
902.0f,
928.0f,
100,
30,
false,
false,
&TEST_PROFILE_PRESET_HASH,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
OVERRIDE_SLOT_PRESET_HASH,
"TEST_PRESET_HASH",
};
static const RegionInfo testRegions[] = {
// A wide US-like region with spacing + padding
{meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, "TEST_US_SPACED"},
// A narrow band simulating tight EU regulation
{meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 3, "TEST_EU_LICENSED"},
// A wide-LoRa region with turbo-only presets
{meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 0, "TEST_LORA24_TURBO"},
// Sentinel - must be last
{meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, "TEST_UNSET"},
};
static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code)
{
const RegionInfo *r = testRegions;
while (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
if (r->code == code)
return r;
r++;
}
return r; // Returns the UNSET sentinel
}
// -----------------------------------------------------------------------
// Shadow table tests
// -----------------------------------------------------------------------
// Helper: replicate the numFreqSlots formula from RadioInterface so tests can compute expected values.
static uint32_t testComputeNumFreqSlots(const RegionInfo *r, float bw_kHz)
{
float w = r->profile->spacing + (r->profile->padding * 2) + (bw_kHz / 1000.0f);
return (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / w) + 0.5f);
}
static void test_shadowTable_spacedProfileHasNonZeroSpacing()
{
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL_STRING("TEST_US_SPACED", r->name);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.025f, r->profile->spacing);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.010f, r->profile->padding);
}
static void test_shadowTable_licensedProfileFlagsCorrect()
{
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_TRUE(r->profile->licensedOnly);
TEST_ASSERT_FALSE(r->profile->audioPermitted);
TEST_ASSERT_EQUAL(3, r->overrideSlot);
}
static void test_shadowTable_presetCountMatchesExpected()
{
const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL(1, spaced->getNumPresets());
const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(2, licensed->getNumPresets());
const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_EQUAL(2, turbo->getNumPresets());
}
static void test_shadowTable_defaultPresetIsFirstInList()
{
const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, spaced->getDefaultPreset());
const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, licensed->getDefaultPreset());
const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, turbo->getDefaultPreset());
}
static void test_shadowTable_channelSpacingWithPadding()
{
// Verify channel count when spacing + padding are non-zero
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
float channelSpacing = r->profile->spacing + (r->profile->padding * 2) + (bw / 1000.0f);
// spacing=0.025, padding=0.010*2=0.020, bw=250kHz=0.250
// channelSpacing = 0.025 + 0.020 + 0.250 = 0.295 MHz
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.295f, channelSpacing);
uint32_t numChannels = (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / channelSpacing) + 0.5f);
// (928 - 902 + 0.025) / 0.295 = 88.2 → 88
TEST_ASSERT_EQUAL_UINT32(88, numChannels);
}
static void test_shadowTable_turboOnlyOnWideLora()
{
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_TRUE(r->wideLora);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, r->getDefaultPreset());
// Verify wide-LoRa bandwidth for SHORT_TURBO
float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
TEST_ASSERT_FLOAT_WITHIN(0.1f, 1625.0f, bw); // 1625 kHz in wide mode
}
static void test_shadowTable_unknownCodeFallsToSentinel()
{
const RegionInfo *r = getTestRegion((meshtastic_Config_LoRaConfig_RegionCode)200);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name);
}
static void test_shadowTable_presetHashProfileHasCorrectOverrideSlot()
{
TEST_ASSERT_EQUAL(OVERRIDE_SLOT_PRESET_HASH, TEST_REGION_PRESET_HASH.overrideSlot);
TEST_ASSERT_EQUAL(-1, TEST_REGION_PRESET_HASH.overrideSlot);
TEST_ASSERT_EQUAL(2, TEST_REGION_PRESET_HASH.getNumPresets());
}
// -----------------------------------------------------------------------
// OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests
//
// Property under test:
// overrideSlot = -1 → slot = hash(presetDisplayName) % numSlots
// regardless of what the primary channel is named
// overrideSlot = 0 → slot = hash(channelName) % numSlots
// when channel name = preset display name, these two modes give identical slots
// -----------------------------------------------------------------------
static void test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot()
{
// US + LONG_FAST: spacing=0, padding=0, bw=250 kHz
// numSlots = round((928-902+0)/0.250) = 104
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity
const char *presetName =
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true);
// OVERRIDE_SLOT_PRESET_HASH (-1):
// channel is "MyCustomNetwork" but slot still uses preset name hash
uint32_t slotPresetHashMode = hash(presetName) % numSlots;
// OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH (0) with channel name = preset name (user never renamed it):
// channelName == presetName → same hash → same slot
const char *defaultChannelName = presetName;
uint32_t slotChannelHashModeDefaultName = hash(defaultChannelName) % numSlots;
TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName);
// Confirm a different custom channel name gives a different hash INPUT
// (so mode 0 would diverge while mode -1 stays locked)
TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0);
}
static void test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot()
{
// US + MEDIUM_FAST: bw=250 kHz → same 104 slots as LONG_FAST for US
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora);
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity
const char *presetName =
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true);
// Mode -1: slot = hash(presetName) % numSlots (channel name irrelevant)
uint32_t slotPresetHashMode = hash(presetName) % numSlots;
// Mode 0 + default name (channel name = preset display name):
uint32_t slotChannelHashModeDefaultName = hash(presetName) % numSlots;
TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName);
TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0);
}
static void test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames()
{
// Mode -1 must give the same slot for LONG_FAST regardless of which custom name is in use.
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
const char *presetName =
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true);
uint32_t expectedSlot = hash(presetName) % numSlots;
// Simulate three different custom channel names; mode -1 ignores all of them
const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"};
for (int i = 0; i < 3; i++) {
uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only
TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom);
// Confirm input would have differed in mode 0
TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0);
}
}
static void test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames()
{
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora);
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
const char *presetName =
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true);
uint32_t expectedSlot = hash(presetName) % numSlots;
const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"};
for (int i = 0; i < 3; i++) {
uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only
TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom);
TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0);
}
}
static void test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets()
{
// LONG_FAST and MEDIUM_FAST have different display names → likely different hash slots.
// This verifies the two presets genuinely occupy distinct positions, so the equivalence
// tests above are not trivially vacuous.
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw_lf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false);
float bw_mf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false);
uint32_t numSlots_lf = testComputeNumFreqSlots(us, bw_lf);
uint32_t numSlots_mf = testComputeNumFreqSlots(us, bw_mf);
const char *nameLF =
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true);
const char *nameMF =
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true);
TEST_ASSERT_TRUE(strcmp(nameLF, nameMF) != 0);
uint32_t slotLF = hash(nameLF) % numSlots_lf;
uint32_t slotMF = hash(nameMF) % numSlots_mf;
// They use the same numSlots (both 250 kHz on US), so a difference in display name
// should produce a different slot.
TEST_ASSERT_NOT_EQUAL(slotLF, slotMF);
}
// -----------------------------------------------------------------------
// validateConfigLora() tests
// -----------------------------------------------------------------------
static void test_validateConfigLora_validPresetForUS()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_allStdPresetsValidForUS()
{
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_MEDIUM_TURBO,
};
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = stdPresets[i];
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for US");
}
}
static void test_validateConfigLora_turboPresetsInvalidForEU868()
{
// 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;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for EU_868");
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()
{
meshtastic_Config_LoRaConfig_ModemPreset eu868Presets[] = {
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,
};
for (size_t i = 0; i < sizeof(eu868Presets) / sizeof(eu868Presets[0]); i++) {
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = true;
cfg.modem_preset = eu868Presets[i];
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for EU_868");
}
}
static void test_validateConfigLora_customBandwidthTooWideForEU868()
{
// EU_868 spans 869.4 - 869.65 = 0.25 MHz = 250 kHz
// A 500 kHz custom BW should be rejected
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = false;
cfg.bandwidth = 500;
cfg.spread_factor = 11;
cfg.coding_rate = 5;
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_customBandwidthFitsUS()
{
// US spans 902 - 928 = 26 MHz, so 250 kHz BW fits easily
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = false;
cfg.bandwidth = 250;
cfg.spread_factor = 11;
cfg.coding_rate = 5;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_customBandwidthFitsEU868()
{
// EU_868 spans 250 kHz, 125 kHz BW should fit
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = false;
cfg.bandwidth = 125;
cfg.spread_factor = 12;
cfg.coding_rate = 8;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_bogusPresetRejected()
{
// A fabricated preset value not in any list should be rejected
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_unsetRegionAcceptsAnyRealPreset()
{
// UNSET is "no region chosen yet", not a regulatory domain, so it must not invalidate
// a preset the user already picked - whichever region that preset belongs to.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
cfg.use_preset = true;
const meshtastic_Config_LoRaConfig_ModemPreset realPresets[] = {
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO,
meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST, meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW,
meshtastic_Config_LoRaConfig_ModemPreset_TINY_FAST,
};
for (auto preset : realPresets) {
cfg.modem_preset = preset;
char msg[64];
snprintf(msg, sizeof(msg), "preset %d should be valid for UNSET", (int)preset);
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), msg);
}
// A value no region offers is still invalid, so the clamp can repair it.
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "bogus preset should be invalid for UNSET");
}
static void test_isKnownModemPreset_matchesRegionTable()
{
// Every preset some region offers is "known"...
TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO));
TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW));
TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW));
// ...and nothing else is, including the retired VERY_LONG_SLOW enum value.
TEST_ASSERT_FALSE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_VERY_LONG_SLOW));
TEST_ASSERT_FALSE(isKnownModemPreset((meshtastic_Config_LoRaConfig_ModemPreset)99));
}
static void test_validateConfigLora_allPresetsValidForLORA24()
{
// 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_MEDIUM_TURBO,
};
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.use_preset = true;
cfg.modem_preset = stdPresets[i];
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for LORA_24");
}
}
// -----------------------------------------------------------------------
// clampConfigLora() tests
// -----------------------------------------------------------------------
static void test_clampConfigLora_invalidPresetClampedToDefault()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; // not in EU_868 preset list
RadioInterface::clampConfigLora(cfg);
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), cfg.modem_preset);
}
static void test_clampConfigLora_validPresetUnchanged()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_customBwTooWideClampedToDefaultBw()
{
// EU_868 span is 250kHz. A 500kHz custom BW should be clamped to default preset BW.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = false;
cfg.bandwidth = 500;
cfg.spread_factor = 11;
cfg.coding_rate = 5;
RadioInterface::clampConfigLora(cfg);
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
float expectedBw = modemPresetToBwKHz(eu868->getDefaultPreset(), eu868->wideLora);
TEST_ASSERT_FLOAT_WITHIN(0.01f, expectedBw, (float)cfg.bandwidth);
}
static void test_clampConfigLora_customBwValidLeftUnchanged()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = false;
cfg.bandwidth = 125;
cfg.spread_factor = 12;
cfg.coding_rate = 8;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL_UINT16(125, cfg.bandwidth);
}
static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast()
{
// UNSET's default preset is LONG_FAST; a value no region offers clamps to it
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
cfg.use_preset = true;
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_unsetRegionKeepsRealPreset()
{
// The boot-time clamp (NodeDB::loadFromDisk) runs on every boot. While the region is
// unset it must leave a real preset alone rather than rewriting it to LONG_FAST.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, cfg.modem_preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, cfg.region);
}
static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault()
{
// LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD)
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.use_preset = true;
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
RadioInterface::clampConfigLora(cfg);
const RegionInfo *lora24 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset);
}
// -----------------------------------------------------------------------
// Region-locked preset swap tests (EU_868 / EU_866 / EU_N_868 trio)
// -----------------------------------------------------------------------
static void test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_litePresetOnEU868SwapsToEU866()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_866, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW, cfg.modem_preset);
}
static void test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_N_868;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_litePresetOnUSDoesNotSwap()
{
// Previous region is not one of the swappable trio, so the preset clamps to the
// region default instead of swapping regions.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
RadioInterface::clampConfigLora(cfg);
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, cfg.region);
TEST_ASSERT_EQUAL(us->getDefaultPreset(), cfg.modem_preset);
}
static void test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap()
{
// ITU2_125CM shares the NARROW presets, so they are valid there and nothing changes
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, cfg.modem_preset);
}
static void test_validateConfigLora_siblingLockedPresetStillFailsValidation()
{
// Validation (no clamp) must keep failing so callers route into clampConfigLora,
// which performs the region swap.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
// -----------------------------------------------------------------------
// RegionInfo preset list integrity tests
// -----------------------------------------------------------------------
static void test_presetsStd_hasTenEntries()
{
// 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(10, us->getNumPresets());
TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets());
}
static void test_presetsEU868_hasSevenEntries()
{
const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(7, eu->getNumPresets());
TEST_ASSERT_EQUAL_PTR(PROFILE_EU868.presets, eu->getAvailablePresets());
}
static void test_presetsUndef_hasOneEntry()
{
const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
TEST_ASSERT_EQUAL(1, unset->getNumPresets());
TEST_ASSERT_EQUAL_PTR(PROFILE_UNDEF.presets, unset->getAvailablePresets());
}
static void test_defaultPresetIsInAvailablePresets()
{
// For every region, the defaultPreset must appear in its own availablePresets list
const RegionInfo *r = regions;
while (true) {
bool found = false;
for (size_t i = 0; i < r->getNumPresets(); i++) {
if (r->getAvailablePresets()[i] == r->getDefaultPreset()) {
found = true;
break;
}
}
char msg[80];
snprintf(msg, sizeof(msg), "Region %s defaultPreset not in availablePresets", r->name);
TEST_ASSERT_TRUE_MESSAGE(found, msg);
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
break; // UNSET is the sentinel, stop after it
r++;
}
}
static void test_regionFieldsAreSane()
{
// Basic sanity check: all regions have freqEnd > freqStart and a non-null name
const RegionInfo *r = regions;
while (true) {
char msg[80];
snprintf(msg, sizeof(msg), "Region %s: freqEnd must be > freqStart", r->name);
TEST_ASSERT_TRUE_MESSAGE(r->freqEnd > r->freqStart, msg);
TEST_ASSERT_NOT_NULL(r->name);
TEST_ASSERT_TRUE_MESSAGE(r->getNumPresets() > 0, "numPresets must be > 0");
TEST_ASSERT_NOT_NULL(r->getAvailablePresets());
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
break;
r++;
}
}
static void test_onlyLORA24HasWideLora()
{
// Verify that LORA_24 is the only region with wideLora=true
const RegionInfo *r = regions;
while (true) {
char msg[80];
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
snprintf(msg, sizeof(msg), "Region %s should have wideLora=true", r->name);
TEST_ASSERT_TRUE_MESSAGE(r->wideLora, msg);
} else {
snprintf(msg, sizeof(msg), "Region %s should have wideLora=false", r->name);
TEST_ASSERT_FALSE_MESSAGE(r->wideLora, msg);
}
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
break;
r++;
}
}
// -----------------------------------------------------------------------
// Channel spacing calculation (placeholder for future protobuf updates)
// -----------------------------------------------------------------------
static void test_channelSpacingCalculation_US_LONG_FAST()
{
// Current formula: channelSpacing = spacing + (padding * 2) + (bw / 1000)
// US: spacing=0, padding=0
// LONG_FAST on non-wide region: bw=250 kHz
// channelSpacing = 0 + 0 + 0.250 = 0.250 MHz
// numChannels = round((928 - 902 + 0) / 0.250) = round(104) = 104
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
float channelSpacing = us->profile->spacing + (us->profile->padding * 2) + (bw / 1000.0f);
uint32_t numChannels = (uint32_t)(((us->freqEnd - us->freqStart + us->profile->spacing) / channelSpacing) + 0.5f);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
TEST_ASSERT_EQUAL_UINT32(104, numChannels);
}
static void test_channelSpacingCalculation_EU868_LONG_FAST()
{
// EU_868: freqStart=869.4, freqEnd=869.65, spacing=0, padding=0
// LONG_FAST: bw=250 kHz => channelSpacing = 0.250 MHz
// numChannels = round((0.25 + 0) / 0.250) = 1
const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, eu->wideLora);
float channelSpacing = eu->profile->spacing + (eu->profile->padding * 2) + (bw / 1000.0f);
uint32_t numChannels = (uint32_t)(((eu->freqEnd - eu->freqStart + eu->profile->spacing) / channelSpacing) + 0.5f);
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
TEST_ASSERT_EQUAL_UINT32(1, numChannels);
}
// Placeholder: when protobuf region definitions include non-zero padding/spacing,
// add tests here to verify the channel count and frequency calculations.
static void test_channelSpacingCalculation_placeholder()
{
// TODO: Once protobuf RegionInfo entries have non-zero padding or spacing values,
// verify:
// - Channel count matches expected value for each (region, preset) pair
// - First channel frequency = freqStart + (bw/2000) + padding
// - Nth channel frequency = first + (n * channelSpacing)
// - overrideSlot, when non-zero, forces the channel_num
TEST_PASS_MESSAGE("Placeholder for future channel spacing tests with updated protobuf region fields");
}
// -----------------------------------------------------------------------
// handleSetConfig fromOthers dispatch tests
// -----------------------------------------------------------------------
// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
static AdminModuleTestShim *testAdmin;
static NodeDB *savedNodeDB;
static NodeDB *replacementNodeDB;
static NodeInfoModule *savedNodeInfoModule;
static meshtastic_DeviceState savedDeviceState;
static meshtastic_User savedOwner;
static meshtastic_LocalConfig savedConfig;
static meshtastic_ChannelFile savedChannelFile;
// Only the ham dispatcher test installs a router (allocErrorResponse() allocates through it).
// Saved/torn down for every test so a failed assertion's longjmp cannot leave one dangling.
static Router *savedRouter;
static Router *hamMockRouter;
// Called from setUp/tearDown for every test, not opted into by a handful. A shared NodeDB plus
// unrestored config/owner/devicestate/channelFile means each test inherits whatever its
// predecessors left, and the admin handlers under test write all four.
static void replaceAdminRadioGlobals()
{
savedNodeDB = nodeDB;
savedNodeInfoModule = nodeInfoModule;
savedRouter = router;
savedDeviceState = devicestate;
savedOwner = owner;
savedConfig = config;
savedChannelFile = channelFile;
replacementNodeDB = new NodeDB();
nodeDB = replacementNodeDB;
}
// Defined with the crypto stub below; tearDown must undo an install even when a failed assertion
// longjmped out of the test body before it could.
static void dropRestoreCryptoStub();
static void restoreAdminRadioGlobals()
{
dropRestoreCryptoStub();
nodeInfoModule = savedNodeInfoModule;
nodeDB = savedNodeDB;
router = savedRouter;
delete hamMockRouter;
hamMockRouter = nullptr;
delete replacementNodeDB;
replacementNodeDB = nullptr;
devicestate = savedDeviceState;
owner = savedOwner;
config = savedConfig;
channelFile = savedChannelFile;
initRegion();
}
static void installEncryptedAndAdminChannels()
{
channels.initDefaults();
meshtastic_Channel admin = meshtastic_Channel_init_zero;
admin.index = 1;
admin.role = meshtastic_Channel_Role_SECONDARY;
admin.has_settings = true;
strncpy(admin.settings.name, Channels::adminChannel, sizeof(admin.settings.name));
admin.settings.psk.size = 16;
memset(admin.settings.psk.bytes, 0xA5, admin.settings.psk.size);
channels.setChannel(admin);
meshtastic_Channel secondary = meshtastic_Channel_init_zero;
secondary.index = 2;
secondary.role = meshtastic_Channel_Role_SECONDARY;
secondary.has_settings = true;
strncpy(secondary.settings.name, "private", sizeof(secondary.settings.name));
secondary.settings.psk.size = 32;
memset(secondary.settings.psk.bytes, 0x5A, secondary.settings.psk.size);
channels.setChannel(secondary);
}
static void assertLicensedChannelsSanitized()
{
TEST_ASSERT_EQUAL(0, channels.getByIndex(0).settings.psk.size);
TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(1).role);
TEST_ASSERT_EQUAL(0, channels.getByIndex(1).settings.psk.size);
TEST_ASSERT_EQUAL(0, channels.getByIndex(2).settings.psk.size);
}
static void test_handleSetOwner_persistsLicensedChannelSanitation()
{
owner = meshtastic_User_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
installEncryptedAndAdminChannels();
meshtastic_User licensed = meshtastic_User_init_zero;
licensed.is_licensed = true;
testAdmin->deferSaves();
nodeInfoModule = reinterpret_cast<NodeInfoModule *>(1); // reloadOwner(false) only checks presence
testAdmin->handleSetOwner(licensed);
TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS);
assertLicensedChannelsSanitized();
uint8_t encoded[meshtastic_ChannelFile_size];
const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ChannelFile_msg, &channelFile);
TEST_ASSERT_GREATER_THAN(0, encodedSize);
meshtastic_ChannelFile reloaded = meshtastic_ChannelFile_init_zero;
TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedSize, &meshtastic_ChannelFile_msg, &reloaded));
channelFile = reloaded;
assertLicensedChannelsSanitized();
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write");
}
// -----------------------------------------------------------------------
// handleSetHamMode() name assembly: the ham long_name rides behind the call
// sign with the "//" separator hams already use on the air.
// -----------------------------------------------------------------------
// Licensing a node touches channels, the NodeDB and the owner struct; an UNSET region keeps the
// keygen/identity-migration path out of these name-only assertions.
static void primeHamModeTest()
{
owner = meshtastic_User_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
channels.initDefaults();
nodeInfoModule = reinterpret_cast<NodeInfoModule *>(1); // reloadOwner(false) only checks presence
testAdmin->deferSaves();
}
static void test_handleSetHamMode_appendsLongNameToCallSign()
{
primeHamModeTest();
meshtastic_HamParameters p = meshtastic_HamParameters_init_zero;
strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1);
strncpy(p.short_name, "ABC", sizeof(p.short_name) - 1);
strncpy(p.long_name, "Attic Heltec", sizeof(p.long_name) - 1);
TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p));
TEST_ASSERT_EQUAL_STRING("KD2ABC//Attic Heltec", owner.long_name);
TEST_ASSERT_EQUAL_STRING("ABC", owner.short_name);
TEST_ASSERT_TRUE(owner.is_licensed);
}
// The widest pair the proto can carry (7 + 2 + 14) still has to arrive whole, or the operator
// silently loses the tail of the name they typed.
static void test_handleSetHamMode_widestPairSurvivesTheLongNameCap()
{
primeHamModeTest();
meshtastic_HamParameters p = meshtastic_HamParameters_init_zero;
strncpy(p.call_sign, "KD2ABCD", sizeof(p.call_sign) - 1);
strncpy(p.long_name, "Attic Heltec 3", sizeof(p.long_name) - 1);
TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p));
TEST_ASSERT_EQUAL_STRING("KD2ABCD//Attic Heltec 3", owner.long_name);
TEST_ASSERT_LESS_OR_EQUAL(MAX_LONG_NAME_BYTES, strlen(owner.long_name));
}
static void test_handleSetHamMode_omittedLongNameKeepsCallSignAlone()
{
primeHamModeTest();
meshtastic_HamParameters p = meshtastic_HamParameters_init_zero;
strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1);
testAdmin->handleSetHamMode(p);
TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name);
TEST_ASSERT_TRUE(owner.is_licensed);
}
// long_name is optional both ways a client can leave it empty: a whitespace-only one is dropped
// (no dangling "//" on the air) instead of costing the operator the whole licensing request.
static void test_handleSetHamMode_blankLongNameIsIgnoredNotRejected()
{
primeHamModeTest();
meshtastic_HamParameters p = meshtastic_HamParameters_init_zero;
strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1);
strncpy(p.long_name, " ", sizeof(p.long_name) - 1);
testAdmin->handleSetHamMode(p);
TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name);
TEST_ASSERT_TRUE(owner.is_licensed);
}
// The call sign is required, unlike the two optional name fields: an empty one would license a
// node that never identifies itself, and once a long_name is set it would compose to a dangling
// "//Attic Heltec".
static void test_handleSetHamMode_blankCallSignIsRejected()
{
primeHamModeTest();
meshtastic_HamParameters missing = meshtastic_HamParameters_init_zero;
strncpy(missing.long_name, "Attic Heltec", sizeof(missing.long_name) - 1);
TEST_ASSERT_FALSE(testAdmin->handleSetHamMode(missing));
TEST_ASSERT_EQUAL_STRING("", owner.long_name);
TEST_ASSERT_FALSE(owner.is_licensed);
primeHamModeTest();
meshtastic_HamParameters whitespace = meshtastic_HamParameters_init_zero;
strncpy(whitespace.call_sign, " ", sizeof(whitespace.call_sign) - 1);
TEST_ASSERT_FALSE(testAdmin->handleSetHamMode(whitespace));
TEST_ASSERT_EQUAL_STRING("", owner.long_name);
TEST_ASSERT_FALSE(owner.is_licensed);
}
// short_name is optional too, so a blank one keeps whatever the node was already called instead of
// blanking it - licensing the node must not cost the operator their existing short name.
static void test_handleSetHamMode_blankShortNameKeepsTheExistingOne()
{
for (const char *blank : {"", " "}) {
primeHamModeTest();
strncpy(owner.short_name, "OLD", sizeof(owner.short_name) - 1);
meshtastic_HamParameters p = meshtastic_HamParameters_init_zero;
strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1);
strncpy(p.short_name, blank, sizeof(p.short_name) - 1);
TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p));
TEST_ASSERT_EQUAL_STRING("OLD", owner.short_name);
TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name);
TEST_ASSERT_TRUE(owner.is_licensed);
}
}
// A rejection has to reach the client, not just the log: allocErrorResponse() builds the reply
// through the router, so this is the one ham test that needs one.
class HamModeMockRouter : public Router
{
public:
~HamModeMockRouter()
{
delete cryptLock; // the Router ctor asserts this is clear, so a later suite can construct one
cryptLock = nullptr;
}
ErrorCode send(meshtastic_MeshPacket *p) override
{
packetPool.release(p);
return ERRNO_OK;
}
};
// Pull the Routing error out of the ack/nak a handler queued in myReply.
static bool decodeRoutingError(meshtastic_MeshPacket *reply, meshtastic_Routing_Error &out)
{
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
return false;
if (reply->decoded.portnum != meshtastic_PortNum_ROUTING_APP)
return false;
meshtastic_Routing routing = meshtastic_Routing_init_zero;
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_Routing_msg, &routing))
return false;
if (routing.which_variant != meshtastic_Routing_error_reason_tag)
return false;
out = routing.error_reason;
return true;
}
// Handler-level rejection is invisible to a want_response client on its own: with no reply queued,
// handleReceivedProtobuf() falls through to its generic "ACK" and answers Routing_Error_NONE, so the
// app reports ham mode as enabled on a node that changed nothing. The dispatcher has to say
// BAD_REQUEST before that fallback runs.
static void test_handleSetHamMode_blankCallSignRepliesBadRequest()
{
primeHamModeTest();
hamMockRouter = new HamModeMockRouter();
router = hamMockRouter;
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_set_ham_mode_tag;
strncpy(m.set_ham_mode.long_name, "Attic Heltec", sizeof(m.set_ham_mode.long_name) - 1);
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.from = 0; // local client, so the passkey gate is bypassed and the switch body runs
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
mp.decoded.want_response = true;
testAdmin->handleReceivedProtobuf(mp, &m);
meshtastic_Routing_Error err = meshtastic_Routing_Error_NONE;
TEST_ASSERT_TRUE_MESSAGE(decodeRoutingError(testAdmin->reply(), err), "a rejected request must queue an error reply");
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_BAD_REQUEST, err);
TEST_ASSERT_FALSE(owner.is_licensed);
testAdmin->drainReply();
}
// The other half of the pair: an accepted request still answers Routing_Error_NONE. Asserting both
// sides is the point - NONE is what the rejection path used to borrow, so a test that only checked
// the reject case could pass against a handler that answered NONE to everything.
static void test_handleSetHamMode_acceptedRequestAcksSuccess()
{
primeHamModeTest();
hamMockRouter = new HamModeMockRouter();
router = hamMockRouter;
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_set_ham_mode_tag;
strncpy(m.set_ham_mode.call_sign, "KD2ABC", sizeof(m.set_ham_mode.call_sign) - 1);
strncpy(m.set_ham_mode.long_name, "Attic Heltec", sizeof(m.set_ham_mode.long_name) - 1);
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.from = 0;
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
mp.decoded.want_response = true;
testAdmin->handleReceivedProtobuf(mp, &m);
meshtastic_Routing_Error err = meshtastic_Routing_Error_BAD_REQUEST;
TEST_ASSERT_TRUE_MESSAGE(decodeRoutingError(testAdmin->reply(), err), "want_response must be answered");
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, err);
TEST_ASSERT_EQUAL_STRING("KD2ABC//Attic Heltec", owner.long_name);
TEST_ASSERT_TRUE(owner.is_licensed);
testAdmin->drainReply();
}
static void test_bootDefense_sanitizesStaleLicensedChannelsOnce()
{
owner = meshtastic_User_init_zero;
owner.is_licensed = true;
installEncryptedAndAdminChannels();
TEST_ASSERT_TRUE(channels.ensureLicensedOperation());
assertLicensedChannelsSanitized();
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "boot sanitation must be idempotent");
}
static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn()
{
NodeDB *savedNodeDB = nodeDB;
nodeDB = new NodeDB();
const meshtastic_DeviceState savedDeviceState = devicestate;
const meshtastic_ChannelFile savedChannelFile = channelFile;
owner = meshtastic_User_init_zero;
owner.is_licensed = true;
installEncryptedAndAdminChannels();
TEST_ASSERT_TRUE(nodeDB->backupPreferences(meshtastic_AdminMessage_BackupLocation_FLASH));
owner.is_licensed = false;
channels.initDefaults();
TEST_ASSERT_TRUE(
nodeDB->restorePreferences(meshtastic_AdminMessage_BackupLocation_FLASH, SEGMENT_DEVICESTATE | SEGMENT_CHANNELS));
TEST_ASSERT_TRUE(owner.is_licensed);
assertLicensedChannelsSanitized();
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "restored licensed channels must remain sanitized");
devicestate = savedDeviceState;
channelFile = savedChannelFile;
nodeDB->saveToDisk(SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
FSCom.remove(backupFileName);
delete nodeDB;
nodeDB = savedNodeDB;
}
static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
meshtastic_Config_LoRaConfig_ModemPreset preset)
{
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_lora_tag;
c.payload_variant.lora.region = region;
c.payload_variant.lora.use_preset = usePreset;
c.payload_variant.lora.modem_preset = preset;
return c;
}
static void test_handleSetConfig_persistsLicensedFirstRegionIdentity()
{
owner = meshtastic_User_init_zero;
owner.is_licensed = true;
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
initRegion();
testAdmin->deferSaves();
const meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
testAdmin->handleSetConfig(c, false);
const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments());
TEST_ASSERT_EQUAL(32, config.security.private_key.size);
TEST_ASSERT_EQUAL(32, owner.public_key.size);
}
// Unlicensed twin of the test above. Without the re-derivation the node signs broadcasts every receiver
// drops (verifyFirstContactNodeInfo: crc32(user.public_key) != from).
static void test_handleSetConfig_persistsUnlicensedFirstRegionIdentity()
{
owner = meshtastic_User_init_zero;
owner.is_licensed = false;
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
initRegion();
testAdmin->deferSaves();
const meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
testAdmin->handleSetConfig(c, false);
const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments());
TEST_ASSERT_EQUAL(32, config.security.private_key.size);
TEST_ASSERT_EQUAL(32, config.security.public_key.size);
TEST_ASSERT_EQUAL(32, owner.public_key.size);
// The invariant: a node's mesh address is derived from its identity key.
TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, config.security.public_key.size),
nodeDB->getNodeNum());
}
static void test_handleSetConfig_fromOthers_invalidPresetRejected()
{
// Set up a known-good baseline in the global config
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
// Build an admin set_config with an invalid preset for EU_868
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
testAdmin->handleSetConfig(c, true); // fromOthers = true
// fromOthers=true: invalid preset should be rejected, old preset preserved
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
}
static void test_handleSetConfig_fromLocal_invalidPresetClamped()
{
// Set up a known-good baseline
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
// Build an admin set_config with an invalid preset for EU_868
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
testAdmin->handleSetConfig(c, false); // fromOthers = false (local client)
// fromOthers=false: invalid preset should be clamped to the region's default
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), config.lora.modem_preset);
}
static void test_handleSetConfig_fromOthers_validPresetAccepted()
{
// Set up baseline
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
// Build an admin set_config with a valid preset for EU_868
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
testAdmin->handleSetConfig(c, true); // fromOthers = true
// Valid preset should be accepted regardless of fromOthers
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset);
}
static void test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected()
{
// Rejecting a remote config must reject ALL of it: an invalid channel_num must not
// leak into config.lora alongside the restored region/preset.
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;
config.lora.channel_num = 0;
initRegion();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
c.payload_variant.lora.channel_num = 5000; // far beyond US slot count
testAdmin->handleSetConfig(c, true); // fromOthers = true
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
TEST_ASSERT_EQUAL_UINT32(0, config.lora.channel_num);
}
// clampBandwidthCode: an unset (0) bandwidth code maps to the default; any other code is left as-is.
static void test_clampBandwidthCode_zeroMapsToDefaultOthersUnchanged()
{
TEST_ASSERT_NOT_EQUAL_UINT16(0, clampBandwidthCode(0)); // the point of the fix: 0 must not stay 0
TEST_ASSERT_EQUAL_UINT16(bwKHzToCode(LORA_BW_DEFAULT_KHZ), clampBandwidthCode(0));
TEST_ASSERT_EQUAL_UINT16(250, clampBandwidthCode(250));
TEST_ASSERT_EQUAL_UINT16(125, clampBandwidthCode(125));
TEST_ASSERT_EQUAL_UINT16(31, clampBandwidthCode(31));
}
// A custom (non-preset) config that leaves bandwidth at its proto zero-value must not persist as 0.
// Pre-fix it slipped past validateConfigLora() and the radio silently ran at the default while
// get_config still reported bandwidth 0. It is now coerced to the default code on ingest.
static void test_handleSetConfig_fromLocal_customBandwidthZeroClampedToDefault()
{
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();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, false, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
c.payload_variant.lora.spread_factor = 11;
c.payload_variant.lora.coding_rate = 5;
c.payload_variant.lora.bandwidth = 0; // the footgun: unset custom bandwidth
testAdmin->handleSetConfig(c, false); // fromOthers = false (local client)
TEST_ASSERT_FALSE(config.lora.use_preset);
TEST_ASSERT_NOT_EQUAL_UINT16(0, config.lora.bandwidth); // must not persist as 0
TEST_ASSERT_EQUAL_UINT16(bwKHzToCode(LORA_BW_DEFAULT_KHZ), config.lora.bandwidth);
}
// Remote admin (fromOthers) is subject to the same ingest clamp: a custom bandwidth 0 from another
// node is normalized to the default rather than persisted as 0 (it does not weaken the wholesale
// rejection of configs that actually fail validation - a 0 bandwidth already passed validation).
static void test_handleSetConfig_fromOthers_customBandwidthZeroClampedToDefault()
{
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();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, false, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
c.payload_variant.lora.spread_factor = 11;
c.payload_variant.lora.coding_rate = 5;
c.payload_variant.lora.bandwidth = 0;
testAdmin->handleSetConfig(c, true); // fromOthers = true
TEST_ASSERT_FALSE(config.lora.use_preset);
TEST_ASSERT_EQUAL_UINT16(bwKHzToCode(LORA_BW_DEFAULT_KHZ), config.lora.bandwidth);
}
// In preset mode bandwidth 0 is the norm (the preset supplies it); the ingest clamp must leave it
// untouched so preset configs still read back bandwidth 0.
static void test_handleSetConfig_fromLocal_presetBandwidthZeroLeftUntouched()
{
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();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
c.payload_variant.lora.bandwidth = 0;
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_TRUE(config.lora.use_preset);
TEST_ASSERT_EQUAL_UINT16(0, config.lora.bandwidth);
}
// A custom (non-preset) config with an already-valid bandwidth must be preserved verbatim.
static void test_handleSetConfig_fromLocal_customBandwidthNonZeroPreserved()
{
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();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, false, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
c.payload_variant.lora.spread_factor = 11;
c.payload_variant.lora.coding_rate = 5;
c.payload_variant.lora.bandwidth = 125;
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_FALSE(config.lora.use_preset);
TEST_ASSERT_EQUAL_UINT16(125, config.lora.bandwidth);
}
// A security-config SET that omits the private key (partial/legacy client editing some other security field)
// must NOT regenerate our keypair: our NodeNum is crc32(public_key), so a new keypair would silently change
// our identity. The existing keypair has to be preserved.
static void test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 32;
memset(config.security.public_key.bytes, 0x22, 32);
// Incoming SET carries no private/public key, just another security field.
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.serial_enabled = true;
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
uint8_t expectedPriv[32];
memset(expectedPriv, 0x11, 32);
uint8_t expectedPub[32];
memset(expectedPub, 0x22, 32);
TEST_ASSERT_EQUAL_UINT(32, config.security.private_key.size);
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size);
TEST_ASSERT_EQUAL_MEMORY(expectedPub, config.security.public_key.bytes, 32);
// The non-key field still applies.
TEST_ASSERT_TRUE(config.security.serial_enabled);
}
// A SET that DOES supply a full 32-byte keypair (legitimate key import) must apply it, not preserve the old one.
static void test_handleSetConfig_security_acceptsSuppliedKeypair()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 32;
memset(config.security.public_key.bytes, 0x22, 32);
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x33, 32);
c.payload_variant.security.public_key.size = 32;
memset(c.payload_variant.security.public_key.bytes, 0x44, 32);
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
uint8_t expectedPriv[32];
memset(expectedPriv, 0x33, 32);
uint8_t expectedPub[32];
memset(expectedPub, 0x44, 32);
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
TEST_ASSERT_EQUAL_MEMORY(expectedPub, config.security.public_key.bytes, 32);
}
// Issue #11073: "regenerate keys" sends a blank SecurityConfig holding only the new private key. Replacing
// the whole struct with it wiped the admin keys, locking the owner out of remote admin.
static void test_handleSetConfig_security_rotationPreservesAdminKeys()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 32;
memset(config.security.public_key.bytes, 0x22, 32);
config.security.admin_key_count = 2;
config.security.admin_key[0].size = 32;
memset(config.security.admin_key[0].bytes, 0xAA, 32);
config.security.admin_key[1].size = 32;
memset(config.security.admin_key[1].bytes, 0xBB, 32);
config.security.is_managed = true;
config.security.serial_enabled = true;
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
// Exactly what the regenerate dialog emits.
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x33, 32);
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
uint8_t expectedPriv[32];
memset(expectedPriv, 0x33, 32);
TEST_ASSERT_EQUAL_UINT(32, config.security.private_key.size);
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
uint8_t expectedAdmin0[32], expectedAdmin1[32];
memset(expectedAdmin0, 0xAA, 32);
memset(expectedAdmin1, 0xBB, 32);
TEST_ASSERT_EQUAL_UINT(2, config.security.admin_key_count);
TEST_ASSERT_EQUAL_UINT(32, config.security.admin_key[0].size);
TEST_ASSERT_EQUAL_MEMORY(expectedAdmin0, config.security.admin_key[0].bytes, 32);
TEST_ASSERT_EQUAL_UINT(32, config.security.admin_key[1].size);
TEST_ASSERT_EQUAL_MEMORY(expectedAdmin1, config.security.admin_key[1].bytes, 32);
TEST_ASSERT_TRUE(config.security.is_managed);
TEST_ASSERT_TRUE(config.security.serial_enabled);
TEST_ASSERT_EQUAL(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT,
config.security.packet_signature_policy);
}
// The escape hatch: a SET that leaves the private key alone still clears admin keys.
static void test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 32;
memset(config.security.public_key.bytes, 0x22, 32);
config.security.admin_key_count = 1;
config.security.admin_key[0].size = 32;
memset(config.security.admin_key[0].bytes, 0xAA, 32);
// Same private key we already hold, empty admin key list.
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x11, 32);
c.payload_variant.security.public_key.size = 32;
memset(c.payload_variant.security.public_key.bytes, 0x22, 32);
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key_count);
TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key[0].size);
}
// No low-entropy private key is published, so stand in for the engine to derive a blacklisted public
// key on demand. hash() is left real: the blacklist lookup runs through it.
static const uint8_t COMPROMISED_PUBLIC_KEY[32] = {0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1,
0xe9, 0xfc, 0x37, 0x23, 0x29, 0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26,
0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24};
class RestoreDerivingCryptoEngine : public CryptoEngine
{
public:
bool regenerateSucceeds = true;
bool derivesLowEntropy = true;
bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) override
{
if (!regenerateSucceeds)
return false;
if (derivesLowEntropy)
memcpy(pubKey, COMPROMISED_PUBLIC_KEY, 32);
else
memset(pubKey, 0x7C, 32);
return true;
}
bool mintsLowEntropy = false;
void generateKeyPair(uint8_t *pubKey, uint8_t *privKey) override
{
if (mintsLowEntropy)
memcpy(pubKey, COMPROMISED_PUBLIC_KEY, 32);
else
memset(pubKey, 0x5E, 32);
memset(privKey, 0x5F, 32);
}
};
static CryptoEngine *savedCrypto;
static RestoreDerivingCryptoEngine *restoreCrypto;
// Installed here and torn down in restoreAdminRadioGlobals(), not at the end of the test body: a failed
// TEST_ASSERT longjmps straight out, which would leave later tests running against a freed stub.
static RestoreDerivingCryptoEngine *installRestoreCrypto()
{
savedCrypto = crypto;
restoreCrypto = new RestoreDerivingCryptoEngine();
crypto = restoreCrypto;
return restoreCrypto;
}
static void dropRestoreCryptoStub()
{
if (!restoreCrypto)
return;
crypto = savedCrypto;
delete restoreCrypto;
restoreCrypto = nullptr;
}
// Arms a bare private-key restore: region set so keygen runs, private key present, public key absent.
static meshtastic_Config makeBareKeyRestoreConfig()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x11, 32);
return c;
}
static bool capturedWarningsContain(const char *needle)
{
for (const std::string &w : capturedWarnings)
if (w.find(needle) != std::string::npos)
return true;
return false;
}
// A restored private key deriving a blacklisted public key is rejected and rotated at set time, and
// the client is told why - not left to discover it after the next reboot.
static void test_handleSetConfig_security_lowEntropyRestoreWarnsAndRotates()
{
installRestoreCrypto();
const meshtastic_Config c = makeBareKeyRestoreConfig();
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_TRUE(nodeDB->keyIsLowEntropy);
TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size);
TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0);
TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key));
TEST_ASSERT_TRUE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING));
}
// A restore carrying a whole blacklisted pair must not skip validation just because it populated the
// public key too - that path reaches neither keygen branch, so the weak identity used to be kept.
static void test_handleSetConfig_security_lowEntropyFullKeypairRestoreIsRejected()
{
installRestoreCrypto();
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x11, 32);
c.payload_variant.security.public_key.size = 32;
memcpy(c.payload_variant.security.public_key.bytes, COMPROMISED_PUBLIC_KEY, 32);
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size);
TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0);
TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key));
TEST_ASSERT_TRUE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING));
}
// A blacklisted public key whose private key derives a clean one is only re-derived - the user's key
// does stick, so the "a new secure key was generated" warning would be a lie here.
static void test_handleSetConfig_security_reDerivedCleanKeyDoesNotWarn()
{
installRestoreCrypto()->derivesLowEntropy = false;
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x11, 32);
c.payload_variant.security.public_key.size = 32;
memcpy(c.payload_variant.security.public_key.bytes, COMPROMISED_PUBLIC_KEY, 32);
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
// The supplied private key survives, and the blacklisted public key is replaced by its derivation.
uint8_t expectedPriv[32];
memset(expectedPriv, 0x11, 32);
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key));
TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0);
TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING));
}
// A replacement that is itself blacklisted leaves no identity behind - persisting a known-weak key
// would defeat the rejection this whole path exists for.
static void test_handleSetConfig_security_blacklistedMintLeavesNoKey()
{
installRestoreCrypto()->mintsLowEntropy = true;
const meshtastic_Config c = makeBareKeyRestoreConfig();
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size);
TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size);
TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING));
}
// factory_reset_config keeps the private key and clears the public one, so the entry check sees no key
// and the boot-time derive path used to adopt whatever it produced - including a known-weak key.
static void test_generateCryptoKeyPair_derivedFromStoredPrivateIsChecked()
{
installRestoreCrypto();
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 0; // as installDefaultConfig(preserveKey = true) leaves it
TEST_ASSERT_TRUE(nodeDB->generateCryptoKeyPair());
TEST_ASSERT_TRUE(nodeDB->keyIsLowEntropy);
TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0);
TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key));
}
// Same clear-and-fail on the boot path: a stored private key that derives nothing must not leave both
// sizes at 32, claiming a pair the node never got.
static void test_generateCryptoKeyPair_failedDerivationFromStoredPrivateClearsKeySizes()
{
installRestoreCrypto()->regenerateSucceeds = false;
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
config.security.private_key.size = 32;
memset(config.security.private_key.bytes, 0x11, 32);
config.security.public_key.size = 0;
TEST_ASSERT_FALSE(nodeDB->generateCryptoKeyPair());
TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size);
TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size);
}
// keyIsLowEntropy survives from a boot-time regeneration, and generateCryptoKeyPair returns early on
// an unset region without clearing it. The restore warning must stay gated on this keygen running.
static void test_handleSetConfig_security_staleLowEntropyFlagDoesNotWarn()
{
config.security = meshtastic_Config_SecurityConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
initRegion();
nodeDB->keyIsLowEntropy = true;
meshtastic_Config c = meshtastic_Config_init_zero;
c.which_payload_variant = meshtastic_Config_security_tag;
c.payload_variant.security.private_key.size = 32;
memset(c.payload_variant.security.private_key.bytes, 0x11, 32);
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING));
}
// A private key that derives nothing usable must not leave sizes claiming a 32-byte pair behind:
// that state gets persisted, and every later keygen re-derives from the same dead key.
static void test_handleSetConfig_security_failedDerivationClearsKeySizes()
{
installRestoreCrypto()->regenerateSucceeds = false;
const meshtastic_Config c = makeBareKeyRestoreConfig();
testAdmin->deferSaves();
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size);
TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size);
TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING));
}
static void test_regionInfo_supportsPreset()
{
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_TRUE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO));
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST));
const RegionInfo *eu866 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
TEST_ASSERT_TRUE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW));
TEST_ASSERT_FALSE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
// UNSET enforces nothing (the radio is silent regardless), so it supports every real
// preset - not just the LONG_FAST its own profile advertises as the default.
const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO));
TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST));
TEST_ASSERT_FALSE(unset->supportsPreset((meshtastic_Config_LoRaConfig_ModemPreset)99));
}
static void test_checkConfigRegion_quietCheckReportsReason()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg));
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)254;
char err[160] = {0};
TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg, err, sizeof(err)));
TEST_ASSERT_TRUE_MESSAGE(strlen(err) > 0, "Expected a failure reason in errBuf");
}
static void test_checkConfigRegion_allowsProspectiveLicensedOwner()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M;
devicestate.owner.is_licensed = false;
TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg));
TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg, nullptr, 0, true));
}
static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion()
{
// Baseline: EU_866 (LITE profile)
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
initRegion();
// Remote admin keeps the region but selects a NARROW preset (locked to EU_N_868)
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_866, true,
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST);
testAdmin->handleSetConfig(c, true); // fromOthers = true
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, config.lora.modem_preset);
// Restore the region table pointer for subsequent tests
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
initRegion();
}
static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected()
{
// Baseline: US is not one of the swappable trio, so a LITE preset must be rejected
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();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST);
testAdmin->handleSetConfig(c, true); // fromOthers = true
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
}
static void test_handleSetConfig_presetChosenBeforeRegionSurvives()
{
// A fresh device: the user picks a preset in the app before choosing a region. The
// unset region must not clamp that choice back to LONG_FAST.
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_UNSET, true,
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset);
}
static void test_handleSetConfig_unsettingRegionKeepsPreset()
{
// Clearing the region is a valid request in its own right. It must take effect (and
// disable tx) without discarding the config because the preset outlives the region.
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_SHORT_TURBO;
config.lora.tx_enabled = true;
initRegion();
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_UNSET, true,
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
c.payload_variant.lora.tx_enabled = true;
testAdmin->handleSetConfig(c, false);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, config.lora.modem_preset);
TEST_ASSERT_FALSE_MESSAGE(config.lora.tx_enabled, "unsetting the region must disable tx");
// Restore the region table pointer for subsequent tests
initRegion();
}
// -----------------------------------------------------------------------
// 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);
}
// An admin message that changes nothing. It answers, so drain the reply or the packet pool leaks.
static void sendGetDeviceMetadata()
{
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_request_tag;
m.get_device_metadata_request = true;
sendAdmin(m);
testAdmin->drainReply();
}
// 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"));
}
// An idle transaction is retired by the next admin message, flushing the warnings it held.
static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage()
{
usePresetLongFast();
sendBeginEdit();
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
// Deferred, exactly as before: nothing emitted while the transaction looks alive.
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
testAdmin->ageEditTransaction();
sendGetDeviceMetadata(); // any later admin message, from any client
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
}
// A write arriving after abandonment is saved, not deferred to a commit that never comes.
static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred()
{
usePresetLongFast();
sendBeginEdit();
testAdmin->ageEditTransaction();
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
// The write itself retired the stale transaction, so its own warning is emitted immediately.
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
}
// A transaction still in use is left alone: each write refreshes the window.
static void test_editTransaction_active_isNotRetired()
{
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));
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
sendCommitEdit();
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1"));
}
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());
}
// -----------------------------------------------------------------------
// Node-DB admin metadata: favorite / ignore / mute
// -----------------------------------------------------------------------
//
// MeshService::reloadConfig() only re-derives the region and fires configChanged - which drives the
// live SX126x/RadioInterface reconfigure - when saveWhat includes SEGMENT_CONFIG or
// SEGMENT_CHANNELS. A pure node-DB metadata save must skip that reconfigure entirely. These watch
// service->configChanged directly, so widening the saveWhat mask or reordering the check is caught
// even though they run outside an edit transaction.
//
// Characterization: all three already hold on develop. They are worth pinning because that reload
// is the path implicated in the WisMesh Tag favourite-node crash, and nothing asserted it.
// Counts configChanged.notifyObservers() calls - the only externally visible signal that
// reloadConfig() took the radio-reconfigure branch.
class ConfigChangedCounter : public Observer<void *>
{
public:
int count = 0;
protected:
int onNotify(void *arg) override
{
count++;
return 0;
}
};
static const NodeNum TEST_NODE_NUM = 0x12345678;
static void test_setFavoriteNode_skipsRadioReload_butPersists()
{
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
ConfigChangedCounter counter;
counter.observe(&service->configChanged);
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_set_favorite_node_tag;
m.set_favorite_node = TEST_NODE_NUM;
sendAdmin(m);
TEST_ASSERT_EQUAL_INT(0, counter.count);
TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(nodeDB->getMeshNode(TEST_NODE_NUM)));
}
static void test_setIgnoredNode_skipsRadioReload_butPersists()
{
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
ConfigChangedCounter counter;
counter.observe(&service->configChanged);
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_set_ignored_node_tag;
m.set_ignored_node = TEST_NODE_NUM;
sendAdmin(m);
TEST_ASSERT_EQUAL_INT(0, counter.count);
TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(nodeDB->getMeshNode(TEST_NODE_NUM)));
}
static void test_toggleMutedNode_skipsRadioReload_butPersists()
{
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
ConfigChangedCounter counter;
counter.observe(&service->configChanged);
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_toggle_muted_node_tag;
m.toggle_muted_node = TEST_NODE_NUM;
sendAdmin(m);
TEST_ASSERT_EQUAL_INT(0, counter.count);
TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
}
// -----------------------------------------------------------------------
// Node menu mute toggle (graphics::menuHandler::toggleNodeMuted)
// -----------------------------------------------------------------------
//
// Reachable only since the mute branch was lifted out of its banner-callback lambda; the lambda
// runs via screen->showOverlayBanner(), so nothing in MenuHandler.cpp was testable before.
#if HAS_SCREEN
static void test_toggleNodeMuted_flipsBitAndSkipsRadioReload()
{
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
ConfigChangedCounter counter;
counter.observe(&service->configChanged);
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
TEST_ASSERT_EQUAL_INT(0, counter.count);
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
TEST_ASSERT_FALSE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
TEST_ASSERT_EQUAL_INT(0, counter.count);
}
static void test_toggleNodeMuted_unknownNodeDoesNothing()
{
ConfigChangedCounter counter;
counter.observe(&service->configChanged);
graphics::menuHandler::toggleNodeMuted(0xDEADBEEF); // never added to the DB
TEST_ASSERT_EQUAL_INT(0, counter.count);
TEST_ASSERT_NULL(nodeDB->getMeshNode(0xDEADBEEF));
}
// CHARACTERIZATION OF A KNOWN DEFECT, not an endorsement. Flipping one NodeInfoLite bit currently
// calls bare nodeDB->saveToDisk(), which rewrites all five segments. saveToDisk() is not virtual,
// so the mask is observed through its effect: every prefs file reappears after being removed.
//
// A pending fix narrows this to SEGMENT_NODEDATABASE. When it lands, only nodes.proto should come
// back and this assertion is EXPECTED to change - that diff is the point, so the improvement is
// visible instead of silent.
static void test_toggleNodeMuted_currentlyRewritesEverySegment()
{
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
const char *segmentFiles[] = {configFileName, moduleConfigFileName, deviceStateFileName, channelFileName,
nodeDatabaseFileName};
for (const char *f : segmentFiles)
FSCom.remove(f);
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
for (const char *f : segmentFiles)
TEST_ASSERT_TRUE_MESSAGE(FSCom.exists(f), f);
}
// -----------------------------------------------------------------------
// BaseUI region chooser preset default (graphics::menuHandler::presetForRegionSelection)
// -----------------------------------------------------------------------
//
// Out-of-box US setup starts on LongTurbo. Each guard below is load-bearing: widening the rule past
// "first region ever chosen, US, no preset on record" re-presets nodes that already have an opinion.
// `region` is the region still in place when the user highlights `selected`.
static meshtastic_Config_LoRaConfig loraAt(meshtastic_Config_LoRaConfig_RegionCode region,
meshtastic_Config_LoRaConfig_ModemPreset preset, bool usePreset = true)
{
meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_default;
lora.region = region;
lora.modem_preset = preset;
lora.use_preset = usePreset;
return lora;
}
#ifndef USERPREFS_LORACONFIG_MODEM_PRESET
static void test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo()
{
const meshtastic_Config_LoRaConfig lora =
loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US));
// Unusable unless US offers it: applyLoraRegion()'s reconciliation would throw it straight back.
TEST_ASSERT_TRUE_MESSAGE(getRegion(meshtastic_Config_LoRaConfig_RegionCode_US)
->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO),
"US no longer supports LongTurbo");
}
#else
// A pinned preset owns the decision outright.
static void test_presetForRegionSelection_pinnedUserprefWins()
{
const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET;
const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, pinned);
TEST_ASSERT_EQUAL(pinned, graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US));
}
#endif
// US on a node that already has a region is a region change, not first-time setup.
static void test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset()
{
const meshtastic_Config_LoRaConfig lora =
loraAt(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US));
}
// The default is US-only; no other region's first selection is touched.
static void test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset()
{
const meshtastic_Config_LoRaConfig lora =
loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
for (auto region : {meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_RegionCode_ANZ,
meshtastic_Config_LoRaConfig_RegionCode_JP})
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
graphics::menuHandler::presetForRegionSelection(lora, region));
}
// A preset off the install default is a preference on record (phone app, admin, preset menu).
static void test_presetForRegionSelection_respectsAPresetAlreadyChosen()
{
const meshtastic_Config_LoRaConfig lora =
loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US));
}
// use_preset false means raw bandwidth/SF/CR: rewriting modem_preset only misleads the preset menu.
static void test_presetForRegionSelection_ignoresNodesOnRawModemSettings()
{
const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET,
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, /*usePreset=*/false);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US));
}
#endif // HAS_SCREEN
// -----------------------------------------------------------------------
// Test runner
// -----------------------------------------------------------------------
void setUp(void)
{
mockMeshService = new MockMeshService();
service = mockMeshService;
testAdmin = new AdminModuleTestShim();
capturedWarnings.clear();
// Every test gets its own NodeDB and its own copy of the globals the admin handlers write.
replaceAdminRadioGlobals();
}
void tearDown(void)
{
restoreAdminRadioGlobals();
service = nullptr;
delete mockMeshService;
mockMeshService = nullptr;
delete testAdmin;
testAdmin = nullptr;
}
void setup()
{
delay(10);
delay(2000);
initializeTestEnvironment();
UNITY_BEGIN();
// getRegion()
RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation);
RUN_TEST(test_handleSetHamMode_appendsLongNameToCallSign);
RUN_TEST(test_handleSetHamMode_widestPairSurvivesTheLongNameCap);
RUN_TEST(test_handleSetHamMode_omittedLongNameKeepsCallSignAlone);
RUN_TEST(test_handleSetHamMode_blankLongNameIsIgnoredNotRejected);
RUN_TEST(test_handleSetHamMode_blankShortNameKeepsTheExistingOne);
RUN_TEST(test_handleSetHamMode_blankCallSignIsRejected);
RUN_TEST(test_handleSetHamMode_blankCallSignRepliesBadRequest);
RUN_TEST(test_handleSetHamMode_acceptedRequestAcksSuccess);
RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity);
RUN_TEST(test_handleSetConfig_persistsUnlicensedFirstRegionIdentity);
RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce);
RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn);
RUN_TEST(test_getRegion_returnsCorrectRegion_US);
RUN_TEST(test_getRegion_returnsCorrectRegion_EU868);
RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24);
RUN_TEST(test_getRegion_unsetCodeReturnsUnsetEntry);
RUN_TEST(test_getRegion_unknownCodeFallsToUnset);
// validateConfigRegion()
RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue);
RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue);
RUN_TEST(test_validateConfigRegion_unknownCodeReturnsFalse);
RUN_TEST(test_validateConfigRegion_anotherUnknownCodeReturnsFalse);
// Shadow table tests
RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing);
RUN_TEST(test_shadowTable_licensedProfileFlagsCorrect);
RUN_TEST(test_shadowTable_presetCountMatchesExpected);
RUN_TEST(test_shadowTable_defaultPresetIsFirstInList);
RUN_TEST(test_shadowTable_channelSpacingWithPadding);
RUN_TEST(test_shadowTable_turboOnlyOnWideLora);
RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel);
RUN_TEST(test_shadowTable_presetHashProfileHasCorrectOverrideSlot);
// validateConfigLora()
RUN_TEST(test_validateConfigLora_validPresetForUS);
RUN_TEST(test_validateConfigLora_allStdPresetsValidForUS);
RUN_TEST(test_validateConfigLora_turboPresetsInvalidForEU868);
RUN_TEST(test_validateConfigLora_validPresetsForEU868);
RUN_TEST(test_validateConfigLora_customBandwidthTooWideForEU868);
RUN_TEST(test_validateConfigLora_customBandwidthFitsUS);
RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868);
RUN_TEST(test_validateConfigLora_bogusPresetRejected);
RUN_TEST(test_validateConfigLora_unsetRegionAcceptsAnyRealPreset);
RUN_TEST(test_isKnownModemPreset_matchesRegionTable);
RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24);
// clampConfigLora()
RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault);
RUN_TEST(test_clampConfigLora_validPresetUnchanged);
RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw);
RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged);
RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast);
RUN_TEST(test_clampConfigLora_unsetRegionKeepsRealPreset);
RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault);
// Region-locked preset swap
RUN_TEST(test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868);
RUN_TEST(test_clampConfigLora_litePresetOnEU868SwapsToEU866);
RUN_TEST(test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868);
RUN_TEST(test_clampConfigLora_litePresetOnUSDoesNotSwap);
RUN_TEST(test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap);
RUN_TEST(test_validateConfigLora_siblingLockedPresetStillFailsValidation);
// RegionInfo preset list integrity
RUN_TEST(test_presetsStd_hasTenEntries);
RUN_TEST(test_presetsEU868_hasSevenEntries);
RUN_TEST(test_presetsUndef_hasOneEntry);
RUN_TEST(test_defaultPresetIsInAvailablePresets);
RUN_TEST(test_regionFieldsAreSane);
RUN_TEST(test_onlyLORA24HasWideLora);
// OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests
RUN_TEST(test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot);
RUN_TEST(test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot);
RUN_TEST(test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames);
RUN_TEST(test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames);
RUN_TEST(test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets);
// Channel spacing (current + placeholder)
RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST);
RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST);
RUN_TEST(test_channelSpacingCalculation_placeholder);
// handleSetConfig fromOthers dispatch
RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected);
RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped);
RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted);
RUN_TEST(test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected);
RUN_TEST(test_clampBandwidthCode_zeroMapsToDefaultOthersUnchanged);
RUN_TEST(test_handleSetConfig_fromLocal_customBandwidthZeroClampedToDefault);
RUN_TEST(test_handleSetConfig_fromOthers_customBandwidthZeroClampedToDefault);
RUN_TEST(test_handleSetConfig_fromLocal_presetBandwidthZeroLeftUntouched);
RUN_TEST(test_handleSetConfig_fromLocal_customBandwidthNonZeroPreserved);
RUN_TEST(test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted);
RUN_TEST(test_handleSetConfig_security_acceptsSuppliedKeypair);
RUN_TEST(test_handleSetConfig_security_rotationPreservesAdminKeys);
RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged);
RUN_TEST(test_handleSetConfig_security_lowEntropyRestoreWarnsAndRotates);
RUN_TEST(test_handleSetConfig_security_lowEntropyFullKeypairRestoreIsRejected);
RUN_TEST(test_handleSetConfig_security_reDerivedCleanKeyDoesNotWarn);
RUN_TEST(test_handleSetConfig_security_blacklistedMintLeavesNoKey);
RUN_TEST(test_generateCryptoKeyPair_derivedFromStoredPrivateIsChecked);
RUN_TEST(test_generateCryptoKeyPair_failedDerivationFromStoredPrivateClearsKeySizes);
RUN_TEST(test_handleSetConfig_security_staleLowEntropyFlagDoesNotWarn);
RUN_TEST(test_handleSetConfig_security_failedDerivationClearsKeySizes);
RUN_TEST(test_regionInfo_supportsPreset);
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner);
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
RUN_TEST(test_handleSetConfig_presetChosenBeforeRegionSurvives);
RUN_TEST(test_handleSetConfig_unsettingRegionKeepsPreset);
// 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_editTransaction_abandoned_isRetiredOnNextAdminMessage);
RUN_TEST(test_editTransaction_abandoned_laterWriteIsNoLongerDeferred);
RUN_TEST(test_editTransaction_active_isNotRetired);
RUN_TEST(test_warn_license_noTransaction_emittedImmediately);
RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage);
// Node-DB metadata saves must not reconfigure the radio
RUN_TEST(test_setFavoriteNode_skipsRadioReload_butPersists);
RUN_TEST(test_setIgnoredNode_skipsRadioReload_butPersists);
RUN_TEST(test_toggleMutedNode_skipsRadioReload_butPersists);
#if HAS_SCREEN
// Node menu mute toggle
RUN_TEST(test_toggleNodeMuted_flipsBitAndSkipsRadioReload);
RUN_TEST(test_toggleNodeMuted_unknownNodeDoesNothing);
RUN_TEST(test_toggleNodeMuted_currentlyRewritesEverySegment);
// BaseUI region chooser preset default
#ifndef USERPREFS_LORACONFIG_MODEM_PRESET
RUN_TEST(test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo);
#else
RUN_TEST(test_presetForRegionSelection_pinnedUserprefWins);
#endif
RUN_TEST(test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset);
RUN_TEST(test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset);
RUN_TEST(test_presetForRegionSelection_respectsAPresetAlreadyChosen);
RUN_TEST(test_presetForRegionSelection_ignoresNodesOnRawModemSettings);
#endif
exit(UNITY_END());
}
void loop() {}