diff --git a/CLAUDE.md b/CLAUDE.md index dca0d26..66b5647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - `__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__/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 ; **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) + - `__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. diff --git a/__tests__/writes.test.js b/__tests__/writes.test.js index a72f026..94eca86 100644 --- a/__tests__/writes.test.js +++ b/__tests__/writes.test.js @@ -18,12 +18,21 @@ const { fileURLToPath } = require("node:url"); // // Every node:fs function that can write is spied BEFORE a fresh require, // because the runtime modules destructure their fs functions at load time -// (same technique as the timingSafeEqual spy in auth.test.js). A call is -// recorded when its stack goes through a runtime file of the repo, then passed -// through to the real function. A shell command can write too, out of the fs +// (same technique as the timingSafeEqual spy in auth.test.js). Every call is +// recorded, whoever makes it, then passed through to the real function. No +// attribution by call stack (#40): a function handed as-is to an asynchronous +// mechanism, `process.nextTick(writeFileSync, file, data)` or +// `res.on("finish", writeFileSync.bind(null, file, data))`, runs with no +// runtime frame on its stack, and a stack filter let it through. The price is +// that a write made by the test runner itself while a case runs would fail the +// case: the right way to fail. A shell command can write too, out of the fs // spies' sight, so child_process is spied the same way and the set of commands // is pinned to the two read-only ones metrics.js runs. // +// The test edits its own fixtures in the degraded case. Those edits go through +// `real`, the functions taken before any spy exists, so the spies never have +// to look away. +// // Limits: a path no case takes stays out of sight, and so does a write // deferred past settle() (a timer longer than 20 ms). // @@ -36,8 +45,14 @@ const TOKEN = "test-token"; const INGEST_TOKEN = "test-ingest-token"; const ORPHAN = ".thinkpad.4242.1700000000000.tmp"; +// The real functions, taken at load time, before any spy: the test's own +// fixture edits go through them and stay out of the logs. Only single-call +// functions belong here. `rmSync(dir, { recursive: true })` would not do: it +// calls the public fs.rmdirSync internally, which is the spy by then. +const real = { writeFileSync: fs.writeFileSync, rmSync: fs.rmSync, renameSync: fs.renameSync }; + // Runtime file = under the repo root, outside node_modules and __tests__. -// Used both to attribute a call to the API and to purge the module cache. +// Used to purge the module cache. function isRuntimeFile(file) { const rel = path.relative(REPO_ROOT, file); if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false; @@ -45,24 +60,6 @@ function isRuntimeFile(file) { return top !== "node_modules" && top !== "__tests__"; } -function fromRuntimeFile(stack) { - return stack - .split("\n") - .slice(1) - .some((frame) => { - const match = /\(?(\/[^()]+?):\d+:\d+\)?$/.exec(frame.trim()); - return match !== null && isRuntimeFile(match[1]); - }); -} - -function captureStack() { - const limit = Error.stackTraceLimit; - Error.stackTraceLimit = 50; - const { stack } = new Error(); - Error.stackTraceLimit = limit; - return stack; -} - // Write APIs of node:fs, by the argument(s) naming what gets written. Each is // spied in its callback, Sync and fs.promises forms, when that form exists. const O_WRITE = @@ -135,10 +132,8 @@ function installWriteSpies() { if (typeof owner[key] !== "function") continue; const original = owner[key]; vi.spyOn(owner, key).mockImplementation(function (...args) { - if (fromRuntimeFile(captureStack())) { - const targets = targetsOf(args); - if (targets) log.push({ fn: key, targets }); - } + const targets = targetsOf(args); + if (targets) log.push({ fn: key, targets }); return original.apply(this, args); }); } @@ -150,7 +145,7 @@ function installCommandSpies() { if (typeof child_process[key] !== "function") continue; const original = child_process[key]; vi.spyOn(child_process, key).mockImplementation(function (...args) { - if (fromRuntimeFile(captureStack())) commandLog.push(commandOf(args)); + commandLog.push(commandOf(args)); return original.apply(this, args); }); } @@ -234,7 +229,7 @@ async function send(route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = } } -// Created before the spies: nothing here is attributed to the API. +// Created before the spies: nothing here is recorded. function writeFixtures() { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-")); const defenseurs = path.join(tmpDir, "defenseurs"); @@ -389,16 +384,17 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => { } // A corrupt agents-map.json: where a quarantine rename would go. - fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt"); + real.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt"); expect((await send("/defenseurs/findings?project=proj")).status).toBe(500); // Missing Defenseurs files and a corrupt snapshot: where a "recreate it" // write would go. Each route must degrade, with the content that says so. - fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" })); - fs.rmSync(process.env.DEFENSEURS_STATUS_PATH); - fs.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH); - fs.rmSync(process.env.REPORTS_DIR, { recursive: true }); - fs.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt"); + // The reports directory is moved away rather than removed: one call. + real.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" })); + real.rmSync(process.env.DEFENSEURS_STATUS_PATH); + real.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH); + real.renameSync(process.env.REPORTS_DIR, path.join(tmpDir, "reports-moved-away")); + real.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt"); const degraded = [ ["/defenseurs", (body) => expect(body.status).toBe("no_data")], ["/defenseurs/state", (body) => expect(body.status).toBe("no_data")],