vps-health-api/test-curl.sh
le king fu 50a829beb3 test(hosts): extend the socket-level pass to the two workstation routes
test-curl.sh predates vitest and still claimed to be the authoritative suite
for one endpoint. It is now the socket-level pass, and its header says so:
vitest covers the logic, this script covers what only a real client and a
real TCP connection can show.

Twenty-four cases for POST /hosts/<id> and GET /hosts, chosen for what they
prove rather than for coverage: the 413 answered before the connection is
cut, the two tokens refusing each other's routes in both directions, 401
landing ahead of 403 so an unauthenticated caller cannot probe the allowlist,
the route regex turning a malformed id and a traversal attempt into 404
before any handler runs, and the ingest-persist-read round trip closing on a
GET that shows the pushed hostname online.

Two cases needed care to be worth anything:

The hostile payload splices "__proto__" into the JSON as text. Written as
__proto__: in an object literal it would set the prototype and JSON.stringify
would emit nothing, leaving the case asserting against a payload that never
carried the key. It now checks the response and the persisted file, whose key
set must be exactly the whitelist.

The 413 case runs under set -e while the server destroys the socket right
after flushing the response, so curl can exit 55/56 having already read the
status line. Every probe goes through an http_code helper that absorbs that;
a curl which truly got nothing reports 000, which fails the case rather than
passing it quietly. -H "Expect:" also suppresses the 100-continue handshake,
which otherwise changes which side notices the reset first.

The fail-closed 503 needs a server booted without HOSTS_INGEST_TOKEN, so the
script now runs a second instance on 3098 for it, and confirms reads still
answer 200 there — the two tokens are independent, including in absence.

The EXIT trap dereferenced an unset SERVER_PID under set -u, which made it
error out instead of cleaning up when anything failed before the boot. Both
PIDs are initialised and the kills guarded.

CLAUDE.md: the vitest count was stale (244 -> 251, auth.test.js 19 -> 26) and
test-curl.sh was undocumented.

Refs #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:18:53 -04:00

449 lines
22 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
#
# Exit 0 if all cases pass, exit 1 otherwise.
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:3099}"
# Second instance, booted WITHOUT HOSTS_INGEST_TOKEN, for the fail-closed case.
NOINGEST_URL="${NOINGEST_URL:-http://localhost:3098}"
TOKEN="${TOKEN:-test-token-123}"
# Deliberately different from TOKEN — every compartmentalisation case below is
# only meaningful because the two values cannot be confused.
INGEST_TOKEN="${INGEST_TOKEN:-test-ingest-token-456}"
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
SERVER_PID=""
NOINGEST_PID=""
trap 'rm -rf "$TMP_DIR"; [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; [ -n "$NOINGEST_PID" ] && kill "$NOINGEST_PID" 2>/dev/null; true' EXIT
# 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"
# Boot the server with the temp REPORTS_DIR and HOSTS_DIR.
PORT=3099 \
HEALTH_TOKEN="$TOKEN" \
REPORTS_DIR="$TMP_DIR/reports" \
HOSTS_DIR="$TMP_DIR/hosts" \
HOSTS_ALLOWED_IDS="thinkpad,workbench" \
HOSTS_INGEST_TOKEN="$INGEST_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=3098 \
HEALTH_TOKEN="$TOKEN" \
REPORTS_DIR="$TMP_DIR/reports" \
HOSTS_DIR="$TMP_DIR/hosts-noingest" \
HOSTS_ALLOWED_IDS="thinkpad" \
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.
for _ in {1..50}; do
if curl -s -o /dev/null "$BASE_URL/health" 2>/dev/null; then break; fi
sleep 0.1
done
for _ in {1..50}; do
if curl -s -o /dev/null "$NOINGEST_URL/health" 2>/dev/null; then break; fi
sleep 0.1
done
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"
echo
echo "=== Results: $PASS passed, $FAIL failed ==="
[[ "$FAIL" == "0" ]] || exit 1