Files
rtl_433/tests/http-integration-test.sh
Andrew Berry 5e20bd2c40 Fix http server truncating large get_stats/get_protocols JSON
The /cmd getters serialized into fixed stack buffers (get_stats 20480,
get_meta 2048, get_protocols 102400). When a report exceeded its buffer
the output was silently corrupted into invalid JSON, not cleanly
truncated: the abuf string builder drops an oversized chunk but keeps
appending the smaller chunks that follow, so e.g. a get_stats reply with
~230 enabled decoders ended like `...,"name":,:8256}`. Clients then fail
to parse the response and the corresponding data never updates.

Make the serializer report truncation and add data_print_jsons_dup(),
which grows a heap buffer until the whole document fits, and use it for
the three JSON-payload getters:

- abuf gains an `overflow` flag, set by abuf_cat/abuf_printf and the
  hand-rolled string formatter whenever a write is dropped or truncated.
- data_print_jsons_dup() retries with a doubled buffer until no overflow
  (64 MiB sanity cap), so the report is never truncated.
- get_stats/get_meta/get_protocols use it and free the buffer after
  responding; out-of-memory yields an error reply instead of a partial one.

Tests:
- data-test asserts a large report serializes to a complete object via
  data_print_jsons_dup() while a fixed undersized buffer does not.
- the HTTP integration test now validates that get_protocols/get_stats/
  get_meta return parseable JSON, not just HTTP 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:43:12 +02:00

204 lines
8.0 KiB
Bash
Executable File

#!/bin/sh
#
# Black-box integration test for the rtl_433 HTTP/WS API server.
#
# Starts rtl_433 in "manual" device mode (-D manual), which runs the HTTP
# server event loop without any SDR hardware or input file, exercises each
# endpoint over the loopback interface, then shuts the server down with
# SIGTERM so it exits cleanly (this is what lets gcov flush .gcda files when
# the binary was built with -DENABLE_COVERAGE=ON).
#
# Usage: http-integration-test.sh [path-to-rtl_433-binary]
# Defaults to ../src/rtl_433 relative to this script.
# Override the port with PORT=NNNN, otherwise a free port is chosen.
set -u
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
RTL_433=${1:-"$SCRIPT_DIR/../src/rtl_433"}
HOST=127.0.0.1
if [ ! -x "$RTL_433" ]; then
echo "ERROR: rtl_433 binary not found or not executable: $RTL_433" >&2
exit 99
fi
# Pick a free TCP port unless one was given, so parallel ctest runs don't collide.
if [ -z "${PORT:-}" ]; then
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null)
fi
PORT=${PORT:-18433}
BASE="http://$HOST:$PORT"
SERVER_LOG=$(mktemp 2>/dev/null || echo /tmp/rtl_433_http_test.$$.log)
SERVER_PID=""
FAILED=0
PASSED=0
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
# SIGTERM -> clean shutdown -> atexit/gcov flush. Do NOT use SIGKILL.
kill -TERM "$SERVER_PID" 2>/dev/null
# give it a moment to drain and write coverage data
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$SERVER_PID" 2>/dev/null || break
sleep 0.3
done
kill -0 "$SERVER_PID" 2>/dev/null && kill -KILL "$SERVER_PID" 2>/dev/null
wait "$SERVER_PID" 2>/dev/null
fi
rm -f "$SERVER_LOG"
}
trap cleanup EXIT INT TERM
fail() { echo " NOT OK: $1" >&2; FAILED=$((FAILED + 1)); }
pass() { echo " ok: $1"; PASSED=$((PASSED + 1)); }
# expect_status DESC EXPECTED_CODE curl-args...
expect_status() {
desc=$1; want=$2; shift 2
got=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$@")
[ "$got" = "$want" ] && pass "$desc (HTTP $got)" || fail "$desc: expected HTTP $want, got $got"
}
# expect_body_contains DESC SUBSTRING curl-args...
expect_body_contains() {
desc=$1; needle=$2; shift 2
body=$(curl -s --max-time 10 "$@")
case "$body" in
*"$needle"*) pass "$desc (body contains '$needle')" ;;
*) fail "$desc: body did not contain '$needle'"; printf ' got: %.200s\n' "$body" >&2 ;;
esac
}
# expect_valid_json DESC curl-args... -- body must parse as JSON. Guards against
# the getters truncating an oversized report into invalid JSON (see get_stats /
# get_protocols, which now grow their output buffer to fit). Skipped when
# python3 is unavailable to validate.
HAVE_PY=0
command -v python3 >/dev/null 2>&1 && HAVE_PY=1
expect_valid_json() {
desc=$1; shift
if [ "$HAVE_PY" != 1 ]; then
echo " skip: $desc (python3 not found)"
return
fi
body=$(curl -s --max-time 10 "$@")
if printf '%s' "$body" | python3 -c 'import sys,json; json.load(sys.stdin)' 2>/dev/null; then
pass "$desc (valid JSON)"
else
fail "$desc: response is not valid JSON"; printf ' got: %.200s\n' "$body" >&2
fi
}
# expect_stream DESC URL -- streaming endpoints stay open; we only check the
# response headers, tolerating the curl timeout (exit 28) that follows.
expect_stream() {
desc=$1; url=$2
hdr=$(curl -s -D - -o /dev/null --max-time 2 "$url" 2>/dev/null)
case "$hdr" in
*"200 OK"*) pass "$desc (200, stream open)" ;;
*) fail "$desc: no '200 OK' in response headers"; printf ' got: %.200s\n' "$hdr" >&2 ;;
esac
}
echo "Starting rtl_433 HTTP server: $RTL_433 -D manual -F $BASE"
"$RTL_433" -D manual -F "http://$HOST:$PORT" >"$SERVER_LOG" 2>&1 &
SERVER_PID=$!
# Wait for readiness (server prints/serves once the manual loop is up).
ready=0
for _ in $(seq 1 50); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "ERROR: server exited during startup" >&2
cat "$SERVER_LOG" >&2
exit 98
fi
if curl -s -o /dev/null --max-time 1 "$BASE/"; then
ready=1
break
fi
sleep 0.2
done
if [ "$ready" != 1 ]; then
echo "ERROR: server did not become ready on $BASE" >&2
cat "$SERVER_LOG" >&2
exit 97
fi
echo "Server ready on port $PORT (pid $SERVER_PID). Running checks:"
# --- static / UI ---
expect_status "GET / returns 200" 200 "$BASE/"
expect_body_contains "GET / serves index html" "DOCTYPE html" "$BASE/"
expect_status "OPTIONS / CORS preflight" 204 -X OPTIONS "$BASE/"
expect_status "GET /ui redirects" 307 "$BASE/ui"
# --- OpenMetrics / Prometheus ---
expect_status "GET /metrics returns 200" 200 "$BASE/metrics"
expect_body_contains "GET /metrics is OpenMetrics" "# TYPE uptime_seconds counter" "$BASE/metrics"
# Non-GET is rejected; send an explicit (empty) body so mongoose frames the
# request rather than waiting for a body that curl's bare -X POST never sends.
expect_status "POST /metrics is rejected" 405 -d '' "$BASE/metrics"
# --- /cmd RPC (GET query string and POST form) ---
expect_body_contains "GET /cmd?cmd=get_meta" "\"samp_rate\"" "$BASE/cmd?cmd=get_meta"
expect_status "GET /cmd?cmd=get_sample_rate" 200 "$BASE/cmd?cmd=get_sample_rate"
expect_body_contains "POST /cmd form get_meta" "\"samp_rate\"" -d "cmd=get_meta" "$BASE/cmd"
expect_status "GET /cmd with no cmd" 200 "$BASE/cmd"
# --- more RPC methods, to exercise the rpc_exec getter/setter branches ---
expect_body_contains "GET /cmd get_protocols" "Nice" "$BASE/cmd?cmd=get_protocols"
expect_status "GET /cmd get_stats" 200 "$BASE/cmd?cmd=get_stats"
# These JSON-payload getters build large reports; verify the whole document is
# well-formed (get_protocols is ~100k, well past the buffer's initial size).
expect_valid_json "GET /cmd get_protocols is valid JSON" "$BASE/cmd?cmd=get_protocols"
expect_valid_json "GET /cmd get_stats is valid JSON" "$BASE/cmd?cmd=get_stats"
expect_valid_json "GET /cmd get_meta is valid JSON" "$BASE/cmd?cmd=get_meta"
expect_status "GET /cmd get_center_frequency" 200 "$BASE/cmd?cmd=get_center_frequency"
expect_status "GET /cmd setter center_frequency" 200 "$BASE/cmd?cmd=center_frequency&val=433920000"
expect_body_contains "GET /cmd unknown method is rejected" "Unknown method" "$BASE/cmd?cmd=no_such_method"
# --- JSON-RPC ---
expect_status "POST /jsonrpc valid method" 200 \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"get_sample_rate"}' "$BASE/jsonrpc"
expect_body_contains "POST /jsonrpc method with params" "result" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"sample_rate","params":[1024000]}' "$BASE/jsonrpc"
expect_status "POST /jsonrpc malformed body does not crash" 200 \
-H 'Content-Type: application/json' -d 'not json at all' "$BASE/jsonrpc"
# --- streaming endpoints ---
expect_stream "GET /stream opens" "$BASE/stream"
expect_stream "GET /events opens" "$BASE/events"
# --- WebSocket: handshake, meta push, and an RPC round-trip ---
if command -v python3 >/dev/null 2>&1; then
if ws_out=$(python3 "$SCRIPT_DIR/ws-probe.py" "$HOST" "$PORT" 10 2>&1); then
pass "websocket handshake + meta + rpc round-trip"
else
fail "websocket probe failed"
printf '%s\n' "$ws_out" | sed 's/^/ /' >&2
fi
else
echo " skip: websocket probe (python3 not found)"
fi
# --- robustness: unknown path must not crash the server ---
curl -s -o /dev/null --max-time 2 "$BASE/no/such/path" 2>/dev/null
if kill -0 "$SERVER_PID" 2>/dev/null; then
pass "server still alive after unknown path"
else
fail "server died after request to unknown path"
fi
echo
echo "Results: $PASSED passed, $FAILED failed"
if [ "$FAILED" -ne 0 ]; then
echo "--- server log ---" >&2
cat "$SERVER_LOG" >&2
exit 1
fi
exit 0