PubSub lib renamed TasmotaPubSub, hardening fixes and comprehensive non-regression tests (#24916)

This commit is contained in:
s-hadinger
2026-07-23 20:49:54 +02:00
committed by GitHub
parent 70fa0c5ad5
commit 2d340102a3
93 changed files with 16481 additions and 3101 deletions
+1
View File
@@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file.
### Changed
- MiElHVAC auto-enable i-See widevane when setting AirDirection (#24860)
- `PubSub` lib renamed `TasmotaPubSub`, hardening fixes and comprehensive non-regression tests
### Fixed
- BLE EQ3 float output in mqtt messages regression from v15.4.0.2 (#24869)
@@ -1,4 +1,7 @@
Copyright (c) 2008-2020 Nicholas O'Leary
TasmotaPubSub - Tasmota fork of the PubSubClient MQTT library
Copyright (c) 2008-2020 Nicholas O'Leary (original PubSubClient)
Copyright (c) 2020-2025 Theo Arends and Tasmota contributors (fork changes)
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
+14
View File
@@ -0,0 +1,14 @@
# TasmotaPubSub
Tasmota fork of Nick O'Leary's [PubSubClient](https://github.com/knolleary/pubsubclient) MQTT library,
hardened and adapted for use within Tasmota on ESP8266 and ESP32.
The public API and the `PubSubClient` class name are kept for compatibility, so Tasmota code
continues to use `#include <PubSubClient.h>`. Tasmota-specific changes in the source are marked
with `// Start Tasmota patch` comments (see `CHANGES.txt`).
Original library by Nick O'Leary — https://pubsubclient.knolleary.net
## License
Released under the MIT License. See `LICENSE.txt`.
+26
View File
@@ -0,0 +1,26 @@
{
"name": "TasmotaPubSub",
"version": "2.8.13",
"keywords": "ethernet, mqtt, m2m, iot, TasmotaPubSub",
"description": "Tasmota fork of the PubSubClient MQTT library. A client library for MQTT messaging, a lightweight messaging protocol ideal for small devices. This fork is hardened and adapted for Tasmota on ESP8266 and ESP32. Based on the original PubSubClient by Nick O'Leary.",
"repository": {
"type": "git",
"url": "https://github.com/arendst/Tasmota/lib/TasmotaPubSub"
},
"authors": [
{
"name": "Nick O'Leary",
"email": "nick.oleary@gmail.com",
"url": "http://knolleary.net"
},
{
"name": "Stephan Hadinger"
}
],
"exclude": "tests",
"frameworks": "arduino",
"platforms": [
"espressif8266",
"espressif32"
]
}
@@ -0,0 +1,9 @@
name=TasmotaPubSub
version=2.8.13
author=Nick O'Leary <nick.oleary@gmail.com>, Stephan Hadinger <stephan.hadinger@gmail.com>
maintainer=Stephan Hadinger <stephan.hadinger@gmail.com>
sentence=Tasmota fork of the PubSubClient library for MQTT messaging.
paragraph=MQTT is a lightweight messaging protocol ideal for small devices. This library allows you to send and receive MQTT messages. This fork is hardened and adapted for Tasmota on ESP8266 and ESP32. Based on the original PubSubClient by Nick O'Leary.
category=Communication
url=https://github.com/arendst/Tasmota
architectures=esp8266,esp32
@@ -1,7 +1,18 @@
/*
PubSubClient.cpp - A simple client for MQTT.
Nick O'Leary
http://knolleary.net
TasmotaPubSub - Tasmota fork of the PubSubClient MQTT library.
Original author:
Nick O'Leary - http://knolleary.net
Tasmota fork maintained by Theo Arends and the Tasmota contributors.
SPDX-FileCopyrightText: 2008-2020 Nicholas O'Leary
SPDX-FileCopyrightText: 2020-2025 Theo Arends and Tasmota contributors
SPDX-License-Identifier: MIT
Tasmota-specific changes are marked with "Start Tasmota patch" comments.
*/
#include "PubSubClient.h"
@@ -187,7 +198,22 @@ boolean PubSubClient::connect(const char *id, const char *user, const char *pass
}
// End Tasmota patch
if(_client->connected()) {
// Start Tasmota patch
// A null client id would crash the later strnlen()/writeString(). MQTT allows a
// zero-length id (with clean session) but not a null pointer.
if (id == nullptr) {
return false;
}
// Reject a Will with an out-of-range QoS before it is shifted into the CONNECT
// flags byte, and a Will topic without a message.
if (willTopic != nullptr) {
if (willQos > 2 || willMessage == nullptr) {
return false;
}
}
// End Tasmota patch
if (_client->connected()) {
result = 1;
} else {
@@ -231,10 +257,10 @@ boolean PubSubClient::connect(const char *id, const char *user, const char *pass
v = v|0x02;
}
if(user != NULL) {
if (user != NULL) {
v = v|0x80;
if(pass != NULL) {
if (pass != NULL) {
v = v|(0x80>>1);
}
}
@@ -252,10 +278,10 @@ boolean PubSubClient::connect(const char *id, const char *user, const char *pass
length = writeString(willMessage,this->buffer,length);
}
if(user != NULL) {
if (user != NULL) {
CHECK_STRING_LENGTH(length,user)
length = writeString(user,this->buffer,length);
if(pass != NULL) {
if (pass != NULL) {
CHECK_STRING_LENGTH(length,pass)
length = writeString(pass,this->buffer,length);
}
@@ -281,7 +307,11 @@ boolean PubSubClient::connect(const char *id, const char *user, const char *pass
uint8_t llen;
uint32_t len = readPacket(&llen);
if (len == 4) {
// Start Tasmota patch
// Only accept a well-formed CONNACK: exact packet type (0x20), Remaining Length 2.
// Previously any 4-byte frame ending in 0 was treated as a successful connection.
if (len == 4 && (buffer[0] == MQTTCONNACK) && (buffer[1] == 2)) {
// End Tasmota patch
if (buffer[3] == 0) {
lastInActivity = millis();
pingOutstanding = false;
@@ -310,7 +340,7 @@ boolean PubSubClient::readByte(uint8_t * result) {
// End Tasmota patch
uint32_t previousMillis = millis();
while(!_client->available()) {
while (!_client->available()) {
// Start Tasmota patch
// yield();
@@ -319,7 +349,7 @@ boolean PubSubClient::readByte(uint8_t * result) {
// End Tasmota patch
uint32_t currentMillis = millis();
if(currentMillis - previousMillis >= ((int32_t) this->socketTimeout * 1000)){
if (currentMillis - previousMillis >= ((int32_t) this->socketTimeout * 1000)) {
return false;
}
}
@@ -328,10 +358,10 @@ boolean PubSubClient::readByte(uint8_t * result) {
}
// reads a byte into result[*index] and increments index
boolean PubSubClient::readByte(uint8_t * result, uint16_t * index){
boolean PubSubClient::readByte(uint8_t * result, uint16_t * index) {
uint16_t current_index = *index;
uint8_t * write_address = &(result[current_index]);
if(readByte(write_address)){
if (readByte(write_address)) {
*index = current_index + 1;
return true;
}
@@ -340,7 +370,7 @@ boolean PubSubClient::readByte(uint8_t * result, uint16_t * index){
uint32_t PubSubClient::readPacket(uint8_t* lengthLength) {
uint16_t len = 0;
if(!readByte(this->buffer, &len)) return 0;
if (!readByte(this->buffer, &len)) { return 0; }
bool isPublish = (this->buffer[0]&0xF0) == MQTTPUBLISH;
uint32_t multiplier = 1;
uint32_t length = 0;
@@ -355,7 +385,7 @@ uint32_t PubSubClient::readPacket(uint8_t* lengthLength) {
_client->stop();
return 0;
}
if(!readByte(&digit)) return 0;
if (!readByte(&digit)) { return 0; }
this->buffer[len++] = digit;
length += (digit & 127) * multiplier;
multiplier <<=7; //multiplier *= 128
@@ -368,10 +398,37 @@ uint32_t PubSubClient::readPacket(uint8_t* lengthLength) {
*lengthLength = len-1;
// Start Tasmota patch (DoS mitigation + sentinel byte)
// In non-stream mode, if the declared packet cannot fit the buffer while leaving
// at least one spare byte (needed by consumers that NUL-terminate at buffer[len]),
// close the connection immediately instead of draining the whole Remaining Length
// byte-by-byte. Draining a large or trickle-fed body would block the event loop.
// total wire packet size = fixed header (1) + length bytes (llen) + Remaining Length
uint32_t total_packet = (uint32_t)1 + (uint32_t)(*lengthLength) + length;
// Configurable hard cap, independent of buffer allocation (0 = disabled).
// Applies to both stream and non-stream mode: a larger packet is refused and the
// connection closed rather than draining/streaming an unbounded body.
if (this->maxIncomingPacketSize != 0 && total_packet > this->maxIncomingPacketSize) {
_state = MQTT_DISCONNECTED;
_client->stop();
return 0;
}
// Non-stream: the packet must also fit the buffer, leaving one spare byte for a
// downstream NUL terminator. Draining an oversized body would block the
// event loop, so close immediately instead.
if (!this->stream && total_packet >= (uint32_t)this->bufferSize) {
_state = MQTT_DISCONNECTED;
_client->stop();
return 0;
}
// End Tasmota patch
if (isPublish) {
// Read in topic length to calculate bytes to skip over for Stream writing
if(!readByte(this->buffer, &len)) return 0;
if(!readByte(this->buffer, &len)) return 0;
if (!readByte(this->buffer, &len)) { return 0; }
if (!readByte(this->buffer, &len)) { return 0; }
skip = (this->buffer[*lengthLength+1]<<8)+this->buffer[*lengthLength+2];
start = 2;
if (this->buffer[0]&MQTTQOS1) {
@@ -382,7 +439,7 @@ uint32_t PubSubClient::readPacket(uint8_t* lengthLength) {
uint32_t idx = len;
for (uint32_t i = start;i<length;i++) {
if(!readByte(&digit)) return 0;
if (!readByte(&digit)) { return 0; }
if (this->stream) {
if (isPublish && idx-*lengthLength-2>skip) {
this->stream->write(digit);
@@ -394,11 +451,22 @@ uint32_t PubSubClient::readPacket(uint8_t* lengthLength) {
len++;
}
idx++;
// Start Tasmota patch
// Periodically yield while consuming a large (typically streamed) body so the
// watchdog is not starved. Non-stream packets are < bufferSize so this rarely fires.
if ((i & 0x3FF) == 0) { delay(0); }
// End Tasmota patch
}
if (!this->stream && idx > this->bufferSize) {
// Start Tasmota patch (sentinel byte)
// Use >= so an exact-buffer packet is also ignored, guaranteeing a spare byte for
// downstream NUL-termination at buffer[len]. Non-stream oversized packets are
// normally already rejected above; this is defense-in-depth.
if (!this->stream && idx >= this->bufferSize) {
len = 0; // This will cause the packet to be ignored.
}
// End Tasmota patch
return len;
}
@@ -443,7 +511,29 @@ boolean PubSubClient::loop() {
// Start Tasmota patch
// Observed heap corruption in some cases since v10.0.0
// Also see https://github.com/knolleary/pubsubclient/pull/843
if (llen+3+tl>this->bufferSize) {
//
// Decode QoS from bits 1-2 and reject unsupported/illegal values
// (2 = unsupported, 3 = protocol violation) before parsing.
// Validate the topic and (for QoS 1) the message-id bytes against the
// number of bytes actually received (len), not just the buffer capacity.
// This prevents out-of-bounds reads and payload-length underflow when a
// broker sends a topic length that overruns the packet.
uint8_t qos = (this->buffer[0] & 0x06) >> 1;
if (qos > 1) {
_state = MQTT_DISCONNECTED;
_client->stop();
return false;
}
// Bytes required before the payload starts:
// fixed header (1) + length bytes (llen) + topic length field (2)
// + topic (tl) + message id for QoS 1 (2)
uint32_t header_len = (uint32_t)llen + 3 + tl + (qos ? 2 : 0);
// Topic must be non-empty, the header must fit within the received
// bytes, and (redundantly) within the buffer with room for the NUL.
if ((tl == 0) ||
(header_len > (uint32_t)len) ||
((uint32_t)llen + 3 + tl > (uint32_t)this->bufferSize)) {
_state = MQTT_DISCONNECTED;
_client->stop();
return false;
@@ -454,7 +544,7 @@ boolean PubSubClient::loop() {
this->buffer[llen+2+tl] = 0; /* end the topic as a 'C' string with \x00 */
char *topic = (char*) this->buffer+llen+2;
// msgId only present for QOS>0
if ((this->buffer[0]&0x06) == MQTTQOS1) {
if (qos == 1) {
msgId = (this->buffer[llen+3+tl]<<8)+this->buffer[llen+3+tl+1];
payload = this->buffer+llen+3+tl+2;
callback(topic,payload,len-llen-3-tl-2);
@@ -509,6 +599,11 @@ boolean PubSubClient::publish(const char* topic, const uint8_t* payload, unsigne
boolean PubSubClient::publish(const char* topic, const uint8_t* payload, unsigned int plength, boolean retained) {
if (connected()) {
// Start Tasmota patch
if ((topic == nullptr) || ((payload == nullptr) && (plength != 0))) {
return false;
}
// End Tasmota patch
if (this->bufferSize < MQTT_MAX_HEADER_SIZE + 2+strnlen(topic, this->bufferSize) + plength) {
// Too long
return false;
@@ -552,7 +647,16 @@ boolean PubSubClient::publish_P(const char* topic, const uint8_t* payload, unsig
return false;
}
// Start Tasmota patch
if ((topic == nullptr) || ((payload == nullptr) && (plength != 0))) {
return false;
}
tlen = strnlen(topic, this->bufferSize);
// Header + topic must fit the working buffer (payload itself is streamed, not buffered).
if ((size_t)MQTT_MAX_HEADER_SIZE + 2 + tlen > this->bufferSize) {
return false;
}
// End Tasmota patch
header = MQTTPUBLISH;
if (retained) {
@@ -568,7 +672,7 @@ boolean PubSubClient::publish_P(const char* topic, const uint8_t* payload, unsig
}
this->buffer[pos++] = digit;
llen++;
} while(len>0);
} while (len>0);
pos = writeString(topic,this->buffer,pos);
@@ -593,6 +697,21 @@ boolean PubSubClient::publish_P(const char* topic, const uint8_t* payload, unsig
boolean PubSubClient::beginPublish(const char* topic, unsigned int plength, boolean retained) {
if (connected()) {
// Start Tasmota patch
if (topic == nullptr) {
return false;
}
// Header + topic must fit the working buffer (writeString starts at MQTT_MAX_HEADER_SIZE).
size_t tlen = strnlen(topic, this->bufferSize);
if ((size_t)MQTT_MAX_HEADER_SIZE + 2 + tlen > this->bufferSize) {
return false;
}
// Remaining Length now uses 32-bit accounting (buildHeader emits up to 4 bytes),
// so payloads above 65535 bytes are framed correctly instead of being truncated.
uint32_t remaining_length = (uint32_t)plength + 2 + tlen;
// End Tasmota patch
// Send the header and variable length field
uint16_t length = MQTT_MAX_HEADER_SIZE;
length = writeString(topic,this->buffer,length);
@@ -600,7 +719,7 @@ boolean PubSubClient::beginPublish(const char* topic, unsigned int plength, bool
if (retained) {
header |= 1;
}
size_t hlen = buildHeader(header, this->buffer, plength+length-MQTT_MAX_HEADER_SIZE);
size_t hlen = buildHeader(header, this->buffer, remaining_length);
uint16_t rc = _client->write(this->buffer+(MQTT_MAX_HEADER_SIZE-hlen),length-(MQTT_MAX_HEADER_SIZE-hlen));
// Start Tasmota patch
@@ -658,12 +777,17 @@ size_t PubSubClient::write(const uint8_t *buffer, size_t size) {
}
size_t PubSubClient::buildHeader(uint8_t header, uint8_t* buf, uint16_t length) {
// Start Tasmota patch
// length is the MQTT Remaining Length and must be 32-bit: encoded as 1-4 bytes
// (max 268435455). A uint16_t parameter previously truncated payloads > 65535,
// desynchronising the connection.
size_t PubSubClient::buildHeader(uint8_t header, uint8_t* buf, uint32_t length) {
uint8_t lenBuf[4];
uint8_t llen = 0;
uint8_t digit;
uint8_t pos = 0;
uint16_t len = length;
uint32_t len = length;
// End Tasmota patch
do {
digit = len & 127; //digit = len %128
@@ -673,7 +797,11 @@ size_t PubSubClient::buildHeader(uint8_t header, uint8_t* buf, uint16_t length)
}
lenBuf[pos++] = digit;
llen++;
} while(len>0);
// Start Tasmota patch
// Remaining Length is encoded in at most 4 bytes; bound the loop to the lenBuf[4]
// size so an out-of-range length can never overflow lenBuf[] or underflow buf[4-llen].
} while (len>0 && llen<4);
// End Tasmota patch
buf[4-llen] = header;
for (int i=0;i<llen;i++) {
@@ -691,7 +819,7 @@ boolean PubSubClient::write(uint8_t header, uint8_t* buf, uint16_t length) {
uint16_t bytesRemaining = length+hlen; //Match the length type
uint8_t bytesToWrite;
boolean result = true;
while((bytesRemaining > 0) && result) {
while ((bytesRemaining > 0) && result) {
bytesToWrite = (bytesRemaining > MQTT_MAX_TRANSFER_SIZE)?MQTT_MAX_TRANSFER_SIZE:bytesRemaining;
rc = _client->write(writeBuf,bytesToWrite);
result = (rc == bytesToWrite);
@@ -719,17 +847,22 @@ boolean PubSubClient::subscribe(const char* topic) {
}
boolean PubSubClient::subscribe(const char* topic, uint8_t qos) {
size_t topicLength = strnlen(topic, this->bufferSize);
if (topic == 0) {
// Start Tasmota patch
// Check for null before calling strnlen(). The SUBSCRIBE packet also writes a
// trailing QoS byte, so it needs one more byte than UNSUBSCRIBE: the buffer must
// hold header(5) + msgId(2) + topic-length(2) + topic + qos(1) = 10 + topicLength.
if (topic == nullptr) {
return false;
}
size_t topicLength = strnlen(topic, this->bufferSize);
if (qos > 1) {
return false;
}
if (this->bufferSize < 9 + topicLength) {
if (this->bufferSize < 10 + topicLength) {
// Too long
return false;
}
// End Tasmota patch
if (connected()) {
// Leave room in the buffer for header and variable length field
uint16_t length = MQTT_MAX_HEADER_SIZE;
@@ -747,14 +880,17 @@ boolean PubSubClient::subscribe(const char* topic, uint8_t qos) {
}
boolean PubSubClient::unsubscribe(const char* topic) {
size_t topicLength = strnlen(topic, this->bufferSize);
if (topic == 0) {
// Start Tasmota patch
// Check for null before calling strnlen().
if (topic == nullptr) {
return false;
}
size_t topicLength = strnlen(topic, this->bufferSize);
if (this->bufferSize < 9 + topicLength) {
// Too long
return false;
}
// End Tasmota patch
if (connected()) {
uint16_t length = MQTT_MAX_HEADER_SIZE;
nextMsgId++;
@@ -859,17 +995,17 @@ PubSubClient& PubSubClient::setCallback(MQTT_CALLBACK_SIGNATURE) {
return *this;
}
PubSubClient& PubSubClient::setClient(Client& client){
PubSubClient& PubSubClient::setClient(Client& client) {
this->_client = &client;
return *this;
}
PubSubClient& PubSubClient::setStream(Stream& stream){
PubSubClient& PubSubClient::setStream(Stream& stream) {
this->stream = &stream;
return *this;
}
int PubSubClient::state() {
int PubSubClient::state() const {
return this->_state;
}
@@ -878,23 +1014,33 @@ boolean PubSubClient::setBufferSize(uint16_t size) {
// Cannot set it back to 0
return false;
}
// Start Tasmota patch
// Commit bufferSize only after a successful (re)allocation. Previously bufferSize
// was set even when malloc() failed, leaving the object reporting a non-zero
// capacity while buffer == nullptr, which later operations would dereference.
if (this->bufferSize == 0) {
this->buffer = (uint8_t*)malloc(size);
} else {
uint8_t* newBuffer = (uint8_t*)realloc(this->buffer, size);
if (newBuffer != NULL) {
this->buffer = newBuffer;
} else {
uint8_t* newBuffer = (uint8_t*)malloc(size);
if (newBuffer == NULL) {
return false;
}
this->buffer = newBuffer;
} else {
uint8_t* newBuffer = (uint8_t*)realloc(this->buffer, size);
if (newBuffer == NULL) {
return false;
}
this->buffer = newBuffer;
}
this->bufferSize = size;
return (this->buffer != NULL);
return true;
// End Tasmota patch
}
uint16_t PubSubClient::getBufferSize() {
uint16_t PubSubClient::getBufferSize() const {
return this->bufferSize;
}
PubSubClient& PubSubClient::setKeepAlive(uint16_t keepAlive) {
this->keepAlive = keepAlive;
return *this;
@@ -1,7 +1,18 @@
/*
PubSubClient.h - A simple client for MQTT.
Nick O'Leary
http://knolleary.net
PubSubClient.h - A simple client for MQTT.
TasmotaPubSub - Tasmota fork of the PubSubClient MQTT library.
Original author:
Nick O'Leary - http://knolleary.net
Tasmota fork maintained by Theo Arends and the Tasmota contributors.
SPDX-FileCopyrightText: 2008-2020 Nicholas O'Leary
SPDX-FileCopyrightText: 2020-2025 Theo Arends and Tasmota contributors
SPDX-License-Identifier: MIT
Tasmota-specific changes are marked with "Start Tasmota patch" comments.
*/
#ifndef PubSubClient_h
@@ -107,7 +118,7 @@ private:
// Returns the size of the header
// Note: the header is built at the end of the first MQTT_MAX_HEADER_SIZE bytes, so will start
// (MQTT_MAX_HEADER_SIZE - <returned size>) bytes into the buffer
size_t buildHeader(uint8_t header, uint8_t* buf, uint16_t length);
size_t buildHeader(uint8_t header, uint8_t* buf, uint32_t length); // Tasmota patch: 32-bit Remaining Length
IPAddress ip;
// Start Tasmota patch
@@ -119,6 +130,13 @@ private:
uint16_t port;
Stream* stream;
int _state;
// Start Tasmota patch
// Hard cap on the total size (fixed header + Remaining Length) of an inbound packet
// that the client will accept, independent of the working buffer allocation.
// 0 = disabled: fall back to the buffer size for non-stream, unbounded for stream.
uint32_t maxIncomingPacketSize = 0;
// End Tasmota patch
public:
PubSubClient();
PubSubClient(Client& client);
@@ -137,6 +155,13 @@ public:
~PubSubClient();
// Start Tasmota patch
// The class owns and frees `buffer`; a copy would share ownership and lead to a
// double-free / use-after-free. Copying is therefore disabled.
PubSubClient(const PubSubClient&) = delete;
PubSubClient& operator=(const PubSubClient&) = delete;
// End Tasmota patch
PubSubClient& setServer(IPAddress ip, uint16_t port);
PubSubClient& setServer(uint8_t * ip, uint16_t port);
PubSubClient& setServer(const char * domain, uint16_t port);
@@ -147,7 +172,15 @@ public:
PubSubClient& setSocketTimeout(uint16_t timeout);
boolean setBufferSize(uint16_t size);
uint16_t getBufferSize();
uint16_t getBufferSize() const;
// Start Tasmota patch
// Set the maximum accepted inbound packet size (total wire bytes). 0 disables the
// cap. When set, a larger declared packet closes the connection instead of being
// drained/streamed. Useful to bound stream-mode input and tune DoS resistance.
PubSubClient& setMaxIncomingPacketSize(uint32_t size) { this->maxIncomingPacketSize = size; return *this; }
uint32_t getMaxIncomingPacketSize() const { return this->maxIncomingPacketSize; }
// End Tasmota patch
boolean connect(const char* id);
boolean connect(const char* id, const char* user, const char* pass);
@@ -189,7 +222,7 @@ public:
boolean unsubscribe(const char* topic);
boolean loop();
boolean connected();
int state();
int state() const;
};
@@ -0,0 +1,8 @@
# Build artifacts for the host-based doctest test system
build/
# Legacy artifacts (harmless if absent)
.build
tmpbin
logs
*.pyc
+105
View File
@@ -0,0 +1,105 @@
# Makefile for the TasmotaPubSub host-based doctest test system.
#
# Builds the *unmodified* library under test (../src/PubSubClient.cpp) together
# with the Arduino environment shim, the test support library (src/lib/*.cpp),
# and every test translation unit (src/*.cpp -> test_main.cpp + *_test.cpp)
# into a single doctest binary: build/pubsub_tests.
#
# Usage:
# make # or `make all` - build build/pubsub_tests
# make test # build + run the whole binary
# make baseline # build + run only the baseline suite (-ts=baseline)
# make hardening # build + run only the hardening suite (-ts=hardening)
# make clean # remove the build/ directory
#
# make SANITIZE=0 ... # build without ASan/UBSan
# make test TS=-ts=baseline ARGS=--no-colors # extra pass-through flags
# --- Toolchain -------------------------------------------------------------
# CXX defaults to `c++` so this works with both Apple clang and GNU g++.
CXX ?= c++
# --- Layout ----------------------------------------------------------------
BUILDDIR := build
OBJDIR := $(BUILDDIR)/obj
BIN := $(BUILDDIR)/pubsub_tests
# Library under test (never modified).
LIB_SRC := ../src/PubSubClient.cpp
# Arduino shim + test support library. Wildcard so future .cpp files under
# src/lib are picked up automatically.
SHIM_SRCS := $(wildcard src/lib/*.cpp)
# Test translation units: test_main.cpp (task 4.2) + all *_test.cpp.
# Wildcard so the build does not fail before any test sources exist and so
# future *_test.cpp files are compiled automatically.
TEST_SRCS := $(wildcard src/*.cpp)
# --- Object files ----------------------------------------------------------
# Objects live under build/obj (mirrored subtrees) to keep the source tree clean.
LIB_OBJ := $(OBJDIR)/lib/PubSubClient.o
SHIM_OBJS := $(patsubst src/lib/%.cpp,$(OBJDIR)/shim/%.o,$(SHIM_SRCS))
TEST_OBJS := $(patsubst src/%.cpp,$(OBJDIR)/test/%.o,$(TEST_SRCS))
OBJS := $(LIB_OBJ) $(SHIM_OBJS) $(TEST_OBJS)
# --- Flags -----------------------------------------------------------------
# Sanitizers ON by default; toggle off with `make SANITIZE=0`.
SANITIZE ?= 1
ifeq ($(SANITIZE),0)
SAN_FLAGS :=
else
SAN_FLAGS := -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all
endif
# Include order matters: src/lib FIRST so the Arduino shim headers shadow any
# platform headers, then the library headers in ../src.
INCLUDES := -I src/lib -I ../src
CXXFLAGS := -std=c++17 -Wall -Wextra -DESP32 $(INCLUDES) $(SAN_FLAGS)
LDFLAGS := $(SAN_FLAGS)
# Suite selection / extra doctest flag pass-through.
# TS - suite selector, e.g. TS=-ts=baseline
# ARGS - any additional doctest flags
TS ?=
ARGS ?=
# --- Targets ---------------------------------------------------------------
.PHONY: all test baseline hardening clean
.DEFAULT_GOAL := all
all: $(BIN)
$(BIN): $(OBJS)
@mkdir -p $(@D)
$(CXX) $(LDFLAGS) $(OBJS) -o $@
# The library under test is compiled with AllocShim.h force-included so its
# malloc()/realloc() calls route through the test allocation interposer
# (F-07 / Requirement 13.2). This force-include is scoped to THIS object only,
# so no other TU (shim, doctest, tests) is affected. The library source itself
# is never modified.
$(LIB_OBJ): $(LIB_SRC)
@mkdir -p $(@D)
$(CXX) $(CXXFLAGS) -include src/lib/AllocShim.h -c $< -o $@
$(OBJDIR)/shim/%.o: src/lib/%.cpp
@mkdir -p $(@D)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(OBJDIR)/test/%.o: src/%.cpp
@mkdir -p $(@D)
$(CXX) $(CXXFLAGS) -c $< -o $@
test: $(BIN)
./$(BIN) $(TS) $(ARGS)
baseline: $(BIN)
./$(BIN) -ts=baseline $(ARGS)
hardening: $(BIN)
./$(BIN) -ts=hardening $(ARGS)
clean:
rm -rf $(BUILDDIR)
+174
View File
@@ -0,0 +1,174 @@
# TasmotaPubSub Host Test System
A host-based (no ESP32/ESP8266 hardware required) unit-test system for the
TasmotaPubSub `PubSubClient` MQTT library. It compiles the **unmodified**
library (`../src/PubSubClient.cpp` / `.h`) against a small host-side Arduino
environment shim, drives it through a scriptable mock transport, and validates
its observable MQTT 3.1.1 wire behavior with the
[doctest](https://github.com/doctest/doctest) single-header framework.
All assertions go through the public API and the recorded/scripted wire bytes of
the mock transport — never through private members — so the suite stays
re-runnable unchanged across future refactors of the library (including a later
MQTT 5 migration).
> The library under test is never modified. If a test fails, the fix belongs in
> the test (or the finding gets hardened in the library separately) — not in the
> library source to make a test pass.
## Layout
```
tests/
Makefile # single-binary build + run targets
README.md # this file
src/
test_main.cpp # the one TU that defines doctest's main()
*_test.cpp # test suites (baseline + hardening cases)
lib/ # Arduino shim + test support library
doctest.h # vendored single-header framework
Arduino.h ... # host Arduino environment shim (Client/Stream/String/...)
TestClock.* # virtual millis()/delay() clock
MockClient.* # scriptable mock transport (inbound/outbound, faults)
MockStream.* # stream-mode mock
MqttPacket.* # MQTT packet builder + parser
CallbackContractAdapter.* # Tasmota MqttDataHandler NUL-write contract
AllocShim.h # malloc/realloc interposer (F-07 alloc-failure tests)
FindingStatus.h # expected-fail registry (see below)
build/ # generated; git-ignored
```
## Requirements
- A C++17 host compiler: Apple `clang`/`clang++` or GNU `g++`. The Makefile
defaults to `CXX = c++`.
- `make`.
No third-party dependencies: doctest is vendored at `src/lib/doctest.h`.
## Building and running
Run everything from the `tests/` directory.
```sh
make # build build/pubsub_tests (default target: all)
make test # build + run the whole binary
make baseline # build + run only the Baseline suite (-ts=baseline)
make hardening # build + run only the Hardening suite (-ts=hardening)
make clean # remove the build/ directory
```
The build produces a single binary, `build/pubsub_tests`, which links the
unmodified `PubSubClient.cpp`, the Arduino shim, the test support library, and
every `src/*_test.cpp`. A compilation failure yields a nonzero exit status and
produces no binary.
## Sanitizers (the `SANITIZE` toggle)
AddressSanitizer and UndefinedBehaviorSanitizer are **on by default**
(`-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`).
Any memory or undefined-behavior violation aborts the run with a nonzero exit
status. This is the primary detector for the memory-safety findings (F-01, F-02,
F-08, and the callback-contract boundary).
Turn sanitizers off with:
```sh
make SANITIZE=0 # build without ASan/UBSan
make SANITIZE=0 test # ... and run
```
## Suite selection (`-ts=`)
Every test case is tagged into exactly one doctest test suite: `baseline` or
`hardening`. The `make baseline` / `make hardening` targets pass the matching
`-ts=` selector to the binary. You can also select suites (or any other doctest
flag) directly:
```sh
./build/pubsub_tests -ts=baseline # only baseline cases
./build/pubsub_tests -ts=hardening # only hardening cases
./build/pubsub_tests # everything
./build/pubsub_tests -ts=baseline -tc="*publish*" # filter within a suite
```
You can also pass extra flags through the Makefile:
```sh
make test TS=-ts=baseline ARGS=--no-colors
make hardening ARGS=-s # -s = show successful assertions
```
## Baseline vs Hardening
- **Baseline suite** — characterization tests that lock in the library's
*current* observable MQTT 3.1.1 behavior across the full public API and the
wire protocol (CONNECT/CONNACK, PUBLISH/PUBACK, SUBSCRIBE/SUBACK,
UNSUBSCRIBE/UNSUBACK, PINGREQ/PINGRESP, DISCONNECT). These are a non-regression
safety net and **must all pass** against the current library. `make baseline`
is expected to be entirely green.
- **Hardening suite** — tests that encode the *correct/hardened* behavior derived
from the static-analysis findings F-01..F-12. The current fork is already
partially hardened, so most hardening cases pass today. The genuinely open
findings are marked **expected-to-fail** (see the registry below) so their
failure is reported as *expected* and does not fail the run. `make hardening`
is expected to exit zero, reporting only the designated expected failures and
**no unexpected passes**.
### Current expected-fail findings
As of the last empirical verification (task 12.1), the open findings — the ones
whose hardening cases are expected to fail against the current library — are:
| Finding | Area | Hardening case(s) |
|---|---|---|
| F-05 | Partial transport write reuses a desynchronized connection | `streaming_test.cpp`, `findings_test.cpp` |
| F-10 | `subscribe` ignores the SUBACK return code (reports success unconditionally) | `subscribe_test.cpp`, `findings_test.cpp` |
| F-11 | `disconnect()` with no argument sends no DISCONNECT packet | `connect_test.cpp`, `findings_test.cpp` |
| F-03 (deadline) | Trickle-fed inbound bytes are bounded only by a per-byte timeout, not a packet-wide deadline | `buffer_test.cpp`, `findings_test.cpp` |
All other findings (F-01, F-02, F-04, F-06, F-07, F-08, F-09, and the F-03
oversized prompt-close aspect) are already hardened and their cases pass.
## Expected-fail registry workflow (`FindingStatus.h`)
The expected-fail state of each finding lives in a single header,
`src/lib/FindingStatus.h`. Each finding maps to a doctest test-case decorator via
`FINDING_MARKER(Fxx)`:
- `FINDING_OPEN``doctest::should_fail()` — the case is expected to fail
(library not yet hardened). A failure is reported as *expected* and does not
set a nonzero exit status. If such a case unexpectedly **passes**, doctest
reports an *unexpected pass* and fails the run — that is the signal that the
finding has been hardened.
- `FINDING_HARDENED``doctest::skip(false)` — a no-op decorator: the case still
runs and is expected to pass.
We deliberately use `should_fail()` and never `may_fail()`, because `may_fail()`
would hide an unexpected pass.
Hardening cases reference their finding like this:
```cpp
TEST_CASE("F-05 partial write disables reuse" * FINDING_MARKER(F05)) { ... }
```
### Flipping a finding when the library gets hardened
When the library is patched so a previously-open finding now behaves correctly,
`make hardening` will surface its case as an **unexpected pass** (and fail the
run). To resolve it, flip that finding's marker in `FindingStatus.h` — a
one-line edit:
```c
// before (open):
#define FINDING_MARKER_F05 FINDING_OPEN
// after (hardened):
#define FINDING_MARKER_F05 FINDING_HARDENED
```
Then re-run `make hardening` and confirm it is green again with no unexpected
passes. The reverse edit (`FINDING_HARDENED``FINDING_OPEN`) applies if a
regression re-opens a finding. Always confirm the marker matches the empirically
observed behavior of the current library before committing.
@@ -0,0 +1,260 @@
/*
MockClient_test.cpp - Unit checks for the MockClient core (task 6.1).
These verify the scriptable mock transport itself (not the library under
test): inbound read/consume, exhaustion semantics, outbound capture, and
connection / close tracking. They are tagged into the baseline suite so they
run as part of the standard non-regression pass.
*/
#include <cstdint>
#include <vector>
#include "doctest.h"
#include "Client.h"
#include "IPAddress.h"
#include "MockClient.h"
#include "TestClock.h"
TEST_SUITE("baseline") {
TEST_CASE("MockClient reads scripted inbound bytes in order and consumes them") {
MockClient c;
c.pushInbound({0x10, 0x20, 0x30});
CHECK(c.available() == 3);
CHECK(c.peek() == 0x10); // peek does not consume
CHECK(c.available() == 3);
CHECK(c.read() == 0x10);
CHECK(c.read() == 0x20);
CHECK(c.available() == 1);
CHECK(c.read() == 0x30);
// Exhausted queue.
CHECK(c.available() == 0);
CHECK(c.read() == -1);
CHECK(c.peek() == -1);
}
TEST_CASE("MockClient read(buf,size) consumes up to size and reports the count") {
MockClient c;
c.pushInbound({1, 2, 3, 4, 5});
uint8_t buf[3] = {0, 0, 0};
int n = c.read(buf, 3);
CHECK(n == 3);
CHECK(buf[0] == 1);
CHECK(buf[1] == 2);
CHECK(buf[2] == 3);
CHECK(c.available() == 2);
// Requesting more than remains returns only what is left.
uint8_t rest[8] = {0};
int m = c.read(rest, 8);
CHECK(m == 2);
CHECK(rest[0] == 4);
CHECK(rest[1] == 5);
CHECK(c.available() == 0);
// Further reads on an empty queue yield nothing.
CHECK(c.read(rest, 8) == 0);
}
TEST_CASE("MockClient pushInbound appends across multiple calls") {
MockClient c;
c.pushInbound({0xAA});
c.pushInbound({0xBB, 0xCC});
CHECK(c.available() == 3);
CHECK(c.read() == 0xAA);
CHECK(c.read() == 0xBB);
CHECK(c.read() == 0xCC);
CHECK(c.available() == 0);
}
TEST_CASE("MockClient clearInbound drops unconsumed bytes") {
MockClient c;
c.pushInbound({1, 2, 3});
CHECK(c.read() == 1);
c.clearInbound();
CHECK(c.available() == 0);
CHECK(c.read() == -1);
}
TEST_CASE("MockClient captures outbound writes (single and buffer)") {
MockClient c;
CHECK(c.write(uint8_t(0x30)) == 1);
const uint8_t payload[] = {0x00, 0x03, 'a', 'b', 'c'};
CHECK(c.write(payload, sizeof(payload)) == sizeof(payload));
const std::vector<uint8_t> expected = {0x30, 0x00, 0x03, 'a', 'b', 'c'};
CHECK(c.outbound() == expected);
c.clearOutbound();
CHECK(c.outbound().empty());
}
TEST_CASE("MockClient write(nullptr) records nothing") {
MockClient c;
CHECK(c.write(static_cast<const uint8_t*>(nullptr), 4) == 0);
CHECK(c.outbound().empty());
}
TEST_CASE("MockClient connection state is settable and connect() records the endpoint") {
MockClient c;
CHECK(c.connected() == 0);
CHECK(static_cast<bool>(c) == false);
c.setConnected(true);
CHECK(c.connected() == 1);
CHECK(static_cast<bool>(c) == true);
// Default connect result is success (1) and brings the socket up.
MockClient d;
CHECK(d.connectCalled() == false);
CHECK(d.connect("broker.example", 1883) == 1);
CHECK(d.connectCalled() == true);
CHECK(d.lastHost() == "broker.example");
CHECK(d.lastPort() == 1883);
CHECK(d.connected() == 1);
// A scripted failure result does not bring the socket up.
MockClient e;
e.setConnectResult(-2);
IPAddress ip(192, 168, 1, 50);
CHECK(e.connect(ip, 8883) == -2);
CHECK(e.lastPort() == 8883);
CHECK(e.lastIp() == ip);
CHECK(e.connected() == 0);
}
TEST_CASE("MockClient tracks stop() and flush()") {
MockClient c;
c.setConnected(true);
CHECK(c.stopCalled() == false);
CHECK(c.flushCalled() == false);
c.flush();
CHECK(c.flushCalled() == true);
CHECK(c.flushCount() == 1);
c.stop();
CHECK(c.stopCalled() == true);
CHECK(c.stopCount() == 1);
CHECK(c.connected() == 0); // stop() clears the connection
CHECK(static_cast<bool>(c) == false);
c.stop();
CHECK(c.stopCount() == 2);
}
TEST_CASE("MockClient is usable through a Client* base pointer") {
MockClient c;
Client* base = &c;
c.pushInbound({0x42});
CHECK(base->available() == 1);
CHECK(base->read() == 0x42);
CHECK(base->write(uint8_t(0x99)) == 1);
CHECK(c.outbound().size() == 1);
CHECK(c.outbound()[0] == 0x99);
}
// --- Fault injection (task 6.2) ----------------------------------------
TEST_CASE("MockClient setWriteLimit truncates a single write to the limit") {
MockClient c;
c.setWriteLimit(3);
const uint8_t payload[] = {'a', 'b', 'c', 'd', 'e', 'f'};
// A single write is truncated: fewer than size, but >= 1.
size_t n = c.write(payload, sizeof(payload));
CHECK(n == 3);
CHECK(n < sizeof(payload));
CHECK(n >= 1);
// Only the accepted prefix is recorded.
const std::vector<uint8_t> expected = {'a', 'b', 'c'};
CHECK(c.outbound() == expected);
}
TEST_CASE("MockClient write limit makes progress across repeated writes") {
MockClient c;
c.setWriteLimit(2);
const uint8_t payload[] = {1, 2, 3, 4, 5};
// Emulate a caller resending the unaccepted tail until fully written.
size_t sent = 0;
int guard = 0;
while (sent < sizeof(payload) && guard++ < 100) {
size_t n = c.write(payload + sent, sizeof(payload) - sent);
CHECK(n >= 1); // always makes progress
CHECK(n <= 2); // never exceeds the limit
sent += n;
}
CHECK(sent == sizeof(payload));
const std::vector<uint8_t> expected = {1, 2, 3, 4, 5};
CHECK(c.outbound() == expected);
}
TEST_CASE("MockClient a write shorter than the limit is accepted whole") {
MockClient c;
c.setWriteLimit(8);
const uint8_t payload[] = {0xDE, 0xAD};
CHECK(c.write(payload, sizeof(payload)) == sizeof(payload));
const std::vector<uint8_t> expected = {0xDE, 0xAD};
CHECK(c.outbound() == expected);
}
TEST_CASE("MockClient trickle reveals bytes only as the virtual clock advances") {
TestClock::instance().reset(0);
MockClient c;
c.pushInbound({10, 20, 30, 40});
// Reveal one byte immediately, then one more per 100 virtual ms.
c.setTrickle(1, 100);
// Only the first byte is visible at t0.
CHECK(c.available() == 1);
CHECK(c.peek() == 10);
// Time has not advanced: still just one byte.
CHECK(c.available() == 1);
// Advancing by less than msPerReveal does not reveal more.
TestClock::instance().advance(50);
CHECK(c.available() == 1);
// Crossing a full step reveals the next byte.
TestClock::instance().advance(50); // total elapsed 100
CHECK(c.available() == 2);
TestClock::instance().advance(100); // total elapsed 200
CHECK(c.available() == 3);
// Enough time reveals all scripted bytes and no more.
TestClock::instance().advance(1000);
CHECK(c.available() == 4);
// Reads consume from the revealed prefix in order.
CHECK(c.read() == 10);
CHECK(c.read() == 20);
CHECK(c.available() == 2);
}
TEST_CASE("MockClient trickle bytesPerReveal>1 reveals in chunks") {
TestClock::instance().reset(0);
MockClient c;
c.pushInbound({1, 2, 3, 4, 5});
c.setTrickle(2, 100); // two bytes now, two more each 100 ms
CHECK(c.available() == 2);
TestClock::instance().advance(100);
CHECK(c.available() == 4);
TestClock::instance().advance(100);
CHECK(c.available() == 5); // saturates at the scripted total
}
}
@@ -0,0 +1,83 @@
/*
MockStream_test.cpp - Unit checks for the MockStream test double.
Verifies that bytes written to the stream (as the library does in stream-mode
PUBLISH payload routing via `stream->write(digit)`) are captured in order,
that both write overloads record correctly, and that the read side is inert.
*/
#include "doctest.h"
#include "MockStream.h"
TEST_SUITE("baseline") {
TEST_CASE("MockStream records single-byte writes in order") {
MockStream stream;
// Simulate the library routing payload digits one byte at a time.
CHECK(stream.write(static_cast<uint8_t>('h')) == 1);
CHECK(stream.write(static_cast<uint8_t>('i')) == 1);
CHECK(stream.write(static_cast<uint8_t>('!')) == 1);
const std::vector<uint8_t>& out = stream.written();
REQUIRE(out.size() == 3);
CHECK(out[0] == 'h');
CHECK(out[1] == 'i');
CHECK(out[2] == '!');
}
TEST_CASE("MockStream records buffer writes in order and appends") {
MockStream stream;
const uint8_t first[] = {0x01, 0x02, 0x03};
CHECK(stream.write(first, sizeof(first)) == sizeof(first));
// A subsequent single-byte write appends after the buffered bytes.
CHECK(stream.write(static_cast<uint8_t>(0x04)) == 1);
const uint8_t second[] = {0x05, 0x06};
CHECK(stream.write(second, sizeof(second)) == sizeof(second));
const std::vector<uint8_t> expected = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
CHECK(stream.written() == expected);
}
TEST_CASE("MockStream write(const char*) helper routes through the byte path") {
MockStream stream;
// Print::write(const char*) is a non-virtual helper; ensure it is not
// hidden and still lands in the recorded buffer in order.
CHECK(stream.write("ab") == 2);
const std::vector<uint8_t> expected = {'a', 'b'};
CHECK(stream.written() == expected);
}
TEST_CASE("MockStream clear() discards recorded bytes") {
MockStream stream;
stream.write(static_cast<uint8_t>(0xAA));
REQUIRE(stream.written().size() == 1);
stream.clear();
CHECK(stream.written().empty());
}
TEST_CASE("MockStream read side is inert") {
MockStream stream;
CHECK(stream.available() == 0);
CHECK(stream.read() == -1);
CHECK(stream.peek() == -1);
}
TEST_CASE("MockStream is usable as a Stream& (library construction seam)") {
MockStream stream;
Stream& asStream = stream;
// The library holds a `Stream*` and calls write(digit) through it.
asStream.write(static_cast<uint8_t>('X'));
REQUIRE(stream.written().size() == 1);
CHECK(stream.written()[0] == 'X');
}
}
@@ -0,0 +1,145 @@
/*
MqttPacket_test.cpp - Unit checks for the MqttPacket builder and the
Remaining Length codec (task 7.1).
These verify the fixture builder itself (not the library under test): each
builder must emit the exact wire byte layout of the MQTT 3.1.1 control packet,
and the Remaining Length encoder must match PubSubClient::buildHeader across
the 1..4 byte boundaries. Tagged into the baseline suite so they run as part
of the standard non-regression pass.
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "MockClient.h"
#include "MqttPacket.h"
TEST_SUITE("baseline") {
// --- Remaining Length codec (mirrors buildHeader) ----------------------
TEST_CASE("MqttPacket encodeRemainingLength matches the MQTT boundaries") {
using V = std::vector<uint8_t>;
// 1-byte range: 0..127.
CHECK(MqttPacket::encodeRemainingLength(0) == V{0x00});
CHECK(MqttPacket::encodeRemainingLength(1) == V{0x01});
CHECK(MqttPacket::encodeRemainingLength(127) == V{0x7F});
// 2-byte range: 128..16383.
CHECK(MqttPacket::encodeRemainingLength(128) == V{0x80, 0x01});
CHECK(MqttPacket::encodeRemainingLength(16383) == V{0xFF, 0x7F});
// 3-byte range: 16384..2097151.
CHECK(MqttPacket::encodeRemainingLength(16384) == V{0x80, 0x80, 0x01});
CHECK(MqttPacket::encodeRemainingLength(2097151) == V{0xFF, 0xFF, 0x7F});
// 4-byte range: 2097152..268435455.
CHECK(MqttPacket::encodeRemainingLength(2097152) == V{0x80, 0x80, 0x80, 0x01});
CHECK(MqttPacket::encodeRemainingLength(268435455) == V{0xFF, 0xFF, 0xFF, 0x7F});
}
TEST_CASE("MqttPacket encodeRemainingLength is bounded to four bytes") {
// Values above the MQTT maximum are clamped to a 4-byte encoding (never
// a fifth byte), exactly as the library's buildHeader loop is bounded.
CHECK(MqttPacket::encodeRemainingLength(0xFFFFFFFFu).size() == 4);
}
// --- Builders: exact byte layouts --------------------------------------
TEST_CASE("MqttPacket connack layout") {
using V = std::vector<uint8_t>;
CHECK(MqttPacket::connack(0).bytes() == V{0x20, 0x02, 0x00, 0x00});
CHECK(MqttPacket::connack(5).bytes() == V{0x20, 0x02, 0x00, 0x05});
// Session-present flag sets bit0 of the acknowledge flags byte.
CHECK(MqttPacket::connack(0, true).bytes() == V{0x20, 0x02, 0x01, 0x00});
}
TEST_CASE("MqttPacket publish QoS 0 has no packet identifier") {
using V = std::vector<uint8_t>;
// topic "a/b" (len 3), payload "hi" (len 2) => remaining length 7.
MqttPacket p = MqttPacket::publish("a/b", std::string("hi"));
const V expected = {
0x30, // MQTTPUBLISH, qos 0, no retain
0x07, // remaining length = 2 + 3 + 2
0x00, 0x03, // topic length
'a', '/', 'b', // topic
'h', 'i' // payload
};
CHECK(p.bytes() == expected);
}
TEST_CASE("MqttPacket publish retained flag sets bit0 of the fixed header") {
MqttPacket p = MqttPacket::publish("t", std::string(""), 0, true);
CHECK(p.bytes().at(0) == 0x31); // MQTTPUBLISH | retain
}
TEST_CASE("MqttPacket publish QoS 1 embeds the packet identifier after the topic") {
using V = std::vector<uint8_t>;
MqttPacket p = MqttPacket::publish("a/b", std::string("hi"), 1, false, 0x1234);
const V expected = {
0x32, // MQTTPUBLISH | qos1 (1<<1)
0x09, // remaining length = 2 + 3 + 2(msgId) + 2
0x00, 0x03, // topic length
'a', '/', 'b', // topic
0x12, 0x34, // packet identifier
'h', 'i' // payload
};
CHECK(p.bytes() == expected);
}
TEST_CASE("MqttPacket publish empty payload frames correctly") {
using V = std::vector<uint8_t>;
MqttPacket p = MqttPacket::publish("x", std::vector<uint8_t>{});
const V expected = {0x30, 0x03, 0x00, 0x01, 'x'};
CHECK(p.bytes() == expected);
}
TEST_CASE("MqttPacket publish frames a 2-byte remaining length past 127") {
// A payload that pushes the total remaining length above 127 must use a
// 2-byte Remaining Length. topic "t" (len 1) => body = 2 + 1 + plen.
const size_t plen = 200;
MqttPacket p = MqttPacket::publish("t", std::vector<uint8_t>(plen, 0xAB));
const uint32_t remaining = 2 + 1 + plen; // 203
REQUIRE(p.bytes().size() >= 2);
CHECK(p.bytes().at(0) == 0x30);
CHECK(p.bytes().at(1) == 0xCB); // 203 & 127 | 0x80 = 0xCB
CHECK(p.bytes().at(2) == 0x01); // 203 >> 7 = 1
// Total wire size = 1 (fixed) + 2 (remaining length) + body.
CHECK(p.bytes().size() == 1 + 2 + remaining);
}
TEST_CASE("MqttPacket ack/ping/disconnect layouts") {
using V = std::vector<uint8_t>;
CHECK(MqttPacket::puback(0x0102).bytes() == V{0x40, 0x02, 0x01, 0x02});
CHECK(MqttPacket::suback(0x0102, 0x01).bytes() == V{0x90, 0x03, 0x01, 0x02, 0x01});
CHECK(MqttPacket::unsuback(0x0102).bytes() == V{0xB0, 0x02, 0x01, 0x02});
CHECK(MqttPacket::pingreq().bytes() == V{0xC0, 0x00});
CHECK(MqttPacket::pingresp().bytes() == V{0xD0, 0x00});
CHECK(MqttPacket::disconnect().bytes() == V{0xE0, 0x00});
}
TEST_CASE("MqttPacket raw passes bytes through verbatim") {
using V = std::vector<uint8_t>;
const V adversarial = {0x30, 0xFF, 0xFF, 0xFF, 0x7F}; // huge declared length
CHECK(MqttPacket::raw(adversarial).bytes() == adversarial);
}
// --- Compatibility with MockClient::pushPacket -------------------------
TEST_CASE("MockClient pushPacket enqueues the packet's exact wire bytes") {
MockClient c;
MqttPacket connack = MqttPacket::connack(0);
c.pushPacket(connack);
CHECK(c.available() == static_cast<int>(connack.size()));
for (uint8_t expected : connack.bytes()) {
CHECK(c.read() == expected);
}
CHECK(c.available() == 0);
}
}
@@ -0,0 +1,260 @@
/*
MqttParser_test.cpp - Unit checks for the MqttParser decoder and the
structural PUBLISH validator (task 7.2).
Two kinds of checks, both tagged into the baseline suite:
1. Round-trip: build a control packet with the MqttPacket builder, decode it
with MqttParser, and assert the decoded fields equal the original inputs.
A subset also drives the *unmodified* PubSubClient to emit real outbound
bytes and decodes those, proving the parser reads genuine library output.
2. Validator: isStructurallyValidPublish accepts well-framed PUBLISH packets
and rejects truncated / malformed ones.
Every assertion goes through decoded wire bytes only (no private members), so
these stay durable across a future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
TEST_SUITE("baseline") {
// --- Remaining Length codec: decode round-trips encode -----------------
TEST_CASE("MqttParser decodeRemainingLength round-trips encodeRemainingLength") {
// Curated boundary values spanning the 1..4 byte encodings.
const uint32_t values[] = {
0, 1, 127, 128, 16383, 16384, 2097151, 2097152, 268435455
};
for (uint32_t n : values) {
const std::vector<uint8_t> enc = MqttPacket::encodeRemainingLength(n);
uint32_t decoded = 0;
size_t consumed = 0;
REQUIRE(MqttParser::decodeRemainingLength(enc, 0, decoded, consumed));
CHECK(decoded == n);
CHECK(consumed == enc.size());
CHECK(consumed <= 4);
}
}
TEST_CASE("MqttParser decodeRemainingLength rejects a 5-byte continuation and truncation") {
uint32_t value = 0;
size_t consumed = 0;
// Continuation bit still set after four bytes.
const std::vector<uint8_t> tooLong = {0x80, 0x80, 0x80, 0x80, 0x01};
CHECK_FALSE(MqttParser::decodeRemainingLength(tooLong, 0, value, consumed));
// Field truncated mid-continuation.
const std::vector<uint8_t> truncated = {0x80};
CHECK_FALSE(MqttParser::decodeRemainingLength(truncated, 0, value, consumed));
}
// --- Generic decode framing -------------------------------------------
TEST_CASE("MqttParser decode splits fixed header and validates framing") {
MqttPacket p = MqttPacket::publish("a/b", std::string("hi"), 1, true, 0x1234);
DecodedPacket d = MqttParser::decode(p.bytes());
REQUIRE(d.valid);
CHECK(d.type == static_cast<uint8_t>(MQTTPUBLISH));
CHECK(d.flags == 0x03); // qos1 (0x02) | retain (0x01)
CHECK(d.remainingLength == d.payload.size());
}
TEST_CASE("MqttParser decode rejects truncated and over-long frames") {
MqttPacket p = MqttPacket::publish("topic", std::string("payload"));
std::vector<uint8_t> bytes = p.bytes();
// Well-formed decodes cleanly.
CHECK(MqttParser::decode(bytes).valid);
// Drop the last byte: declared Remaining Length now exceeds trailing bytes.
std::vector<uint8_t> truncated = bytes;
truncated.pop_back();
CHECK_FALSE(MqttParser::decode(truncated).valid);
// Append a stray byte: trailing bytes now exceed declared Remaining Length.
std::vector<uint8_t> overlong = bytes;
overlong.push_back(0x00);
CHECK_FALSE(MqttParser::decode(overlong).valid);
// Empty input is invalid.
CHECK_FALSE(MqttParser::decode(std::vector<uint8_t>{}).valid);
}
// --- CONNECT round-trip (builder side + real library output) -----------
TEST_CASE("MqttParser decodePublish round-trips PUBLISH fields") {
struct Case { std::string topic; std::string payload; uint8_t qos; bool retain; uint16_t msgId; };
const Case cases[] = {
{"a/b", "hi", 0, false, 0},
{"sensor/temp", "23.5", 1, true, 0x0102},
{"x", "", 0, false, 0}, // empty payload
{"long/topic/name","payload-2", 1, false, 0xFFFF},
};
for (const Case& c : cases) {
MqttPacket p = MqttPacket::publish(c.topic, c.payload, c.qos, c.retain, c.msgId);
DecodedPublish d = MqttParser::decodePublish(p.bytes());
REQUIRE(d.valid);
CHECK(d.topic == c.topic);
CHECK(d.qos == c.qos);
CHECK(d.retain == c.retain);
const std::vector<uint8_t> expectedPayload(c.payload.begin(), c.payload.end());
CHECK(d.payload == expectedPayload);
if (c.qos > 0) {
CHECK(d.msgId == c.msgId);
}
}
}
TEST_CASE("MqttParser decodeSubscribe / decodeUnsubscribe round-trip filters and msgId") {
MqttPacket sub = MqttPacket::raw({
0x82, 0x08, // SUBSCRIBE | qos1, remaining length 8
0x00, 0x2A, // msgId 42
0x00, 0x03, 'a', '/', 'b', // topic filter "a/b"
0x01 // requested qos 1
});
DecodedSubscribe ds = MqttParser::decodeSubscribe(sub.bytes());
REQUIRE(ds.valid);
CHECK(ds.msgId == 42);
REQUIRE(ds.filters.size() == 1);
CHECK(ds.filters[0] == "a/b");
REQUIRE(ds.requestedQos.size() == 1);
CHECK(ds.requestedQos[0] == 1);
MqttPacket unsub = MqttPacket::raw({
0xA2, 0x07, // UNSUBSCRIBE | qos1, remaining length 7
0x00, 0x2A, // msgId 42
0x00, 0x03, 'a', '/', 'b' // topic filter "a/b"
});
DecodedUnsubscribe du = MqttParser::decodeUnsubscribe(unsub.bytes());
REQUIRE(du.valid);
CHECK(du.msgId == 42);
REQUIRE(du.filters.size() == 1);
CHECK(du.filters[0] == "a/b");
}
TEST_CASE("MqttParser decodeConnect round-trips a real library CONNECT packet") {
// Drive the unmodified library so the parser reads genuine wire bytes.
TestClock::instance().reset();
MockClient client;
// Script a valid CONNACK so connect() completes in finite virtual time.
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
const bool ok = psc.connect("client-42", "alice", "s3cret");
REQUIRE(ok);
DecodedConnect c = MqttParser::decodeConnect(client.outbound());
REQUIRE(c.valid);
CHECK(c.protocolName == "MQTT");
CHECK(c.protocolLevel == MQTT_VERSION_3_1_1);
CHECK(c.clientId == "client-42");
CHECK(c.userFlag);
CHECK(c.passwordFlag);
CHECK(c.username == "alice");
CHECK(c.password == "s3cret");
CHECK(c.cleanSession);
CHECK_FALSE(c.willFlag);
}
TEST_CASE("MqttParser decodePublish round-trips a real library PUBLISH packet") {
// A successful connect is required for connected() to report true, which
// publish() guards on. Script a CONNACK, connect, then clear the CONNECT
// bytes so only the PUBLISH remains in the outbound record.
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("dev"));
client.clearOutbound();
REQUIRE(psc.publish("stat/dev/RESULT", "{\"POWER\":\"ON\"}", true));
DecodedPublish d = MqttParser::decodePublish(client.outbound());
REQUIRE(d.valid);
CHECK(d.topic == "stat/dev/RESULT");
CHECK(d.retain);
CHECK(d.qos == 0);
const std::string expected = "{\"POWER\":\"ON\"}";
CHECK(std::string(d.payload.begin(), d.payload.end()) == expected);
}
TEST_CASE("MqttParser decodeSubscribe round-trips a real library SUBSCRIBE packet") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("dev"));
client.clearOutbound();
REQUIRE(psc.subscribe("cmnd/dev/#", 1));
DecodedSubscribe d = MqttParser::decodeSubscribe(client.outbound());
REQUIRE(d.valid);
CHECK(d.msgId != 0); // library assigns a nonzero id
REQUIRE(d.filters.size() == 1);
CHECK(d.filters[0] == "cmnd/dev/#");
REQUIRE(d.requestedQos.size() == 1);
CHECK(d.requestedQos[0] == 1);
}
// --- Structural PUBLISH validator: accept / reject ---------------------
TEST_CASE("isStructurallyValidPublish accepts well-formed PUBLISH packets") {
CHECK(MqttParser::isStructurallyValidPublish(
MqttPacket::publish("a/b", std::string("hi")).bytes()));
CHECK(MqttParser::isStructurallyValidPublish(
MqttPacket::publish("t", std::string("")).bytes())); // empty payload
CHECK(MqttParser::isStructurallyValidPublish(
MqttPacket::publish("a/b", std::string("hi"), 1, false, 7).bytes())); // qos1 + msgId
// A 2-byte Remaining Length PUBLISH (payload pushes it past 127).
CHECK(MqttParser::isStructurallyValidPublish(
MqttPacket::publish("t", std::vector<uint8_t>(200, 0xAB)).bytes()));
}
TEST_CASE("isStructurallyValidPublish rejects wrong packet type") {
// CONNACK, PINGRESP, SUBACK are not PUBLISH.
CHECK_FALSE(MqttParser::isStructurallyValidPublish(MqttPacket::connack(0).bytes()));
CHECK_FALSE(MqttParser::isStructurallyValidPublish(MqttPacket::pingresp().bytes()));
CHECK_FALSE(MqttParser::isStructurallyValidPublish(MqttPacket::suback(1, 0).bytes()));
}
TEST_CASE("isStructurallyValidPublish rejects truncated packets") {
std::vector<uint8_t> bytes = MqttPacket::publish("a/b", std::string("hi")).bytes();
// Remaining Length says more bytes than are present.
std::vector<uint8_t> truncated = bytes;
truncated.pop_back();
CHECK_FALSE(MqttParser::isStructurallyValidPublish(truncated));
// Empty input.
CHECK_FALSE(MqttParser::isStructurallyValidPublish(std::vector<uint8_t>{}));
// Fixed header only, no Remaining Length byte.
CHECK_FALSE(MqttParser::isStructurallyValidPublish(std::vector<uint8_t>{0x30}));
}
TEST_CASE("isStructurallyValidPublish rejects a topic length that overruns the body") {
// remaining length = 3, but the declared topic length is 0x00FF which
// cannot fit: 2 (prefix) + 255 (topic) > 3.
const std::vector<uint8_t> bad = {0x30, 0x03, 0x00, 0xFF, 0x00};
CHECK_FALSE(MqttParser::isStructurallyValidPublish(bad));
}
TEST_CASE("isStructurallyValidPublish rejects a QoS>0 packet with no room for the packet id") {
// qos1 fixed header (0x32), remaining length 4: topic length prefix (2)
// + a 2-byte topic leaves no room for the 2-byte packet identifier.
const std::vector<uint8_t> bad = {0x32, 0x04, 0x00, 0x02, 'a', 'b'};
CHECK_FALSE(MqttParser::isStructurallyValidPublish(bad));
}
}
@@ -0,0 +1,498 @@
/*
api_surface_test.cpp - Public-API surface characterization for the
TasmotaPubSub host test system (task 10.8).
All cases here are Baseline (TEST_SUITE("baseline")) characterization tests:
they lock in the *current* observable behavior of the unmodified library
across the parts of the Public_API that connect_test.cpp does not already
cover in depth, so the two files together characterize the entire surface
without gaps. Specifically:
- Every constructor form declared in PubSubClient.h (Requirement 18.1).
connect_test.cpp exercises six forms (default, client-only, ip+client,
domain+client, ip+callback+client, domain+callback+client); this file
adds the remaining eight (the uint8_t* byte-array IP forms and all the
Stream& / callback+Stream& variants) so no constructor form is missing.
- setServer / setClient / setCallback / setStream / setKeepAlive /
setSocketTimeout observable behavior (Requirement 18.2). setClient is
shown to actually route bytes through the supplied client; setStream is
shown to route the inbound PUBLISH payload to the supplied MockStream;
setKeepAlive is shown in the CONNECT keepalive field; setSocketTimeout is
shown to bound the connect read-wait in virtual time.
- Each connect() overload incl. user+password, Last Will, and cleanSession
(Requirement 18.3), complementing connect_test.cpp's field round-trip.
- state() across every documented code: MQTT_CONNECTION_TIMEOUT(-4),
MQTT_CONNECTION_LOST(-3), MQTT_CONNECT_FAILED(-2), MQTT_DISCONNECTED(-1),
MQTT_CONNECTED(0), and the CONNACK return codes 1..5 (Requirement 18.8),
each driven through a realistic scenario.
- loop() and connected() observable behavior.
Every assertion goes through the public API and decoded MockClient.outbound()
wire bytes - never private members - so the baseline stays durable across a
future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "MockClient.h"
#include "MockStream.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// A harmless callback used only so the callback-taking constructor overloads
// can be instantiated; it records nothing.
void noopCallback(char*, uint8_t*, unsigned int) {}
} // namespace
TEST_SUITE("baseline") {
// --- 18.1: constructor forms (complement connect_test.cpp) --------------
// connect_test.cpp covers 6 of the 14 constructor forms. This case adds the
// remaining 8 (the uint8_t* byte-array IP forms and every Stream& variant)
// so all 14 declared forms are characterized for a stable initial state.
TEST_CASE("remaining constructor forms initialize to a stable disconnected state") {
TestClock::instance().reset();
MockClient c1, c2, c3, c4, c5, c6, c7, c8;
MockStream s1, s2, s3, s4;
IPAddress ip(192, 168, 1, 20);
uint8_t ipb[4] = {10, 1, 2, 3};
// IPAddress + Stream forms.
PubSubClient ipStream(ip, 1883, c1, s1);
PubSubClient ipCbStream(ip, 1883, noopCallback, c2, s2);
// uint8_t* byte-array IP forms (all four).
PubSubClient ba(ipb, 1883, c3);
PubSubClient baStream(ipb, 1883, c4, s3);
PubSubClient baCb(ipb, 1883, noopCallback, c5);
PubSubClient baCbStream(ipb, 1883, noopCallback, c6, s4);
// domain + Stream forms (the two connect_test.cpp does not build).
PubSubClient domStream("broker.example", 1883, c7); // domain + client
PubSubClient domCbStream("broker.example", 1883, noopCallback, c8);
PubSubClient* forms[] = {
&ipStream, &ipCbStream, &ba, &baStream,
&baCb, &baCbStream, &domStream, &domCbStream,
};
for (PubSubClient* p : forms) {
CHECK(p->state() == MQTT_DISCONNECTED);
CHECK_FALSE(p->connected());
CHECK(p->getBufferSize() == MQTT_MAX_PACKET_SIZE);
}
}
// The uint8_t* byte-array IP constructor must route connect() to that
// address, proving the endpoint captured by this form is actually used.
TEST_CASE("byte-array IP constructor routes connect to that address") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
uint8_t ipb[4] = {203, 0, 113, 7};
PubSubClient psc(ipb, 1883, client);
REQUIRE(psc.connect("dev"));
CHECK(psc.connected());
CHECK(client.connectCalled());
CHECK(client.lastIp() == IPAddress(203, 0, 113, 7));
CHECK(client.lastPort() == 1883);
}
// A Stream-form constructor attaches the stream; in stream mode the library
// routes each inbound PUBLISH payload byte to that stream during readPacket.
TEST_CASE("stream-form constructor routes inbound PUBLISH payload to the attached stream") {
TestClock::instance().reset();
MockClient client;
MockStream stream;
IPAddress ip(10, 0, 0, 8);
PubSubClient psc(ip, 1883, client, stream);
client.pushPacket(MqttPacket::connack(0));
REQUIRE(psc.connect("stream-dev"));
REQUIRE(psc.connected());
// A QoS 0 PUBLISH: topic "t", payload "hi". In stream mode the payload
// bytes (after the topic) are written to the attached stream.
const std::string topic = "t";
const std::vector<uint8_t> payload = {'h', 'i'};
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/0));
REQUIRE(psc.loop());
CHECK(stream.written() == payload);
}
// --- 18.2: setClient routes outbound bytes through the supplied client ---
TEST_CASE("setClient makes connect route bytes through the supplied client") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
// Default-construct (no client) then attach one via setClient.
PubSubClient psc;
psc.setServer("broker.example", 1883);
psc.setClient(client);
REQUIRE(psc.connect("dev"));
CHECK(psc.connected());
// The CONNECT bytes were recorded by the client supplied via setClient.
CHECK(client.connectCalled());
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.clientId == "dev");
}
// --- 18.2: setCallback registers the dispatch callback; latest wins ------
TEST_CASE("setCallback registers the inbound dispatch callback and the latest registration wins") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
int firstCount = 0;
int secondCount = 0;
std::string seenTopic;
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
// Register a first callback, then replace it: only the second must fire.
psc.setCallback([&firstCount](char*, uint8_t*, unsigned int) { firstCount++; });
psc.setCallback([&secondCount, &seenTopic](char* topic, uint8_t*, unsigned int) {
secondCount++;
seenTopic = topic;
});
REQUIRE(psc.connect("cb-dev"));
client.clearOutbound();
const std::string topic = "tele/dev/STATE";
const std::vector<uint8_t> payload = {'O', 'N'};
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/0));
REQUIRE(psc.loop());
CHECK(firstCount == 0); // the replaced callback never fires
CHECK(secondCount == 1); // the latest callback is used
CHECK(seenTopic == topic);
}
// --- 18.2: setStream routes inbound PUBLISH payload to the stream --------
TEST_CASE("setStream routes the inbound PUBLISH payload to the supplied stream") {
TestClock::instance().reset();
MockClient client;
MockStream stream;
PubSubClient psc(client);
psc.setStream(stream);
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("stream-dev"));
REQUIRE(psc.connected());
const std::string topic = "sensor";
const std::vector<uint8_t> payload = {'1', '2', '3', '4'};
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/0));
REQUIRE(psc.loop());
// Every payload byte (after the topic) was routed to the stream in order.
CHECK(stream.written() == payload);
}
// --- 18.2: setKeepAlive is reflected in the CONNECT keepalive field ------
TEST_CASE("setKeepAlive is reflected in the CONNECT keepalive field") {
// Curated intervals, including the default, a short and a long value.
const uint16_t intervals[] = {MQTT_KEEPALIVE, 1, 30, 3600};
for (uint16_t ka : intervals) {
CAPTURE(ka);
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
psc.setKeepAlive(ka); // seconds, set BEFORE connect
REQUIRE(psc.connect("ka-dev"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.keepAlive == ka);
}
}
// --- 18.2: setSocketTimeout bounds the connect read-wait in virtual time -
TEST_CASE("setSocketTimeout bounds the connect read-wait in virtual time") {
// With no CONNACK scripted, connect() busy-waits for the transport and
// gives up after socketTimeout seconds. Because the shim's delay()
// advances the same virtual clock millis() reads, the elapsed VIRTUAL
// time is bounded by socketTimeout*1000 ms - a larger timeout waits
// proportionally longer - with no real wall-clock time consumed.
auto measureConnectTimeout = [](uint16_t timeoutSecs) -> unsigned long {
TestClock::instance().reset();
MockClient client; // empty inbound: no CONNACK
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
psc.setSocketTimeout(timeoutSecs);
const unsigned long start = TestClock::instance().millis();
const bool ok = psc.connect("to-dev"); // must fail via timeout
const unsigned long elapsed = TestClock::instance().millis() - start;
CHECK_FALSE(ok);
CHECK(psc.state() == MQTT_CONNECTION_TIMEOUT);
CHECK(client.stopCalled());
return elapsed;
};
const unsigned long elapsed1 = measureConnectTimeout(1);
const unsigned long elapsed3 = measureConnectTimeout(3);
// Each wait lasts at least its configured timeout in virtual ms, and a
// larger timeout waits strictly longer.
CHECK(elapsed1 >= 1UL * 1000UL);
CHECK(elapsed3 >= 3UL * 1000UL);
CHECK(elapsed3 > elapsed1);
}
// --- 18.2: setServer selects the most recent endpoint form ---------------
TEST_CASE("setServer selects the most recently configured endpoint") {
// A later setServer(domain) overrides an earlier setServer(ip): connect
// routes to the domain form. (The library clears the domain when an IP
// form is set and vice-versa, so the last call wins.)
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer(IPAddress(10, 0, 0, 1), 1883); // first: IP form
psc.setServer("broker.example", 8883); // then: domain form wins
REQUIRE(psc.connect("dev"));
CHECK(client.lastHost() == "broker.example");
CHECK(client.lastPort() == 8883);
}
// --- 18.3: connect() overloads (complement connect_test.cpp) -------------
// connect_test.cpp round-trips the CONNECT fields per overload; this case
// consolidates the *delegation* contract: every non-explicit overload forces
// cleanSession=1, and only the fully-explicit overload can clear it.
TEST_CASE("connect overloads force cleanSession except the explicit cleanSession overload") {
SUBCASE("connect(id) => clean session, no user/will") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.cleanSession);
CHECK_FALSE(d.userFlag);
CHECK_FALSE(d.willFlag);
}
SUBCASE("connect(id, user, pass) => clean session + credentials") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id", "alice", "s3cret"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.cleanSession);
CHECK(d.userFlag);
CHECK(d.passwordFlag);
CHECK(d.username == "alice");
CHECK(d.password == "s3cret");
}
SUBCASE("connect(id, willTopic, willQos, willRetain, willMessage) => clean session + will") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id", "tele/LWT", 1, true, "Offline"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.cleanSession);
CHECK(d.willFlag);
CHECK(d.willQos == 1);
CHECK(d.willRetain);
CHECK(d.willTopic == "tele/LWT");
CHECK(d.willMessage == "Offline");
}
SUBCASE("connect(id, user, pass, will...) => clean session + credentials + will") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id", "u", "p", "tele/LWT", 0, false, "bye"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.cleanSession);
CHECK(d.userFlag);
CHECK(d.passwordFlag);
CHECK(d.willFlag);
}
SUBCASE("connect(id, ..., cleanSession=false) => persistent session") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id", "u", "p", "tele/LWT", 2, true, "bye", false));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK_FALSE(d.cleanSession); // only this overload can clear it
CHECK(d.willQos == 2);
}
}
// --- 18.8: state() across every documented code -------------------------
TEST_CASE("state() reports MQTT_DISCONNECTED before any connect attempt") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CHECK(psc.state() == MQTT_DISCONNECTED);
CHECK_FALSE(psc.connected());
}
TEST_CASE("state() reports MQTT_CONNECTED after a successful CONNACK") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("dev"));
CHECK(psc.state() == MQTT_CONNECTED);
CHECK(psc.connected());
}
TEST_CASE("state() reports MQTT_CONNECT_FAILED when the transport connect fails") {
// Force the underlying transport connect() to fail; the library never
// sends a CONNECT and reports MQTT_CONNECT_FAILED.
TestClock::instance().reset();
MockClient client;
client.setConnectResult(0); // transport refuses the connection
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
CHECK_FALSE(psc.connect("dev"));
CHECK(psc.state() == MQTT_CONNECT_FAILED);
CHECK_FALSE(psc.connected());
// No CONNECT bytes were emitted because the transport never came up.
CHECK(client.outbound().empty());
}
TEST_CASE("state() reports MQTT_CONNECTION_TIMEOUT when no CONNACK arrives") {
// Transport connects, but no CONNACK is scripted; the connect read-wait
// times out in virtual time and reports MQTT_CONNECTION_TIMEOUT.
TestClock::instance().reset();
MockClient client; // empty inbound queue
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
psc.setSocketTimeout(1); // bound the wait in virtual time
CHECK_FALSE(psc.connect("dev"));
CHECK(psc.state() == MQTT_CONNECTION_TIMEOUT);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
TEST_CASE("state() reports MQTT_CONNECTION_LOST when the transport drops after connect") {
// Establish a connection, then have the transport report itself down.
// The next connected() poll detects the drop and reports the lost state.
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("dev"));
REQUIRE(psc.state() == MQTT_CONNECTED);
client.setConnected(false); // transport silently drops
CHECK_FALSE(psc.connected()); // poll observes the drop
CHECK(psc.state() == MQTT_CONNECTION_LOST);
}
TEST_CASE("state() reflects each CONNACK return code 1..5") {
struct Case { uint8_t code; int expectedState; };
const Case cases[] = {
{1, MQTT_CONNECT_BAD_PROTOCOL},
{2, MQTT_CONNECT_BAD_CLIENT_ID},
{3, MQTT_CONNECT_UNAVAILABLE},
{4, MQTT_CONNECT_BAD_CREDENTIALS},
{5, MQTT_CONNECT_UNAUTHORIZED},
};
for (const Case& c : cases) {
CAPTURE(c.code);
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(c.code));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
CHECK_FALSE(psc.connect("dev"));
CHECK(psc.state() == c.expectedState);
CHECK_FALSE(psc.connected());
}
}
// --- loop() and connected() observable behavior -------------------------
TEST_CASE("loop() returns false before connect and true while idle-connected") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
// Not connected yet: loop() is a no-op and reports false.
CHECK_FALSE(psc.loop());
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("dev"));
client.clearOutbound();
// Connected and idle (no inbound, well within keepalive): loop() reports
// true and writes nothing.
CHECK(psc.loop());
CHECK(client.outbound().empty());
CHECK(psc.connected());
}
TEST_CASE("connected() tracks the transport connection state") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
CHECK_FALSE(psc.connected()); // before connect
REQUIRE(psc.connect("dev"));
CHECK(psc.connected()); // after a successful CONNACK
psc.disconnect(); // explicit local disconnect
CHECK_FALSE(psc.connected());
CHECK(psc.state() == MQTT_DISCONNECTED);
CHECK(client.stopCalled());
}
}
@@ -0,0 +1,322 @@
/*
buffer_test.cpp - buffer sizing and incoming-packet-cap characterization +
hardening for the TasmotaPubSub host test system (task 10.7).
Baseline (TEST_SUITE("baseline")):
- setBufferSize with a valid size (allocation succeeds) => getBufferSize
returns that size (Requirement 13.1).
- Property 7 (setBufferSize / getBufferSize round-trip) is folded in as a
single deterministic, data-driven case over a curated table of sizes
(no randomized generators). A rejected size (0) leaves the prior size
unchanged.
- setMaxIncomingPacketSize / getMaxIncomingPacketSize round-trip, and the
OBSERVED behavior of the current implementation when an inbound packet
declares a total size larger than the configured cap (Requirement 13.3):
the connection is closed and loop() reports the disconnect.
Hardening (TEST_SUITE("hardening")):
- F-07 (Requirement 13.2, expected PASS): with the malloc/realloc interposer
armed to fail the next allocation, a failed setBufferSize does NOT leave a
nonzero reported capacity backed by a null/failed buffer. The library
commits the new size only after a successful realloc, so getBufferSize
still reports the previous (valid) size and the buffer stays usable - proven
by a subsequent publish exercised under AddressSanitizer.
Marked FINDING_MARKER(F07).
- Property 12 / F-03 prompt-close (Requirement 13.4, expected PASS): an
inbound packet declaring a Remaining Length larger than the accepted
capacity closes the connection after a bounded prefix (assert stopCalled())
rather than draining the whole declared body. Marked
FINDING_MARKER(F03_PROMPT_CLOSE).
- F-03 packet-wide deadline (Requirement 13.5, expected FAIL): trickle-fed
inbound bytes should be bounded by a packet-wide deadline, not only by the
per-byte socket timeout. The current fork enforces only a per-byte timeout,
so a byte-per-second trickle is read to completion and dispatched. Marked
FINDING_MARKER(F03_DEADLINE) (should_fail).
Every assertion goes through the public API and decoded MockClient.outbound()
wire bytes - never private members - so the baseline stays durable across a
future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "AllocInterposer.h"
#include "FindingStatus.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Records inbound PUBLISH dispatches so the deadline test can observe whether a
// trickle-fed packet was delivered (current behavior) or refused (hardened).
struct CallbackCapture {
int count = 0;
std::string topic;
unsigned length = 0;
};
// Connect the client/psc pair with a scripted CONNACK and clear the recorded
// CONNECT bytes so only the packet(s) under test remain in outbound().
void connectAndClear(MockClient& client, PubSubClient& psc) {
client.pushPacket(MqttPacket::connack(0)); // return code 0 = accepted
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("buffer-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
} // namespace
TEST_SUITE("baseline") {
// --- 13.1: setBufferSize (allocation succeeds) => getBufferSize ---------
TEST_CASE("setBufferSize with a valid size makes getBufferSize return that size") {
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
// The constructor already sized the buffer to MQTT_MAX_PACKET_SIZE.
CHECK(psc.getBufferSize() == MQTT_MAX_PACKET_SIZE);
REQUIRE(psc.setBufferSize(512));
CHECK(psc.getBufferSize() == 512);
}
// --- Property 7: setBufferSize / getBufferSize round-trip ---------------
// Feature: tasmota-pubsub-tests, Property 7: for all valid buffer sizes,
// calling setBufferSize with that size (when allocation succeeds) makes
// getBufferSize return exactly that size. Validated deterministically over a
// curated table of sizes spanning small, power-of-two, remaining-length
// boundaries, and the uint16_t maximum (no randomized generators).
TEST_CASE("Property 7: setBufferSize / getBufferSize round-trip over curated sizes") {
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
const uint16_t sizes[] = {
1, 2, 16, 64, 127, 128, 255, 256, 512, 1024,
MQTT_MAX_PACKET_SIZE, 2048, 8192, 16384, 65535,
};
for (uint16_t size : sizes) {
CAPTURE(size);
REQUIRE(psc.setBufferSize(size)); // allocation succeeds
CHECK(psc.getBufferSize() == size); // reports exactly that size
}
// A size of 0 is rejected and leaves the previously committed size intact
// (the library refuses to shrink the capacity to zero).
const uint16_t last = psc.getBufferSize();
CHECK_FALSE(psc.setBufferSize(0));
CHECK(psc.getBufferSize() == last);
}
// --- 13.3: setMaxIncomingPacketSize characterization --------------------
TEST_CASE("setMaxIncomingPacketSize / getMaxIncomingPacketSize round-trip") {
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
// Default: the cap is disabled (0).
CHECK(psc.getMaxIncomingPacketSize() == 0);
const uint32_t caps[] = {1, 64, 256, 1200, 100000, 0};
for (uint32_t cap : caps) {
CAPTURE(cap);
psc.setMaxIncomingPacketSize(cap);
CHECK(psc.getMaxIncomingPacketSize() == cap);
}
}
TEST_CASE("an inbound packet declaring a total size over the cap closes the connection") {
// Characterize the CURRENT behavior (Requirement 13.3): with a small cap
// configured, a packet whose declared total wire size exceeds the cap is
// refused and the connection is closed - the library does not attempt to
// drain or buffer the oversized body.
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
psc.setMaxIncomingPacketSize(50);
CHECK(psc.getMaxIncomingPacketSize() == 50);
// A PUBLISH fixed header (0x30) followed by a single-byte Remaining
// Length of 100. total = 1 (fixed) + 1 (length byte) + 100 = 102 > 50, so
// the cap fires right after the length is decoded, before any body read.
// Only the two-byte prefix is supplied; a drain-the-body implementation
// would instead block on the (empty) queue and time out without closing.
std::vector<uint8_t> frame = {static_cast<uint8_t>(MQTTPUBLISH), 100};
client.pushInbound(frame);
psc.setSocketTimeout(1); // bound any unexpected read as a safety net
const bool looped = psc.loop();
CHECK_FALSE(looped);
CHECK_FALSE(psc.connected());
CHECK(psc.state() == MQTT_DISCONNECTED);
CHECK(client.stopCalled());
}
}
TEST_SUITE("hardening") {
// --- F-07 (13.2): failed setBufferSize must not report null-backed size --
// With the allocation interposer armed to fail the next allocation, the
// realloc() inside setBufferSize() returns NULL. The library must NOT commit
// the new size: getBufferSize() keeps reporting the previous (valid) size and
// the existing buffer remains intact and usable. If instead it committed the
// size while buffer became null, the subsequent publish would dereference a
// null/invalid buffer and AddressSanitizer would abort. F-07 is hardened
// (expected PASS).
TEST_CASE("F-07 a failed setBufferSize leaves no nonzero capacity backed by a null buffer"
* FINDING_MARKER(F07)) {
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
// Baseline: the constructor allocated the working buffer successfully.
const uint16_t before = psc.getBufferSize();
REQUIRE(before == MQTT_MAX_PACKET_SIZE);
// Arm the next allocation (the realloc in setBufferSize) to fail.
AllocInterposer::failNextAllocation();
const bool grew = psc.setBufferSize(4096);
AllocInterposer::reset();
// The (re)allocation failed, so setBufferSize reports failure and the
// reported size is UNCHANGED - never a nonzero size backed by a failed
// allocation.
CHECK_FALSE(grew);
CHECK(psc.getBufferSize() == before);
// Prove the buffer behind that reported size is still valid and usable:
// connect and publish, decoding the emitted PUBLISH from the wire. Under
// AddressSanitizer this also proves no null/dangling buffer was left
// behind by the failed realloc.
connectAndClear(client, psc);
REQUIRE(psc.publish("tele/dev/STATE", "online"));
DecodedPublish pub = MqttParser::decodePublish(client.outbound());
REQUIRE(pub.valid);
CHECK(pub.topic == "tele/dev/STATE");
const std::string payload(pub.payload.begin(), pub.payload.end());
CHECK(payload == "online");
CHECK(psc.connected());
}
// --- Property 12 / F-03 prompt-close (13.4) -----------------------------
// Feature: tasmota-pubsub-tests, Property 12: for all inbound packets
// declaring a Remaining Length larger than the accepted capacity, the library
// closes the connection after consuming only a bounded prefix rather than
// draining the entire declared body.
//
// The fixture supplies ONLY the fixed header + Remaining Length bytes and no
// body at all. A prompt-close implementation calls _client->stop() as soon as
// it sees the oversized declared length (so stopCalled() is true almost
// immediately). A drain-the-body implementation would instead loop reading
// the declared body, exhaust the (empty) queue, and time out - a path that
// returns without calling stop(), so stopCalled() would be false. Asserting
// stopCalled() therefore proves the prompt close. F-03 prompt-close is
// hardened (expected PASS).
TEST_CASE("Property 12: an oversized declared inbound packet closes the connection promptly"
* FINDING_MARKER(F03_PROMPT_CLOSE)) {
// Declared Remaining Lengths well beyond the buffer capacity (default
// 1200), each encoded in 1..4 Remaining-Length bytes.
const uint32_t declaredLengths[] = {2000, 300000, 5000000, 200000000};
for (uint32_t rl : declaredLengths) {
CAPTURE(rl);
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
// Fixed header (PUBLISH) + Remaining Length only; no body bytes.
std::vector<uint8_t> frame;
frame.push_back(static_cast<uint8_t>(MQTTPUBLISH));
const std::vector<uint8_t> rlBytes = MqttPacket::encodeRemainingLength(rl);
frame.insert(frame.end(), rlBytes.begin(), rlBytes.end());
client.pushInbound(frame);
psc.setSocketTimeout(1); // a drain path would time out fast (no stop)
const bool looped = psc.loop();
// Closed promptly after the bounded prefix, not after draining.
CHECK(client.stopCalled());
CHECK_FALSE(looped);
CHECK_FALSE(psc.connected());
CHECK(psc.state() == MQTT_DISCONNECTED);
}
}
// --- F-03 packet-wide deadline (13.5) - expected FAIL --------------------
// A packet-wide deadline should bound how long the library spends assembling
// a single inbound packet, independent of how often individual bytes arrive.
// Here the broker trickles one byte per simulated second while the per-byte
// socket timeout is five seconds, so no single byte ever times out. A
// hardened implementation would still abandon and close the connection once
// the packet-wide deadline elapsed; the current fork enforces ONLY the
// per-byte timeout, so it reads the whole packet to completion and dispatches
// the callback. The assertions below encode the hardened expectation, so the
// case reports an EXPECTED FAILURE against the current implementation.
TEST_CASE("F-03 trickle-fed inbound bytes are bounded by a packet-wide deadline"
* FINDING_MARKER(F03_DEADLINE)) {
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
psc.setServer("broker.example", 1883);
psc.setKeepAlive(120); // keep keepalive well clear of the read
psc.setCallback([&cap](char* topic, uint8_t* /*payload*/, unsigned int len) {
cap.count++;
cap.topic = topic;
cap.length = len;
});
client.pushPacket(MqttPacket::connack(0));
REQUIRE(psc.connect("deadline-client"));
REQUIRE(psc.connected());
client.clearOutbound();
// A small, well-formed QoS 0 PUBLISH (topic "t", payload "hi").
const std::string topic = "t";
const std::vector<uint8_t> payload = {'h', 'i'};
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/0));
// Per-byte timeout 5 s; trickle one byte per simulated second. Each byte
// arrives inside the per-byte window, so only a packet-wide deadline could
// stop the read. Virtual time advances via the shim's delay(), so this
// consumes no real wall-clock time.
psc.setSocketTimeout(5);
client.setTrickle(/*bytesPerReveal=*/1, /*msPerReveal=*/1000);
psc.loop();
// Hardened expectation: the packet-wide deadline elapsed while the bytes
// trickled in, so the library abandoned the packet and closed the
// connection WITHOUT dispatching it. The current fork instead delivers the
// packet, so both checks fail here => reported as an expected failure.
CHECK(client.stopCalled());
CHECK(cap.count == 0);
}
}
@@ -0,0 +1,453 @@
/*
connect_test.cpp - Connect / CONNACK, constructor forms, and setServer
characterization + hardening for the TasmotaPubSub host test system (task 10.1).
Baseline (TEST_SUITE("baseline")):
- A valid scripted CONNACK drives the *unmodified* library into the connected
state (Requirement 8.1).
- The recorded outbound bytes decode into a valid CONNECT packet (Requirement 8.2).
- Property 2 (outbound packets are structurally well-framed) and Property 3
(CONNECT field round-trip) are exercised as single, deterministic,
data-driven cases over curated combinations of client id / user / password /
Last Will / cleanSession (no randomized generators).
- Constructor forms and setServer overloads are characterized (Requirements
18.1, 18.3) and each connect() overload is decoded from the wire.
Hardening (TEST_SUITE("hardening")):
- Property 10 / F-06: a four-byte inbound frame that is not a well-formed
CONNACK (wrong packet type, nonzero fixed flags, or Remaining Length != 2)
is rejected as a failed connection, independent of the fourth byte value
(expected PASS per the finding table).
- F-11 (expected FAIL): disconnect() with no argument should transmit an MQTT
DISCONNECT packet; the current fork sends nothing, so the case is marked
FINDING_MARKER(F11) (should_fail).
Every assertion goes through the public API and decoded MockClient.outbound()
wire bytes - never private members - so the baseline stays durable across a
future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "FindingStatus.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
TEST_SUITE("baseline") {
// --- 8.1: valid CONNACK => connected state ------------------------------
TEST_CASE("connect with a valid scripted CONNACK enters the connected state") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0)); // return code 0 = accepted
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
const bool ok = psc.connect("client-1");
REQUIRE(ok);
CHECK(psc.connected());
CHECK(psc.state() == MQTT_CONNECTED);
}
TEST_CASE("connect surfaces a CONNACK non-zero return code as the state and stays disconnected") {
// Characterize the documented CONNACK return codes 1..5: a nonzero code
// is reflected in state() and the client is not connected (Requirement 18.8).
struct Case { uint8_t code; int expectedState; };
const Case cases[] = {
{1, MQTT_CONNECT_BAD_PROTOCOL},
{2, MQTT_CONNECT_BAD_CLIENT_ID},
{3, MQTT_CONNECT_UNAVAILABLE},
{4, MQTT_CONNECT_BAD_CREDENTIALS},
{5, MQTT_CONNECT_UNAUTHORIZED},
};
for (const Case& c : cases) {
CAPTURE(c.code);
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(c.code));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
CHECK_FALSE(psc.connect("client-1"));
CHECK(psc.state() == c.expectedState);
CHECK_FALSE(psc.connected());
}
}
// --- 8.2 + Property 2: recorded outbound is a valid CONNECT packet ------
// Feature: tasmota-pubsub-tests, Property 2: outbound packets are
// structurally well-framed - the recorded CONNECT decodes into a complete
// control packet whose declared Remaining Length equals the trailing bytes.
TEST_CASE("connect records a structurally well-framed CONNECT packet") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("dev-42"));
const std::vector<uint8_t>& out = client.outbound();
// Generic framing: the outbound bytes are one complete, self-consistent
// control packet whose Remaining Length matches the trailing byte count.
DecodedPacket generic = MqttParser::decode(out);
REQUIRE(generic.valid);
CHECK(generic.type == static_cast<uint8_t>(MQTTCONNECT));
CHECK(generic.flags == 0x00);
CHECK(generic.remainingLength == generic.payload.size());
// CONNECT-specific: protocol name / level and client id round-trip.
DecodedConnect c = MqttParser::decodeConnect(out);
REQUIRE(c.valid);
CHECK(c.protocolName == "MQTT");
CHECK(c.protocolLevel == MQTT_VERSION_3_1_1);
CHECK(c.keepAlive == MQTT_KEEPALIVE);
CHECK(c.clientId == "dev-42");
CHECK(c.cleanSession);
}
// --- Property 3: CONNECT field round-trip -------------------------------
// Feature: tasmota-pubsub-tests, Property 3: for all valid combinations of
// client id, optional user, optional password, and optional Last Will with a
// cleanSession flag, the outbound CONNECT decodes to a packet whose protocol
// name/level, flags, keepalive, and embedded strings match the arguments.
// Validated deterministically over a curated table (no randomized generators).
TEST_CASE("CONNECT field round-trip over curated argument combinations") {
struct Case {
const char* clientId;
const char* user; // nullptr => no user
const char* pass; // nullptr => no password (only set when user set)
const char* willTopic; // nullptr => no will
uint8_t willQos;
bool willRetain;
const char* willMessage;
bool cleanSession;
};
const Case cases[] = {
// clientId only, clean session.
{"c-min", nullptr, nullptr, nullptr, 0, false, nullptr, true},
// clientId, clean session false (persistent session).
{"c-persist", nullptr, nullptr, nullptr, 0, false, nullptr, false},
// user + password.
{"c-auth", "alice", "s3cret", nullptr, 0, false, nullptr, true},
// user only (no password).
{"c-user", "bob", nullptr, nullptr, 0, false, nullptr, true},
// Last Will, qos 0, not retained.
{"c-will0", nullptr, nullptr, "tele/dev/LWT", 0, false, "Offline", true},
// Last Will, qos 1, retained.
{"c-will1", nullptr, nullptr, "tele/dev/LWT", 1, true, "Offline", true},
// Last Will, qos 2, retained (qos accepted by connect()).
{"c-will2", nullptr, nullptr, "tele/dev/LWT", 2, true, "bye", false},
// Everything at once: user + password + retained will + clean session.
{"c-full", "alice", "s3cret", "tele/dev/LWT", 1, true, "Offline", true},
// Empty client id (allowed with clean session) + empty will message.
{"", nullptr, nullptr, "tele/x/LWT", 0, false, "", true},
};
for (const Case& c : cases) {
CAPTURE(c.clientId);
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
// Drive every combination through the fully-explicit overload so the
// cleanSession flag can be varied directly.
const bool ok = psc.connect(c.clientId, c.user, c.pass,
c.willTopic, c.willQos, c.willRetain,
c.willMessage, c.cleanSession);
REQUIRE(ok);
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
// Fixed protocol identity.
CHECK(d.protocolName == "MQTT");
CHECK(d.protocolLevel == MQTT_VERSION_3_1_1);
CHECK(d.keepAlive == MQTT_KEEPALIVE);
// Client id round-trip.
CHECK(d.clientId == std::string(c.clientId));
// cleanSession flag round-trip.
CHECK(d.cleanSession == c.cleanSession);
// User / password flags and embedded strings.
const bool wantUser = (c.user != nullptr);
const bool wantPass = (c.user != nullptr && c.pass != nullptr);
CHECK(d.userFlag == wantUser);
CHECK(d.passwordFlag == wantPass);
if (wantUser) { CHECK(d.username == std::string(c.user)); }
if (wantPass) { CHECK(d.password == std::string(c.pass)); }
// Last Will flags and embedded strings.
const bool wantWill = (c.willTopic != nullptr);
CHECK(d.willFlag == wantWill);
if (wantWill) {
CHECK(d.willQos == c.willQos);
CHECK(d.willRetain == c.willRetain);
CHECK(d.willTopic == std::string(c.willTopic));
CHECK(d.willMessage == std::string(c.willMessage));
}
}
}
// --- 18.3: each connect() overload ------------------------------------
TEST_CASE("connect overloads set the expected CONNECT flags") {
// Each overload delegates to the 8-arg form with a fixed cleanSession=1
// (except the explicit form). Decode the wire to confirm the flag layout.
SUBCASE("connect(id) - clean session, no user/will") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id-a"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.clientId == "id-a");
CHECK(d.cleanSession);
CHECK_FALSE(d.userFlag);
CHECK_FALSE(d.passwordFlag);
CHECK_FALSE(d.willFlag);
}
SUBCASE("connect(id, user, pass)") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id-b", "u", "p"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.userFlag);
CHECK(d.passwordFlag);
CHECK(d.username == "u");
CHECK(d.password == "p");
CHECK_FALSE(d.willFlag);
}
SUBCASE("connect(id, willTopic, willQos, willRetain, willMessage)") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id-c", "tele/c/LWT", 1, true, "Offline"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.willFlag);
CHECK(d.willQos == 1);
CHECK(d.willRetain);
CHECK(d.willTopic == "tele/c/LWT");
CHECK(d.willMessage == "Offline");
CHECK_FALSE(d.userFlag);
}
SUBCASE("connect(id, user, pass, willTopic, willQos, willRetain, willMessage)") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id-d", "u", "p", "tele/d/LWT", 0, false, "bye"));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK(d.userFlag);
CHECK(d.passwordFlag);
CHECK(d.willFlag);
CHECK(d.cleanSession); // this overload forces cleanSession=1
}
SUBCASE("connect(id, ..., cleanSession=false)") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("id-e", "u", "p", "tele/e/LWT", 2, false, "bye", false));
DecodedConnect d = MqttParser::decodeConnect(client.outbound());
REQUIRE(d.valid);
CHECK_FALSE(d.cleanSession);
CHECK(d.willQos == 2);
}
}
// --- 18.1: constructor forms -------------------------------------------
TEST_CASE("constructor forms have a stable initial disconnected state") {
TestClock::instance().reset();
MockClient c1, c2, c3, c4, c5;
IPAddress ip(192, 168, 1, 10);
PubSubClient def; // default
PubSubClient fromClient(c1); // client only
PubSubClient fromIp(ip, 1883, c2); // ip + port + client
PubSubClient fromDomain("broker.example", 1883, c3); // domain + port + client
auto cb = [](char*, uint8_t*, unsigned int) {};
PubSubClient fromIpCb(ip, 1883, cb, c4); // ip + port + callback + client
PubSubClient fromDomainCb("broker.example", 1883, cb, c5); // domain + callback
for (PubSubClient* p : {&def, &fromClient, &fromIp, &fromDomain, &fromIpCb, &fromDomainCb}) {
CHECK(p->state() == MQTT_DISCONNECTED);
CHECK_FALSE(p->connected());
CHECK(p->getBufferSize() == MQTT_MAX_PACKET_SIZE);
}
}
TEST_CASE("constructor-supplied endpoint is used by connect (ip and domain forms)") {
// IP-form constructor: connect routes to the constructor IP/port.
{
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
IPAddress ip(10, 0, 0, 5);
PubSubClient psc(ip, 1883, client);
REQUIRE(psc.connect("dev"));
CHECK(psc.connected());
CHECK(client.connectCalled());
CHECK(client.lastIp() == ip);
CHECK(client.lastPort() == 1883);
}
// Domain-form constructor: connect routes to the constructor host/port.
{
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc("mqtt.local", 8883, client);
REQUIRE(psc.connect("dev"));
CHECK(client.connectCalled());
CHECK(client.lastHost() == "mqtt.local");
CHECK(client.lastPort() == 8883);
}
}
// --- setServer overloads -----------------------------------------------
TEST_CASE("setServer overloads route connect to the configured endpoint") {
SUBCASE("setServer(IPAddress, port)") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
IPAddress ip(172, 16, 0, 9);
psc.setServer(ip, 1883);
REQUIRE(psc.connect("dev"));
CHECK(client.lastIp() == ip);
CHECK(client.lastPort() == 1883);
}
SUBCASE("setServer(uint8_t*, port)") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
uint8_t ipbytes[4] = {192, 0, 2, 44};
psc.setServer(ipbytes, 1883);
REQUIRE(psc.connect("dev"));
CHECK(client.lastIp() == IPAddress(192, 0, 2, 44));
CHECK(client.lastPort() == 1883);
}
SUBCASE("setServer(const char* domain, port)") {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1884);
REQUIRE(psc.connect("dev"));
CHECK(client.lastHost() == "broker.example");
CHECK(client.lastPort() == 1884);
}
}
}
TEST_SUITE("hardening") {
// Feature: tasmota-pubsub-tests, Property 10: for all four-byte inbound
// frames that are not a well-formed CONNACK (packet type other than 0x20,
// nonzero fixed flags, or Remaining Length other than 2), connect() reports
// failure and does not enter the connected state, regardless of the fourth
// byte value. F-06 is hardened in the current fork (expected PASS).
TEST_CASE("F-06 malformed four-byte CONNACK frames are rejected" * FINDING_MARKER(F06)) {
struct Case { const char* label; uint8_t b0; uint8_t b1; };
const Case malformations[] = {
// Wrong packet type (Remaining Length 2, but not CONNACK 0x20).
{"type CONNECT (0x10)", 0x10, 0x02},
{"type PUBLISH (0x30)", 0x30, 0x02},
{"type PINGRESP (0xD0)", 0xD0, 0x02},
// Correct type nibble (0x2) but nonzero fixed flags in the low nibble.
{"nonzero flags (0x21)", 0x21, 0x02},
{"nonzero flags (0x2F)", 0x2F, 0x02},
// Correct type + zero flags, but Remaining Length != 2.
{"remaining length 1", 0x20, 0x01},
{"remaining length 3", 0x20, 0x03},
{"remaining length 0", 0x20, 0x00},
};
// The fourth byte (CONNACK return-code position) must not influence the
// decision for a malformed frame - exercise both 0x00 and 0xFF.
const uint8_t fourthBytes[] = {0x00, 0xFF};
for (const Case& m : malformations) {
for (uint8_t fourth : fourthBytes) {
CAPTURE(m.label);
CAPTURE(fourth);
TestClock::instance().reset();
MockClient client;
// Bound any over-declared-length read to a short virtual timeout.
client.pushInbound({m.b0, m.b1, 0x00, fourth});
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
psc.setSocketTimeout(1);
CHECK_FALSE(psc.connect("client-x"));
CHECK_FALSE(psc.connected());
CHECK(psc.state() != MQTT_CONNECTED);
}
}
}
// F-11 (Requirement 8.4): disconnect() with no argument SHOULD transmit an
// MQTT DISCONNECT packet. The current fork only flushes/stops the transport
// and writes nothing, so this is expected to fail until F-11 is hardened.
TEST_CASE("F-11 disconnect() with no argument transmits a DISCONNECT packet" * FINDING_MARKER(F11)) {
TestClock::instance().reset();
MockClient client;
client.pushPacket(MqttPacket::connack(0));
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("dev"));
client.clearOutbound();
psc.disconnect(); // default argument: no DISCONNECT packet in current fork
// A hardened implementation writes the 2-byte DISCONNECT frame (0xE0 0x00).
DecodedPacket d = MqttParser::decode(client.outbound());
REQUIRE(d.valid);
CHECK(d.type == static_cast<uint8_t>(MQTTDISCONNECT));
CHECK(d.remainingLength == 0);
}
}
@@ -0,0 +1,706 @@
/*
findings_test.cpp - one consolidated adversarial/boundary fixture per
static-analysis finding F-01..F-12 for the TasmotaPubSub host test system
(task 10.9).
Feature: tasmota-pubsub-tests
Requirement 14.1 asks that EVERY documented finding (F-01..F-12) have at least
one corresponding executable check. Several findings are already exercised in
the behavior-specific suites (connect/publish/streaming/subscribe/receive/
keepalive/buffer/api-surface); this file provides one clear, self-contained
fixture per finding in a single place so the finding->test mapping is obvious
and auditable.
Rules honored throughout:
- Adversarial fixtures are built ONLY via MqttPacket / sized
std::vector<uint8_t>, so the test code itself performs no out-of-bounds
writes (Requirement 14.3). Any over-read/over-write is therefore the
library's, and AddressSanitizer (on by default) is the detector
(Requirement 14.2).
- F-01 uses inbound PUBLISH fixtures at total packet sizes bufferSize-1,
bufferSize and bufferSize+1 (Requirement 14.4); setBufferSize shrinks the
working buffer AFTER connecting so these boundaries are small and precise.
- Every assertion goes through the public API, the registered callback, and
decoded MockClient.outbound() wire bytes - never private members
(Requirement 19.1).
- Expected-FAIL findings (F-05, F-10, F-11 and the F-03 packet-wide deadline)
carry FINDING_MARKER(...) expanding to should_fail(); the rest are expected
to pass against the already-partially-hardened fork.
Observed status is verified empirically by `make hardening` / `make test`; the
FINDING_MARKER for each case matches the FindingStatus.h seeding.
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "AllocInterposer.h"
#include "CallbackContractAdapter.h"
#include "FindingStatus.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Records every inbound PUBLISH the library dispatches to the callback so a
// rejection can be proven by the callback never firing.
struct CallbackCapture {
int count = 0;
std::string topic;
unsigned int length = 0;
std::vector<uint8_t> payload;
};
// Connect the client/psc pair with a scripted CONNACK, register a recording
// callback, and clear the recorded CONNECT bytes so only the packet-under-test
// remains in outbound().
void connectWithCallback(MockClient& client, PubSubClient& psc, CallbackCapture& cap) {
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
psc.setCallback([&cap](char* topic, uint8_t* payload, unsigned int len) {
cap.count++;
cap.topic = topic;
cap.length = len;
cap.payload.assign(payload, payload + len);
});
REQUIRE(psc.connect("findings-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
// Connect the client/psc pair with a scripted CONNACK and clear the recorded
// CONNECT bytes; no callback registered.
void connectAndClear(MockClient& client, PubSubClient& psc) {
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("findings-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
// Deterministic payload of length n (includes 0x00 bytes so round-trips prove
// binary-safety, not C-string termination).
std::vector<uint8_t> makePayload(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; ++i) {
v[i] = static_cast<uint8_t>((i * 31u + 7u) & 0xFFu);
}
return v;
}
} // namespace
TEST_SUITE("hardening") {
// =======================================================================
// F-01 - Exact-buffer inbound one-byte heap overflow (Requirement 14.4)
// =======================================================================
//
// A PUBLISH whose complete encoded packet is exactly bufferSize bytes would,
// in the unpatched code, let the consumer write a NUL at buffer[bufferSize] -
// one byte past the allocation. The fork reserves a sentinel byte: readPacket
// rejects total_packet >= bufferSize (and idx >= bufferSize). So:
// total == bufferSize-1 : ACCEPTED -> callback fires, NUL lands at the
// reserved buffer[bufferSize-1] (in-bounds)
// total == bufferSize : REJECTED -> connection closed, no callback
// total == bufferSize+1 : REJECTED -> connection closed, no callback
//
// The CallbackContractAdapter reproduces the real Tasmota `mqtt_data[data_len]
// = 0` write into the delivered (in-library) buffer, so the accepted case
// exercises the exact consumer-side boundary write under AddressSanitizer. If
// the guard were off-by-one, the accepted case's NUL write would hit
// buffer[bufferSize] and ASan would abort. F-01 is hardened (expected PASS).
TEST_CASE("F-01 exact-buffer inbound PUBLISH boundaries stay in-bounds under ASan"
* FINDING_MARKER(F01)) {
// A small, precise working buffer so the three boundary totals are exact.
// The buffer is shrunk AFTER connect (CONNECT needs the default buffer).
const uint16_t kBufferSize = 64;
const std::string topic = "t"; // topicLen == 1
// total wire size of a QoS 0 PUBLISH = 1 (fixed) + 1 (RL byte, RL<128)
// + 2 (topic-length field) + topicLen + payloadLen = 4 + topicLen + payloadLen.
// With topicLen == 1 => total = 5 + payloadLen, so payloadLen = total - 5.
struct Case { const char* label; int totalDelta; bool accepted; };
const Case cases[] = {
{"bufferSize-1 (accepted)", -1, true},
{"bufferSize (rejected)", 0, false},
{"bufferSize+1 (rejected)", +1, false},
};
for (const Case& c : cases) {
CAPTURE(c.label);
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
// Register the faithful MqttDataHandler-contract adapter as the
// callback so the accepted case performs the real NUL-termination
// write into the library buffer under ASan.
CallbackContractAdapter adapter;
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
psc.setCallback(adapter.callback());
REQUIRE(psc.connect("f01-client"));
REQUIRE(psc.connected());
client.clearOutbound();
// Precisely size the buffer for the boundary math.
REQUIRE(psc.setBufferSize(kBufferSize));
REQUIRE(psc.getBufferSize() == kBufferSize);
const int total = static_cast<int>(kBufferSize) + c.totalDelta;
const size_t payloadLen = static_cast<size_t>(total) - 5;
const std::vector<uint8_t> payload = makePayload(payloadLen);
// Built structurally via the packet builder (sized vectors only).
const MqttPacket pkt = MqttPacket::publish(topic, payload, /*qos=*/0);
REQUIRE(pkt.size() == static_cast<size_t>(total)); // fixture is exact
client.pushPacket(pkt);
psc.setSocketTimeout(1); // bound any read as a safety net
psc.loop();
if (c.accepted) {
// Delivered exactly once with the fixture payload; the NUL write
// (adapter, enabled by default) landed on the reserved sentinel
// byte buffer[bufferSize-1] - clean under ASan.
REQUIRE(adapter.count() == 1);
CHECK(adapter.last().length == payloadLen);
CHECK(adapter.last().payload == payload);
CHECK(psc.connected());
CHECK_FALSE(client.stopCalled());
} else {
// Rejected before dispatch: no callback, connection closed.
CHECK(adapter.count() == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
}
// =======================================================================
// F-02 - PUBLISH parsing trusts topic length instead of packet length
// =======================================================================
//
// A QoS 1 PUBLISH whose 2-byte topic-length field claims far more bytes than
// the packet actually contains must be rejected without reading past the
// received bytes, and a short PUBLISH whose length arithmetic would underflow
// must not reach the callback with a huge length. The fork's guard
// (header_len = llen + 3 + tl + (qos?2:0) > len) fires first. All reads stay
// within the scripted bytes, so ASan proves no over-read. Expected PASS.
TEST_CASE("F-02 PUBLISH with claimed topic length exceeding the packet is rejected (no over-read)"
* FINDING_MARKER(F02)) {
// {fixed header, Remaining Length byte, claimed topic length, body bytes}.
// The claimed topic length far exceeds the body, and for the underflow
// cases the QoS 0 payload length (len - llen - 3 - tl) would wrap.
struct Case { const char* label; uint8_t fixedHeader; uint8_t bodyLen; uint16_t claimedTopicLen; };
const Case cases[] = {
{"QoS1 tl=500 body=4", static_cast<uint8_t>(MQTTPUBLISH | MQTTQOS1), 4, 500},
{"QoS1 tl=0x7FFF body=10",static_cast<uint8_t>(MQTTPUBLISH | MQTTQOS1),10, 0x7FFF},
{"QoS0 underflow tl=10 body=5", static_cast<uint8_t>(MQTTPUBLISH), 5, 10},
{"QoS0 underflow tl=5 body=2", static_cast<uint8_t>(MQTTPUBLISH), 2, 5},
};
for (const Case& c : cases) {
CAPTURE(c.label);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
// Sized vector => no OOB in the test code itself.
std::vector<uint8_t> frame;
frame.push_back(c.fixedHeader);
frame.push_back(c.bodyLen); // RL < 128
frame.push_back(static_cast<uint8_t>(c.claimedTopicLen >> 8)); // topic len hi
frame.push_back(static_cast<uint8_t>(c.claimedTopicLen & 0xFF)); // topic len lo
for (uint8_t i = 2; i < c.bodyLen; ++i) {
frame.push_back(static_cast<uint8_t>('x'));
}
client.pushPacket(MqttPacket::raw(frame));
psc.setSocketTimeout(1);
psc.loop();
// No callback (so no over-read and no underflowed length), closed.
CHECK(cap.count == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
// =======================================================================
// F-03 (a) - Oversized inbound packet closes promptly (Requirement 13.4)
// =======================================================================
//
// An inbound packet declaring a Remaining Length larger than the accepted
// capacity must close the connection after a bounded prefix rather than
// draining the whole declared body. The fixture supplies ONLY the fixed
// header + Remaining Length bytes (no body): a prompt-close implementation
// calls stop() as soon as it sees the oversized length; a drain-the-body
// implementation would block on the empty queue and time out WITHOUT calling
// stop(). Asserting stopCalled() proves the prompt close. Expected PASS.
TEST_CASE("F-03 oversized declared inbound packet closes the connection promptly"
* FINDING_MARKER(F03_PROMPT_CLOSE)) {
const uint32_t declaredLengths[] = {2000, 300000, 5000000, 200000000};
for (uint32_t rl : declaredLengths) {
CAPTURE(rl);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
std::vector<uint8_t> frame;
frame.push_back(static_cast<uint8_t>(MQTTPUBLISH));
const std::vector<uint8_t> rlBytes = MqttPacket::encodeRemainingLength(rl);
frame.insert(frame.end(), rlBytes.begin(), rlBytes.end());
client.pushInbound(frame);
psc.setSocketTimeout(1);
const bool looped = psc.loop();
CHECK(client.stopCalled());
CHECK_FALSE(looped);
CHECK_FALSE(psc.connected());
CHECK(psc.state() == MQTT_DISCONNECTED);
}
}
// =======================================================================
// F-03 (b) - Packet-wide read deadline (Requirement 13.5) - EXPECTED FAIL
// =======================================================================
//
// Trickle-fed inbound bytes should be bounded by a packet-wide deadline, not
// only by a per-byte socket timeout. Here the broker trickles one byte per
// simulated second while the per-byte timeout is five seconds, so no single
// byte ever times out. A hardened implementation would still abandon and
// close the packet once a packet-wide deadline elapsed; the current fork
// enforces ONLY the per-byte timeout and reads the whole packet to
// completion, dispatching the callback. The assertions encode the hardened
// expectation, so this reports an EXPECTED FAILURE.
TEST_CASE("F-03 trickle-fed inbound bytes are bounded by a packet-wide deadline"
* FINDING_MARKER(F03_DEADLINE)) {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
psc.setServer("broker.example", 1883);
psc.setKeepAlive(120); // keep keepalive well clear of the read
psc.setCallback([&cap](char* topic, uint8_t* payload, unsigned int len) {
cap.count++;
cap.topic = topic;
cap.length = len;
cap.payload.assign(payload, payload + len);
});
client.pushPacket(MqttPacket::connack(0));
REQUIRE(psc.connect("f03-deadline-client"));
REQUIRE(psc.connected());
client.clearOutbound();
// A small well-formed QoS 0 PUBLISH trickled one byte per simulated
// second, inside a 5 s per-byte window. Virtual time advances via the
// shim's delay(), so this consumes no real wall-clock time.
const std::string topic = "t";
const std::vector<uint8_t> payload = {'h', 'i'};
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/0));
psc.setSocketTimeout(5);
client.setTrickle(/*bytesPerReveal=*/1, /*msPerReveal=*/1000);
psc.loop();
// Hardened expectation (fails against the current fork => expected fail).
CHECK(client.stopCalled());
CHECK(cap.count == 0);
}
// =======================================================================
// F-04 - Streaming publish length 16-bit truncation (Requirement 9.4)
// =======================================================================
//
// beginPublish() buffers only the header (fixed header + Remaining Length +
// topic) and streams the payload separately, so a very large *declared*
// length can be framed without allocating the payload. The emitted Remaining
// Length must be the full 32-bit value (plength + 2 + topicLen), not a 16-bit
// truncation that would desynchronize framing. The fork uses a uint32_t
// buildHeader bounded to four length bytes. Expected PASS.
TEST_CASE("F-04 large declared streaming length frames Remaining Length without truncation"
* FINDING_MARKER(F04)) {
const std::string topic = "t"; // topicLen == 1
const size_t topicLen = topic.size();
// Declared payload lengths above the 16-bit range, spanning the 2->3 and
// 3->4 byte Remaining Length transitions and the MQTT maximum.
const unsigned int plengths[] = {
65533u, // RL = 65536 (first value needing 3 bytes)
100000u, // RL = 100003 (3 bytes)
2097149u, // RL = 2097152 (first value needing 4 bytes)
268435455u - 2u - 1u, // RL = 268435455 (MQTT maximum, 4 bytes)
};
for (unsigned int plen : plengths) {
CAPTURE(plen);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.beginPublish(topic.c_str(), plen, false));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(out.size() >= 2);
CHECK(static_cast<uint8_t>(out[0] & 0xF0) == static_cast<uint8_t>(MQTTPUBLISH));
uint32_t rl = 0;
size_t rlBytes = 0;
REQUIRE(MqttParser::decodeRemainingLength(out, 1, rl, rlBytes));
const uint32_t expected =
static_cast<uint32_t>(plen) + 2u + static_cast<uint32_t>(topicLen);
CHECK(rl == expected); // full 32-bit value
CHECK(rl != (expected & 0xFFFFu)); // NOT a 16-bit truncation
// Header framing is self-consistent: fixed(1) + RL bytes + topic
// length prefix(2) + topic. No payload streamed yet.
CHECK(out.size() == 1u + rlBytes + 2u + topicLen);
}
}
// =======================================================================
// F-05 - Partial write corrupts the stream, connection reused - EXPECTED FAIL
// =======================================================================
//
// With a partial transport write injected (setWriteLimit), the publish path
// should report failure and leave the connection unusable so no later packet
// is written onto a desynchronized stream. The current fork returns success
// from endPublish() and keeps the connection, so the hardened assertions
// below report an EXPECTED FAILURE. Cut points are strictly less than the
// full packet length.
TEST_CASE("F-05 partial transport write fails the publish and disables reuse"
* FINDING_MARKER(F05)) {
const std::string topic = "s"; // topicLen == 1
const std::vector<uint8_t> payload = makePayload(300);
// Header = fixed(1) + RL(2, RL=303) + topic-len(2) + topic(1) = 6; full
// packet = 306. Cut points >= 6 (header write completes) and < 300 (the
// payload write is truncated) - all strictly less than 306.
const size_t cutPoints[] = {10, 50, 150, 299};
for (size_t cut : cutPoints) {
CAPTURE(cut);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
client.setWriteLimit(cut);
REQUIRE(psc.beginPublish(topic.c_str(),
static_cast<unsigned int>(payload.size()), false));
const size_t written = psc.write(payload.data(), payload.size());
REQUIRE(written < payload.size()); // a genuine partial write
// Hardened contract: endPublish reports failure and the connection is
// left unusable. The current fork does neither => expected failure.
CHECK(psc.endPublish() == 0);
CHECK_FALSE(psc.connected());
}
}
// =======================================================================
// F-06 - CONNACK response validation (Requirement 8.3)
// =======================================================================
//
// A four-byte inbound frame that is not a well-formed CONNACK (packet type
// other than 0x20, nonzero fixed flags, or Remaining Length other than 2)
// must be rejected as a failed connection, regardless of the fourth byte.
// The fork requires len==4 && buffer[0]==0x20 && buffer[1]==2. Expected PASS.
TEST_CASE("F-06 malformed four-byte CONNACK frames are rejected" * FINDING_MARKER(F06)) {
struct Case { const char* label; uint8_t b0; uint8_t b1; };
const Case malformations[] = {
{"type CONNECT (0x10)", 0x10, 0x02},
{"type PUBLISH (0x30)", 0x30, 0x02},
{"type PINGRESP (0xD0)", 0xD0, 0x02},
{"nonzero flags (0x21)", 0x21, 0x02},
{"nonzero flags (0x2F)", 0x2F, 0x02},
{"remaining length 1", 0x20, 0x01},
{"remaining length 3", 0x20, 0x03},
{"remaining length 0", 0x20, 0x00},
};
const uint8_t fourthBytes[] = {0x00, 0xFF};
for (const Case& m : malformations) {
for (uint8_t fourth : fourthBytes) {
CAPTURE(m.label);
CAPTURE(fourth);
TestClock::instance().reset();
MockClient client;
client.pushInbound({m.b0, m.b1, 0x00, fourth});
PubSubClient psc(client);
psc.setServer("broker.example", 1883);
psc.setSocketTimeout(1);
CHECK_FALSE(psc.connect("f06-client"));
CHECK_FALSE(psc.connected());
CHECK(psc.state() != MQTT_CONNECTED);
}
}
}
// =======================================================================
// F-07 - Allocation-failure buffer state (Requirement 13.2)
// =======================================================================
//
// With the allocation interposer armed to fail the next allocation, the
// realloc inside setBufferSize returns NULL. The library must NOT commit the
// new size: getBufferSize keeps reporting the previous (valid) size and the
// existing buffer stays usable. If it committed a nonzero size backed by a
// null buffer, the subsequent publish would dereference it and ASan would
// abort. Expected PASS.
TEST_CASE("F-07 a failed setBufferSize leaves no nonzero capacity backed by a null buffer"
* FINDING_MARKER(F07)) {
TestClock::instance().reset();
AllocInterposer::reset();
MockClient client;
PubSubClient psc(client);
const uint16_t before = psc.getBufferSize();
REQUIRE(before == MQTT_MAX_PACKET_SIZE);
AllocInterposer::failNextAllocation();
const bool grew = psc.setBufferSize(4096);
AllocInterposer::reset();
CHECK_FALSE(grew);
CHECK(psc.getBufferSize() == before); // size unchanged
// Prove the buffer behind that size is still valid and usable (clean
// under ASan - no null/dangling buffer left by the failed realloc).
connectAndClear(client, psc);
REQUIRE(psc.publish("tele/dev/STATE", "online"));
DecodedPublish pub = MqttParser::decodePublish(client.outbound());
REQUIRE(pub.valid);
CHECK(pub.topic == "tele/dev/STATE");
const std::string payload(pub.payload.begin(), pub.payload.end());
CHECK(payload == "online");
CHECK(psc.connected());
}
// =======================================================================
// F-08 - SUBSCRIBE off-by-one exact-buffer write + null-topic checks
// =======================================================================
//
// A SUBSCRIBE whose topic length brings the packet to exactly the working
// buffer capacity must not write the trailing QoS byte out of bounds. The
// library's bound is bufferSize >= 10 + topicLength (header(5) + msgId(2) +
// topic-length(2) + topic + qos(1)); at exact capacity the QoS byte lands at
// buffer[bufferSize-1]. An off-by-one would write buffer[bufferSize] and ASan
// would abort. Null topics must be rejected before any strnlen dereference.
// Expected PASS.
TEST_CASE("F-08 exact-buffer SUBSCRIBE stays in-bounds and null topics are rejected"
* FINDING_MARKER(F08)) {
const size_t topicLengths[] = {1, 5, 16, 50, 117};
const uint8_t qosValues[] = {0, 1};
for (size_t topicLen : topicLengths) {
for (uint8_t qos : qosValues) {
CAPTURE(topicLen);
CAPTURE(qos);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
// Shrink the buffer to EXACTLY the SUBSCRIBE bound (10 + topicLen).
const uint16_t exactCapacity = static_cast<uint16_t>(10 + topicLen);
REQUIRE(psc.setBufferSize(exactCapacity));
REQUIRE(psc.getBufferSize() == exactCapacity);
const std::string topic(topicLen, 'a');
// ASan proves the QoS byte is written at buffer[bufferSize-1], not
// buffer[bufferSize].
REQUIRE(psc.subscribe(topic.c_str(), qos));
DecodedSubscribe s = MqttParser::decodeSubscribe(client.outbound());
REQUIRE(s.valid);
REQUIRE(s.filters.size() == 1);
CHECK(s.filters[0] == topic);
REQUIRE(s.requestedQos.size() == 1);
CHECK(s.requestedQos[0] == qos);
}
}
// Null-topic rejection (no strnlen(nullptr) dereference): subscribe /
// unsubscribe return false and emit nothing.
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
CHECK_FALSE(psc.subscribe(nullptr));
CHECK_FALSE(psc.subscribe(nullptr, 1));
CHECK_FALSE(psc.unsubscribe(nullptr));
CHECK(client.outbound().empty());
CHECK(psc.connected());
}
// =======================================================================
// F-09 - Unsupported/malformed QoS in inbound PUBLISH (Req 11.5, 11.6)
// =======================================================================
//
// An inbound PUBLISH whose QoS bits encode 2 (unsupported) or 3 (illegal)
// must be rejected rather than parsed as QoS 0. Each fixture is otherwise
// well-framed, so a naive parser would dispatch it; the fork rejects qos > 1
// up front. Expected PASS.
TEST_CASE("F-09 inbound PUBLISH with QoS 2 or QoS 3 is rejected" * FINDING_MARKER(F09)) {
const uint8_t badQos[] = {2, 3};
for (uint8_t qos : badQos) {
CAPTURE(qos);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
const std::string topic = "tele/dev/SENSOR";
const std::vector<uint8_t> payload = makePayload(16);
// qos > 0 => the builder emits a packet identifier, so the frame is
// well-formed apart from the illegal/unsupported QoS.
client.pushPacket(MqttPacket::publish(topic, payload, qos,
/*retained=*/false, /*msgId=*/0x0042));
psc.setSocketTimeout(1);
psc.loop();
CHECK(cap.count == 0); // not dispatched as QoS 0
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
// =======================================================================
// F-10 - SUBACK / session-present tracking (Requirement 10.4) - EXPECTED FAIL
// =======================================================================
//
// A subscribe operation should reflect the SUBACK return code rather than
// reporting success unconditionally. A hardened implementation, on receiving
// a SUBACK with the failure return code (0x80), would surface the failure.
// The current fork ignores SUBACK and returns the transport write result, so
// the hardened assertion reports an EXPECTED FAILURE.
TEST_CASE("F-10 subscribe reflects the SUBACK return code" * FINDING_MARKER(F10)) {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
// SUBACK for the first packet identifier (nextMsgId advances to 1) with
// the MQTT "failure" return code 0x80.
client.pushPacket(MqttPacket::suback(1, 0x80));
CHECK_FALSE(psc.subscribe("tele/dev/SENSOR"));
}
// =======================================================================
// F-11 - Graceful disconnect default (Requirement 8.4) - EXPECTED FAIL
// =======================================================================
//
// disconnect() with no argument should transmit an MQTT DISCONNECT packet.
// The current fork defaults to an ungraceful transport close and writes
// nothing, so the hardened assertion reports an EXPECTED FAILURE.
TEST_CASE("F-11 disconnect() with no argument transmits a DISCONNECT packet"
* FINDING_MARKER(F11)) {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
psc.disconnect(); // default argument: no DISCONNECT packet in current fork
DecodedPacket d = MqttParser::decode(client.outbound());
REQUIRE(d.valid);
CHECK(d.type == static_cast<uint8_t>(MQTTDISCONNECT));
CHECK(d.remainingLength == 0);
}
// =======================================================================
// F-12 - Test-suite quality meta-finding
// =======================================================================
//
// F-12 is not a library bug but a property of the TEST SYSTEM: the original
// tests contained their own out-of-bounds stack writes and missed the
// dangerous boundaries. This test system addresses it by construction -
// adversarial fixtures are built via sized std::vector<uint8_t> / MqttPacket
// (never a raw over-indexed array), memory-safety fixtures run under ASan/
// UBSan (on by default), and an integration boundary exercises the real
// callback contract. This fixture documents and verifies that meta-finding:
// it builds a battery of adversarial frames the way the whole suite does,
// confirms each is a properly sized container (no OOB in the test code
// itself, Requirement 14.3), and drives the worst-case fixture through the
// library so a regression that read past the received bytes would trip ASan.
TEST_CASE("F-12 adversarial fixtures are built via sized containers and run clean under sanitizers") {
// A battery of adversarial frames, each built ONLY through MqttPacket /
// sized std::vector<uint8_t>. Indexing stays within .size() throughout.
std::vector<std::vector<uint8_t>> fixtures;
// Malformed CONNACK (F-06 shape).
fixtures.push_back({0x30, 0x02, 0x00, 0x00});
// QoS 1 PUBLISH claiming a topic far larger than the packet (F-02 shape).
{
std::vector<uint8_t> f = {static_cast<uint8_t>(MQTTPUBLISH | MQTTQOS1),
0x04, 0x01, 0xF4, 'x', 'y'};
fixtures.push_back(f);
}
// Oversized declared Remaining Length, no body (F-03 shape).
{
std::vector<uint8_t> f = {static_cast<uint8_t>(MQTTPUBLISH)};
const std::vector<uint8_t> rl = MqttPacket::encodeRemainingLength(5000000);
f.insert(f.end(), rl.begin(), rl.end());
fixtures.push_back(f);
}
// Well-formed QoS 3 PUBLISH (F-09 shape), built structurally.
fixtures.push_back(MqttPacket::publish("t", std::vector<uint8_t>{'a'},
/*qos=*/3, /*retained=*/false,
/*msgId=*/0x0001).bytes());
// Meta-assertion: every fixture is a properly sized container and every
// byte is addressable within bounds (the test code performs no OOB
// access when constructing or reading its own fixtures).
for (const std::vector<uint8_t>& f : fixtures) {
CHECK(f.size() >= 2);
size_t seen = 0;
for (size_t i = 0; i < f.size(); ++i) {
(void)f[i]; // in-bounds by construction (i < f.size())
++seen;
}
CHECK(seen == f.size());
}
// Drive the worst-case over-claiming fixture through the library. If a
// regression read beyond the received bytes, ASan/UBSan would abort the
// process; reaching the end of this case cleanly is the verification.
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
client.pushPacket(MqttPacket::raw(fixtures[1])); // the F-02 over-claim
psc.setSocketTimeout(1);
psc.loop();
// The adversarial packet is rejected without dispatching the callback and
// without any out-of-bounds access (ASan-clean run).
CHECK(cap.count == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
@@ -0,0 +1,260 @@
/*
integration_test.cpp - Tasmota callback-contract integration boundary tests
for the TasmotaPubSub host test system (task 10.10).
Feature: tasmota-pubsub-tests
These tests exercise the *seam between the library and its real consumer*: an
inbound PUBLISH is scripted into the MockClient, delivered to the *unmodified*
library via psc.loop(), and dispatched to the CallbackContractAdapter. The
adapter faithfully reproduces the Tasmota `MqttDataHandler` contract from
xdrv_02_9_mqtt.ino by writing a NUL byte at index `data_len` of the delivered
payload buffer (`mqtt_data[data_len] = 0`, Requirement 15.1). Because that
payload pointer points INTO the library's own working-buffer allocation, the
NUL write lands relative to that allocation and is therefore validated under
AddressSanitizer (on by default). This is what catches consumer-side boundary
bugs such as F-01 (Requirement 15).
Baseline (TEST_SUITE("baseline")), Requirement 15.2:
- A normal delivered QoS 0 / QoS 1 PUBLISH runs the adapter's NUL-write path
end to end (delivered through the library, NUL terminator written into the
library buffer) with the recorded (topic, payload, length) matching the
fixture. ASan proves the NUL write is in-bounds.
Hardening (TEST_SUITE("hardening")), Requirement 15.3 / F-01:
- Using setBufferSize to create a precise, small working buffer, inbound
PUBLISH fixtures are delivered at total wire sizes bufferSize-1,
bufferSize and bufferSize+1. Because the fork reserves a sentinel byte
(readPacket rejects total_packet >= bufferSize and idx >= bufferSize), the
exact-buffer and over-buffer packets are REJECTED before dispatch, so the
delivered length is bounded and the adapter's NUL write stays in-bounds:
no byte is written past the library buffer allocation (ASan proves it).
The accepted (bufferSize-1) case runs the real NUL write on the reserved
spare byte; the recorded length is asserted to be within the buffer bounds
and the callback dispatch is asserted to match only the accepted case.
F-01 is hardened, so this is an expected PASS (FINDING_MARKER(F01)).
Every assertion goes through the public API, the adapter's recorded values,
and decoded MockClient.outbound() wire bytes - never private members - so the
suite stays durable across a future MQTT 5 migration (Requirement 19.1). The
library under test is NOT modified.
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "CallbackContractAdapter.h"
#include "FindingStatus.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Connect the client/psc pair with a scripted CONNACK, register the faithful
// MqttDataHandler-contract adapter as the callback, and clear the recorded
// CONNECT bytes so only the packet-under-test remains in outbound().
void connectWithAdapter(MockClient& client, PubSubClient& psc,
CallbackContractAdapter& adapter) {
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
psc.setCallback(adapter.callback());
REQUIRE(psc.connect("integration-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
// Deterministic binary payload of length n (includes 0x00 bytes so the
// round-trip proves the dispatch is binary-safe and length-driven, not
// dependent on C-string termination).
std::vector<uint8_t> makePayload(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; ++i) {
v[i] = static_cast<uint8_t>((i * 31u + 7u) & 0xFFu);
}
return v;
}
} // namespace
TEST_SUITE("baseline") {
// --- 15.2: the adapter NUL-write path runs on a normal delivered PUBLISH -
// A normal QoS 0 PUBLISH delivered through the library to the adapter runs
// the real `mqtt_data[data_len] = 0` write into the library working buffer.
// The recorded (topic, payload, length) matches the fixture and the NUL
// write is in-bounds (ASan proves it, since the write targets the reserved
// spare byte of the library allocation).
TEST_CASE("integration: adapter NUL-write path runs on a normal QoS 0 PUBLISH") {
struct Case { const char* label; std::string topic; std::vector<uint8_t> payload; };
std::vector<Case> cases = {
{"empty payload", "tele/dev/STATE", {}},
{"short payload", "cmnd/dev/POWER", {'O','N'}},
{"binary payload", "tele/dev/SENSOR", makePayload(128)},
{"single-char topic","t", makePayload(40)},
};
for (const Case& c : cases) {
CAPTURE(c.label);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackContractAdapter adapter;
connectWithAdapter(client, psc, adapter);
client.pushPacket(MqttPacket::publish(c.topic, c.payload, /*qos=*/0));
REQUIRE(psc.loop());
// The adapter recorded exactly one invocation (the payload copy is
// captured BEFORE the NUL write) and then performed the NUL write
// into the delivered library buffer - clean under ASan.
REQUIRE(adapter.performsNulWrite());
REQUIRE(adapter.count() == 1);
CHECK(adapter.last().topic == c.topic);
CHECK(adapter.last().length == c.payload.size());
CHECK(adapter.last().payload == c.payload);
// A QoS 0 PUBLISH is not acknowledged; the connection stays healthy.
CHECK(client.outbound().empty());
CHECK(psc.connected());
CHECK_FALSE(client.stopCalled());
}
}
// A QoS 1 PUBLISH exercises the same adapter NUL-write contract while the
// library additionally records a PUBACK outbound. This confirms the
// adapter path is driven correctly on the acknowledged receive path too.
TEST_CASE("integration: adapter NUL-write path runs on a QoS 1 PUBLISH and a PUBACK is emitted") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackContractAdapter adapter;
connectWithAdapter(client, psc, adapter);
const std::string topic = "cmnd/dev/POWER";
const std::vector<uint8_t> payload = makePayload(64);
const uint16_t msgId = 0x1234;
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/1,
/*retained=*/false, msgId));
REQUIRE(psc.loop());
REQUIRE(adapter.count() == 1);
CHECK(adapter.last().topic == topic);
CHECK(adapter.last().length == payload.size());
CHECK(adapter.last().payload == payload);
// The PUBACK echoing the message id is recorded outbound.
DecodedPacket ack = MqttParser::decode(client.outbound());
REQUIRE(ack.valid);
CHECK(ack.type == static_cast<uint8_t>(MQTTPUBACK));
CHECK(ack.remainingLength == 2);
REQUIRE(ack.payload.size() == 2);
const uint16_t ackMsgId =
static_cast<uint16_t>((ack.payload[0] << 8) | ack.payload[1]);
CHECK(ackMsgId == msgId);
CHECK(psc.connected());
}
}
TEST_SUITE("hardening") {
// --- 15.3 / F-01: exact-buffer inbound PUBLISH boundary under ASan -------
// Feature: tasmota-pubsub-tests
//
// An exact-buffer inbound PUBLISH must not cause the adapter's
// `mqtt_data[data_len] = 0` write to access memory beyond the library
// buffer allocation (Requirement 15.3). setBufferSize creates precise, small
// working buffers; PUBLISH fixtures are delivered at total wire sizes
// bufferSize-1, bufferSize and bufferSize+1:
// total == bufferSize-1 : ACCEPTED -> callback fires; the adapter NUL write
// lands on the reserved spare byte
// buffer[bufferSize-1] (in-bounds).
// total == bufferSize : REJECTED -> no callback; connection closed.
// total == bufferSize+1 : REJECTED -> no callback; connection closed.
// Because the exact/over-buffer packets are rejected before dispatch, the
// delivered length is bounded and no byte is ever written past the library
// allocation. AddressSanitizer proves the boundary; the assertions also pin
// the recorded length to within the buffer bounds and require the callback
// to fire only for the accepted case. F-01 is hardened => expected PASS.
TEST_CASE("F-01 integration: exact-buffer inbound PUBLISH keeps the adapter NUL write in-bounds"
* FINDING_MARKER(F01)) {
// Several precise buffer sizes. topicLen == 1 => total wire size of a
// QoS 0 PUBLISH = 1 (fixed) + 1 (RL byte, RL < 128) + 2 (topic-length
// field) + 1 (topic) + payloadLen = 5 + payloadLen, so payloadLen =
// total - 5. Sizes are kept small enough that the Remaining Length stays
// in a single byte (RL = 2 + topicLen + payloadLen < 128).
const uint16_t bufferSizes[] = {32, 64, 100};
const std::string topic = "t"; // topicLen == 1
struct Case { const char* label; int totalDelta; bool accepted; };
const Case cases[] = {
{"bufferSize-1 (accepted)", -1, true},
{"bufferSize (rejected)", 0, false},
{"bufferSize+1 (rejected)", +1, false},
};
for (uint16_t kBufferSize : bufferSizes) {
for (const Case& c : cases) {
CAPTURE(kBufferSize);
CAPTURE(c.label);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackContractAdapter adapter;
// Register the faithful MqttDataHandler-contract adapter so the
// accepted case performs the real NUL-termination write into the
// library buffer under ASan.
connectWithAdapter(client, psc, adapter);
// Precisely size the working buffer AFTER connect (CONNECT needs
// the default buffer); the three boundary totals are then exact.
REQUIRE(psc.setBufferSize(kBufferSize));
REQUIRE(psc.getBufferSize() == kBufferSize);
const int total = static_cast<int>(kBufferSize) + c.totalDelta;
const size_t payloadLen = static_cast<size_t>(total) - 5;
const std::vector<uint8_t> payload = makePayload(payloadLen);
// Built structurally via the packet builder (sized vectors only,
// so the test code performs no out-of-bounds writes).
const MqttPacket pkt = MqttPacket::publish(topic, payload, /*qos=*/0);
REQUIRE(pkt.size() == static_cast<size_t>(total)); // fixture is exact
client.pushPacket(pkt);
psc.setSocketTimeout(1); // bound any read as a safety net
psc.loop();
if (c.accepted) {
// Delivered exactly once with the fixture payload; the NUL
// write (adapter, enabled by default) landed on the reserved
// sentinel byte buffer[bufferSize-1] - no byte written past
// the library allocation (ASan proves it).
REQUIRE(adapter.count() == 1);
CHECK(adapter.last().length == payloadLen);
CHECK(adapter.last().payload == payload);
// The delivered length + its NUL terminator stay within the
// library buffer bounds (data_len < bufferSize).
CHECK(adapter.last().length < kBufferSize);
CHECK(psc.connected());
CHECK_FALSE(client.stopCalled());
} else {
// Rejected before dispatch: the adapter is never invoked, so
// no NUL write occurs at or beyond buffer[bufferSize]; the
// connection is closed.
CHECK(adapter.count() == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
}
}
}
@@ -0,0 +1,227 @@
/*
keepalive_test.cpp - keepalive / ping cycle characterization for the
TasmotaPubSub host test system (task 10.6).
Keepalive is driven entirely in VIRTUAL time: the shim's delay() advances the
same TestClock that the library's millis() reads, so advancing the clock past
the keepalive interval and calling loop() exercises the ping machinery with no
real elapsed wall-clock time (Requirement 7.3). setKeepAlive() (seconds) is
configured BEFORE connect so the CONNECT packet and the loop() timing both use
the chosen interval.
Baseline (TEST_SUITE("baseline")):
- Advancing TestClock beyond the keepalive interval then calling loop()
records a PINGREQ (Requirement 12.1).
- A scripted PINGRESP clears the outstanding-ping state, so the connection
stays alive and a later idle interval issues a further PINGREQ instead of
declaring a timeout (Requirement 12.2).
- Advancing beyond the interval a second time with no PINGRESP reports a lost
connection (Requirement 12.3).
Properties (folded in as single deterministic, data-driven cases over a curated
interval table {15 s default, 2 s short} - NO randomized generators):
- Property 8: exactly one PINGREQ is issued per idle interval, and after a
matching PINGRESP another PINGREQ is issued on the next interval rather
than a timeout being declared (Requirements 12.1, 12.2, 18.9).
- Property 9: advancing beyond two consecutive intervals with no PINGRESP
reports a lost/timed-out connection (Requirements 12.3, 18.8, 18.9).
Every assertion goes through the public API and decoded MockClient.outbound()
wire bytes - never private members - so the baseline stays durable across a
future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <vector>
#include "doctest.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Connect the client/psc pair with a scripted CONNACK using the supplied
// keepalive interval (seconds, set BEFORE connect), then clear the recorded
// CONNECT bytes so only the packets emitted by loop() remain in outbound().
void connectAndClear(MockClient& client, PubSubClient& psc, uint16_t keepAliveSecs) {
client.pushPacket(MqttPacket::connack(0)); // return code 0 = accepted
psc.setServer("broker.example", 1883);
psc.setKeepAlive(keepAliveSecs);
REQUIRE(psc.connect("keepalive-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
// Move virtual time strictly beyond one keepalive interval (the library's guard
// is `t - lastActivity > keepAlive*1000`). A generous 1 s margin also absorbs
// any small virtual time consumed during connect().
void advanceBeyondInterval(uint16_t keepAliveSecs) {
TestClock::instance().advance(static_cast<unsigned long>(keepAliveSecs) * 1000UL + 1000UL);
}
// True when `out` is exactly one MQTT PINGREQ control packet (0xC0 0x00).
bool isSinglePingreq(const std::vector<uint8_t>& out) {
DecodedPacket d = MqttParser::decode(out);
return d.valid &&
d.type == static_cast<uint8_t>(MQTTPINGREQ) &&
d.flags == 0x00 &&
d.remainingLength == 0 &&
out.size() == 2;
}
} // namespace
TEST_SUITE("baseline") {
// --- 12.1: idle beyond the interval + loop() => PINGREQ recorded --------
TEST_CASE("advancing beyond the keepalive interval and calling loop records a PINGREQ") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc, MQTT_KEEPALIVE); // default 15 s
advanceBeyondInterval(MQTT_KEEPALIVE);
REQUIRE(psc.loop()); // still connected
// The recorded outbound is exactly one well-framed PINGREQ.
CHECK(isSinglePingreq(client.outbound()));
CHECK(psc.connected());
}
// --- 12.2: a scripted PINGRESP clears the outstanding-ping state --------
TEST_CASE("a scripted PINGRESP clears the outstanding-ping state and keeps the connection alive") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc, MQTT_KEEPALIVE);
// First idle interval: a PINGREQ goes out and a ping is now outstanding.
advanceBeyondInterval(MQTT_KEEPALIVE);
REQUIRE(psc.loop());
REQUIRE(isSinglePingreq(client.outbound()));
// Deliver the matching PINGRESP; loop() consumes it and clears the
// outstanding-ping state. No bytes are written back for a PINGRESP.
client.clearOutbound();
client.pushPacket(MqttPacket::pingresp());
REQUIRE(psc.loop());
CHECK(client.outbound().empty());
CHECK(psc.connected());
// Because the outstanding ping was cleared, the next idle interval issues
// a FURTHER PINGREQ instead of declaring a timeout - the observable proof
// that the outstanding-ping state was reset.
client.clearOutbound();
advanceBeyondInterval(MQTT_KEEPALIVE);
REQUIRE(psc.loop());
CHECK(isSinglePingreq(client.outbound()));
CHECK(psc.connected());
}
// --- 12.3: idle beyond the interval with no PINGRESP => lost connection --
TEST_CASE("advancing beyond the interval without a PINGRESP reports a lost connection") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc, MQTT_KEEPALIVE);
// First interval: PINGREQ sent, ping now outstanding.
advanceBeyondInterval(MQTT_KEEPALIVE);
REQUIRE(psc.loop());
REQUIRE(isSinglePingreq(client.outbound()));
// Second interval with no PINGRESP delivered: the library detects the
// unanswered ping and reports a lost/timed-out connection.
advanceBeyondInterval(MQTT_KEEPALIVE);
CHECK_FALSE(psc.loop());
CHECK_FALSE(psc.connected());
CHECK(psc.state() == MQTT_CONNECTION_TIMEOUT);
CHECK(client.stopCalled());
}
// --- Property 8: exactly one PINGREQ per idle interval ------------------
// Feature: tasmota-pubsub-tests, Property 8: for all keepalive intervals,
// when the virtual clock is advanced beyond the interval with no other
// activity and loop() is called, exactly one PINGREQ is recorded; and after a
// matching PINGRESP is delivered, advancing another interval issues a further
// PINGREQ rather than declaring a timeout. Validated deterministically over a
// curated interval table (default 15 s and a short 2 s), no randomized
// generators.
TEST_CASE("Property 8: keepalive issues exactly one PINGREQ per idle interval") {
const uint16_t intervals[] = {MQTT_KEEPALIVE, 2};
for (uint16_t keepAliveSecs : intervals) {
CAPTURE(keepAliveSecs);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc, keepAliveSecs);
// One idle interval => exactly one PINGREQ.
advanceBeyondInterval(keepAliveSecs);
REQUIRE(psc.loop());
CHECK(isSinglePingreq(client.outbound()));
// Calling loop() again WITHOUT advancing must not emit a second
// PINGREQ within the same idle interval.
client.clearOutbound();
REQUIRE(psc.loop());
CHECK(client.outbound().empty());
CHECK(psc.connected());
// Deliver the matching PINGRESP so the outstanding ping is cleared.
client.clearOutbound();
client.pushPacket(MqttPacket::pingresp());
REQUIRE(psc.loop());
CHECK(client.outbound().empty());
CHECK(psc.connected());
// The next idle interval issues a FURTHER PINGREQ (not a timeout).
client.clearOutbound();
advanceBeyondInterval(keepAliveSecs);
REQUIRE(psc.loop());
CHECK(isSinglePingreq(client.outbound()));
CHECK(psc.connected());
}
}
// --- Property 9: missing PINGRESP produces a lost connection ------------
// Feature: tasmota-pubsub-tests, Property 9: for all keepalive intervals,
// advancing the virtual clock beyond two consecutive intervals without
// delivering a PINGRESP causes the library to report a lost/timed-out
// connection. Validated deterministically over a curated interval table
// (default 15 s and a short 2 s), no randomized generators.
TEST_CASE("Property 9: missing PINGRESP over two intervals reports a lost connection") {
const uint16_t intervals[] = {MQTT_KEEPALIVE, 2};
for (uint16_t keepAliveSecs : intervals) {
CAPTURE(keepAliveSecs);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc, keepAliveSecs);
// First interval: PINGREQ sent, connection still alive.
advanceBeyondInterval(keepAliveSecs);
REQUIRE(psc.loop());
CHECK(isSinglePingreq(client.outbound()));
CHECK(psc.connected());
// Second interval with no PINGRESP: the unanswered ping trips the
// keepalive timeout and the connection is reported lost.
advanceBeyondInterval(keepAliveSecs);
CHECK_FALSE(psc.loop());
CHECK_FALSE(psc.connected());
CHECK(psc.state() == MQTT_CONNECTION_TIMEOUT);
CHECK(client.stopCalled());
}
}
}
@@ -0,0 +1,72 @@
/*
AllocInterposer.cpp - Implementation of the allocation-failure injection used
by the F-07 hardening test (Requirement 13.2).
This translation unit is compiled with the NORMAL build rule (no AllocShim.h
force-include), so `malloc`/`realloc` below are the real C-library allocators.
Only the library TU (PubSubClient.cpp) has its malloc/realloc macro-routed to
the tpubsub_test_* wrappers defined here.
*/
#include "AllocInterposer.h"
#include <cstdlib>
namespace {
// Number of upcoming routed allocations still armed to fail. Single-threaded.
unsigned g_failCountdown = 0;
unsigned g_mallocCount = 0;
unsigned g_reallocCount = 0;
} // namespace
namespace AllocInterposer {
void failNextAllocation() {
g_failCountdown = 1;
}
void failNextAllocations(unsigned n) {
g_failCountdown = n;
}
void reset() {
g_failCountdown = 0;
g_mallocCount = 0;
g_reallocCount = 0;
}
unsigned pendingFailures() {
return g_failCountdown;
}
unsigned mallocCount() {
return g_mallocCount;
}
unsigned reallocCount() {
return g_reallocCount;
}
} // namespace AllocInterposer
extern "C" void* tpubsub_test_malloc(size_t size) {
++g_mallocCount;
if (g_failCountdown > 0) {
--g_failCountdown;
return nullptr; // model malloc() returning NULL
}
return malloc(size);
}
extern "C" void* tpubsub_test_realloc(void* ptr, size_t size) {
++g_reallocCount;
if (g_failCountdown > 0) {
--g_failCountdown;
// Model realloc() failing: return NULL and leave the original block
// (ptr) untouched, exactly as the C contract specifies.
return nullptr;
}
return realloc(ptr, size);
}
@@ -0,0 +1,57 @@
/*
AllocInterposer.h - Allocation-failure injection for the TasmotaPubSub host
test system (support for F-07 / Requirement 13.2).
The *unmodified* library allocates its working buffer with malloc()/realloc()
in setBufferSize(). To exercise the allocation-failure path deterministically
on the host, the library translation unit is compiled with the malloc/realloc
calls routed through the C-linkage wrappers declared below (see
src/lib/AllocShim.h, force-included ONLY when compiling PubSubClient.cpp - so
no other translation unit, the shim, doctest, or the tests are affected).
Tests arm the next allocation(s) to fail via the AllocInterposer API. When
disarmed (the default), every wrapper call passes straight through to the real
allocator, so behavior is identical to a normal build. When armed, the wrapper
returns nullptr WITHOUT calling the real allocator - modelling malloc()/
realloc() returning NULL. Because realloc() leaving its original block intact
on failure is the real contract, an armed realloc wrapper also leaves the
caller's pointer untouched (it simply never calls realloc()).
This is intentionally single-threaded (the test binary is single-threaded) and
keeps counters so tests can assert how many allocations the library performed.
*/
#ifndef TASMOTA_PUBSUB_TEST_ALLOC_INTERPOSER_H
#define TASMOTA_PUBSUB_TEST_ALLOC_INTERPOSER_H
#include <cstddef>
namespace AllocInterposer {
// Arm exactly the next routed allocation (malloc or realloc) to fail.
void failNextAllocation();
// Arm the next `n` routed allocations to fail (n == 0 disarms).
void failNextAllocations(unsigned n);
// Disarm any pending failures and reset the malloc/realloc call counters. A
// test should call this before arming and again once the failure has been
// exercised, so later tests in the same process are never affected.
void reset();
// How many armed failures are still pending (0 when disarmed).
unsigned pendingFailures();
// Total number of routed malloc()/realloc() calls seen since the last reset().
unsigned mallocCount();
unsigned reallocCount();
} // namespace AllocInterposer
// C-linkage wrappers the library TU's malloc()/realloc() calls are routed to
// (via the macros in AllocShim.h). Declared here so both the library shim and
// the interposer implementation agree on the signatures.
extern "C" void* tpubsub_test_malloc(size_t size);
extern "C" void* tpubsub_test_realloc(void* ptr, size_t size);
#endif // TASMOTA_PUBSUB_TEST_ALLOC_INTERPOSER_H
@@ -0,0 +1,45 @@
/*
AllocShim.h - malloc/realloc routing header, force-included ONLY when compiling
the library under test (PubSubClient.cpp). See tests/Makefile ($(LIB_OBJ)).
Purpose: route the *unmodified* library's malloc()/realloc() calls through the
test interposer (AllocInterposer) so the F-07 allocation-failure path can be
exercised deterministically (Requirement 13.2), WITHOUT modifying the library
source itself.
Safety: every standard header the library pulls in is included HERE, before the
macros are defined. That way the function-like `malloc(`/`realloc(` macros
only ever rewrite the library's OWN call sites and can never corrupt a
declaration inside <cstdlib>, <functional>, <string>, etc. (those are all
processed while the macros are still undefined, and their include guards make
the library's later #includes no-ops). The shim headers the library includes
(Arduino.h / Client.h / Stream.h / IPAddress.h) contain no malloc/realloc call
sites, so they are unaffected.
Scope: because this header is force-included ONLY for the library object, no
other translation unit (the shim .cpp files, doctest, or the *_test.cpp files)
sees these macros. AllocInterposer.cpp therefore calls the real allocators.
*/
#ifndef TASMOTA_PUBSUB_TEST_ALLOC_SHIM_H
#define TASMOTA_PUBSUB_TEST_ALLOC_SHIM_H
// Lock in every standard header the library TU depends on BEFORE defining the
// macros, so no system declaration of malloc/realloc is ever rewritten.
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <cstdlib>
#include <functional>
#include <string>
#include "AllocInterposer.h"
// Route the library's own malloc()/realloc() call sites to the interposer.
// free() is intentionally left as the real deallocator: the wrappers return
// blocks obtained from the real malloc()/realloc(), so plain free() releases
// them correctly (including the destructor's free(this->buffer)).
#define malloc(sz) tpubsub_test_malloc((sz))
#define realloc(p, sz) tpubsub_test_realloc((p), (sz))
#endif // TASMOTA_PUBSUB_TEST_ALLOC_SHIM_H
@@ -0,0 +1,47 @@
/*
Arduino.cpp - Host-side Arduino core shim time primitives for TasmotaPubSub tests.
Defines the time / scheduling primitives declared in Arduino.h by wiring them to
the virtual TestClock (Clock_Injector). This is the mechanism that lets the
*unmodified* PubSubClient library's busy-wait loops terminate deterministically
in finite virtual time, without any real elapsed wall-clock time:
- readByte() busy-waits calling delay(1) while polling the transport.
- connect() busy-waits calling delay(0) while waiting for CONNACK.
- loop() keepalive compares millis() against the last activity timestamp.
Because onDelay() always advances the virtual clock by at least autoStep (>= 1 ms),
even delay(0) makes forward progress, so an exhausted inbound queue drives the
library's millis()-based timeout path to completion instead of spinning forever.
Requirements: 7.1 (millis() supplies the library's time), 7.2 (advanced value is
observed on subsequent reads), 7.3 (target time reached without real elapsed time).
*/
#include "Arduino.h"
#include "TestClock.h"
// Requirement 7.1 / 7.2: the library's notion of "now" is the virtual clock.
unsigned long millis() {
return TestClock::instance().millis();
}
// Requirement 7.3: delay() advances virtual time (never sleeps on the wall clock).
// onDelay() guarantees a minimum forward step so delay(0)/delay(1) busy-wait loops
// in the library terminate deterministically.
void delay(unsigned long ms) {
TestClock::instance().onDelay(ms);
}
// Microsecond delays are sub-millisecond for the library's purposes. Route them
// through the same forward-progress path so no code path can wall-clock sleep;
// onDelay(0) advances by autoStep, keeping any micro-scale busy-wait deterministic.
void delayMicroseconds(unsigned int /*us*/) {
TestClock::instance().onDelay(0);
}
// yield() cooperatively reschedules on-device; on the host there is nothing to
// yield to, so it is a no-op (advances by 0).
void yield() {
// no-op
}
@@ -0,0 +1,60 @@
/*
Arduino.h - Host-side Arduino core shim for TasmotaPubSub host tests.
Provides the Arduino primitives the *unmodified* PubSubClient library depends
on when compiled on a host (g++/clang) instead of an ESP toolchain. This is the
platform seam: the library `#include <Arduino.h>` resolves to this header
because `tests/src/lib` is first on the include path.
Time primitives (millis/delay/yield) are DECLARED here but intentionally NOT
defined. They are wired to the virtual TestClock in a later task (3.2). The
library links against these declarations.
*/
#ifndef TASMOTA_PUBSUB_TEST_ARDUINO_H
#define TASMOTA_PUBSUB_TEST_ARDUINO_H
#include <cstdint>
#include <cstring>
#include <cstdlib>
#include <cstddef>
#include <functional>
// The library selects the std::function callback signature only when building
// for an ESP target. The host tests must build with -DESP32 so lambdas with
// captures can be bound as callbacks. Make the requirement explicit.
#if !defined(ESP32) && !defined(ESP8266)
#error "Build the host shim with -DESP32 so MQTT_CALLBACK_SIGNATURE resolves to std::function"
#endif
// Arduino type aliases used by the library.
typedef bool boolean;
typedef uint8_t byte;
// PROGMEM is a no-op on a host with a flat address space. publish_P therefore
// reads normal memory via pgm_read_byte_near.
#ifndef PROGMEM
#define PROGMEM
#endif
#ifndef PGM_P
typedef const char* PGM_P;
#endif
#ifndef pgm_read_byte_near
#define pgm_read_byte_near(addr) (*reinterpret_cast<const uint8_t*>(addr))
#endif
#ifndef pgm_read_byte
#define pgm_read_byte(addr) (*reinterpret_cast<const uint8_t*>(addr))
#endif
// Time / scheduling primitives. Declared only; defined against TestClock later.
unsigned long millis();
void delay(unsigned long ms);
void delayMicroseconds(unsigned int us);
void yield();
// Arduino String, provided by <Arduino.h> on the real platform.
#include "ArduinoString.h"
#endif // TASMOTA_PUBSUB_TEST_ARDUINO_H
@@ -0,0 +1,22 @@
/*
ArduinoString.cpp - Host-side minimal Arduino `String` shim implementation.
*/
#include "ArduinoString.h"
String::String() : _str() {}
String::String(const char* cstr) : _str(cstr ? cstr : "") {}
String& String::operator=(const char* cstr) {
_str = cstr ? cstr : "";
return *this;
}
size_t String::length() const {
return _str.length();
}
const char* String::c_str() const {
return _str.c_str();
}
@@ -0,0 +1,35 @@
/*
ArduinoString.h - Host-side minimal Arduino `String` shim for TasmotaPubSub tests.
The library only uses a tiny slice of the Arduino String API:
- `String domain;` (default construction)
- `this->domain = domain;` (assignment from const char*)
- `this->domain = "";` (assignment from string literal)
- `domain.length()` (is-empty test)
- `domain.c_str()` (passed to Client::connect)
This class backs those with std::string and handles nullptr safely.
*/
#ifndef TASMOTA_PUBSUB_TEST_ARDUINO_STRING_H
#define TASMOTA_PUBSUB_TEST_ARDUINO_STRING_H
#include <cstddef>
#include <string>
class String {
public:
String();
String(const char* cstr);
String(const String& other) = default;
String& operator=(const char* cstr);
String& operator=(const String& other) = default;
size_t length() const;
const char* c_str() const;
private:
std::string _str;
};
#endif // TASMOTA_PUBSUB_TEST_ARDUINO_STRING_H
@@ -0,0 +1,52 @@
/*
CallbackContractAdapter.cpp - Implementation of the Tasmota MqttDataHandler
contract reproduction (task 8.1). See CallbackContractAdapter.h for the
rationale behind writing the NUL terminator into the delivered buffer.
*/
#include "CallbackContractAdapter.h"
#include <cassert>
CallbackContractAdapter::CallbackContractAdapter()
: _invocations(), _performNulWrite(true) {}
void CallbackContractAdapter::onMessage(char* topic, uint8_t* payload,
unsigned int length) {
// Record the invocation first: copy the delivered payload bytes exactly as
// received, before any NUL-termination write mutates the buffer. The topic
// is a C-string the library has already NUL-terminated.
Invocation inv;
inv.topic = (topic != nullptr) ? std::string(topic) : std::string();
inv.length = length;
if (payload != nullptr && length > 0) {
inv.payload.assign(payload, payload + length);
}
_invocations.push_back(std::move(inv));
// Reproduce the exact Tasmota driver contract: mqtt_data[data_len] = 0.
// `payload` points into the library's working buffer, so this write lands
// relative to the library allocation. For a well-formed packet the library
// reserves the spare byte at buffer[len], so the write is in-bounds; an
// exact-buffer packet that slipped past the guard would write one byte past
// the allocation and trip AddressSanitizer (F-01).
if (_performNulWrite && payload != nullptr) {
payload[length] = 0;
}
}
std::function<void(char*, uint8_t*, unsigned int)>
CallbackContractAdapter::callback() {
return [this](char* topic, uint8_t* payload, unsigned int length) {
this->onMessage(topic, payload, length);
};
}
const CallbackContractAdapter::Invocation& CallbackContractAdapter::last() const {
assert(!_invocations.empty() && "last() called with no recorded invocations");
return _invocations.back();
}
void CallbackContractAdapter::clear() {
_invocations.clear();
}
@@ -0,0 +1,90 @@
/*
CallbackContractAdapter.h - Faithful host reproduction of the Tasmota
`MqttDataHandler` callback contract for the TasmotaPubSub test system
(task 8.1).
Feature: tasmota-pubsub-tests
When registered as the PubSubClient callback, on each inbound PUBLISH this
adapter reproduces exactly what the real Tasmota driver does in
`xdrv_02_9_mqtt.ino`: it writes a NUL byte at index `data_len` of the
delivered payload buffer (`mqtt_data[data_len] = 0`). The delivered payload
pointer points *into the library's own working buffer allocation*, so the NUL
write lands relative to that allocation:
payload == buffer + (llen + 3 + tl)
length == len - llen - 3 - tl
&payload[length] == &buffer[len]
For a well-behaved PUBLISH the library guarantees `len < bufferSize` (the F-01
sentinel-byte guard rejects `total_packet >= bufferSize` and `idx >=
bufferSize`), so `buffer[len]` is the reserved spare byte and the NUL write is
in-bounds. If a packet ever slipped past the guard so that `len == bufferSize`,
the write would hit `buffer[bufferSize]` — one byte past the allocation — and
AddressSanitizer would trip. This is what makes the F-01 consumer-side
boundary bug observable (Requirement 15), and why the write must target the
delivered buffer rather than a private copy.
In addition to the NUL write, the adapter copies the delivered payload into a
recorded buffer and captures `(topic, payloadCopy, length)` for every
invocation, so callback-dispatch baseline assertions (Requirements 11.1/11.2)
can check the correct topic, payload bytes, and length.
The build is compiled with `-DESP32`, so `MQTT_CALLBACK_SIGNATURE` resolves to
`std::function<void(char*, uint8_t*, unsigned int)>`. `callback()` returns such
a std::function bound to this instance, suitable for
`PubSubClient::setCallback(adapter.callback())`.
*/
#ifndef TASMOTA_PUBSUB_TEST_CALLBACK_CONTRACT_ADAPTER_H
#define TASMOTA_PUBSUB_TEST_CALLBACK_CONTRACT_ADAPTER_H
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
class CallbackContractAdapter {
public:
// One recorded callback invocation. `payload` is a copy of the delivered
// bytes captured BEFORE the NUL-termination write, and `length` is the
// authoritative payload length passed by the library.
struct Invocation {
std::string topic; // NUL-terminated C-string from the library
std::vector<uint8_t> payload; // copy of the delivered payload (length bytes)
unsigned int length = 0;
};
CallbackContractAdapter();
// The MqttDataHandler-contract callback. Records the invocation and then
// performs the `payload[length] = 0` NUL-termination write into the
// delivered buffer (unless the write has been disabled for a record-only
// test). Matches the MQTT_CALLBACK_SIGNATURE argument order/types.
void onMessage(char* topic, uint8_t* payload, unsigned int length);
// Return a std::function bound to this instance, suitable for passing to
// PubSubClient::setCallback(). Because the build defines ESP32, the callback
// signature is std::function<void(char*, uint8_t*, unsigned int)>.
std::function<void(char*, uint8_t*, unsigned int)> callback();
// --- Recorded invocations ---------------------------------------------
const std::vector<Invocation>& invocations() const { return _invocations; }
std::size_t count() const { return _invocations.size(); }
const Invocation& last() const; // precondition: count() > 0
void clear();
// Toggle the NUL-termination write. Enabled by default (the faithful
// contract). Disable to record deliveries without mutating the library
// buffer (useful when a test wants to inspect the payload independently of
// the boundary write).
void setPerformNulWrite(bool enable) { _performNulWrite = enable; }
bool performsNulWrite() const { return _performNulWrite; }
private:
std::vector<Invocation> _invocations;
bool _performNulWrite;
};
#endif // TASMOTA_PUBSUB_TEST_CALLBACK_CONTRACT_ADAPTER_H
@@ -0,0 +1,46 @@
/*
Client.h - Host-side Arduino `Client` shim for TasmotaPubSub host tests.
Abstract reproduction of the Arduino network `Client` interface the library
holds as `Client* _client`. It declares the full pure-virtual set the library
actually exercises. MockClient (later task) subclasses this exactly as a real
`WiFiClient` would be used.
Inheritance mirrors Arduino: `Client : public Stream : public Print`, so the
two `write` overloads originate in `Print`. They are re-declared here as pure
virtual to express the complete transport contract the library relies on.
*/
#ifndef TASMOTA_PUBSUB_TEST_CLIENT_H
#define TASMOTA_PUBSUB_TEST_CLIENT_H
#include <cstddef>
#include <cstdint>
#include "IPAddress.h"
#include "Stream.h"
class Client : public Stream {
public:
virtual ~Client() = default;
virtual int connect(IPAddress ip, uint16_t port) = 0;
virtual int connect(const char* host, uint16_t port) = 0;
// Transport write side (declared in Print; the library calls both forms).
virtual size_t write(uint8_t) = 0;
virtual size_t write(const uint8_t* buf, size_t size) = 0;
// Read side.
virtual int available() = 0;
virtual int read() = 0;
virtual int read(uint8_t* buf, size_t size) = 0;
virtual int peek() = 0;
virtual void flush() = 0;
virtual void stop() = 0;
virtual uint8_t connected() = 0;
virtual operator bool() = 0;
};
#endif // TASMOTA_PUBSUB_TEST_CLIENT_H
@@ -0,0 +1,63 @@
// FindingStatus.h — Expected-fail registry for the TasmotaPubSub Hardening_Suite.
//
// Feature: tasmota-pubsub-tests
//
// This header maps each static-analysis finding (F-01..F-12, plus the two
// distinct aspects of F-03) to a single doctest test-case decorator. Hardening
// test cases reference a finding via FINDING_MARKER(Fxx), e.g.:
//
// TEST_CASE("F-05 partial write disables reuse" * FINDING_MARKER(F05)) { ... }
//
// A finding that is still OPEN (the library is NOT yet hardened for it) expands
// to `doctest::should_fail()`, so the run reports an *expected* failure and does
// NOT set a nonzero exit status for that failure alone (Requirement 4.6). If the
// case unexpectedly PASSES, doctest reports an unexpected pass and fails the run
// (Requirement 4.7) — the signal to flip the finding to hardened here.
//
// A finding that is already HARDENED (expected to pass) expands to a no-op
// decorator: `doctest::skip(false)` — a decorator that does NOT skip the case,
// so the test always runs and is expected to pass. We deliberately use
// `should_fail()` (never `may_fail()`, which would hide an unexpected pass).
//
// Flipping a finding from open to hardened is a one-line edit: change its
// FINDING_MARKER_* definition below from FINDING_OPEN to FINDING_HARDENED.
//
// The finding-status seeding below is the starting hypothesis derived from the
// design's finding-status table; task 12.1 confirms each empirically and
// finalizes these markers.
#ifndef TASMOTA_PUBSUB_TESTS_FINDING_STATUS_H
#define TASMOTA_PUBSUB_TESTS_FINDING_STATUS_H
#include "doctest.h"
// Semantic aliases for the two decorator states.
// FINDING_OPEN -> expected-FAIL (library not yet hardened for this finding)
// FINDING_HARDENED -> expected-PASS (no-op decorator that always runs the case)
#define FINDING_OPEN doctest::should_fail()
#define FINDING_HARDENED doctest::skip(false)
// Indirection so FINDING_MARKER(F05) resolves to the per-finding status macro.
#define FINDING_MARKER(id) FINDING_MARKER_##id
// ---------------------------------------------------------------------------
// Finding status seeding (starting hypothesis; finalized empirically in 12.1).
// ---------------------------------------------------------------------------
// Expected-FAIL (open findings).
#define FINDING_MARKER_F05 FINDING_OPEN // partial-write reuse
#define FINDING_MARKER_F10 FINDING_OPEN // SUBACK / session-present tracking
#define FINDING_MARKER_F11 FINDING_OPEN // graceful disconnect default
#define FINDING_MARKER_F03_DEADLINE FINDING_OPEN // F-03 packet-wide read deadline
// Expected-PASS (already hardened).
#define FINDING_MARKER_F01 FINDING_HARDENED // exact-buffer inbound overflow
#define FINDING_MARKER_F02 FINDING_HARDENED // PUBLISH structural checks
#define FINDING_MARKER_F04 FINDING_HARDENED // streaming length 16-bit truncation
#define FINDING_MARKER_F06 FINDING_HARDENED // CONNACK validation
#define FINDING_MARKER_F07 FINDING_HARDENED // alloc-failure buffer state
#define FINDING_MARKER_F08 FINDING_HARDENED // SUBSCRIBE off-by-one + null checks
#define FINDING_MARKER_F09 FINDING_HARDENED // QoS / fixed-header validation
#define FINDING_MARKER_F03_PROMPT_CLOSE FINDING_HARDENED // F-03 oversized prompt-close
#endif // TASMOTA_PUBSUB_TESTS_FINDING_STATUS_H
@@ -0,0 +1,38 @@
/*
IPAddress.cpp - Host-side Arduino `IPAddress` shim implementation.
*/
#include "IPAddress.h"
IPAddress::IPAddress() {
_octets[0] = 0;
_octets[1] = 0;
_octets[2] = 0;
_octets[3] = 0;
}
IPAddress::IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) {
_octets[0] = first;
_octets[1] = second;
_octets[2] = third;
_octets[3] = fourth;
}
uint8_t IPAddress::operator[](int index) const {
return _octets[index & 3];
}
uint8_t& IPAddress::operator[](int index) {
return _octets[index & 3];
}
bool IPAddress::operator==(const IPAddress& other) const {
return _octets[0] == other._octets[0] &&
_octets[1] == other._octets[1] &&
_octets[2] == other._octets[2] &&
_octets[3] == other._octets[3];
}
bool IPAddress::operator!=(const IPAddress& other) const {
return !(*this == other);
}
@@ -0,0 +1,31 @@
/*
IPAddress.h - Host-side Arduino `IPAddress` shim for TasmotaPubSub host tests.
Minimal reproduction of the Arduino `IPAddress` type. The library holds an
`IPAddress ip;` member, constructs one from 4 octets in setServer(uint8_t*),
copies/assigns it, and passes it to `Client::connect(IPAddress, uint16_t)`.
Equality is provided for completeness (used when comparing endpoints).
*/
#ifndef TASMOTA_PUBSUB_TEST_IPADDRESS_H
#define TASMOTA_PUBSUB_TEST_IPADDRESS_H
#include <cstdint>
class IPAddress {
public:
IPAddress();
IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth);
// Access an individual octet (0..3). Mirrors the Arduino operator[].
uint8_t operator[](int index) const;
uint8_t& operator[](int index);
bool operator==(const IPAddress& other) const;
bool operator!=(const IPAddress& other) const;
private:
uint8_t _octets[4];
};
#endif // TASMOTA_PUBSUB_TEST_IPADDRESS_H
@@ -0,0 +1,239 @@
/*
MockClient.cpp - Implementation of the host-side scriptable mock transport.
Task 6.1 core: inbound queue, outbound record, connection state and close
tracking. Fault injection (setWriteLimit / setTrickle) is deliberately absent
here and is added in task 6.2 at the revealedCount() / write() choke points.
*/
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
MockClient::MockClient()
: _inbound(),
_readPos(0),
_outbound(),
_writeLimit(0),
_trickleBytesPerReveal(0),
_trickleMsPerReveal(0),
_trickleBaseMs(0),
_connected(false),
_connectResult(1),
_connectCalled(false),
_lastHost(),
_lastIp(),
_lastPort(0),
_stopCalled(false),
_stopCount(0),
_flushCalled(false),
_flushCount(0) {}
// --- Inbound scripting -----------------------------------------------------
void MockClient::pushInbound(const std::vector<uint8_t>& bytes) {
_inbound.insert(_inbound.end(), bytes.begin(), bytes.end());
}
void MockClient::pushPacket(const MqttPacket& p) {
// Append the built packet's exact wire bytes to the inbound queue so the
// library reads them via read()/available() just like any scripted bytes.
pushInbound(p.bytes());
}
void MockClient::clearInbound() {
_inbound.clear();
_readPos = 0;
}
// --- Outbound record -------------------------------------------------------
const std::vector<uint8_t>& MockClient::outbound() const {
return _outbound;
}
void MockClient::clearOutbound() {
_outbound.clear();
}
// --- Fault injection -------------------------------------------------------
void MockClient::setWriteLimit(size_t maxPerWrite) {
_writeLimit = maxPerWrite;
}
void MockClient::setTrickle(size_t bytesPerReveal, unsigned long msPerReveal) {
_trickleBytesPerReveal = bytesPerReveal;
_trickleMsPerReveal = msPerReveal;
// Anchor the reveal schedule at "now" so bytesPerReveal are visible
// immediately and additional bytes appear as virtual time advances.
_trickleBaseMs = TestClock::instance().millis();
}
// --- Connection control ----------------------------------------------------
void MockClient::setConnected(bool connected) {
_connected = connected;
}
void MockClient::setConnectResult(int result) {
_connectResult = result;
}
bool MockClient::stopCalled() const {
return _stopCalled;
}
unsigned MockClient::stopCount() const {
return _stopCount;
}
bool MockClient::flushCalled() const {
return _flushCalled;
}
unsigned MockClient::flushCount() const {
return _flushCount;
}
bool MockClient::connectCalled() const {
return _connectCalled;
}
const std::string& MockClient::lastHost() const {
return _lastHost;
}
IPAddress MockClient::lastIp() const {
return _lastIp;
}
uint16_t MockClient::lastPort() const {
return _lastPort;
}
// --- Client interface ------------------------------------------------------
int MockClient::connect(IPAddress ip, uint16_t port) {
_connectCalled = true;
_lastIp = ip;
_lastPort = port;
// Model a real client: a successful connect brings the socket up. Tests can
// still override the reported state with setConnected().
if (_connectResult == 1) {
_connected = true;
}
return _connectResult;
}
int MockClient::connect(const char* host, uint16_t port) {
_connectCalled = true;
_lastHost = (host != nullptr) ? host : "";
_lastPort = port;
if (_connectResult == 1) {
_connected = true;
}
return _connectResult;
}
size_t MockClient::write(uint8_t b) {
_outbound.push_back(b);
return 1;
}
size_t MockClient::write(const uint8_t* buf, size_t size) {
if (buf == nullptr) {
return 0;
}
// Partial-write injection (task 6.2): when a write limit is configured,
// accept at most _writeLimit bytes of this call. Because _writeLimit >= 1
// whenever it is set, a nonempty write always makes >= 1 byte of progress
// (matching WiFiClientSecure_light). Only the accepted prefix is recorded,
// so repeated writes continue advancing through the caller's buffer. A
// limit of 0 preserves the core "accept everything" behavior.
size_t accepted = size;
if (_writeLimit != 0 && accepted > _writeLimit) {
accepted = _writeLimit;
}
_outbound.insert(_outbound.end(), buf, buf + accepted);
return accepted;
}
int MockClient::available() {
return static_cast<int>(revealedCount());
}
int MockClient::read() {
if (revealedCount() == 0) {
return -1;
}
return static_cast<int>(_inbound[_readPos++]);
}
int MockClient::read(uint8_t* buf, size_t size) {
if (buf == nullptr) {
return 0;
}
size_t n = 0;
while (n < size && revealedCount() > 0) {
buf[n++] = _inbound[_readPos++];
}
return static_cast<int>(n);
}
int MockClient::peek() {
if (revealedCount() == 0) {
return -1;
}
return static_cast<int>(_inbound[_readPos]);
}
void MockClient::flush() {
_flushCalled = true;
++_flushCount;
}
void MockClient::stop() {
_connected = false;
_stopCalled = true;
++_stopCount;
}
uint8_t MockClient::connected() {
return _connected ? 1 : 0;
}
MockClient::operator bool() {
return _connected;
}
// --- Internals -------------------------------------------------------------
size_t MockClient::revealedCount() const {
const size_t unconsumed = _inbound.size() - _readPos;
// Core / trickle-disabled behavior: every unconsumed byte is visible.
// msPerReveal == 0 would make the schedule ill-defined, so it also means
// "reveal everything".
if (_trickleBytesPerReveal == 0 || _trickleMsPerReveal == 0) {
return unconsumed;
}
// Trickle mode: bytesPerReveal are revealed immediately (step 1), and
// bytesPerReveal more become visible for every msPerReveal of virtual time
// elapsed since the schedule was configured.
const unsigned long now = TestClock::instance().millis();
const unsigned long elapsed = (now >= _trickleBaseMs) ? (now - _trickleBaseMs) : 0UL;
const unsigned long long steps = 1ULL + (elapsed / _trickleMsPerReveal);
// Absolute count of revealed bytes, saturated at the total scripted size.
const unsigned long long revealedAbsWide =
steps * static_cast<unsigned long long>(_trickleBytesPerReveal);
const size_t revealedAbs = (revealedAbsWide >= _inbound.size())
? _inbound.size()
: static_cast<size_t>(revealedAbsWide);
// Only bytes past the read cursor are still visible to the caller.
return (revealedAbs <= _readPos) ? 0 : (revealedAbs - _readPos);
}
@@ -0,0 +1,136 @@
/*
MockClient.h - Host-side scriptable mock transport (Mock_Transport) for the
TasmotaPubSub host test system.
MockClient subclasses the abstract Arduino shim `Client` so the *unmodified*
PubSubClient library drives it exactly as it would a real `WiFiClient`. It
provides:
- a scriptable inbound byte queue the library reads from,
- an outbound record capturing every byte the library writes,
- controllable connection state / connect() results and close tracking.
This file implements the task 6.1 "core" only. Fault injection
(partial-write via setWriteLimit and slow/trickle delivery via setTrickle)
is added later in task 6.2; the read/available and write paths below are
intentionally structured with single choke points (revealedCount() and the
write helpers) so that behavior can be layered in without reshaping the core.
*/
#ifndef TASMOTA_PUBSUB_TEST_MOCK_CLIENT_H
#define TASMOTA_PUBSUB_TEST_MOCK_CLIENT_H
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "Client.h"
#include "IPAddress.h"
// MqttPacket is only forward-declared here to keep this header light; the
// pushPacket() overload is implemented in MockClient.cpp, which includes the
// full MqttPacket definition (task 7.1).
class MqttPacket;
class MockClient : public Client {
public:
MockClient();
~MockClient() override = default;
// --- Inbound scripting -------------------------------------------------
// Append raw bytes to the inbound queue the library reads via read().
void pushInbound(const std::vector<uint8_t>& bytes);
// Append a built MQTT packet's exact wire bytes to the inbound queue.
void pushPacket(const MqttPacket& p);
// Drop any not-yet-consumed inbound bytes and reset the read cursor.
void clearInbound();
// --- Outbound record ---------------------------------------------------
// Every byte the library writes is appended here for structural assertion.
const std::vector<uint8_t>& outbound() const;
void clearOutbound();
// --- Fault injection (task 6.2) ----------------------------------------
// Partial-write injection (Requirement 6.4). When maxPerWrite is nonzero, a
// single write(buf,size) accepts, records and returns at most maxPerWrite
// bytes (but always >= 1 for a successful write with size >= 1), modelling
// WiFiClientSecure_light's short writes. Only the accepted prefix is
// appended to the outbound record; repeated writes keep making progress.
// A limit of 0 means "no limit" (accept all) - the core behavior. Used by F-05.
void setWriteLimit(size_t maxPerWrite);
// Trickle delivery (Requirement 6.5). When bytesPerReveal is nonzero,
// available() reveals at most bytesPerReveal bytes immediately and
// bytesPerReveal more for every msPerReveal of virtual time that elapses
// (measured against TestClock) since this schedule was configured. This
// models a slow broker and drives the F-03 per-byte-timeout vs
// packet-deadline distinction. A bytesPerReveal of 0 means "reveal
// everything" - the core behavior.
void setTrickle(size_t bytesPerReveal, unsigned long msPerReveal);
// --- Connection control ------------------------------------------------
// Force the reported connection state.
void setConnected(bool connected);
// Script the value returned by the next connect() call (default 1 = ok).
void setConnectResult(int result);
// True once stop() has been called at least once (F-03 / F-06 closes).
bool stopCalled() const;
// Introspection helpers for connect()/close/flush bookkeeping.
unsigned stopCount() const;
bool flushCalled() const;
unsigned flushCount() const;
bool connectCalled() const;
const std::string& lastHost() const; // set by connect(const char*, ...)
IPAddress lastIp() const; // set by connect(IPAddress, ...)
uint16_t lastPort() const;
// --- Client interface (see Client.h) -----------------------------------
int connect(IPAddress ip, uint16_t port) override;
int connect(const char* host, uint16_t port) override;
size_t write(uint8_t b) override;
size_t write(const uint8_t* buf, size_t size) override;
int available() override;
int read() override;
int read(uint8_t* buf, size_t size) override;
int peek() override;
void flush() override;
void stop() override;
uint8_t connected() override;
operator bool() override;
private:
// Number of inbound bytes currently visible to the library. With no fault
// injection this is simply every unconsumed byte; task 6.2's trickle mode
// overrides this single choke point to reveal bytes gradually.
size_t revealedCount() const;
// Inbound queue and read cursor. Consumed bytes stay in _inbound; _readPos
// marks the boundary so accessors remain cheap and index math is obvious.
std::vector<uint8_t> _inbound;
size_t _readPos;
// Outbound capture.
std::vector<uint8_t> _outbound;
// Fault injection (task 6.2). All zero == disabled == core behavior.
size_t _writeLimit; // max bytes accepted per write() (0 = unlimited)
size_t _trickleBytesPerReveal; // bytes revealed per step (0 = reveal all)
unsigned long _trickleMsPerReveal; // virtual ms between reveal steps
unsigned long _trickleBaseMs; // virtual time the trickle schedule started
// Connection bookkeeping.
bool _connected;
int _connectResult;
bool _connectCalled;
std::string _lastHost;
IPAddress _lastIp;
uint16_t _lastPort;
// Close / flush tracking.
bool _stopCalled;
unsigned _stopCount;
bool _flushCalled;
unsigned _flushCount;
};
#endif // TASMOTA_PUBSUB_TEST_MOCK_CLIENT_H
@@ -0,0 +1,36 @@
/*
MockStream.cpp - Implementation of the host-side `Stream` test double.
Records every byte the library writes to the stream (stream-mode PUBLISH
payload routing) and provides an inert read side.
*/
#include "MockStream.h"
size_t MockStream::write(uint8_t b) {
_written.push_back(b);
return 1;
}
size_t MockStream::write(const uint8_t* buffer, size_t size) {
if (buffer == nullptr) {
return 0;
}
_written.insert(_written.end(), buffer, buffer + size);
return size;
}
int MockStream::available() {
// The library never reads from this stream; report "nothing to read".
return 0;
}
int MockStream::read() {
// Inert read side: no bytes available.
return -1;
}
int MockStream::peek() {
// Inert read side: no bytes available.
return -1;
}
@@ -0,0 +1,61 @@
/*
MockStream.h - Host-side test double for the Arduino `Stream` used in
stream-mode inbound PUBLISH (Mock_Transport stream variant).
This is NOT part of the library. It is a test support type used when
constructing `PubSubClient` with the optional `Stream&` argument. In stream
mode the *unmodified* library routes each inbound PUBLISH payload byte to the
attached stream via `stream->write(digit)` (inherited from Print) during
`readPacket()`. MockStream records those written bytes, in order, so tests can:
- assert stream-mode payload routing, and
- exercise the stream branch of the DoS/trickle logic (F-03 stream mode).
The read side is intentionally inert (available()==0, read()==-1, peek()==-1):
the library never reads *from* this stream; it only writes routed payload
digits *to* it. Capturing those written digits is the whole job of this class.
*/
#ifndef TASMOTA_PUBSUB_TEST_MOCK_STREAM_H
#define TASMOTA_PUBSUB_TEST_MOCK_STREAM_H
#include <cstddef>
#include <cstdint>
#include <vector>
#include "Stream.h"
class MockStream : public Stream {
public:
MockStream() = default;
~MockStream() override = default;
// --- Print write side (what the library actually exercises) -------------
// Record a single routed payload byte.
size_t write(uint8_t b) override;
// Record a buffer of routed payload bytes in order. Overridden (rather than
// relying on Print's byte-at-a-time default) so bulk writes are captured
// directly and efficiently; behavior is observably identical.
size_t write(const uint8_t* buffer, size_t size) override;
// Bring the non-virtual Print::write(const char*) helper into scope so it is
// not hidden by the overrides above.
using Stream::write;
// --- Stream read side (inert; the library never reads from this) --------
int available() override;
int read() override;
int peek() override;
// --- Test accessors -----------------------------------------------------
// Bytes the library has written to this stream, in the order written.
const std::vector<uint8_t>& written() const { return _written; }
// Discard all recorded bytes (for reuse across sub-scenarios).
void clear() { _written.clear(); }
private:
std::vector<uint8_t> _written;
};
#endif // TASMOTA_PUBSUB_TEST_MOCK_STREAM_H
@@ -0,0 +1,390 @@
/*
MqttPacket.cpp - Implementation of the structural MQTT 3.1.1 control-packet
builder (task 7.1).
Every builder produces the exact bytes a conformant peer would put on the
wire. The Remaining Length encoder mirrors PubSubClient::buildHeader so the
fixtures frame packets identically to the library under test.
*/
#include "MqttPacket.h"
// --- Remaining Length codec (mirrors PubSubClient::buildHeader) -------------
std::vector<uint8_t> MqttPacket::encodeRemainingLength(uint32_t length) {
std::vector<uint8_t> out;
uint8_t llen = 0;
uint32_t len = length;
// Same loop shape as buildHeader: emit at least one byte, continuation bit
// set while more remains, bounded to 4 bytes so the encoding can never run
// past the MQTT maximum of four Remaining Length bytes.
do {
uint8_t digit = len & 127; // digit = len % 128
len >>= 7; // len = len / 128
if (len > 0) {
digit |= 0x80; // continuation bit
}
out.push_back(digit);
llen++;
} while (len > 0 && llen < 4);
return out;
}
// --- Internal helpers -------------------------------------------------------
void MqttPacket::appendString(std::vector<uint8_t>& out, const std::string& s) {
const uint16_t len = static_cast<uint16_t>(s.size());
out.push_back(static_cast<uint8_t>(len >> 8));
out.push_back(static_cast<uint8_t>(len & 0xFF));
out.insert(out.end(), s.begin(), s.end());
}
MqttPacket MqttPacket::framed(uint8_t fixedHeader, const std::vector<uint8_t>& body) {
std::vector<uint8_t> bytes;
bytes.push_back(fixedHeader);
const std::vector<uint8_t> rl = encodeRemainingLength(static_cast<uint32_t>(body.size()));
bytes.insert(bytes.end(), rl.begin(), rl.end());
bytes.insert(bytes.end(), body.begin(), body.end());
return MqttPacket(std::move(bytes));
}
// --- Builders ---------------------------------------------------------------
MqttPacket MqttPacket::connack(uint8_t returnCode, bool sessionPresent) {
std::vector<uint8_t> body;
body.push_back(sessionPresent ? 0x01 : 0x00); // connack acknowledge flags
body.push_back(returnCode);
return framed(MQTTCONNACK, body);
}
MqttPacket MqttPacket::publish(const std::string& topic,
const std::vector<uint8_t>& payload,
uint8_t qos,
bool retained,
uint16_t msgId) {
uint8_t header = static_cast<uint8_t>(MQTTPUBLISH)
| static_cast<uint8_t>((qos & 0x03) << 1)
| (retained ? 0x01 : 0x00);
std::vector<uint8_t> body;
appendString(body, topic);
if (qos > 0) {
// Packet identifier is present only for QoS 1 and QoS 2.
body.push_back(static_cast<uint8_t>(msgId >> 8));
body.push_back(static_cast<uint8_t>(msgId & 0xFF));
}
body.insert(body.end(), payload.begin(), payload.end());
return framed(header, body);
}
MqttPacket MqttPacket::publish(const std::string& topic,
const std::string& payload,
uint8_t qos,
bool retained,
uint16_t msgId) {
const std::vector<uint8_t> bytes(payload.begin(), payload.end());
return publish(topic, bytes, qos, retained, msgId);
}
MqttPacket MqttPacket::puback(uint16_t msgId) {
std::vector<uint8_t> body;
body.push_back(static_cast<uint8_t>(msgId >> 8));
body.push_back(static_cast<uint8_t>(msgId & 0xFF));
return framed(MQTTPUBACK, body);
}
MqttPacket MqttPacket::suback(uint16_t msgId, uint8_t code) {
std::vector<uint8_t> body;
body.push_back(static_cast<uint8_t>(msgId >> 8));
body.push_back(static_cast<uint8_t>(msgId & 0xFF));
body.push_back(code);
return framed(MQTTSUBACK, body);
}
MqttPacket MqttPacket::unsuback(uint16_t msgId) {
std::vector<uint8_t> body;
body.push_back(static_cast<uint8_t>(msgId >> 8));
body.push_back(static_cast<uint8_t>(msgId & 0xFF));
return framed(MQTTUNSUBACK, body);
}
MqttPacket MqttPacket::pingreq() {
return framed(MQTTPINGREQ, {});
}
MqttPacket MqttPacket::pingresp() {
return framed(MQTTPINGRESP, {});
}
MqttPacket MqttPacket::disconnect() {
return framed(MQTTDISCONNECT, {});
}
MqttPacket MqttPacket::raw(const std::vector<uint8_t>& bytes) {
return MqttPacket(bytes);
}
// ===========================================================================
// MqttParser - structural decoder + validators (task 7.2)
// ===========================================================================
bool MqttParser::decodeRemainingLength(const std::vector<uint8_t>& bytes,
size_t offset,
uint32_t& value,
size_t& bytesConsumed) {
// Mirror of the wire codec: up to four continuation-flagged bytes, low 7
// bits each, little-endian by septet. A fifth continuation byte is illegal.
uint32_t multiplier = 1;
uint32_t result = 0;
size_t i = 0;
for (; i < 4; ++i) {
if (offset + i >= bytes.size()) {
return false; // truncated Remaining Length field
}
const uint8_t digit = bytes[offset + i];
result += static_cast<uint32_t>(digit & 0x7F) * multiplier;
multiplier <<= 7;
if ((digit & 0x80) == 0) {
value = result;
bytesConsumed = i + 1;
return true;
}
}
return false; // continuation bit still set after four bytes
}
DecodedPacket MqttParser::decode(const std::vector<uint8_t>& bytes) {
DecodedPacket p;
if (bytes.empty()) {
return p; // valid == false
}
p.type = static_cast<uint8_t>(bytes[0] & 0xF0);
p.flags = static_cast<uint8_t>(bytes[0] & 0x0F);
uint32_t remaining = 0;
size_t rlBytes = 0;
if (!decodeRemainingLength(bytes, 1, remaining, rlBytes)) {
return p; // malformed Remaining Length field
}
p.remainingLength = remaining;
const size_t bodyStart = 1 + rlBytes;
const size_t trailing = bytes.size() - bodyStart;
// Framing is self-consistent only when the declared Remaining Length equals
// the number of trailing bytes actually present.
if (remaining != trailing) {
return p; // valid == false: truncated or over-long
}
p.payload.assign(bytes.begin() + static_cast<std::ptrdiff_t>(bodyStart), bytes.end());
p.valid = true;
return p;
}
// Read a 2-byte big-endian length-prefixed string from `body` at `pos`.
// Advances `pos` past the string. Returns false if it would run past the body.
static bool readString(const std::vector<uint8_t>& body, size_t& pos, std::string& out) {
if (pos + 2 > body.size()) {
return false;
}
const uint16_t len = static_cast<uint16_t>((body[pos] << 8) | body[pos + 1]);
pos += 2;
if (pos + len > body.size()) {
return false;
}
out.assign(body.begin() + static_cast<std::ptrdiff_t>(pos),
body.begin() + static_cast<std::ptrdiff_t>(pos + len));
pos += len;
return true;
}
DecodedConnect MqttParser::decodeConnect(const std::vector<uint8_t>& bytes) {
DecodedConnect c;
const DecodedPacket p = decode(bytes);
if (!p.valid || p.type != static_cast<uint8_t>(MQTTCONNECT)) {
return c;
}
const std::vector<uint8_t>& body = p.payload;
size_t pos = 0;
// Variable header: protocol name, level, connect flags, keep alive.
if (!readString(body, pos, c.protocolName)) {
return c;
}
if (pos + 4 > body.size()) { // level(1) + flags(1) + keepAlive(2)
return c;
}
c.protocolLevel = body[pos++];
c.connectFlags = body[pos++];
c.keepAlive = static_cast<uint16_t>((body[pos] << 8) | body[pos + 1]);
pos += 2;
c.cleanSession = (c.connectFlags & 0x02) != 0;
c.willFlag = (c.connectFlags & 0x04) != 0;
c.willQos = static_cast<uint8_t>((c.connectFlags >> 3) & 0x03);
c.willRetain = (c.connectFlags & 0x20) != 0;
c.userFlag = (c.connectFlags & 0x80) != 0;
c.passwordFlag = (c.connectFlags & 0x40) != 0;
// Payload: client id, [will topic, will message], [username], [password].
if (!readString(body, pos, c.clientId)) {
return c;
}
if (c.willFlag) {
if (!readString(body, pos, c.willTopic) || !readString(body, pos, c.willMessage)) {
return c;
}
}
if (c.userFlag) {
if (!readString(body, pos, c.username)) {
return c;
}
if (c.passwordFlag && !readString(body, pos, c.password)) {
return c;
}
}
c.valid = true;
return c;
}
DecodedPublish MqttParser::decodePublish(const std::vector<uint8_t>& bytes) {
DecodedPublish r;
const DecodedPacket p = decode(bytes);
if (!p.valid || p.type != static_cast<uint8_t>(MQTTPUBLISH)) {
return r;
}
r.dup = (p.flags & 0x08) != 0;
r.qos = static_cast<uint8_t>((p.flags >> 1) & 0x03);
r.retain = (p.flags & 0x01) != 0;
const std::vector<uint8_t>& body = p.payload;
size_t pos = 0;
if (!readString(body, pos, r.topic)) {
return r;
}
if (r.qos > 0) {
// Packet identifier is present only for QoS 1 and QoS 2.
if (pos + 2 > body.size()) {
return r;
}
r.msgId = static_cast<uint16_t>((body[pos] << 8) | body[pos + 1]);
pos += 2;
}
r.payload.assign(body.begin() + static_cast<std::ptrdiff_t>(pos), body.end());
r.valid = true;
return r;
}
DecodedSubscribe MqttParser::decodeSubscribe(const std::vector<uint8_t>& bytes) {
DecodedSubscribe s;
const DecodedPacket p = decode(bytes);
if (!p.valid || p.type != static_cast<uint8_t>(MQTTSUBSCRIBE)) {
return s;
}
const std::vector<uint8_t>& body = p.payload;
size_t pos = 0;
if (pos + 2 > body.size()) {
return s;
}
s.msgId = static_cast<uint16_t>((body[pos] << 8) | body[pos + 1]);
pos += 2;
// One or more (topic filter, requested QoS) pairs.
while (pos < body.size()) {
std::string filter;
if (!readString(body, pos, filter)) {
return s; // valid == false: truncated filter
}
if (pos >= body.size()) {
return s; // missing requested-QoS byte
}
s.filters.push_back(filter);
s.requestedQos.push_back(body[pos++]);
}
if (s.filters.empty()) {
return s; // a SUBSCRIBE must carry at least one filter
}
s.valid = true;
return s;
}
DecodedUnsubscribe MqttParser::decodeUnsubscribe(const std::vector<uint8_t>& bytes) {
DecodedUnsubscribe u;
const DecodedPacket p = decode(bytes);
if (!p.valid || p.type != static_cast<uint8_t>(MQTTUNSUBSCRIBE)) {
return u;
}
const std::vector<uint8_t>& body = p.payload;
size_t pos = 0;
if (pos + 2 > body.size()) {
return u;
}
u.msgId = static_cast<uint16_t>((body[pos] << 8) | body[pos + 1]);
pos += 2;
// One or more topic filters (no QoS byte in UNSUBSCRIBE).
while (pos < body.size()) {
std::string filter;
if (!readString(body, pos, filter)) {
return u; // valid == false: truncated filter
}
u.filters.push_back(filter);
}
if (u.filters.empty()) {
return u; // an UNSUBSCRIBE must carry at least one filter
}
u.valid = true;
return u;
}
bool MqttParser::isStructurallyValidPublish(const std::vector<uint8_t>& bytes) {
if (bytes.empty()) {
return false;
}
// Fixed-header high nibble must be PUBLISH.
if (static_cast<uint8_t>(bytes[0] & 0xF0) != static_cast<uint8_t>(MQTTPUBLISH)) {
return false;
}
uint32_t remaining = 0;
size_t rlBytes = 0;
if (!decodeRemainingLength(bytes, 1, remaining, rlBytes)) {
return false;
}
// Decoded Remaining Length must equal the actual trailing byte count.
const size_t bodyStart = 1 + rlBytes;
if (bodyStart > bytes.size()) {
return false;
}
if (remaining != bytes.size() - bodyStart) {
return false;
}
// The 2-byte topic length must fit within the body.
if (remaining < 2) {
return false; // no room for the topic length prefix
}
const uint16_t topicLen =
static_cast<uint16_t>((bytes[bodyStart] << 8) | bytes[bodyStart + 1]);
// QoS > 0 reserves an extra 2-byte packet identifier after the topic.
const uint8_t qos = static_cast<uint8_t>((bytes[0] >> 1) & 0x03);
const size_t reserved = (qos > 0) ? 2u : 0u;
// topic length prefix (2) + topic bytes + packet id (if any) must fit.
if (static_cast<size_t>(2) + topicLen + reserved > remaining) {
return false;
}
return true;
}
@@ -0,0 +1,217 @@
/*
MqttPacket.h - Structural MQTT 3.1.1 control-packet builder for the
TasmotaPubSub host test system (task 7.1).
Tests express intent structurally rather than hard-coding brittle magic-byte
arrays: a builder produces a complete, correctly framed control packet whose
bytes can be scripted as inbound (via MockClient::pushPacket / pushInbound) or
compared against recorded outbound bytes.
The Remaining Length codec here MIRRORS the library's PubSubClient::buildHeader
exactly (1..4 length bytes, bounded so a value can never emit a fifth byte), so
fixtures stay in sync with the code under test. Packet-type constants are
REUSED from PubSubClient.h (MQTTPUBLISH, MQTTCONNACK, ...) rather than
redefined, so the fixtures automatically track any change in the library's own
definitions.
The decoder / structural validators (MqttParser) are added in task 7.2 at
the bottom of this header. They let tests assert on recorded MockClient
outbound bytes by DECODING them structurally (round-trip against the public
API arguments), never by comparing brittle magic-byte arrays. Every assertion
therefore goes through decoded wire bytes only, so the suite stays durable
across a future MQTT 5 migration (Requirement 19.1).
*/
#ifndef TASMOTA_PUBSUB_TEST_MQTT_PACKET_H
#define TASMOTA_PUBSUB_TEST_MQTT_PACKET_H
#include <cstdint>
#include <string>
#include <vector>
// Reuse the library's own packet-type / QoS constants so fixtures cannot drift
// out of sync with the code under test. This header pulls in the Arduino shim
// transitively (Arduino.h / Client.h / Stream.h / IPAddress.h), which is fine
// on the host include path (src/lib is searched first).
#include "PubSubClient.h"
// A single, immutable MQTT control packet represented as its exact wire bytes.
//
// Instances are produced only through the static builder functions below; the
// value can then be handed to MockClient::pushPacket() (inbound scripting) or
// its bytes() compared against decoded outbound bytes.
class MqttPacket {
public:
// --- Builders ----------------------------------------------------------
// CONNACK: 0x20 0x02 <ackFlags> <returnCode>.
// ackFlags bit0 is the Session Present flag; all other bits are zero.
static MqttPacket connack(uint8_t returnCode, bool sessionPresent = false);
// PUBLISH with a raw byte payload.
// fixed header = MQTTPUBLISH | (qos << 1) | (retained ? 1 : 0)
// variable header = topic (2-byte length prefix + bytes)
// + packet identifier (2 bytes) ONLY when qos > 0
// payload = raw bytes
// msgId is emitted only for qos > 0 (a QoS 0 PUBLISH has no packet id).
static MqttPacket publish(const std::string& topic,
const std::vector<uint8_t>& payload,
uint8_t qos = 0,
bool retained = false,
uint16_t msgId = 0);
// PUBLISH convenience overload taking a string payload.
static MqttPacket publish(const std::string& topic,
const std::string& payload,
uint8_t qos = 0,
bool retained = false,
uint16_t msgId = 0);
// PUBACK: 0x40 0x02 <msgId hi> <msgId lo>.
static MqttPacket puback(uint16_t msgId);
// SUBACK: 0x90 0x03 <msgId hi> <msgId lo> <returnCode>.
static MqttPacket suback(uint16_t msgId, uint8_t code);
// UNSUBACK: 0xB0 0x02 <msgId hi> <msgId lo>.
static MqttPacket unsuback(uint16_t msgId);
// PINGREQ: 0xC0 0x00.
static MqttPacket pingreq();
// PINGRESP: 0xD0 0x00.
static MqttPacket pingresp();
// DISCONNECT: 0xE0 0x00.
static MqttPacket disconnect();
// Arbitrary bytes verbatim, for adversarial / malformed fixtures
// (F-01/F-02/F-06/F-09 etc.). No framing is added or validated.
static MqttPacket raw(const std::vector<uint8_t>& bytes);
// --- Accessors ---------------------------------------------------------
const std::vector<uint8_t>& bytes() const { return _bytes; }
size_t size() const { return _bytes.size(); }
// --- Remaining Length codec (mirrors PubSubClient::buildHeader) ---------
// Encode an MQTT Remaining Length into 1..4 bytes. The loop is bounded to
// four bytes exactly as the library's buildHeader is, so a value outside the
// 0..268435455 range is clamped to a 4-byte encoding rather than emitting a
// fifth byte.
static std::vector<uint8_t> encodeRemainingLength(uint32_t length);
private:
explicit MqttPacket(std::vector<uint8_t> bytes) : _bytes(std::move(bytes)) {}
// Build a fixed header (packet type + Remaining Length) followed by the
// given variable-header-plus-payload body.
static MqttPacket framed(uint8_t fixedHeader, const std::vector<uint8_t>& body);
// Append a 2-byte big-endian length-prefixed UTF-8 string, exactly as the
// library's writeString lays it out on the wire.
static void appendString(std::vector<uint8_t>& out, const std::string& s);
std::vector<uint8_t> _bytes;
};
// ===========================================================================
// MqttParser - structural decoder + validators (task 7.2)
// ===========================================================================
// Output of the generic decoder. `type` is the fixed-header high nibble (e.g.
// MQTTPUBLISH), `flags` the low nibble (dup/qos/retain), `remainingLength` the
// decoded 1..4 byte value. Because splitting a variable header from the payload
// is packet-type specific, the generic decode places the ENTIRE decoded body
// (everything after the fixed header + Remaining Length field) into `payload`
// and leaves `variableHeader` empty; the type-specific decoders below carve out
// the individual fields. `valid` is true only when the framing is
// self-consistent: a Remaining Length that decodes within 1..4 bytes and whose
// value exactly equals the number of trailing bytes actually present.
struct DecodedPacket {
uint8_t type = 0; // high nibble, e.g. MQTTPUBLISH
uint8_t flags = 0; // low nibble (dup/qos/retain)
uint32_t remainingLength = 0; // decoded 1..4 byte value
std::vector<uint8_t> variableHeader;
std::vector<uint8_t> payload;
bool valid = false; // framing self-consistent
};
// Decoded CONNECT fields (MQTT 3.1.1 variable header + payload).
struct DecodedConnect {
bool valid = false;
std::string protocolName; // "MQTT" for 3.1.1
uint8_t protocolLevel = 0; // 4 for 3.1.1
uint8_t connectFlags = 0;
uint16_t keepAlive = 0;
bool cleanSession = false;
bool willFlag = false;
uint8_t willQos = 0;
bool willRetain = false;
bool userFlag = false;
bool passwordFlag = false;
std::string clientId;
std::string willTopic;
std::string willMessage;
std::string username;
std::string password;
};
// Decoded PUBLISH fields.
struct DecodedPublish {
bool valid = false;
bool dup = false;
uint8_t qos = 0;
bool retain = false;
std::string topic;
uint16_t msgId = 0; // 0 when qos == 0 (no packet identifier)
std::vector<uint8_t> payload;
};
// Decoded SUBSCRIBE fields. `filters[i]` carries requested QoS `requestedQos[i]`.
struct DecodedSubscribe {
bool valid = false;
uint16_t msgId = 0;
std::vector<std::string> filters;
std::vector<uint8_t> requestedQos;
};
// Decoded UNSUBSCRIBE fields.
struct DecodedUnsubscribe {
bool valid = false;
uint16_t msgId = 0;
std::vector<std::string> filters;
};
class MqttParser {
public:
// Decode a single MQTT control packet from the front of `bytes`. The
// returned DecodedPacket is `valid` only when the framing is
// self-consistent (see DecodedPacket).
static DecodedPacket decode(const std::vector<uint8_t>& bytes);
// Type-specific decoders. Each first runs the generic decode, checks the
// fixed-header nibble, then parses the body. The returned struct's `valid`
// flag is false if the bytes are not a well-formed packet of that type.
static DecodedConnect decodeConnect(const std::vector<uint8_t>& bytes);
static DecodedPublish decodePublish(const std::vector<uint8_t>& bytes);
static DecodedSubscribe decodeSubscribe(const std::vector<uint8_t>& bytes);
static DecodedUnsubscribe decodeUnsubscribe(const std::vector<uint8_t>& bytes);
// Structural validator for PUBLISH: verifies the fixed-header high nibble is
// MQTTPUBLISH, that the decoded Remaining Length equals the actual trailing
// byte count, and that the 2-byte topic length fits within the body (leaving
// room for the packet identifier when QoS > 0). Rejects truncated packets.
static bool isStructurallyValidPublish(const std::vector<uint8_t>& bytes);
// Decode a Remaining Length starting at `bytes[offset]`. Returns true on
// success and sets `value` (decoded length) and `bytesConsumed` (1..4). A
// value needing more than four bytes, or a truncated field, returns false.
static bool decodeRemainingLength(const std::vector<uint8_t>& bytes,
size_t offset,
uint32_t& value,
size_t& bytesConsumed);
};
#endif // TASMOTA_PUBSUB_TEST_MQTT_PACKET_H
@@ -0,0 +1,64 @@
/*
Print.h - Host-side Arduino `Print` shim for TasmotaPubSub host tests.
This is NOT a test double of the library. It is a minimal reproduction of the
Arduino `Print` base class so the *unmodified* PubSubClient library (which is
`class PubSubClient : public Print`) compiles and links against a host platform.
Only the surface the library depends on is provided:
- `virtual size_t write(uint8_t)` (pure) and
`virtual size_t write(const uint8_t*, size_t)` (default Arduino behavior:
loop calling write(uint8_t)). The library overrides both.
A few non-virtual print/println helpers are provided for parity with the real
Arduino API; they are unused by the library but harmless.
*/
#ifndef TASMOTA_PUBSUB_TEST_PRINT_H
#define TASMOTA_PUBSUB_TEST_PRINT_H
#include <cstddef>
#include <cstdint>
#include <cstring>
class Print {
public:
virtual ~Print() = default;
// Core Arduino Print virtuals.
virtual size_t write(uint8_t) = 0;
// Standard Arduino default: write a buffer by looping over single-byte writes,
// stopping early if a write reports failure (0 bytes written). Derived classes
// (including the library under test) may override this.
virtual size_t write(const uint8_t* buffer, size_t size) {
size_t n = 0;
while (size--) {
if (write(*buffer++) == 0) {
break;
}
n++;
}
return n;
}
// Convenience overload matching the Arduino API. Non-virtual and unused by the
// library, but provided so this header is a faithful stand-in.
size_t write(const char* str) {
if (str == nullptr) {
return 0;
}
return write(reinterpret_cast<const uint8_t*>(str), std::strlen(str));
}
// Harmless print/println helpers (unused by the library).
size_t print(const char* str) {
return write(str);
}
size_t println(const char* str) {
size_t n = write(str);
n += write(reinterpret_cast<const uint8_t*>("\r\n"), 2);
return n;
}
};
#endif // TASMOTA_PUBSUB_TEST_PRINT_H
@@ -0,0 +1,12 @@
/*
Stream.cpp - Host-side Arduino `Stream` shim implementation.
Most of `Stream` is pure-virtual/inline; only the non-pure `flush()` needs an
out-of-line definition. Kept present because the design lists Stream.h/.cpp.
*/
#include "Stream.h"
void Stream::flush() {
// No-op default, matching the harmless Arduino base behavior.
}
@@ -0,0 +1,31 @@
/*
Stream.h - Host-side Arduino `Stream` shim for TasmotaPubSub host tests.
Minimal reproduction of the Arduino `Stream` class: `Stream : public Print`
adding the read side (available/read/peek/flush). The library holds an optional
`Stream* stream;` and, in stream mode, calls `stream->write(digit)` (inherited
from Print). MockStream subclasses this to record routed payload bytes.
*/
#ifndef TASMOTA_PUBSUB_TEST_STREAM_H
#define TASMOTA_PUBSUB_TEST_STREAM_H
#include "Print.h"
class Stream : public Print {
public:
virtual ~Stream() = default;
// Read-side interface added by Arduino `Stream`.
virtual int available() = 0;
virtual int read() = 0;
virtual int peek() = 0;
virtual void flush();
// Bring Print's write overloads into scope so a derived class that only
// overrides write(uint8_t) does not hide write(const uint8_t*, size_t)
// (avoids -Woverloaded-virtual under -Wextra).
using Print::write;
};
#endif // TASMOTA_PUBSUB_TEST_STREAM_H
@@ -0,0 +1,35 @@
/*
TestClock.cpp - Host-side virtual clock (Clock_Injector) implementation.
*/
#include "TestClock.h"
TestClock::TestClock() : _now(0), _autoStep(1) {}
TestClock& TestClock::instance() {
static TestClock clock;
return clock;
}
unsigned long TestClock::millis() const {
return _now;
}
void TestClock::advance(unsigned long ms) {
_now += ms;
}
void TestClock::reset(unsigned long start) {
_now = start;
}
void TestClock::onDelay(unsigned long ms) {
// Always make forward progress: even delay(0) advances by at least autoStep
// so the library's millis()-based busy-wait loops terminate deterministically.
unsigned long step = (ms > _autoStep) ? ms : _autoStep;
_now += step;
}
void TestClock::setAutoStep(unsigned long step) {
_autoStep = step;
}
@@ -0,0 +1,59 @@
/*
TestClock.h - Host-side virtual clock (Clock_Injector) for TasmotaPubSub tests.
Virtualizes simulated time so the *unmodified* PubSubClient library's
time-dependent behavior (keepalive, socket timeouts, connect/readByte
busy-wait loops) runs deterministically and instantly on a host, without
real elapsed wall-clock time.
The Arduino shim wires its time primitives to this singleton (task 3.2):
- `millis()` returns `TestClock::instance().millis()`
- `delay(ms)` calls `TestClock::instance().onDelay(ms)`
- `yield()` is a no-op
Why delay() must advance time: the library busy-waits in readByte() calling
`delay(1)` and in connect() calling `delay(0)` while polling the transport.
If delay() did not move the virtual clock, an exhausted inbound queue would
spin those loops forever on the host. onDelay() therefore always advances by
at least `autoStep` (default 1 ms) so even `delay(0)` makes forward progress
and the library's `millis()`-based timeouts fire deterministically.
*/
#ifndef TASMOTA_PUBSUB_TEST_TEST_CLOCK_H
#define TASMOTA_PUBSUB_TEST_TEST_CLOCK_H
class TestClock {
public:
// Process-global virtual clock instance shared by the shim and the tests.
static TestClock& instance();
// Current virtual time in milliseconds (what the shim's millis() returns).
unsigned long millis() const;
// Jump virtual time forward by `ms` milliseconds.
void advance(unsigned long ms);
// Reset virtual time to `start` (Requirement 7.4). A doctest fixture calls
// this before each test for isolation.
void reset(unsigned long start = 0);
// Applied by the shim's delay(ms): advances by max(ms, autoStep) so that a
// library busy-wait calling delay(0)/delay(1) always makes forward progress.
void onDelay(unsigned long ms);
// Configure the minimum advance applied by onDelay(). Default is 1 ms so
// delay(0) still moves time forward.
void setAutoStep(unsigned long step);
private:
TestClock();
// Non-copyable singleton.
TestClock(const TestClock&) = delete;
TestClock& operator=(const TestClock&) = delete;
unsigned long _now;
unsigned long _autoStep;
};
#endif // TASMOTA_PUBSUB_TEST_TEST_CLOCK_H
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,467 @@
/*
publish_test.cpp - publish / publish_P / retained-flag characterization for
the TasmotaPubSub host test system (task 10.2).
Baseline (TEST_SUITE("baseline")):
- publish() with a topic and payload records a structurally valid PUBLISH
packet (Requirement 9.1).
- publish_P() records a structurally valid PUBLISH packet (Requirement 9.2).
- Retained-message flag handling: the decoded retain bit matches the
requested flag across both publish paths (Requirement 18.4).
Property 4 (PUBLISH field round-trip: decoded topic == input topic, decoded
payload == input payload, retain bit == requested flag) and Property 1
(remaining-length codec round-trip) are folded in as single, deterministic,
data-driven cases over curated boundary tables (no randomized generators).
Curated boundary coverage (design "Property validation via curated
data-driven tests"): payload/topic lengths 0, 1, 127, 128, 16383, 16384,
65535, 65536 and near-buffer sizes; topics/payloads that are empty, typical,
max-fitting, and multi-byte/UTF-8; retained flag on and off.
The default working buffer is MQTT_MAX_PACKET_SIZE = 1200 and the buffer size
is a uint16_t, so the buffered publish() path cannot frame very large
payloads; this file characterizes the library's *actual* observable behavior
there (publish() returns false and emits no packet) rather than assuming a
packet is produced. The streamed publish_P() path frames large payloads
because its payload is not buffered, so it is used to exercise the 2-/3-byte
Remaining Length boundaries against the library's real emitted bytes; the
4-byte boundary is driven through the MqttPacket/MqttParser codec.
Every assertion goes through the public API and decoded MockClient.outbound()
wire bytes - never private members - so the baseline stays durable across a
future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Deterministic payload of length n. The pattern intentionally produces 0x00
// bytes so the round-trip also proves the paths are binary-safe (no reliance on
// C-string NUL termination for the explicit-length / streamed overloads).
std::vector<uint8_t> makePayload(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; ++i) {
v[i] = static_cast<uint8_t>((i * 31u + 7u) & 0xFFu);
}
return v;
}
// A topic of exactly n 'a' bytes (used for length-boundary coverage).
std::string makeTopic(size_t n) {
return std::string(n, 'a');
}
// Connect the client/psc pair with a scripted CONNACK and clear the recorded
// CONNECT bytes so only the packet-under-test remains in outbound(). The buffer
// size can be pre-set by the caller before invoking this helper.
void connectAndClear(MockClient& client, PubSubClient& psc) {
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("pub-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
} // namespace
TEST_SUITE("baseline") {
// --- 9.1: publish() records a valid PUBLISH packet ----------------------
TEST_CASE("publish with a topic and payload records a valid PUBLISH packet") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.publish("tele/dev/SENSOR", "hello world"));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.qos == 0);
CHECK(d.msgId == 0); // QoS 0 => no packet identifier
CHECK_FALSE(d.retain);
CHECK(d.topic == "tele/dev/SENSOR");
const std::string msg = "hello world";
const std::vector<uint8_t> expected(msg.begin(), msg.end());
CHECK(d.payload == expected);
}
// --- 9.2: publish_P() records a valid PUBLISH packet --------------------
TEST_CASE("publish_P records a valid PUBLISH packet") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::string topic = "tele/dev/STATE";
const std::vector<uint8_t> payload = makePayload(64);
REQUIRE(psc.publish_P(topic.c_str(), payload.data(),
static_cast<unsigned int>(payload.size()), false));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.qos == 0);
CHECK(d.msgId == 0);
CHECK_FALSE(d.retain);
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
// --- 18.4: retained-message flag handling -------------------------------
TEST_CASE("retained flag round-trips through publish and publish_P") {
const bool retainedFlags[] = {false, true};
for (bool retained : retainedFlags) {
CAPTURE(retained);
// publish(topic, payload, retained) - C-string overload.
{
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.publish("tele/dev/POWER", "ON", retained));
DecodedPublish d = MqttParser::decodePublish(client.outbound());
REQUIRE(d.valid);
CHECK(d.retain == retained);
CHECK(d.topic == "tele/dev/POWER");
const std::vector<uint8_t> expected{'O', 'N'};
CHECK(d.payload == expected);
}
// publish(topic, payload, plength, retained) - explicit-length overload.
{
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::vector<uint8_t> payload = makePayload(32);
REQUIRE(psc.publish("stat/dev/RESULT", payload.data(),
static_cast<unsigned int>(payload.size()), retained));
DecodedPublish d = MqttParser::decodePublish(client.outbound());
REQUIRE(d.valid);
CHECK(d.retain == retained);
CHECK(d.payload == payload);
}
// publish_P(topic, payload, plength, retained).
{
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::vector<uint8_t> payload = makePayload(48);
REQUIRE(psc.publish_P("cmnd/dev/Backlog", payload.data(),
static_cast<unsigned int>(payload.size()), retained));
DecodedPublish d = MqttParser::decodePublish(client.outbound());
REQUIRE(d.valid);
CHECK(d.retain == retained);
CHECK(d.payload == payload);
}
}
}
// --- Property 4: PUBLISH field round-trip over curated payload lengths ---
// Feature: tasmota-pubsub-tests, Property 4: for all topics and payloads
// (including the retained flag and empty payloads), every publish path emits
// a PUBLISH whose decoded topic equals the input topic, whose decoded payload
// equals the input payload, and whose retain bit equals the requested flag.
// Validated deterministically over a curated boundary table (no randomized
// generators). The streamed publish_P() path frames every size because its
// payload is not buffered.
TEST_CASE("Property 4: publish_P PUBLISH field round-trip over payload-length boundaries") {
// Payload-length boundaries: empty, single byte, the 1->2 and 2->3 byte
// Remaining Length transitions, and sizes that exceed the 16-bit range.
const size_t payloadLengths[] = {0, 1, 127, 128, 16383, 16384, 65535, 65536};
const bool retainedFlags[] = {false, true};
const std::string topic = "tele/dev/SENSOR";
for (size_t plen : payloadLengths) {
for (bool retained : retainedFlags) {
CAPTURE(plen);
CAPTURE(retained);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::vector<uint8_t> payload = makePayload(plen);
REQUIRE(psc.publish_P(topic.c_str(), payload.data(),
static_cast<unsigned int>(payload.size()), retained));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.qos == 0);
CHECK(d.msgId == 0);
CHECK(d.retain == retained);
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
}
}
// Property 4 for the buffered publish() path over the payload lengths that
// fit a working buffer. Small sizes use the default 1200-byte buffer; the
// 16383/16384 sizes use an enlarged buffer (still within the uint16_t buffer
// size limit) so the real buffered framing path is exercised end to end.
TEST_CASE("Property 4: buffered publish PUBLISH field round-trip within buffer capacity") {
struct Case { size_t plen; uint16_t bufferSize; };
const Case cases[] = {
{0, MQTT_MAX_PACKET_SIZE},
{1, MQTT_MAX_PACKET_SIZE},
{127, MQTT_MAX_PACKET_SIZE},
{128, MQTT_MAX_PACKET_SIZE},
{16383, 20000},
{16384, 20000},
};
const bool retainedFlags[] = {false, true};
const std::string topic = "tele/dev/SENSOR";
for (const Case& c : cases) {
for (bool retained : retainedFlags) {
CAPTURE(c.plen);
CAPTURE(retained);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
REQUIRE(psc.setBufferSize(c.bufferSize));
REQUIRE(psc.getBufferSize() == c.bufferSize);
connectAndClear(client, psc);
const std::vector<uint8_t> payload = makePayload(c.plen);
REQUIRE(psc.publish(topic.c_str(), payload.data(),
static_cast<unsigned int>(payload.size()), retained));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.qos == 0);
CHECK(d.retain == retained);
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
}
}
// Property 4 over curated topic-length and multi-byte/UTF-8 vectors: empty,
// single byte, the 127/128 boundary, a near-buffer topic, and UTF-8 topics
// and payloads. Both publish paths are checked for each vector.
TEST_CASE("Property 4: PUBLISH topic round-trip incl. multi-byte/UTF-8 and near-buffer") {
struct Case { const char* label; std::string topic; std::vector<uint8_t> payload; };
// A UTF-8 topic (degree sign + checkmark) and a UTF-8 payload.
const std::string utf8Topic = "tele/\xC2\xB0" "C/\xE2\x9C\x93";
const std::string utf8PayloadStr = "temp=21\xC2\xB0" "C \xE2\x9C\x93";
const std::vector<uint8_t> utf8Payload(utf8PayloadStr.begin(), utf8PayloadStr.end());
std::vector<Case> cases = {
{"empty topic", makeTopic(0), makePayload(4)},
{"single-char topic",makeTopic(1), makePayload(4)},
{"topic len 127", makeTopic(127), makePayload(8)},
{"topic len 128", makeTopic(128), makePayload(8)},
{"near-buffer topic",makeTopic(1000), makePayload(8)},
{"utf8 topic+payload", utf8Topic, utf8Payload},
};
for (const Case& c : cases) {
CAPTURE(c.label);
// Streamed path (publish_P): frames every case (topic fits 1200).
{
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.publish_P(c.topic.c_str(), c.payload.data(),
static_cast<unsigned int>(c.payload.size()), false));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.topic == c.topic);
CHECK(d.payload == c.payload);
}
// Buffered path (publish): all these cases fit the default 1200 buffer.
{
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.publish(c.topic.c_str(), c.payload.data(),
static_cast<unsigned int>(c.payload.size()), false));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.topic == c.topic);
CHECK(d.payload == c.payload);
}
}
}
// --- Buffered publish oversized-payload behavior (characterization) ------
// The buffered publish() path must frame the whole packet in the working
// buffer. Characterize the library's real behavior when the payload cannot
// fit: publish() returns false and emits no bytes. The buffer size is a
// uint16_t, so payloads at/above ~65529 bytes can never fit any buffer.
TEST_CASE("buffered publish refuses payloads that exceed the working buffer") {
SUBCASE("payload just over the default buffer capacity emits nothing") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client); // default buffer 1200
connectAndClear(client, psc);
// topic "t" (len 1): fits when 1200 >= 7 + 1 + plength, i.e. plength <= 1192.
const std::vector<uint8_t> fits = makePayload(1192);
REQUIRE(psc.publish("t", fits.data(),
static_cast<unsigned int>(fits.size()), false));
REQUIRE(MqttParser::isStructurallyValidPublish(client.outbound()));
client.clearOutbound();
const std::vector<uint8_t> tooBig = makePayload(1193);
CHECK_FALSE(psc.publish("t", tooBig.data(),
static_cast<unsigned int>(tooBig.size()), false));
CHECK(client.outbound().empty());
}
SUBCASE("payloads >= 16-bit buffer limit cannot be buffered even at max buffer") {
const size_t hugeLengths[] = {16384, 65535, 65536};
for (size_t plen : hugeLengths) {
CAPTURE(plen);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
REQUIRE(psc.setBufferSize(65535)); // largest a uint16_t buffer allows
connectAndClear(client, psc);
// 16384 fits a 65535 buffer; 65535/65536 cannot (need > uint16_t).
const std::vector<uint8_t> payload = makePayload(plen);
const bool ok = psc.publish("t", payload.data(),
static_cast<unsigned int>(payload.size()), false);
if (plen + 8 <= 65535) {
CHECK(ok);
CHECK(MqttParser::isStructurallyValidPublish(client.outbound()));
} else {
CHECK_FALSE(ok);
CHECK(client.outbound().empty());
}
}
}
}
// --- Property 1: remaining-length codec round-trip ----------------------
// Feature: tasmota-pubsub-tests, Property 1: for all integers n in the MQTT
// range 0..268435455, encoding n as an MQTT Remaining Length and decoding it
// yields n using at most four bytes. Exercised deterministically at the
// 1/2/3/4-byte boundaries via the MqttPacket/MqttParser codec, which mirrors
// the library's buildHeader.
TEST_CASE("Property 1: Remaining Length codec round-trip at the byte-count boundaries") {
struct RLCase { uint32_t value; size_t bytes; };
const RLCase cases[] = {
{0, 1}, // minimum
{1, 1},
{127, 1}, // last 1-byte value
{128, 2}, // first 2-byte value
{16383, 2}, // last 2-byte value
{16384, 3}, // first 3-byte value
{2097151, 3}, // last 3-byte value
{2097152, 4}, // first 4-byte value
{268435455, 4}, // MQTT maximum Remaining Length
};
for (const RLCase& c : cases) {
CAPTURE(c.value);
const std::vector<uint8_t> enc = MqttPacket::encodeRemainingLength(c.value);
CHECK(enc.size() == c.bytes);
uint32_t decoded = 0xFFFFFFFFu;
size_t consumed = 0;
REQUIRE(MqttParser::decodeRemainingLength(enc, 0, decoded, consumed));
CHECK(decoded == c.value);
CHECK(consumed == c.bytes);
}
}
// Property 1 against the library's real emitted bytes: for PUBLISH packets
// whose Remaining Length crosses the 1->2 and 2->3 byte transitions, the
// decoded Remaining Length equals the actual trailing byte count and equals
// 2 + topicLen + plength. The streamed publish_P() path is used so the
// larger sizes are framed (the buffered path cannot hold them).
TEST_CASE("Property 1: emitted PUBLISH Remaining Length equals trailing byte count") {
const std::string topic = "t"; // topicLen == 1
const size_t topicLen = topic.size();
// Remaining Length = 2 + topicLen + plength. Choose plength so RL lands
// on each byte-count boundary.
struct Case { size_t plen; size_t expectedRlBytes; };
const Case cases[] = {
{127 - 2 - 1, 1}, // RL = 127 (1 byte)
{128 - 2 - 1, 2}, // RL = 128 (2 bytes)
{16383 - 2 - 1, 2}, // RL = 16383 (2 bytes)
{16384 - 2 - 1, 3}, // RL = 16384 (3 bytes)
};
for (const Case& c : cases) {
CAPTURE(c.plen);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::vector<uint8_t> payload = makePayload(c.plen);
REQUIRE(psc.publish_P(topic.c_str(), payload.data(),
static_cast<unsigned int>(payload.size()), false));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
// Decode the Remaining Length field directly from the emitted bytes.
uint32_t rl = 0;
size_t rlBytes = 0;
REQUIRE(MqttParser::decodeRemainingLength(out, 1, rl, rlBytes));
CHECK(rlBytes == c.expectedRlBytes);
// Declared Remaining Length equals the actual trailing byte count...
const size_t trailing = out.size() - (1 + rlBytes);
CHECK(rl == trailing);
// ...and equals 2 + topicLen + plength (topic-length prefix + topic + payload).
CHECK(rl == 2 + topicLen + c.plen);
}
}
}
@@ -0,0 +1,404 @@
/*
receive_test.cpp - inbound PUBLISH parsing and callback dispatch
characterization + hardening for the TasmotaPubSub host test system (task 10.5).
Inbound PUBLISH bytes are scripted into the MockClient and delivered to the
*unmodified* library by calling psc.loop() (the callback is registered via
psc.setCallback; the std::function signature is enabled by the -DESP32 build
flag, so lambdas with captures can be bound).
Baseline (TEST_SUITE("baseline")):
- A well-formed QoS 0 PUBLISH scripted inbound => the registered callback is
invoked with the correct topic, payload pointer, and payload length
(Requirement 11.1).
- A well-formed QoS 1 PUBLISH => the callback is invoked AND a PUBACK is
recorded outbound echoing the message id (Requirement 11.2).
- Property 6 (inbound PUBLISH callback round-trip for QoS {0,1}) is folded in
as a single deterministic, data-driven case (SUBCASE / table loops, no
randomized generators) over curated topic/payload vectors: empty payload,
typical, multi-byte/binary, and various topic lengths. The callback
captures (topic, payload copy, length) for assertion (Requirement 18.5).
Hardening (TEST_SUITE("hardening")), Property 11 / F-02 + F-09
(Requirements 11.3-11.6), all expected PASS under AddressSanitizer:
- A QoS 1 PUBLISH whose claimed topic length exceeds the received packet is
rejected without reading beyond the received bytes (Req 11.3).
- A short PUBLISH whose length arithmetic would underflow the payload length
is rejected without invoking the callback with an underflowed length
(Req 11.4).
- A PUBLISH with QoS value 3 is rejected (Req 11.5).
- A PUBLISH with QoS value 2 is rejected rather than parsed as QoS 0
(Req 11.6).
On every rejected packet the callback is NOT invoked, and the library closes
the connection. Adversarial fixtures are built via MqttPacket / sized
std::vector<uint8_t> so the test code itself performs no out-of-bounds access;
AddressSanitizer proves the library reads no bytes beyond those received.
Every assertion goes through the public API, the registered callback, and
decoded MockClient.outbound() wire bytes - never private members - so the
baseline stays durable across a future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "FindingStatus.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Records every inbound PUBLISH the library dispatches to the callback. The
// callback signature is std::function<void(char*,uint8_t*,unsigned int)> under
// -DESP32, so a lambda capturing this struct by reference can be registered.
struct CallbackCapture {
int count = 0; // number of callback invocations
std::string topic; // last topic (NUL-terminated C string)
const uint8_t* payloadPtr = nullptr; // last payload pointer as delivered
unsigned int length = 0; // last payload length as delivered
std::vector<uint8_t> payload; // copy of the last payload bytes
};
// Connect the client/psc pair with a scripted CONNACK, register a callback that
// records into `cap`, and clear the recorded CONNECT bytes so only the
// packet-under-test remains in outbound().
void connectWithCallback(MockClient& client, PubSubClient& psc, CallbackCapture& cap) {
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
psc.setCallback([&cap](char* topic, uint8_t* payload, unsigned int len) {
cap.count++;
cap.topic = topic; // library NUL-terminates the topic
cap.payloadPtr = payload;
cap.length = len;
// Copy exactly `len` bytes; for len == 0 this dereferences nothing.
cap.payload.assign(payload, payload + len);
});
REQUIRE(psc.connect("recv-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
// Deterministic binary payload of length n (includes 0x00 bytes so the
// round-trip proves the dispatch is binary-safe and length-driven, not
// dependent on C-string termination).
std::vector<uint8_t> makePayload(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; ++i) {
v[i] = static_cast<uint8_t>((i * 31u + 7u) & 0xFFu);
}
return v;
}
} // namespace
TEST_SUITE("baseline") {
// --- 11.1: well-formed QoS 0 PUBLISH => callback dispatch ---------------
TEST_CASE("inbound QoS 0 PUBLISH invokes the callback with correct topic/payload/length") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
const std::string topic = "tele/dev/SENSOR";
const std::string msg = "hello world";
const std::vector<uint8_t> payload(msg.begin(), msg.end());
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/0));
REQUIRE(psc.loop());
// The callback fired exactly once with the fixture topic, a non-null
// payload pointer, and the exact payload bytes and length.
CHECK(cap.count == 1);
CHECK(cap.topic == topic);
CHECK(cap.payloadPtr != nullptr);
CHECK(cap.length == payload.size());
CHECK(cap.payload == payload);
// A QoS 0 PUBLISH is not acknowledged, so nothing is written back.
CHECK(client.outbound().empty());
CHECK(psc.connected());
}
// --- 11.2: well-formed QoS 1 PUBLISH => callback + PUBACK ---------------
TEST_CASE("inbound QoS 1 PUBLISH invokes the callback and records a PUBACK echoing the msgId") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
const std::string topic = "cmnd/dev/POWER";
const std::string msg = "ON";
const std::vector<uint8_t> payload(msg.begin(), msg.end());
const uint16_t msgId = 0x1234;
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/1,
/*retained=*/false, msgId));
REQUIRE(psc.loop());
// Callback dispatched with the fixture fields.
CHECK(cap.count == 1);
CHECK(cap.topic == topic);
CHECK(cap.payloadPtr != nullptr);
CHECK(cap.length == payload.size());
CHECK(cap.payload == payload);
// A PUBACK echoing the message id is recorded outbound.
DecodedPacket ack = MqttParser::decode(client.outbound());
REQUIRE(ack.valid);
CHECK(ack.type == static_cast<uint8_t>(MQTTPUBACK));
CHECK(ack.remainingLength == 2);
REQUIRE(ack.payload.size() == 2);
const uint16_t ackMsgId =
static_cast<uint16_t>((ack.payload[0] << 8) | ack.payload[1]);
CHECK(ackMsgId == msgId);
CHECK(psc.connected());
}
// --- Property 6: inbound PUBLISH callback round-trip (QoS 0 and 1) -------
// Feature: tasmota-pubsub-tests, Property 6: for all well-formed inbound
// PUBLISH fixtures with QoS in {0,1}, arbitrary nonempty topic, and arbitrary
// payload, the registered callback is invoked exactly once with a topic
// string equal to the fixture topic and a payload region whose bytes and
// length equal the fixture payload; and for QoS 1 a PUBACK echoing the
// fixture message id is recorded outbound. Validated deterministically over a
// curated table of topic/payload vectors (no randomized generators).
TEST_CASE("Property 6: inbound PUBLISH callback round-trip over curated topics/payloads") {
struct Case { const char* label; std::string topic; std::vector<uint8_t> payload; };
// A multi-byte / UTF-8 topic and payload (degree sign + checkmark).
const std::string utf8Topic = "tele/\xC2\xB0" "C/\xE2\x9C\x93";
const std::string utf8PayloadStr = "temp=21\xC2\xB0" "C \xE2\x9C\x93";
const std::vector<uint8_t> utf8Payload(utf8PayloadStr.begin(), utf8PayloadStr.end());
std::vector<Case> cases = {
{"single-char topic, empty payload", "a", {}},
{"typical topic, short payload", "tele/dev/STATE", {'O','N'}},
{"single-char topic, binary payload","t", makePayload(64)},
{"long topic, empty payload",
"tele/very/deeply/nested/topic/filter/name", {}},
{"long topic, binary payload",
"stat/tasmota_ABCDEF/RESULT", makePayload(200)},
{"utf8 topic + utf8 payload", utf8Topic, utf8Payload},
};
const uint8_t qosValues[] = {0, 1};
for (const Case& c : cases) {
for (uint8_t qos : qosValues) {
CAPTURE(c.label);
CAPTURE(qos);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
// A distinct nonzero message id per QoS-1 fixture.
const uint16_t msgId = static_cast<uint16_t>(0x2000 + c.topic.size());
client.pushPacket(MqttPacket::publish(c.topic, c.payload, qos,
/*retained=*/false,
qos ? msgId : 0));
REQUIRE(psc.loop());
// Callback invoked exactly once with the fixture topic/payload.
CHECK(cap.count == 1);
CHECK(cap.topic == c.topic);
CHECK(cap.payloadPtr != nullptr);
CHECK(cap.length == c.payload.size());
CHECK(cap.payload == c.payload);
if (qos == 1) {
// QoS 1 => a PUBACK echoing the fixture message id.
DecodedPacket ack = MqttParser::decode(client.outbound());
REQUIRE(ack.valid);
CHECK(ack.type == static_cast<uint8_t>(MQTTPUBACK));
CHECK(ack.remainingLength == 2);
REQUIRE(ack.payload.size() == 2);
const uint16_t ackMsgId =
static_cast<uint16_t>((ack.payload[0] << 8) | ack.payload[1]);
CHECK(ackMsgId == msgId);
} else {
// QoS 0 => no acknowledgement is written.
CHECK(client.outbound().empty());
}
CHECK(psc.connected());
}
}
}
}
TEST_SUITE("hardening") {
// Feature: tasmota-pubsub-tests, Property 11: for all inbound PUBLISH
// fixtures whose QoS bits encode 2 or 3, or whose claimed topic length
// exceeds the received packet length, or whose length arithmetic would
// underflow the payload length, the library rejects the packet without
// invoking the callback and without reading beyond the received bytes
// (verified clean under AddressSanitizer).
//
// F-02 (Requirement 11.3): a QoS 1 PUBLISH whose claimed topic length exceeds
// the received packet is rejected. The fixture is a fully well-framed frame
// (Remaining Length equals the trailing byte count) whose 2-byte topic-length
// field claims far more bytes than the packet contains, so the library's
// guard header_len = llen + 3 + tl + 2 > len fires. All reads stay within the
// scripted bytes, so ASan proves no over-read. F-02 is hardened (expected PASS).
TEST_CASE("F-02 QoS 1 PUBLISH with claimed topic length exceeding the packet is rejected"
* FINDING_MARKER(F02)) {
// Each fixture: fixed header PUBLISH|QoS1 (0x32), a single Remaining
// Length byte, then `bodyLen` body bytes whose first two bytes are the
// (oversized) topic-length field. The claimed topic length far exceeds
// the body, so header_len > len.
struct Case { uint8_t bodyLen; uint16_t claimedTopicLen; };
const Case cases[] = {
{4, 500}, // tl=500 but only 4 body bytes present
{6, 1000},
{10, 0x7FFF}, // near-max claimed topic length
{2, 300}, // only the topic-length field present, no topic bytes
};
for (const Case& c : cases) {
CAPTURE(c.bodyLen);
CAPTURE(c.claimedTopicLen);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
// Build the adversarial frame in a sized vector (no OOB in test code).
std::vector<uint8_t> frame;
frame.push_back(static_cast<uint8_t>(MQTTPUBLISH | MQTTQOS1)); // 0x32
frame.push_back(c.bodyLen); // RL (< 128)
frame.push_back(static_cast<uint8_t>(c.claimedTopicLen >> 8)); // topic len hi
frame.push_back(static_cast<uint8_t>(c.claimedTopicLen & 0xFF));// topic len lo
// Remaining body filler so the frame length matches the declared RL.
for (uint8_t i = 2; i < c.bodyLen; ++i) {
frame.push_back(static_cast<uint8_t>('x'));
}
client.pushPacket(MqttPacket::raw(frame));
// Bound any read to a short virtual timeout as a safety net.
psc.setSocketTimeout(1);
psc.loop();
// The packet is rejected: no callback, connection closed.
CHECK(cap.count == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
// F-02 (Requirement 11.4): a short PUBLISH whose length arithmetic would
// underflow the payload length (payload length = len - llen - 3 - tl for
// QoS 0) must not invoke the callback with an underflowed (huge) length. The
// library's guard header_len > len rejects the frame before the subtraction,
// so the callback is never reached. F-02 is hardened (expected PASS).
TEST_CASE("F-02 short PUBLISH that would underflow the payload length is rejected"
* FINDING_MARKER(F02)) {
// QoS 0 frames whose claimed topic length is just large enough that the
// header (llen + 3 + tl) exceeds the received length: without the guard
// the payload length len - llen - 3 - tl would wrap to a huge value.
struct Case { uint8_t bodyLen; uint16_t claimedTopicLen; };
const Case cases[] = {
{5, 10}, // len=7, header_len=1+3+10=14 > 7
{3, 8}, // len=5, header_len=1+3+8=12 > 5
{2, 5}, // len=4, header_len=1+3+5=9 > 4 (no topic bytes at all)
{8, 20}, // len=10, header_len=1+3+20=24 > 10
};
for (const Case& c : cases) {
CAPTURE(c.bodyLen);
CAPTURE(c.claimedTopicLen);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
std::vector<uint8_t> frame;
frame.push_back(static_cast<uint8_t>(MQTTPUBLISH)); // 0x30, QoS 0
frame.push_back(c.bodyLen); // RL (< 128)
frame.push_back(static_cast<uint8_t>(c.claimedTopicLen >> 8)); // topic len hi
frame.push_back(static_cast<uint8_t>(c.claimedTopicLen & 0xFF));// topic len lo
for (uint8_t i = 2; i < c.bodyLen; ++i) {
frame.push_back(static_cast<uint8_t>('y'));
}
client.pushPacket(MqttPacket::raw(frame));
psc.setSocketTimeout(1);
psc.loop();
// No callback (so no underflowed length was ever delivered), and the
// connection is closed.
CHECK(cap.count == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
// F-09 (Requirement 11.5): a PUBLISH whose QoS bits encode 3 (a protocol
// violation) is rejected. The fixture is otherwise well-framed (valid topic
// and payload), so a naive parser would dispatch it; the library rejects
// qos > 1 up front. F-09 is hardened (expected PASS).
TEST_CASE("F-09 inbound PUBLISH with QoS 3 is rejected" * FINDING_MARKER(F09)) {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
const std::string topic = "tele/dev/SENSOR";
const std::vector<uint8_t> payload{'d', 'a', 't', 'a'};
// qos=3 sets both QoS bits; the builder emits a message id (qos > 0) so
// the frame is well-formed apart from the illegal QoS.
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/3,
/*retained=*/false, /*msgId=*/0x0007));
psc.setSocketTimeout(1);
psc.loop();
CHECK(cap.count == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
// F-09 (Requirement 11.6): an inbound QoS 2 PUBLISH is rejected rather than
// parsed as QoS 0. The fixture is a fully well-framed QoS 2 PUBLISH (topic,
// packet identifier, payload); if the library ignored the QoS bits it would
// dispatch the callback. Because it rejects qos > 1, the callback never
// fires. F-09 is hardened (expected PASS).
TEST_CASE("F-09 inbound PUBLISH with QoS 2 is rejected rather than parsed as QoS 0"
* FINDING_MARKER(F09)) {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
CallbackCapture cap;
connectWithCallback(client, psc, cap);
const std::string topic = "tele/dev/SENSOR";
const std::vector<uint8_t> payload = makePayload(16);
client.pushPacket(MqttPacket::publish(topic, payload, /*qos=*/2,
/*retained=*/false, /*msgId=*/0x0042));
psc.setSocketTimeout(1);
psc.loop();
// Not dispatched as QoS 0 (or anything else) - callback never fired.
CHECK(cap.count == 0);
CHECK_FALSE(psc.connected());
CHECK(client.stopCalled());
}
}
@@ -0,0 +1,399 @@
/*
streaming_test.cpp - beginPublish / write / endPublish streaming-publish
characterization + hardening for the TasmotaPubSub host test system (task 10.3).
Baseline (TEST_SUITE("baseline")):
- A payload streamed via beginPublish() + write() + endPublish() is reflected
in the recorded outbound bytes (Requirement 9.3). beginPublish() emits only
the fixed header + Remaining Length + topic; the payload is streamed
afterwards through write(), so the recorded outbound bytes form one complete
PUBLISH packet whose decoded topic/payload equal the input.
- Property 4 (PUBLISH field round-trip: decoded topic == input, decoded
payload == input, retain bit == requested flag) is folded in for the
streaming path as a single deterministic, data-driven case (SUBCASE / table
loops, no randomized generators) covering both the single-byte write(data)
and the buffered write(buf,size) forms, various payload lengths, and the
retained flag on/off.
Hardening (TEST_SUITE("hardening")):
- F-04 (Requirement 9.4, expected PASS): a declared payload length exceeding
the 16-bit range must not emit a truncated Remaining Length that desyncs the
framing. beginPublish() takes a uint32_t Remaining Length and buildHeader is
bounded to four length bytes. Because beginPublish() buffers only the header
(the payload is streamed via write()), a very large *declared* length is
feasible to frame without allocating the payload, so the emitted fixed-header
Remaining Length is verified to equal plength + 2 + topicLen (full 32-bit)
rather than a 16-bit truncation. Marked FINDING_MARKER(F04).
- Property 13 / F-05 (Requirement 9.5, expected FAIL): with a partial transport
write injected (MockClient::setWriteLimit), the publish path should report
failure and leave the connection unusable with intact framing so no later
packet is written onto a desynchronized stream. The current fork returns
success from endPublish() and reuses the connection, so the case is marked
FINDING_MARKER(F05) (should_fail). Cut points are strictly less than the full
packet length.
Every assertion goes through the public API and decoded MockClient.outbound()
wire bytes - never private members - so the baseline stays durable across a
future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "FindingStatus.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Deterministic payload of length n. The pattern intentionally produces 0x00
// bytes so the round-trip also proves the streaming path is binary-safe (no
// reliance on C-string NUL termination).
std::vector<uint8_t> makePayload(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; ++i) {
v[i] = static_cast<uint8_t>((i * 31u + 7u) & 0xFFu);
}
return v;
}
// Connect the client/psc pair with a scripted CONNACK and clear the recorded
// CONNECT bytes so only the packet-under-test remains in outbound().
void connectAndClear(MockClient& client, PubSubClient& psc) {
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("stream-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
// How the payload is fed to the library after beginPublish().
enum class WriteMode { Single, Buffered };
// Perform a complete streamed publish through the public API only:
// beginPublish() -> write() (per WriteMode) -> endPublish(). Returns true when
// every step reported success. All bytes written are recorded by the MockClient
// and, together with the header emitted by beginPublish(), form the full PUBLISH.
bool streamPublish(PubSubClient& psc, const std::string& topic,
const std::vector<uint8_t>& payload, bool retained,
WriteMode mode) {
if (!psc.beginPublish(topic.c_str(),
static_cast<unsigned int>(payload.size()), retained)) {
return false;
}
if (mode == WriteMode::Single) {
for (uint8_t b : payload) {
if (psc.write(b) != 1) {
return false;
}
}
} else { // WriteMode::Buffered
if (!payload.empty()) {
if (psc.write(payload.data(), payload.size()) != payload.size()) {
return false;
}
}
}
return psc.endPublish() != 0;
}
} // namespace
TEST_SUITE("baseline") {
// --- 9.3: streamed payload is reflected in the recorded outbound bytes ---
TEST_CASE("streamed publish (single-byte write) is reflected in the outbound PUBLISH") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::string topic = "tele/dev/SENSOR";
const std::string msg = "streamed hello";
const std::vector<uint8_t> payload(msg.begin(), msg.end());
REQUIRE(psc.beginPublish(topic.c_str(),
static_cast<unsigned int>(payload.size()), false));
for (uint8_t b : payload) {
REQUIRE(psc.write(b) == 1);
}
REQUIRE(psc.endPublish() != 0);
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.qos == 0);
CHECK(d.msgId == 0); // streaming publish is QoS 0 (no packet id)
CHECK_FALSE(d.retain);
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
TEST_CASE("streamed publish (buffered write) is reflected in the outbound PUBLISH") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::string topic = "stat/dev/RESULT";
const std::vector<uint8_t> payload = makePayload(200);
REQUIRE(psc.beginPublish(topic.c_str(),
static_cast<unsigned int>(payload.size()), true));
REQUIRE(psc.write(payload.data(), payload.size()) == payload.size());
REQUIRE(psc.endPublish() != 0);
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.qos == 0);
CHECK(d.retain); // retained flag requested
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
// The payload may be streamed across several write() calls; the recorded
// outbound bytes must still assemble into one complete, well-framed PUBLISH.
TEST_CASE("streamed publish across chunked writes assembles one framed PUBLISH") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::string topic = "tele/dev/CHUNKED";
const std::vector<uint8_t> payload = makePayload(50);
REQUIRE(psc.beginPublish(topic.c_str(),
static_cast<unsigned int>(payload.size()), false));
// Mix single-byte and buffered writes to model an incremental producer.
REQUIRE(psc.write(payload[0]) == 1);
REQUIRE(psc.write(payload.data() + 1, 9) == 9);
for (size_t i = 10; i < 20; ++i) {
REQUIRE(psc.write(payload[i]) == 1);
}
REQUIRE(psc.write(payload.data() + 20, payload.size() - 20)
== payload.size() - 20);
REQUIRE(psc.endPublish() != 0);
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
// --- Property 4: streaming PUBLISH field round-trip ---------------------
// Feature: tasmota-pubsub-tests, Property 4: for all topics and payloads
// (including the retained flag and empty payloads), every publish path emits
// a PUBLISH whose decoded topic equals the input topic, whose decoded payload
// equals the input payload, and whose retain bit equals the requested flag.
// Validated deterministically for the streaming path over a curated table of
// payload lengths, both write modes, and the retained flag (no randomized
// generators).
TEST_CASE("Property 4: streaming PUBLISH field round-trip over write modes and lengths") {
// Payload-length boundaries reachable through the default 1200-byte
// working buffer's header (the payload itself is streamed, not buffered):
// empty, single byte, the 1->2 byte Remaining Length transition, and a
// few larger sizes.
const size_t payloadLengths[] = {0, 1, 2, 127, 128, 255, 1000};
const bool retainedFlags[] = {false, true};
const WriteMode modes[] = {WriteMode::Single, WriteMode::Buffered};
const std::string topic = "tele/dev/SENSOR";
for (WriteMode mode : modes) {
for (size_t plen : payloadLengths) {
for (bool retained : retainedFlags) {
CAPTURE(static_cast<int>(mode));
CAPTURE(plen);
CAPTURE(retained);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::vector<uint8_t> payload = makePayload(plen);
REQUIRE(streamPublish(psc, topic, payload, retained, mode));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.qos == 0);
CHECK(d.msgId == 0);
CHECK(d.retain == retained);
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
}
}
}
// Property 4 for streamed payloads that cross the 16-bit Remaining Length
// boundary, asserted against the library's *real* emitted bytes. The buffered
// publish() path cannot frame these (its payload must fit the working
// buffer), but the streaming path frames them because the payload is not
// buffered. This exercises the 2->3 byte Remaining Length transition and
// payloads above 65535 bytes end to end.
TEST_CASE("Property 4: streaming PUBLISH field round-trip across the 16-bit boundary") {
const size_t payloadLengths[] = {16383, 16384, 65535, 65536};
const bool retainedFlags[] = {false, true};
const std::string topic = "t"; // small topic keeps the header tiny
for (size_t plen : payloadLengths) {
for (bool retained : retainedFlags) {
CAPTURE(plen);
CAPTURE(retained);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
const std::vector<uint8_t> payload = makePayload(plen);
// Buffered write is used so the large payload is emitted in one
// call; the recorded outbound bytes are the full PUBLISH.
REQUIRE(streamPublish(psc, topic, payload, retained, WriteMode::Buffered));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(MqttParser::isStructurallyValidPublish(out));
DecodedPublish d = MqttParser::decodePublish(out);
REQUIRE(d.valid);
CHECK(d.retain == retained);
CHECK(d.topic == topic);
CHECK(d.payload == payload);
}
}
}
}
TEST_SUITE("hardening") {
// F-04 (Requirement 9.4): a declared payload length exceeding the 16-bit
// range must not emit a truncated Remaining Length that desynchronizes the
// packet framing. beginPublish() buffers only the header (fixed header +
// Remaining Length + topic) and streams the payload separately, so a very
// large *declared* length can be framed without allocating the payload.
// Verify the emitted Remaining Length equals plength + 2 + topicLen using the
// full 32-bit value rather than a 16-bit truncation, and that the header's
// byte layout stays self-consistent. F-04 is hardened in the current fork
// (32-bit buildHeader bounded to four length bytes), so this is expected PASS.
TEST_CASE("F-04 large declared payload length frames the Remaining Length without truncation"
* FINDING_MARKER(F04)) {
const std::string topic = "t"; // topicLen == 1
const size_t topicLen = topic.size();
// Declared lengths that force Remaining Length above the 16-bit range,
// including the 2->3 and 3->4 byte transitions and the MQTT maximum.
// Remaining Length = plength + 2 + topicLen.
const unsigned int plengths[] = {
65533u, // RL = 65536 (first value needing 3 bytes)
65536u, // RL = 65539 (3 bytes)
100000u, // RL = 100003 (3 bytes)
2097149u, // RL = 2097152 (first value needing 4 bytes)
10000000u, // RL = 10000003 (4 bytes)
268435455u - 2u - 1u, // RL = 268435455 (MQTT maximum, 4 bytes)
};
for (unsigned int plen : plengths) {
CAPTURE(plen);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
// beginPublish emits the header only; with no write limit the mock
// accepts every byte so beginPublish reports success.
REQUIRE(psc.beginPublish(topic.c_str(), plen, false));
const std::vector<uint8_t>& out = client.outbound();
REQUIRE(out.size() >= 2);
// Fixed-header high nibble is PUBLISH, retain bit clear.
CHECK(static_cast<uint8_t>(out[0] & 0xF0) == static_cast<uint8_t>(MQTTPUBLISH));
CHECK((out[0] & 0x01) == 0x00);
// Decode the Remaining Length field directly from the emitted header.
uint32_t rl = 0;
size_t rlBytes = 0;
REQUIRE(MqttParser::decodeRemainingLength(out, 1, rl, rlBytes));
const uint32_t expected =
static_cast<uint32_t>(plen) + 2u + static_cast<uint32_t>(topicLen);
// The Remaining Length is the full 32-bit value, not a 16-bit
// truncation that would desync framing.
CHECK(rl == expected);
if (expected > 0xFFFFu) {
CHECK(rl != (expected & 0xFFFFu));
}
// Header framing stays self-consistent: beginPublish emits exactly
// the fixed header (1) + Remaining Length bytes + topic length prefix
// (2) + topic bytes. No payload has been streamed yet.
CHECK(out.size() == 1u + rlBytes + 2u + topicLen);
// The emitted 2-byte topic length prefix matches the topic.
const size_t topicLenPos = 1u + rlBytes;
REQUIRE(out.size() >= topicLenPos + 2u);
const uint16_t emittedTopicLen = static_cast<uint16_t>(
(out[topicLenPos] << 8) | out[topicLenPos + 1]);
CHECK(emittedTopicLen == topicLen);
}
}
// Feature: tasmota-pubsub-tests, Property 13: for all partial-write cut
// points strictly less than the full packet length, the publish path reports
// failure and leaves the connection unusable (lost/closed) so that no
// subsequent packet is written onto a desynchronized stream. The current fork
// returns success from endPublish() and keeps the connection, so this is
// expected to fail until F-05 is hardened.
TEST_CASE("F-05 partial transport write fails the streaming publish and disables reuse"
* FINDING_MARKER(F05)) {
const std::string topic = "s"; // topicLen == 1
const std::vector<uint8_t> payload = makePayload(300);
// Header = fixed(1) + Remaining Length(2, since RL=303) + topic len(2)
// + topic(1) = 6 bytes; full packet length = 6 + 300 = 306. Cut points
// are >= 6 (so beginPublish's header write completes) and < 300 (so the
// buffered payload write is truncated) - all strictly less than 306.
const size_t cutPoints[] = {10, 50, 150, 299};
for (size_t cut : cutPoints) {
CAPTURE(cut);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
client.setWriteLimit(cut);
// The header (6 bytes) fits within the cut, so beginPublish succeeds.
REQUIRE(psc.beginPublish(topic.c_str(),
static_cast<unsigned int>(payload.size()), false));
// The transport accepts fewer payload bytes than requested: a genuine
// partial write is injected here.
const size_t written = psc.write(payload.data(), payload.size());
REQUIRE(written < payload.size());
// Hardened contract: the publish path reports failure...
const int endRc = psc.endPublish();
CHECK(endRc == 0);
// ...and leaves the connection unusable so no later packet can be
// written onto the now-desynchronized stream.
CHECK_FALSE(psc.connected());
}
}
}
@@ -0,0 +1,324 @@
/*
subscribe_test.cpp - subscribe / unsubscribe characterization + hardening for
the TasmotaPubSub host test system (task 10.4).
Baseline (TEST_SUITE("baseline")):
- subscribe() records a structurally valid SUBSCRIBE packet (Requirement 10.1).
- unsubscribe() records a structurally valid UNSUBSCRIBE packet (Req 10.2).
- Property 5 (SUBSCRIBE/UNSUBSCRIBE field round-trip: nonzero message id, the
trailing requested-QoS byte for SUBSCRIBE, and the topic filter equal to the
input) is folded in as a single deterministic, data-driven case (SUBCASE /
table loops, no randomized generators) over curated QoS {0,1} and topic
vectors. The explicit-QoS subscribe overload is covered here (Req 18.6).
Hardening (TEST_SUITE("hardening")):
- F-08 (Requirement 10.3, expected PASS): a SUBSCRIBE whose topic length brings
the packet to exactly the working-buffer capacity does not write the trailing
QoS byte out of bounds. The buffer is sized to exactly 10 + topicLength (the
library's own bound: header(5) + msgId(2) + topic-length(2) + topic + qos(1)),
so the QoS byte lands at buffer[bufferSize - 1]; an off-by-one would write at
buffer[bufferSize] and trip AddressSanitizer. Marked FINDING_MARKER(F08).
- Null-topic rejection (Requirement 10.5, expected PASS): subscribe() and
unsubscribe() reject a null topic pointer before any length computation
dereferences it (no strnlen on nullptr, no packet emitted). Plain hardening
case (expected to pass against the current fork).
- F-10 (Requirement 10.4, expected FAIL): a subscribe operation should reflect
the SUBACK return code rather than reporting success unconditionally. The
current fork ignores SUBACK entirely and returns the transport write result,
so the case is marked FINDING_MARKER(F10) (should_fail).
Every assertion goes through the public API and decoded MockClient.outbound()
wire bytes - never private members - so the baseline stays durable across a
future MQTT 5 migration (Requirement 19.1).
*/
#include <cstdint>
#include <string>
#include <vector>
#include "doctest.h"
#include "FindingStatus.h"
#include "MockClient.h"
#include "MqttPacket.h"
#include "TestClock.h"
#include "PubSubClient.h"
namespace {
// Connect the client/psc pair with a scripted CONNACK and clear the recorded
// CONNECT bytes so only the packet-under-test remains in outbound().
void connectAndClear(MockClient& client, PubSubClient& psc) {
client.pushPacket(MqttPacket::connack(0));
psc.setServer("broker.example", 1883);
REQUIRE(psc.connect("sub-client"));
REQUIRE(psc.connected());
client.clearOutbound();
}
} // namespace
TEST_SUITE("baseline") {
// --- 10.1: subscribe() records a valid SUBSCRIBE packet -----------------
TEST_CASE("subscribe records a structurally well-framed SUBSCRIBE packet") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.subscribe("tele/dev/SENSOR"));
const std::vector<uint8_t>& out = client.outbound();
// Generic framing: one complete control packet whose declared Remaining
// Length equals the trailing byte count, SUBSCRIBE type, QoS1 fixed flags
// (the library always sends MQTTSUBSCRIBE | MQTTQOS1).
DecodedPacket generic = MqttParser::decode(out);
REQUIRE(generic.valid);
CHECK(generic.type == static_cast<uint8_t>(MQTTSUBSCRIBE));
CHECK(generic.flags == static_cast<uint8_t>(MQTTQOS1));
CHECK(generic.remainingLength == generic.payload.size());
// SUBSCRIBE-specific fields.
DecodedSubscribe s = MqttParser::decodeSubscribe(out);
REQUIRE(s.valid);
CHECK(s.msgId != 0); // packet identifier is nonzero
REQUIRE(s.filters.size() == 1);
CHECK(s.filters[0] == "tele/dev/SENSOR");
REQUIRE(s.requestedQos.size() == 1);
CHECK(s.requestedQos[0] == 0); // subscribe(topic) defaults QoS 0
}
// --- 10.2: unsubscribe() records a valid UNSUBSCRIBE packet -------------
TEST_CASE("unsubscribe records a structurally well-framed UNSUBSCRIBE packet") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.unsubscribe("cmnd/dev/POWER"));
const std::vector<uint8_t>& out = client.outbound();
DecodedPacket generic = MqttParser::decode(out);
REQUIRE(generic.valid);
CHECK(generic.type == static_cast<uint8_t>(MQTTUNSUBSCRIBE));
CHECK(generic.flags == static_cast<uint8_t>(MQTTQOS1));
CHECK(generic.remainingLength == generic.payload.size());
DecodedUnsubscribe u = MqttParser::decodeUnsubscribe(out);
REQUIRE(u.valid);
CHECK(u.msgId != 0); // packet identifier is nonzero
REQUIRE(u.filters.size() == 1);
CHECK(u.filters[0] == "cmnd/dev/POWER");
}
// --- Property 5: SUBSCRIBE/UNSUBSCRIBE field round-trip -----------------
// Feature: tasmota-pubsub-tests, Property 5: for all topics and requested QoS
// in {0,1}, subscribe emits a SUBSCRIBE with a nonzero message id, the topic
// filter equal to the input, and a trailing requested-QoS byte equal to the
// argument; unsubscribe emits an UNSUBSCRIBE with a nonzero message id and the
// topic filter equal to the input. Validated deterministically over a curated
// table of QoS values and topic vectors (no randomized generators). The
// explicit-QoS subscribe overload (Requirement 18.6) is exercised for QoS 1.
TEST_CASE("Property 5: SUBSCRIBE/UNSUBSCRIBE field round-trip over curated QoS and topics") {
// Curated topic filters: single level, multi-level, wildcards, a
// single-character topic, and a longer nested filter.
const std::string topics[] = {
"a",
"tele/dev/SENSOR",
"cmnd/tasmota_ABCDEF/+",
"stat/#",
"tele/very/deeply/nested/topic/filter/name",
};
const uint8_t qosValues[] = {0, 1};
for (const std::string& topic : topics) {
// --- SUBSCRIBE for each requested QoS in {0,1} ------------------
for (uint8_t qos : qosValues) {
CAPTURE(topic);
CAPTURE(qos);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
// Exercise the explicit-QoS overload (Requirement 18.6).
REQUIRE(psc.subscribe(topic.c_str(), qos));
const std::vector<uint8_t>& out = client.outbound();
DecodedPacket generic = MqttParser::decode(out);
REQUIRE(generic.valid);
CHECK(generic.type == static_cast<uint8_t>(MQTTSUBSCRIBE));
CHECK(generic.flags == static_cast<uint8_t>(MQTTQOS1));
CHECK(generic.remainingLength == generic.payload.size());
DecodedSubscribe s = MqttParser::decodeSubscribe(out);
REQUIRE(s.valid);
CHECK(s.msgId != 0);
REQUIRE(s.filters.size() == 1);
CHECK(s.filters[0] == topic);
REQUIRE(s.requestedQos.size() == 1);
CHECK(s.requestedQos[0] == qos);
}
// --- UNSUBSCRIBE (no requested-QoS byte on the wire) ------------
{
CAPTURE(topic);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.unsubscribe(topic.c_str()));
const std::vector<uint8_t>& out = client.outbound();
DecodedPacket generic = MqttParser::decode(out);
REQUIRE(generic.valid);
CHECK(generic.type == static_cast<uint8_t>(MQTTUNSUBSCRIBE));
CHECK(generic.flags == static_cast<uint8_t>(MQTTQOS1));
CHECK(generic.remainingLength == generic.payload.size());
DecodedUnsubscribe u = MqttParser::decodeUnsubscribe(out);
REQUIRE(u.valid);
CHECK(u.msgId != 0);
REQUIRE(u.filters.size() == 1);
CHECK(u.filters[0] == topic);
}
}
}
// Successive subscribe/unsubscribe calls advance the packet identifier, so
// each emitted packet carries its own nonzero message id (characterization of
// the observable nextMsgId behavior across the public API).
TEST_CASE("successive subscribe/unsubscribe calls carry distinct nonzero message ids") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
REQUIRE(psc.subscribe("tele/a/#"));
DecodedSubscribe s1 = MqttParser::decodeSubscribe(client.outbound());
REQUIRE(s1.valid);
client.clearOutbound();
REQUIRE(psc.subscribe("tele/b/#"));
DecodedSubscribe s2 = MqttParser::decodeSubscribe(client.outbound());
REQUIRE(s2.valid);
client.clearOutbound();
REQUIRE(psc.unsubscribe("tele/a/#"));
DecodedUnsubscribe u1 = MqttParser::decodeUnsubscribe(client.outbound());
REQUIRE(u1.valid);
CHECK(s1.msgId != 0);
CHECK(s2.msgId != 0);
CHECK(u1.msgId != 0);
CHECK(s2.msgId != s1.msgId); // identifier advances between calls
CHECK(u1.msgId != s2.msgId);
}
}
TEST_SUITE("hardening") {
// F-08 (Requirement 10.3): a SUBSCRIBE whose topic length brings the packet to
// exactly the buffer capacity must not write the trailing QoS byte beyond the
// buffer bounds. The library's own bound is bufferSize >= 10 + topicLength
// (header(5) + msgId(2) + topic-length(2) + topic + qos(1)); at exact capacity
// the QoS byte is written at buffer[9 + topicLength] == buffer[bufferSize - 1].
// An off-by-one would write at buffer[bufferSize] and AddressSanitizer would
// abort. The buffer is set AFTER connecting (the CONNECT packet needs the full
// default buffer) so the exact-capacity boundary applies only to the SUBSCRIBE.
// F-08 is hardened in the current fork, so this is expected PASS.
TEST_CASE("F-08 exact-buffer SUBSCRIBE does not write the QoS byte out of bounds"
* FINDING_MARKER(F08)) {
// Topic lengths kept small so the Remaining Length stays a single byte;
// the point is the exact-capacity boundary, not large packets.
const size_t topicLengths[] = {1, 5, 16, 50, 117};
const uint8_t qosValues[] = {0, 1};
for (size_t topicLen : topicLengths) {
for (uint8_t qos : qosValues) {
CAPTURE(topicLen);
CAPTURE(qos);
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
// Shrink the working buffer to EXACTLY the SUBSCRIBE bound. The
// realloc succeeds; the connection state is unaffected.
const uint16_t exactCapacity =
static_cast<uint16_t>(10 + topicLen);
REQUIRE(psc.setBufferSize(exactCapacity));
REQUIRE(psc.getBufferSize() == exactCapacity);
const std::string topic(topicLen, 'a');
// The QoS byte is written at buffer[bufferSize - 1]; ASan proves
// no write at buffer[bufferSize].
REQUIRE(psc.subscribe(topic.c_str(), qos));
// The emitted packet is still a well-framed SUBSCRIBE carrying the
// exact topic and requested QoS.
const std::vector<uint8_t>& out = client.outbound();
DecodedSubscribe s = MqttParser::decodeSubscribe(out);
REQUIRE(s.valid);
CHECK(s.msgId != 0);
REQUIRE(s.filters.size() == 1);
CHECK(s.filters[0] == topic);
REQUIRE(s.requestedQos.size() == 1);
CHECK(s.requestedQos[0] == qos);
}
}
}
// Requirement 10.5: subscribe() and unsubscribe() must reject a null topic
// pointer before any length computation dereferences it. A hardened
// implementation returns false and emits nothing (no strnlen(nullptr, ...)).
// The current fork already guards this, so it is expected PASS. Run under
// ASan, a missing guard would fault while computing the topic length.
TEST_CASE("subscribe / unsubscribe reject a null topic pointer") {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
CHECK_FALSE(psc.subscribe(nullptr));
CHECK_FALSE(psc.subscribe(nullptr, 0));
CHECK_FALSE(psc.subscribe(nullptr, 1));
CHECK_FALSE(psc.unsubscribe(nullptr));
// No packet was written for any rejected call.
CHECK(client.outbound().empty());
// The connection remains usable after the rejections.
CHECK(psc.connected());
}
// F-10 (Requirement 10.4): a subscribe operation should reflect the SUBACK
// return code rather than reporting success unconditionally. A hardened
// implementation, on receiving a SUBACK carrying a failure return code
// (0x80), would surface the failure to the caller. The current fork ignores
// SUBACK entirely and returns the transport write result (true), so this case
// is expected to fail until F-10 is hardened.
TEST_CASE("F-10 subscribe reflects the SUBACK return code" * FINDING_MARKER(F10)) {
TestClock::instance().reset();
MockClient client;
PubSubClient psc(client);
connectAndClear(client, psc);
// Script a SUBACK with the MQTT "failure" return code (0x80) for the
// packet identifier the first subscribe() will use (nextMsgId advances to
// 1 on the first call after connect).
client.pushPacket(MqttPacket::suback(1, 0x80));
// Hardened contract: subscribe reflects the SUBACK failure and reports
// failure. The current fork returns true unconditionally.
CHECK_FALSE(psc.subscribe("tele/dev/SENSOR"));
}
}
@@ -0,0 +1,40 @@
/*
test_main.cpp - doctest entry point for the TasmotaPubSub host test system.
This is the SINGLE translation unit in the whole test binary that defines
DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN before including the doctest single header,
so doctest's main() and implementation are emitted here exactly once. Every
other *_test.cpp includes "doctest.h" only.
It also carries the first trivial baseline test that constructs the unmodified
PubSubClient library against the Arduino shim, proving the whole toolchain
(library + shim + support lib + doctest) compiles, links, runs, and reports
pass/fail/skip counts under ASan/UBSan (task 4.2 / Requirements 1.2, 1.3,
3.1, 3.2, 3.3).
*/
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include "TestClock.h"
#include "PubSubClient.h"
// Every case is tagged into either the "baseline" or "hardening" suite so the
// two groups can be selected independently via -ts=baseline / -ts=hardening.
TEST_SUITE("baseline") {
// Characterization test: a freshly default-constructed PubSubClient has a
// stable, well-defined initial observable state. Per PubSubClient.h the
// _state member is initialized to MQTT_DISCONNECTED (-1) and no transport
// is attached, so connected() must report false.
TEST_CASE("PubSubClient default construction has a stable initial state") {
// Reset the virtual clock for determinism/isolation (optional here).
TestClock::instance().reset();
PubSubClient client;
CHECK(client.state() == MQTT_DISCONNECTED);
CHECK(client.state() == -1);
CHECK(client.connected() == false);
}
}
@@ -1,5 +0,0 @@
tests/bin
.pioenvs
.piolibdeps
.clang_complete
.gcc-flags.json
@@ -1,7 +0,0 @@
sudo: false
language: cpp
compiler:
- g++
script: cd tests && make && make test
os:
- linux
@@ -1,85 +0,0 @@
2.8
* Add setBufferSize() to override MQTT_MAX_PACKET_SIZE
* Add setKeepAlive() to override MQTT_KEEPALIVE
* Add setSocketTimeout() to overide MQTT_SOCKET_TIMEOUT
* Added check to prevent subscribe/unsubscribe to empty topics
* Declare wifi mode prior to connect in ESP example
* Use `strnlen` to avoid overruns
* Support pre-connected Client objects
2.7
* Fix remaining-length handling to prevent buffer overrun
* Add large-payload API - beginPublish/write/publish/endPublish
* Add yield call to improve reliability on ESP
* Add Clean Session flag to connect options
* Add ESP32 support for functional callback signature
* Various other fixes
2.4
* Add MQTT_SOCKET_TIMEOUT to prevent it blocking indefinitely
whilst waiting for inbound data
* Fixed return code when publishing >256 bytes
2.3
* Add publish(topic,payload,retained) function
2.2
* Change code layout to match Arduino Library reqs
2.1
* Add MAX_TRANSFER_SIZE def to chunk messages if needed
* Reject topic/payloads that exceed MQTT_MAX_PACKET_SIZE
2.0
* Add (and default to) MQTT 3.1.1 support
* Fix PROGMEM handling for Intel Galileo/ESP8266
* Add overloaded constructors for convenience
* Add chainable setters for server/callback/client/stream
* Add state function to return connack return code
1.9
* Do not split MQTT packets over multiple calls to _client->write()
* API change: All constructors now require an instance of Client
to be passed in.
* Fixed example to match 1.8 api changes - dpslwk
* Added username/password support - WilHall
* Added publish_P - publishes messages from PROGMEM - jobytaffey
1.8
* KeepAlive interval is configurable in PubSubClient.h
* Maximum packet size is configurable in PubSubClient.h
* API change: Return boolean rather than int from various functions
* API change: Length parameter in message callback changed
from int to unsigned int
* Various internal tidy-ups around types
1.7
* Improved keepalive handling
* Updated to the Arduino-1.0 API
1.6
* Added the ability to publish a retained message
1.5
* Added default constructor
* Fixed compile error when used with arduino-0021 or later
1.4
* Fixed connection lost handling
1.3
* Fixed packet reading bug in PubSubClient.readPacket
1.2
* Fixed compile error when used with arduino-0016 or later
1.1
* Reduced size of library
* Added support for Will messages
* Clarified licensing - see LICENSE.txt
1.0
* Only Quality of Service (QOS) 0 messaging is supported
* The maximum message size, including header, is 128 bytes
* The keepalive interval is set to 30 seconds
* No support for Will messages
-50
View File
@@ -1,50 +0,0 @@
# Arduino Client for MQTT
This library provides a client for doing simple publish/subscribe messaging with
a server that supports MQTT.
## Examples
The library comes with a number of example sketches. See File > Examples > PubSubClient
within the Arduino application.
Full API documentation is available here: https://pubsubclient.knolleary.net
## Limitations
- It can only publish QoS 0 messages. It can subscribe at QoS 0 or QoS 1.
- The maximum message size, including header, is **256 bytes** by default. This
is configurable via `MQTT_MAX_PACKET_SIZE` in `PubSubClient.h` or can be changed
by calling `PubSubClient::setBufferSize(size)`.
- The keepalive interval is set to 15 seconds by default. This is configurable
via `MQTT_KEEPALIVE` in `PubSubClient.h` or can be changed by calling
`PubSubClient::setKeepAlive(keepAlive)`.
- The client uses MQTT 3.1.1 by default. It can be changed to use MQTT 3.1 by
changing value of `MQTT_VERSION` in `PubSubClient.h`.
## Compatible Hardware
The library uses the Arduino Ethernet Client api for interacting with the
underlying network hardware. This means it Just Works with a growing number of
boards and shields, including:
- Arduino Ethernet
- Arduino Ethernet Shield
- Arduino YUN use the included `YunClient` in place of `EthernetClient`, and
be sure to do a `Bridge.begin()` first
- Arduino WiFi Shield - if you want to send packets > 90 bytes with this shield,
enable the `MQTT_MAX_TRANSFER_SIZE` define in `PubSubClient.h`.
- Sparkfun WiFly Shield [library](https://github.com/dpslwk/WiFly)
- TI CC3000 WiFi - [library](https://github.com/sparkfun/SFE_CC3000_Library)
- Intel Galileo/Edison
- ESP8266
- ESP32
The library cannot currently be used with hardware based on the ENC28J60 chip
such as the Nanode or the Nuelectronics Ethernet Shield. For those, there is an
[alternative library](https://github.com/njh/NanodeMQTT) available.
## License
This code is released under the MIT License.
@@ -1,43 +0,0 @@
/*
Basic MQTT example with Authentication
- connects to an MQTT server, providing username
and password
- publishes "hello world" to the topic "outTopic"
- subscribes to the topic "inTopic"
*/
#include <SPI.h>
#include <Ethernet.h>
#include <PubSubClient.h>
// Update these with values suitable for your network.
byte mac[] = { 0xDE, 0xED, 0xBA, 0xFE, 0xFE, 0xED };
IPAddress ip(172, 16, 0, 100);
IPAddress server(172, 16, 0, 2);
void callback(char* topic, byte* payload, unsigned int length) {
// handle message arrived
}
EthernetClient ethClient;
PubSubClient client(server, 1883, callback, ethClient);
void setup()
{
Ethernet.begin(mac, ip);
// Note - the default maximum packet size is 128 bytes. If the
// combined length of clientId, username and password exceed this use the
// following to increase the buffer size:
// client.setBufferSize(255);
if (client.connect("arduinoClient", "testuser", "testpass")) {
client.publish("outTopic","hello world");
client.subscribe("inTopic");
}
}
void loop()
{
client.loop();
}
@@ -1,77 +0,0 @@
/*
Basic MQTT example
This sketch demonstrates the basic capabilities of the library.
It connects to an MQTT server then:
- publishes "hello world" to the topic "outTopic"
- subscribes to the topic "inTopic", printing out any messages
it receives. NB - it assumes the received payloads are strings not binary
It will reconnect to the server if the connection is lost using a blocking
reconnect function. See the 'mqtt_reconnect_nonblocking' example for how to
achieve the same result without blocking the main loop.
*/
#include <SPI.h>
#include <Ethernet.h>
#include <PubSubClient.h>
// Update these with values suitable for your network.
byte mac[] = { 0xDE, 0xED, 0xBA, 0xFE, 0xFE, 0xED };
IPAddress ip(172, 16, 0, 100);
IPAddress server(172, 16, 0, 2);
void callback(char* topic, byte* payload, unsigned int length) {
Serial.print("Message arrived [");
Serial.print(topic);
Serial.print("] ");
for (int i=0;i<length;i++) {
Serial.print((char)payload[i]);
}
Serial.println();
}
EthernetClient ethClient;
PubSubClient client(ethClient);
void reconnect() {
// Loop until we're reconnected
while (!client.connected()) {
Serial.print("Attempting MQTT connection...");
// Attempt to connect
if (client.connect("arduinoClient")) {
Serial.println("connected");
// Once connected, publish an announcement...
client.publish("outTopic","hello world");
// ... and resubscribe
client.subscribe("inTopic");
} else {
Serial.print("failed, rc=");
Serial.print(client.state());
Serial.println(" try again in 5 seconds");
// Wait 5 seconds before retrying
delay(5000);
}
}
}
void setup()
{
Serial.begin(57600);
client.setServer(server, 1883);
client.setCallback(callback);
Ethernet.begin(mac, ip);
// Allow the hardware to sort itself out
delay(1500);
}
void loop()
{
if (!client.connected()) {
reconnect();
}
client.loop();
}
@@ -1,129 +0,0 @@
/*
Basic ESP8266 MQTT example
This sketch demonstrates the capabilities of the pubsub library in combination
with the ESP8266 board/library.
It connects to an MQTT server then:
- publishes "hello world" to the topic "outTopic" every two seconds
- subscribes to the topic "inTopic", printing out any messages
it receives. NB - it assumes the received payloads are strings not binary
- If the first character of the topic "inTopic" is an 1, switch ON the ESP Led,
else switch it off
It will reconnect to the server if the connection is lost using a blocking
reconnect function. See the 'mqtt_reconnect_nonblocking' example for how to
achieve the same result without blocking the main loop.
To install the ESP8266 board, (using Arduino 1.6.4+):
- Add the following 3rd party board manager under "File -> Preferences -> Additional Boards Manager URLs":
http://arduino.esp8266.com/stable/package_esp8266com_index.json
- Open the "Tools -> Board -> Board Manager" and click install for the ESP8266"
- Select your ESP8266 in "Tools -> Board"
*/
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
// Update these with values suitable for your network.
const char* ssid = "........";
const char* password = "........";
const char* mqtt_server = "broker.mqtt-dashboard.com";
WiFiClient espClient;
PubSubClient client(espClient);
unsigned long lastMsg = 0;
#define MSG_BUFFER_SIZE (50)
char msg[MSG_BUFFER_SIZE];
int value = 0;
void setup_wifi() {
delay(10);
// We start by connecting to a WiFi network
Serial.println();
Serial.print("Connecting to ");
Serial.println(ssid);
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
randomSeed(micros());
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
}
void callback(char* topic, byte* payload, unsigned int length) {
Serial.print("Message arrived [");
Serial.print(topic);
Serial.print("] ");
for (int i = 0; i < length; i++) {
Serial.print((char)payload[i]);
}
Serial.println();
// Switch on the LED if an 1 was received as first character
if ((char)payload[0] == '1') {
digitalWrite(BUILTIN_LED, LOW); // Turn the LED on (Note that LOW is the voltage level
// but actually the LED is on; this is because
// it is active low on the ESP-01)
} else {
digitalWrite(BUILTIN_LED, HIGH); // Turn the LED off by making the voltage HIGH
}
}
void reconnect() {
// Loop until we're reconnected
while (!client.connected()) {
Serial.print("Attempting MQTT connection...");
// Create a random client ID
String clientId = "ESP8266Client-";
clientId += String(random(0xffff), HEX);
// Attempt to connect
if (client.connect(clientId.c_str())) {
Serial.println("connected");
// Once connected, publish an announcement...
client.publish("outTopic", "hello world");
// ... and resubscribe
client.subscribe("inTopic");
} else {
Serial.print("failed, rc=");
Serial.print(client.state());
Serial.println(" try again in 5 seconds");
// Wait 5 seconds before retrying
delay(5000);
}
}
}
void setup() {
pinMode(BUILTIN_LED, OUTPUT); // Initialize the BUILTIN_LED pin as an output
Serial.begin(115200);
setup_wifi();
client.setServer(mqtt_server, 1883);
client.setCallback(callback);
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
unsigned long now = millis();
if (now - lastMsg > 2000) {
lastMsg = now;
++value;
snprintf (msg, MSG_BUFFER_SIZE, "hello world #%ld", value);
Serial.print("Publish message: ");
Serial.println(msg);
client.publish("outTopic", msg);
}
}
@@ -1,179 +0,0 @@
/*
Long message ESP8266 MQTT example
This sketch demonstrates sending arbitrarily large messages in combination
with the ESP8266 board/library.
It connects to an MQTT server then:
- publishes "hello world" to the topic "outTopic"
- subscribes to the topic "greenBottles/#", printing out any messages
it receives. NB - it assumes the received payloads are strings not binary
- If the sub-topic is a number, it publishes a "greenBottles/lyrics" message
with a payload consisting of the lyrics to "10 green bottles", replacing
10 with the number given in the sub-topic.
It will reconnect to the server if the connection is lost using a blocking
reconnect function. See the 'mqtt_reconnect_nonblocking' example for how to
achieve the same result without blocking the main loop.
To install the ESP8266 board, (using Arduino 1.6.4+):
- Add the following 3rd party board manager under "File -> Preferences -> Additional Boards Manager URLs":
http://arduino.esp8266.com/stable/package_esp8266com_index.json
- Open the "Tools -> Board -> Board Manager" and click install for the ESP8266"
- Select your ESP8266 in "Tools -> Board"
*/
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
// Update these with values suitable for your network.
const char* ssid = "........";
const char* password = "........";
const char* mqtt_server = "broker.mqtt-dashboard.com";
WiFiClient espClient;
PubSubClient client(espClient);
long lastMsg = 0;
char msg[50];
int value = 0;
void setup_wifi() {
delay(10);
// We start by connecting to a WiFi network
Serial.println();
Serial.print("Connecting to ");
Serial.println(ssid);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
randomSeed(micros());
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP());
}
void callback(char* topic, byte* payload, unsigned int length) {
Serial.print("Message arrived [");
Serial.print(topic);
Serial.print("] ");
for (int i = 0; i < length; i++) {
Serial.print((char)payload[i]);
}
Serial.println();
// Find out how many bottles we should generate lyrics for
String topicStr(topic);
int bottleCount = 0; // assume no bottles unless we correctly parse a value from the topic
if (topicStr.indexOf('/') >= 0) {
// The topic includes a '/', we'll try to read the number of bottles from just after that
topicStr.remove(0, topicStr.indexOf('/')+1);
// Now see if there's a number of bottles after the '/'
bottleCount = topicStr.toInt();
}
if (bottleCount > 0) {
// Work out how big our resulting message will be
int msgLen = 0;
for (int i = bottleCount; i > 0; i--) {
String numBottles(i);
msgLen += 2*numBottles.length();
if (i == 1) {
msgLen += 2*String(" green bottle, standing on the wall\n").length();
} else {
msgLen += 2*String(" green bottles, standing on the wall\n").length();
}
msgLen += String("And if one green bottle should accidentally fall\nThere'll be ").length();
switch (i) {
case 1:
msgLen += String("no green bottles, standing on the wall\n\n").length();
break;
case 2:
msgLen += String("1 green bottle, standing on the wall\n\n").length();
break;
default:
numBottles = i-1;
msgLen += numBottles.length();
msgLen += String(" green bottles, standing on the wall\n\n").length();
break;
};
}
// Now we can start to publish the message
client.beginPublish("greenBottles/lyrics", msgLen, false);
for (int i = bottleCount; i > 0; i--) {
for (int j = 0; j < 2; j++) {
client.print(i);
if (i == 1) {
client.print(" green bottle, standing on the wall\n");
} else {
client.print(" green bottles, standing on the wall\n");
}
}
client.print("And if one green bottle should accidentally fall\nThere'll be ");
switch (i) {
case 1:
client.print("no green bottles, standing on the wall\n\n");
break;
case 2:
client.print("1 green bottle, standing on the wall\n\n");
break;
default:
client.print(i-1);
client.print(" green bottles, standing on the wall\n\n");
break;
};
}
// Now we're done!
client.endPublish();
}
}
void reconnect() {
// Loop until we're reconnected
while (!client.connected()) {
Serial.print("Attempting MQTT connection...");
// Create a random client ID
String clientId = "ESP8266Client-";
clientId += String(random(0xffff), HEX);
// Attempt to connect
if (client.connect(clientId.c_str())) {
Serial.println("connected");
// Once connected, publish an announcement...
client.publish("outTopic", "hello world");
// ... and resubscribe
client.subscribe("greenBottles/#");
} else {
Serial.print("failed, rc=");
Serial.print(client.state());
Serial.println(" try again in 5 seconds");
// Wait 5 seconds before retrying
delay(5000);
}
}
}
void setup() {
pinMode(BUILTIN_LED, OUTPUT); // Initialize the BUILTIN_LED pin as an output
Serial.begin(115200);
setup_wifi();
client.setServer(mqtt_server, 1883);
client.setCallback(callback);
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
}
@@ -1,60 +0,0 @@
/*
Publishing in the callback
- connects to an MQTT server
- subscribes to the topic "inTopic"
- when a message is received, republishes it to "outTopic"
This example shows how to publish messages within the
callback function. The callback function header needs to
be declared before the PubSubClient constructor and the
actual callback defined afterwards.
This ensures the client reference in the callback function
is valid.
*/
#include <SPI.h>
#include <Ethernet.h>
#include <PubSubClient.h>
// Update these with values suitable for your network.
byte mac[] = { 0xDE, 0xED, 0xBA, 0xFE, 0xFE, 0xED };
IPAddress ip(172, 16, 0, 100);
IPAddress server(172, 16, 0, 2);
// Callback function header
void callback(char* topic, byte* payload, unsigned int length);
EthernetClient ethClient;
PubSubClient client(server, 1883, callback, ethClient);
// Callback function
void callback(char* topic, byte* payload, unsigned int length) {
// In order to republish this payload, a copy must be made
// as the orignal payload buffer will be overwritten whilst
// constructing the PUBLISH packet.
// Allocate the correct amount of memory for the payload copy
byte* p = (byte*)malloc(length);
// Copy the payload to the new buffer
memcpy(p,payload,length);
client.publish("outTopic", p, length);
// Free the memory
free(p);
}
void setup()
{
Ethernet.begin(mac, ip);
if (client.connect("arduinoClient")) {
client.publish("outTopic","hello world");
client.subscribe("inTopic");
}
}
void loop()
{
client.loop();
}
@@ -1,67 +0,0 @@
/*
Reconnecting MQTT example - non-blocking
This sketch demonstrates how to keep the client connected
using a non-blocking reconnect function. If the client loses
its connection, it attempts to reconnect every 5 seconds
without blocking the main loop.
*/
#include <SPI.h>
#include <Ethernet.h>
#include <PubSubClient.h>
// Update these with values suitable for your hardware/network.
byte mac[] = { 0xDE, 0xED, 0xBA, 0xFE, 0xFE, 0xED };
IPAddress ip(172, 16, 0, 100);
IPAddress server(172, 16, 0, 2);
void callback(char* topic, byte* payload, unsigned int length) {
// handle message arrived
}
EthernetClient ethClient;
PubSubClient client(ethClient);
long lastReconnectAttempt = 0;
boolean reconnect() {
if (client.connect("arduinoClient")) {
// Once connected, publish an announcement...
client.publish("outTopic","hello world");
// ... and resubscribe
client.subscribe("inTopic");
}
return client.connected();
}
void setup()
{
client.setServer(server, 1883);
client.setCallback(callback);
Ethernet.begin(mac, ip);
delay(1500);
lastReconnectAttempt = 0;
}
void loop()
{
if (!client.connected()) {
long now = millis();
if (now - lastReconnectAttempt > 5000) {
lastReconnectAttempt = now;
// Attempt to reconnect
if (reconnect()) {
lastReconnectAttempt = 0;
}
}
} else {
// Client connected
client.loop();
}
}
@@ -1,57 +0,0 @@
/*
Example of using a Stream object to store the message payload
Uses SRAM library: https://github.com/ennui2342/arduino-sram
but could use any Stream based class such as SD
- connects to an MQTT server
- publishes "hello world" to the topic "outTopic"
- subscribes to the topic "inTopic"
*/
#include <SPI.h>
#include <Ethernet.h>
#include <PubSubClient.h>
#include <SRAM.h>
// Update these with values suitable for your network.
byte mac[] = { 0xDE, 0xED, 0xBA, 0xFE, 0xFE, 0xED };
IPAddress ip(172, 16, 0, 100);
IPAddress server(172, 16, 0, 2);
SRAM sram(4, SRAM_1024);
void callback(char* topic, byte* payload, unsigned int length) {
sram.seek(1);
// do something with the message
for(uint8_t i=0; i<length; i++) {
Serial.write(sram.read());
}
Serial.println();
// Reset position for the next message to be stored
sram.seek(1);
}
EthernetClient ethClient;
PubSubClient client(server, 1883, callback, ethClient, sram);
void setup()
{
Ethernet.begin(mac, ip);
if (client.connect("arduinoClient")) {
client.publish("outTopic","hello world");
client.subscribe("inTopic");
}
sram.begin();
sram.seek(1);
Serial.begin(9600);
}
void loop()
{
client.loop();
}
@@ -1,36 +0,0 @@
#######################################
# Syntax Coloring Map For PubSubClient
#######################################
#######################################
# Datatypes (KEYWORD1)
#######################################
PubSubClient KEYWORD1
#######################################
# Methods and Functions (KEYWORD2)
#######################################
connect KEYWORD2
disconnect KEYWORD2
publish KEYWORD2
publish_P KEYWORD2
beginPublish KEYWORD2
endPublish KEYWORD2
write KEYWORD2
subscribe KEYWORD2
unsubscribe KEYWORD2
loop KEYWORD2
connected KEYWORD2
setServer KEYWORD2
setCallback KEYWORD2
setClient KEYWORD2
setStream KEYWORD2
setKeepAlive KEYWORD2
setBufferSize KEYWORD2
setSocketTimeout KEYWORD2
#######################################
# Constants (LITERAL1)
#######################################
@@ -1,18 +0,0 @@
{
"name": "PubSubClient",
"keywords": "ethernet, mqtt, m2m, iot",
"description": "A client library for MQTT messaging. MQTT is a lightweight messaging protocol ideal for small devices. This library allows you to send and receive MQTT messages. It supports the latest MQTT 3.1.1 protocol and can be configured to use the older MQTT 3.1 if needed. It supports all Arduino Ethernet Client compatible hardware, including the Intel Galileo/Edison, ESP8266 and TI CC3000.",
"repository": {
"type": "git",
"url": "https://github.com/knolleary/pubsubclient.git"
},
"version": "2.8",
"exclude": "tests",
"examples": "examples/*/*.ino",
"frameworks": "arduino",
"platforms": [
"atmelavr",
"espressif8266",
"espressif32"
]
}
@@ -1,9 +0,0 @@
name=PubSubClient
version=2.8
author=Nick O'Leary <nick.oleary@gmail.com>
maintainer=Nick O'Leary <nick.oleary@gmail.com>
sentence=A client library for MQTT messaging.
paragraph=MQTT is a lightweight messaging protocol ideal for small devices. This library allows you to send and receive MQTT messages. It supports the latest MQTT 3.1.1 protocol and can be configured to use the older MQTT 3.1 if needed. It supports all Arduino Ethernet Client compatible hardware, including the Intel Galileo/Edison, ESP8266 and TI CC3000.
category=Communication
url=http://pubsubclient.knolleary.net
architectures=esp8266,esp32
@@ -1,4 +0,0 @@
.build
tmpbin
logs
*.pyc
@@ -1,25 +0,0 @@
SRC_PATH=./src
OUT_PATH=./bin
TEST_SRC=$(wildcard ${SRC_PATH}/*_spec.cpp)
TEST_BIN= $(TEST_SRC:${SRC_PATH}/%.cpp=${OUT_PATH}/%)
VPATH=${SRC_PATH}
SHIM_FILES=${SRC_PATH}/lib/*.cpp
PSC_FILE=../src/PubSubClient.cpp
CC=g++
CFLAGS=-I${SRC_PATH}/lib -I../src
all: $(TEST_BIN)
${OUT_PATH}/%: ${SRC_PATH}/%.cpp ${PSC_FILE} ${SHIM_FILES}
mkdir -p ${OUT_PATH}
${CC} ${CFLAGS} $^ -o $@
clean:
@rm -rf ${OUT_PATH}
test:
@bin/connect_spec
@bin/publish_spec
@bin/receive_spec
@bin/subscribe_spec
@bin/keepalive_spec
@@ -1,93 +0,0 @@
# Arduino Client for MQTT Test Suite
This is a regression test suite for the `PubSubClient` library.
There are two parts:
- Tests that can be compiled and run on any machine
- Tests that build the example sketches using the Arduino IDE
It is a work-in-progress and is subject to complete refactoring as the whim takes
me.
## Local tests
These are a set of executables that can be run to test specific areas of functionality.
They do not require a real Arduino to be attached, nor the use of the Arduino IDE.
The tests include a set of mock files to stub out the parts of the Arduino environment the library
depends on.
### Dependencies
- g++
### Running
Build the tests using the provided `Makefile`:
$ make
This will create a set of executables in `./bin/`. Run each of these executables to test the corresponding functionality.
*Note:* the `connect_spec` and `keepalive_spec` tests involve testing keepalive timers so naturally take a few minutes to run through.
## Arduino tests
*Note:* INO Tool doesn't currently play nicely with Arduino 1.5. This has broken this test suite.
Without a suitable arduino plugged in, the test suite will only check the
example sketches compile cleanly against the library.
With an arduino plugged in, each sketch that has a corresponding python
test case is built, uploaded and then the tests run.
### Dependencies
- Python 2.7+
- [INO Tool](http://inotool.org/) - this provides command-line build/upload of Arduino sketches
### Running
The test suite _does not_ run an MQTT server - it is assumed to be running already.
$ python testsuite.py
A summary of activity is printed to the console. More comprehensive logs are written
to the `logs` directory.
### What it does
For each sketch in the library's `examples` directory, e.g. `mqtt_basic.ino`, the suite looks for a matching test case
`testcases/mqtt_basic.py`.
The test case must follow these conventions:
- sub-class `unittest.TestCase`
- provide the class methods `setUpClass` and `tearDownClass` (TODO: make this optional)
- all test method names begin with `test_`
The suite will call the `setUpClass` method _before_ uploading the sketch. This
allows any test setup to be performed before the sketch runs - such as connecting
a client and subscribing to topics.
### Settings
The file `testcases/settings.py` is used to config the test environment.
- `server_ip` - the IP address of the broker the client should connect to (the broker port is assumed to be 1883).
- `arduino_ip` - the IP address the arduino should use (when not testing DHCP).
Before each sketch is compiled, these values are automatically substituted in. To
do this, the suite looks for lines that _start_ with the following:
byte server[] = {
byte ip[] = {
and replaces them with the appropriate values.
@@ -1,336 +0,0 @@
#include "PubSubClient.h"
#include "ShimClient.h"
#include "Buffer.h"
#include "BDDTest.h"
#include "trace.h"
byte server[] = { 172, 16, 0, 2 };
void callback(char* topic, byte* payload, unsigned int length) {
// handle message arrived
}
int test_connect_fails_no_network() {
IT("fails to connect if underlying client doesn't connect");
ShimClient shimClient;
shimClient.setAllowConnect(false);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_FALSE(rc);
int state = client.state();
IS_TRUE(state == MQTT_CONNECT_FAILED);
END_IT
}
int test_connect_fails_on_no_response() {
IT("fails to connect if no response received after 15 seconds");
ShimClient shimClient;
shimClient.setAllowConnect(true);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_FALSE(rc);
int state = client.state();
IS_TRUE(state == MQTT_CONNECTION_TIMEOUT);
END_IT
}
int test_connect_properly_formatted() {
IT("sends a properly formatted connect packet and succeeds");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte expectServer[] = { 172, 16, 0, 2 };
shimClient.expectConnect(expectServer,1883);
byte connect[] = {0x10,0x18,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0x2,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,26);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int state = client.state();
IS_TRUE(state == MQTT_DISCONNECTED);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
state = client.state();
IS_TRUE(state == MQTT_CONNECTED);
END_IT
}
int test_connect_properly_formatted_hostname() {
IT("accepts a hostname");
ShimClient shimClient;
shimClient.setAllowConnect(true);
shimClient.expectConnect((char* const)"localhost",1883);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client((char* const)"localhost", 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_connect_fails_on_bad_rc() {
IT("fails to connect if a bad return code is received");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x01 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_FALSE(rc);
int state = client.state();
IS_TRUE(state == 0x01);
END_IT
}
int test_connect_non_clean_session() {
IT("sends a properly formatted non-clean session connect packet and succeeds");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte expectServer[] = { 172, 16, 0, 2 };
shimClient.expectConnect(expectServer,1883);
byte connect[] = {0x10,0x18,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0x0,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,26);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int state = client.state();
IS_TRUE(state == MQTT_DISCONNECTED);
int rc = client.connect((char*)"client_test1",0,0,0,0,0,0,0);
IS_TRUE(rc);
IS_FALSE(shimClient.error());
state = client.state();
IS_TRUE(state == MQTT_CONNECTED);
END_IT
}
int test_connect_accepts_username_password() {
IT("accepts a username and password");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connect[] = { 0x10,0x24,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0xc2,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31,0x0,0x4,0x75,0x73,0x65,0x72,0x0,0x4,0x70,0x61,0x73,0x73};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,0x26);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1",(char*)"user",(char*)"pass");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_connect_accepts_username_no_password() {
IT("accepts a username but no password");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connect[] = { 0x10,0x1e,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0x82,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31,0x0,0x4,0x75,0x73,0x65,0x72};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,0x20);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1",(char*)"user",0);
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_connect_accepts_username_blank_password() {
IT("accepts a username and blank password");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connect[] = { 0x10,0x20,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0xc2,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31,0x0,0x4,0x75,0x73,0x65,0x72,0x0,0x0};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,0x26);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1",(char*)"user",(char*)"pass");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_connect_ignores_password_no_username() {
IT("ignores a password but no username");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connect[] = {0x10,0x18,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0x2,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,26);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1",0,(char*)"pass");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_connect_with_will() {
IT("accepts a will");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connect[] = {0x10,0x30,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0xe,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31,0x0,0x9,0x77,0x69,0x6c,0x6c,0x54,0x6f,0x70,0x69,0x63,0x0,0xb,0x77,0x69,0x6c,0x6c,0x4d,0x65,0x73,0x73,0x61,0x67,0x65};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,0x32);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1",(char*)"willTopic",1,0,(char*)"willMessage");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_connect_with_will_username_password() {
IT("accepts a will, username and password");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connect[] = {0x10,0x40,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0xce,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31,0x0,0x9,0x77,0x69,0x6c,0x6c,0x54,0x6f,0x70,0x69,0x63,0x0,0xb,0x77,0x69,0x6c,0x6c,0x4d,0x65,0x73,0x73,0x61,0x67,0x65,0x0,0x4,0x75,0x73,0x65,0x72,0x0,0x8,0x70,0x61,0x73,0x73,0x77,0x6f,0x72,0x64};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,0x42);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1",(char*)"user",(char*)"password",(char*)"willTopic",1,0,(char*)"willMessage");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_connect_disconnect_connect() {
IT("connects, disconnects and connects again");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte expectServer[] = { 172, 16, 0, 2 };
shimClient.expectConnect(expectServer,1883);
byte connect[] = {0x10,0x18,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0x2,0x0,0xf,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,26);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int state = client.state();
IS_TRUE(state == MQTT_DISCONNECTED);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
state = client.state();
IS_TRUE(state == MQTT_CONNECTED);
byte disconnect[] = {0xE0,0x00};
shimClient.expect(disconnect,2);
client.disconnect();
IS_FALSE(client.connected());
IS_FALSE(shimClient.connected());
IS_FALSE(shimClient.error());
state = client.state();
IS_TRUE(state == MQTT_DISCONNECTED);
shimClient.expect(connect,28);
shimClient.respond(connack,4);
rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
state = client.state();
IS_TRUE(state == MQTT_CONNECTED);
END_IT
}
int test_connect_custom_keepalive() {
IT("sends a properly formatted connect packet with custom keepalive value");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte expectServer[] = { 172, 16, 0, 2 };
shimClient.expectConnect(expectServer,1883);
// Set keepalive to 300secs == 0x01 0x2c
byte connect[] = {0x10,0x18,0x0,0x4,0x4d,0x51,0x54,0x54,0x4,0x2,0x01,0x2c,0x0,0xc,0x63,0x6c,0x69,0x65,0x6e,0x74,0x5f,0x74,0x65,0x73,0x74,0x31};
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.expect(connect,26);
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int state = client.state();
IS_TRUE(state == MQTT_DISCONNECTED);
client.setKeepAlive(300);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
state = client.state();
IS_TRUE(state == MQTT_CONNECTED);
END_IT
}
int main()
{
SUITE("Connect");
test_connect_fails_no_network();
test_connect_fails_on_no_response();
test_connect_properly_formatted();
test_connect_non_clean_session();
test_connect_accepts_username_password();
test_connect_fails_on_bad_rc();
test_connect_properly_formatted_hostname();
test_connect_accepts_username_no_password();
test_connect_ignores_password_no_username();
test_connect_with_will();
test_connect_with_will_username_password();
test_connect_disconnect_connect();
test_connect_custom_keepalive();
FINISH
}
@@ -1,185 +0,0 @@
#include "PubSubClient.h"
#include "ShimClient.h"
#include "Buffer.h"
#include "BDDTest.h"
#include "trace.h"
#include <unistd.h>
byte server[] = { 172, 16, 0, 2 };
void callback(char* topic, byte* payload, unsigned int length) {
// handle message arrived
}
int test_keepalive_pings_idle() {
IT("keeps an idle connection alive (takes 1 minute)");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte pingreq[] = { 0xC0,0x0 };
shimClient.expect(pingreq,2);
byte pingresp[] = { 0xD0,0x0 };
shimClient.respond(pingresp,2);
for (int i = 0; i < 50; i++) {
sleep(1);
if ( i == 15 || i == 31 || i == 47) {
shimClient.expect(pingreq,2);
shimClient.respond(pingresp,2);
}
rc = client.loop();
IS_TRUE(rc);
}
IS_FALSE(shimClient.error());
END_IT
}
int test_keepalive_pings_with_outbound_qos0() {
IT("keeps a connection alive that only sends qos0 (takes 1 minute)");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,0xe,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
for (int i = 0; i < 50; i++) {
TRACE(i<<":");
shimClient.expect(publish,16);
rc = client.publish((char*)"topic",(char*)"payload");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
sleep(1);
if ( i == 15 || i == 31 || i == 47) {
byte pingreq[] = { 0xC0,0x0 };
shimClient.expect(pingreq,2);
byte pingresp[] = { 0xD0,0x0 };
shimClient.respond(pingresp,2);
}
rc = client.loop();
IS_TRUE(rc);
IS_FALSE(shimClient.error());
}
END_IT
}
int test_keepalive_pings_with_inbound_qos0() {
IT("keeps a connection alive that only receives qos0 (takes 1 minute)");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,0xe,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
for (int i = 0; i < 50; i++) {
TRACE(i<<":");
sleep(1);
if ( i == 15 || i == 31 || i == 47) {
byte pingreq[] = { 0xC0,0x0 };
shimClient.expect(pingreq,2);
byte pingresp[] = { 0xD0,0x0 };
shimClient.respond(pingresp,2);
}
shimClient.respond(publish,16);
rc = client.loop();
IS_TRUE(rc);
IS_FALSE(shimClient.error());
}
END_IT
}
int test_keepalive_no_pings_inbound_qos1() {
IT("does not send pings for connections with inbound qos1 (takes 1 minute)");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x32,0x10,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x12,0x34,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
byte puback[] = {0x40,0x2,0x12,0x34};
for (int i = 0; i < 50; i++) {
shimClient.respond(publish,18);
shimClient.expect(puback,4);
sleep(1);
rc = client.loop();
IS_TRUE(rc);
IS_FALSE(shimClient.error());
}
END_IT
}
int test_keepalive_disconnects_hung() {
IT("disconnects a hung connection (takes 30 seconds)");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte pingreq[] = { 0xC0,0x0 };
shimClient.expect(pingreq,2);
for (int i = 0; i < 32; i++) {
sleep(1);
rc = client.loop();
}
IS_FALSE(rc);
int state = client.state();
IS_TRUE(state == MQTT_CONNECTION_TIMEOUT);
IS_FALSE(shimClient.error());
END_IT
}
int main()
{
SUITE("Keep-alive");
test_keepalive_pings_idle();
test_keepalive_pings_with_outbound_qos0();
test_keepalive_pings_with_inbound_qos0();
test_keepalive_no_pings_inbound_qos1();
test_keepalive_disconnects_hung();
FINISH
}
@@ -1,26 +0,0 @@
#ifndef Arduino_h
#define Arduino_h
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include "Print.h"
extern "C"{
typedef uint8_t byte ;
typedef uint8_t boolean ;
/* sketch */
extern void setup( void ) ;
extern void loop( void ) ;
uint32_t millis( void );
}
#define PROGMEM
#define pgm_read_byte_near(x) *(x)
#define yield(x) {}
#endif // Arduino_h
@@ -1,50 +0,0 @@
#include "BDDTest.h"
#include "trace.h"
#include <sstream>
#include <iostream>
#include <string>
#include <list>
int testCount = 0;
int testPasses = 0;
const char* testDescription;
std::list<std::string> failureList;
void bddtest_suite(const char* name) {
LOG(name << "\n");
}
int bddtest_test(const char* file, int line, const char* assertion, int result) {
if (!result) {
LOG("\n");
std::ostringstream os;
os << " ! "<<testDescription<<"\n " <<file << ":" <<line<<" : "<<assertion<<" ["<<result<<"]";
failureList.push_back(os.str());
}
return result;
}
void bddtest_start(const char* description) {
LOG(" - "<<description<<" ");
testDescription = description;
testCount ++;
}
void bddtest_end() {
LOG("\n");
testPasses ++;
}
int bddtest_summary() {
for (std::list<std::string>::iterator it = failureList.begin(); it != failureList.end(); it++) {
LOG("\n");
LOG(*it);
LOG("\n");
}
LOG(std::dec << testPasses << "/" << testCount << " tests passed\n\n");
if (testPasses == testCount) {
return 0;
}
return 1;
}
@@ -1,23 +0,0 @@
#ifndef bddtest_h
#define bddtest_h
void bddtest_suite(const char* name);
int bddtest_test(const char*, int, const char*, int);
void bddtest_start(const char*);
void bddtest_end();
int bddtest_summary();
#define SUITE(x) { bddtest_suite(x); }
#define TEST(x) { if (!bddtest_test(__FILE__, __LINE__, #x, (x))) return false; }
#define IT(x) { bddtest_start(x); }
#define END_IT { bddtest_end();return true;}
#define FINISH { return bddtest_summary(); }
#define IS_TRUE(x) TEST(x)
#define IS_FALSE(x) TEST(!(x))
#define IS_EQUAL(x,y) TEST(x==y)
#define IS_NOT_EQUAL(x,y) TEST(x!=y)
#endif
@@ -1,34 +0,0 @@
#include "Buffer.h"
#include "Arduino.h"
Buffer::Buffer() {
this->pos = 0;
this->length = 0;
}
Buffer::Buffer(uint8_t* buf, size_t size) {
this->pos = 0;
this->length = 0;
this->add(buf,size);
}
bool Buffer::available() {
return this->pos < this->length;
}
uint8_t Buffer::next() {
if (this->available()) {
return this->buffer[this->pos++];
}
return 0;
}
void Buffer::reset() {
this->pos = 0;
}
void Buffer::add(uint8_t* buf, size_t size) {
uint16_t i = 0;
for (;i<size;i++) {
this->buffer[this->length++] = buf[i];
}
}
@@ -1,23 +0,0 @@
#ifndef buffer_h
#define buffer_h
#include "Arduino.h"
class Buffer {
private:
uint8_t buffer[2048];
uint16_t pos;
uint16_t length;
public:
Buffer();
Buffer(uint8_t* buf, size_t size);
virtual bool available();
virtual uint8_t next();
virtual void reset();
virtual void add(uint8_t* buf, size_t size);
};
#endif
@@ -1,21 +0,0 @@
#ifndef client_h
#define client_h
#include "IPAddress.h"
class Client {
public:
virtual int connect(IPAddress ip, uint16_t port) =0;
virtual int connect(const char *host, uint16_t port) =0;
virtual size_t write(uint8_t) =0;
virtual size_t write(const uint8_t *buf, size_t size) =0;
virtual int available() = 0;
virtual int read() = 0;
virtual int read(uint8_t *buf, size_t size) = 0;
virtual int peek() = 0;
virtual void flush() = 0;
virtual void stop() = 0;
virtual uint8_t connected() = 0;
virtual operator bool() = 0;
};
#endif
@@ -1,44 +0,0 @@
#include <Arduino.h>
#include <IPAddress.h>
IPAddress::IPAddress()
{
memset(_address, 0, sizeof(_address));
}
IPAddress::IPAddress(uint8_t first_octet, uint8_t second_octet, uint8_t third_octet, uint8_t fourth_octet)
{
_address[0] = first_octet;
_address[1] = second_octet;
_address[2] = third_octet;
_address[3] = fourth_octet;
}
IPAddress::IPAddress(uint32_t address)
{
memcpy(_address, &address, sizeof(_address));
}
IPAddress::IPAddress(const uint8_t *address)
{
memcpy(_address, address, sizeof(_address));
}
IPAddress& IPAddress::operator=(const uint8_t *address)
{
memcpy(_address, address, sizeof(_address));
return *this;
}
IPAddress& IPAddress::operator=(uint32_t address)
{
memcpy(_address, (const uint8_t *)&address, sizeof(_address));
return *this;
}
bool IPAddress::operator==(const uint8_t* addr)
{
return memcmp(addr, _address, sizeof(_address)) == 0;
}
@@ -1,72 +0,0 @@
/*
*
* MIT License:
* Copyright (c) 2011 Adrian McEwen
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* adrianm@mcqn.com 1/1/2011
*/
#ifndef IPAddress_h
#define IPAddress_h
// A class to make it easier to handle and pass around IP addresses
class IPAddress {
private:
uint8_t _address[4]; // IPv4 address
// Access the raw byte array containing the address. Because this returns a pointer
// to the internal structure rather than a copy of the address this function should only
// be used when you know that the usage of the returned uint8_t* will be transient and not
// stored.
uint8_t* raw_address() { return _address; };
public:
// Constructors
IPAddress();
IPAddress(uint8_t first_octet, uint8_t second_octet, uint8_t third_octet, uint8_t fourth_octet);
IPAddress(uint32_t address);
IPAddress(const uint8_t *address);
// Overloaded cast operator to allow IPAddress objects to be used where a pointer
// to a four-byte uint8_t array is expected
operator uint32_t() { return *((uint32_t*)_address); };
bool operator==(const IPAddress& addr) { return (*((uint32_t*)_address)) == (*((uint32_t*)addr._address)); };
bool operator==(const uint8_t* addr);
// Overloaded index operator to allow getting and setting individual octets of the address
uint8_t operator[](int index) const { return _address[index]; };
uint8_t& operator[](int index) { return _address[index]; };
// Overloaded copy operators to allow initialisation of IPAddress objects from other types
IPAddress& operator=(const uint8_t *address);
IPAddress& operator=(uint32_t address);
friend class EthernetClass;
friend class UDP;
friend class Client;
friend class Server;
friend class DhcpClass;
friend class DNSClient;
};
#endif
@@ -1,28 +0,0 @@
/*
Print.h - Base class that provides print() and println()
Copyright (c) 2008 David A. Mellis. All right reserved.
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#ifndef Print_h
#define Print_h
class Print {
public:
virtual size_t write(uint8_t) = 0;
};
#endif
@@ -1,153 +0,0 @@
#include "ShimClient.h"
#include "trace.h"
#include <iostream>
#include <Arduino.h>
#include <ctime>
extern "C" {
uint32_t millis(void) {
return time(0)*1000;
}
}
ShimClient::ShimClient() {
this->responseBuffer = new Buffer();
this->expectBuffer = new Buffer();
this->_allowConnect = true;
this->_connected = false;
this->_error = false;
this->expectAnything = true;
this->_received = 0;
this->_expectedPort = 0;
}
int ShimClient::connect(IPAddress ip, uint16_t port) {
if (this->_allowConnect) {
this->_connected = true;
}
if (this->_expectedPort !=0) {
// if (memcmp(ip,this->_expectedIP,4) != 0) {
// TRACE( "ip mismatch\n");
// this->_error = true;
// }
if (port != this->_expectedPort) {
TRACE( "port mismatch\n");
this->_error = true;
}
}
return this->_connected;
}
int ShimClient::connect(const char *host, uint16_t port) {
if (this->_allowConnect) {
this->_connected = true;
}
if (this->_expectedPort !=0) {
if (strcmp(host,this->_expectedHost) != 0) {
TRACE( "host mismatch\n");
this->_error = true;
}
if (port != this->_expectedPort) {
TRACE( "port mismatch\n");
this->_error = true;
}
}
return this->_connected;
}
size_t ShimClient::write(uint8_t b) {
this->_received += 1;
TRACE(std::hex << (unsigned int)b);
if (!this->expectAnything) {
if (this->expectBuffer->available()) {
uint8_t expected = this->expectBuffer->next();
if (expected != b) {
this->_error = true;
TRACE("!=" << (unsigned int)expected);
}
} else {
this->_error = true;
}
}
TRACE("\n"<< std::dec);
return 1;
}
size_t ShimClient::write(const uint8_t *buf, size_t size) {
this->_received += size;
TRACE( "[" << std::dec << (unsigned int)(size) << "] ");
uint16_t i=0;
for (;i<size;i++) {
if (i>0) {
TRACE(":");
}
TRACE(std::hex << (unsigned int)(buf[i]));
if (!this->expectAnything) {
if (this->expectBuffer->available()) {
uint8_t expected = this->expectBuffer->next();
if (expected != buf[i]) {
this->_error = true;
TRACE("!=" << (unsigned int)expected);
}
} else {
this->_error = true;
}
}
}
TRACE("\n"<<std::dec);
return size;
}
int ShimClient::available() {
return this->responseBuffer->available();
}
int ShimClient::read() { return this->responseBuffer->next(); }
int ShimClient::read(uint8_t *buf, size_t size) {
uint16_t i = 0;
for (;i<size;i++) {
buf[i] = this->read();
}
return size;
}
int ShimClient::peek() { return 0; }
void ShimClient::flush() {}
void ShimClient::stop() {
this->setConnected(false);
}
uint8_t ShimClient::connected() { return this->_connected; }
ShimClient::operator bool() { return true; }
ShimClient* ShimClient::respond(uint8_t *buf, size_t size) {
this->responseBuffer->add(buf,size);
return this;
}
ShimClient* ShimClient::expect(uint8_t *buf, size_t size) {
this->expectAnything = false;
this->expectBuffer->add(buf,size);
return this;
}
void ShimClient::setConnected(bool b) {
this->_connected = b;
}
void ShimClient::setAllowConnect(bool b) {
this->_allowConnect = b;
}
bool ShimClient::error() {
return this->_error;
}
uint16_t ShimClient::received() {
return this->_received;
}
void ShimClient::expectConnect(IPAddress ip, uint16_t port) {
this->_expectedIP = ip;
this->_expectedPort = port;
}
void ShimClient::expectConnect(const char *host, uint16_t port) {
this->_expectedHost = host;
this->_expectedPort = port;
}
@@ -1,51 +0,0 @@
#ifndef shimclient_h
#define shimclient_h
#include "Arduino.h"
#include "Client.h"
#include "IPAddress.h"
#include "Buffer.h"
class ShimClient : public Client {
private:
Buffer* responseBuffer;
Buffer* expectBuffer;
bool _allowConnect;
bool _connected;
bool expectAnything;
bool _error;
uint16_t _received;
IPAddress _expectedIP;
uint16_t _expectedPort;
const char* _expectedHost;
public:
ShimClient();
virtual int connect(IPAddress ip, uint16_t port);
virtual int connect(const char *host, uint16_t port);
virtual size_t write(uint8_t);
virtual size_t write(const uint8_t *buf, size_t size);
virtual int available();
virtual int read();
virtual int read(uint8_t *buf, size_t size);
virtual int peek();
virtual void flush();
virtual void stop();
virtual uint8_t connected();
virtual operator bool();
virtual ShimClient* respond(uint8_t *buf, size_t size);
virtual ShimClient* expect(uint8_t *buf, size_t size);
virtual void expectConnect(IPAddress ip, uint16_t port);
virtual void expectConnect(const char *host, uint16_t port);
virtual uint16_t received();
virtual bool error();
virtual void setAllowConnect(bool b);
virtual void setConnected(bool b);
};
#endif
@@ -1,39 +0,0 @@
#include "Stream.h"
#include "trace.h"
#include <iostream>
#include <Arduino.h>
Stream::Stream() {
this->expectBuffer = new Buffer();
this->_error = false;
this->_written = 0;
}
size_t Stream::write(uint8_t b) {
this->_written++;
TRACE(std::hex << (unsigned int)b);
if (this->expectBuffer->available()) {
uint8_t expected = this->expectBuffer->next();
if (expected != b) {
this->_error = true;
TRACE("!=" << (unsigned int)expected);
}
} else {
this->_error = true;
}
TRACE("\n"<< std::dec);
return 1;
}
bool Stream::error() {
return this->_error;
}
void Stream::expect(uint8_t *buf, size_t size) {
this->expectBuffer->add(buf,size);
}
uint16_t Stream::length() {
return this->_written;
}
@@ -1,22 +0,0 @@
#ifndef Stream_h
#define Stream_h
#include "Arduino.h"
#include "Buffer.h"
class Stream {
private:
Buffer* expectBuffer;
bool _error;
uint16_t _written;
public:
Stream();
virtual size_t write(uint8_t);
virtual bool error();
virtual void expect(uint8_t *buf, size_t size);
virtual uint16_t length();
};
#endif
@@ -1,10 +0,0 @@
#ifndef trace_h
#define trace_h
#include <iostream>
#include <stdlib.h>
#define LOG(x) {std::cout << x << std::flush; }
#define TRACE(x) {if (getenv("TRACE")) { std::cout << x << std::flush; }}
#endif
@@ -1,191 +0,0 @@
#include "PubSubClient.h"
#include "ShimClient.h"
#include "Buffer.h"
#include "BDDTest.h"
#include "trace.h"
byte server[] = { 172, 16, 0, 2 };
void callback(char* topic, byte* payload, unsigned int length) {
// handle message arrived
}
int test_publish() {
IT("publishes a null-terminated string");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,0xe,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
shimClient.expect(publish,16);
rc = client.publish((char*)"topic",(char*)"payload");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_publish_bytes() {
IT("publishes a byte array");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte payload[] = { 0x01,0x02,0x03,0x0,0x05 };
int length = 5;
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,0xc,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x1,0x2,0x3,0x0,0x5};
shimClient.expect(publish,14);
rc = client.publish((char*)"topic",payload,length);
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_publish_retained() {
IT("publishes retained - 1");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte payload[] = { 0x01,0x02,0x03,0x0,0x05 };
int length = 5;
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x31,0xc,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x1,0x2,0x3,0x0,0x5};
shimClient.expect(publish,14);
rc = client.publish((char*)"topic",payload,length,true);
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_publish_retained_2() {
IT("publishes retained - 2");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x31,0xc,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,'A','B','C','D','E'};
shimClient.expect(publish,14);
rc = client.publish((char*)"topic",(char*)"ABCDE",true);
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_publish_not_connected() {
IT("publish fails when not connected");
ShimClient shimClient;
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.publish((char*)"topic",(char*)"payload");
IS_FALSE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_publish_too_long() {
IT("publish fails when topic/payload are too long");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
client.setBufferSize(128);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
// 0 1 2 3 4 5 6 7 8 9 0 1 2
rc = client.publish((char*)"topic",(char*)"123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890");
IS_FALSE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_publish_P() {
IT("publishes using PROGMEM");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte payload[] = { 0x01,0x02,0x03,0x0,0x05 };
int length = 5;
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x31,0xc,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x1,0x2,0x3,0x0,0x5};
shimClient.expect(publish,14);
rc = client.publish_P((char*)"topic",payload,length,true);
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int main()
{
SUITE("Publish");
test_publish();
test_publish_bytes();
test_publish_retained();
test_publish_retained_2();
test_publish_not_connected();
test_publish_too_long();
test_publish_P();
FINISH
}
@@ -1,340 +0,0 @@
#include "PubSubClient.h"
#include "ShimClient.h"
#include "Buffer.h"
#include "BDDTest.h"
#include "trace.h"
byte server[] = { 172, 16, 0, 2 };
bool callback_called = false;
char lastTopic[1024];
char lastPayload[1024];
unsigned int lastLength;
void reset_callback() {
callback_called = false;
lastTopic[0] = '\0';
lastPayload[0] = '\0';
lastLength = 0;
}
void callback(char* topic, byte* payload, unsigned int length) {
TRACE("Callback received topic=[" << topic << "] length=" << length << "\n")
callback_called = true;
strcpy(lastTopic,topic);
memcpy(lastPayload,payload,length);
lastLength = length;
}
int test_receive_callback() {
IT("receives a callback message");
reset_callback();
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,0xe,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
shimClient.respond(publish,16);
rc = client.loop();
IS_TRUE(rc);
IS_TRUE(callback_called);
IS_TRUE(strcmp(lastTopic,"topic")==0);
IS_TRUE(memcmp(lastPayload,"payload",7)==0);
IS_TRUE(lastLength == 7);
IS_FALSE(shimClient.error());
END_IT
}
int test_receive_stream() {
IT("receives a streamed callback message");
reset_callback();
Stream stream;
stream.expect((uint8_t*)"payload",7);
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient, stream);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,0xe,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
shimClient.respond(publish,16);
rc = client.loop();
IS_TRUE(rc);
IS_TRUE(callback_called);
IS_TRUE(strcmp(lastTopic,"topic")==0);
IS_TRUE(lastLength == 7);
IS_FALSE(stream.error());
IS_FALSE(shimClient.error());
END_IT
}
int test_receive_max_sized_message() {
IT("receives an max-sized message");
reset_callback();
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int length = 80; // If this is changed to > 128 then the publish packet below
// is no longer valid as it assumes the remaining length
// is a single-byte. Don't make that mistake like I just
// did and lose a whole evening tracking down the issue.
client.setBufferSize(length);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,length-2,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
byte bigPublish[length];
memset(bigPublish,'A',length);
bigPublish[length] = 'B';
memcpy(bigPublish,publish,16);
shimClient.respond(bigPublish,length);
rc = client.loop();
IS_TRUE(rc);
IS_TRUE(callback_called);
IS_TRUE(strcmp(lastTopic,"topic")==0);
IS_TRUE(lastLength == length-9);
IS_TRUE(memcmp(lastPayload,bigPublish+9,lastLength)==0);
IS_FALSE(shimClient.error());
END_IT
}
int test_receive_oversized_message() {
IT("drops an oversized message");
reset_callback();
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
int length = 80; // See comment in test_receive_max_sized_message before changing this value
PubSubClient client(server, 1883, callback, shimClient);
client.setBufferSize(length-1);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,length-2,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
byte bigPublish[length];
memset(bigPublish,'A',length);
bigPublish[length] = 'B';
memcpy(bigPublish,publish,16);
shimClient.respond(bigPublish,length);
rc = client.loop();
IS_TRUE(rc);
IS_FALSE(callback_called);
IS_FALSE(shimClient.error());
END_IT
}
int test_drop_invalid_remaining_length_message() {
IT("drops invalid remaining length message");
reset_callback();
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,0x92,0x92,0x92,0x92,0x01,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
shimClient.respond(publish,20);
rc = client.loop();
IS_FALSE(rc);
IS_FALSE(callback_called);
IS_FALSE(shimClient.error());
END_IT
}
int test_resize_buffer() {
IT("receives a message larger than the default maximum");
reset_callback();
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
int length = 80; // See comment in test_receive_max_sized_message before changing this value
PubSubClient client(server, 1883, callback, shimClient);
client.setBufferSize(length-1);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,length-2,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
byte bigPublish[length];
memset(bigPublish,'A',length);
bigPublish[length] = 'B';
memcpy(bigPublish,publish,16);
// Send it twice
shimClient.respond(bigPublish,length);
shimClient.respond(bigPublish,length);
rc = client.loop();
IS_TRUE(rc);
// First message fails as it is too big
IS_FALSE(callback_called);
// Resize the buffer
client.setBufferSize(length);
rc = client.loop();
IS_TRUE(rc);
IS_TRUE(callback_called);
IS_TRUE(strcmp(lastTopic,"topic")==0);
IS_TRUE(lastLength == length-9);
IS_TRUE(memcmp(lastPayload,bigPublish+9,lastLength)==0);
IS_FALSE(shimClient.error());
END_IT
}
int test_receive_oversized_stream_message() {
IT("receive an oversized streamed message");
reset_callback();
Stream stream;
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
int length = 80; // See comment in test_receive_max_sized_message before changing this value
PubSubClient client(server, 1883, callback, shimClient, stream);
client.setBufferSize(length-1);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x30,length-2,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
byte bigPublish[length];
memset(bigPublish,'A',length);
bigPublish[length] = 'B';
memcpy(bigPublish,publish,16);
shimClient.respond(bigPublish,length);
stream.expect(bigPublish+9,length-9);
rc = client.loop();
IS_TRUE(rc);
IS_TRUE(callback_called);
IS_TRUE(strcmp(lastTopic,"topic")==0);
IS_TRUE(lastLength == length-10);
IS_FALSE(stream.error());
IS_FALSE(shimClient.error());
END_IT
}
int test_receive_qos1() {
IT("receives a qos1 message");
reset_callback();
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte publish[] = {0x32,0x10,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x12,0x34,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64};
shimClient.respond(publish,18);
byte puback[] = {0x40,0x2,0x12,0x34};
shimClient.expect(puback,4);
rc = client.loop();
IS_TRUE(rc);
IS_TRUE(callback_called);
IS_TRUE(strcmp(lastTopic,"topic")==0);
IS_TRUE(memcmp(lastPayload,"payload",7)==0);
IS_TRUE(lastLength == 7);
IS_FALSE(shimClient.error());
END_IT
}
int main()
{
SUITE("Receive");
test_receive_callback();
test_receive_stream();
test_receive_max_sized_message();
test_drop_invalid_remaining_length_message();
test_receive_oversized_message();
test_resize_buffer();
test_receive_oversized_stream_message();
test_receive_qos1();
FINISH
}
@@ -1,178 +0,0 @@
#include "PubSubClient.h"
#include "ShimClient.h"
#include "Buffer.h"
#include "BDDTest.h"
#include "trace.h"
byte server[] = { 172, 16, 0, 2 };
void callback(char* topic, byte* payload, unsigned int length) {
// handle message arrived
}
int test_subscribe_no_qos() {
IT("subscribe without qos defaults to 0");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte subscribe[] = { 0x82,0xa,0x0,0x2,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x0 };
shimClient.expect(subscribe,12);
byte suback[] = { 0x90,0x3,0x0,0x2,0x0 };
shimClient.respond(suback,5);
rc = client.subscribe((char*)"topic");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_subscribe_qos_1() {
IT("subscribes qos 1");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte subscribe[] = { 0x82,0xa,0x0,0x2,0x0,0x5,0x74,0x6f,0x70,0x69,0x63,0x1 };
shimClient.expect(subscribe,12);
byte suback[] = { 0x90,0x3,0x0,0x2,0x1 };
shimClient.respond(suback,5);
rc = client.subscribe((char*)"topic",1);
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_subscribe_not_connected() {
IT("subscribe fails when not connected");
ShimClient shimClient;
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.subscribe((char*)"topic");
IS_FALSE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_subscribe_invalid_qos() {
IT("subscribe fails with invalid qos values");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
rc = client.subscribe((char*)"topic",2);
IS_FALSE(rc);
rc = client.subscribe((char*)"topic",254);
IS_FALSE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_subscribe_too_long() {
IT("subscribe fails with too long topic");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
client.setBufferSize(128);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
// max length should be allowed
// 0 1 2 3 4 5 6 7 8 9 0 1 2
rc = client.subscribe((char*)"12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789");
IS_TRUE(rc);
// 0 1 2 3 4 5 6 7 8 9 0 1 2
rc = client.subscribe((char*)"123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890");
IS_FALSE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_unsubscribe() {
IT("unsubscribes");
ShimClient shimClient;
shimClient.setAllowConnect(true);
byte connack[] = { 0x20, 0x02, 0x00, 0x00 };
shimClient.respond(connack,4);
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.connect((char*)"client_test1");
IS_TRUE(rc);
byte unsubscribe[] = { 0xA2,0x9,0x0,0x2,0x0,0x5,0x74,0x6f,0x70,0x69,0x63 };
shimClient.expect(unsubscribe,12);
byte unsuback[] = { 0xB0,0x2,0x0,0x2 };
shimClient.respond(unsuback,4);
rc = client.unsubscribe((char*)"topic");
IS_TRUE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int test_unsubscribe_not_connected() {
IT("unsubscribe fails when not connected");
ShimClient shimClient;
PubSubClient client(server, 1883, callback, shimClient);
int rc = client.unsubscribe((char*)"topic");
IS_FALSE(rc);
IS_FALSE(shimClient.error());
END_IT
}
int main()
{
SUITE("Subscribe");
test_subscribe_no_qos();
test_subscribe_qos_1();
test_subscribe_not_connected();
test_subscribe_invalid_qos();
test_subscribe_too_long();
test_unsubscribe();
test_unsubscribe_not_connected();
FINISH
}
@@ -1,39 +0,0 @@
import unittest
import settings
import time
import mosquitto
def on_message(mosq, obj, msg):
obj.message_queue.append(msg)
class mqtt_basic(unittest.TestCase):
message_queue = []
@classmethod
def setUpClass(self):
self.client = mosquitto.Mosquitto("pubsubclient_ut", clean_session=True, obj=self)
self.client.connect(settings.server_ip)
self.client.on_message = on_message
self.client.subscribe("outTopic", 0)
@classmethod
def tearDownClass(self):
self.client.disconnect()
def test_one(self):
i = 30
while len(self.message_queue) == 0 and i > 0:
self.client.loop()
time.sleep(0.5)
i -= 1
self.assertTrue(i > 0, "message receive timed-out")
self.assertEqual(len(self.message_queue), 1, "unexpected number of messages received")
msg = self.message_queue[0]
self.assertEqual(msg.mid, 0, "message id not 0")
self.assertEqual(msg.topic, "outTopic", "message topic incorrect")
self.assertEqual(msg.payload, "hello world")
self.assertEqual(msg.qos, 0, "message qos not 0")
self.assertEqual(msg.retain, False, "message retain flag incorrect")
@@ -1,59 +0,0 @@
import unittest
import settings
import time
import mosquitto
def on_message(mosq, obj, msg):
obj.message_queue.append(msg)
class mqtt_publish_in_callback(unittest.TestCase):
message_queue = []
@classmethod
def setUpClass(self):
self.client = mosquitto.Mosquitto("pubsubclient_ut", clean_session=True, obj=self)
self.client.connect(settings.server_ip)
self.client.on_message = on_message
self.client.subscribe("outTopic", 0)
@classmethod
def tearDownClass(self):
self.client.disconnect()
def test_connect(self):
i = 30
while len(self.message_queue) == 0 and i > 0:
self.client.loop()
time.sleep(0.5)
i -= 1
self.assertTrue(i > 0, "message receive timed-out")
self.assertEqual(len(self.message_queue), 1, "unexpected number of messages received")
msg = self.message_queue.pop(0)
self.assertEqual(msg.mid, 0, "message id not 0")
self.assertEqual(msg.topic, "outTopic", "message topic incorrect")
self.assertEqual(msg.payload, "hello world")
self.assertEqual(msg.qos, 0, "message qos not 0")
self.assertEqual(msg.retain, False, "message retain flag incorrect")
def test_publish(self):
self.assertEqual(len(self.message_queue), 0, "message queue not empty")
payload = "abcdefghij"
self.client.publish("inTopic", payload)
i = 30
while len(self.message_queue) == 0 and i > 0:
self.client.loop()
time.sleep(0.5)
i -= 1
self.assertTrue(i > 0, "message receive timed-out")
self.assertEqual(len(self.message_queue), 1, "unexpected number of messages received")
msg = self.message_queue.pop(0)
self.assertEqual(msg.mid, 0, "message id not 0")
self.assertEqual(msg.topic, "outTopic", "message topic incorrect")
self.assertEqual(msg.payload, payload)
self.assertEqual(msg.qos, 0, "message qos not 0")
self.assertEqual(msg.retain, False, "message retain flag incorrect")
@@ -1,2 +0,0 @@
server_ip = "172.16.0.2"
arduino_ip = "172.16.0.100"
@@ -1,181 +0,0 @@
#!/usr/bin/env python
import os
import os.path
import sys
import shutil
from subprocess import call
import importlib
import unittest
import re
from testcases import settings
class Workspace(object):
def __init__(self):
self.root_dir = os.getcwd()
self.build_dir = os.path.join(self.root_dir, "tmpbin")
self.log_dir = os.path.join(self.root_dir, "logs")
self.tests_dir = os.path.join(self.root_dir, "testcases")
self.examples_dir = os.path.join(self.root_dir, "../PubSubClient/examples")
self.examples = []
self.tests = []
if not os.path.isdir("../PubSubClient"):
raise Exception("Cannot find PubSubClient library")
try:
return __import__('ino')
except ImportError:
raise Exception("ino tool not installed")
def init(self):
if os.path.isdir(self.build_dir):
shutil.rmtree(self.build_dir)
os.mkdir(self.build_dir)
if os.path.isdir(self.log_dir):
shutil.rmtree(self.log_dir)
os.mkdir(self.log_dir)
os.chdir(self.build_dir)
call(["ino", "init"])
shutil.copytree("../../PubSubClient", "lib/PubSubClient")
filenames = []
for root, dirs, files in os.walk(self.examples_dir):
filenames += [os.path.join(root, f) for f in files if f.endswith(".ino")]
filenames.sort()
for e in filenames:
self.examples.append(Sketch(self, e))
filenames = []
for root, dirs, files in os.walk(self.tests_dir):
filenames += [os.path.join(root, f) for f in files if f.endswith(".ino")]
filenames.sort()
for e in filenames:
self.tests.append(Sketch(self, e))
def clean(self):
shutil.rmtree(self.build_dir)
class Sketch(object):
def __init__(self, wksp, fn):
self.w = wksp
self.filename = fn
self.basename = os.path.basename(self.filename)
self.build_log = os.path.join(self.w.log_dir, "%s.log" % (os.path.basename(self.filename),))
self.build_err_log = os.path.join(self.w.log_dir, "%s.err.log" % (os.path.basename(self.filename),))
self.build_upload_log = os.path.join(self.w.log_dir, "%s.upload.log" % (os.path.basename(self.filename),))
def build(self):
sys.stdout.write(" Build: ")
sys.stdout.flush()
# Copy sketch over, replacing IP addresses as necessary
fin = open(self.filename, "r")
lines = fin.readlines()
fin.close()
fout = open(os.path.join(self.w.build_dir, "src", "sketch.ino"), "w")
for l in lines:
if re.match(r"^byte server\[\] = {", l):
fout.write("byte server[] = { %s };\n" % (settings.server_ip.replace(".", ", "),))
elif re.match(r"^byte ip\[\] = {", l):
fout.write("byte ip[] = { %s };\n" % (settings.arduino_ip.replace(".", ", "),))
else:
fout.write(l)
fout.flush()
fout.close()
# Run build
fout = open(self.build_log, "w")
ferr = open(self.build_err_log, "w")
rc = call(["ino", "build"], stdout=fout, stderr=ferr)
fout.close()
ferr.close()
if rc == 0:
sys.stdout.write("pass")
sys.stdout.write("\n")
return True
else:
sys.stdout.write("fail")
sys.stdout.write("\n")
with open(self.build_err_log) as f:
for line in f:
print(" " + line)
return False
def upload(self):
sys.stdout.write(" Upload: ")
sys.stdout.flush()
fout = open(self.build_upload_log, "w")
rc = call(["ino", "upload"], stdout=fout, stderr=fout)
fout.close()
if rc == 0:
sys.stdout.write("pass")
sys.stdout.write("\n")
return True
else:
sys.stdout.write("fail")
sys.stdout.write("\n")
with open(self.build_upload_log) as f:
for line in f:
print(" " + line)
return False
def test(self):
# import the matching test case, if it exists
try:
basename = os.path.basename(self.filename)[:-4]
i = importlib.import_module("testcases." + basename)
except:
sys.stdout.write(" Test: no tests found")
sys.stdout.write("\n")
return
c = getattr(i, basename)
testmethods = [m for m in dir(c) if m.startswith("test_")]
testmethods.sort()
tests = []
for m in testmethods:
tests.append(c(m))
result = unittest.TestResult()
c.setUpClass()
if self.upload():
sys.stdout.write(" Test: ")
sys.stdout.flush()
for t in tests:
t.run(result)
print(str(result.testsRun - len(result.failures) - len(result.errors)) + "/" + str(result.testsRun))
if not result.wasSuccessful():
if len(result.failures) > 0:
for f in result.failures:
print("-- " + str(f[0]))
print(f[1])
if len(result.errors) > 0:
print(" Errors:")
for f in result.errors:
print("-- " + str(f[0]))
print(f[1])
c.tearDownClass()
if __name__ == '__main__':
run_tests = True
w = Workspace()
w.init()
for e in w.examples:
print("--------------------------------------")
print("[" + e.basename + "]")
if e.build() and run_tests:
e.test()
for e in w.tests:
print("--------------------------------------")
print("[" + e.basename + "]")
if e.build() and run_tests:
e.test()
w.clean()
@@ -220,6 +220,7 @@ void MqttSetClientTimeout(void) {
void MqttInit(void) {
// Force buffer size since the #define may not be visible from Arduino lib
MqttClient.setBufferSize(MQTT_MAX_PACKET_SIZE);
MqttClient.setMaxIncomingPacketSize(MQTT_MAX_PACKET_SIZE);
#ifdef USE_MQTT_AZURE_IOT
Settings->mqtt_port = 8883;
@@ -544,7 +545,12 @@ bool MqttPublishLib(const char* topic, const uint8_t* payload, unsigned int plen
uint32_t written = MqttClient.write(payload, plength);
if (written != plength) {
AddLog(LOG_LEVEL_DEBUG, PSTR(D_LOG_MQTT "Message too large"));
// The fixed header (and possibly part of the payload) has already been sent
// by beginPublish()/write(). A short write leaves an incomplete MQTT control packet
// on the wire, so the connection is desynchronised and must not be reused. Drop it;
// Tasmota will reconnect on the next loop.
AddLog(LOG_LEVEL_DEBUG, PSTR(D_LOG_MQTT "Partial write (%u/%u), dropping connection"), written, plength);
MqttClient.disconnect();
return false;
}
/*
@@ -221,6 +221,7 @@ struct BerryMqttClient {
mqtt = new PubSubClient();
mqtt->setClient(*active_client);
mqtt->setBufferSize(MQTT_MAX_PACKET_SIZE);
mqtt->setMaxIncomingPacketSize(MQTT_MAX_PACKET_SIZE);
mqtt->setKeepAlive(Settings->mqtt_keepalive);
mqtt->setSocketTimeout(Settings->mqtt_socket_timeout);