Add helpful checks of channel names and PSK (#10792)

* added warnings from firmware for simple channel setting mistakes

* more and better checks

* one ping only

* Drop literal 'AQ==' from blank-PSK channel warnings

The base64 encoding of the default channel key isn't something a user
should type in by hand; state the condition instead of a raw key value.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Tom
2026-07-12 07:43:28 -05:00
committed by GitHub
co-authored by GitHub Ben Meadors Claude
parent 7a25ffd0a2
commit 5513c36757
3 changed files with 487 additions and 9 deletions
+271 -8
View File
@@ -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: {
@@ -1041,6 +1045,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;
}
@@ -1093,6 +1102,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)
@@ -1298,7 +1312,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;
@@ -1321,6 +1335,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();
}
/**
@@ -1736,7 +1754,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();
@@ -1874,14 +1892,259 @@ 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 the default key, 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) instead of default key", // 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) instead of default key", // 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) instead of default key", // 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) instead of default key", // 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
+21
View File
@@ -89,6 +89,27 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, 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 =
+195 -1
View File
@@ -17,16 +17,34 @@
#include "NodeDB.h"
#include "RadioInterface.h"
#include "TestUtil.h"
#include "mesh/Channels.h"
#include "modules/AdminModule.h"
#include <string>
#include <unity.h>
#include <vector>
#include "meshtastic/config.pb.h"
#include "support/AdminModuleTestShim.h"
#include "support/MockMeshService.h"
// hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests
extern uint32_t hash(const char *str);
// Every client notification the AdminModule emits flows through sendClientNotification();
// capture each formatted message so the warning/coalescing tests can assert on the exact
// set of messages produced by a sequence of admin messages. This shadows test/support/MockMeshService.h's
// release-only stub because these tests need to inspect the captured message text, not just avoid leaks.
static std::vector<std::string> capturedWarnings;
class MockMeshService : public MeshService
{
public:
void sendClientNotification(meshtastic_ClientNotification *n) override
{
capturedWarnings.push_back(n->message);
releaseClientNotificationToPool(n);
}
};
static MockMeshService *mockMeshService;
// -----------------------------------------------------------------------
@@ -1244,6 +1262,167 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
}
// -----------------------------------------------------------------------
// Channel-configuration warning + coalescing tests
//
// These exercise the real incoming-admin-message path (handleReceivedProtobuf):
// begin_edit_settings / set_channel / commit_edit_settings. Warnings raised while a
// transaction is open must be deferred and collapsed into a single notification at
// commit; outside a transaction each save emits its own single message immediately.
// -----------------------------------------------------------------------
static const uint8_t DEFAULT_KEY[] = {0x01}; // the well-known "default" PSK (AQ==)
static const uint8_t CUSTOM_KEY[] = {0x42, 0x17}; // any non-default key
// Count captured warnings whose text contains substr.
static int warningsContaining(const char *substr)
{
int n = 0;
for (const auto &w : capturedWarnings)
if (w.find(substr) != std::string::npos)
n++;
return n;
}
static meshtastic_Channel makeChannel(int8_t index, meshtastic_Channel_Role role, const char *name, const uint8_t *psk,
size_t pskLen)
{
meshtastic_Channel ch = meshtastic_Channel_init_zero;
ch.index = index;
ch.role = role;
ch.has_settings = true;
strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1);
ch.settings.psk.size = pskLen;
for (size_t i = 0; i < pskLen; i++)
ch.settings.psk.bytes[i] = psk[i];
return ch;
}
// Dispatch one admin message as if it arrived from a local (from==0) client, which bypasses
// the passkey/authorization gates so the switch body runs.
static void sendAdmin(meshtastic_AdminMessage &m)
{
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.from = 0;
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; // required: handler drops non-decoded packets
testAdmin->handleReceivedProtobuf(mp, &m);
}
static void sendSetChannel(const meshtastic_Channel &ch)
{
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_set_channel_tag;
m.set_channel = ch;
sendAdmin(m);
}
static void sendBeginEdit()
{
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_begin_edit_settings_tag;
m.begin_edit_settings = true;
sendAdmin(m);
}
static void sendCommitEdit()
{
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
m.which_payload_variant = meshtastic_AdminMessage_commit_edit_settings_tag;
m.commit_edit_settings = true;
sendAdmin(m);
}
// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against.
static void usePresetLongFast()
{
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
owner.is_licensed = false;
}
static void test_warn_singleChannel_variantName_oneSpecificMessage()
{
usePresetLongFast();
// Name is a case/space variant of the preset with the default key: a single name issue.
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
}
static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll()
{
usePresetLongFast();
// Variant name AND a non-default key: two issues on one channel collapse to one catch-all.
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2));
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0"));
}
static void test_warn_cleanChannel_noMessage()
{
usePresetLongFast();
// Exact preset name + default key: nothing to warn about.
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1));
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
}
static void test_warn_transaction_multipleChannels_singleCoalescedMessage()
{
usePresetLongFast();
sendBeginEdit();
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1));
// Nothing emitted yet - warnings are deferred until commit.
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
sendCommitEdit();
// Exactly one message, naming both channels.
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1"));
}
static void test_warn_transaction_singleChannel_keepsSpecificMessage()
{
usePresetLongFast();
sendBeginEdit();
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
sendCommitEdit();
// One flagged channel: the specific message verbatim, not the plural catch-all.
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels"));
}
static void test_warn_license_noTransaction_emittedImmediately()
{
usePresetLongFast();
owner.is_licensed = true;
// Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it.
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2));
TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated"));
}
static void test_warn_license_transaction_coalescedToSingleMessage()
{
usePresetLongFast();
owner.is_licensed = true;
sendBeginEdit();
// Two separate triggers within one transaction (two channels with keys to strip).
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2));
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2));
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
sendCommitEdit();
// Collapsed to a single licensed-mode notice (and no channel warning, since names are blank).
TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated"));
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
}
// -----------------------------------------------------------------------
// Test runner
// -----------------------------------------------------------------------
@@ -1253,6 +1432,12 @@ void setUp(void)
mockMeshService = new MockMeshService();
service = mockMeshService;
testAdmin = new AdminModuleTestShim();
capturedWarnings.clear();
// Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB.
// Create it once (kept reachable via the global, so no leak) for the warning tests; the
// other tests in this suite set their own config/region state and are unaffected.
if (!nodeDB)
nodeDB = new NodeDB();
}
void tearDown(void)
{
@@ -1360,6 +1545,15 @@ void setup()
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
// Channel-configuration warning + coalescing
RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage);
RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll);
RUN_TEST(test_warn_cleanChannel_noMessage);
RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage);
RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage);
RUN_TEST(test_warn_license_noTransaction_emittedImmediately);
RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage);
exit(UNITY_END());
}