Compare commits

..

1 commit

Author SHA1 Message Date
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
2 changed files with 228 additions and 11 deletions

View file

@ -56,13 +56,14 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests
- `npm test` (vitest) — 244 cas
- `npm test` (vitest) — 251 cas
- `__tests__/findings.test.js``/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence)
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (19 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**)
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (26 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**)
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (155 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture)
- `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**)
- Runtime reste 0-dep ; vitest en devDep uniquement
- `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (41 cas). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu **avant** que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, et le 503 d'une instance demarree sans `HOSTS_INGEST_TOKEN` (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis.
## Gotchas

View file

@ -1,23 +1,34 @@
#!/usr/bin/env bash
# test-curl.sh — manual smoke test for vps-health-api endpoints.
#
# Spins up the server against a temporary REPORTS_DIR populated with
# scan/run-report fixtures, then runs curl against each endpoint and
# checks status codes + payload shape. No test runner installed — this
# script is the authoritative regression suite for the GET /reports/scans
# endpoint until vitest/jest is added.
# 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 on first failure (fail-fast).
# 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)"
trap 'rm -rf "$TMP_DIR"; kill "$SERVER_PID" 2>/dev/null || true' EXIT
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)
@ -111,19 +122,43 @@ cat > "$TMP_DIR/reports/archive/defenseur-maximus_2026-05-07T05-00-12-100Z.json"
}
JSON
# Boot the server with the temp REPORTS_DIR.
# 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=$!
# Wait for the server to be ready.
# 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
@ -136,6 +171,18 @@ pass() {
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"
@ -228,6 +275,175 @@ body=$(curl -s -H "Authorization: Bearer $TOKEN" \
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