diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7c7e1290738..a96da8e5d85 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,5 +1,6 @@ #include "AdminModule.h" #include "Channels.h" +#include "DisplayFormatters.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" @@ -461,6 +462,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Commit transaction for edited settings"); hasOpenEditTransaction = false; saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE); + flushChannelWarnings(); // one coalesced message for everything edited in this transaction break; } case meshtastic_AdminMessage_get_device_connection_status_request_tag: { @@ -755,7 +757,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) changed = 1; owner.is_licensed = o.is_licensed; if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } } if (owner.has_is_unmessagable != o.has_is_unmessagable || @@ -777,6 +779,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) auto existingRole = config.device.role; bool isRegionUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET); bool requiresReboot = true; + bool loraPresetWarnPending = false; + meshtastic_Config_LoRaConfig pendingOldLora = {}, pendingNewLora = {}; switch (c.which_payload_variant) { case meshtastic_Config_device_tag: { @@ -1030,6 +1034,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) #endif config.lora = validatedLora; // Finally, return the validated config back to the main config + if (validatedLora.modem_preset != oldLoraConfig.modem_preset) { + pendingOldLora = oldLoraConfig; + pendingNewLora = validatedLora; + loraPresetWarnPending = true; + } break; } @@ -1082,6 +1091,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } // end of switch case which_payload_variant saveChanges(changes, requiresReboot); + if (loraPresetWarnPending) + warnOnLoraPresetChange(pendingOldLora, pendingNewLora); + // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. + if (!hasOpenEditTransaction) + flushChannelWarnings(); } // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) @@ -1287,7 +1301,7 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc) { channels.setChannel(cc); if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } // Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey() // resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex; @@ -1310,6 +1324,10 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc) if (clamped) sendWarning(publicChannelPrecisionMessage); saveChanges(SEGMENT_CHANNELS, false); + warnOnChannelSet(channels.getByIndex(cc.index)); // passes the saved channel + // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. + if (!hasOpenEditTransaction) + flushChannelWarnings(); } /** @@ -1725,7 +1743,7 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) // Remove PSK of primary channel for plaintext amateur usage if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } channels.onConfigChanged(); @@ -1863,14 +1881,263 @@ void AdminModule::sendWarningAndLog(const char *format, ...) vsnprintf(buf, sizeof(buf), format, args); va_end(args); - LOG_WARN(buf); - // 2. Call sendWarning - // SECURITY NOTE: We pass "%s", buf instead of just 'buf'. - // If 'buf' contained a % symbol (e.g. "Battery 50%"), passing it directly - // would crash sendWarning. "%s" treats it purely as text. + // SECURITY NOTE: Both LOG_WARN and sendWarning are printf-style, so we pass + // "%s", buf rather than 'buf' directly. If 'buf' contained a % symbol (e.g. a + // user-supplied channel name like "50%"), passing it as the format string would + // read bogus varargs and could crash. "%s" treats it purely as text. + LOG_WARN("%s", buf); sendWarning("%s", buf); } +// Strip spaces and fold to lowercase for loose preset-name comparison. +static void normalizePresetName(const char *src, char *dst, size_t dstLen) +{ + size_t j = 0; + for (size_t i = 0; src[i] && j + 1 < dstLen; i++) { + if (src[i] != ' ') + dst[j++] = (char)tolower((unsigned char)src[i]); + } + dst[j] = '\0'; +} + +// Record one channel-configuration warning. The first message is kept verbatim in case it +// turns out to be the only one; nameIssue/pskIssue and the channel bitmask feed the catch-all +// wording if more than one channel ends up flagged. flushChannelWarnings() emits the result. +void AdminModule::queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...) +{ + if (pendingWarningCount == 0) { + va_list args; + va_start(args, format); + vsnprintf(pendingWarningText, sizeof(pendingWarningText), format, args); + va_end(args); + } + if (channelIndex < MAX_NUM_CHANNELS) + pendingWarningChannels |= (1u << channelIndex); + pendingWarningNameIssue |= nameIssue; + pendingWarningPskIssue |= pskIssue; + pendingWarningCount++; +} + +// Queue the fixed "licensed mode activated" notice, deferring it to commit during an edit +// transaction so repeated triggers collapse to a single message. +void AdminModule::warnLicensedMode() +{ + if (hasOpenEditTransaction) + pendingLicenseWarning = true; + else + sendWarning(licensedModeMessage); +} + +// Emit the coalesced channel warning(s): nothing if none queued, the lone message verbatim if +// exactly one, otherwise a single catch-all naming every flagged channel. The licensed-mode +// notice, if queued, is emitted once alongside. Always resets state. +void AdminModule::flushChannelWarnings() +{ + if (pendingLicenseWarning) + sendWarning(licensedModeMessage); + + if (pendingWarningCount == 1) { + sendWarningAndLog("%s", pendingWarningText); + } else if (pendingWarningCount > 1) { + char list[48] = {}; + for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) { + if (!(pendingWarningChannels & (1u << i))) + continue; + char num[8]; + snprintf(num, sizeof(num), "%s%u", *list ? ", " : "", i); + strncat(list, num, sizeof(list) - strlen(list) - 1); + } + if (pendingWarningNameIssue && pendingWarningPskIssue) + sendWarningAndLog("There may be name and PSK issues on channels %s", list); // max 60 bytes + else if (pendingWarningNameIssue) + sendWarningAndLog("There may be name issues on channels %s", list); // max 52 bytes + else + sendWarningAndLog("There may be PSK issues on channels %s", list); // max 51 bytes + } + pendingWarningText[0] = '\0'; + pendingWarningChannels = 0; + pendingWarningCount = 0; + pendingWarningNameIssue = false; + pendingWarningPskIssue = false; + pendingLicenseWarning = false; +} + +/** + * @brief Emit client warnings for common misconfigurations on a newly committed channel. + * + * Called from handleSetChannel() after the channel has been saved. The following checks + * are performed: + * + * - Blank PSK (size == 0) on a non-licensed device: the channel has no encryption. + * - Blank name with a non-default key (not AQ== / 0x01): the name will auto-resolve to + * the current modem preset name, but the key mismatch means other preset nodes cannot + * decode traffic on this channel. + * - Named channel whose name is a case/space variant of the current modem preset: the + * explicit name prevents auto-resolution; client should clear it. + * - Same variant match but PSK is not the default key: looks like the preset channel but + * is incompatible with nodes using the preset's default key. + * + * @param cc The channel that was written. + */ +void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) +{ + if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings) // don't check unused channels + return; + + bool blankPsk = (cc.settings.psk.size == 0 && !owner.is_licensed); + + if (!config.lora.use_preset) { // custom or unset preset can mistype things too + const char *mistypePreset = nullptr; + if (*cc.settings.name) { + char normChan[32]; + normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); + for (auto preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; + preset <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; + preset = (meshtastic_Config_LoRaConfig_ModemPreset)(preset + 1)) { + const char *name = DisplayFormatters::getModemPresetDisplayName(preset, false, true); + if (strcmp(name, "Invalid") == 0) + continue; // skip preset slots without a real display name + if (strcmp(cc.settings.name, name) == 0) + break; // exact match - not a mistype, no warning + char normPreset[32]; + normalizePresetName(name, normPreset, sizeof(normPreset)); + if (strcmp(normChan, normPreset) == 0) { + mistypePreset = name; + break; + } + } + } + // At most one warning: collapse blank-PSK + mistype into a single catch-all. + if (blankPsk && mistypePreset) + queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index); + else if (mistypePreset) + queueChannelWarning(cc.index, true, false, + "Channel %d name '%s' looks like a mistype of '%s' - " + "make sure to type it exactly!", // max 90 bytes + cc.index, cc.settings.name, mistypePreset); + else if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); + return; + } + + const char *presetName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true); + bool isDefaultKey = (cc.settings.psk.size == 1 && cc.settings.psk.bytes[0] == 0x01); + char normPreset[32], normChan[32]; // max size is 11 plus nul, but allow for future expansion + normalizePresetName(presetName, normPreset, sizeof(normPreset)); + normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); + + if (!*cc.settings.name) { + // Blank name resolves to the preset name - A and B are mutually exclusive (psk.size can't be both 0 and >0) + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); + else if (!isDefaultKey && cc.settings.psk.size > 0) + queueChannelWarning(cc.index, false, true, + "Channel %d will resolve to preset '%s' but uses a non-default key - " + "default-key nodes can't decode it.", // max 102 bytes + cc.index, presetName); + return; + } + + if (strcmp(normChan, normPreset) != 0) { // name unrelated to preset + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); + return; + } + + bool variantName = (strcmp(cc.settings.name, presetName) != 0); + bool keyMismatch = (!isDefaultKey && cc.settings.psk.size > 0); + int issues = (int)blankPsk + (int)variantName + (int)keyMismatch; + + if (issues > 1) { + bool hasNameIssue = variantName; + bool hasPskIssue = blankPsk || keyMismatch; + if (hasNameIssue && hasPskIssue) + queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index); + else if (hasNameIssue) + queueChannelWarning(cc.index, true, false, "There may be name issues on channel %d", cc.index); + else + queueChannelWarning(cc.index, false, true, "There may be PSK issues on channel %d", cc.index); + return; + } + + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); + if (variantName) + queueChannelWarning(cc.index, true, false, + "Channel %d name '%s' looks like a mistype of '%s' - " + "clear the name to use the preset name automatically.", // max 113 bytes + cc.index, cc.settings.name, presetName); + if (keyMismatch) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' matches preset '%s' but uses a non-default key - " + "default-key nodes can't decode it.", // max 108 bytes + cc.index, cc.settings.name, presetName); +} // warnOnChannelSet + +/** + * @brief Scan all channels for preset-name conflicts after a modem preset change is committed. + * + * Called from handleSetConfig() after the LoRa config has been saved, and only when + * modem_preset actually changed (rejected configs are never passed here). For every + * named, non-disabled channel two checks are performed: + * + * - Name matches the *old* preset (case-insensitive, spaces stripped): the channel + * was likely tracking the previous preset; the user should rename it if it should + * follow the new one. + * - Name matches the *new* preset: the channel name collides with the auto-generated + * preset name but won't resolve automatically because the name is set explicitly. + * + * No-ops if the new config does not use a preset. + * + * @param oldLora LoRa config before the update. + * @param newLora LoRa config after the update. + */ +void AdminModule::warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora) +{ + if (!newLora.use_preset || newLora.modem_preset == oldLora.modem_preset) + return; + + char normOld[32] = {}, normNew[32]; + if (oldLora.use_preset) { + const char *oldName = DisplayFormatters::getModemPresetDisplayName(oldLora.modem_preset, false, true); + normalizePresetName(oldName, normOld, sizeof(normOld)); + } + const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, true); + normalizePresetName(newName, normNew, sizeof(normNew)); + + // Queue one (name) warning per affected channel; flushChannelWarnings() collapses them + // into a single message - either the lone warning verbatim or a catch-all listing indices. + for (int i = 0; i < channels.getNumChannels(); i++) { + const meshtastic_Channel &ch = channels.getByIndex(i); + if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.has_settings || !*ch.settings.name) + continue; + char normChan[32]; + normalizePresetName(ch.settings.name, normChan, sizeof(normChan)); + if (*normOld && strcmp(normChan, normOld) == 0) + queueChannelWarning(i, true, false, + "Channel %d name '%s' matches the old preset. " + "Rename it manually if it should track the new preset.", // max 98 bytes + i, ch.settings.name); + else if (strcmp(normChan, normNew) == 0) + queueChannelWarning(i, true, false, + "Channel %d '%s' looks like preset '%s' but won't auto-resolve - " + "clear the name to fix it.", // max 98 bytes + i, ch.settings.name, newName); + } +} // warnOnLoraPresetChange + void disableBluetooth() { #if HAS_BLUETOOTH diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index f123a137bbf..bd9c4a32445 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -89,6 +89,27 @@ class AdminModule : public ProtobufModule, public Obser bool messageIsRequest(const meshtastic_AdminMessage *r); void sendWarning(const char *format, ...) __attribute__((format(printf, 2, 3))); void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3))); + void warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora); + void warnOnChannelSet(const meshtastic_Channel &cc); + + // Channel-configuration warnings are coalesced into a single client notification. + // queueChannelWarning() records one warning for a channel; while an edit transaction + // is open (begin/commit_edit_settings) the warnings accumulate and flushChannelWarnings() + // emits one combined message at commit. Outside a transaction the caller flushes + // immediately, so a single channel/preset edit still produces a single message. + void queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...) + __attribute__((format(printf, 5, 6))); + // Emit the "licensed mode activated" notice, deferring to commit during an edit transaction + // so repeated triggers (e.g. owner + several channels) produce a single message. + void warnLicensedMode(); + void flushChannelWarnings(); + + char pendingWarningText[250] = {}; // the lone queued message, used verbatim when only one fired + uint32_t pendingWarningChannels = 0; // bitmask of channel indices with a queued warning + uint8_t pendingWarningCount = 0; // number of queued warnings this transaction + bool pendingWarningNameIssue = false; // any queued warning was about a channel name + bool pendingWarningPskIssue = false; // any queued warning was about a PSK + bool pendingLicenseWarning = false; // a licensed-mode notice is queued for this transaction }; static constexpr const char *licensedModeMessage = diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 886694caf08..7d92652b89b 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -17,18 +17,30 @@ #include "NodeDB.h" #include "RadioInterface.h" #include "TestUtil.h" +#include "mesh/Channels.h" #include "modules/AdminModule.h" +#include #include +#include #include "meshtastic/config.pb.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. +static std::vector capturedWarnings; + class MockMeshService : public MeshService { public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } + void sendClientNotification(meshtastic_ClientNotification *n) override + { + capturedWarnings.push_back(n->message); + releaseClientNotificationToPool(n); + } }; static MockMeshService *mockMeshService; @@ -932,6 +944,7 @@ static void test_channelSpacingCalculation_placeholder() class AdminModuleTestShim : public AdminModule { public: + using AdminModule::handleReceivedProtobuf; // drive full incoming admin messages (begin/commit/set_channel) using AdminModule::handleSetConfig; // Defer persistence so handleSetConfig() exercises the in-RAM config logic without saveToDisk() touching // the (uninitialized) node database in this lightweight harness. @@ -1154,6 +1167,167 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); } +// ----------------------------------------------------------------------- +// Channel-configuration warning + coalescing tests +// +// These exercise the real incoming-admin-message path (handleReceivedProtobuf): +// begin_edit_settings / set_channel / commit_edit_settings. Warnings raised while a +// transaction is open must be deferred and collapsed into a single notification at +// commit; outside a transaction each save emits its own single message immediately. +// ----------------------------------------------------------------------- + +static const uint8_t DEFAULT_KEY[] = {0x01}; // the well-known "default" PSK (AQ==) +static const uint8_t CUSTOM_KEY[] = {0x42, 0x17}; // any non-default key + +// Count captured warnings whose text contains substr. +static int warningsContaining(const char *substr) +{ + int n = 0; + for (const auto &w : capturedWarnings) + if (w.find(substr) != std::string::npos) + n++; + return n; +} + +static meshtastic_Channel makeChannel(int8_t index, meshtastic_Channel_Role role, const char *name, const uint8_t *psk, + size_t pskLen) +{ + meshtastic_Channel ch = meshtastic_Channel_init_zero; + ch.index = index; + ch.role = role; + ch.has_settings = true; + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + ch.settings.psk.size = pskLen; + for (size_t i = 0; i < pskLen; i++) + ch.settings.psk.bytes[i] = psk[i]; + return ch; +} + +// Dispatch one admin message as if it arrived from a local (from==0) client, which bypasses +// the passkey/authorization gates so the switch body runs. +static void sendAdmin(meshtastic_AdminMessage &m) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; // required: handler drops non-decoded packets + testAdmin->handleReceivedProtobuf(mp, &m); +} + +static void sendSetChannel(const meshtastic_Channel &ch) +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_channel_tag; + m.set_channel = ch; + sendAdmin(m); +} + +static void sendBeginEdit() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_begin_edit_settings_tag; + m.begin_edit_settings = true; + sendAdmin(m); +} + +static void sendCommitEdit() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_commit_edit_settings_tag; + m.commit_edit_settings = true; + sendAdmin(m); +} + +// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against. +static void usePresetLongFast() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + owner.is_licensed = false; +} + +static void test_warn_singleChannel_variantName_oneSpecificMessage() +{ + usePresetLongFast(); + // Name is a case/space variant of the preset with the default key: a single name issue. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll() +{ + usePresetLongFast(); + // Variant name AND a non-default key: two issues on one channel collapse to one catch-all. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0")); +} + +static void test_warn_cleanChannel_noMessage() +{ + usePresetLongFast(); + // Exact preset name + default key: nothing to warn about. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); +} + +static void test_warn_transaction_multipleChannels_singleCoalescedMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + // Nothing emitted yet - warnings are deferred until commit. + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // Exactly one message, naming both channels. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); +} + +static void test_warn_transaction_singleChannel_keepsSpecificMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // One flagged channel: the specific message verbatim, not the plural catch-all. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels")); +} + +static void test_warn_license_noTransaction_emittedImmediately() +{ + usePresetLongFast(); + owner.is_licensed = true; + // Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); +} + +static void test_warn_license_transaction_coalescedToSingleMessage() +{ + usePresetLongFast(); + owner.is_licensed = true; + sendBeginEdit(); + // Two separate triggers within one transaction (two channels with keys to strip). + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // Collapsed to a single licensed-mode notice (and no channel warning, since names are blank). + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); +} + // ----------------------------------------------------------------------- // Test runner // ----------------------------------------------------------------------- @@ -1163,6 +1337,12 @@ void setUp(void) mockMeshService = new MockMeshService(); service = mockMeshService; testAdmin = new AdminModuleTestShim(); + capturedWarnings.clear(); + // Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB. + // Create it once (kept reachable via the global, so no leak) for the warning tests; the + // other tests in this suite set their own config/region state and are unaffected. + if (!nodeDB) + nodeDB = new NodeDB(); } void tearDown(void) { @@ -1265,6 +1445,15 @@ void setup() RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); + // Channel-configuration warning + coalescing + RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage); + RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll); + RUN_TEST(test_warn_cleanChannel_noMessage); + RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage); + RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage); + RUN_TEST(test_warn_license_noTransaction_emittedImmediately); + RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage); + exit(UNITY_END()); }