All checks were successful
CI / verification (pull_request) Successful in 36s
Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.
- GET /backups (read token) serves { vps, lenovo }: the status.json the
VPS script writes, rebuilt through a whitelist (closed error codes,
rotation block included), and the last workstation pull report. Each
block gets ageSeconds, stale and staleAfterSeconds from the server
(BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
server-stamped receivedAt. A broken file gives a null block plus
vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
{ pulledAt, files, ok, error? }, server receivedAt, atomic write in
mode 600 through writeJsonAtomic(), now shared with /claude-usage.
Answers 204 and 413 like the two other ingest routes (the agent only
checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
(101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.
Refs #58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
612 lines
33 KiB
Bash
Executable file
612 lines
33 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# test-curl.sh — manual smoke test for vps-health-api endpoints.
|
|
#
|
|
# Spins up the server against a temporary REPORTS_DIR and HOSTS_DIR populated
|
|
# with fixtures, then runs curl against each endpoint and checks status codes +
|
|
# payload shape.
|
|
#
|
|
# This is the socket-level pass, not a substitute for `npm test`: vitest covers
|
|
# the logic, this script covers what only a real client and a real TCP socket
|
|
# can show — a 413 answered before the connection is cut, the two tokens
|
|
# refusing each other's routes, and the 503 a server booted without
|
|
# HOSTS_INGEST_TOKEN gives on the ingestion path.
|
|
#
|
|
# Usage :
|
|
# bash test-curl.sh
|
|
# TEST_CURL_PORT=<port> TEST_CURL_NOINGEST_PORT=<port> bash test-curl.sh
|
|
#
|
|
# The two servers listen on 3099 and 3098 unless these variables name other
|
|
# ports. The URLs follow from the ports and are not read from the environment.
|
|
#
|
|
# Exit 0 if all cases pass, exit 1 otherwise.
|
|
|
|
set -euo pipefail
|
|
|
|
# Every curl below targets this machine's loopback, and a proxy from the
|
|
# environment (http_proxy, ALL_PROXY) would answer in its place: the wait loops
|
|
# and the cases would talk to the proxy instead of the servers started here.
|
|
# Both spellings, since curl reads the lowercase one first and an inherited
|
|
# no_proxy would win. Same line as scripts/boot-from-copy.sh.
|
|
export NO_PROXY='*' no_proxy='*'
|
|
# Several cases compare what `node -e 'console.log(...)'` prints. Under
|
|
# FORCE_COLOR node colours a number even into a pipe, and "3" stops matching.
|
|
unset FORCE_COLOR
|
|
|
|
# The ports the two servers listen on. Names of their own rather than PORT: a
|
|
# PORT inherited from the shell would move the servers without a word.
|
|
MAIN_PORT="${TEST_CURL_PORT:-3099}"
|
|
# Second instance, booted WITHOUT HOSTS_INGEST_TOKEN, for the fail-closed case.
|
|
NOINGEST_PORT="${TEST_CURL_NOINGEST_PORT:-3098}"
|
|
|
|
# Refused here, with the cause named. Port 0 would make node listen on a port
|
|
# of its own choosing, and two identical ports pass require_free: both would
|
|
# only end on a wait_for message that says nothing of the value given.
|
|
require_port() {
|
|
if ! [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || [ "$2" -gt 65535 ]; then
|
|
echo "test-curl.sh: $1 must be a port number (1-65535), got '$2'" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
require_port TEST_CURL_PORT "$MAIN_PORT"
|
|
require_port TEST_CURL_NOINGEST_PORT "$NOINGEST_PORT"
|
|
if [ "$MAIN_PORT" = "$NOINGEST_PORT" ]; then
|
|
echo "test-curl.sh: TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ (both $MAIN_PORT)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Derived, never read from the environment: a URL overridden on its own would
|
|
# send every curl below somewhere else than the servers started here.
|
|
BASE_URL="http://localhost:$MAIN_PORT"
|
|
NOINGEST_URL="http://localhost:$NOINGEST_PORT"
|
|
# Fixed, like the URLs: the script starts its own servers, so nothing outside
|
|
# has a say in the tokens they expect.
|
|
TOKEN="test-token-123"
|
|
# Deliberately different from TOKEN — every compartmentalisation case below is
|
|
# only meaningful because the two values cannot be confused. Read from the
|
|
# environment, two inherited values that happened to be equal would break them.
|
|
INGEST_TOKEN="test-ingest-token-456"
|
|
# Third token, for POST /claude-usage (#48), fixed for the same reason.
|
|
CLAUDE_TOKEN="test-claude-token-789"
|
|
# Fourth token, for POST /backups/pull (#58), fixed for the same reason.
|
|
BACKUPS_TOKEN="test-backups-token-012"
|
|
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
|
|
SERVER_PID=""
|
|
NOINGEST_PID=""
|
|
# Each kill carries its own `|| true`: under `set -e` a kill that fails (the
|
|
# first server already dead) would end the trap there and leave the second
|
|
# server running on its port. The final `wait` holds the exit until both are
|
|
# gone: a run started right after this one must find the two ports free.
|
|
trap 'rm -rf "$TMP_DIR"; [ -n "$SERVER_PID" ] && { kill "$SERVER_PID" 2>/dev/null || true; }; [ -n "$NOINGEST_PID" ] && { kill "$NOINGEST_PID" 2>/dev/null || true; }; wait 2>/dev/null || true' EXIT
|
|
|
|
# Both ports must be free before anything starts. With a listener already on
|
|
# one of them, the server started here dies of EADDRINUSE, but only after the
|
|
# wait loop has had time to reach the neighbour: the cases would then run
|
|
# against a server this script never started. Same rule as
|
|
# scripts/boot-from-copy.sh: free means "connection refused" (curl exit 7); an
|
|
# HTTP answer (0), a listener that stalls (28) or a non-HTTP service (52, 56)
|
|
# all mean taken.
|
|
require_free() {
|
|
local rc=0
|
|
curl -s -o /dev/null --max-time 2 "$1/" || rc=$?
|
|
if [ "$rc" != "7" ]; then
|
|
echo "test-curl.sh: $1 is already taken (curl exit $rc)" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
require_free "$BASE_URL"
|
|
require_free "$NOINGEST_URL"
|
|
|
|
# Fixtures :
|
|
# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus)
|
|
# - 1 defenseur-auto run report on 2026-05-07 (must be filtered out)
|
|
# - 1 booking scan report on 2026-05-06 (must be excluded by date filter)
|
|
# - 1 archived scan report on 2026-05-04 (sergent rotated it post-07:30 UTC)
|
|
# - 1 archived scan report on 2026-05-07 used to assert top-level priority
|
|
# when the same filename also exists in REPORTS_DIR (defensive dedupe).
|
|
mkdir -p "$TMP_DIR/reports"
|
|
mkdir -p "$TMP_DIR/reports/archive"
|
|
|
|
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-07T05-30-11-249Z.json" <<'JSON'
|
|
{
|
|
"agent": "defenseur-booking",
|
|
"timestamp": "2026-05-07T05:30:11.249Z",
|
|
"project": "la-suite-booking",
|
|
"checksRun": 16,
|
|
"checksPassed": 14,
|
|
"findings": []
|
|
}
|
|
JSON
|
|
|
|
cat > "$TMP_DIR/reports/defenseur-simpl-liste_2026-05-07T05-32-04-512Z.json" <<'JSON'
|
|
{
|
|
"agent": "defenseur-simpl-liste",
|
|
"timestamp": "2026-05-07T05:32:04.512Z",
|
|
"project": "simpl-liste",
|
|
"checksRun": 12,
|
|
"checksPassed": 11,
|
|
"findings": []
|
|
}
|
|
JSON
|
|
|
|
cat > "$TMP_DIR/reports/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON'
|
|
{
|
|
"agent": "defenseur-maximus",
|
|
"timestamp": "2026-05-07T05:00:12.100Z",
|
|
"project": "la-compagnie-maximus",
|
|
"checksRun": 8,
|
|
"checksPassed": 8,
|
|
"findings": []
|
|
}
|
|
JSON
|
|
|
|
cat > "$TMP_DIR/reports/defenseur-auto_2026-05-07.json" <<'JSON'
|
|
{
|
|
"agent": "defenseur-auto",
|
|
"timestamp": "2026-05-07T07:00:00.000Z",
|
|
"status": "ok",
|
|
"actions": [],
|
|
"skipped": [],
|
|
"totalCostUsd": 0
|
|
}
|
|
JSON
|
|
|
|
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-06T05-30-00-000Z.json" <<'JSON'
|
|
{
|
|
"agent": "defenseur-booking",
|
|
"timestamp": "2026-05-06T05:30:00.000Z",
|
|
"project": "la-suite-booking",
|
|
"checksRun": 16,
|
|
"checksPassed": 16,
|
|
"findings": []
|
|
}
|
|
JSON
|
|
|
|
# Archived scan report (sergent renameSync at 07:30 UTC moves files here).
|
|
cat > "$TMP_DIR/reports/archive/defenseur-vps_2026-05-04T05-15-00-000Z.json" <<'JSON'
|
|
{
|
|
"agent": "defenseur-vps",
|
|
"timestamp": "2026-05-04T05:15:00.000Z",
|
|
"project": "vps",
|
|
"checksRun": 10,
|
|
"checksPassed": 10,
|
|
"findings": []
|
|
}
|
|
JSON
|
|
|
|
# Same filename present at top-level (already created above) AND in archive/.
|
|
# Top-level wins (more recent — the archive copy is the stale one). The
|
|
# archive copy carries agent="defenseur-maximus-STALE" so the dedupe
|
|
# regression case can detect a leak.
|
|
cat > "$TMP_DIR/reports/archive/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON'
|
|
{
|
|
"agent": "defenseur-maximus-STALE",
|
|
"timestamp": "2026-05-07T05:00:12.100Z",
|
|
"project": "la-compagnie-maximus",
|
|
"checksRun": 1,
|
|
"checksPassed": 0,
|
|
"findings": []
|
|
}
|
|
JSON
|
|
|
|
# HOSTS_DIR is created by the server on first write; the parent must exist and
|
|
# be writable — the same requirement the /data/hosts mount carries in prod.
|
|
mkdir -p "$TMP_DIR/hosts"
|
|
|
|
# Sergent findings state, served verbatim by GET /defenseurs/state. The spacing
|
|
# is deliberately non-canonical: a server that parsed and re-serialised the file
|
|
# would not reproduce it byte for byte.
|
|
printf '%s\n' '{ "schemaVersion":1, "findings": {"a-deps-1": {"firstSeen":"2026-09-01T00:00:00.000Z"}},"history":[] }' \
|
|
> "$TMP_DIR/findings-state.json"
|
|
|
|
# Boot the server with the temp REPORTS_DIR and HOSTS_DIR.
|
|
PORT="$MAIN_PORT" \
|
|
HEALTH_TOKEN="$TOKEN" \
|
|
REPORTS_DIR="$TMP_DIR/reports" \
|
|
HOSTS_DIR="$TMP_DIR/hosts" \
|
|
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/findings-state.json" \
|
|
HOSTS_ALLOWED_IDS="thinkpad,workbench" \
|
|
HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \
|
|
CLAUDE_USAGE_PATH="$TMP_DIR/claude-usage/claude-usage.json" \
|
|
CLAUDE_USAGE_INGEST_TOKEN="$CLAUDE_TOKEN" \
|
|
BACKUPS_STATUS_PATH="$TMP_DIR/backups-status/status.json" \
|
|
BACKUPS_PULL_PATH="$TMP_DIR/backups-pull/pull.json" \
|
|
BACKUPS_INGEST_TOKEN="$BACKUPS_TOKEN" \
|
|
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
|
|
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
|
|
SERVER_PID=$!
|
|
|
|
# Second instance with HOSTS_INGEST_TOKEN unset, to observe the fail-closed 503
|
|
# on the ingestion path. Its HOSTS_DIR is separate so a stray write cannot
|
|
# pollute the fixtures of the main instance.
|
|
mkdir -p "$TMP_DIR/hosts-noingest"
|
|
PORT="$NOINGEST_PORT" \
|
|
HEALTH_TOKEN="$TOKEN" \
|
|
REPORTS_DIR="$TMP_DIR/reports" \
|
|
HOSTS_DIR="$TMP_DIR/hosts-noingest" \
|
|
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/absent-findings-state.json" \
|
|
HOSTS_ALLOWED_IDS="thinkpad" \
|
|
BACKUPS_STATUS_PATH="$TMP_DIR/backups-noingest/status.json" \
|
|
BACKUPS_PULL_PATH="$TMP_DIR/backups-noingest/pull.json" \
|
|
BACKUPS_INGEST_TOKEN= \
|
|
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
|
|
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
|
|
NOINGEST_PID=$!
|
|
|
|
# Wait for both servers to be ready. A server that died or never answers stops
|
|
# the run here, with a message: without it the script went on to the first bare
|
|
# curl, which `set -e` turned into an exit with no output at all. The deadline
|
|
# runs on the clock, not on a number of tries: a try can last up to a second
|
|
# against a listener that accepts and stalls.
|
|
#
|
|
# The process is checked on both sides of the probe. require_free above is what
|
|
# keeps a neighbour out; the second check only narrows what is left, a listener
|
|
# that takes the port between require_free and the server's own listen.
|
|
wait_for() {
|
|
local deadline=$((SECONDS + 5))
|
|
while [ "$SECONDS" -lt "$deadline" ]; do
|
|
if ! kill -0 "$2" 2>/dev/null; then
|
|
echo "test-curl.sh: the server for $1 exited before answering" >&2
|
|
exit 1
|
|
fi
|
|
if curl -s -o /dev/null --max-time 1 "$1/health" 2>/dev/null; then
|
|
kill -0 "$2" 2>/dev/null && return 0
|
|
echo "test-curl.sh: $1 answered, but the server started for it is gone" >&2
|
|
exit 1
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
echo "test-curl.sh: no server answered at $1 within 5 s" >&2
|
|
exit 1
|
|
}
|
|
wait_for "$BASE_URL" "$SERVER_PID"
|
|
wait_for "$NOINGEST_URL" "$NOINGEST_PID"
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
fail() {
|
|
echo "FAIL: $1"
|
|
FAIL=$((FAIL+1))
|
|
}
|
|
pass() {
|
|
echo "PASS: $1"
|
|
PASS=$((PASS+1))
|
|
}
|
|
|
|
# `set -e` is on and the 413 case ends with the server calling req.destroy()
|
|
# once the response is flushed — curl can therefore read the status line and
|
|
# still exit non-zero on the reset that follows. Wrapping every probe keeps a
|
|
# transport-level hiccup a FAIL line instead of a silent early exit; a curl
|
|
# that truly failed reports "000", which no case accepts.
|
|
http_code() {
|
|
curl -s -o /dev/null -w '%{http_code}' "$@" || true
|
|
}
|
|
http_body() {
|
|
curl -s "$@" || true
|
|
}
|
|
|
|
# Case 1 : no auth -> 401
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/reports/scans?date=2026-05-07")
|
|
[[ "$code" == "401" ]] && pass "no-auth -> 401" || fail "no-auth -> got $code"
|
|
|
|
# Case 2 : wrong token -> 401
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
-H "Authorization: Bearer wrong" "$BASE_URL/reports/scans?date=2026-05-07")
|
|
[[ "$code" == "401" ]] && pass "wrong-token -> 401" || fail "wrong-token -> got $code"
|
|
|
|
# Case 3 : valid token + date 2026-05-07 -> 200, count=3, sorted asc, no auto report
|
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
agents=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports.map(r=>r.agent).join(","));});')
|
|
[[ "$count" == "3" ]] && pass "valid date 2026-05-07 -> count=3" || fail "valid date 2026-05-07 -> count=$count"
|
|
[[ "$agents" == "defenseur-maximus,defenseur-booking,defenseur-simpl-liste" ]] \
|
|
&& pass "sort by timestamp asc + auto filtered" \
|
|
|| fail "sort/filter mismatch -> $agents"
|
|
|
|
# Case 4 : invalid date format -> 400
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans?date=hello")
|
|
[[ "$code" == "400" ]] && pass "invalid date -> 400" || fail "invalid date -> got $code"
|
|
|
|
# Case 5 : missing date param -> 400
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans")
|
|
[[ "$code" == "400" ]] && pass "missing date -> 400" || fail "missing date -> got $code"
|
|
|
|
# Case 6 : valid token + unknown date (no fixtures) -> 200 count=0
|
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2025-01-01")
|
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
[[ "$count" == "0" ]] && pass "unknown date -> count=0" || fail "unknown date -> count=$count"
|
|
|
|
# Case 7 : valid token + date 2026-05-06 -> 200 count=1
|
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2026-05-06")
|
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
[[ "$count" == "1" ]] && pass "date 2026-05-06 -> count=1" || fail "date 2026-05-06 -> count=$count"
|
|
|
|
# Case 8 : path traversal -> 400 (regex blocks)
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=../../../etc/passwd")
|
|
[[ "$code" == "400" ]] && pass "path traversal -> 400" || fail "path traversal -> got $code"
|
|
|
|
# Case 9 : POST -> 404 (GET-only)
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
[[ "$code" == "404" ]] && pass "POST -> 404" || fail "POST -> got $code"
|
|
|
|
# Case 10 : wrong path -> 404
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/nope")
|
|
[[ "$code" == "404" ]] && pass "wrong path -> 404" || fail "wrong path -> got $code"
|
|
|
|
# Case 11 : archive-only date -> 200 count=1, returns the archived report.
|
|
# Reproduces the post-07:30 UTC window (sergent rotated all reports out of
|
|
# REPORTS_DIR into REPORTS_DIR/archive).
|
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2026-05-04")
|
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
agent=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports[0]?.agent||"");});')
|
|
[[ "$count" == "1" ]] && pass "archive-only date 2026-05-04 -> count=1" || fail "archive-only date 2026-05-04 -> count=$count"
|
|
[[ "$agent" == "defenseur-vps" ]] && pass "archive report agent matches" || fail "archive report agent mismatch -> $agent"
|
|
|
|
# Case 12 : top-level + archive same filename -> top-level wins (defensive
|
|
# dedupe). The archive copy carries agent="defenseur-maximus-STALE" — if we
|
|
# see that string in the response we picked the wrong copy.
|
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
stale=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s).reports.map(r=>r.agent);console.log(a.includes("defenseur-maximus-STALE")?"yes":"no");});')
|
|
[[ "$stale" == "no" ]] && pass "top-level priority over archive (no STALE)" || fail "archive copy leaked -> reports include STALE"
|
|
# Also assert count is still 3 — no duplication of the maximus report.
|
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
[[ "$count" == "3" ]] && pass "no dedupe duplication on 2026-05-07" || fail "dedupe duplication -> count=$count"
|
|
|
|
# Case 13 : missing archive/ subdir is OK (silent skip). Remove the directory
|
|
# and re-query 2026-05-07 — should still return the 3 top-level reports.
|
|
rm -rf "$TMP_DIR/reports/archive"
|
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
[[ "$count" == "3" ]] && pass "missing archive/ -> still count=3 from top-level" || fail "missing archive/ -> count=$count"
|
|
# And the archive-only date now collapses to 0 silently.
|
|
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
"$BASE_URL/reports/scans?date=2026-05-04")
|
|
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
[[ "$count" == "0" ]] && pass "missing archive/ + archive-only date -> count=0" || fail "missing archive/ archive-only -> count=$count"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workstation snapshots — POST /hosts/<id> and GET /hosts (issue #16)
|
|
#
|
|
# Allowlist for this run is "thinkpad,workbench"; `laptop` is the well-formed
|
|
# id that is deliberately NOT on it, and `ThinkPad` the malformed one.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Fixtures for the ingestion cases.
|
|
cat > "$TMP_DIR/snapshot.json" <<'JSON'
|
|
{
|
|
"hostname": "thinkpad-x1",
|
|
"uptime": 123456,
|
|
"cpu": { "model": "Intel Core i7", "cores": 8, "loadAvg": [0.4, 0.5, 0.6], "usagePercent": 12.5 },
|
|
"memory": { "totalGB": 16, "usedGB": 7.5, "freeGB": 8.5, "usagePercent": 46.9 },
|
|
"disk": { "totalGB": 512, "usedGB": 210, "freeGB": 302, "usagePercent": 41 }
|
|
}
|
|
JSON
|
|
|
|
# Same shape, plus an unknown key and a __proto__ that must never reach the
|
|
# persisted file (which GET /hosts feeds straight into the admin's React tree),
|
|
# and a hostname well past the 128-character cap.
|
|
node -e '
|
|
const payload = {
|
|
hostname: "h".repeat(300),
|
|
uptime: 42,
|
|
cpu: { model: "M", cores: 4, loadAvg: [0, 0, 0], usagePercent: 1 },
|
|
memory: { totalGB: 8, usedGB: 4, freeGB: 4, usagePercent: 50 },
|
|
disk: { totalGB: 100, usedGB: 50, freeGB: 50, usagePercent: 50 },
|
|
injected: "should not survive",
|
|
};
|
|
// Spliced in as text on purpose: written as `__proto__:` in an object literal
|
|
// it would SET the prototype and JSON.stringify would emit nothing, leaving
|
|
// this case asserting against a payload that never carried the key.
|
|
const hostile = "{\"__proto__\":{\"polluted\":true}," + JSON.stringify(payload).slice(1);
|
|
require("fs").writeFileSync(process.argv[1], hostile);
|
|
' "$TMP_DIR/hostile.json"
|
|
|
|
# Body over the 4 KiB ceiling — padding lives in a legitimate field so the
|
|
# request is rejected on size alone, not on shape.
|
|
node -e '
|
|
const payload = {
|
|
hostname: "x".repeat(5000),
|
|
uptime: 1,
|
|
cpu: { model: "M", cores: 1, loadAvg: [0, 0, 0], usagePercent: 0 },
|
|
memory: { totalGB: 1, usedGB: 0, freeGB: 1, usagePercent: 0 },
|
|
disk: { totalGB: 1, usedGB: 0, freeGB: 1, usagePercent: 0 },
|
|
};
|
|
require("fs").writeFileSync(process.argv[1], JSON.stringify(payload));
|
|
' "$TMP_DIR/oversize.json"
|
|
|
|
# Case 14 : GET /hosts without auth -> 401
|
|
code=$(http_code "$BASE_URL/hosts")
|
|
[[ "$code" == "401" ]] && pass "GET /hosts no-auth -> 401" || fail "GET /hosts no-auth -> got $code"
|
|
|
|
# Case 15 : GET /hosts with a wrong token -> 401
|
|
code=$(http_code -H "Authorization: Bearer wrong" "$BASE_URL/hosts")
|
|
[[ "$code" == "401" ]] && pass "GET /hosts wrong-token -> 401" || fail "GET /hosts wrong-token -> got $code"
|
|
|
|
# Case 16 : the ingest token must NOT open a read route.
|
|
code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/hosts")
|
|
[[ "$code" == "401" ]] && pass "GET /hosts with ingest token -> 401" || fail "GET /hosts with ingest token -> got $code"
|
|
|
|
# Case 17 : before any push, every allowlisted id is present and neverSeen.
|
|
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
|
|
ids=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.map(h=>h.id).join(","));});')
|
|
never=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.every(h=>h.neverSeen===true&&h.online===false)?"yes":"no");});')
|
|
stale=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).staleAfterSeconds);});')
|
|
[[ "$ids" == "thinkpad,workbench" ]] && pass "GET /hosts lists the allowlist sorted" || fail "GET /hosts ids -> $ids"
|
|
[[ "$never" == "yes" ]] && pass "unpushed hosts degrade to neverSeen + offline" || fail "neverSeen degradation -> $never"
|
|
[[ "$stale" == "900" ]] && pass "staleAfterSeconds defaults to 900" || fail "staleAfterSeconds -> $stale"
|
|
|
|
# Case 18 : POST /hosts/<id> without auth -> 401
|
|
code=$(http_code -X POST -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
|
|
[[ "$code" == "401" ]] && pass "POST /hosts/thinkpad no-auth -> 401" || fail "POST /hosts/thinkpad no-auth -> got $code"
|
|
|
|
# Case 19 : the read token must NOT open the ingestion route.
|
|
code=$(http_code -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
|
|
[[ "$code" == "401" ]] && pass "POST /hosts/thinkpad with read token -> 401" || fail "POST with read token -> got $code"
|
|
|
|
# Case 20 : 401 comes before 403 — an unauthenticated caller cannot probe the
|
|
# allowlist by watching the status code change.
|
|
code=$(http_code -X POST -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/laptop")
|
|
[[ "$code" == "401" ]] && pass "POST unknown id no-auth -> 401 (not 403)" || fail "POST unknown id no-auth -> got $code"
|
|
|
|
# Case 21 : well-formed id, valid token, not on the allowlist -> 403
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/laptop")
|
|
[[ "$code" == "403" ]] && pass "POST /hosts/laptop authenticated -> 403" || fail "POST /hosts/laptop -> got $code"
|
|
|
|
# Case 22 : malformed id never reaches the handler — the route regex is the
|
|
# first control, so it answers 404 rather than 403.
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/ThinkPad")
|
|
[[ "$code" == "404" ]] && pass "POST malformed id -> 404" || fail "POST malformed id -> got $code"
|
|
|
|
# Case 23 : traversal attempt collapses to a path the router does not know.
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/../../etc/passwd")
|
|
[[ "$code" == "404" ]] && pass "POST traversal -> 404" || fail "POST traversal -> got $code"
|
|
|
|
# Case 24 : the ingestion path is POST-only.
|
|
code=$(http_code -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts/thinkpad")
|
|
[[ "$code" == "404" ]] && pass "GET /hosts/thinkpad -> 404 (POST-only)" || fail "GET /hosts/thinkpad -> got $code"
|
|
|
|
# Case 25 : unreadable JSON -> 400
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
-d 'not json at all' "$BASE_URL/hosts/thinkpad")
|
|
[[ "$code" == "400" ]] && pass "POST unreadable JSON -> 400" || fail "POST unreadable JSON -> got $code"
|
|
|
|
# Case 26 : valid JSON, missing field -> 400
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
-d '{"hostname":"x"}' "$BASE_URL/hosts/thinkpad")
|
|
[[ "$code" == "400" ]] && pass "POST missing field -> 400" || fail "POST missing field -> got $code"
|
|
|
|
# Case 27 : body over 4 KiB -> 413. The server answers, THEN destroys the
|
|
# socket, so curl can exit 55/56 on the reset after having read the status line;
|
|
# http_code absorbs that. `-H "Expect:"` suppresses the 100-continue handshake
|
|
# curl adds on larger uploads (it changes WHEN the body is sent, and with it
|
|
# which side notices the reset first), and --max-time keeps a half-closed
|
|
# socket from hanging the run. A curl that gets nothing at all reports 000,
|
|
# which fails the case rather than passing it silently.
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
-H "Expect:" --http1.1 --max-time 10 \
|
|
--data-binary @"$TMP_DIR/oversize.json" "$BASE_URL/hosts/thinkpad")
|
|
[[ "$code" == "413" ]] && pass "POST oversize body -> 413" || fail "POST oversize body -> got $code"
|
|
|
|
# Case 28 : the happy path -> 204 with no body.
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
|
|
[[ "$code" == "204" ]] && pass "POST valid snapshot -> 204" || fail "POST valid snapshot -> got $code"
|
|
|
|
# Case 29 : the snapshot is now readable, fresh, and stamped by the server.
|
|
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
|
|
host=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const h=JSON.parse(s).hosts.find(x=>x.id==="thinkpad");console.log([h.hostname,h.online,h.ageSeconds<60,h.neverSeen===undefined].join("|"));});')
|
|
[[ "$host" == "thinkpad-x1|true|true|true" ]] \
|
|
&& pass "GET /hosts reflects the push (online, fresh, no neverSeen)" \
|
|
|| fail "GET /hosts after push -> $host"
|
|
# The other allowlisted id is untouched — one file per host, no cross-talk.
|
|
other=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.find(x=>x.id==="workbench").neverSeen===true?"yes":"no");});')
|
|
[[ "$other" == "yes" ]] && pass "a push for one id leaves the others neverSeen" || fail "cross-talk between ids -> $other"
|
|
|
|
# Case 30 : hostile payload is rebuilt from the whitelist — the unknown key and
|
|
# the __proto__ are dropped, the long hostname is cut to 128 characters.
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/hostile.json" "$BASE_URL/hosts/workbench")
|
|
[[ "$code" == "204" ]] && pass "POST hostile payload -> 204 (accepted, sanitised)" || fail "POST hostile payload -> got $code"
|
|
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
|
|
clean=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const h=JSON.parse(s).hosts.find(x=>x.id==="workbench");const own=Object.keys(h);console.log([h.hostname.length,own.includes("injected"),own.includes("polluted")].join("|"));});')
|
|
[[ "$clean" == "128|false|false" ]] \
|
|
&& pass "hostile payload sanitised (128-char cap, unknown keys dropped)" \
|
|
|| fail "sanitisation -> $clean"
|
|
# And on disk: the persisted file carries the whitelist and nothing else.
|
|
keys=$(node -e 'const r=require("fs").readFileSync(process.argv[1],"utf8");console.log(Object.keys(JSON.parse(r)).sort().join(","));' "$TMP_DIR/hosts/workbench.json")
|
|
[[ "$keys" == "cpu,disk,hostname,id,memory,receivedAt,uptime" ]] \
|
|
&& pass "persisted file holds the whitelist only" \
|
|
|| fail "persisted keys -> $keys"
|
|
|
|
# Case 31 : fail-closed. On an instance booted without HOSTS_INGEST_TOKEN the
|
|
# ingestion path answers 503 — never 204, never a silently dropped snapshot.
|
|
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/snapshot.json" "$NOINGEST_URL/hosts/thinkpad")
|
|
[[ "$code" == "503" ]] && pass "no HOSTS_INGEST_TOKEN -> POST answers 503" || fail "fail-closed ingest -> got $code"
|
|
# Reads keep working on that instance: the two tokens are independent.
|
|
code=$(http_code -H "Authorization: Bearer $TOKEN" "$NOINGEST_URL/hosts")
|
|
[[ "$code" == "200" ]] && pass "no HOSTS_INGEST_TOKEN -> GET /hosts still 200" || fail "read path on fail-closed instance -> got $code"
|
|
|
|
# Case 32 : GET /defenseurs/state serves findings-state.json byte for byte.
|
|
# Compared through files so the trailing newline is part of the comparison
|
|
# (command substitution would strip it).
|
|
http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/defenseurs/state" > "$TMP_DIR/state-served.json"
|
|
cmp -s "$TMP_DIR/state-served.json" "$TMP_DIR/findings-state.json" \
|
|
&& pass "GET /defenseurs/state -> file served byte for byte" \
|
|
|| fail "GET /defenseurs/state -> body differs from the file"
|
|
# Absent file -> 200 no_data, the same contract as /defenseurs.
|
|
body=$(http_body -H "Authorization: Bearer $TOKEN" "$NOINGEST_URL/defenseurs/state")
|
|
[[ "$body" == '{"status":"no_data"}' ]] \
|
|
&& pass "GET /defenseurs/state, file absent -> no_data" \
|
|
|| fail "GET /defenseurs/state absent -> $body"
|
|
code=$(http_code "$BASE_URL/defenseurs/state")
|
|
[[ "$code" == "401" ]] && pass "GET /defenseurs/state no-auth -> 401" || fail "GET /defenseurs/state no-auth -> got $code"
|
|
# A read route: the ingest token must not open it.
|
|
code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/defenseurs/state")
|
|
[[ "$code" == "401" ]] && pass "GET /defenseurs/state with ingest token -> 401" || fail "GET /defenseurs/state ingest token -> got $code"
|
|
|
|
# Case 33 : GET /claude-usage/history (#55), the archive of the closed days.
|
|
code=$(http_code "$BASE_URL/claude-usage/history")
|
|
[[ "$code" == "401" ]] && pass "GET /claude-usage/history no-auth -> 401" || fail "GET /claude-usage/history no-auth -> got $code"
|
|
code=$(http_code -H "Authorization: Bearer $CLAUDE_TOKEN" "$BASE_URL/claude-usage/history")
|
|
[[ "$code" == "401" ]] && pass "GET /claude-usage/history with the claude ingest token -> 401" || fail "history with ingest token -> got $code"
|
|
code=$(http_code -H "Authorization: Bearer $TOKEN" "$BASE_URL/claude-usage/history?from=2026-13")
|
|
[[ "$code" == "400" ]] && pass "GET /claude-usage/history?from=2026-13 -> 400" || fail "history bad from -> got $code"
|
|
# A two-day snapshot: the first day is closed and lands in history/2026-09.json.
|
|
printf '%s' '{"schemaVersion":1,"days":[{"date":"2026-09-29","models":{"claude-opus-5-5":{"input":1,"output":2,"requests":1}}},{"date":"2026-09-30","models":{}}]}' \
|
|
> "$TMP_DIR/claude-usage.json"
|
|
code=$(http_code -X POST -H "Authorization: Bearer $CLAUDE_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @"$TMP_DIR/claude-usage.json" "$BASE_URL/claude-usage")
|
|
[[ "$code" == "204" ]] && pass "POST /claude-usage two-day snapshot -> 204" || fail "POST /claude-usage -> got $code"
|
|
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/claude-usage/history?from=2026-09&to=2026-09")
|
|
dates=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).days.map(d=>d.date).join(","));});')
|
|
[[ "$dates" == "2026-09-29" ]] && pass "GET /claude-usage/history serves the closed day only" || fail "history after push -> $dates"
|
|
mode=$(node -e 'console.log((require("fs").statSync(process.argv[1]).mode & 0o777).toString(8))' "$TMP_DIR/claude-usage/history/2026-09.json")
|
|
[[ "$mode" == "600" ]] && pass "history month file written in mode 600" || fail "history file mode -> $mode"
|
|
|
|
# Case 34 : the backups routes (#58). Fail-closed 503 on the instance without
|
|
# BACKUPS_INGEST_TOKEN (set empty there, so an exported one cannot leak in).
|
|
pull='{"pulledAt":"2026-10-11T04:10:00Z","files":9,"ok":true}'
|
|
code=$(printf '%s' "$pull" | http_code -X POST -H "Authorization: Bearer $BACKUPS_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @- "$NOINGEST_URL/backups/pull")
|
|
[[ "$code" == "503" ]] && pass "no BACKUPS_INGEST_TOKEN -> POST /backups/pull answers 503" || fail "fail-closed backups ingest -> got $code"
|
|
code=$(http_code "$BASE_URL/backups")
|
|
[[ "$code" == "401" ]] && pass "GET /backups no-auth -> 401" || fail "GET /backups no-auth -> got $code"
|
|
# The tokens refuse each other's routes: the Claude token on the backups write.
|
|
code=$(printf '%s' "$pull" | http_code -X POST -H "Authorization: Bearer $CLAUDE_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @- "$BASE_URL/backups/pull")
|
|
[[ "$code" == "401" ]] && pass "POST /backups/pull with the claude ingest token -> 401" || fail "backups pull with claude token -> got $code"
|
|
# Past 1 KiB: the 413 is rendered before the connection is cut.
|
|
code=$(head -c 1025 /dev/zero | tr '\0' 'x' | http_code -X POST -H "Authorization: Bearer $BACKUPS_TOKEN" \
|
|
-H "Content-Type: application/json" --data-binary @- "$BASE_URL/backups/pull")
|
|
[[ "$code" == "413" ]] && pass "POST /backups/pull 1025 bytes -> 413" || fail "backups pull oversized -> got $code"
|
|
code=$(printf '%s' "$pull" | http_code -X POST -H "Authorization: Bearer $BACKUPS_TOKEN" -H "Content-Type: application/json" \
|
|
--data-binary @- "$BASE_URL/backups/pull")
|
|
[[ "$code" == "204" ]] && pass "POST /backups/pull -> 204" || fail "POST /backups/pull -> got $code"
|
|
# Read back: no status.json on this instance, so vps is null and lenovo holds
|
|
# the report, aged by the server.
|
|
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/backups")
|
|
seen=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const b=JSON.parse(s);console.log([b.vps,b.lenovo.files,b.lenovo.stale,typeof b.lenovo.receivedAt].join("|"));});')
|
|
[[ "$seen" == "|9|false|string" ]] && pass "GET /backups serves the pull report with server freshness" || fail "GET /backups after push -> $seen"
|
|
|
|
echo
|
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
[[ "$FAIL" == "0" ]] || exit 1
|