mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 17:38:16 +00:00
[P1] A failed setup no longer strands the slot. setupSlot() returns bool and the startup loops count only successful activations, so a slot that fails on a client allocation neither consumes an active-slot position (starving a later healthy broker on capped hardware) nor sits dead forever: maintainSlotConnections() previously skipped clientless slots and the reconnect ladder is gated on initial_connect_done, so nothing retried it. It now retries an enabled but unactivated slot on a 60 s timer, one per cycle, gated on the same _slots_setup_done ordering so the NTP-deferred setup sequence is preserved. [P2] JWT setup no longer proceeds without a usable token. Both the preset and custom-audience paths returned after ignoring createSlotAuthToken()'s result, then called connect() and latched initial_connect_done -- so the token-allocation failure introduced by the previous commit produced an unauthenticated attempt exactly when memory was exhausted. They now return false and let the retry path handle it. [P2] ensureSlotAuthToken() no longer clears an existing token. It cleared unconditionally, so every renewal wiped the current token before JWTHelper ran; a renewal that then failed left an empty password where the inline buffer used to preserve working credentials (JWTHelper writes only on success). Only freshly allocated buffers are initialised now. [P2] Raw publications reuse the shared document. buildRawJSON() reached MQTTPayloadBuilder::buildRawMessage(), which constructed its own default JsonDocument and therefore malloc'd and freed an internal-heap variant pool per message -- on the highest-rate topic. The document is threaded through both builders and the bridge passes _json_scratch_doc. [P3] The writeTo() guard validates the source fields, not just the destination. A corrupt payload_len of MAX_PACKET_PAYLOAD + 1 still leaves getRawLength() inside MAX_TRANS_UNIT, so writeTo() read past packet->payload. Sizing and validation moved to a pure MQTTWireScratch header with host tests covering the accept/reject edges, matching the MQTTPacketFilter/MQTTConnectionPolicy pattern. Two findings fell out: MAX_PATH_SIZE one-byte hops is not encodable (the hop count is 6 bits, so 64 & 63 == 0; 32 two-byte hops is the widest real path), and a zero-payload packet serializes but does not survive readFrom() -- pinned as a test because it constrains any future wire-only queue. [P3] Corrected the pool-size comment: these targets are 32-bit, so ARDUINOJSON_SLOT_ID_SIZE is 2 and a pool block is 128 slots / 1024 bytes, not 4096. The 4096 figure came from a pre-existing comment near NEIGHBORS_DOC_POOL_BUDGET, which is left alone -- its byte measurements are empirical and still stand, only the block-size attribution is wrong. Activation is now centralized in activatedSlotCount()/canActivateSlot(), used by both startup loops, the retry path, and applySlotPreset(). That closes the pre-existing divergence where a live preset change called setupSlot() without consulting _max_active_slots, letting a non-PSRAM board reach three concurrent TLS sessions against a cap of two. BEHAVIOUR CHANGE: a reconfigure that would exceed the cap now logs and leaves the slot inactive instead of connecting. Reconfiguring an already-active slot still works, because teardownSlot() releases its position first. 272/272 native tests pass (5 new); both observer envs and an nRF52 repeater build clean. Flash 1593249 B non-PSRAM, 1555625 B PSRAM.
Host unit tests
Fast, hardware-free unit tests for the fork's pure logic, run on the host with
GoogleTest via PlatformIO's native environment. They cover the extractable
observer/WebConfig logic (validation, preset table, topic templates, key
parsing) — the parts that don't depend on the ESP32, radio, or network stack.
Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised
separately; see "Local testing without hardware" in MQTT_IMPLEMENTATION.md.
Running
pio test -e native # all suites
pio test -e native -f test_webconfig_keys # a single suite
A green [PASSED] per suite means GoogleTest returned 0 (all assertions
passed). PlatformIO's "0 test cases" line is just its Unity-style counter and
does not reflect the GoogleTest count — run the built binary directly
(.pio/build/native/program) to see the per-assertion breakdown.
Suites
| Suite | Source under test | Covers |
|---|---|---|
test_mqtt_presets |
src/helpers/MQTTPresets.h |
preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); mqttPresetNeedsSlotCredentials; slot-count constants |
test_observer_validation |
src/helpers/MQTTObserverValidation.h |
IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation — including boundaries and nulls |
test_webconfig_keys |
src/helpers/WebConfigKeys.h |
POST-key allowlist, secret detection, admin-password classification/validation, slot-index bounds, and the short-key out-of-bounds guard (attacker-supplied keys) |
test_topic_template |
src/helpers/MQTTTopicTemplate.h |
{iata}/{device}/{token}/{type} expansion, overflow/NUL-termination, and a buffer-size fuzz |
test_mqtt_topic_router |
src/helpers/MQTTTopicRouter.h |
complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries |
test_mqtt_connection_policy |
src/helpers/MQTTConnectionPolicy.h |
reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit millis() rollover |
test_mqtt_packet_queue_policy |
src/helpers/MQTTPacketQueuePolicy.h |
queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit millis() rollover |
test_mqtt_packet_filter |
src/helpers/MQTTPacketFilter.h |
per-slot 0-15 allowlist parsing/formatting, numeric and named spellings; exact bounds; membership; candidate/eligible split and retry-completion policy; pre-queue union gate; default-mask detection |
test_mqtt_runtime_buffer_lifecycle |
src/helpers/MQTTRuntimeBufferLifecycle.h |
idempotent allocation/release; partial-allocation degradation; retry of only missing buffers |
test_mqtt_prefs_codec |
src/helpers/MQTTPrefsStorage.h, src/helpers/MQTTPrefsCodec.h |
binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation; shortest-payload write policy (default filters stay downgrade-readable) |
test_mqtt_prefs_atomic_store |
src/helpers/MQTTPrefsAtomicStore.h |
transactional MQTT writes and legacy /node_prefs handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
test_mqtt_payload_builder |
src/helpers/MQTTPayloadBuilder.cpp |
status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads |
test_utils |
src/Utils.cpp |
Utils::toHex (upstream) |
Conventions (and how to add a suite)
- Each
test/test_<name>/directory builds into its own GoogleTest program and must define its ownmain()(::testing::InitGoogleTest+RUN_ALL_TESTS). - Tests are host-only: include only pure headers. Arduino/crypto stubs live
in
test/mocks/(on the include path via-I test/mocks). - Firmware headers are included from
src(via-I src, e.g.#include "helpers/MQTTPresets.h"). Some are guarded or ESP-flavored, so a suite may need shims before the include — e.g.test_mqtt_presetsdoes#define WITH_MQTT_BRIDGE 1(the preset table is behind that flag) and#define PROGMEM(the embedded CA-cert strings are PROGMEM-qualified). - To add a suite: create
test/test_<name>/test_<name>.cppwith amain(), and add any host-only source it links to thenativeenv'sbuild_src_filterinplatformio.ini(header-only code needs no source entry). No other wiring. - Keep logic testable by extracting pure functions into headers (as
MQTTObserverValidation.h/WebConfigKeys.h/MQTTTopicTemplate.hdo) and having the firmware call the same functions.