/** * 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 "mesh/Channels.h" #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include #include #include #include #include #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 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_unsetRegionOnlyAcceptsLongFast() { // UNSET uses PROFILE_UNDEF which has only 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_LONG_FAST; TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET"); cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET"); cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET"); } 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 uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp 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_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 bool adminRadioGlobalsActive; 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; static void replaceAdminRadioGlobals() { savedNodeDB = nodeDB; savedNodeInfoModule = nodeInfoModule; savedDeviceState = devicestate; savedOwner = owner; savedConfig = config; savedChannelFile = channelFile; replacementNodeDB = new NodeDB(); nodeDB = replacementNodeDB; adminRadioGlobalsActive = true; } static void restoreAdminRadioGlobals() { if (!adminRadioGlobalsActive) return; nodeInfoModule = savedNodeInfoModule; nodeDB = savedNodeDB; delete replacementNodeDB; replacementNodeDB = nullptr; devicestate = savedDeviceState; owner = savedOwner; config = savedConfig; channelFile = savedChannelFile; initRegion(); adminRadioGlobalsActive = false; } 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() { replaceAdminRadioGlobals(); 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(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"); } 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() { replaceAdminRadioGlobals(); 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); } 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); } 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)); } 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); } // ----------------------------------------------------------------------- // 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()); } // ----------------------------------------------------------------------- // Test runner // ----------------------------------------------------------------------- void setUp(void) { mockMeshService = new MockMeshService(); service = mockMeshService; testAdmin = new AdminModuleTestShim(); capturedWarnings.clear(); // Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB. // Create it once (kept reachable via the global, so no leak) for the warning tests; the // other tests in this suite set their own config/region state and are unaffected. if (!nodeDB) nodeDB = new NodeDB(); } void tearDown(void) { 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_handleSetConfig_persistsLicensedFirstRegionIdentity); 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_unsetRegionOnlyAcceptsLongFast); 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_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_regionInfo_supportsPreset); RUN_TEST(test_checkConfigRegion_quietCheckReportsReason); RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner); RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); // Channel-configuration warning + coalescing RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage); RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll); RUN_TEST(test_warn_cleanChannel_noMessage); RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage); RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage); RUN_TEST(test_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); exit(UNITY_END()); } void loop() {}