mirror of
				https://github.com/meshtastic/firmware.git
				synced 2025-10-28 23:34:03 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			932 lines
		
	
	
		
			32 KiB
		
	
	
	
		
			C++
		
	
	
	
	
	
			
		
		
	
	
			932 lines
		
	
	
		
			32 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 <PubSubClient.h>
 | |
| #include <WiFiClient.h>
 | |
| 
 | |
| #include <arpa/inet.h>
 | |
| 
 | |
| #include <algorithm>
 | |
| #include <list>
 | |
| #include <optional>
 | |
| #include <set>
 | |
| #include <sstream>
 | |
| #include <string>
 | |
| #include <string_view>
 | |
| #include <utility>
 | |
| #include <variant>
 | |
| 
 | |
| #if defined(UNIT_TEST)
 | |
| #define IS_RUNNING_TESTS 1
 | |
| #else
 | |
| #define IS_RUNNING_TESTS 0
 | |
| #endif
 | |
| 
 | |
| 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:
 | |
|     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:
 | |
|     meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override { return &emptyNode; }
 | |
|     meshtastic_NodeInfoLite emptyNode = {};
 | |
| };
 | |
| 
 | |
| // 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) override
 | |
|     {
 | |
|         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;
 | |
| 
 | |
| // 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);
 | |
|     }
 | |
|     static void restart()
 | |
|     {
 | |
|         if (mqtt != NULL) {
 | |
|             delete mqtt;
 | |
|             mqtt = unitTest = NULL;
 | |
|         }
 | |
|         mqtt = unitTest = new MQTTUnitTest();
 | |
|         mqtt->start();
 | |
| 
 | |
|         if (!moduleConfig.mqtt.enabled || moduleConfig.mqtt.proxy_to_client_enabled || *moduleConfig.mqtt.root) {
 | |
|             loopUntil([] { return true; }); // Loop once
 | |
|             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/+"); }));
 | |
|     }
 | |
|     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,
 | |
| };
 | |
| } // namespace
 | |
| 
 | |
| // Initialize mocks and configuration before running each test.
 | |
| void setUp(void)
 | |
| {
 | |
|     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};
 | |
|     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;
 | |
|     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 = 7 * 1e7, .has_longitude_i = true, .longitude_i = 3 * 1e7};
 | |
| 
 | |
|     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);
 | |
| }
 | |
| 
 | |
| // 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());
 | |
| }
 | |
| 
 | |
| // 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)
 | |
| {
 | |
|     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.
 | |
| void test_receiveAcksOwnSentMessages(void)
 | |
| {
 | |
|     meshtastic_MeshPacket p = decoded;
 | |
|     p.from = myNodeInfo.my_node_num;
 | |
| 
 | |
|     unitTest->publish(&p, nodeDB->getNodeId().c_str());
 | |
| 
 | |
|     TEST_ASSERT_TRUE(mockRouter->packets_.empty());
 | |
|     TEST_ASSERT_EQUAL(1, mockRoutingModule->ackNacks_.size());
 | |
|     const auto &[err, to, idFrom, chIndex, hopLimit] = mockRoutingModule->ackNacks_.front();
 | |
|     TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, err);
 | |
|     TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, to);
 | |
|     TEST_ASSERT_EQUAL(p.id, idFrom);
 | |
| }
 | |
| 
 | |
| // 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());
 | |
| }
 | |
| 
 | |
| // 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());
 | |
| }
 | |
| 
 | |
| // 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());
 | |
| }
 | |
| 
 | |
| // 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());
 | |
| }
 | |
| 
 | |
| // 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.
 | |
| void test_configEnabledEmptyIsValid(void)
 | |
| {
 | |
|     meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true};
 | |
|     MockPubSubServer client;
 | |
| 
 | |
|     TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
 | |
|     TEST_ASSERT_TRUE(client.connected_);
 | |
|     TEST_ASSERT_EQUAL_STRING(default_mqtt_address, client.host_.c_str());
 | |
|     TEST_ASSERT_EQUAL(1883, client.port_);
 | |
| }
 | |
| 
 | |
| // 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));
 | |
| }
 | |
| 
 | |
| // isValidConfig connects to a custom host and port.
 | |
| void test_configCustomHostAndPort(void)
 | |
| {
 | |
|     meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server:1234"};
 | |
|     MockPubSubServer client;
 | |
| 
 | |
|     TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
 | |
|     TEST_ASSERT_TRUE(client.connected_);
 | |
|     TEST_ASSERT_EQUAL_STRING("server", client.host_.c_str());
 | |
|     TEST_ASSERT_EQUAL(1234, client.port_);
 | |
| }
 | |
| 
 | |
| // isValidConfig returns false if a connection cannot be established.
 | |
| void test_configWithConnectionFailure(void)
 | |
| {
 | |
|     meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server"};
 | |
|     MockPubSubServer client;
 | |
|     client.refuseConnection_ = true;
 | |
| 
 | |
|     TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client));
 | |
| }
 | |
| 
 | |
| // 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};
 | |
|     MockPubSubServer client;
 | |
| 
 | |
| #if MQTT_SUPPORTS_TLS
 | |
|     TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client));
 | |
| #else
 | |
|     TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client));
 | |
| #endif
 | |
| }
 | |
| 
 | |
| void setup()
 | |
| {
 | |
|     initializeTestEnvironment();
 | |
|     const std::unique_ptr<MockNodeDB> mockNodeDB(new MockNodeDB());
 | |
|     nodeDB = mockNodeDB.get();
 | |
| 
 | |
|     UNITY_BEGIN();
 | |
|     RUN_TEST(test_sendDirectlyConnectedDecoded);
 | |
|     RUN_TEST(test_sendDirectlyConnectedEncrypted);
 | |
|     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_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);
 | |
|     RUN_TEST(test_receiveIgnoresUnexpectedFields);
 | |
|     RUN_TEST(test_receiveIgnoresInvalidHopLimit);
 | |
|     RUN_TEST(test_publishTextMessageDirect);
 | |
|     RUN_TEST(test_publishTextMessageWithProxy);
 | |
|     RUN_TEST(test_reportToMapDefaultImprecise);
 | |
|     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_customMqttRoot);
 | |
|     RUN_TEST(test_configEmptyIsValid);
 | |
|     RUN_TEST(test_configEnabledEmptyIsValid);
 | |
|     RUN_TEST(test_configWithDefaultServer);
 | |
|     RUN_TEST(test_configWithDefaultServerAndInvalidPort);
 | |
|     RUN_TEST(test_configCustomHostAndPort);
 | |
|     RUN_TEST(test_configWithConnectionFailure);
 | |
|     RUN_TEST(test_configWithTLSEnabled);
 | |
|     exit(UNITY_END());
 | |
| }
 | |
| #else
 | |
| void setup()
 | |
| {
 | |
|     initializeTestEnvironment();
 | |
|     LOG_WARN("This test requires the ARCH_PORTDUINO variant of WiFiClient");
 | |
|     UNITY_BEGIN();
 | |
|     UNITY_END();
 | |
| }
 | |
| #endif
 | |
| void loop() {} | 
