Files
firmware/test/test_mqtt/MQTT.cpp
T
Ben Meadors 9a59e9088d fix(test): restore the sendAckNak overrides broken by #10767 (#11626)
#10767 added a relaySource parameter to the RoutingModule::sendAckNak
virtual, but the five test mocks that derive from RoutingModule still
declared the six-parameter signature with `override`. Nothing overrides
the new virtual, so all five suites fail to compile and the native test
job has been red on develop since the merge:

  test/test_reliable_ack_matrix/test_main.cpp:167:10: error: 'void
  MockRoutingModule::sendAckNak(meshtastic_Routing_Error, NodeNum,
  PacketId, ChannelIndex, uint8_t, bool)' marked 'override', but does
  not override

Widen the five mocks to the new signature.

Also carry has_rx_rssi with rx_rssi in allocAckNak(). rx_rssi has
explicit presence, so copying only the value left has_rx_rssi false and
nanopb dropped the field at encode time - the phone never saw the
relayer's RSSI that #10767 set out to deliver.

Cover both: test_reliable_ack_matrix asserts the overheard rebroadcast
is handed through as the relay source on the decodable path and the
opaque #11502 ingress path, and that no other ACK/NAK claims a relayer;
test_mesh_module drives a real RoutingModule and asserts the relay
fields, has_rx_rssi included, survive all the way to the phone.
2026-08-27 06:32:06 -05:00

1577 lines
58 KiB
C++

#include "DebugConfiguration.h"
#include "TestUtil.h"
#include <unity.h>
#ifdef ARCH_PORTDUINO
#include "mesh/CryptoEngine.h"
#include "mesh/Default.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "mesh/Router.h"
#include "modules/RoutingModule.h"
#include "mqtt/MQTT.h"
#include "mqtt/ServiceEnvelope.h"
#include "support/DeterministicRng.h" // rngSeed/rngNext/rngByte/rngRange - shared seeded LCG (fuzz group)
#include <PubSubClient.h>
#include <WiFiClient.h>
// htonl() for remoteIP() below. MinGW has no <arpa/inet.h>; the byte-order helpers live in
// winsock2.h, which must precede any <windows.h> the Arduino shims pull in.
#ifdef _WIN32
#include <winsock2.h>
#else
#include <arpa/inet.h>
#endif
#include <algorithm>
#include <list>
#include <map>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <variant>
namespace
{
// Minimal router needed to receive messages from MQTT.
class MockRouter : public Router
{
public:
~MockRouter()
{
// cryptLock is created in the constructor for Router.
delete cryptLock;
cryptLock = NULL;
}
void enqueueReceivedMessage(meshtastic_MeshPacket *p) override
{
packets_.emplace_back(*p);
packetPool.release(p);
}
std::list<meshtastic_MeshPacket> packets_; // Packets received by the Router.
};
// Minimal MeshService needed to receive messages from MQTT for testing PKI channel.
class MockMeshService : public MeshService
{
public:
// No PhoneAPI reader exists in these tests, so packets the receive pipeline forwards to the phone
// (MeshService::sendToPhone enqueues pooled copies into toPhoneQueue) would leak at teardown. Drain
// the queue like the phone would. This surfaced once sendLocal() began dispatching local packets
// through handleReceived() directly rather than via the (mock-overridden) enqueueReceivedMessage().
~MockMeshService()
{
while (meshtastic_MeshPacket *p = getForPhone())
releaseToPool(p);
}
void sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) override
{
messages_.emplace_back(*m);
releaseMqttClientProxyMessageToPool(m);
}
void sendClientNotification(meshtastic_ClientNotification *n) override
{
notifications_.emplace_back(*n);
releaseClientNotificationToPool(n);
}
std::list<meshtastic_MqttClientProxyMessage> messages_; // Messages received from the MeshService.
std::list<meshtastic_ClientNotification> notifications_; // Notifications received from the MeshService.
};
// Minimal NodeDB needed to return values from getMeshNode.
class MockNodeDB : public NodeDB
{
public:
// Per-NodeNum overlay on top of the shared node, so a test can make one endpoint known
// while another stays unknown; everything else keeps the shared-node semantics.
meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override
{
auto it = nodes_.find(n);
return it != nodes_.end() ? &it->second : &emptyNode;
}
meshtastic_NodeInfoLite emptyNode = {};
std::map<NodeNum, meshtastic_NodeInfoLite> nodes_;
};
// Minimal RoutingModule needed to return values from sendAckNak.
class MockRoutingModule : public RoutingModule
{
public:
void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0,
bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override
{
(void)ackWantsAck;
(void)relaySource;
ackNacks_.emplace_back(err, to, idFrom, chIndex, hopLimit);
}
std::list<std::tuple<meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t>>
ackNacks_; // ackNacks received by the RoutingModule.
};
// A WiFi client used by the MQTT::PubSubClient. Implements a minimal pub/sub server.
// There isn't an easy way to mock PubSubClient due to it not having virtual methods, so we mock using
// the WiFiClinet that PubSubClient uses.
class MockPubSubServer : public WiFiClient
{
public:
static constexpr char kTextTopic[] = "TextTopic";
uint8_t connected() override { return connected_; }
void flush() override {}
IPAddress remoteIP() const override { return IPAddress(htonl(ipAddress_)); }
void stop() override { connected_ = false; }
int connect(IPAddress ip, uint16_t port) override
{
port_ = port;
if (refuseConnection_)
return 0;
connected_ = true;
return 1;
}
int connect(const char *host, uint16_t port) override
{
host_ = host;
port_ = port;
if (refuseConnection_)
return 0;
connected_ = true;
return 1;
}
int available() override
{
if (buffer_.empty())
return 0;
return buffer_.front().size();
}
int read() override
{
assert(available());
std::string &front = buffer_.front();
char ch = front[0];
front = front.substr(1, front.size());
if (front.empty())
buffer_.pop_front();
return ch;
}
size_t write(uint8_t data) override { return write(&data, 1); }
size_t write(const uint8_t *buf, size_t size) override
{
command_ += std::string(reinterpret_cast<const char *>(buf), size);
if (command_.size() < 2)
return size;
const int len = (uint8_t)command_[1] + 2;
if (command_.size() < len)
return size;
handleCommand(command_[0], command_.substr(2, len));
command_ = command_.substr(len, command_.size());
return size;
}
// The pub/sub "server".
// https://public.dhe.ibm.com/software/dw/webservices/ws-mqtt/MQTT_V3.1_Protocol_Specific.pdf
void handleCommand(uint8_t header, std::string_view message)
{
switch (header & 0xf0) {
case MQTTCONNECT:
LOG_DEBUG("MQTTCONNECT");
buffer_.push_back(std::string("\x20\x02\x00\x00", 4));
break;
case MQTTSUBSCRIBE: {
LOG_DEBUG("MQTTSUBSCRIBE");
assert(message.size() >= 5);
message.remove_prefix(2); // skip messageId
while (message.size() >= 3) {
const uint16_t topicSize = ((uint8_t)message[0]) << 8 | (uint8_t)message[1];
message.remove_prefix(2);
assert(message.size() >= topicSize + 1);
std::string topic(message.data(), topicSize);
message.remove_prefix(topicSize + 1);
LOG_DEBUG("Subscribed to topic: %s", topic.c_str());
subscriptions_.insert(std::move(topic));
}
break;
}
case MQTTPINGREQ:
LOG_DEBUG("MQTTPINGREQ");
buffer_.push_back(std::string("\xd0\x00", 2));
break;
case MQTTPUBLISH: {
LOG_DEBUG("MQTTPUBLISH");
assert(message.size() >= 3);
const uint16_t topicSize = ((uint8_t)message[0]) << 8 | (uint8_t)message[1];
message.remove_prefix(2);
assert(message.size() >= topicSize);
std::string topic(message.data(), topicSize);
message.remove_prefix(topicSize);
if (topic == kTextTopic) {
published_.emplace_back(std::move(topic), std::string(message.data(), message.size()));
} else {
published_.emplace_back(
std::move(topic), DecodedServiceEnvelope(reinterpret_cast<const uint8_t *>(message.data()), message.size()));
}
break;
}
}
}
bool connected_ = false;
bool refuseConnection_ = false; // Simulate a failed connection.
uint32_t ipAddress_ = 0x01010101; // IP address of the MQTT server.
std::string host_; // Requested host.
uint16_t port_; // Requested port.
std::list<std::string> buffer_; // Buffer of messages for the pubSub client to receive.
std::string command_; // Current command received from the pubSub client.
std::set<std::string> subscriptions_; // Topics that the pubSub client has subscribed to.
std::list<std::pair<std::string, std::variant<std::string,
DecodedServiceEnvelope>>>
published_; // Messages published from the pubSub client. Each list element is a pair containing the topic name and either
// a text message (if from the kTextTopic topic) or a DecodedServiceEnvelope.
};
// Instances of our mocks.
class MQTTUnitTest;
MQTTUnitTest *unitTest;
MockPubSubServer *pubsub;
MockRoutingModule *mockRoutingModule;
MockMeshService *mockMeshService;
MockRouter *mockRouter;
MockNodeDB *mockNodeDB;
// Keep running the loop until either conditionMet returns true or 4 seconds elapse.
// Returns true if conditionMet returns true, returns false on timeout.
bool loopUntil(std::function<bool()> conditionMet)
{
long start = millis();
while (start + 4000 > millis()) {
long delayMsec = concurrency::mainController.runOrDelay();
if (conditionMet())
return true;
concurrency::mainDelay.delay(std::min(delayMsec, 5L));
}
return false;
}
// Used to access protected/private members of MQTT for unit testing.
class MQTTUnitTest : public MQTT
{
public:
MQTTUnitTest() : MQTT(std::make_unique<MockPubSubServer>())
{
pubsub = reinterpret_cast<MockPubSubServer *>(mqttClient.get());
}
~MQTTUnitTest()
{
// Needed because WiFiClient does not have a virtual destructor.
mqttClient.release();
delete pubsub;
}
using MQTT::isValidConfig;
using MQTT::reconnect;
int queueSize() { return mqttQueue.numUsed(); }
void reportToMap(std::optional<uint32_t> precision = std::nullopt)
{
if (precision.has_value())
map_position_precision = precision.value();
map_publish_interval_msecs = 0;
perhapsReportToMap();
}
void publish(const meshtastic_MeshPacket *p, std::string gateway = "!87654321", std::string channel = "test")
{
std::stringstream topic;
topic << "msh/2/e/" << channel << "/!" << gateway;
const meshtastic_ServiceEnvelope env = {.packet = const_cast<meshtastic_MeshPacket *>(p),
.channel_id = const_cast<char *>(channel.c_str()),
.gateway_id = const_cast<char *>(gateway.c_str())};
uint8_t bytes[256];
size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env);
mqttCallback(const_cast<char *>(topic.str().c_str()), bytes, numBytes);
}
// Feed arbitrary bytes straight into the subscription callback - the non-RF ingress a malicious or
// broken broker could push. Mirrors publish()'s final mqttCallback() call but with an unconstrained
// payload, so it exercises DecodedServiceEnvelope decode + onReceiveProto with garbage.
void deliverRaw(const std::string &topic, const uint8_t *bytes, size_t n)
{
mqttCallback(const_cast<char *>(topic.c_str()), const_cast<uint8_t *>(bytes), (unsigned int)n);
}
static void restart()
{
if (mqtt != NULL) {
delete mqtt;
mqtt = unitTest = NULL;
}
mqtt = unitTest = new MQTTUnitTest();
mqtt->start();
auto clearStartupOutput = []() {
pubsub->published_.clear();
if (mockMeshService != nullptr) {
mockMeshService->messages_.clear();
mockMeshService->notifications_.clear();
}
};
if (!moduleConfig.mqtt.enabled || moduleConfig.mqtt.proxy_to_client_enabled || *moduleConfig.mqtt.root) {
loopUntil([] { return true; }); // Loop once
clearStartupOutput();
return;
}
// Wait for MQTT to subscribe to all topics.
TEST_ASSERT_TRUE(loopUntil(
[] { return pubsub->subscriptions_.count("msh/2/e/test/+") && pubsub->subscriptions_.count("msh/2/e/PKI/+"); }));
clearStartupOutput();
}
PubSubClient &getPubSub() { return pubSub; }
};
// Packets used in unit tests.
const meshtastic_MeshPacket decoded = {
.from = 1,
.to = 2,
.which_payload_variant = meshtastic_MeshPacket_decoded_tag,
.decoded = {.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP, .has_bitfield = true, .bitfield = BITFIELD_OK_TO_MQTT_MASK},
.id = 4,
};
const meshtastic_MeshPacket encrypted = {
.from = 1,
.to = 2,
.which_payload_variant = meshtastic_MeshPacket_encrypted_tag,
.encrypted = {.size = 0},
.id = 3,
};
void configureCoordinatePolicyChannels(bool eventChannelIsPrimary = true)
{
memset(&channelFile, 0, sizeof(channelFile));
channelFile.channels_count = 2;
auto &eventChannel = channelFile.channels[0];
eventChannel.index = 0;
eventChannel.has_settings = true;
strncpy(eventChannel.settings.name, "everyone", sizeof(eventChannel.settings.name) - 1);
eventChannel.settings.uplink_enabled = true;
eventChannel.settings.downlink_enabled = true;
eventChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY;
#ifdef USERPREFS_CHANNEL_0_PSK
static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK;
eventChannel.settings.psk.size = sizeof(configuredEventPsk);
memcpy(eventChannel.settings.psk.bytes, configuredEventPsk, sizeof(configuredEventPsk));
#endif
auto &privateChannel = channelFile.channels[1];
privateChannel.index = 1;
privateChannel.has_settings = true;
strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1);
privateChannel.settings.psk.size = 32;
memset(privateChannel.settings.psk.bytes, 0xab, privateChannel.settings.psk.size);
privateChannel.settings.uplink_enabled = true;
privateChannel.settings.downlink_enabled = true;
privateChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_SECONDARY : meshtastic_Channel_Role_PRIMARY;
channels.onConfigChanged();
}
meshtastic_MeshPacket makePositionPacket(ChannelIndex channel)
{
meshtastic_MeshPacket packet = decoded;
packet.to = NODENUM_BROADCAST;
packet.channel = channel;
packet.decoded.portnum = meshtastic_PortNum_POSITION_APP;
return packet;
}
void clearPublicationState()
{
TEST_ASSERT_EQUAL(0, unitTest->queueSize());
pubsub->published_.clear();
mockMeshService->messages_.clear();
}
} // namespace
// Initialize mocks and configuration before running each test.
void setUp(void)
{
memset(&config, 0, sizeof(config));
moduleConfig.mqtt =
meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true};
moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{
.publish_interval_secs = 0, .position_precision = 14, .should_report_location = true};
memset(&channelFile, 0, sizeof(channelFile));
channelFile.channels[0] = meshtastic_Channel{
.index = 0,
.has_settings = true,
.settings = {.name = "test", .uplink_enabled = true, .downlink_enabled = true},
.role = meshtastic_Channel_Role_PRIMARY,
};
channelFile.channels_count = 1;
channels.onConfigChanged();
owner = meshtastic_User{.id = "!12345678"};
myNodeInfo = meshtastic_MyNodeInfo{.my_node_num = 0x12345678}; // Match the expected gateway ID in topic
localPosition =
meshtastic_Position{.has_latitude_i = true, .latitude_i = 700000000, .has_longitude_i = true, .longitude_i = 300000000};
// The shared MockNodeDB node is mutated by the XEdDSA policy tests (signer bit, public
// key); reset it so state can't leak between tests.
if (mockNodeDB) {
mockNodeDB->emptyNode = meshtastic_NodeInfoLite();
mockNodeDB->nodes_.clear();
}
router = mockRouter = new MockRouter();
service = mockMeshService = new MockMeshService();
routingModule = mockRoutingModule = new MockRoutingModule();
MQTTUnitTest::restart();
}
// Deinitialize all objects created in setUp.
void tearDown(void)
{
delete unitTest;
mqtt = unitTest = NULL;
delete mockRoutingModule;
routingModule = mockRoutingModule = NULL;
delete mockMeshService;
service = mockMeshService = NULL;
delete mockRouter;
router = mockRouter = NULL;
}
// Test that the decoded MeshPacket is published when encryption_enabled = false.
void test_sendDirectlyConnectedDecoded(void)
{
mqtt->onSend(encrypted, decoded, 0);
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
const auto &[topic, payload] = pubsub->published_.front();
const DecodedServiceEnvelope &env = std::get<DecodedServiceEnvelope>(payload);
TEST_ASSERT_EQUAL_STRING("msh/2/e/test/!12345678", topic.c_str());
TEST_ASSERT_TRUE(env.validDecode);
TEST_ASSERT_EQUAL(decoded.id, env.packet->id);
}
// Test that the encrypted MeshPacket is published when encryption_enabled = true.
void test_sendDirectlyConnectedEncrypted(void)
{
moduleConfig.mqtt.encryption_enabled = true;
mqtt->onSend(encrypted, decoded, 0);
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
const auto &[topic, payload] = pubsub->published_.front();
const DecodedServiceEnvelope &env = std::get<DecodedServiceEnvelope>(payload);
TEST_ASSERT_EQUAL_STRING("msh/2/e/test/!12345678", topic.c_str());
TEST_ASSERT_TRUE(env.validDecode);
TEST_ASSERT_EQUAL(encrypted.id, env.packet->id);
}
void test_eventPositionPublicationFollowsCompileTimePolicy(void)
{
configureCoordinatePolicyChannels();
clearPublicationState();
const meshtastic_MeshPacket position = makePositionPacket(0);
mqtt->onSend(encrypted, position, 0);
#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK)
TEST_ASSERT_TRUE(pubsub->published_.empty());
TEST_ASSERT_EQUAL(0, unitTest->queueSize());
#else
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
#endif
}
void test_privatePositionStillPublishesWithEventPolicy(void)
{
configureCoordinatePolicyChannels();
clearPublicationState();
const meshtastic_MeshPacket position = makePositionPacket(1);
mqtt->onSend(encrypted, position, 1);
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
TEST_ASSERT_EQUAL_STRING("msh/2/e/private/!12345678", pubsub->published_.front().first.c_str());
}
void test_explicitPkiPositionStillPublishesWithEventPolicy(void)
{
configureCoordinatePolicyChannels();
clearPublicationState();
meshtastic_MeshPacket position = makePositionPacket(0);
meshtastic_MeshPacket encryptedPki = encrypted;
position.to = 2;
position.pki_encrypted = true;
encryptedPki.pki_encrypted = true;
mqtt->onSend(encryptedPki, position, 0);
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
TEST_ASSERT_EQUAL_STRING("msh/2/e/PKI/!12345678", pubsub->published_.front().first.c_str());
}
// Verify that the decoded MeshPacket is proxied through the MeshService when encryption_enabled = false.
void test_proxyToMeshServiceDecoded(void)
{
moduleConfig.mqtt.proxy_to_client_enabled = true;
MQTTUnitTest::restart();
mqtt->onSend(encrypted, decoded, 0);
TEST_ASSERT_EQUAL(1, mockMeshService->messages_.size());
const meshtastic_MqttClientProxyMessage &message = mockMeshService->messages_.front();
TEST_ASSERT_EQUAL_STRING("msh/2/e/test/!12345678", message.topic);
TEST_ASSERT_EQUAL(meshtastic_MqttClientProxyMessage_data_tag, message.which_payload_variant);
const DecodedServiceEnvelope env(message.payload_variant.data.bytes, message.payload_variant.data.size);
TEST_ASSERT_TRUE(env.validDecode);
TEST_ASSERT_EQUAL(decoded.id, env.packet->id);
}
// Verify that the encrypted MeshPacket is proxied through the MeshService when encryption_enabled = true.
void test_proxyToMeshServiceEncrypted(void)
{
moduleConfig.mqtt.proxy_to_client_enabled = true;
moduleConfig.mqtt.encryption_enabled = true;
MQTTUnitTest::restart();
mqtt->onSend(encrypted, decoded, 0);
TEST_ASSERT_EQUAL(1, mockMeshService->messages_.size());
const meshtastic_MqttClientProxyMessage &message = mockMeshService->messages_.front();
TEST_ASSERT_EQUAL_STRING("msh/2/e/test/!12345678", message.topic);
TEST_ASSERT_EQUAL(meshtastic_MqttClientProxyMessage_data_tag, message.which_payload_variant);
const DecodedServiceEnvelope env(message.payload_variant.data.bytes, message.payload_variant.data.size);
TEST_ASSERT_TRUE(env.validDecode);
TEST_ASSERT_EQUAL(encrypted.id, env.packet->id);
}
// A packet without the OK to MQTT bit set should not be published to a public server.
void test_dontMqttMeOnPublicServer(void)
{
meshtastic_MeshPacket p = decoded;
p.decoded.bitfield = 0;
p.decoded.has_bitfield = 0;
mqtt->onSend(encrypted, p, 0);
TEST_ASSERT_TRUE(pubsub->published_.empty());
}
// A packet without the OK to MQTT bit set should be published to a private server.
void test_okToMqttOnPrivateServer(void)
{
// Cause a disconnect.
pubsub->connected_ = false;
pubsub->refuseConnection_ = true;
TEST_ASSERT_TRUE(loopUntil([] { return !unitTest->getPubSub().connected(); }));
// Use 127.0.0.1 for the server's IP.
pubsub->ipAddress_ = 0x7f000001;
// Reconnect.
pubsub->refuseConnection_ = false;
TEST_ASSERT_TRUE(loopUntil([] { return unitTest->getPubSub().connected(); }));
// Send the same packet as test_dontMqttMeOnPublicServer.
meshtastic_MeshPacket p = decoded;
p.decoded.bitfield = 0;
p.decoded.has_bitfield = 0;
mqtt->onSend(encrypted, p, 0);
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
}
// Range tests messages are not uplinked to the default server.
void test_noRangeTestAppOnDefaultServer(void)
{
meshtastic_MeshPacket p = decoded;
p.decoded.portnum = meshtastic_PortNum_RANGE_TEST_APP;
mqtt->onSend(encrypted, p, 0);
TEST_ASSERT_TRUE(pubsub->published_.empty());
}
// Detection sensor messages are not uplinked to the default server.
void test_noDetectionSensorAppOnDefaultServer(void)
{
meshtastic_MeshPacket p = decoded;
p.decoded.portnum = meshtastic_PortNum_DETECTION_SENSOR_APP;
mqtt->onSend(encrypted, p, 0);
TEST_ASSERT_TRUE(pubsub->published_.empty());
}
// Test that a MeshPacket is queued while the MQTT server is disconnected.
void test_sendQueued(void)
{
// Cause a disconnect.
pubsub->connected_ = false;
pubsub->refuseConnection_ = true;
TEST_ASSERT_TRUE(loopUntil([] { return !unitTest->getPubSub().connected(); }));
// Send while disconnected.
mqtt->onSend(encrypted, decoded, 0);
TEST_ASSERT_EQUAL(1, unitTest->queueSize());
TEST_ASSERT_TRUE(pubsub->published_.empty());
TEST_ASSERT_FALSE(unitTest->getPubSub().connected());
// Allow reconnect to happen. Expect to see the packet published now.
pubsub->refuseConnection_ = false;
TEST_ASSERT_TRUE(loopUntil([] { return !pubsub->published_.empty(); }));
TEST_ASSERT_EQUAL(0, unitTest->queueSize());
const auto &[topic, payload] = pubsub->published_.front();
const DecodedServiceEnvelope &env = std::get<DecodedServiceEnvelope>(payload);
TEST_ASSERT_EQUAL_STRING("msh/2/e/test/!12345678", topic.c_str());
TEST_ASSERT_TRUE(env.validDecode);
TEST_ASSERT_EQUAL(decoded.id, env.packet->id);
}
// Verify reconnecting with the proxy enabled does not reconnect to a MQTT server.
void test_reconnectProxyDoesNotReconnectMqtt(void)
{
moduleConfig.mqtt.proxy_to_client_enabled = true;
MQTTUnitTest::restart();
unitTest->reconnect();
TEST_ASSERT_FALSE(pubsub->connected_);
}
// Test receiving an empty MeshPacket on a subscribed topic.
void test_receiveEmptyMeshPacket(void)
{
unitTest->publish(NULL);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty());
}
// Test receiving a decoded MeshPacket on a subscribed topic.
void test_receiveDecodedProto(void)
{
unitTest->publish(&decoded);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
const meshtastic_MeshPacket &p = mockRouter->packets_.front();
TEST_ASSERT_EQUAL(decoded.id, p.id);
TEST_ASSERT_TRUE(p.via_mqtt);
}
// Test receiving a decoded MeshPacket from the phone proxy.
void test_receiveDecodedProtoFromProxy(void)
{
const meshtastic_ServiceEnvelope env = {
.packet = const_cast<meshtastic_MeshPacket *>(&decoded), .channel_id = "test", .gateway_id = "!87654321"};
meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default;
strcat(message.topic, "msh/2/e/test/!87654321");
message.which_payload_variant = meshtastic_MqttClientProxyMessage_data_tag;
message.payload_variant.data.size = pb_encode_to_bytes(
message.payload_variant.data.bytes, sizeof(message.payload_variant.data.bytes), &meshtastic_ServiceEnvelope_msg, &env);
mqtt->onClientProxyReceive(message);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
const meshtastic_MeshPacket &p = mockRouter->packets_.front();
TEST_ASSERT_EQUAL(decoded.id, p.id);
TEST_ASSERT_TRUE(p.via_mqtt);
}
// Properly handles the case where the received message is empty.
void test_receiveEmptyDataFromProxy(void)
{
meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default;
message.which_payload_variant = meshtastic_MqttClientProxyMessage_data_tag;
mqtt->onClientProxyReceive(message);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Text must be read as text: data.size aliases the string's first bytes, so reading it regardless
// of the variant let a client name a length of up to PB_SIZE_MAX. There is no delivery control for
// this variant: an encoded ServiceEnvelope always contains NUL, so text can never carry one.
void test_receiveTextVariantFromProxyIsNotReadAsBytes(void)
{
meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default;
snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321");
message.which_payload_variant = meshtastic_MqttClientProxyMessage_text_tag;
// data.size would read these as the largest length a pb_size_t can name.
memset(message.payload_variant.text, 0xFF, sizeof(message.payload_variant.text) - 1);
message.payload_variant.text[sizeof(message.payload_variant.text) - 1] = '\0';
mqtt->onClientProxyReceive(message);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// A proxy message with no payload variant set must be ignored rather than read as bytes.
void test_receiveNoVariantFromProxyIsIgnored(void)
{
meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default;
snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321");
message.which_payload_variant = 0;
memset(message.payload_variant.data.bytes, 0xFF, sizeof(message.payload_variant.data.bytes));
message.payload_variant.data.size = sizeof(message.payload_variant.data.bytes);
mqtt->onClientProxyReceive(message);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Packets should be ignored if downlink is not enabled.
void test_receiveWithoutChannelDownlink(void)
{
channelFile.channels[0].settings.downlink_enabled = false;
unitTest->publish(&decoded);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Test receiving an encrypted MeshPacket on the PKI topic.
void test_receiveEncryptedPKITopicToUs(void)
{
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
meshtastic_MeshPacket e = encrypted;
e.to = myNodeInfo.my_node_num;
unitTest->publish(&e, "!87654321", "PKI");
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
const meshtastic_MeshPacket &p = mockRouter->packets_.front();
TEST_ASSERT_EQUAL(encrypted.id, p.id);
TEST_ASSERT_TRUE(p.via_mqtt);
}
// Should ignore messages published to MQTT by this gateway.
void test_receiveIgnoresOwnPublishedMessages(void)
{
unitTest->publish(&decoded, nodeDB->getNodeId().c_str());
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty());
}
// Considers receiving one of our packets an acknowledgement of it being sent: hearing our own
// packet back on our own gateway topic synthesizes an implicit ACK, delivered locally through
// sendLocal() -> handleReceived() -> the phone queue, marked as arriving via MQTT transport.
void test_receiveAcksOwnSentMessages(void)
{
meshtastic_MeshPacket p = decoded;
p.from = myNodeInfo.my_node_num;
unitTest->publish(&p, nodeDB->getNodeId().c_str());
// The implicit ACK is delivered locally, never enqueued as MQTT downlink ingress.
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
meshtastic_MeshPacket *ack = mockMeshService->getForPhone();
TEST_ASSERT_NOT_NULL(ack);
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, ack->which_payload_variant);
TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, ack->decoded.portnum);
TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->to);
TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->from);
TEST_ASSERT_EQUAL(p.id, ack->decoded.request_id);
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, ack->transport_mechanism);
meshtastic_Routing routing = meshtastic_Routing_init_default;
TEST_ASSERT_TRUE(
pb_decode_from_bytes(ack->decoded.payload.bytes, ack->decoded.payload.size, &meshtastic_Routing_msg, &routing));
TEST_ASSERT_EQUAL(meshtastic_Routing_error_reason_tag, routing.which_variant);
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, routing.error_reason);
mockMeshService->releaseToPool(ack);
TEST_ASSERT_NULL(mockMeshService->getForPhone()); // exactly one ACK
}
// Should ignore our own messages from MQTT that were heard by other nodes.
void test_receiveIgnoresSentMessagesFromOthers(void)
{
meshtastic_MeshPacket p = decoded;
p.from = myNodeInfo.my_node_num;
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty());
}
// Decoded MQTT messages should be ignored when encryption is enabled.
void test_receiveIgnoresDecodedWhenEncryptionEnabled(void)
{
moduleConfig.mqtt.encryption_enabled = true;
unitTest->publish(&decoded);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Non-encrypted messages for the Admin App should be ignored.
void test_receiveIgnoresDecodedAdminApp(void)
{
meshtastic_MeshPacket p = decoded;
p.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Small decoded broadcast from a remote node, as a plaintext broker would deliver it.
static meshtastic_MeshPacket makeDecodedBroadcast()
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = 1;
p.to = NODENUM_BROADCAST;
p.id = 7;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p.decoded.payload.size = 5;
memcpy(p.decoded.payload.bytes, "hello", 5);
return p;
}
// Decoded (plaintext-broker) downlink skips perhapsDecode's crypto path, so MQTT applies
// checkXeddsaReceivePolicy at ingress. An unsigned broadcast claiming to come from a node that
// previously signed must be dropped - without this, a rogue broker peer could impersonate any
// signing node (audit F3).
void test_receiveDropsUnsignedBroadcastFromSigner(void)
{
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED;
mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
const meshtastic_MeshPacket p = makeDecodedBroadcast();
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// The same unsigned broadcast from a node never seen signing is accepted.
void test_receiveAcceptsUnsignedBroadcastFromNonSigner(void)
{
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED;
const meshtastic_MeshPacket p = makeDecodedBroadcast();
unitTest->publish(&p);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
TEST_ASSERT_FALSE(mockRouter->packets_.front().xeddsa_signed);
}
// A validly signed decoded downlink verifies at ingress: delivered with xeddsa_signed set and
// the sender's signer bit learned.
void test_receiveVerifiesSignedDecodedDownlink(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->emptyNode.public_key.size = 32;
memcpy(mockNodeDB->emptyNode.public_key.bytes, pub, 32);
meshtastic_MeshPacket p = makeDecodedBroadcast();
TEST_ASSERT_TRUE(crypto->xeddsa_sign(p.from, p.id, p.decoded.portnum, p.decoded.payload.bytes, p.decoded.payload.size,
p.decoded.xeddsa_signature.bytes));
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
unitTest->publish(&p);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
TEST_ASSERT_TRUE(mockRouter->packets_.front().xeddsa_signed);
TEST_ASSERT_TRUE(mockNodeDB->emptyNode.bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
}
// A decoded downlink carrying a signature that fails verification is dropped.
void test_receiveDropsBadSignatureOnDecodedDownlink(void)
{
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->emptyNode.public_key.size = 32;
memcpy(mockNodeDB->emptyNode.public_key.bytes, pub, 32);
meshtastic_MeshPacket p = makeDecodedBroadcast();
TEST_ASSERT_TRUE(crypto->xeddsa_sign(p.from, p.id, p.decoded.portnum, p.decoded.payload.bytes, p.decoded.payload.size,
p.decoded.xeddsa_signature.bytes));
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
p.decoded.xeddsa_signature.bytes[0] ^= 0xFF;
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
void test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner(void)
{
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK;
const meshtastic_MeshPacket p = makeDecodedBroadcast();
unitTest->publish(&p);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
}
void test_receiveStrictDropsUnsignedPortnumsAndUnicast(void)
{
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
const meshtastic_PortNum ports[] = {
meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP,
meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_WAYPOINT_APP,
};
for (const auto port : ports) {
meshtastic_MeshPacket p = makeDecodedBroadcast();
p.decoded.portnum = port;
unitTest->publish(&p);
}
meshtastic_MeshPacket unicast = makeDecodedBroadcast();
unicast.to = myNodeInfo.my_node_num;
unicast.decoded.portnum = meshtastic_PortNum_POSITION_APP;
unitTest->publish(&unicast);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// A plaintext broker assertion is not evidence that AES-CCM authentication succeeded locally.
void test_receiveStrictDoesNotTrustDecodedPkiFlag(void)
{
config.security.packet_signature_policy =
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
meshtastic_MeshPacket p = makeDecodedBroadcast();
p.to = myNodeInfo.my_node_num;
p.pki_encrypted = true;
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
#endif // !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Only the same fields that are transmitted over LoRa should be set in MQTT messages.
void test_receiveIgnoresUnexpectedFields(void)
{
meshtastic_MeshPacket input = decoded;
input.rx_snr = 10;
input.rx_rssi = 20;
unitTest->publish(&input);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
const meshtastic_MeshPacket &p = mockRouter->packets_.front();
TEST_ASSERT_EQUAL(0, p.rx_snr);
TEST_ASSERT_EQUAL(0, p.rx_rssi);
}
// Messages with an invalid hop_limit are ignored.
void test_receiveIgnoresInvalidHopLimit(void)
{
meshtastic_MeshPacket p = decoded;
p.hop_limit = 10;
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// ===========================================================================
// Downlink acceptance gates - shouldDropMqttDownlink + onReceiveProto policy
// ===========================================================================
// hop_start above HOP_MAX is rejected even when hop_limit is valid.
void test_receiveIgnoresInvalidHopStart(void)
{
meshtastic_MeshPacket p = decoded;
p.hop_start = 10;
p.hop_limit = 3;
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// The ignore_mqtt kill-switch drops every MQTT downlink.
void test_receiveDropsWhenIgnoreMqttSet(void)
{
config.lora.ignore_mqtt = true;
unitTest->publish(&decoded);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// A sender listed in config.lora.ignore_incoming is dropped.
void test_receiveDropsSenderInIgnoreIncomingList(void)
{
config.lora.ignore_incoming_count = 1;
config.lora.ignore_incoming[0] = decoded.from;
unitTest->publish(&decoded);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// A non-empty ignore list only drops matching senders - presence of the list alone must not drop.
void test_receiveAcceptsSenderNotInIgnoreIncomingList(void)
{
config.lora.ignore_incoming_count = 2;
config.lora.ignore_incoming[0] = 99;
config.lora.ignore_incoming[1] = 100;
unitTest->publish(&decoded);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
}
// A sender whose NodeDB entry carries the is_ignored bit is dropped (resurrect-ignored-node guard).
void test_receiveDropsNodeDbIgnoredSender(void)
{
mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_IS_IGNORED_MASK;
unitTest->publish(&decoded);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// A packet claiming the broadcast address as its source is dropped.
void test_receiveDropsBroadcastSource(void)
{
meshtastic_MeshPacket p = decoded;
p.from = NODENUM_BROADCAST;
unitTest->publish(&p);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty());
}
// A broker cannot assert PKI authentication or a transport: every accepted downlink is laundered
// to pki_encrypted=false + TRANSPORT_MQTT + via_mqtt=true. pki_encrypted grants admin-level trust
// downstream, so a regression here is remote privilege escalation.
void test_receiveLaundersPkiAndTransportFields(void)
{
meshtastic_MeshPacket p = decoded;
p.pki_encrypted = true;
p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
unitTest->publish(&p);
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
const meshtastic_MeshPacket &r = mockRouter->packets_.front();
TEST_ASSERT_FALSE(r.pki_encrypted);
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism);
TEST_ASSERT_TRUE(r.via_mqtt);
}
// PKI-topic envelopes are dropped when no channel has downlink enabled, even when addressed to us.
void test_receiveDropsPkiTopicWhenNoChannelHasDownlink(void)
{
channelFile.channels[0].settings.downlink_enabled = false;
meshtastic_MeshPacket e = encrypted;
e.to = myNodeInfo.my_node_num;
unitTest->publish(&e, "!87654321", "PKI");
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Any single downlink-enabled channel (here only a secondary) is enough to admit PKI envelopes.
void test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink(void)
{
channelFile.channels[0].settings.downlink_enabled = false;
channelFile.channels[1] = meshtastic_Channel{
.index = 1,
.has_settings = true,
.settings = {.name = "second", .downlink_enabled = true},
.role = meshtastic_Channel_Role_SECONDARY,
};
channelFile.channels_count = 2;
channels.onConfigChanged();
meshtastic_MeshPacket e = encrypted;
e.to = myNodeInfo.my_node_num;
unitTest->publish(&e, "!87654321", "PKI");
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
}
// An encrypted PKI envelope not addressed to us needs both endpoints known with user info.
void test_receiveDropsPkiNotToUsWithUnknownEndpoints(void)
{
unitTest->publish(&encrypted, "!87654321", "PKI"); // to=2; neither endpoint has user info
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
void test_receiveAcceptsPkiNotToUsWithKnownEndpoints(void)
{
// MockNodeDB serves the same node for every NodeNum, so this marks both endpoints known.
mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
unitTest->publish(&encrypted, "!87654321", "PKI");
TEST_ASSERT_EQUAL(1, mockRouter->packets_.size());
const meshtastic_MeshPacket &r = mockRouter->packets_.front();
TEST_ASSERT_TRUE(r.via_mqtt);
TEST_ASSERT_FALSE(r.pki_encrypted); // laundered even on the PKI topic
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism);
}
// The endpoint gate is an AND: knowing only the sender (from=1) while the receiver (to=2) is
// unknown must still drop. Distinguishes && from || in the MQTT.cpp acceptance rule.
void test_receiveDropsPkiNotToUsWithOnlySenderKnown(void)
{
mockNodeDB->nodes_[1].bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; // only from=1 known; to=2 stays unknown
unitTest->publish(&encrypted, "!87654321", "PKI");
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// An envelope naming a channel we do not have is dropped, even though getByName falls back to
// the primary channel - the case-sensitive global-id recheck must refuse the substitution.
void test_receiveDropsUnknownChannelName(void)
{
unitTest->publish(&decoded, "!87654321", "nope");
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// getByName matches case-insensitively, but the downlink gate compares case-sensitively; a
// mixed-case channel_id must not ride the primary channel's downlink permission.
void test_receiveDropsCaseMismatchedChannelName(void)
{
unitTest->publish(&decoded, "!87654321", "TEST");
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// A validly-decoding envelope missing channel_id is rejected before any gate runs.
void test_receiveRejectsEnvelopeWithoutChannelId(void)
{
const meshtastic_ServiceEnvelope env = {.packet = const_cast<meshtastic_MeshPacket *>(&decoded),
.channel_id = NULL,
.gateway_id = const_cast<char *>("!87654321")};
uint8_t bytes[256];
const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env);
unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, numBytes);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Every strict prefix of a valid envelope must be rejected: either the truncated decode fails, or
// it succeeds with gateway_id (the last-encoded field) missing and the NULL check refuses it.
void test_receiveRejectsTruncatedEnvelope(void)
{
const meshtastic_ServiceEnvelope env = {.packet = const_cast<meshtastic_MeshPacket *>(&decoded),
.channel_id = const_cast<char *>("test"),
.gateway_id = const_cast<char *>("!87654321")};
uint8_t bytes[256];
const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env);
TEST_ASSERT_TRUE(numBytes > 0);
for (size_t n = 1; n < numBytes; n++)
unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, n);
TEST_ASSERT_TRUE(mockRouter->packets_.empty());
}
// Publishing to a text channel.
void test_publishTextMessageDirect(void)
{
TEST_ASSERT_TRUE(mqtt->publish(MockPubSubServer::kTextTopic, "payload", 0));
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
const auto &[topic, payload] = pubsub->published_.front();
TEST_ASSERT_EQUAL_STRING("payload", std::get<std::string>(payload).c_str());
}
// Publishing to a text channel via the MQTT client proxy.
void test_publishTextMessageWithProxy(void)
{
moduleConfig.mqtt.proxy_to_client_enabled = true;
TEST_ASSERT_TRUE(mqtt->publish(MockPubSubServer::kTextTopic, "payload", 0));
TEST_ASSERT_EQUAL(1, mockMeshService->messages_.size());
const meshtastic_MqttClientProxyMessage &message = mockMeshService->messages_.front();
TEST_ASSERT_EQUAL_STRING(MockPubSubServer::kTextTopic, message.topic);
TEST_ASSERT_EQUAL(meshtastic_MqttClientProxyMessage_text_tag, message.which_payload_variant);
TEST_ASSERT_EQUAL_STRING("payload", message.payload_variant.text);
}
// Helper method to verify the expected latitude/longitude was received.
void verifyLatLong(const DecodedServiceEnvelope &env, uint32_t latitude, uint32_t longitude)
{
TEST_ASSERT_TRUE(env.validDecode);
const meshtastic_MeshPacket &p = *env.packet;
TEST_ASSERT_EQUAL(NODENUM_BROADCAST, p.to);
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant);
TEST_ASSERT_EQUAL(meshtastic_PortNum_MAP_REPORT_APP, p.decoded.portnum);
meshtastic_MapReport mapReport;
TEST_ASSERT_TRUE(
pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_MapReport_msg, &mapReport));
TEST_ASSERT_EQUAL(latitude, mapReport.latitude_i);
TEST_ASSERT_EQUAL(longitude, mapReport.longitude_i);
}
// Map reporting defaults to an imprecise location.
void test_reportToMapDefaultImprecise(void)
{
unitTest->reportToMap();
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
const auto &[topic, payload] = pubsub->published_.front();
TEST_ASSERT_EQUAL_STRING("msh/2/map/", topic.c_str());
}
void test_eventPrimaryMapReportFollowsCompileTimePolicy(void)
{
configureCoordinatePolicyChannels();
clearPublicationState();
unitTest->reportToMap();
#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK)
TEST_ASSERT_TRUE(pubsub->published_.empty());
TEST_ASSERT_EQUAL(0, unitTest->queueSize());
#else
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
#endif
}
void test_privatePrimaryMapReportStillPublishesWithEventPolicy(void)
{
configureCoordinatePolicyChannels(false);
clearPublicationState();
unitTest->reportToMap();
TEST_ASSERT_EQUAL(1, pubsub->published_.size());
TEST_ASSERT_EQUAL_STRING("msh/2/map/", pubsub->published_.front().first.c_str());
}
// Location is sent over the phone proxy.
void test_reportToMapImpreciseProxied(void)
{
moduleConfig.mqtt.proxy_to_client_enabled = true;
MQTTUnitTest::restart();
unitTest->reportToMap(/*precision=*/14);
TEST_ASSERT_EQUAL(1, mockMeshService->messages_.size());
const meshtastic_MqttClientProxyMessage &message = mockMeshService->messages_.front();
TEST_ASSERT_EQUAL_STRING("msh/2/map/", message.topic);
TEST_ASSERT_EQUAL(meshtastic_MqttClientProxyMessage_data_tag, message.which_payload_variant);
const DecodedServiceEnvelope env(message.payload_variant.data.bytes, message.payload_variant.data.size);
}
// isUsingDefaultServer returns true when using the default server.
void test_usingDefaultServer(void)
{
TEST_ASSERT_TRUE(mqtt->isUsingDefaultServer());
}
// isUsingDefaultServer returns true when using the default server and a port.
void test_usingDefaultServerWithPort(void)
{
std::string server = default_mqtt_address;
server += ":1883";
strcpy(moduleConfig.mqtt.address, server.c_str());
MQTTUnitTest::restart();
TEST_ASSERT_TRUE(mqtt->isUsingDefaultServer());
}
// isUsingDefaultServer returns true when using the default server and invalid port.
void test_usingDefaultServerWithInvalidPort(void)
{
std::string server = default_mqtt_address;
server += ":invalid";
strcpy(moduleConfig.mqtt.address, server.c_str());
MQTTUnitTest::restart();
TEST_ASSERT_TRUE(mqtt->isUsingDefaultServer());
}
// isUsingDefaultServer returns false when not using the default server.
void test_usingCustomServer(void)
{
strcpy(moduleConfig.mqtt.address, "custom");
MQTTUnitTest::restart();
TEST_ASSERT_FALSE(mqtt->isUsingDefaultServer());
}
// Test that isEnabled returns true the MQTT module is enabled.
void test_enabled(void)
{
TEST_ASSERT_TRUE(mqtt->isEnabled());
}
// Test that isEnabled returns false the MQTT module not enabled.
void test_disabled(void)
{
moduleConfig.mqtt.enabled = false;
MQTTUnitTest::restart();
TEST_ASSERT_FALSE(mqtt->isEnabled());
}
void test_mqttInitSkipsAllocationWhenDisabled(void)
{
delete unitTest;
mqtt = unitTest = NULL;
moduleConfig.mqtt.enabled = false;
mqttInit();
TEST_ASSERT_NULL(mqtt);
}
// Subscriptions contain the moduleConfig.mqtt.root prefix.
void test_customMqttRoot(void)
{
strcpy(moduleConfig.mqtt.root, "custom");
MQTTUnitTest::restart();
TEST_ASSERT_TRUE(loopUntil(
[] { return pubsub->subscriptions_.count("custom/2/e/test/+") && pubsub->subscriptions_.count("custom/2/e/PKI/+"); }));
}
// Empty configuration is valid.
void test_configEmptyIsValid(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {};
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// Empty 'enabled' configuration is valid. A lightweight TCP check may be performed
// but does not affect the result.
void test_configEnabledEmptyIsValid(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true};
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// Configuration with the default server is valid.
void test_configWithDefaultServer(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.address = default_mqtt_address};
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// Configuration with the default server and port 8888 is invalid.
void test_configWithDefaultServerAndInvalidPort(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.address = default_mqtt_address ":8888"};
TEST_ASSERT_FALSE(MQTT::isValidConfig(config));
}
// Custom host and port is valid. TCP reachability is checked but does not block saving.
void test_configCustomHostAndPort(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server:1234"};
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// An unreachable server is still a valid config - settings always save.
// A warning notification is sent in non-test builds, but isValidConfig returns true.
void test_configWithUnreachableServerIsStillValid(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server"};
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
}
// isValidConfig returns true when tls_enabled is supported, or false otherwise.
void test_configWithTLSEnabled(void)
{
meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server", .tls_enabled = true};
#if MQTT_SUPPORTS_TLS
TEST_ASSERT_TRUE(MQTT::isValidConfig(config));
#else
TEST_ASSERT_FALSE(MQTT::isValidConfig(config));
#endif
}
// ===========================================================================
// Fuzz - adversarial MQTT downlink ingress (the non-RF path a broker can push)
// ===========================================================================
// Blitzes the onReceiveProto() chain with (a) raw garbage that must fail envelope decode cleanly and
// (b) well-formed envelopes wrapping crafted inner packets. Contract: no crash, at most one enqueue per envelope.
constexpr uint64_t MQTT_FUZZ_SEED = 0x00E3A71C0FULL;
void test_receiveFuzzServiceEnvelope(void)
{
printf(" seed=0x%llx\n", (unsigned long long)MQTT_FUZZ_SEED);
rngSeed(MQTT_FUZZ_SEED);
const char *channelIds[] = {"test", "PKI", "nope", ""};
const char *gatewayIds[] = {"!12345678", "!87654321", "!00000000"}; // [0] == our node id -> self path
for (unsigned k = 0; k < 4000; k++) {
if (rngRange(3) == 0) {
// (a) Raw bytes: mostly random, must be rejected at DecodedServiceEnvelope without crashing.
uint8_t raw[128];
size_t n = rngRange(sizeof(raw) + 1);
rngFill(raw, n);
unitTest->deliverRaw("msh/2/e/test/!87654321", raw, n);
} else {
// (b) Well-formed envelope around a crafted inner packet.
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = (rngRange(3) == 0) ? myNodeInfo.my_node_num : rngNext(); // self origin hits isFromUs
p.to = (rngRange(2)) ? myNodeInfo.my_node_num : rngNext();
p.id = rngNext();
p.channel = (uint8_t)rngByte();
p.hop_limit = (uint8_t)rngRange(10); // includes > HOP_MAX (the invalid-hop reject path)
p.hop_start = (uint8_t)rngRange(10);
p.want_ack = (rngRange(2) == 0);
p.pki_encrypted = (rngRange(2) == 0);
if (rngRange(2) == 0) {
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = (rngRange(6) == 0) ? meshtastic_PortNum_ADMIN_APP : (meshtastic_PortNum)rngRange(80);
p.decoded.want_response = (rngRange(2) == 0);
p.decoded.has_bitfield = (rngRange(2) == 0);
p.decoded.bitfield = (uint32_t)rngNext();
p.decoded.payload.size = rngRange(64);
rngFill(p.decoded.payload.bytes, p.decoded.payload.size);
} else {
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p.encrypted.size = rngRange(64);
rngFill(p.encrypted.bytes, p.encrypted.size);
}
unitTest->publish(&p, gatewayIds[rngRange(3)], channelIds[rngRange(4)]);
}
// A single envelope reaches at most one enqueueReceivedMessage; more would be a routing bug.
TEST_ASSERT_TRUE_MESSAGE(mockRouter->packets_.size() <= 1, "MQTT downlink enqueued >1 packet per envelope");
// Drain capture lists so 4000 iterations stay bounded (values already released to their pools).
mockRouter->packets_.clear();
mockRoutingModule->ackNacks_.clear();
mockMeshService->messages_.clear();
mockMeshService->notifications_.clear();
}
}
void setup()
{
initializeTestEnvironment();
nodeDB = mockNodeDB = new MockNodeDB(); // freed implicitly by exit(UNITY_END()) below
UNITY_BEGIN();
RUN_TEST(test_sendDirectlyConnectedDecoded);
RUN_TEST(test_sendDirectlyConnectedEncrypted);
RUN_TEST(test_eventPositionPublicationFollowsCompileTimePolicy);
RUN_TEST(test_privatePositionStillPublishesWithEventPolicy);
RUN_TEST(test_explicitPkiPositionStillPublishesWithEventPolicy);
RUN_TEST(test_proxyToMeshServiceDecoded);
RUN_TEST(test_proxyToMeshServiceEncrypted);
RUN_TEST(test_dontMqttMeOnPublicServer);
RUN_TEST(test_okToMqttOnPrivateServer);
RUN_TEST(test_noRangeTestAppOnDefaultServer);
RUN_TEST(test_noDetectionSensorAppOnDefaultServer);
RUN_TEST(test_sendQueued);
RUN_TEST(test_reconnectProxyDoesNotReconnectMqtt);
RUN_TEST(test_receiveEmptyMeshPacket);
RUN_TEST(test_receiveDecodedProto);
RUN_TEST(test_receiveDecodedProtoFromProxy);
RUN_TEST(test_receiveEmptyDataFromProxy);
RUN_TEST(test_receiveTextVariantFromProxyIsNotReadAsBytes);
RUN_TEST(test_receiveNoVariantFromProxyIsIgnored);
RUN_TEST(test_receiveWithoutChannelDownlink);
RUN_TEST(test_receiveEncryptedPKITopicToUs);
RUN_TEST(test_receiveIgnoresOwnPublishedMessages);
RUN_TEST(test_receiveAcksOwnSentMessages);
RUN_TEST(test_receiveIgnoresSentMessagesFromOthers);
RUN_TEST(test_receiveIgnoresDecodedWhenEncryptionEnabled);
RUN_TEST(test_receiveIgnoresDecodedAdminApp);
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
RUN_TEST(test_receiveDropsUnsignedBroadcastFromSigner);
RUN_TEST(test_receiveAcceptsUnsignedBroadcastFromNonSigner);
RUN_TEST(test_receiveVerifiesSignedDecodedDownlink);
RUN_TEST(test_receiveDropsBadSignatureOnDecodedDownlink);
RUN_TEST(test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner);
RUN_TEST(test_receiveStrictDropsUnsignedPortnumsAndUnicast);
RUN_TEST(test_receiveStrictDoesNotTrustDecodedPkiFlag);
#endif
RUN_TEST(test_receiveIgnoresUnexpectedFields);
RUN_TEST(test_receiveIgnoresInvalidHopLimit);
RUN_TEST(test_receiveIgnoresInvalidHopStart);
RUN_TEST(test_receiveDropsWhenIgnoreMqttSet);
RUN_TEST(test_receiveDropsSenderInIgnoreIncomingList);
RUN_TEST(test_receiveAcceptsSenderNotInIgnoreIncomingList);
RUN_TEST(test_receiveDropsNodeDbIgnoredSender);
RUN_TEST(test_receiveDropsBroadcastSource);
RUN_TEST(test_receiveLaundersPkiAndTransportFields);
RUN_TEST(test_receiveDropsPkiTopicWhenNoChannelHasDownlink);
RUN_TEST(test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink);
RUN_TEST(test_receiveDropsPkiNotToUsWithUnknownEndpoints);
RUN_TEST(test_receiveAcceptsPkiNotToUsWithKnownEndpoints);
RUN_TEST(test_receiveDropsPkiNotToUsWithOnlySenderKnown);
RUN_TEST(test_receiveDropsUnknownChannelName);
RUN_TEST(test_receiveDropsCaseMismatchedChannelName);
RUN_TEST(test_receiveRejectsEnvelopeWithoutChannelId);
RUN_TEST(test_receiveRejectsTruncatedEnvelope);
RUN_TEST(test_receiveFuzzServiceEnvelope);
RUN_TEST(test_publishTextMessageDirect);
RUN_TEST(test_publishTextMessageWithProxy);
RUN_TEST(test_reportToMapDefaultImprecise);
RUN_TEST(test_eventPrimaryMapReportFollowsCompileTimePolicy);
RUN_TEST(test_privatePrimaryMapReportStillPublishesWithEventPolicy);
RUN_TEST(test_reportToMapImpreciseProxied);
RUN_TEST(test_usingDefaultServer);
RUN_TEST(test_usingDefaultServerWithPort);
RUN_TEST(test_usingDefaultServerWithInvalidPort);
RUN_TEST(test_usingCustomServer);
RUN_TEST(test_enabled);
RUN_TEST(test_disabled);
RUN_TEST(test_mqttInitSkipsAllocationWhenDisabled);
RUN_TEST(test_customMqttRoot);
RUN_TEST(test_configEmptyIsValid);
RUN_TEST(test_configEnabledEmptyIsValid);
RUN_TEST(test_configWithDefaultServer);
RUN_TEST(test_configWithDefaultServerAndInvalidPort);
RUN_TEST(test_configCustomHostAndPort);
RUN_TEST(test_configWithUnreachableServerIsStillValid);
RUN_TEST(test_configWithTLSEnabled);
exit(UNITY_END());
}
#else
void setup()
{
initializeTestEnvironment();
LOG_WARN("This test requires the ARCH_PORTDUINO variant of WiFiClient");
UNITY_BEGIN();
exit(UNITY_END());
}
#endif
void loop() {}