#!/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= TEST_CURL_NOINGEST_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" 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" \ 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" \ 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/ 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/ 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" echo echo "=== Results: $PASS passed, $FAIL failed ===" [[ "$FAIL" == "0" ]] || exit 1