fix(test-curl): keep the socket pass off the environment (#41)
All checks were successful
CI / verification (pull_request) Successful in 20s

Behind a proxy from the environment, test-curl.sh sent its 18 curl lines to
the proxy instead of the servers it boots: with a dead proxy it exited 7
after 11 s without a line of output.

- export NO_PROXY and no_proxy, as scripts/boot-from-copy.sh does
- unset FORCE_COLOR: node colours a number even into a pipe, and 8 cases
  compared "3" against a coloured "3"
- wait_for checks the server process before the port and stops the run with
  a message when a server died or never answered
- the EXIT trap guards each kill: under set -e, a failed kill on the first
  server ended the trap and left the second one running on 3098

ci.test.js runs the whole script three times: behind a fake proxy that
answers everything, and with a fake node that fails for each port in turn.

Fixes #41

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-01 20:12:57 -04:00
parent 5912f08e41
commit 096f95da05
3 changed files with 137 additions and 14 deletions

View file

@ -62,18 +62,18 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests
- **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits/<sha>/status`), l'API Actions repondant 404 sur cette instance.
- `npm test` (vitest) — 355 cas
- `npm test` (vitest) — 358 cas
- `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST)
- `__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 (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur)
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (173 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture, **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`)
- `__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__/ci.test.js` — epingles du workflow (25 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`)
- `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (28 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41, 3 cas, environ 1 s chacun : derriere le meme faux proxy avec `FORCE_COLOR=3`, rien ne l'atteint et les 45 cas passent, le nombre etant ecrit en dur dans le test ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port 3099, puis 3098 : message sur stderr, code 1, aucun serveur laisse sur son port. Ces cas occupent 3099 et 3098 pendant `npm test`, et retirent `BASE_URL` de l'environnement, que vite pose a `/` dans le processus de test), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`)
- `__tests__/writes.test.js` — seule surface d'ecriture : `HOSTS_DIR` (11 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/<id>` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir)
- Runtime reste 0-dep ; vitest en devDep uniquement
- `sh scripts/boot-from-copy.sh` — demarre le `CMD` depuis les seules sources du `COPY`, copiees dans un dossier vide, et exige 401 sans token, 200 avec, et des metriques disque/memoire non nulles. Tient lieu de `docker build` en CI : un vrai build demanderait le socket Docker dans le runner d'instance, donc root sur le VPS de prod pour tout workflow de tout depot. Il n'accepte qu'une ligne `COPY` en forme shell sans option, et un `CMD` en forme exec ; toute autre forme echoue bruyamment plutot que de verifier du vide. Port 3097 par defaut (`BOOT_PORT` pour en changer) ; il refuse de demarrer si le port est deja pris (tout code de sortie curl autre que 7, « connexion refusee »), sans quoi ses assertions pourraient interroger un voisin. Il exporte `NO_PROXY='*'` et `no_proxy='*'` en tete : chaque `curl` vise la boucle locale, et un `http_proxy` de l'environnement repondrait a la place du port comme du serveur demarre. Les deux graphies, car curl lit d'abord la minuscule : un `no_proxy` herite l'emporterait sur un `NO_PROXY` exporte seul. Il n'appelle que `/health` : il ne prouve que les modules charges au demarrage, d'ou l'epingle « `require` en colonne 0 » dans `ci.test.js`.
- `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (45 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, le fichier servi octet pour octet par `/defenseurs/state`, 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.
- `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (45 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, le fichier servi octet pour octet par `/defenseurs/state`, 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. Trois protections contre l'environnement, en tete (#41) : `NO_PROXY='*'` et `no_proxy='*'` exportes, comme dans `boot-from-copy.sh` (sans eux, derriere un proxy mort, le script sortait en code 7 apres 11 s sans une ligne) ; `unset FORCE_COLOR`, car sous cette variable `node -e 'console.log(3)'` colore le nombre meme dans un tube et 8 cas echouent ; et `wait_for`, qui verifie le processus du serveur (`kill -0`) avant le port, donc un serveur mort au demarrage arrete le script tout de suite avec un message, et un voisin qui repond sur le meme port ne passe pas pour lui. Le `trap EXIT` protege chaque `kill` par `|| true` : sous `set -e`, un `kill` en echec sur le premier serveur deja mort terminait le trap et laissait le second tourner sur 3098. Non epingle : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule.
## Gotchas

View file

@ -1,6 +1,8 @@
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const { spawn } = require("node:child_process");
// Pins on the CI workflow and on what the boot check assumes of the Dockerfile.
@ -137,10 +139,12 @@ describe("what the boot check assumes of the Dockerfile", () => {
function run(command, args, env) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd: ROOT, env: { ...process.env, ...env } });
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => (stdout += d));
child.stderr.on("data", (d) => (stderr += d));
child.on("error", reject);
child.on("close", (code) => resolve({ code, stderr }));
child.on("close", (code) => resolve({ code, stdout, stderr }));
});
}
@ -216,6 +220,102 @@ describe("boot-from-copy.sh", () => {
}, 20000);
});
// The socket-level pass boots its own servers on 3099 and 3098, fixed ports:
// these cases fail if something else holds them, and must not overlap (vitest
// runs the tests of a file one after the other). BASE_URL is dropped from the
// environment each time: vite sets it to "/" in the test process, and the
// script reads a variable of that name.
describe("test-curl.sh", () => {
// True once nothing accepts a connection on the port, false after 3 s of
// trying: a server killed by the script's trap takes a moment to let go.
async function released(port) {
for (let i = 0; i < 30; i++) {
const open = await new Promise((resolve) => {
const socket = net.connect(port, "127.0.0.1");
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
if (!open) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
const runSocketPass = (env) => run("bash", ["test-curl.sh"], { BASE_URL: undefined, ...env });
// Same exposure as the boot check, 18 curl lines of it (#41). Before the
// export, a dead proxy made the script exit 7 with no output, and a live one
// would have answered every case in the API's place.
test("never sends a request through a proxy from the environment", async () => {
const proxied = [];
const proxy = http.createServer((req, res) => {
proxied.push(`${req.method} ${req.url}`);
res.end("answered by the proxy");
});
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
try {
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
// Same environment as the boot check's case above, for the same reasons.
// FORCE_COLOR on top: node would colour the numbers the cases compare.
const env = {
http_proxy: proxyUrl,
ALL_PROXY: proxyUrl,
NO_PROXY: undefined,
no_proxy: "example.invalid",
FORCE_COLOR: "3",
};
// The proxy is live: a bare curl with the same environment reaches it.
const port = await freePort();
await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${port}/`], env);
expect(proxied).toEqual([`GET http://127.0.0.1:${port}/`]);
proxied.length = 0;
const { code, stdout, stderr } = await runSocketPass(env);
expect(proxied).toEqual([]);
expect(code, stdout + stderr).toBe(0);
// Every case ran against the API: a count, so a script cut short fails.
expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/);
} finally {
await new Promise((resolve) => proxy.close(resolve));
}
}, 30000);
// A fake `node` first on the PATH stands in for a server that fails to start:
// it exits at once for the port named, and hands over to the real node
// otherwise. The script must name the URL instead of dying silently, for the
// main instance as for the one booted without HOSTS_INGEST_TOKEN.
test.each([
["3099", "http://localhost:3099"],
["3098", "http://localhost:3098"],
])("says so, and exits 1, when its server on %s does not start", async (port, url) => {
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
try {
fs.writeFileSync(
path.join(bin, "node"),
`#!/bin/sh\n[ "$PORT" = "${port}" ] && exit 1\nexec "${process.execPath}" "$@"\n`,
{ mode: 0o755 },
);
const { code, stdout, stderr } = await runSocketPass({
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
});
expect(stderr).toContain(`test-curl.sh: the server for ${url} exited before answering`);
expect(code).toBe(1);
expect(stdout).not.toMatch(/PASS|FAIL/);
// The server that did start is not left behind on its port.
expect(await released(3099)).toBe(true);
expect(await released(3098)).toBe(true);
} finally {
fs.rmSync(bin, { recursive: true, force: true });
}
}, 20000);
});
// The boot check only calls /health, so it proves the existence of the modules
// loaded at startup, nothing more. A require inside a route handler would load
// only when that route runs and escape it. Heuristic stand-in for "loaded at

View file

@ -18,6 +18,16 @@
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
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}"
@ -28,7 +38,10 @@ 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
# 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.
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; }; true' EXIT
# Fixtures :
# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus)
@ -158,15 +171,25 @@ 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
# Wait for both servers to be ready, 5 s each at most. 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 process is checked before the port, so that a neighbour answering
# on the same port cannot stand in for a server that failed to start.
wait_for() {
for _ in {1..50}; 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 return 0; 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
done
echo "test-curl.sh: no server answered at $1 after 5 s" >&2
exit 1
}
wait_for "$BASE_URL" "$SERVER_PID"
wait_for "$NOINGEST_URL" "$NOINGEST_PID"
PASS=0
FAIL=0