/** * Unit tests for MeshBeaconModule: * - AdminModule::handleSetModuleConfig validation (invalid/valid inputs) * - MeshBeaconBroadcastModule payload cache lifecycle * - MeshBeaconBroadcastModule::sendBeacon sends a correctly formed packet * - MeshBeaconListenerModule offer caching and empty-message guard */ #include "TestUtil.h" #include #if defined(ARCH_PORTDUINO) #define BEACON_TEST_ENTRY extern "C" #else #define BEACON_TEST_ENTRY #endif #if !MESHTASTIC_EXCLUDE_BEACON #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" #include "RadioInterface.h" #include "airtime.h" #include "modules/AdminModule.h" #include "modules/MeshBeaconModule.h" #include "support/AdminModuleTestShim.h" #include "support/MockMeshService.h" #include #include #include #include #include // --------------------------------------------------------------------------- // Formatted diagnostic helper. TEST_MESSAGE emits a line into Unity's per-test // output (shown inline alongside the :PASS/:FAIL result); use this when you need // a printf-style formatted note tied to a specific assertion. Plain printf() also // works for free-standing log lines (e.g. the group headers in setup() below). // --------------------------------------------------------------------------- #define MSG_BUF_LEN 256 #define TEST_MSG_FMT(fmt, ...) \ do { \ char _buf[MSG_BUF_LEN]; \ snprintf(_buf, sizeof(_buf), fmt, ##__VA_ARGS__); \ TEST_MESSAGE(_buf); \ } while (0) namespace { constexpr NodeNum kLocalNode = 0xAAAA0001; constexpr NodeNum kRemoteNode = 0xBBBB0002; // MockMeshService (test/support) stubs the side-effecting virtuals; handleToRadio is non-virtual so // it runs the real implementation - the router->sendLocal path is guarded by MockRouter below. // --------------------------------------------------------------------------- // MockRouter: captures every packet handed to send() instead of transmitting. // --------------------------------------------------------------------------- class MockRouter : public Router { public: ~MockRouter() { delete cryptLock; cryptLock = nullptr; } ErrorCode send(meshtastic_MeshPacket *p) override { // Capture the primary channel as seen AT send() time. sendBeaconPacket() temporarily swaps // the beacon channel into the primary slot around this call so perhapsEncode keys off it, // so this snapshot reflects which channel the packet would actually be encrypted on. if (channelFile.channels_count > 0) primaryAtSend.push_back(channels.getByIndex(channels.getPrimaryIndex()).settings); sentPackets.push_back(*p); packetPool.release(p); return ERRNO_OK; } // Locally-addressed packets land here instead of send(). Release immediately // rather than queuing into fromRadioQueue (which is never drained in tests). void enqueueReceivedMessage(meshtastic_MeshPacket *p) override { packetPool.release(p); } std::vector sentPackets; std::vector primaryAtSend; }; // AdminModuleTestShim (test/support) exposes protected handleSetModuleConfig. // --------------------------------------------------------------------------- // MeshBeaconBroadcastModuleTestShim - exposes private internals for testing. // --------------------------------------------------------------------------- class MeshBeaconBroadcastModuleTestShim : public MeshBeaconBroadcastModule { public: using MeshBeaconBroadcastModule::payloadCache; using MeshBeaconBroadcastModule::payloadCacheDirty; using MeshBeaconBroadcastModule::payloadCacheSize; using MeshBeaconBroadcastModule::rebuildCache; using MeshBeaconBroadcastModule::runOnce; using MeshBeaconBroadcastModule::sendBeacon; }; // --------------------------------------------------------------------------- // MeshBeaconListenerModuleTestShim - exposes handleReceivedProtobuf. // --------------------------------------------------------------------------- class MeshBeaconListenerModuleTestShim : public MeshBeaconListenerModule { public: using MeshBeaconListenerModule::handleReceivedProtobuf; using MeshBeaconListenerModule::wantPacket; }; // --------------------------------------------------------------------------- // Globals managed by setUp / tearDown. // --------------------------------------------------------------------------- static MockMeshService *mockSvc = nullptr; static MockRouter *mockRouter = nullptr; static AdminModuleTestShim *testAdmin = nullptr; static AirTime *testAirTime = nullptr; // --------------------------------------------------------------------------- // Helper: build a ModuleConfig wrapper for the beacon case (mirrors the wire // format used by set_module_config admin messages). // --------------------------------------------------------------------------- static meshtastic_ModuleConfig makeBeaconModuleConfig(meshtastic_ModuleConfig_MeshBeaconConfig bcfg) { meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero; mc.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag; mc.payload_variant.mesh_beacon = bcfg; return mc; } // --------------------------------------------------------------------------- // Helper: reset module/device config to a known baseline. // --------------------------------------------------------------------------- static void resetConfig() { moduleConfig = meshtastic_LocalModuleConfig_init_zero; config = meshtastic_LocalConfig_init_zero; // Device is an EU_868 node with LONG_FAST - the starting point. config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; config.lora.use_preset = true; config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; // Allow TX unconditionally so airtime checks don't block sendBeacon(). config.lora.override_duty_cycle = true; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; myNodeInfo.my_node_num = kLocalNode; initRegion(); } // Install a single PRIMARY channel with an explicit name + PSK so the beacon channel-swap path // (sendBeaconPacket) has a real primary slot to save/restore and to read the active PSK from. static void installTestPrimaryChannel(const char *name, const uint8_t *psk, size_t pskLen) { channelFile.channels_count = 1; meshtastic_Channel &ch = channelFile.channels[0]; ch = meshtastic_Channel_init_zero; ch.index = 0; ch.has_settings = true; ch.role = meshtastic_Channel_Role_PRIMARY; strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); ch.settings.psk.size = (pb_size_t)pskLen; memcpy(ch.settings.psk.bytes, psk, pskLen); channels.onConfigChanged(); // set primaryIndex + recompute hashes } // Install a secondary channel at the given table index. Pass psk=nullptr/pskLen=0 for a blank slot // (no name, no PSK) to exercise the "referenced slot is unconfigured" fallback. static void installTestSecondaryChannel(uint8_t index, const char *name, const uint8_t *psk, size_t pskLen) { if (channelFile.channels_count < (pb_size_t)(index + 1)) channelFile.channels_count = index + 1; meshtastic_Channel &ch = channelFile.channels[index]; ch = meshtastic_Channel_init_zero; ch.index = index; ch.has_settings = true; ch.role = meshtastic_Channel_Role_SECONDARY; if (name) strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); if (psk && pskLen) { ch.settings.psk.size = (pb_size_t)pskLen; memcpy(ch.settings.psk.bytes, psk, pskLen); } channels.onConfigChanged(); } // =========================================================================== // Group 1: AdminModule config validation - bad inputs must be sanitised // =========================================================================== /** * Verify SHORT_TURBO is rejected when the region is EU_868 (turbo presets are not in that * region's allowed preset set). Important to catch regressions where admin stores unlawful * radio settings that would violate regional radio regulations. */ static void test_adminValidation_turboPresetOnEU868_isCleared(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.has_broadcast_on_preset, "SHORT_TURBO must be cleared for EU_868"); } /** * Verify LONG_TURBO is also cleared for EU_868, not just SHORT_TURBO. * Important to confirm rejection covers the entire turbo preset family rather than one variant. */ static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); } /** * Verify a turbo preset passes validation for US (PROFILE_STD allows all presets). * Important because the same preset that is illegal in EU_868 must be preserved in permissive regions. */ static void test_adminValidation_turboPresetOnUS_isAccepted(void) { resetConfig(); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); } /** * Verify MEDIUM_TURBO is also cleared for EU_868. Like SHORT_TURBO/LONG_TURBO it is a 500 kHz preset * that does not fit EU_868's 250 kHz band, so it must not survive admin validation there. */ static void test_adminValidation_mediumTurboPresetOnEU868_isCleared(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); } /** * Verify MEDIUM_TURBO passes validation for US (PROFILE_STD allows the full turbo family). * The same 500 kHz preset that is illegal in EU_868 must be preserved in permissive regions. */ static void test_adminValidation_mediumTurboPresetOnUS_isAccepted(void) { resetConfig(); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); } /** * Verify an out-of-range region code (255) is sanitised to UNSET rather than stored verbatim. * Important to prevent invalid proto enum values from reaching the broadcaster and being broadcast * over the air. */ static void test_adminValidation_unknownOfferRegion_isCleared(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_offer_region = (meshtastic_Config_LoRaConfig_RegionCode)255; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_offer_region); } /** * Verify a known-good offer region (US) is written through unchanged after admin validation. * Important as a positive-path control alongside the rejection tests. */ static void test_adminValidation_validOfferRegion_isPreserved(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_offer_region); } /** * Verify an out-of-range region in a multi-target entry is sanitised to UNSET on write. * Important because broadcast_targets entries are validated independently of the single-target * broadcast_on_* fields, and an invalid enum must never reach the radio-switch path. */ static void test_adminValidation_targetUnknownRegion_isCleared(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_targets_count = 1; bcfg.broadcast_targets[0].region = (meshtastic_Config_LoRaConfig_RegionCode)255; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_EQUAL(1, moduleConfig.mesh_beacon.broadcast_targets_count); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_targets[0].region); } /** * Verify a preset that is illegal for a multi-target entry's region clears that entry's preset * (and its channel), matching the single-target broadcast_on_preset rule. */ static void test_adminValidation_targetInvalidPresetForRegion_isCleared(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_targets_count = 1; bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; bcfg.broadcast_targets[0].has_preset = true; bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; bcfg.broadcast_targets[0].has_channel_index = true; bcfg.broadcast_targets[0].channel_index = 1; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset, "SHORT_TURBO must be cleared for EU_868 target"); TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); } /** * Verify a channel_index beyond the channel-table capacity is cleared on write. * Important so the broadcaster never indexes out of bounds when resolving a target channel. */ static void test_adminValidation_targetChannelIndexOutOfRange_isCleared(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_targets_count = 1; bcfg.broadcast_targets[0].has_channel_index = true; bcfg.broadcast_targets[0].channel_index = MAX_NUM_CHANNELS; // one past the last valid slot testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); } /** * Verify an in-range channel_index survives admin validation unchanged. */ static void test_adminValidation_targetChannelIndexInRange_isPreserved(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_targets_count = 1; bcfg.broadcast_targets[0].has_channel_index = true; bcfg.broadcast_targets[0].channel_index = 0; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_targets[0].channel_index); } /** * Verify a valid preset/region multi-target entry survives admin validation unchanged. * Positive-path control for the per-target validation. */ static void test_adminValidation_targetValidPresetForRegion_isPreserved(void) { resetConfig(); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_targets_count = 1; bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_US; bcfg.broadcast_targets[0].has_preset = true; bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_targets[0].preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_targets[0].region); } /** * Verify broadcast_message is hard-capped at 100 characters (NUL forced at index 100). * Important to prevent oversized beacon payloads from abusing airtime across the mesh. */ static void test_adminValidation_messageTooLong_isTruncatedAt100(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; // Fill with 'A' up to the full array size; admin must enforce ≤100 chars. memset(bcfg.broadcast_message, 'A', sizeof(bcfg.broadcast_message)); bcfg.broadcast_message[sizeof(bcfg.broadcast_message) - 1] = '\0'; // pb_decode guarantee testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); // Byte at index 100 must be NUL (length capped at 100). TEST_ASSERT_EQUAL('\0', moduleConfig.mesh_beacon.broadcast_message[100]); // Bytes before it should still be 'A'. TEST_ASSERT_EQUAL('A', moduleConfig.mesh_beacon.broadcast_message[0]); } /** * Verify any non-zero interval below 3600 s is clamped up to the 1-hour minimum. * Important to prevent high-rate beacon floods from a misconfigured or malicious client. */ static void test_adminValidation_intervalTooLow_isClamped(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_interval_secs = 60; // way below minimum testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_EQUAL_UINT32(3600, moduleConfig.mesh_beacon.broadcast_interval_secs); } /** * Verify an interval above the minimum is stored as-is without modification. * Important to confirm the clamp is one-sided (lower bound only, no upper bound enforced). */ static void test_adminValidation_intervalTooHigh_isPreserved(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_interval_secs = 999999; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_EQUAL_UINT32(999999, moduleConfig.mesh_beacon.broadcast_interval_secs); } /** * Verify LONG_FAST (enum value 0) survives admin validation without being treated as 'absent'. * Important to guard the has_broadcast_offer_preset presence-flag fix against zero-value erasure. */ static void test_adminValidation_longFastOfferPreset_isPreserved(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.has_broadcast_offer_preset = true; bcfg.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_offer_preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, moduleConfig.mesh_beacon.broadcast_offer_preset); } /** * Verify that interval 0 (the documented 'use default' sentinel) is not raised to 3600 by the clamp. * Important because 0 and 3600 have different runtime semantics in runOnce via * Default::getConfiguredOrDefault. */ static void test_adminValidation_intervalZero_isNotClamped(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_interval_secs = 0; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_interval_secs); } /** * Verify that saving a new beacon config marks the broadcaster's payload cache dirty. * Important so the next TX re-encodes from the latest config rather than a pre-save stale snapshot. */ static void test_adminValidation_validSave_invalidatesCache(void) { resetConfig(); // Prime the broadcaster with a clean state so the dirty flag is known. std::unique_ptr bcast(new MeshBeaconBroadcastModuleTestShim()); meshBeaconBroadcastModule = bcast.get(); bcast->payloadCacheDirty = false; // pretend it was freshly built meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; strncpy(bcfg.broadcast_message, "hello", sizeof(bcfg.broadcast_message) - 1); testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE_MESSAGE(bcast->payloadCacheDirty, "Config save must mark payload cache dirty"); meshBeaconBroadcastModule = nullptr; } // =========================================================================== // Group 2: Broadcaster payload cache // =========================================================================== /** * Verify rebuildCache produces at least one encoded byte when broadcast_message is set. * Important as the most basic liveness check for the protobuf encoding path. */ static void test_broadcaster_rebuildCache_producesNonEmptyPayload(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; strncpy(moduleConfig.mesh_beacon.broadcast_message, "Test beacon", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; TEST_ASSERT_TRUE(bcast.payloadCacheDirty); bcast.rebuildCache(); TEST_ASSERT_FALSE_MESSAGE(bcast.payloadCacheDirty, "rebuildCache must clear dirty flag"); TEST_ASSERT_GREATER_THAN_MESSAGE(0, (int)bcast.payloadCacheSize, "rebuildCache must produce a non-empty payload"); } /** * Verify the cached bytes round-trip through pb_decode back to the original message string. * Important to catch any protobuf field-tag or wire-type regression in the encoding path. */ static void test_broadcaster_rebuildCache_payloadDecodesCorrectly(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; const char *msg = "Hello, Meshtastic!"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.rebuildCache(); // Decode the cached bytes back into a MeshBeacon struct. meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize); bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); TEST_ASSERT_TRUE_MESSAGE(ok, "Cached payload must decode without error"); TEST_ASSERT_EQUAL_STRING(msg, decoded.message); } /** * Verify offer_region and offer_preset are present in the encoded cache payload. * Important to confirm the offer-carrying path correctly uses the has_broadcast_offer_preset flag. */ static void test_broadcaster_rebuildCache_offerFieldsEncoded(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; strncpy(moduleConfig.mesh_beacon.broadcast_message, "offer-test", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.rebuildCache(); meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize); pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, decoded.offer_region); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, decoded.offer_preset); } /** * Verify invalidateCache flips payloadCacheDirty back to true after a successful rebuild. * Important to confirm the cache-invalidation contract relied on by admin saves and config observers. */ static void test_broadcaster_invalidateCache_setsDirtyFlag(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; MeshBeaconBroadcastModuleTestShim bcast; bcast.rebuildCache(); TEST_ASSERT_FALSE(bcast.payloadCacheDirty); bcast.invalidateCache(); TEST_ASSERT_TRUE(bcast.payloadCacheDirty); } /** * Verify calling rebuildCache a second time without an intervening invalidation is a no-op. * Important to prevent spurious re-encodes when config-observer callbacks fire multiple times. */ static void test_broadcaster_rebuildCache_idempotent(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; strncpy(moduleConfig.mesh_beacon.broadcast_message, "idem", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.rebuildCache(); pb_size_t firstSize = bcast.payloadCacheSize; bcast.rebuildCache(); // second call - should be identical pb_size_t secondSize = bcast.payloadCacheSize; TEST_ASSERT_FALSE(bcast.payloadCacheDirty); TEST_ASSERT_EQUAL(firstSize, secondSize); } // =========================================================================== // Group 3: Broadcaster sendBeacon - packet structure // =========================================================================== /** * Verify the 'from' field defaults to the local node number when broadcast_send_as_node is 0. * Important for correct source attribution in peer node tables that receive the beacon. */ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; moduleConfig.mesh_beacon.broadcast_send_as_node = 0; strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-local", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); } /** * Verify broadcast_send_as_node is currently disabled: 'from' is always the local node * even when broadcast_send_as_node is set to a remote node number. * (broadcast_send_as_node is commented out as "not suitable right now".) */ static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; moduleConfig.mesh_beacon.broadcast_send_as_node = kRemoteNode; strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-remote", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); // broadcast_send_as_node is disabled; from is always the local node TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); } /** * Verify the 'to' field is always NODENUM_BROADCAST regardless of other settings. * Important because beacons are mesh-wide announcements and must never be addressed to a single peer. */ static void test_broadcaster_sendBeacon_addressedToBroadcast(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; strncpy(moduleConfig.mesh_beacon.broadcast_message, "bcast-addr", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); TEST_ASSERT_EQUAL_UINT32(NODENUM_BROADCAST, mockRouter->sentPackets[0].to); } /** * Verify MESH_BEACON_APP portnum is used when the packet carries a radio offer payload. * Important so receivers use the structured protobuf decoder rather than treating it as raw text. */ static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; strncpy(moduleConfig.mesh_beacon.broadcast_message, "portnum-check", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); } /** * Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if * broadcast_on_preset is set (that field governs which radio config to use for TX, not portnum). * Important so standard clients display plain-text beacons without needing a MESH_BEACON_APP decoder. */ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; const char *msg = "plain-text-beacon"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); // broadcast_on_preset set, but no offer - should still be TEXT_MESSAGE_APP moduleConfig.mesh_beacon.has_broadcast_on_preset = true; moduleConfig.mesh_beacon.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); const meshtastic_MeshPacket &p = mockRouter->sentPackets[0]; TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, p.decoded.portnum); TEST_ASSERT_EQUAL_UINT32(strlen(msg), p.decoded.payload.size); TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)p.decoded.payload.bytes, p.decoded.payload.size); } /** * Verify the MESH_BEACON_APP payload decodes back to the original message string. * Important to catch encode/decode regressions in the full sendBeacon → wire → pb_decode round-trip. */ static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; const char *msg = "Greetings from the beacon"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); const meshtastic_MeshPacket &p = mockRouter->sentPackets[0]; meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; pb_istream_t stream = pb_istream_from_buffer(p.decoded.payload.bytes, p.decoded.payload.size); bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); TEST_ASSERT_TRUE_MESSAGE(ok, "Sent payload must decode without error"); TEST_ASSERT_EQUAL_STRING(msg, decoded.message); } /** * Verify a beacon with offer fields but no message text is still emitted on MESH_BEACON_APP. * Important because offer-only beacons are a valid use case that the early-return guard must not * suppress. */ static void test_broadcaster_sendBeacon_offerOnly_isSent(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); } /** * Verify runOnce sends exactly one packet when broadcast_enabled is true. * Important to confirm the OSThread timer callback drives the full send path end-to-end. */ static void test_broadcaster_runOnce_sendsWhenEnabled(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; moduleConfig.mesh_beacon.broadcast_interval_secs = 3600; strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-enabled", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.runOnce(); TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); } /** * Verify runOnce transmits nothing when broadcast_enabled is false. * Important to confirm the feature can be cleanly disabled via remote admin without rebooting. */ static void test_broadcaster_runOnce_silentWhenDisabled(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags &= ~MESH_BEACON_FLAG_BROADCAST_ENABLED; strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-disabled", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; bcast.runOnce(); TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); } // =========================================================================== // Group 4: Listener - offer caching and guards // =========================================================================== // Helper: build a decoded MESH_BEACON_APP packet carrying the given MeshBeacon. static meshtastic_MeshPacket makeBeaconPacket(const meshtastic_MeshBeacon &b, NodeNum from = kRemoteNode) { meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; p.from = from; p.to = NODENUM_BROADCAST; p.id = 0xDEAD0001; p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; p.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; p.decoded.payload.size = (pb_size_t)pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_MeshBeacon_msg, &b); return p; } /** * Verify a beacon carrying preset and region offer fields is stored in lastReceivedOffer. * Important to confirm the client app's offer cache is populated correctly for join-offer UI flows. */ static void test_listener_receiveWithOffer_cachesOffer(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; strncpy(b.message, "Join us on US/MEDIUM_FAST", sizeof(b.message) - 1); b.has_offer_preset = true; b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; b.offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; meshtastic_MeshPacket mp = makeBeaconPacket(b); listener.handleReceivedProtobuf(mp, &b); TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Offer with preset must be cached"); TEST_ASSERT_EQUAL(kRemoteNode, MeshBeaconListenerModule::lastReceivedOffer.sender); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, MeshBeaconListenerModule::lastReceivedOffer.region); } /** * Verify a beacon with a full ChannelSettings offer sets has_channel and copies the channel struct. * Important because the client app checks has_channel before rendering a channel join offer. */ static void test_listener_receiveWithChannelOffer_setsHasChannel(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; strncpy(b.message, "Channel offer test", sizeof(b.message) - 1); b.has_offer_channel = true; b.offer_channel.channel_num = 5; strncpy(b.offer_channel.name, "TestNet", sizeof(b.offer_channel.name) - 1); meshtastic_MeshPacket mp = makeBeaconPacket(b); listener.handleReceivedProtobuf(mp, &b); TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid); TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.has_channel, "has_channel must be set when offer_channel is present"); TEST_ASSERT_EQUAL_UINT32(5, MeshBeaconListenerModule::lastReceivedOffer.channel.channel_num); } /** * Verify a beacon with neither message text nor offer fields is silently discarded. * Important to avoid spurious cache updates and wasted inbox copies from empty-payload packets. */ static void test_listener_emptyMessageWithoutOffer_isDropped(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; // message field intentionally left blank meshtastic_MeshPacket mp = makeBeaconPacket(b); listener.handleReceivedProtobuf(mp, &b); TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Empty message must not update offer cache"); } /** * Verify a LONG_FAST offer (preset enum value 0) with no message still populates the offer cache. * Important to guard the has_offer_preset fix - LONG_FAST must not be treated as 'no offer present'. */ static void test_listener_offerOnly_isCached(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; b.has_offer_preset = true; b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; meshtastic_MeshPacket mp = makeBeaconPacket(b); listener.handleReceivedProtobuf(mp, &b); TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset); } /** * Verify a null MeshBeacon pointer is handled gracefully and returns false without a crash. * Important to guard against the ProtobufModule base class passing nullptr on a decode failure. */ static void test_listener_nullBeacon_isDropped(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; bool result = listener.handleReceivedProtobuf(mp, nullptr); TEST_ASSERT_FALSE_MESSAGE(result, "Null beacon must return false"); TEST_ASSERT_FALSE(MeshBeaconListenerModule::lastReceivedOffer.valid); } /** * Verify a text-only beacon (no offer fields set) does not mark the offer cache valid. * Important to prevent the client from showing a join dialog in response to plain-text beacons. */ static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; strncpy(b.message, "No offer here", sizeof(b.message) - 1); // has_offer_preset == false, has_offer_channel == false meshtastic_MeshPacket mp = makeBeaconPacket(b); listener.handleReceivedProtobuf(mp, &b); TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "No offer fields → cache must stay invalid"); } /** * Verify the listener does NOT unwrap a combined beacon's text into a synthesized TEXT_MESSAGE_APP. * The original MESH_BEACON_APP packet already reaches the client (the handler returns CONTINUE), so * a beacon-aware client reads `message` directly from it - injecting a copy would only duplicate it, * and re-injecting onto the mesh would amplify/re-attribute. So: nothing onto the mesh, nothing * synthesized to the phone, and the handler must not consume the packet. */ static void test_listener_textMessage_notUnwrapped(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; strncpy(b.message, "hello mesh", sizeof(b.message) - 1); meshtastic_MeshPacket mp = makeBeaconPacket(b); bool consumed = listener.handleReceivedProtobuf(mp, &b); // CONTINUE (not STOP): the original MESH_BEACON_APP keeps flowing to the client, which reads // `message` from it - the simple path for a beacon-aware client. TEST_ASSERT_FALSE_MESSAGE(consumed, "Listener must not consume the beacon; the original must reach the client"); // Nothing re-injected onto the mesh. TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, mockRouter->sentPackets.size(), "Received beacon text must not be re-injected into the mesh"); // No synthesized TEXT_MESSAGE_APP delivered to the phone (no duplicate of the beacon's text). meshtastic_MeshPacket *toPhone = service->getForPhone(); TEST_ASSERT_NULL_MESSAGE(toPhone, "Listener must not inject a duplicate text packet to the phone"); } /** * Verify wantPacket returns false for MESH_BEACON_APP when listen_enabled is false. * Important to confirm the module opts out of processing when its config flag is cleared. */ static void test_listener_wantPacket_falseWhenDisabled(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags &= ~MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; TEST_ASSERT_FALSE(listener.wantPacket(&mp)); } /** * Verify wantPacket returns true for MESH_BEACON_APP packets when listen_enabled is true. * Important as a basic routing sanity check confirming the module is registered for its portnum. */ static void test_listener_wantPacket_trueWhenEnabled(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; MeshBeaconListenerModuleTestShim listener; meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; TEST_ASSERT_TRUE(listener.wantPacket(&mp)); } // =========================================================================== // Group 6: Legacy split messages // =========================================================================== /** * Verify broadcast_legacy_split causes sendBeacon to emit exactly two packets when both * text and offer content are present. * Important to confirm the split path is wired end-to-end rather than short-circuiting. */ static void test_broadcaster_legacySplit_sendsTwoPackets(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-text", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "Legacy split must emit exactly 2 packets"); } /** * Verify the first packet in a legacy-split send is MESH_BEACON_APP (the offer packet). * Important so receivers without a MESH_BEACON_APP decoder still get the text from packet B. */ static void test_broadcaster_legacySplit_firstPacketIsBeaconApp(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-offer-only", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); } /** * Verify the MESH_BEACON_APP packet in a legacy-split send carries no message text. * Important so peers' MESH_BEACON_APP handlers do not duplicate the text already in packet B. */ static void test_broadcaster_legacySplit_firstPacketHasNoMessageText(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; strncpy(moduleConfig.mesh_beacon.broadcast_message, "hidden-in-split", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); const meshtastic_MeshPacket &pA = mockRouter->sentPackets[0]; meshtastic_MeshBeacon decodedA = meshtastic_MeshBeacon_init_zero; pb_istream_t stream = pb_istream_from_buffer(pA.decoded.payload.bytes, pA.decoded.payload.size); pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decodedA); TEST_ASSERT_EQUAL_STRING_MESSAGE("", decodedA.message, "Offer-only MESH_BEACON_APP must have empty message"); } /** * Verify the second packet in a legacy-split send is TEXT_MESSAGE_APP containing the message text. * Important so legacy clients that only handle TEXT_MESSAGE_APP receive the human-readable text. */ static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; const char *msg = "split-B-text"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); const meshtastic_MeshPacket &pB = mockRouter->sentPackets[1]; TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, pB.decoded.portnum); TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)pB.decoded.payload.bytes, pB.decoded.payload.size); } // =========================================================================== // Group 7: Beacon-channel PSK swap (broadcast_on_channel override) // =========================================================================== /** * When broadcast_on_channel overrides the primary channel's name/PSK, the packet must be encrypted * on the BEACON channel, not the primary. perhapsEncode keys off the primary slot, so sendBeaconPacket * temporarily installs the beacon channel there for the send and restores it after. Verify both: the * primary slot IS the beacon channel during send(), and it is restored afterwards (no leak). */ static void test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores(void) { resetConfig(); static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // gives the beacon radio content to send moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; moduleConfig.mesh_beacon.has_broadcast_on_channel = true; strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, "BeaconCh", sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconPsk); memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconPsk, sizeof(beaconPsk)); MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); // During send(), the primary slot must hold the BEACON channel (so encryption uses its PSK). TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconCh", atSend.name, "primary must be the beacon channel during send"); TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the beacon channel PSK"); // After send(), the primary channel must be restored to the original (no leak into normal traffic). const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); } /** * Without a broadcast_on_channel override, the beacon must transmit on the primary channel unchanged * (no swap). Guards against the swap firing - and churning the channel table - when it isn't needed. */ static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) { resetConfig(); static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; // No broadcast_on_channel override. MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name, "primary must stay unchanged when no channel override is set"); } /** * A broadcast_target whose channel_index points at a configured table slot must transmit encrypted * on THAT slot's channel (name + PSK), not the primary. Verifies the slot-index → channel-table * resolution introduced when BroadcastTarget dropped its embedded ChannelSettings. */ static void test_broadcaster_targetChannelIndex_usesTableSlot(void) { resetConfig(); static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); installTestSecondaryChannel(1, "BeaconNet", beaconPsk, sizeof(beaconPsk)); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // content to send moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; moduleConfig.mesh_beacon.broadcast_targets_count = 1; moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index = true; moduleConfig.mesh_beacon.broadcast_targets[0].channel_index = 1; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", mockRouter->primaryAtSend[0].name, "beacon must be encrypted on the referenced slot's channel"); // Primary slot restored to home after send (no leak). TEST_ASSERT_EQUAL_STRING("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name); } /** * A broadcast_target whose channel_index points at a BLANK table slot (no name, no PSK) must fall * back to the default channel for the preset rather than borrowing the primary's name/PSK with a * clobbered channel_num. Guards the blank-slot handling for multi-target configs. */ static void test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset(void) { resetConfig(); static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); installTestSecondaryChannel(1, nullptr, nullptr, 0); // blank slot: no name, no PSK moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; moduleConfig.mesh_beacon.broadcast_targets_count = 1; moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index = true; moduleConfig.mesh_beacon.broadcast_targets[0].channel_index = 1; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); // Exactly one beacon goes out, and a blank slot does NOT trigger a crypto swap - the primary // stays "Home" (no garbage / no clobber), matching the no-channel-override default-for-preset path. TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name, "blank slot must not swap the beacon onto a borrowed channel"); } /** * Two broadcast_targets that resolve to the same effective radio config (same preset/region/channel) * must produce only ONE beacon - the payload is identical, so re-broadcasting wastes airtime. */ static void test_broadcaster_duplicateTargets_dedupedToOnePacket(void) { resetConfig(); static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; moduleConfig.mesh_beacon.broadcast_targets_count = 2; for (int i = 0; i < 2; i++) { moduleConfig.mesh_beacon.broadcast_targets[i].has_preset = true; moduleConfig.mesh_beacon.broadcast_targets[i].preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW; moduleConfig.mesh_beacon.broadcast_targets[i].region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; } MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, mockRouter->sentPackets.size(), "duplicate targets must collapse to one beacon"); } /** * Two distinct broadcast_targets (different presets) must BOTH be sent - dedup must not over-collapse. */ static void test_broadcaster_distinctTargets_bothSent(void) { resetConfig(); static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; moduleConfig.mesh_beacon.broadcast_targets_count = 2; moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW; moduleConfig.mesh_beacon.broadcast_targets[1].has_preset = true; moduleConfig.mesh_beacon.broadcast_targets[1].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "distinct targets must each be sent"); } } // namespace // =========================================================================== // Unity lifecycle // =========================================================================== void setUp(void) { testAirTime = new AirTime(); airTime = testAirTime; mockSvc = new MockMeshService(); service = mockSvc; mockRouter = new MockRouter(); router = mockRouter; testAdmin = new AdminModuleTestShim(); } void tearDown(void) { meshBeaconBroadcastModule = nullptr; delete testAdmin; testAdmin = nullptr; // Drain any packets the listener delivered via sendToPhone() (toPhoneQueue takes ownership and // nothing else dequeues them in tests) so they are returned to packetPool - otherwise they leak // and LeakSanitizer aborts the process at exit. if (mockSvc) { meshtastic_MeshPacket *p; while ((p = mockSvc->getForPhone()) != nullptr) mockSvc->releaseToPool(p); } service = nullptr; delete mockSvc; mockSvc = nullptr; router = nullptr; delete mockRouter; mockRouter = nullptr; airTime = nullptr; delete testAirTime; testAirTime = nullptr; } BEACON_TEST_ENTRY void setup() { delay(10); initializeTestEnvironment(); UNITY_BEGIN(); printf("\n=== AdminModule config validation ===\n"); RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted); RUN_TEST(test_adminValidation_mediumTurboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_mediumTurboPresetOnUS_isAccepted); RUN_TEST(test_adminValidation_unknownOfferRegion_isCleared); RUN_TEST(test_adminValidation_validOfferRegion_isPreserved); RUN_TEST(test_adminValidation_targetUnknownRegion_isCleared); RUN_TEST(test_adminValidation_targetInvalidPresetForRegion_isCleared); RUN_TEST(test_adminValidation_targetValidPresetForRegion_isPreserved); RUN_TEST(test_adminValidation_targetChannelIndexOutOfRange_isCleared); RUN_TEST(test_adminValidation_targetChannelIndexInRange_isPreserved); RUN_TEST(test_adminValidation_messageTooLong_isTruncatedAt100); RUN_TEST(test_adminValidation_intervalTooLow_isClamped); RUN_TEST(test_adminValidation_intervalTooHigh_isPreserved); RUN_TEST(test_adminValidation_intervalZero_isNotClamped); RUN_TEST(test_adminValidation_longFastOfferPreset_isPreserved); RUN_TEST(test_adminValidation_validSave_invalidatesCache); printf("\n=== Broadcaster payload cache ===\n"); RUN_TEST(test_broadcaster_rebuildCache_producesNonEmptyPayload); RUN_TEST(test_broadcaster_rebuildCache_payloadDecodesCorrectly); RUN_TEST(test_broadcaster_rebuildCache_offerFieldsEncoded); RUN_TEST(test_broadcaster_invalidateCache_setsDirtyFlag); RUN_TEST(test_broadcaster_rebuildCache_idempotent); printf("\n=== Broadcaster sendBeacon ===\n"); RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum); RUN_TEST(test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum); RUN_TEST(test_broadcaster_sendBeacon_payloadDecodesCorrectly); RUN_TEST(test_broadcaster_sendBeacon_offerOnly_isSent); RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); printf("\n=== Listener offer caching ===\n"); RUN_TEST(test_listener_receiveWithOffer_cachesOffer); RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); RUN_TEST(test_listener_emptyMessageWithoutOffer_isDropped); RUN_TEST(test_listener_offerOnly_isCached); RUN_TEST(test_listener_nullBeacon_isDropped); RUN_TEST(test_listener_receiveWithNoOffer_cacheStaysInvalid); RUN_TEST(test_listener_textMessage_notUnwrapped); RUN_TEST(test_listener_wantPacket_falseWhenDisabled); RUN_TEST(test_listener_wantPacket_trueWhenEnabled); printf("\n=== Legacy split messages ===\n"); RUN_TEST(test_broadcaster_legacySplit_sendsTwoPackets); RUN_TEST(test_broadcaster_legacySplit_firstPacketIsBeaconApp); RUN_TEST(test_broadcaster_legacySplit_firstPacketHasNoMessageText); RUN_TEST(test_broadcaster_legacySplit_secondPacketIsTextMessage); printf("\n=== Beacon-channel PSK swap ===\n"); RUN_TEST(test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores); RUN_TEST(test_broadcaster_noChannelOverride_doesNotSwapPrimary); RUN_TEST(test_broadcaster_targetChannelIndex_usesTableSlot); RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset); RUN_TEST(test_broadcaster_duplicateTargets_dedupedToOnePacket); RUN_TEST(test_broadcaster_distinctTargets_bothSent); exit(UNITY_END()); } BEACON_TEST_ENTRY void loop() {} #else // MESHTASTIC_EXCLUDE_BEACON void setUp(void) {} void tearDown(void) {} BEACON_TEST_ENTRY void setup() { initializeTestEnvironment(); UNITY_BEGIN(); exit(UNITY_END()); } BEACON_TEST_ENTRY void loop() {} #endif