From 096f95da05cd45f7c48a41575b4f7a91da91b3c6 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 1 Oct 2026 20:12:57 -0400 Subject: [PATCH 1/3] fix(test-curl): keep the socket pass off the environment (#41) 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 --- CLAUDE.md | 6 +-- __tests__/ci.test.js | 102 ++++++++++++++++++++++++++++++++++++++++++- test-curl.sh | 43 +++++++++++++----- 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 66b5647..6871d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//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/` 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 diff --git a/__tests__/ci.test.js b/__tests__/ci.test.js index 3ff7b09..6f0f58f 100644 --- a/__tests__/ci.test.js +++ b/__tests__/ci.test.js @@ -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 diff --git a/test-curl.sh b/test-curl.sh index e9c0058..2b6020e 100755 --- a/test-curl.sh +++ b/test-curl.sh @@ -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 - 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 +# 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 + 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 -- 2.45.2 From 193635a68e0fb8297debb9d7aa04adfdc8ea2d7f Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 1 Oct 2026 20:51:42 -0400 Subject: [PATCH 2/3] fix(test-curl): refuse a port already taken before starting (#41) Review of PR #45: wait_for did not keep a neighbour out. With a listener already on 3099, the server started by the script only dies of EADDRINUSE after the wait loop has reached the neighbour, and the cases then ran against it. - require_free checks both ports before anything starts: free means "connection refused" (curl exit 7), as in scripts/boot-from-copy.sh - wait_for runs on a clock deadline, and checks the process again after a successful probe - the EXIT trap waits for the servers it killed, so a run started right after finds the two ports free ci.test.js adds a real neighbour on each port, and bounds the two failed-start cases to 3 s: a message that does not stop the run is now red. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +++--- __tests__/ci.test.js | 38 +++++++++++++++++++++++++++++++++-- test-curl.sh | 48 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6871d1f..706df82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//status`), l'API Actions repondant 404 sur cette instance. -- `npm test` (vitest) — 358 cas +- `npm test` (vitest) — 360 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 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__/ci.test.js` — epingles du workflow et des scripts de CI (30 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, 5 cas : 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 ; un **voisin HTTP reel** sur 3099, puis sur 3098 : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port 3099, puis 3098 : message sur stderr, code 1 en moins de 3 s, 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/` 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. 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. +- `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 ; `require_free`, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que `boot-from-copy.sh`) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son `EADDRINUSE` qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et `wait_for`, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (`kill -0`) avant et apres la sonde. 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) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Non epingles : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule, et le second `kill -0` de `wait_for`, qui ne couvre que la fenetre residuelle entre `require_free` et le `listen` du serveur. ## Gotchas diff --git a/__tests__/ci.test.js b/__tests__/ci.test.js index 6f0f58f..8acc2ba 100644 --- a/__tests__/ci.test.js +++ b/__tests__/ci.test.js @@ -138,13 +138,14 @@ describe("what the boot check assumes of the Dockerfile", () => { // values). Async, so a listener in this process keeps answering meanwhile. function run(command, args, env) { return new Promise((resolve, reject) => { + const started = Date.now(); 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, stdout, stderr })); + child.on("close", (code) => resolve({ code, stdout, stderr, ms: Date.now() - started })); }); } @@ -285,6 +286,36 @@ describe("test-curl.sh", () => { } }, 30000); + // A real neighbour on one of the two ports, as a second `npm test` or a + // stray server would be. Without the check up front the wait loop reaches the + // neighbour before the script's own server has died of EADDRINUSE, and the + // cases run against it (review of PR #45). Nothing must start, on either port. + test.each([ + [3099, "http://localhost:3099"], + [3098, "http://localhost:3098"], + ])("refuses to run when something else holds %i", async (port, url) => { + const asked = []; + const neighbour = http.createServer((req, res) => { + asked.push(req.url); + res.end("neighbour"); + }); + await new Promise((resolve, reject) => neighbour.once("error", reject).listen(port, resolve)); + try { + const { code, stdout, stderr } = await runSocketPass({}); + + expect(stderr).toContain(`test-curl.sh: ${url} is already taken (curl exit 0)`); + expect(code).toBe(1); + expect(stdout).not.toMatch(/PASS|FAIL/); + // The neighbour saw the probe and nothing else: no case reached it. + expect(asked).toEqual(["/"]); + } finally { + await new Promise((resolve) => neighbour.close(resolve)); + } + // The other port was free: no server of the script was left on it. + expect(await released(3099)).toBe(true); + expect(await released(3098)).toBe(true); + }, 20000); + // 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 @@ -300,13 +331,16 @@ describe("test-curl.sh", () => { `#!/bin/sh\n[ "$PORT" = "${port}" ] && exit 1\nexec "${process.execPath}" "$@"\n`, { mode: 0o755 }, ); - const { code, stdout, stderr } = await runSocketPass({ + const { code, stdout, stderr, ms } = 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/); + // At once, not at the end of the 5 s wait: a message that did not stop + // the run would be repeated until the deadline. + expect(ms).toBeLessThan(3000); // The server that did start is not left behind on its port. expect(await released(3099)).toBe(true); expect(await released(3098)).toBe(true); diff --git a/test-curl.sh b/test-curl.sh index 2b6020e..be7c430 100755 --- a/test-curl.sh +++ b/test-curl.sh @@ -40,8 +40,9 @@ 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. -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 +# 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 # Fixtures : # - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus) @@ -50,6 +51,24 @@ trap 'rm -rf "$TMP_DIR"; [ -n "$SERVER_PID" ] && { kill "$SERVER_PID" 2>/dev/nul # - 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). +# 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" + mkdir -p "$TMP_DIR/reports" mkdir -p "$TMP_DIR/reports/archive" @@ -171,21 +190,30 @@ 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, 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 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() { - for _ in {1..50}; do + 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 return 0; 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 after 5 s" >&2 + echo "test-curl.sh: no server answered at $1 within 5 s" >&2 exit 1 } wait_for "$BASE_URL" "$SERVER_PID" -- 2.45.2 From 7427577053add54dbe1c809f9dce4dda20062e2e Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 1 Oct 2026 21:18:25 -0400 Subject: [PATCH 3/3] test(ci): pin the non-HTTP occupant and the wait deadline of test-curl.sh (#41) Second review of PR #45, non-blocking: two behaviours were right by hand and held by no test. - a listener on 3099 that closes at once, then one that accepts and stalls: the port rule is "anything but connection refused", not "an HTTP answer" - a server that stays alive and never listens: the wait ends on its deadline, with a message and exit 1 Also moves the port check above the "Fixtures" comment it had split from its mkdir lines, and counts four protections in CLAUDE.md, not three. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++--- __tests__/ci.test.js | 52 ++++++++++++++++++++++++++++++++++++++++++++ test-curl.sh | 14 ++++++------ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 706df82..ba6aee6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//status`), l'API Actions repondant 404 sur cette instance. -- `npm test` (vitest) — 360 cas +- `npm test` (vitest) — 363 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 et des scripts de CI (30 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, 5 cas : 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 ; un **voisin HTTP reel** sur 3099, puis sur 3098 : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port 3099, puis 3098 : message sur stderr, code 1 en moins de 3 s, 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__/ci.test.js` — epingles du workflow et des scripts de CI (33 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, 8 cas : 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 ; un **voisin HTTP reel** sur 3099, puis sur 3098 : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur 3099, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port 3099, puis 3098 : message sur stderr, code 1 en moins de 3 s, 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/` 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. 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 ; `require_free`, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que `boot-from-copy.sh`) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son `EADDRINUSE` qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et `wait_for`, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (`kill -0`) avant et apres la sonde. 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) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Non epingles : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule, et le second `kill -0` de `wait_for`, qui ne couvre que la fenetre residuelle entre `require_free` et le `listen` du serveur. +- `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. Quatre protections contre l'environnement (#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 ; `require_free`, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que `boot-from-copy.sh`) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son `EADDRINUSE` qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et `wait_for`, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (`kill -0`) avant et apres la sonde. 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) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Non epingles : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule, et le second `kill -0` de `wait_for`, qui ne couvre que la fenetre residuelle entre `require_free` et le `listen` du serveur. ## Gotchas diff --git a/__tests__/ci.test.js b/__tests__/ci.test.js index 8acc2ba..3f7a89d 100644 --- a/__tests__/ci.test.js +++ b/__tests__/ci.test.js @@ -316,6 +316,58 @@ describe("test-curl.sh", () => { expect(await released(3098)).toBe(true); }, 20000); + // The rule is "anything but connection refused", not "an HTTP answer": an + // occupant that speaks no HTTP holds the port just as well. + test.each([ + ["closes at once", (socket) => socket.destroy(), /\(curl exit (52|56)\)/], + ["accepts and stalls", () => {}, /\(curl exit 28\)/], + ])("refuses to run when a listener that %s holds 3099", async (_, onConnection, exitCode) => { + const sockets = new Set(); + const occupant = net.createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + onConnection(socket); + }); + await new Promise((resolve, reject) => occupant.once("error", reject).listen(3099, resolve)); + try { + const { code, stdout, stderr } = await runSocketPass({}); + + expect(stderr).toContain("test-curl.sh: http://localhost:3099 is already taken"); + expect(stderr).toMatch(exitCode); + expect(code).toBe(1); + expect(stdout).not.toMatch(/PASS|FAIL/); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => occupant.close(resolve)); + } + expect(await released(3098)).toBe(true); + }, 20000); + + // A server that stays alive and never listens: only the deadline can end the + // wait. The fake `node` sleeps for the port named; the script's trap kills it. + test("gives up after 5 s on a server that is alive and never answers", async () => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-")); + try { + fs.writeFileSync( + path.join(bin, "node"), + `#!/bin/sh\n[ "$PORT" = "3099" ] && exec sleep 30\nexec "${process.execPath}" "$@"\n`, + { mode: 0o755 }, + ); + const { code, stdout, stderr, ms } = await runSocketPass({ + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + }); + + expect(stderr).toContain("test-curl.sh: no server answered at http://localhost:3099 within 5 s"); + expect(code).toBe(1); + expect(stdout).not.toMatch(/PASS|FAIL/); + expect(ms).toBeGreaterThan(4000); + expect(ms).toBeLessThan(10000); + expect(await released(3098)).toBe(true); + } finally { + fs.rmSync(bin, { recursive: true, force: true }); + } + }, 20000); + // 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 diff --git a/test-curl.sh b/test-curl.sh index be7c430..7d0e1f0 100755 --- a/test-curl.sh +++ b/test-curl.sh @@ -44,13 +44,6 @@ NOINGEST_PID="" # 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 -# 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). # 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 @@ -69,6 +62,13 @@ require_free() { 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" -- 2.45.2