test(hosts): extend the socket-level pass to the two workstation routes #26

Open
maximus wants to merge 1 commit from issue-16-hosts-monitoring-commissioning into main
2 changed files with 228 additions and 11 deletions

View file

@ -56,13 +56,14 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests ## 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__/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__/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__/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**) - `__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 - 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 ## Gotchas

View file

@ -1,23 +1,34 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# test-curl.sh — manual smoke test for vps-health-api endpoints. # test-curl.sh — manual smoke test for vps-health-api endpoints.
# #
# Spins up the server against a temporary REPORTS_DIR populated with # Spins up the server against a temporary REPORTS_DIR and HOSTS_DIR populated
# scan/run-report fixtures, then runs curl against each endpoint and # with fixtures, then runs curl against each endpoint and checks status codes +
# checks status codes + payload shape. No test runner installed — this # payload shape.
# script is the authoritative regression suite for the GET /reports/scans #
# endpoint until vitest/jest is added. # 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 : # Usage :
# bash test-curl.sh # 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 set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:3099}" 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}" 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)" 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 : # Fixtures :
# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus) # - 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 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 \ PORT=3099 \
HEALTH_TOKEN="$TOKEN" \ HEALTH_TOKEN="$TOKEN" \
REPORTS_DIR="$TMP_DIR/reports" \ 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" \ LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
node "$(dirname "$0")/index.js" >/dev/null 2>&1 & node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
SERVER_PID=$! 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 for _ in {1..50}; do
if curl -s -o /dev/null "$BASE_URL/health" 2>/dev/null; then break; fi if curl -s -o /dev/null "$BASE_URL/health" 2>/dev/null; then break; fi
sleep 0.1 sleep 0.1
done 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 PASS=0
FAIL=0 FAIL=0
@ -136,6 +171,18 @@ pass() {
PASS=$((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 # Case 1 : no auth -> 401
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/reports/scans?date=2026-05-07") 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" [[ "$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=$(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" [[ "$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
echo "=== Results: $PASS passed, $FAIL failed ===" echo "=== Results: $PASS passed, $FAIL failed ==="
[[ "$FAIL" == "0" ]] || exit 1 [[ "$FAIL" == "0" ]] || exit 1