fix(test-curl): take the two ports from the environment (#46) #47
3 changed files with 233 additions and 67 deletions
|
|
@ -62,18 +62,18 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits/<sha>/status`), l'API Actions repondant 404 sur cette instance.
|
- **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits/<sha>/status`), l'API Actions repondant 404 sur cette instance.
|
||||||
- `npm test` (vitest) — 363 cas
|
- `npm test` (vitest) — 377 cas
|
||||||
- `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
|
- `__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__/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__/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__/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__/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 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__/ci.test.js` — epingles du workflow et des scripts de CI (47 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 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 45 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 45 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, 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 principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`)
|
||||||
- `__tests__/writes.test.js` — seule surface d'ecriture : `HOSTS_DIR` (11 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/<id>` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir)
|
- `__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. 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.
|
- `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. Les deux ports se changent par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` (#46), ce qui laisse tourner deux passages cote a cote ; une valeur hors 1-65535 ou deux ports identiques sont refuses avec un message, avant tout lancement. Des noms propres au script plutot que `PORT`, qu'un shell de dev exporte souvent deja. `BASE_URL` et `NOINGEST_URL` sont derives des ports et ne se lisent plus dans l'environnement : surcharges seuls, ils envoyaient les `curl` ailleurs que sur les serveurs demarres. `TOKEN` et `INGEST_TOKEN` sont fixes pour la meme raison : deux valeurs egales heritees cassaient les cas de cloisonnement. 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 son port) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Ce `wait` n'est epingle que par une tentative de connexion unique a la sortie du script : sans lui, le cas rougit le plus souvent, pas a chaque fois (4 passages sur 5 mesures). 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
|
## Gotchas
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,16 +151,22 @@ function run(command, args, env) {
|
||||||
|
|
||||||
const runBootScript = (env) => run("sh", ["scripts/boot-from-copy.sh"], env);
|
const runBootScript = (env) => run("sh", ["scripts/boot-from-copy.sh"], env);
|
||||||
|
|
||||||
// A port nothing listens on. The probe closes before the script starts: should
|
// Ports nothing listens on, all distinct: the probes are open together, since
|
||||||
// something take the port in between, the script's own check refuses it and
|
// a port closed first could be handed out again to the next probe. They close
|
||||||
// the test fails loudly.
|
// before the script starts: should something take a port in between, the
|
||||||
async function freePort() {
|
// script's own check refuses it and the test fails loudly.
|
||||||
|
async function freePorts(count) {
|
||||||
|
const probes = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
const probe = http.createServer();
|
const probe = http.createServer();
|
||||||
await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve));
|
||||||
const { port } = probe.address();
|
probes.push(probe);
|
||||||
await new Promise((resolve) => probe.close(resolve));
|
}
|
||||||
return port;
|
const ports = probes.map((probe) => probe.address().port);
|
||||||
|
await Promise.all(probes.map((probe) => new Promise((resolve) => probe.close(resolve))));
|
||||||
|
return ports;
|
||||||
}
|
}
|
||||||
|
const freePort = async () => (await freePorts(1))[0];
|
||||||
|
|
||||||
describe("boot-from-copy.sh", () => {
|
describe("boot-from-copy.sh", () => {
|
||||||
// A listener already on the port: without the pre-check, the wait loop could
|
// A listener already on the port: without the pre-check, the wait loop could
|
||||||
|
|
@ -221,17 +227,20 @@ describe("boot-from-copy.sh", () => {
|
||||||
}, 20000);
|
}, 20000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The socket-level pass boots its own servers on 3099 and 3098, fixed ports:
|
// The socket-level pass boots its own two servers. Every case hands it ports
|
||||||
// these cases fail if something else holds them, and must not overlap (vitest
|
// of its own through TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT (#46): the
|
||||||
// runs the tests of a file one after the other). BASE_URL is dropped from the
|
// defaults, 3099 and 3098, are never touched here, so a second `npm test` or a
|
||||||
// environment each time: vite sets it to "/" in the test process, and the
|
// `bash test-curl.sh` running next to this one is not in the way. The defaults
|
||||||
// script reads a variable of that name.
|
// are pinned on the source below and exercised by the CI step that runs the
|
||||||
|
// script bare.
|
||||||
describe("test-curl.sh", () => {
|
describe("test-curl.sh", () => {
|
||||||
// True once nothing accepts a connection on the port, false after 3 s of
|
const script = fs.readFileSync(path.join(ROOT, "test-curl.sh"), "utf-8");
|
||||||
// trying: a server killed by the script's trap takes a moment to let go.
|
const scriptCode = stripCommentLines(script);
|
||||||
async function released(port) {
|
const urlOf = (port) => `http://localhost:${port}`;
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
const open = await new Promise((resolve) => {
|
// One connection attempt: true if something accepts it.
|
||||||
|
function accepts(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
const socket = net.connect(port, "127.0.0.1");
|
const socket = net.connect(port, "127.0.0.1");
|
||||||
socket.once("connect", () => {
|
socket.once("connect", () => {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
|
|
@ -239,13 +248,51 @@ describe("test-curl.sh", () => {
|
||||||
});
|
});
|
||||||
socket.once("error", () => resolve(false));
|
socket.once("error", () => resolve(false));
|
||||||
});
|
});
|
||||||
if (!open) return true;
|
}
|
||||||
|
|
||||||
|
// 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++) {
|
||||||
|
if (!(await accepts(port))) return true;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runSocketPass = (env) => run("bash", ["test-curl.sh"], { BASE_URL: undefined, ...env });
|
// Throws on a missing port: the script reads an empty variable as unset, and
|
||||||
|
// the case would drift back to 3099 and 3098 with nothing to show for it.
|
||||||
|
function runSocketPass(ports, env = {}) {
|
||||||
|
const [main, noIngest] = ports;
|
||||||
|
if (!Number.isInteger(main) || !Number.isInteger(noIngest)) {
|
||||||
|
throw new Error("runSocketPass needs the two ports of the script");
|
||||||
|
}
|
||||||
|
return run("bash", ["test-curl.sh"], {
|
||||||
|
TEST_CURL_PORT: String(main),
|
||||||
|
TEST_CURL_NOINGEST_PORT: String(noIngest),
|
||||||
|
...env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read on the source, not by running the script bare: that run would hold
|
||||||
|
// 3099 and 3098 during `npm test`, which is what #46 removed.
|
||||||
|
test("listens on 3099 and 3098 unless the two variables say otherwise", () => {
|
||||||
|
expect(scriptCode).toMatch(/^MAIN_PORT="\$\{TEST_CURL_PORT:-3099\}"$/m);
|
||||||
|
expect(scriptCode).toMatch(/^NOINGEST_PORT="\$\{TEST_CURL_NOINGEST_PORT:-3098\}"$/m);
|
||||||
|
// The two defaults and nothing else: no port written into a launch or a URL.
|
||||||
|
expect(scriptCode.match(/309[89]/g)).toEqual(["3099", "3098"]);
|
||||||
|
expect(scriptCode).toMatch(/^PORT="\$MAIN_PORT" \\$/m);
|
||||||
|
expect(scriptCode).toMatch(/^PORT="\$NOINGEST_PORT" \\$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An overridden URL with the listening port left alone sent every curl away
|
||||||
|
// from the servers the script had started.
|
||||||
|
test("derives its two URLs from the ports, never from the environment", () => {
|
||||||
|
expect(scriptCode.match(/^(BASE_URL|NOINGEST_URL)=.*$/gm)).toEqual([
|
||||||
|
'BASE_URL="http://localhost:$MAIN_PORT"',
|
||||||
|
'NOINGEST_URL="http://localhost:$NOINGEST_PORT"',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// Same exposure as the boot check, 18 curl lines of it (#41). Before the
|
// 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
|
// export, a dead proxy made the script exit 7 with no output, and a live one
|
||||||
|
|
@ -261,22 +308,37 @@ describe("test-curl.sh", () => {
|
||||||
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
|
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
|
||||||
// Same environment as the boot check's case above, for the same reasons.
|
// 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.
|
// FORCE_COLOR on top: node would colour the numbers the cases compare.
|
||||||
|
// The two URLs point at a port nothing can hold: read from the
|
||||||
|
// environment, they would fail every case (vite alone already sets
|
||||||
|
// BASE_URL to "/" in the test process).
|
||||||
const env = {
|
const env = {
|
||||||
http_proxy: proxyUrl,
|
http_proxy: proxyUrl,
|
||||||
ALL_PROXY: proxyUrl,
|
ALL_PROXY: proxyUrl,
|
||||||
NO_PROXY: undefined,
|
NO_PROXY: undefined,
|
||||||
no_proxy: "example.invalid",
|
no_proxy: "example.invalid",
|
||||||
FORCE_COLOR: "3",
|
FORCE_COLOR: "3",
|
||||||
|
BASE_URL: "http://127.0.0.1:1",
|
||||||
|
NOINGEST_URL: "http://127.0.0.1:1",
|
||||||
|
// Same for the two tokens: one value for both, read from the
|
||||||
|
// environment, would let each token open the other's routes.
|
||||||
|
TOKEN: "inherited-token",
|
||||||
|
INGEST_TOKEN: "inherited-token",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The proxy is live: a bare curl with the same environment reaches it.
|
// The proxy is live: a bare curl with the same environment reaches it.
|
||||||
const port = await freePort();
|
const [probed, ...ports] = await freePorts(3);
|
||||||
await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${port}/`], env);
|
await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${probed}/`], env);
|
||||||
expect(proxied).toEqual([`GET http://127.0.0.1:${port}/`]);
|
expect(proxied).toEqual([`GET http://127.0.0.1:${probed}/`]);
|
||||||
proxied.length = 0;
|
proxied.length = 0;
|
||||||
|
|
||||||
const { code, stdout, stderr } = await runSocketPass(env);
|
const { code, stdout, stderr } = await runSocketPass(ports, env);
|
||||||
|
// On the first try, with no grace: the trap ends on a `wait`, so both
|
||||||
|
// servers are gone by the time the script exits. On the fixed ports the
|
||||||
|
// next case found out by failing to listen; with ports of its own,
|
||||||
|
// nothing else would.
|
||||||
|
const stillOpen = [await accepts(ports[0]), await accepts(ports[1])];
|
||||||
|
|
||||||
|
expect(stillOpen).toEqual([false, false]);
|
||||||
expect(proxied).toEqual([]);
|
expect(proxied).toEqual([]);
|
||||||
expect(code, stdout + stderr).toBe(0);
|
expect(code, stdout + stderr).toBe(0);
|
||||||
// Every case ran against the API: a count, so a script cut short fails.
|
// Every case ran against the API: a count, so a script cut short fails.
|
||||||
|
|
@ -286,24 +348,97 @@ describe("test-curl.sh", () => {
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
// A real neighbour on one of the two ports, as a second `npm test` or a
|
// What #46 is for: two passes at the same instant, each on its own ports,
|
||||||
// stray server would be. Without the check up front the wait loop reaches the
|
// as two `npm test` side by side. On the fixed ports at least one of the two
|
||||||
// neighbour before the script's own server has died of EADDRINUSE, and the
|
// failed every time (review of PR #45).
|
||||||
// cases run against it (review of PR #45). Nothing must start, on either port.
|
test("two passes at once both succeed, each on its own ports", async () => {
|
||||||
|
const ports = await freePorts(4);
|
||||||
|
try {
|
||||||
|
const results = await Promise.all([
|
||||||
|
runSocketPass(ports.slice(0, 2)),
|
||||||
|
runSocketPass(ports.slice(2)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const { code, stdout, stderr } of results) {
|
||||||
|
expect(code, stdout + stderr).toBe(0);
|
||||||
|
expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const port of ports) expect(await released(port)).toBe(true);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Refused before anything starts, with the variable named. Port 0 would
|
||||||
|
// make node pick a port of its own, and two identical ports pass the "is it
|
||||||
|
// free" check: both would only end on a message about a server that died.
|
||||||
|
// Each value on each variable: the two are checked by separate calls.
|
||||||
|
const badValues = [
|
||||||
|
["a value that is no number", "abc"],
|
||||||
|
["port 0", "0"],
|
||||||
|
["a port above 65535", "65536"],
|
||||||
|
// Digits first, then anything: without the end anchor of the pattern the
|
||||||
|
// value got through, and the numeric comparison after it errored out.
|
||||||
|
["a number followed by letters", "80abc"],
|
||||||
|
].flatMap(([what, value]) =>
|
||||||
|
["TEST_CURL_PORT", "TEST_CURL_NOINGEST_PORT"].map((name) => [
|
||||||
|
`${what} in ${name}`,
|
||||||
|
() => ({ [name]: value }),
|
||||||
|
`${name} must be a port number (1-65535), got '${value}'`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
test.each([
|
test.each([
|
||||||
[3099, "http://localhost:3099"],
|
...badValues,
|
||||||
[3098, "http://localhost:3098"],
|
[
|
||||||
])("refuses to run when something else holds %i", async (port, url) => {
|
"the same port twice",
|
||||||
|
([main]) => ({ TEST_CURL_NOINGEST_PORT: String(main) }),
|
||||||
|
"TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ",
|
||||||
|
],
|
||||||
|
])("refuses %s", async (_, override, message) => {
|
||||||
|
const ports = await freePorts(2);
|
||||||
|
const { code, stdout, stderr, ms } = await runSocketPass(ports, override(ports));
|
||||||
|
|
||||||
|
expect(stderr).toContain(`test-curl.sh: ${message}`);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
||||||
|
expect(ms).toBeLessThan(3000);
|
||||||
|
for (const port of ports) expect(await released(port)).toBe(true);
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
// The two ends of the range are accepted. Given to both variables, so that
|
||||||
|
// nothing has to listen on a port fixed in advance: the "must differ"
|
||||||
|
// refusal comes after the range check, and reaching it shows the value
|
||||||
|
// passed.
|
||||||
|
test.each(["1", "65535"])("accepts port %s, the end of the range", async (port) => {
|
||||||
|
const { code, stdout, stderr } = await run("bash", ["test-curl.sh"], {
|
||||||
|
TEST_CURL_PORT: port,
|
||||||
|
TEST_CURL_NOINGEST_PORT: port,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stderr).not.toContain("must be a port number");
|
||||||
|
expect(stderr).toContain(`test-curl.sh: TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ (both ${port})`);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
// A real neighbour on one of the two ports, as 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([
|
||||||
|
["main", 0],
|
||||||
|
["no-ingest", 1],
|
||||||
|
])("refuses to run when something else holds the port of the %s server", async (_, held) => {
|
||||||
|
const ports = await freePorts(2);
|
||||||
const asked = [];
|
const asked = [];
|
||||||
const neighbour = http.createServer((req, res) => {
|
const neighbour = http.createServer((req, res) => {
|
||||||
asked.push(req.url);
|
asked.push(req.url);
|
||||||
res.end("neighbour");
|
res.end("neighbour");
|
||||||
});
|
});
|
||||||
await new Promise((resolve, reject) => neighbour.once("error", reject).listen(port, resolve));
|
await new Promise((resolve, reject) => neighbour.once("error", reject).listen(ports[held], resolve));
|
||||||
try {
|
try {
|
||||||
const { code, stdout, stderr } = await runSocketPass({});
|
const { code, stdout, stderr } = await runSocketPass(ports);
|
||||||
|
|
||||||
expect(stderr).toContain(`test-curl.sh: ${url} is already taken (curl exit 0)`);
|
expect(stderr).toContain(`test-curl.sh: ${urlOf(ports[held])} is already taken (curl exit 0)`);
|
||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(stdout).not.toMatch(/PASS|FAIL/);
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
||||||
// The neighbour saw the probe and nothing else: no case reached it.
|
// The neighbour saw the probe and nothing else: no case reached it.
|
||||||
|
|
@ -312,8 +447,7 @@ describe("test-curl.sh", () => {
|
||||||
await new Promise((resolve) => neighbour.close(resolve));
|
await new Promise((resolve) => neighbour.close(resolve));
|
||||||
}
|
}
|
||||||
// The other port was free: no server of the script was left on it.
|
// The other port was free: no server of the script was left on it.
|
||||||
expect(await released(3099)).toBe(true);
|
for (const port of ports) expect(await released(port)).toBe(true);
|
||||||
expect(await released(3098)).toBe(true);
|
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
// The rule is "anything but connection refused", not "an HTTP answer": an
|
// The rule is "anything but connection refused", not "an HTTP answer": an
|
||||||
|
|
@ -321,18 +455,19 @@ describe("test-curl.sh", () => {
|
||||||
test.each([
|
test.each([
|
||||||
["closes at once", (socket) => socket.destroy(), /\(curl exit (52|56)\)/],
|
["closes at once", (socket) => socket.destroy(), /\(curl exit (52|56)\)/],
|
||||||
["accepts and stalls", () => {}, /\(curl exit 28\)/],
|
["accepts and stalls", () => {}, /\(curl exit 28\)/],
|
||||||
])("refuses to run when a listener that %s holds 3099", async (_, onConnection, exitCode) => {
|
])("refuses to run when a listener that %s holds the main port", async (_, onConnection, exitCode) => {
|
||||||
|
const ports = await freePorts(2);
|
||||||
const sockets = new Set();
|
const sockets = new Set();
|
||||||
const occupant = net.createServer((socket) => {
|
const occupant = net.createServer((socket) => {
|
||||||
sockets.add(socket);
|
sockets.add(socket);
|
||||||
socket.on("error", () => {});
|
socket.on("error", () => {});
|
||||||
onConnection(socket);
|
onConnection(socket);
|
||||||
});
|
});
|
||||||
await new Promise((resolve, reject) => occupant.once("error", reject).listen(3099, resolve));
|
await new Promise((resolve, reject) => occupant.once("error", reject).listen(ports[0], resolve));
|
||||||
try {
|
try {
|
||||||
const { code, stdout, stderr } = await runSocketPass({});
|
const { code, stdout, stderr } = await runSocketPass(ports);
|
||||||
|
|
||||||
expect(stderr).toContain("test-curl.sh: http://localhost:3099 is already taken");
|
expect(stderr).toContain(`test-curl.sh: ${urlOf(ports[0])} is already taken`);
|
||||||
expect(stderr).toMatch(exitCode);
|
expect(stderr).toMatch(exitCode);
|
||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(stdout).not.toMatch(/PASS|FAIL/);
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
||||||
|
|
@ -340,29 +475,30 @@ describe("test-curl.sh", () => {
|
||||||
for (const socket of sockets) socket.destroy();
|
for (const socket of sockets) socket.destroy();
|
||||||
await new Promise((resolve) => occupant.close(resolve));
|
await new Promise((resolve) => occupant.close(resolve));
|
||||||
}
|
}
|
||||||
expect(await released(3098)).toBe(true);
|
expect(await released(ports[1])).toBe(true);
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
// A server that stays alive and never listens: only the deadline can end the
|
// 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.
|
// 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 () => {
|
test("gives up after 5 s on a server that is alive and never answers", async () => {
|
||||||
|
const ports = await freePorts(2);
|
||||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
|
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(bin, "node"),
|
path.join(bin, "node"),
|
||||||
`#!/bin/sh\n[ "$PORT" = "3099" ] && exec sleep 30\nexec "${process.execPath}" "$@"\n`,
|
`#!/bin/sh\n[ "$PORT" = "${ports[0]}" ] && exec sleep 30\nexec "${process.execPath}" "$@"\n`,
|
||||||
{ mode: 0o755 },
|
{ mode: 0o755 },
|
||||||
);
|
);
|
||||||
const { code, stdout, stderr, ms } = await runSocketPass({
|
const { code, stdout, stderr, ms } = await runSocketPass(ports, {
|
||||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stderr).toContain("test-curl.sh: no server answered at http://localhost:3099 within 5 s");
|
expect(stderr).toContain(`test-curl.sh: no server answered at ${urlOf(ports[0])} within 5 s`);
|
||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(stdout).not.toMatch(/PASS|FAIL/);
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
||||||
expect(ms).toBeGreaterThan(4000);
|
expect(ms).toBeGreaterThan(4000);
|
||||||
expect(ms).toBeLessThan(10000);
|
expect(ms).toBeLessThan(10000);
|
||||||
expect(await released(3098)).toBe(true);
|
expect(await released(ports[1])).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(bin, { recursive: true, force: true });
|
fs.rmSync(bin, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
@ -373,29 +509,29 @@ describe("test-curl.sh", () => {
|
||||||
// otherwise. The script must name the URL instead of dying silently, for the
|
// otherwise. The script must name the URL instead of dying silently, for the
|
||||||
// main instance as for the one booted without HOSTS_INGEST_TOKEN.
|
// main instance as for the one booted without HOSTS_INGEST_TOKEN.
|
||||||
test.each([
|
test.each([
|
||||||
["3099", "http://localhost:3099"],
|
["main", 0],
|
||||||
["3098", "http://localhost:3098"],
|
["no-ingest", 1],
|
||||||
])("says so, and exits 1, when its server on %s does not start", async (port, url) => {
|
])("says so, and exits 1, when its %s server does not start", async (_, failing) => {
|
||||||
|
const ports = await freePorts(2);
|
||||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
|
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(bin, "node"),
|
path.join(bin, "node"),
|
||||||
`#!/bin/sh\n[ "$PORT" = "${port}" ] && exit 1\nexec "${process.execPath}" "$@"\n`,
|
`#!/bin/sh\n[ "$PORT" = "${ports[failing]}" ] && exit 1\nexec "${process.execPath}" "$@"\n`,
|
||||||
{ mode: 0o755 },
|
{ mode: 0o755 },
|
||||||
);
|
);
|
||||||
const { code, stdout, stderr, ms } = await runSocketPass({
|
const { code, stdout, stderr, ms } = await runSocketPass(ports, {
|
||||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stderr).toContain(`test-curl.sh: the server for ${url} exited before answering`);
|
expect(stderr).toContain(`test-curl.sh: the server for ${urlOf(ports[failing])} exited before answering`);
|
||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(stdout).not.toMatch(/PASS|FAIL/);
|
expect(stdout).not.toMatch(/PASS|FAIL/);
|
||||||
// At once, not at the end of the 5 s wait: a message that did not stop
|
// 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.
|
// the run would be repeated until the deadline.
|
||||||
expect(ms).toBeLessThan(3000);
|
expect(ms).toBeLessThan(3000);
|
||||||
// The server that did start is not left behind on its port.
|
// The server that did start is not left behind on its port.
|
||||||
expect(await released(3099)).toBe(true);
|
for (const port of ports) expect(await released(port)).toBe(true);
|
||||||
expect(await released(3098)).toBe(true);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(bin, { recursive: true, force: true });
|
fs.rmSync(bin, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
test-curl.sh
44
test-curl.sh
|
|
@ -13,6 +13,10 @@
|
||||||
#
|
#
|
||||||
# Usage :
|
# Usage :
|
||||||
# bash test-curl.sh
|
# bash test-curl.sh
|
||||||
|
# TEST_CURL_PORT=<port> TEST_CURL_NOINGEST_PORT=<port> bash test-curl.sh
|
||||||
|
#
|
||||||
|
# The two servers listen on 3099 and 3098 unless these variables name other
|
||||||
|
# ports. The URLs follow from the ports and are not read from the environment.
|
||||||
#
|
#
|
||||||
# Exit 0 if all cases pass, exit 1 otherwise.
|
# Exit 0 if all cases pass, exit 1 otherwise.
|
||||||
|
|
||||||
|
|
@ -28,13 +32,39 @@ export NO_PROXY='*' no_proxy='*'
|
||||||
# FORCE_COLOR node colours a number even into a pipe, and "3" stops matching.
|
# FORCE_COLOR node colours a number even into a pipe, and "3" stops matching.
|
||||||
unset FORCE_COLOR
|
unset FORCE_COLOR
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-http://localhost:3099}"
|
# The ports the two servers listen on. Names of their own rather than PORT: a
|
||||||
|
# PORT inherited from the shell would move the servers without a word.
|
||||||
|
MAIN_PORT="${TEST_CURL_PORT:-3099}"
|
||||||
# Second instance, booted WITHOUT HOSTS_INGEST_TOKEN, for the fail-closed case.
|
# Second instance, booted WITHOUT HOSTS_INGEST_TOKEN, for the fail-closed case.
|
||||||
NOINGEST_URL="${NOINGEST_URL:-http://localhost:3098}"
|
NOINGEST_PORT="${TEST_CURL_NOINGEST_PORT:-3098}"
|
||||||
TOKEN="${TOKEN:-test-token-123}"
|
|
||||||
|
# Refused here, with the cause named. Port 0 would make node listen on a port
|
||||||
|
# of its own choosing, and two identical ports pass require_free: both would
|
||||||
|
# only end on a wait_for message that says nothing of the value given.
|
||||||
|
require_port() {
|
||||||
|
if ! [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || [ "$2" -gt 65535 ]; then
|
||||||
|
echo "test-curl.sh: $1 must be a port number (1-65535), got '$2'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
require_port TEST_CURL_PORT "$MAIN_PORT"
|
||||||
|
require_port TEST_CURL_NOINGEST_PORT "$NOINGEST_PORT"
|
||||||
|
if [ "$MAIN_PORT" = "$NOINGEST_PORT" ]; then
|
||||||
|
echo "test-curl.sh: TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ (both $MAIN_PORT)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Derived, never read from the environment: a URL overridden on its own would
|
||||||
|
# send every curl below somewhere else than the servers started here.
|
||||||
|
BASE_URL="http://localhost:$MAIN_PORT"
|
||||||
|
NOINGEST_URL="http://localhost:$NOINGEST_PORT"
|
||||||
|
# Fixed, like the URLs: the script starts its own servers, so nothing outside
|
||||||
|
# has a say in the tokens they expect.
|
||||||
|
TOKEN="test-token-123"
|
||||||
# Deliberately different from TOKEN — every compartmentalisation case below is
|
# Deliberately different from TOKEN — every compartmentalisation case below is
|
||||||
# only meaningful because the two values cannot be confused.
|
# only meaningful because the two values cannot be confused. Read from the
|
||||||
INGEST_TOKEN="${INGEST_TOKEN:-test-ingest-token-456}"
|
# environment, two inherited values that happened to be equal would break them.
|
||||||
|
INGEST_TOKEN="test-ingest-token-456"
|
||||||
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
|
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
|
||||||
SERVER_PID=""
|
SERVER_PID=""
|
||||||
NOINGEST_PID=""
|
NOINGEST_PID=""
|
||||||
|
|
@ -165,7 +195,7 @@ printf '%s\n' '{ "schemaVersion":1, "findings": {"a-deps-1": {"firstSeen":"2026
|
||||||
> "$TMP_DIR/findings-state.json"
|
> "$TMP_DIR/findings-state.json"
|
||||||
|
|
||||||
# Boot the server with the temp REPORTS_DIR and HOSTS_DIR.
|
# Boot the server with the temp REPORTS_DIR and HOSTS_DIR.
|
||||||
PORT=3099 \
|
PORT="$MAIN_PORT" \
|
||||||
HEALTH_TOKEN="$TOKEN" \
|
HEALTH_TOKEN="$TOKEN" \
|
||||||
REPORTS_DIR="$TMP_DIR/reports" \
|
REPORTS_DIR="$TMP_DIR/reports" \
|
||||||
HOSTS_DIR="$TMP_DIR/hosts" \
|
HOSTS_DIR="$TMP_DIR/hosts" \
|
||||||
|
|
@ -180,7 +210,7 @@ SERVER_PID=$!
|
||||||
# on the ingestion path. Its HOSTS_DIR is separate so a stray write cannot
|
# on the ingestion path. Its HOSTS_DIR is separate so a stray write cannot
|
||||||
# pollute the fixtures of the main instance.
|
# pollute the fixtures of the main instance.
|
||||||
mkdir -p "$TMP_DIR/hosts-noingest"
|
mkdir -p "$TMP_DIR/hosts-noingest"
|
||||||
PORT=3098 \
|
PORT="$NOINGEST_PORT" \
|
||||||
HEALTH_TOKEN="$TOKEN" \
|
HEALTH_TOKEN="$TOKEN" \
|
||||||
REPORTS_DIR="$TMP_DIR/reports" \
|
REPORTS_DIR="$TMP_DIR/reports" \
|
||||||
HOSTS_DIR="$TMP_DIR/hosts-noingest" \
|
HOSTS_DIR="$TMP_DIR/hosts-noingest" \
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue