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:
co-authored by
GitHub
Ben Meadors
Claude
parent
7a25ffd0a2
commit
5513c36757
+271
-8
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user