Files
Ben MeadorsandClaude Opus 5 83fd62b756 test(native): add 14 suites for routing, persistence, parsing and identity gaps (#11515)
* test(native): add 14 suites for routing, persistence, parsing and identity gaps

Coverage audit of the native test tree; adds the highest-value untested
logic as 11 new suites and extends 3 existing ones (200 test functions).

New: test_stream_framing, test_nodedb_boot_recovery,
test_nodedb_legacy_migration, test_nodedb_v25_roundtrip,
test_nodedb_identity_hygiene, test_channel_keys, test_reliable_ack_matrix,
test_hop_start_policy, test_routing_response_hops,
test_phone_api_config_dump, test_observer.
Extended: test_rtc, test_mqtt, test_xmodem.

Two source changes the audit produced:

- StreamAPI::handleRecStream copied stream->read()'s `cInt < 0` EOF check
  into the buffer-fed path, where there is no EOF sentinel; with signed
  char any byte >= 0x80 (START1 is 0x94) aborted the parse. Read the byte
  as uint8_t directly. Latent on develop (no callers), pinned by
  test_stream_framing.
- Extract the post-decode pre-hop predicate from Router::handleReceived
  into shouldSkipHandleForPostDecodeHop() (NodeDB.h) so
  test_hop_start_policy drives the exact expression the router calls.
  No behavior change.

test/state-manifest.tsv declares the suites that construct a NodeDB.
Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): address review - harden observer dispatch, trim comments

Review follow-ups on the coverage-audit suites:

- Observable::notifyObservers() erased list nodes while holding an iterator
  into them, so an observer that unobserves itself from onNotify corrupted the
  dispatch. Today the only self-detacher (PhoneAPI::onNotify ->
  checkConnectionTimeout -> close -> unobserve) survives solely because it
  returns -1 and aborts the chain before the increment; that unwritten contract
  is now gone. Removal during a dispatch nulls the entry and the outermost
  notify sweeps afterwards, which keeps self-detach, next-detach and
  destruction-during-notify all safe without an allocation. Hoisting the next
  iterator instead would have inverted the hazard and broken the existing
  next-detach case. Two regression tests added.

- Correct the documented caller of shouldSkipHandleForPostDecodeHop: the call
  is in Router::dispatchReceived, not handleReceived.

- Cast hop fields to unsigned at the %u call site in test_hop_start_policy.

- Trim the new suites' file headers to the one-or-two-line rule in AGENTS.md.

- Rename eight test functions whose names were exactly `test_` + 35 chars:
  that is the shape of a Lob API key, so trufflehog flagged them as secrets
  and failed the Trunk CI check.

Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): revert the observer dispatch change, keep the contract test

Backs out the notifyObservers() deferred-removal hardening from the previous
commit. It was reviewer-driven scope creep: nothing in the coverage audit
needed it, no test required it, and it changes dispatch semantics in a header
with ~76 observe() call sites on native verification alone.

The hazard it addressed is not reachable today. The only observer that
unobserves itself from onNotify is PhoneAPI (onNotify ->
checkConnectionTimeout -> close -> unobserve), and it returns -1, which aborts
the chain before the iterator is advanced past the erased node.

test_self_detach_with_abort_during_notify stays: it passes against the
unmodified dispatch and pins that the -1 is load-bearing, so a later cleanup
that "simplifies" it away goes red. The unsafe variant (self-detach returning
0) is documented in a comment rather than tested, since asserting it would be
asserting UB.

* fix(serial): recover the frame behind a stray framing marker

A byte that failed the START2 check was discarded rather than re-tested as
a possible START1, so 0x94 0x94 0xc3 ... lost the real frame: one corrupted
byte on a noisy UART silently dropped the frame behind it. Re-test the byte
in place instead.

Applied to both copies of the receive state machine. readStream() is the one
that matters in the field - it is the serial path every phone client uses -
while handleRecStream() still has no callers on develop.

Strictly widens what the parser accepts; no frame that parsed before parses
differently. test_stream_framing covers it on both receive paths, plus a run
of stray markers and a START1-then-unrelated-byte resync.

This was originally documented as a known gap in the framing suite. Fixing it
instead was NomDeTom's call on review: a passing test asserting the bad
behavior is what makes it hard to change later, and it is the same defect
shape as the signedness fix three functions away.

Also: use Throttle::deadlinePassed() in test_reliable_ack_matrix rather than
a bare millis() compare, matching the house deadline rule.

* test(native): cover the stray-marker resync on the buffer path too

The stray-marker fix went into both copies of the receive state machine, but
only test_stray_start1_before_frame_still_delivers drove both. The repeated-
marker and unrelated-byte cases drove readStream() alone, so a regression in
handleRecStream() would have gone unnoticed by two of the three.

Verified load-bearing: reverting only the handleRecStream() half of the fix
turns test_repeated_stray_start1_before_frame_still_delivers red on the new
assertion. test_start1_then_unrelated_byte_resyncs stays green under that
mutation by design - its failing byte is 0x00, where both branches reset to 0 -
and covers the other half of the ternary.

Also drops the stale header on test_stray_start1_before_frame_still_delivers,
which still described the gap as pinned-as-is after the fix landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(native): make the hop-start truth table assert the rows it prints

test_truth_table_summary was six TEST_MESSAGE lines and no assertion, so it
reported as a case that could not fail - the anti-pattern #11517 names in its
unfinished assertion-presence lint, and the one exception to NomDeTom's "no
RUN_TEST without an assertion" pass over this PR.

The printed row and the checked expectation now come from one struct, so the
summary cannot narrate a table the predicates no longer implement. It also
covers the consequence columns the per-row tests do not assert together:
classifyHopStart, shouldDropPacketForPreHop and shouldSkipHandleForPostDecodeHop
for the same packet, with the expectations gated on MESHTASTIC_PREHOP_DROP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:41:08 +00:00

398 lines
15 KiB
C++

#include "MeshTypes.h"
#include "TestUtil.h"
#include "configuration.h"
#include "mesh/MeshService.h"
#include "mesh/StreamAPI.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <limits>
#include <unity.h>
#include <vector>
// Framing constants mirrored from StreamAPI.cpp (defined only in that translation unit).
static constexpr uint8_t kStart1 = 0x94;
static constexpr uint8_t kStart2 = 0xc3;
static constexpr size_t kHeaderLen = 4;
/// Input-scripted stream feeding queued bytes through the readStream() polling path.
class InputScriptedStream : public Stream
{
public:
/// Report how many queued input bytes remain.
int available() override { return (int)input.size(); }
/// Return the next queued byte as an unsigned value, or -1 when drained.
int read() override
{
if (input.empty())
return -1;
int value = input.front();
input.pop_front();
return value;
}
/// Return the next queued byte without consuming it.
int peek() override { return input.empty() ? -1 : input.front(); }
/// Accept unlimited output; this suite only exercises the receive side.
int availableForWrite() override { return std::numeric_limits<int>::max(); }
size_t write(uint8_t) override { return 1; }
size_t write(const uint8_t *, size_t size) override { return size; }
void flush() override {}
/// Queue bytes for the next readStream() poll.
void feed(const std::vector<uint8_t> &bytes) { input.insert(input.end(), bytes.begin(), bytes.end()); }
std::deque<uint8_t> input;
};
// The global `service` is installed in setUp() and restored in tearDown() rather than by RAII
// because a failed TEST_ASSERT longjmps out of the test without running destructors, which would
// leave `service` dangling for the rest of the suite. testService is intentionally never freed:
// it stays reachable through the static, so LeakSanitizer does not flag it.
static MeshService *testService = nullptr;
static MeshService *previousService = nullptr;
/// Records every framed ToRadio payload the receive state machine delivers.
class FramingStreamAPIShim : public StreamAPI
{
public:
/// Construct the shim over a scripted input stream.
explicit FramingStreamAPIShim(Stream *stream) : StreamAPI(stream) {}
/// Keep connection-timeout handling inactive during tests.
bool checkIsConnected() override { return true; }
/// Capture one delivered payload instead of running the real PhoneAPI decode.
bool handleToRadio(const uint8_t *buf, size_t len) override
{
deliveries.emplace_back(buf, buf + len);
return true;
}
std::vector<std::vector<uint8_t>> deliveries;
};
/// Wrap a payload in the 0x94C3 big-endian-length stream framing.
static std::vector<uint8_t> makeFrame(const std::vector<uint8_t> &payload)
{
std::vector<uint8_t> frame = {kStart1, kStart2, (uint8_t)(payload.size() >> 8), (uint8_t)(payload.size() & 0xff)};
frame.insert(frame.end(), payload.begin(), payload.end());
return frame;
}
/// Drive the buffer-fed receive path (SerialModule/native callers) with one burst.
static void feedBufferPath(FramingStreamAPIShim &api, const std::vector<uint8_t> &bytes)
{
std::vector<uint8_t> copy = bytes; // runOncePart takes a mutable char*
api.runOncePart(reinterpret_cast<char *>(copy.data()), (uint16_t)copy.size());
}
/// Drive the stream-polling receive path with one burst.
static void feedStreamPath(FramingStreamAPIShim &api, InputScriptedStream &stream, const std::vector<uint8_t> &bytes)
{
stream.feed(bytes);
api.runOncePart();
}
/// Assert delivery `index` matches the expected payload, size first so a short delivery is a
/// clean assertion failure rather than an out-of-bounds read.
static void assertDeliveryAt(const FramingStreamAPIShim &api, size_t index, const std::vector<uint8_t> &expected)
{
TEST_ASSERT_TRUE_MESSAGE(index < api.deliveries.size(), "delivery index out of range");
TEST_ASSERT_EQUAL_UINT(expected.size(), api.deliveries[index].size());
if (!expected.empty()) // Unity rejects zero-length array asserts as pointless
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected.data(), api.deliveries[index].data(), expected.size());
}
/// Assert the shim recorded exactly one delivery matching the expected payload.
static void assertSingleDelivery(const FramingStreamAPIShim &api, const std::vector<uint8_t> &expected)
{
TEST_ASSERT_EQUAL_UINT_MESSAGE(1, api.deliveries.size(), "expected exactly one handleToRadio delivery");
assertDeliveryAt(api, 0, expected);
}
/// Verify one well-formed frame off the scripted stream delivers its exact payload once.
void test_stream_single_frame_delivers_exact_payload()
{
InputScriptedStream stream;
FramingStreamAPIShim api(&stream);
std::vector<uint8_t> payload = {0x08, 0x01, 0x2a, 0x00, 0x7f};
feedStreamPath(api, stream, makeFrame(payload));
assertSingleDelivery(api, payload);
TEST_ASSERT_TRUE_MESSAGE(stream.input.empty(), "readStream must drain everything available");
}
/// Verify parser state persists across stream polls split mid-header and mid-payload.
void test_stream_partial_reads_persist_state()
{
InputScriptedStream stream;
FramingStreamAPIShim api(&stream);
std::vector<uint8_t> payload = {0xaa, 0xbb, 0xcc};
std::vector<uint8_t> frame = makeFrame(payload);
// First poll sees only 3 of the 4 header bytes.
feedStreamPath(api, stream, std::vector<uint8_t>(frame.begin(), frame.begin() + 3));
TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size());
// Second poll supplies the length byte and part of the payload.
feedStreamPath(api, stream, std::vector<uint8_t>(frame.begin() + 3, frame.begin() + 5));
TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size());
// Final poll completes the payload: exactly one delivery.
feedStreamPath(api, stream, std::vector<uint8_t>(frame.begin() + 5, frame.end()));
assertSingleDelivery(api, payload);
}
/// Verify rxPtr persists across buffer-path invocations fed one byte at a time.
void test_buffer_path_one_byte_per_call_persists_state()
{
InputScriptedStream stream;
FramingStreamAPIShim api(&stream);
std::vector<uint8_t> payload = {0x12, 0x34};
std::vector<uint8_t> frame = makeFrame(payload);
for (size_t i = 0; i + 1 < frame.size(); i++) {
feedBufferPath(api, {frame[i]});
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api.deliveries.size(), "no delivery before the final byte");
}
feedBufferPath(api, {frame.back()});
assertSingleDelivery(api, payload);
}
/// Verify the parser hunts past leading ASCII boot-log garbage to the frame marker.
void test_leading_garbage_resyncs_to_frame()
{
InputScriptedStream stream;
FramingStreamAPIShim api(&stream);
std::vector<uint8_t> payload = {0x55, 0x66};
const char *bootLog = "INFO | ??:??:?? 1 Booting\r\n";
std::vector<uint8_t> burst(bootLog, bootLog + strlen(bootLog));
std::vector<uint8_t> frame = makeFrame(payload);
burst.insert(burst.end(), frame.begin(), frame.end());
feedStreamPath(api, stream, burst);
assertSingleDelivery(api, payload);
}
/// Verify a header advertising len 513 is rejected and a later frame in the burst still delivers.
void test_bogus_length_rejected_then_next_frame_recovered()
{
InputScriptedStream stream;
FramingStreamAPIShim api(&stream);
std::vector<uint8_t> payload = {0x77};
// MAX_TO_FROM_RADIO_SIZE is 512, so a big-endian length of 513 must fail header validation.
std::vector<uint8_t> burst = {kStart1, kStart2, 0x02, 0x01};
const char *junk = "junk";
burst.insert(burst.end(), junk, junk + strlen(junk));
std::vector<uint8_t> frame = makeFrame(payload);
burst.insert(burst.end(), frame.begin(), frame.end());
feedBufferPath(api, burst);
assertSingleDelivery(api, payload);
}
/// Verify a len==512 frame (the exact cap, filling rxBuf to its last byte) is delivered intact
/// on both receive paths.
void test_max_length_frame_accepted_exactly()
{
std::vector<uint8_t> payload(MAX_TO_FROM_RADIO_SIZE);
for (size_t i = 0; i < payload.size(); i++)
payload[i] = (uint8_t)(i & 0xff);
std::vector<uint8_t> frame = makeFrame(payload);
// Total frame is 516 bytes == sizeof(rxBuf); ASan in the coverage env guards the bound.
InputScriptedStream stream;
FramingStreamAPIShim streamApi(&stream);
feedStreamPath(streamApi, stream, frame);
assertSingleDelivery(streamApi, payload);
// Buffer path, split so the cap is reached with rxPtr state persisted across calls.
InputScriptedStream unusedStream;
FramingStreamAPIShim bufferApi(&unusedStream);
const size_t split = frame.size() / 2;
feedBufferPath(bufferApi, std::vector<uint8_t>(frame.begin(), frame.begin() + split));
TEST_ASSERT_EQUAL_UINT(0, bufferApi.deliveries.size());
feedBufferPath(bufferApi, std::vector<uint8_t>(frame.begin() + split, frame.end()));
assertSingleDelivery(bufferApi, payload);
}
/// Verify a zero-length payload is a valid frame delivering len 0 on both receive paths.
void test_zero_length_payload_delivers_empty()
{
std::vector<uint8_t> frame = makeFrame({});
TEST_ASSERT_EQUAL_UINT(kHeaderLen, frame.size());
InputScriptedStream stream;
FramingStreamAPIShim streamApi(&stream);
feedStreamPath(streamApi, stream, frame);
assertSingleDelivery(streamApi, {});
InputScriptedStream unusedStream;
FramingStreamAPIShim bufferApi(&unusedStream);
feedBufferPath(bufferApi, frame);
assertSingleDelivery(bufferApi, {});
}
/// Verify two back-to-back frames in one burst deliver twice, in order, on both paths.
void test_back_to_back_frames_deliver_in_order()
{
std::vector<uint8_t> first = {0x01, 0x02, 0x03};
std::vector<uint8_t> second = {0xf0, 0x0d};
std::vector<uint8_t> burst = makeFrame(first);
std::vector<uint8_t> secondFrame = makeFrame(second);
burst.insert(burst.end(), secondFrame.begin(), secondFrame.end());
InputScriptedStream stream;
FramingStreamAPIShim streamApi(&stream);
feedStreamPath(streamApi, stream, burst);
TEST_ASSERT_EQUAL_UINT(2, streamApi.deliveries.size());
assertDeliveryAt(streamApi, 0, first);
assertDeliveryAt(streamApi, 1, second);
InputScriptedStream unusedStream;
FramingStreamAPIShim bufferApi(&unusedStream);
feedBufferPath(bufferApi, burst);
TEST_ASSERT_EQUAL_UINT(2, bufferApi.deliveries.size());
assertDeliveryAt(bufferApi, 0, first);
assertDeliveryAt(bufferApi, 1, second);
}
/// Verify payload bytes >= 0x80 survive the buffer path identically to the stream path.
/// Pins the unsigned read in StreamAPI::handleRecStream(const char *, uint16_t): a plain
/// (signed) char compare treated any high byte - START1 itself is 0x94 - as EOF and
/// silently dropped frames mid-buffer.
void test_high_bytes_in_payload_delivered_on_both_paths()
{
// Includes the framing bytes themselves mid-payload: length counts them as data.
std::vector<uint8_t> payload = {0x80, kStart1, kStart2, 0xff, 0x00, 0xfe, 0x7f, 0x81};
std::vector<uint8_t> frame = makeFrame(payload);
InputScriptedStream stream;
FramingStreamAPIShim streamApi(&stream);
feedStreamPath(streamApi, stream, frame);
assertSingleDelivery(streamApi, payload);
InputScriptedStream unusedStream;
FramingStreamAPIShim bufferApi(&unusedStream);
feedBufferPath(bufferApi, frame);
assertSingleDelivery(bufferApi, payload);
TEST_ASSERT_EQUAL_UINT8_ARRAY(streamApi.deliveries[0].data(), bufferApi.deliveries[0].data(), payload.size());
}
/// A byte that fails START2 is re-tested as START1, so 0x94 0x94 0xc3 ... keeps the frame behind
/// the stray marker instead of consuming its real marker in the reset.
void test_stray_start1_before_frame_still_delivers()
{
std::vector<uint8_t> payload = {0x42};
std::vector<uint8_t> frame = makeFrame(payload);
std::vector<uint8_t> burst = {kStart1}; // stray marker, then the real frame
burst.insert(burst.end(), frame.begin(), frame.end());
// The byte that fails START2 is itself START1 here, so the frame behind it must survive.
InputScriptedStream bufStream;
FramingStreamAPIShim bufferApi(&bufStream);
feedBufferPath(bufferApi, burst);
assertSingleDelivery(bufferApi, payload);
InputScriptedStream stream;
FramingStreamAPIShim streamApi(&stream);
feedStreamPath(streamApi, stream, burst);
assertSingleDelivery(streamApi, payload);
}
/// A run of stray markers before a frame must not consume it either.
void test_repeated_stray_start1_before_frame_still_delivers()
{
std::vector<uint8_t> payload = {0x43, 0x44};
std::vector<uint8_t> frame = makeFrame(payload);
std::vector<uint8_t> burst = {kStart1, kStart1, kStart1};
burst.insert(burst.end(), frame.begin(), frame.end());
InputScriptedStream bufStream;
FramingStreamAPIShim bufferApi(&bufStream);
feedBufferPath(bufferApi, burst);
assertSingleDelivery(bufferApi, payload);
InputScriptedStream stream;
FramingStreamAPIShim streamApi(&stream);
feedStreamPath(streamApi, stream, burst);
assertSingleDelivery(streamApi, payload);
}
/// START1 followed by a non-START1, non-START2 byte still resyncs on the next real frame.
void test_start1_then_unrelated_byte_resyncs()
{
std::vector<uint8_t> payload = {0x45};
std::vector<uint8_t> frame = makeFrame(payload);
std::vector<uint8_t> burst = {kStart1, 0x00};
burst.insert(burst.end(), frame.begin(), frame.end());
InputScriptedStream bufStream;
FramingStreamAPIShim bufferApi(&bufStream);
feedBufferPath(bufferApi, burst);
assertSingleDelivery(bufferApi, payload);
InputScriptedStream stream;
FramingStreamAPIShim streamApi(&stream);
feedStreamPath(streamApi, stream, burst);
assertSingleDelivery(streamApi, payload);
}
/// Unity per-test setup: install the test MeshService the StreamAPI fixtures expect.
void setUp(void)
{
previousService = service;
if (!testService)
testService = new MeshService();
service = testService;
}
/// Unity per-test teardown: runs even after an aborted test, so the restore is failure-safe.
void tearDown(void)
{
service = previousService;
}
/// Initialize the native environment and run the receive-framing suite.
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
printf("\n=== Frame delivery ===\n");
RUN_TEST(test_stream_single_frame_delivers_exact_payload);
RUN_TEST(test_zero_length_payload_delivers_empty);
RUN_TEST(test_back_to_back_frames_deliver_in_order);
RUN_TEST(test_high_bytes_in_payload_delivered_on_both_paths);
printf("\n=== Partial reads / state persistence ===\n");
RUN_TEST(test_stream_partial_reads_persist_state);
RUN_TEST(test_buffer_path_one_byte_per_call_persists_state);
RUN_TEST(test_max_length_frame_accepted_exactly);
printf("\n=== Resync and rejection ===\n");
RUN_TEST(test_leading_garbage_resyncs_to_frame);
RUN_TEST(test_bogus_length_rejected_then_next_frame_recovered);
printf("\n=== Stray framing markers ===\n");
RUN_TEST(test_stray_start1_before_frame_still_delivers);
RUN_TEST(test_repeated_stray_start1_before_frame_still_delivers);
RUN_TEST(test_start1_then_unrelated_byte_resyncs);
exit(UNITY_END());
}
/// Unused Arduino loop required by the native Unity runner.
void loop() {}