test(writes): count every write, drop the stack attribution (#40) #44
2 changed files with 31 additions and 35 deletions
|
|
@ -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__/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__/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 (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
|
- 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`.
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,21 @@ const { fileURLToPath } = require("node:url");
|
||||||
//
|
//
|
||||||
// Every node:fs function that can write is spied BEFORE a fresh require,
|
// Every node:fs function that can write is spied BEFORE a fresh require,
|
||||||
// because the runtime modules destructure their fs functions at load time
|
// because the runtime modules destructure their fs functions at load time
|
||||||
// (same technique as the timingSafeEqual spy in auth.test.js). A call is
|
// (same technique as the timingSafeEqual spy in auth.test.js). Every call is
|
||||||
// recorded when its stack goes through a runtime file of the repo, then passed
|
// recorded, whoever makes it, then passed through to the real function. No
|
||||||
// through to the real function. A shell command can write too, out of the fs
|
// 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
|
// 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.
|
// 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
|
// Limits: a path no case takes stays out of sight, and so does a write
|
||||||
// deferred past settle() (a timer longer than 20 ms).
|
// deferred past settle() (a timer longer than 20 ms).
|
||||||
//
|
//
|
||||||
|
|
@ -36,8 +45,14 @@ const TOKEN = "test-token";
|
||||||
const INGEST_TOKEN = "test-ingest-token";
|
const INGEST_TOKEN = "test-ingest-token";
|
||||||
const ORPHAN = ".thinkpad.4242.1700000000000.tmp";
|
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__.
|
// 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) {
|
function isRuntimeFile(file) {
|
||||||
const rel = path.relative(REPO_ROOT, file);
|
const rel = path.relative(REPO_ROOT, file);
|
||||||
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false;
|
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__";
|
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
|
// 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.
|
// spied in its callback, Sync and fs.promises forms, when that form exists.
|
||||||
const O_WRITE =
|
const O_WRITE =
|
||||||
|
|
@ -135,10 +132,8 @@ function installWriteSpies() {
|
||||||
if (typeof owner[key] !== "function") continue;
|
if (typeof owner[key] !== "function") continue;
|
||||||
const original = owner[key];
|
const original = owner[key];
|
||||||
vi.spyOn(owner, key).mockImplementation(function (...args) {
|
vi.spyOn(owner, key).mockImplementation(function (...args) {
|
||||||
if (fromRuntimeFile(captureStack())) {
|
const targets = targetsOf(args);
|
||||||
const targets = targetsOf(args);
|
if (targets) log.push({ fn: key, targets });
|
||||||
if (targets) log.push({ fn: key, targets });
|
|
||||||
}
|
|
||||||
return original.apply(this, args);
|
return original.apply(this, args);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +145,7 @@ function installCommandSpies() {
|
||||||
if (typeof child_process[key] !== "function") continue;
|
if (typeof child_process[key] !== "function") continue;
|
||||||
const original = child_process[key];
|
const original = child_process[key];
|
||||||
vi.spyOn(child_process, key).mockImplementation(function (...args) {
|
vi.spyOn(child_process, key).mockImplementation(function (...args) {
|
||||||
if (fromRuntimeFile(captureStack())) commandLog.push(commandOf(args));
|
commandLog.push(commandOf(args));
|
||||||
return original.apply(this, 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() {
|
function writeFixtures() {
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-"));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-"));
|
||||||
const defenseurs = path.join(tmpDir, "defenseurs");
|
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.
|
// 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);
|
expect((await send("/defenseurs/findings?project=proj")).status).toBe(500);
|
||||||
|
|
||||||
// Missing Defenseurs files and a corrupt snapshot: where a "recreate it"
|
// Missing Defenseurs files and a corrupt snapshot: where a "recreate it"
|
||||||
// write would go. Each route must degrade, with the content that says so.
|
// 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" }));
|
// The reports directory is moved away rather than removed: one call.
|
||||||
fs.rmSync(process.env.DEFENSEURS_STATUS_PATH);
|
real.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" }));
|
||||||
fs.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH);
|
real.rmSync(process.env.DEFENSEURS_STATUS_PATH);
|
||||||
fs.rmSync(process.env.REPORTS_DIR, { recursive: true });
|
real.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH);
|
||||||
fs.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt");
|
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 = [
|
const degraded = [
|
||||||
["/defenseurs", (body) => expect(body.status).toBe("no_data")],
|
["/defenseurs", (body) => expect(body.status).toBe("no_data")],
|
||||||
["/defenseurs/state", (body) => expect(body.status).toBe("no_data")],
|
["/defenseurs/state", (body) => expect(body.status).toBe("no_data")],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue