test(writes): count every write, drop the stack attribution (#40) #44

Merged
maximus merged 1 commit from issue-40-count-every-write into main 2026-10-02 00:46:10 +00:00
2 changed files with 31 additions and 35 deletions

View file

@ -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/<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 ; **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/<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.

View file

@ -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 });
}
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")],