test(claude-usage): cover the routes, the sanitizer and the new write surface
- __tests__/claude-usage.test.js (119 cases): sanitizer contract and idempotence, dropped values (unknown fields, negative, past 2^53, detail outside its regex, status outside the enum, bad dates, model keys), list bounds, token isolation both ways, 503 naming each variable, 413 at 65 537 bytes and /hosts/<id> still 413 at 4 097, atomic mode-600 write, POST then GET round trip, exact staleness boundary under a frozen clock, no_data degradation, rejection logs, COPY line. - writes.test.js: POST /claude-usage may write only in the directory of CLAUDE_USAGE_PATH, its rejections write nothing, GET /claude-usage joins the read routes. - README.md and CLAUDE.md: both routes, the three env vars, the /data/claude-usage read-write mount owned by uid 1000, the token table. Resolves #48 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e1224e0df4
commit
ef8558e90e
4 changed files with 1045 additions and 24 deletions
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -11,20 +11,22 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
|
||||||
- `GET /defenseurs/findings?project=X` — findings detailles du Defenseur correspondant. Query params : `project=<name>` (obligatoire, lookup via `agents-map.json`), `category=<deps|secrets|code|acces|infra>` (optionnel, exact match), `severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>` (optionnel, threshold inclusif vers le haut). Sans `severity` -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). `severity=LOW` -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). `severity=INFO` -> INFO uniquement (opt-in explicite). Reponses : 200 `{ agent, project, timestamp, findings[] }` si report present (sans champ `status`) ; 200 `{ findings: [], status: "no_data" }` si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si `agents-map.json` corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill `/analyse-vulnerabilite`. Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"`.
|
- `GET /defenseurs/findings?project=X` — findings detailles du Defenseur correspondant. Query params : `project=<name>` (obligatoire, lookup via `agents-map.json`), `category=<deps|secrets|code|acces|infra>` (optionnel, exact match), `severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>` (optionnel, threshold inclusif vers le haut). Sans `severity` -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). `severity=LOW` -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). `severity=INFO` -> INFO uniquement (opt-in explicite). Reponses : 200 `{ agent, project, timestamp, findings[] }` si report present (sans champ `status`) ; 200 `{ findings: [], status: "no_data" }` si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si `agents-map.json` corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill `/analyse-vulnerabilite`. Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"`.
|
||||||
|
|
||||||
- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.
|
- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.
|
||||||
- `POST /hosts/<id>` — **seule surface d'ecriture de l'API** (figee par `__tests__/writes.test.js`, sur les chemins nominaux et degrades qu'il exerce). Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `<id>` bien forme mais hors allowlist / 404 `<id>` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`.
|
- `POST /hosts/<id>` — **premiere des deux surfaces d'ecriture de l'API** (figees par `__tests__/writes.test.js`, sur les chemins nominaux et degrades qu'il exerce). Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `<id>` bien forme mais hors allowlist / 404 `<id>` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`.
|
||||||
|
- `POST /claude-usage` — **seconde surface d'ecriture** (issue #48) : snapshot de consommation Claude pousse par l'agent du ThinkPad, contrat `schemaVersion: 1` partage avec le dashboard (`la-compagnie-maximus`, `spec-plan-suivi-claude.md`). Auth par `CLAUDE_USAGE_INGEST_TOKEN`, son propre token : ni `HOSTS_INGEST_TOKEN` ni `HEALTH_TOKEN` n'y passent, et lui n'ouvre ni `POST /hosts/<id>` ni aucune lecture. Le corps passe par `sanitizeClaudeUsage()`, module pur `claude-usage.js` (l'agent teste son snapshot contre le vrai sanitizer) : reconstruction par liste blanche, champ inconnu jete, **valeur invalide jetee (cle omise), jamais forcee** — nombres `Number.isSafeInteger` >= 0, `percent` 0-100, `limits.status` dans l'enum `ok|token_expired|http_error|unavailable|disabled`, `limits.detail` `^[a-z0-9_]{1,32}$` (code, jamais texte libre : un corps de reponse amont ne doit pas voyager jusqu'au dashboard), dates ISO completes avec decalage, `date` de jour `^\d{4}-\d{2}-\d{2}$` et jour reel, cles de `models` `^[a-z0-9][a-z0-9.\-]{0,63}$`, autres chaines tronquees a 128. Listes bornees : `days` <= 31 (au-dela, les 31 plus recents, le contrat les ordonnant du plus ancien a aujourd'hui), `models` <= 12 par jour, `limits.items` <= 12, `projects` <= 9 (au-dela, les premiers). Seuls deux cas refusent le corps entier : pas un objet JSON, ou `schemaVersion` different de 1. Codes : 204 / 400 JSON illisible, pas un objet ou `schemaVersion` != 1 / 401 / 413 corps > 64 Kio (65 536 octets recus) / 503 `CLAUDE_USAGE_INGEST_TOKEN` non configure. `receivedAt` pose par le serveur, ecriture atomique (temporaire `.<nom>.<pid>.<ms>.tmp` dans le meme dossier, mode 600, `renameSync`), patron de `writeHostSnapshot`.
|
||||||
|
- `GET /claude-usage` — `{ staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }`, ou 200 `{ status: "no_data" }` si le fichier est absent ou illisible (corrompu, hors contrat, sans `receivedAt` exploitable). Le fichier est **re-sanitise** a la lecture (`sanitizeClaudeUsage` est idempotent). `stale = ageSeconds > CLAUDE_USAGE_STALE_SECONDS`, decide par le serveur seul : le dashboard ne le recalcule pas. Consommateur : dashboard admin Vercel.
|
||||||
|
|
||||||
## Routage et auth
|
## Routage et auth
|
||||||
|
|
||||||
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
|
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
|
||||||
|
|
||||||
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
|
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
|
||||||
2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie (`tokenKind: "read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee.
|
2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie, lu dans la table `TOKEN_KINDS` (issue #48) : `"read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`, `"claude-ingest"` -> `CLAUDE_USAGE_INGEST_TOKEN`. Chaque entree porte aussi sa reponse quand le token manque. Un descripteur de `ROUTES` qui nomme un `tokenKind` absent de la table fait echouer le chargement du module (refus de demarrer plutot qu'un crash a la premiere requete). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee. Un nouveau token = une entree de la table, jamais un `if` dans `checkAuth()` (l'epingle de `auth.test.js` n'y admet plus que `typeof header === "string"` comme egalite).
|
||||||
3. Dispatch vers le handler.
|
3. Dispatch vers le handler.
|
||||||
|
|
||||||
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
|
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
|
||||||
|
|
||||||
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; `HOSTS_INGEST_TOKEN` absent -> 503 sur l'ingestion
|
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; chaque token d'ingestion absent -> 503 sur sa seule route, avec un message qui nomme sa propre variable (`HOSTS_INGEST_TOKEN not configured`, `CLAUDE_USAGE_INGEST_TOKEN not configured`)
|
||||||
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256, longueurs egalisees par le hachage) compare **les deux tokens** depuis l'issue #17 : `checkAuth()` n'a qu'une expression de comparaison, seul le token attendu varie. Ne jamais revenir a une egalite de chaine (`` header === `Bearer ${x}` ``) : elle s'arrete au premier octet different et fuit longueur et prefixe par le temps de reponse. Un test de timing serait instable ; la garde est double dans `__tests__/auth.test.js` : un espion sur `crypto.timingSafeEqual` (pose avant un `require` frais, `index.js` destructurant l'import au chargement) exige que chaque requete compare deux empreintes de 32 octets, meme quand le token recu n'a pas la bonne longueur ; et des epingles sur le source de `checkAuth()` et de `tokenMatches()`
|
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256, longueurs egalisees par le hachage) compare **tous les tokens** depuis l'issue #17 : `checkAuth()` n'a qu'une expression de comparaison, seul le token attendu varie. Ne jamais revenir a une egalite de chaine (`` header === `Bearer ${x}` ``) : elle s'arrete au premier octet different et fuit longueur et prefixe par le temps de reponse. Un test de timing serait instable ; la garde est double dans `__tests__/auth.test.js` : un espion sur `crypto.timingSafeEqual` (pose avant un `require` frais, `index.js` destructurant l'import au chargement) exige que chaque requete compare deux empreintes de 32 octets, meme quand le token recu n'a pas la bonne longueur ; et des epingles sur le source de `checkAuth()` et de `tokenMatches()`
|
||||||
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
|
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
|
||||||
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
|
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
|
||||||
- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
|
- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
|
||||||
|
|
@ -41,12 +43,16 @@ Consequence voulue : un appelant non authentifie sur une route inconnue voit 404
|
||||||
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
|
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
|
||||||
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
|
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
|
||||||
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
|
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
|
||||||
|
- `CLAUDE_USAGE_INGEST_TOKEN` : token de `POST /claude-usage`, distinct des deux autres (pas de default — absent = 503 nommant la variable). Meme regle Coolify que les autres tokens : `is_runtime=true, is_buildtime=false`
|
||||||
|
- `CLAUDE_USAGE_PATH` : fichier **ecrit** par `POST /claude-usage` et lu par `GET /claude-usage` (default `/data/claude-usage/claude-usage.json`)
|
||||||
|
- `CLAUDE_USAGE_STALE_SECONDS` : age au-dela duquel `GET /claude-usage` repond `stale: true` (default 2700, soit 45 min ; une valeur non numerique ou <= 0 retombe sur le default)
|
||||||
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
|
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
|
||||||
- `/data/defenseurs` (host) -> `/data/defenseurs:ro` : `status.json` + `findings-state.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Monte en lecture seule** depuis le 2026-09-30 (issue #36).
|
- `/data/defenseurs` (host) -> `/data/defenseurs:ro` : `status.json` + `findings-state.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Monte en lecture seule** depuis le 2026-09-30 (issue #36).
|
||||||
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports:ro` : rapports de scan (+ sous-dir `archive/`), montage imbrique dans le precedent. Pose le 2026-07-15 (issue #10), recree le 2026-09-30 en Volume Mount avec Source Path pour recevoir le `:ro` (#36). **Monte en lecture seule**.
|
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports:ro` : rapports de scan (+ sous-dir `archive/`), montage imbrique dans le precedent. Pose le 2026-07-15 (issue #10), recree le 2026-09-30 en Volume Mount avec Source Path pour recevoir le `:ro` (#36). **Monte en lecture seule**.
|
||||||
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
|
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
|
||||||
|
- `/data/claude-usage` (host) -> `/data/claude-usage` : snapshot de consommation Claude (issue #48). **Lecture-ecriture**, proprietaire uid 1000, meme regle que `/data/hosts` (sinon chaque `POST /claude-usage` repond 500). Monter le **dossier**, jamais le fichier seul : le snapshot est remplace par `rename`, un bind de fichier resterait accroche a l'ancien inode. A poser par Max avant le premier push de l'agent.
|
||||||
- Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage : `:ro` est un suffixe de la destination dans Coolify, recopie tel quel dans le compose genere (`'/data/defenseurs:/data/defenseurs:ro'`). (2) Les droits Unix : le contenu appartient a `defenseur:defenseur` (uid et gid 1001), dossiers en 755/775, fichiers en 664, et le conteneur tourne en uid 1000 (`node`, gid 1000, sans groupe supplementaire), donc `test -w` y echoue meme sans `:ro`. Ce sont les bits de groupe qui donnent l'ecriture : cette couche tient parce que le groupe proprietaire n'est pas le gid 1000 (groupe de `node` dans l'image, et de `ubuntu` sur l'hote). Un `chgrp ubuntu` ou un `chmod o+w` la ferait tomber sans bruit. Le `:ro` ne vaut que pour la vue du conteneur : le Sergent ecrit cote hote comme avant.
|
- Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage : `:ro` est un suffixe de la destination dans Coolify, recopie tel quel dans le compose genere (`'/data/defenseurs:/data/defenseurs:ro'`). (2) Les droits Unix : le contenu appartient a `defenseur:defenseur` (uid et gid 1001), dossiers en 755/775, fichiers en 664, et le conteneur tourne en uid 1000 (`node`, gid 1000, sans groupe supplementaire), donc `test -w` y echoue meme sans `:ro`. Ce sont les bits de groupe qui donnent l'ecriture : cette couche tient parce que le groupe proprietaire n'est pas le gid 1000 (groupe de `node` dans l'image, et de `ubuntu` sur l'hote). Un `chgrp ubuntu` ou un `chmod o+w` la ferait tomber sans bruit. Le `:ro` ne vaut que pour la vue du conteneur : le Sergent ecrit cote hote comme avant.
|
||||||
- Verification apres tout redeploy, par trois vues : `docker inspect <conteneur> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} RW={{.RW}}{{println}}{{end}}'` (`RW=false` sur les deux montages Defenseurs, `RW=true` sur `/data/hosts`) ; `/proc/mounts` dans le conteneur (vue du noyau) ; et le **contenu** des routes de lecture, pas seulement leur code, car un montage manquant repond 200 `no_data`.
|
- Verification apres tout redeploy, par trois vues : `docker inspect <conteneur> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} RW={{.RW}}{{println}}{{end}}'` (`RW=false` sur les deux montages Defenseurs, `RW=true` sur `/data/hosts` et `/data/claude-usage`) ; `/proc/mounts` dans le conteneur (vue du noyau) ; et le **contenu** des routes de lecture, pas seulement leur code, car un montage manquant repond 200 `no_data`.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|
@ -62,15 +68,16 @@ 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) — 377 cas
|
- `npm test` (vitest) — 500 cas
|
||||||
|
- `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (119 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/<id>` toujours 413 a 4 097 ; ecriture atomique en mode 600 ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture ; journalisation des rejets ; `claude-usage.js` dans le `COPY`)
|
||||||
- `__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 (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__/ci.test.js` — epingles du workflow et des scripts de CI (48 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` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage` (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture) (14 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. 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.
|
- `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.
|
||||||
|
|
@ -78,13 +85,13 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Pas d'Express — HTTP natif Node.js uniquement
|
- Pas d'Express — HTTP natif Node.js uniquement
|
||||||
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
|
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js claude-usage.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
|
||||||
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
|
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
|
||||||
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
|
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
|
||||||
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`. Consequence : ce montage-la doit etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR`, et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas.
|
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`, et `POST /claude-usage` (#48) dans le dossier de `CLAUDE_USAGE_PATH`. Consequence : ces deux montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR` (ou, pour le seul cas `POST /claude-usage`, le dossier de `CLAUDE_USAGE_PATH`), et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas.
|
||||||
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`.
|
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeClaudeUsage()` suit le meme patron (temporaire `.claude-usage.json.<pid>.<ms>.tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle.
|
||||||
- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload. `loadAvg` doit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade en `neverSeen`. Les chaines sont tronquees, **pas filtrees** (caracteres de controle conserves) : la surete repose sur l'echappement du consommateur, React aujourd'hui.
|
- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload. `loadAvg` doit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade en `neverSeen`. Les chaines sont tronquees, **pas filtrees** (caracteres de controle conserves) : la surete repose sur l'echappement du consommateur, React aujourd'hui.
|
||||||
- Le plafond de corps a 4 Kio compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
|
- Le plafond de corps est passe par route a `readBody(req, res, maxBytes)` : 4 Kio (`MAX_BODY_BYTES`) pour `/hosts/<id>`, 64 Kio (`CLAUDE_USAGE_MAX_BODY_BYTES`, exporte par `claude-usage.js`) pour `/claude-usage`, le pire cas realiste estime par la spec pesant ~46 Ko (un corps qui sature toutes les bornes a la fois, cles de 64 caracteres et compteurs a 16 chiffres partout, depasserait 64 Kio et recevrait 413 : le plafond d'octets prime sur les bornes de liste). Il compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
|
||||||
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500.
|
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500.
|
||||||
- Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Lecture seule : suffixer la destination par `:ro`, que Coolify recopie tel quel dans le compose (verifie sur beta.463, #36). Un Directory Mount ne se modifie pas dans l'UI : sauvegarder le dossier hote, supprimer le montage **sans cocher l'effacement du dossier sur le serveur**, puis le recreer en Volume Mount **avec Source Path** (sans Source Path, c'est un volume nomme qui ne lit pas le dossier hote). Sous un parent en `:ro`, le point de montage imbrique doit deja exister cote hote : `/data/defenseurs/reports`, dossier vide root en 755, est a garder, car Docker ne peut pas le creer dans un parent en lecture seule et le conteneur ne demarrerait pas. Ne jamais monter les fichiers Defenseurs un par un : le Sergent ecrit par `rename` atomique, un bind de fichier resterait accroche a l'ancien inode. Details dans `la-compagnie-maximus/docs/coolify-ops.md`.
|
- Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Lecture seule : suffixer la destination par `:ro`, que Coolify recopie tel quel dans le compose (verifie sur beta.463, #36). Un Directory Mount ne se modifie pas dans l'UI : sauvegarder le dossier hote, supprimer le montage **sans cocher l'effacement du dossier sur le serveur**, puis le recreer en Volume Mount **avec Source Path** (sans Source Path, c'est un volume nomme qui ne lit pas le dossier hote). Sous un parent en `:ro`, le point de montage imbrique doit deja exister cote hote : `/data/defenseurs/reports`, dossier vide root en 755, est a garder, car Docker ne peut pas le creer dans un parent en lecture seule et le conteneur ne demarrerait pas. Ne jamais monter les fichiers Defenseurs un par un : le Sergent ecrit par `rename` atomique, un bind de fichier resterait accroche a l'ancien inode. Details dans `la-compagnie-maximus/docs/coolify-ops.md`.
|
||||||
- Severity threshold est asymetrique : `?severity=LOW` retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via `?severity=INFO` explicite (cache le bruit par defaut).
|
- Severity threshold est asymetrique : `?severity=LOW` retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via `?severity=INFO` explicite (cache le bruit par defaut).
|
||||||
|
|
|
||||||
92
README.md
92
README.md
|
|
@ -4,9 +4,11 @@ Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtim
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. The single write
|
Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. Each write
|
||||||
endpoint uses its own token, `$HOSTS_INGEST_TOKEN`, so a workstation agent can
|
endpoint has its own token — `$HOSTS_INGEST_TOKEN` for workstation snapshots,
|
||||||
push its snapshot without gaining read access to the Defenseurs reports.
|
`$CLAUDE_USAGE_INGEST_TOKEN` for the Claude usage snapshot — so an agent can
|
||||||
|
push its data without gaining read access to the Defenseurs reports, and
|
||||||
|
neither ingestion token opens the other route.
|
||||||
|
|
||||||
| Method | Path | Token | Description |
|
| Method | Path | Token | Description |
|
||||||
|--------|------|-------|-------------|
|
|--------|------|-------|-------------|
|
||||||
|
|
@ -17,6 +19,8 @@ push its snapshot without gaining read access to the Defenseurs reports.
|
||||||
| GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date |
|
| GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date |
|
||||||
| GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
|
| GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
|
||||||
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
|
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
|
||||||
|
| GET | `/claude-usage` | `HEALTH_TOKEN` | Latest Claude usage snapshot, with server-side staleness |
|
||||||
|
| POST | `/claude-usage` | `CLAUDE_USAGE_INGEST_TOKEN` | Ingest the Claude usage snapshot |
|
||||||
|
|
||||||
Routing resolves before authentication: an unknown path or a wrong method
|
Routing resolves before authentication: an unknown path or a wrong method
|
||||||
answers `404`, never `401`.
|
answers `404`, never `401`.
|
||||||
|
|
@ -77,6 +81,74 @@ unreadable — degrades to `{ id, receivedAt: null, ageSeconds: null, online:
|
||||||
false, neverSeen: true }` instead of failing the response, so the dashboard can
|
false, neverSeen: true }` instead of failing the response, so the dashboard can
|
||||||
say "agent not installed" during commissioning.
|
say "agent not installed" during commissioning.
|
||||||
|
|
||||||
|
### `POST /claude-usage`
|
||||||
|
|
||||||
|
Body: the Claude usage snapshot, contract `schemaVersion: 1`, shared with the
|
||||||
|
workstation agent and the admin dashboard:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"generatedAt": "2026-09-27T19:00:00.000Z", // workstation clock, informative
|
||||||
|
"claudeCodeVersion": "2.1.283",
|
||||||
|
"timeZone": "America/Toronto",
|
||||||
|
"limits": {
|
||||||
|
"status": "ok", // ok | token_expired | http_error | unavailable | disabled
|
||||||
|
"detail": "http_429", // optional reason code
|
||||||
|
"fetchedAt": "2026-09-27T19:00:00.000Z", // or null
|
||||||
|
"items": [ { "kind": "session", "group": "session", "percent": 1,
|
||||||
|
"severity": "normal", "resetsAt": "2026-09-27T21:30:00Z", "model": null } ]
|
||||||
|
},
|
||||||
|
"days": [ { "date": "2026-09-27",
|
||||||
|
"models": { "claude-opus-5-5": { "input": 2, "output": 1950, "cacheCreation": 43176,
|
||||||
|
"cacheRead": 33814, "requests": 1 } } } ],
|
||||||
|
"projects": [ { "name": "simpl-resultat", "work": 123456, "cacheRead": 7890123, "requests": 812 } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server rebuilds it from a whitelist (`sanitizeClaudeUsage` in
|
||||||
|
`claude-usage.js`, a pure module the agent can test against). An unknown field
|
||||||
|
is dropped; an invalid value is dropped (the key is left out), never coerced:
|
||||||
|
|
||||||
|
- numbers must be safe integers ≥ 0 (`Number.isSafeInteger`); `percent` 0–100;
|
||||||
|
- `limits.status` must be in the enum; `limits.detail` must match
|
||||||
|
`^[a-z0-9_]{1,32}$` (a reason code, never free text);
|
||||||
|
- `days` ≤ 31 (past that, the most recent 31), `date` `^\d{4}-\d{2}-\d{2}$`
|
||||||
|
and a real calendar day; `models` ≤ 12 keys matching
|
||||||
|
`^[a-z0-9][a-z0-9.\-]{0,63}$`; `limits.items` ≤ 12; `projects` ≤ 9;
|
||||||
|
- timestamps must be full ISO 8601 with an offset; other strings are
|
||||||
|
truncated to 128 characters.
|
||||||
|
|
||||||
|
| Code | Case |
|
||||||
|
|------|------|
|
||||||
|
| 204 | Snapshot accepted and written |
|
||||||
|
| 400 | Unparsable JSON, a body that is not an object, or `schemaVersion` other than 1 |
|
||||||
|
| 401 | Token missing or wrong — including `HOSTS_INGEST_TOKEN` or `HEALTH_TOKEN` |
|
||||||
|
| 413 | Body past 64 KiB (65 536 bytes received) — connection cut |
|
||||||
|
| 503 | `CLAUDE_USAGE_INGEST_TOKEN` not configured (fail-closed) |
|
||||||
|
|
||||||
|
`receivedAt` is stamped by the server; the file is written atomically (temp
|
||||||
|
file with the pid in its name, mode 600, then `rename`).
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST -H "Authorization: Bearer $CLAUDE_USAGE_INGEST_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" --data @claude-usage.json \
|
||||||
|
"https://health.lacompagniemaximus.com/claude-usage"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /claude-usage`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "staleAfterSeconds": 2700, "receivedAt": "…", "ageSeconds": 42,
|
||||||
|
"stale": false, "snapshot": { …sanitized snapshot… } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The stored file is re-run through the sanitizer on the way out.
|
||||||
|
`stale = ageSeconds > staleAfterSeconds`, decided by the server only — the
|
||||||
|
dashboard displays it, it does not recompute it. A missing or unreadable file
|
||||||
|
(corrupt, off-contract, no usable `receivedAt`) answers
|
||||||
|
`200 { "status": "no_data" }`.
|
||||||
|
|
||||||
### `GET /defenseurs/state`
|
### `GET /defenseurs/state`
|
||||||
|
|
||||||
Serves the Sergent's `findings-state.json` verbatim — per-finding `firstSeen`,
|
Serves the Sergent's `findings-state.json` verbatim — per-finding `firstSeen`,
|
||||||
|
|
@ -137,6 +209,9 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
|
||||||
| `HOSTS_ALLOWED_IDS` | `thinkpad` | Comma-separated allowlist of host ids |
|
| `HOSTS_ALLOWED_IDS` | `thinkpad` | Comma-separated allowlist of host ids |
|
||||||
| `HOSTS_INGEST_TOKEN` | — | Bearer token for `POST /hosts/<id>` (503 if missing) |
|
| `HOSTS_INGEST_TOKEN` | — | Bearer token for `POST /hosts/<id>` (503 if missing) |
|
||||||
| `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline |
|
| `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline |
|
||||||
|
| `CLAUDE_USAGE_INGEST_TOKEN` | — | Bearer token for `POST /claude-usage` (503 if missing) |
|
||||||
|
| `CLAUDE_USAGE_PATH` | `/data/claude-usage/claude-usage.json` | Where the Claude usage snapshot is **written** |
|
||||||
|
| `CLAUDE_USAGE_STALE_SECONDS` | `2700` | Age past which `GET /claude-usage` reports `stale: true` |
|
||||||
|
|
||||||
## Bind-mounts (Coolify)
|
## Bind-mounts (Coolify)
|
||||||
|
|
||||||
|
|
@ -158,14 +233,19 @@ only while the owning group is not gid 1000 (`node` in the image, `ubuntu` on
|
||||||
the host): a `chgrp ubuntu` would drop it silently.
|
the host): a `chgrp ubuntu` would drop it silently.
|
||||||
|
|
||||||
`__tests__/writes.test.js` pins that `node:fs` writes go nowhere but
|
`__tests__/writes.test.js` pins that `node:fs` writes go nowhere but
|
||||||
`HOSTS_DIR`, and that the only shell commands are the two read-only ones in
|
`HOSTS_DIR` — and, for `POST /claude-usage` only, the directory of
|
||||||
`metrics.js`, on every path it exercises: boot, nominal reads and ingest,
|
`CLAUDE_USAGE_PATH` — and that the only shell commands are the two read-only
|
||||||
rejections, invalid input, a corrupt file, missing files.
|
ones in `metrics.js`, on every path it exercises: boot, nominal reads and
|
||||||
|
ingest, rejections, invalid input, a corrupt file, missing files.
|
||||||
|
|
||||||
Read-write — the API is the writer:
|
Read-write — the API is the writer:
|
||||||
|
|
||||||
- `<host>/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid
|
- `<host>/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid
|
||||||
1000, the `node` user the container runs as, otherwise every ingest 500s.
|
1000, the `node` user the container runs as, otherwise every ingest 500s.
|
||||||
|
- `<host>/data/claude-usage/` -> `/data/claude-usage/` (directory of
|
||||||
|
`CLAUDE_USAGE_PATH`). Same rule: read-write, owned by uid 1000, otherwise
|
||||||
|
every `POST /claude-usage` 500s. Mount the directory, never the file: the
|
||||||
|
snapshot is replaced by `rename`, which a file bind would not follow.
|
||||||
|
|
||||||
## Workstation agent
|
## Workstation agent
|
||||||
|
|
||||||
|
|
|
||||||
866
__tests__/claude-usage.test.js
Normal file
866
__tests__/claude-usage.test.js
Normal file
|
|
@ -0,0 +1,866 @@
|
||||||
|
const http = require("node:http");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
// Claude usage snapshot (issue #48): POST /claude-usage pushed by the ThinkPad
|
||||||
|
// agent, GET /claude-usage read by the admin dashboard.
|
||||||
|
//
|
||||||
|
// Three things are pinned here.
|
||||||
|
// * The sanitizer, as a pure function (claude-usage.js): the whitelist, the
|
||||||
|
// contract bounds, and the rule that an invalid value is dropped rather
|
||||||
|
// than coerced.
|
||||||
|
// * The gate: a third token kind, `claude-ingest`, isolated both ways from
|
||||||
|
// the workstation token and from the read token, each ingestion token
|
||||||
|
// failing closed to a 503 that names its own variable.
|
||||||
|
// * The round trip: ceiling, atomic write, server-stamped receivedAt, and a
|
||||||
|
// staleness decided by the server alone.
|
||||||
|
|
||||||
|
const TOKEN = "test-token";
|
||||||
|
const INGEST_TOKEN = "test-ingest-token";
|
||||||
|
const CLAUDE_TOKEN = "test-claude-token";
|
||||||
|
|
||||||
|
// Restated rather than imported, so a silent change to the constant turns the
|
||||||
|
// matrix red instead of quietly re-drawing the ceiling (same rule as hosts).
|
||||||
|
const CLAUDE_MAX_BODY_BYTES = 65536;
|
||||||
|
const HOSTS_MAX_BODY_BYTES = 4096;
|
||||||
|
const DEFAULT_STALE_SECONDS = 2700;
|
||||||
|
const FROZEN_NOW = Date.UTC(2026, 8, 27, 19, 0, 0);
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let usagePath;
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
function validUsage() {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: "2026-09-27T19:00:00.000Z",
|
||||||
|
claudeCodeVersion: "2.1.283",
|
||||||
|
timeZone: "America/Toronto",
|
||||||
|
limits: {
|
||||||
|
status: "ok",
|
||||||
|
detail: "http_429",
|
||||||
|
fetchedAt: "2026-09-27T19:00:00.000Z",
|
||||||
|
items: [
|
||||||
|
{ kind: "session", group: "session", percent: 1, severity: "normal", resetsAt: "2026-09-27T21:30:00Z", model: null },
|
||||||
|
{ kind: "weekly_scoped", group: "weekly", percent: 63, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: "Fable" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
days: [
|
||||||
|
{ date: "2026-09-26", models: {} },
|
||||||
|
{
|
||||||
|
date: "2026-09-27",
|
||||||
|
models: { "claude-opus-5-5": { input: 2, output: 1950, cacheCreation: 43176, cacheRead: 33814, requests: 1 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{ name: "simpl-resultat", work: 123456, cacheRead: 7890123, requests: 812 },
|
||||||
|
{ name: "(autres)", work: 10, cacheRead: 20, requests: 3 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshSanitizer() {
|
||||||
|
delete require.cache[require.resolve("../claude-usage.js")];
|
||||||
|
return require("../claude-usage.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
delete require.cache[require.resolve("../index.js")];
|
||||||
|
delete require.cache[require.resolve("../claude-usage.js")];
|
||||||
|
const { handler } = require("../index.js");
|
||||||
|
server = http.createServer(handler);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!server) return resolve();
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartServer() {
|
||||||
|
await stopServer();
|
||||||
|
await startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(route, { method = "GET", auth, body, headers: extra } = {}) {
|
||||||
|
const headers = { ...extra };
|
||||||
|
if (auth) headers.Authorization = auth;
|
||||||
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||||
|
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
|
||||||
|
const parsed = await res.json().catch(() => ({}));
|
||||||
|
return { status: res.status, body: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function push({ auth = `Bearer ${CLAUDE_TOKEN}`, body = JSON.stringify(validUsage()) } = {}) {
|
||||||
|
return request("/claude-usage", { method: "POST", auth, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(auth = `Bearer ${TOKEN}`) {
|
||||||
|
return request("/claude-usage", { auth });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same contract as hosts.test.js: the 413 must come back as a status, a dead
|
||||||
|
// socket is reported as such and never folded into the expected value.
|
||||||
|
async function pushOversized(route, body, auth) {
|
||||||
|
try {
|
||||||
|
const { status } = await request(route, { method: "POST", auth, body });
|
||||||
|
return status;
|
||||||
|
} catch (err) {
|
||||||
|
return `transport error instead of a status: ${err.cause?.code || err.code || err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A JSON body of exactly `bytes` bytes around a payload that is otherwise valid.
|
||||||
|
function bodyOfExactly(base, bytes) {
|
||||||
|
const padded = { ...base, padding: "" };
|
||||||
|
const overhead = JSON.stringify(padded).length;
|
||||||
|
return JSON.stringify({ ...padded, padding: "x".repeat(bytes - overhead) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStored(record) {
|
||||||
|
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
|
||||||
|
fs.writeFileSync(usagePath, typeof record === "string" ? record : JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frozenRead() {
|
||||||
|
const realNow = Date.now;
|
||||||
|
Date.now = () => FROZEN_NOW;
|
||||||
|
try {
|
||||||
|
return await read();
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-claude-usage-test-"));
|
||||||
|
usagePath = path.join(tmpDir, "claude-usage", "claude-usage.json");
|
||||||
|
|
||||||
|
process.env.HEALTH_TOKEN = TOKEN;
|
||||||
|
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
|
||||||
|
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
|
||||||
|
process.env.CLAUDE_USAGE_PATH = usagePath;
|
||||||
|
delete process.env.CLAUDE_USAGE_STALE_SECONDS;
|
||||||
|
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
|
||||||
|
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
|
||||||
|
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
|
||||||
|
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
|
||||||
|
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
|
||||||
|
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
|
||||||
|
// Closed port: no request may reach the real IdP.
|
||||||
|
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
|
||||||
|
|
||||||
|
await startServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stopServer();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Other files reuse the worker: leave no claude-usage config behind.
|
||||||
|
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
|
||||||
|
delete process.env.CLAUDE_USAGE_PATH;
|
||||||
|
delete process.env.CLAUDE_USAGE_STALE_SECONDS;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- The sanitizer, as a pure function --------------------------------------
|
||||||
|
|
||||||
|
describe("sanitizeClaudeUsage — the contract, kept as is", () => {
|
||||||
|
test("a valid snapshot comes back identical", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
expect(sanitizeClaudeUsage(validUsage())).toEqual({ snapshot: validUsage() });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it is idempotent, so GET can re-run it on the stored file", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const noisy = { ...validUsage(), extra: 1, timeZone: "T".repeat(500) };
|
||||||
|
const once = sanitizeClaudeUsage(noisy).snapshot;
|
||||||
|
expect(sanitizeClaudeUsage(once).snapshot).toEqual(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the nullable fields keep an explicit null", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.fetchedAt = null;
|
||||||
|
payload.limits.items[0].resetsAt = null;
|
||||||
|
const { snapshot } = sanitizeClaudeUsage(payload);
|
||||||
|
expect(snapshot.limits.fetchedAt).toBeNull();
|
||||||
|
expect(snapshot.limits.items[0].resetsAt).toBeNull();
|
||||||
|
expect(snapshot.limits.items[0].model).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every status of the enum is kept", () => {
|
||||||
|
const { sanitizeClaudeUsage, LIMIT_STATUSES } = freshSanitizer();
|
||||||
|
expect(LIMIT_STATUSES).toEqual(["ok", "token_expired", "http_error", "unavailable", "disabled"]);
|
||||||
|
for (const status of LIMIT_STATUSES) {
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.status = status;
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.limits.status).toBe(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the documented detail codes are kept", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
for (const detail of ["no_credentials", "parse_error", "no_version", "timeout", "network", "bad_response", "http_503"]) {
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.detail = detail;
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.limits.detail).toBe(detail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("0 is a legitimate count and 0 and 100 legitimate percents", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.items[0].percent = 0;
|
||||||
|
payload.limits.items[1].percent = 100;
|
||||||
|
payload.projects[0].work = 0;
|
||||||
|
const { snapshot } = sanitizeClaudeUsage(payload);
|
||||||
|
expect(snapshot.limits.items.map((item) => item.percent)).toEqual([0, 100]);
|
||||||
|
expect(snapshot.projects[0].work).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeClaudeUsage — the whole body refused", () => {
|
||||||
|
test.each([
|
||||||
|
["an array", []],
|
||||||
|
["null", null],
|
||||||
|
["a string", "snapshot"],
|
||||||
|
["a number", 1],
|
||||||
|
])("error on %s", (_label, payload) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
expect(sanitizeClaudeUsage(payload)).toEqual({ error: "body must be a JSON object" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["missing", undefined],
|
||||||
|
["2", 2],
|
||||||
|
["the string \"1\"", "1"],
|
||||||
|
])("error when schemaVersion is %s", (_label, schemaVersion) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
expect(sanitizeClaudeUsage({ ...validUsage(), schemaVersion })).toEqual({ error: "schemaVersion must be 1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeClaudeUsage — invalid values are dropped, not coerced", () => {
|
||||||
|
test("unknown fields are dropped at every level", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.extra = "x";
|
||||||
|
payload.limits.extra = "x";
|
||||||
|
payload.limits.items[0].extra = "x";
|
||||||
|
payload.days[1].extra = "x";
|
||||||
|
payload.days[1].models["claude-opus-5-5"].extra = 1;
|
||||||
|
payload.projects[0].extra = "x";
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot).toEqual(validUsage());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a __proto__ key, top-level or as a model name, never survives", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const raw = JSON.stringify(validUsage())
|
||||||
|
.replace('"schemaVersion":1', '"__proto__":{"polluted":true},"schemaVersion":1')
|
||||||
|
.replace('"models":{}', '"models":{"__proto__":{"input":1}}');
|
||||||
|
const { snapshot } = sanitizeClaudeUsage(JSON.parse(raw));
|
||||||
|
expect(Object.keys(snapshot)).not.toContain("__proto__");
|
||||||
|
expect(Object.keys(snapshot.days[0].models)).toEqual([]);
|
||||||
|
expect({}.polluted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const BAD_NUMBERS = [
|
||||||
|
["negative", -1],
|
||||||
|
["past 2^53", 2 ** 53 + 2],
|
||||||
|
["exactly 2^53 (not a safe integer)", 2 ** 53],
|
||||||
|
["fractional", 1.5],
|
||||||
|
["a string", "12"],
|
||||||
|
["null", null],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(BAD_NUMBERS)("a %s model counter is dropped", (_label, value) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.days[1].models["claude-opus-5-5"].output = value;
|
||||||
|
const model = sanitizeClaudeUsage(payload).snapshot.days[1].models["claude-opus-5-5"];
|
||||||
|
expect(model).not.toHaveProperty("output");
|
||||||
|
expect(model.input).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(BAD_NUMBERS)("a %s project counter is dropped", (_label, value) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.projects[0].work = value;
|
||||||
|
const project = sanitizeClaudeUsage(payload).snapshot.projects[0];
|
||||||
|
expect(project).not.toHaveProperty("work");
|
||||||
|
expect(project.requests).toBe(812);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a number past 2^53 written in the raw JSON is dropped", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const raw = JSON.stringify(validUsage()).replace('"requests":812', '"requests":9007199254740993');
|
||||||
|
expect(sanitizeClaudeUsage(JSON.parse(raw)).snapshot.projects[0]).not.toHaveProperty("requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["over 100", 101],
|
||||||
|
["negative", -1],
|
||||||
|
["fractional", 63.5],
|
||||||
|
])("a percent %s is dropped", (_label, value) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.items[1].percent = value;
|
||||||
|
const item = sanitizeClaudeUsage(payload).snapshot.limits.items[1];
|
||||||
|
expect(item).not.toHaveProperty("percent");
|
||||||
|
expect(item.kind).toBe("weekly_scoped");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["an unknown value", "rate_limited"],
|
||||||
|
["a different case", "OK"],
|
||||||
|
["a number", 1],
|
||||||
|
])("a status that is %s is dropped", (_label, status) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.status = status;
|
||||||
|
const { limits } = sanitizeClaudeUsage(payload).snapshot;
|
||||||
|
expect(limits).not.toHaveProperty("status");
|
||||||
|
expect(limits.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// `detail` is a reason code, never free text: an upstream response body
|
||||||
|
// must not ride along to the dashboard.
|
||||||
|
test.each([
|
||||||
|
["free text with spaces", "HTTP 500 Internal Server Error"],
|
||||||
|
["markup", "<html>"],
|
||||||
|
["uppercase", "HTTP_429"],
|
||||||
|
["empty", ""],
|
||||||
|
["33 characters", "a".repeat(33)],
|
||||||
|
["a number", 429],
|
||||||
|
])("a detail that is %s is dropped", (_label, detail) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.detail = detail;
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.limits).not.toHaveProperty("detail");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a detail of exactly 32 characters is kept", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.detail = "a".repeat(32);
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.limits.detail).toBe("a".repeat(32));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["a date without time", "2026-09-27"],
|
||||||
|
["no offset", "2026-09-27T19:00:00"],
|
||||||
|
["an impossible instant", "2026-13-45T99:00:00Z"],
|
||||||
|
["free text", "yesterday"],
|
||||||
|
["a number", 1759000000000],
|
||||||
|
])("a timestamp that is %s is dropped", (_label, value) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.generatedAt = value;
|
||||||
|
payload.limits.fetchedAt = value;
|
||||||
|
payload.limits.items[0].resetsAt = value;
|
||||||
|
const { snapshot } = sanitizeClaudeUsage(payload);
|
||||||
|
expect(snapshot).not.toHaveProperty("generatedAt");
|
||||||
|
expect(snapshot.limits).not.toHaveProperty("fetchedAt");
|
||||||
|
expect(snapshot.limits.items[0]).not.toHaveProperty("resetsAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an offset timestamp is a valid ISO date", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = { ...validUsage(), generatedAt: "2026-09-27T15:00:00.000-04:00" };
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.generatedAt).toBe("2026-09-27T15:00:00.000-04:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["no zero padding", "2026-9-27"],
|
||||||
|
["a day that does not exist", "2026-02-31"],
|
||||||
|
["a timestamp", "2026-09-27T00:00:00Z"],
|
||||||
|
["a number", 20260927],
|
||||||
|
])("a day whose date is %s is dropped", (_label, date) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.days[0].date = date;
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.days.map((day) => day.date)).toEqual(["2026-09-27"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["uppercase", "Claude-Opus"],
|
||||||
|
["a leading dot", ".hidden"],
|
||||||
|
["a slash", "claude/opus"],
|
||||||
|
["65 characters", `a${"b".repeat(64)}`],
|
||||||
|
])("a model key that is %s is dropped", (_label, key) => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.days[0].models[key] = { input: 1 };
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.days[0].models).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a model key of 64 characters is kept", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
const key = `a${"b".repeat(63)}`;
|
||||||
|
payload.days[0].models[key] = { input: 1 };
|
||||||
|
expect(sanitizeClaudeUsage(payload).snapshot.days[0].models).toEqual({ [key]: { input: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strings are truncated to 128 characters", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.claudeCodeVersion = "v".repeat(500);
|
||||||
|
payload.timeZone = "t".repeat(500);
|
||||||
|
payload.limits.items[0].kind = "k".repeat(500);
|
||||||
|
payload.limits.items[1].model = "m".repeat(500);
|
||||||
|
payload.projects[0].name = "n".repeat(500);
|
||||||
|
const { snapshot } = sanitizeClaudeUsage(payload);
|
||||||
|
expect(snapshot.claudeCodeVersion).toBe("v".repeat(128));
|
||||||
|
expect(snapshot.timeZone).toBe("t".repeat(128));
|
||||||
|
expect(snapshot.limits.items[0].kind).toBe("k".repeat(128));
|
||||||
|
expect(snapshot.limits.items[1].model).toBe("m".repeat(128));
|
||||||
|
expect(snapshot.projects[0].name).toBe("n".repeat(128));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a section of the wrong type is dropped, the rest kept", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = { ...validUsage(), limits: "ok", days: {}, projects: null };
|
||||||
|
const { snapshot } = sanitizeClaudeUsage(payload);
|
||||||
|
expect(snapshot).not.toHaveProperty("limits");
|
||||||
|
expect(snapshot).not.toHaveProperty("days");
|
||||||
|
expect(snapshot).not.toHaveProperty("projects");
|
||||||
|
expect(snapshot.timeZone).toBe("America/Toronto");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entries that are not objects are dropped from every list", () => {
|
||||||
|
const { sanitizeClaudeUsage } = freshSanitizer();
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.days.unshift("2026-09-25", null);
|
||||||
|
payload.limits.items.push([1]);
|
||||||
|
payload.projects.push(42);
|
||||||
|
payload.days[3].models["claude-opus-5-5"] = [1, 2];
|
||||||
|
const { snapshot } = sanitizeClaudeUsage(payload);
|
||||||
|
expect(snapshot.days).toHaveLength(2);
|
||||||
|
expect(snapshot.days[1].models).toEqual({});
|
||||||
|
expect(snapshot.limits.items).toHaveLength(2);
|
||||||
|
expect(snapshot.projects).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeClaudeUsage — list bounds", () => {
|
||||||
|
function dayList(count) {
|
||||||
|
const start = Date.UTC(2026, 7, 1);
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
date: new Date(start + i * 86400000).toISOString().slice(0, 10),
|
||||||
|
models: {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("31 days are kept; past that, the most recent 31", () => {
|
||||||
|
const { sanitizeClaudeUsage, MAX_DAYS } = freshSanitizer();
|
||||||
|
expect(MAX_DAYS).toBe(31);
|
||||||
|
expect(sanitizeClaudeUsage({ ...validUsage(), days: dayList(31) }).snapshot.days).toEqual(dayList(31));
|
||||||
|
const days = sanitizeClaudeUsage({ ...validUsage(), days: dayList(40) }).snapshot.days;
|
||||||
|
expect(days).toEqual(dayList(40).slice(-31));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("12 models per day; past that, the first 12", () => {
|
||||||
|
const { sanitizeClaudeUsage, MAX_MODELS_PER_DAY } = freshSanitizer();
|
||||||
|
expect(MAX_MODELS_PER_DAY).toBe(12);
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.days[0].models = Object.fromEntries(
|
||||||
|
Array.from({ length: 15 }, (_, i) => [`model-${String(i).padStart(2, "0")}`, { requests: i }]),
|
||||||
|
);
|
||||||
|
const models = sanitizeClaudeUsage(payload).snapshot.days[0].models;
|
||||||
|
expect(Object.keys(models)).toHaveLength(12);
|
||||||
|
expect(Object.keys(models).at(-1)).toBe("model-11");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("12 limit items; past that, the first 12", () => {
|
||||||
|
const { sanitizeClaudeUsage, MAX_LIMIT_ITEMS } = freshSanitizer();
|
||||||
|
expect(MAX_LIMIT_ITEMS).toBe(12);
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.limits.items = Array.from({ length: 14 }, (_, i) => ({ kind: `k${i}`, percent: i }));
|
||||||
|
const items = sanitizeClaudeUsage(payload).snapshot.limits.items;
|
||||||
|
expect(items).toHaveLength(12);
|
||||||
|
expect(items.at(-1).kind).toBe("k11");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("9 projects (top 8 plus \"(autres)\"); past that, the first 9", () => {
|
||||||
|
const { sanitizeClaudeUsage, MAX_PROJECTS } = freshSanitizer();
|
||||||
|
expect(MAX_PROJECTS).toBe(9);
|
||||||
|
const payload = validUsage();
|
||||||
|
payload.projects = Array.from({ length: 11 }, (_, i) => ({ name: `p${i}`, work: i }));
|
||||||
|
const projects = sanitizeClaudeUsage(payload).snapshot.projects;
|
||||||
|
expect(projects).toHaveLength(9);
|
||||||
|
expect(projects.at(-1).name).toBe("p8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the body ceiling is 64 KiB", () => {
|
||||||
|
expect(freshSanitizer().CLAUDE_USAGE_MAX_BODY_BYTES).toBe(CLAUDE_MAX_BODY_BYTES);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- The gate ---------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("POST /claude-usage — authentication", () => {
|
||||||
|
test.each([
|
||||||
|
["no Authorization header", null],
|
||||||
|
["a wrong token", "Bearer wrong"],
|
||||||
|
["the workstation ingest token", `Bearer ${INGEST_TOKEN}`],
|
||||||
|
["the read token", `Bearer ${TOKEN}`],
|
||||||
|
["the Claude token without the Bearer scheme", CLAUDE_TOKEN],
|
||||||
|
["a prefix of the Claude token", `Bearer ${CLAUDE_TOKEN.slice(0, -1)}`],
|
||||||
|
])("401 with %s, and nothing is written", async (_label, auth) => {
|
||||||
|
const { status, body } = await push({ auth });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
expect(fs.existsSync(usagePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Claude token does not open POST /hosts/<id>", async () => {
|
||||||
|
const { status, body } = await request("/hosts/thinkpad", {
|
||||||
|
method: "POST",
|
||||||
|
auth: `Bearer ${CLAUDE_TOKEN}`,
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
expect(fs.existsSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["/claude-usage", "/hosts", "/health", "/defenseurs"])(
|
||||||
|
"the Claude token does not open GET %s",
|
||||||
|
async (route) => {
|
||||||
|
const { status, body } = await request(route, { auth: `Bearer ${CLAUDE_TOKEN}` });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the comparison goes through timingSafeEqual on two digests", async () => {
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const spy = vi.spyOn(crypto, "timingSafeEqual");
|
||||||
|
try {
|
||||||
|
await restartServer();
|
||||||
|
const { status } = await push({ auth: "Bearer x" });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
const [a, b] = spy.mock.calls[0];
|
||||||
|
expect([a.length, b.length]).toEqual([32, 32]);
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /claude-usage — authentication", () => {
|
||||||
|
test.each([
|
||||||
|
["no Authorization header", null],
|
||||||
|
["a wrong token", "Bearer wrong"],
|
||||||
|
["the workstation ingest token", `Bearer ${INGEST_TOKEN}`],
|
||||||
|
])("401 with %s", async (_label, auth) => {
|
||||||
|
const { status, body } = await read(auth);
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("401 when HEALTH_TOKEN is unset (fail-closed)", async () => {
|
||||||
|
delete process.env.HEALTH_TOKEN;
|
||||||
|
await restartServer();
|
||||||
|
const { status, body } = await read();
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("HEALTH_TOKEN not configured");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fail-closed — each ingestion token names its own variable", () => {
|
||||||
|
test("503 naming CLAUDE_USAGE_INGEST_TOKEN when it is unset", async () => {
|
||||||
|
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
|
||||||
|
await restartServer();
|
||||||
|
for (const auth of [null, `Bearer ${CLAUDE_TOKEN}`, `Bearer ${INGEST_TOKEN}`]) {
|
||||||
|
const { status, body } = await push({ auth });
|
||||||
|
expect(status).toBe(503);
|
||||||
|
expect(body.error).toBe("CLAUDE_USAGE_INGEST_TOKEN not configured");
|
||||||
|
}
|
||||||
|
expect(fs.existsSync(usagePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the other routes are unaffected when CLAUDE_USAGE_INGEST_TOKEN is unset", async () => {
|
||||||
|
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
|
||||||
|
await restartServer();
|
||||||
|
expect((await read()).status).toBe(200);
|
||||||
|
expect((await request("/hosts", { auth: `Bearer ${TOKEN}` })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/hosts keeps its 503 naming HOSTS_INGEST_TOKEN, and /claude-usage keeps working", async () => {
|
||||||
|
delete process.env.HOSTS_INGEST_TOKEN;
|
||||||
|
await restartServer();
|
||||||
|
const hosts = await request("/hosts/thinkpad", { method: "POST", auth: `Bearer ${CLAUDE_TOKEN}`, body: "{}" });
|
||||||
|
expect(hosts.status).toBe(503);
|
||||||
|
expect(hosts.body.error).toBe("HOSTS_INGEST_TOKEN not configured");
|
||||||
|
expect((await push()).status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routing", () => {
|
||||||
|
test.each([
|
||||||
|
["PUT", "/claude-usage"],
|
||||||
|
["DELETE", "/claude-usage"],
|
||||||
|
["POST", "/claude-usage/"],
|
||||||
|
["POST", "/claude-usage/x"],
|
||||||
|
["GET", "/claude-usage/../hosts/thinkpad.json"],
|
||||||
|
])("404 (not 401) on %s %s without Authorization", async (method, route) => {
|
||||||
|
const { status, body } = await request(route, { method });
|
||||||
|
expect(status).toBe(404);
|
||||||
|
expect(body.error).toBe("Not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rejections leave an audit trail", () => {
|
||||||
|
let warn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 401 on POST /claude-usage is logged", async () => {
|
||||||
|
await push({ auth: "Bearer wrong" });
|
||||||
|
const line = warn.mock.calls.at(-1)[0];
|
||||||
|
expect(line).toContain("[auth] 401 POST /claude-usage");
|
||||||
|
expect(line).toContain("id=-");
|
||||||
|
expect(line).toContain("reason=missing or invalid bearer token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 401 on GET /claude-usage is logged", async () => {
|
||||||
|
await read(null);
|
||||||
|
expect(warn.mock.calls.at(-1)[0]).toContain("[auth] 401 GET /claude-usage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 503 names its cause in the log", async () => {
|
||||||
|
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
|
||||||
|
await restartServer();
|
||||||
|
await push();
|
||||||
|
expect(warn.mock.calls.at(-1)[0]).toContain("reason=claude usage ingest token not configured");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Ingestion --------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("POST /claude-usage — the 64 KiB ceiling", () => {
|
||||||
|
const claudeAuth = `Bearer ${CLAUDE_TOKEN}`;
|
||||||
|
|
||||||
|
test("a body of exactly 65 536 bytes is accepted, the padding dropped", async () => {
|
||||||
|
const body = bodyOfExactly(validUsage(), CLAUDE_MAX_BODY_BYTES);
|
||||||
|
expect(Buffer.byteLength(body)).toBe(CLAUDE_MAX_BODY_BYTES);
|
||||||
|
const { status } = await push({ body });
|
||||||
|
expect(status).toBe(204);
|
||||||
|
expect(fs.readFileSync(usagePath, "utf-8")).not.toContain("padding");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body of 65 537 bytes is refused with 413 and nothing is written", async () => {
|
||||||
|
const body = bodyOfExactly(validUsage(), CLAUDE_MAX_BODY_BYTES + 1);
|
||||||
|
expect(await pushOversized("/claude-usage", body, claudeAuth)).toBe(413);
|
||||||
|
expect(fs.existsSync(usagePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /hosts/<id> keeps its own 4 KiB ceiling: 413 at 4 097 bytes", async () => {
|
||||||
|
const hostBase = {
|
||||||
|
hostname: "thinkpad-x1",
|
||||||
|
uptime: 4242,
|
||||||
|
cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 },
|
||||||
|
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
|
||||||
|
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
|
||||||
|
};
|
||||||
|
const body = bodyOfExactly(hostBase, HOSTS_MAX_BODY_BYTES + 1);
|
||||||
|
expect(await pushOversized("/hosts/thinkpad", body, `Bearer ${INGEST_TOKEN}`)).toBe(413);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /claude-usage — payload", () => {
|
||||||
|
test.each([
|
||||||
|
["truncated JSON", "{not json"],
|
||||||
|
["an empty body", ""],
|
||||||
|
])("400 on %s, nothing written", async (_label, body) => {
|
||||||
|
const res = await push({ body });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("Bad request: body must be valid JSON");
|
||||||
|
expect(fs.existsSync(usagePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("400 on a JSON array", async () => {
|
||||||
|
const res = await push({ body: "[]" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("Bad request: body must be a JSON object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("400 on another schemaVersion", async () => {
|
||||||
|
const res = await push({ body: JSON.stringify({ ...validUsage(), schemaVersion: 2 }) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("Bad request: schemaVersion must be 1");
|
||||||
|
expect(fs.existsSync(usagePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("204, the file holds the sanitized snapshot plus a server receivedAt, mode 600", async () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const sent = { ...validUsage(), extra: "dropped", receivedAt: "2000-01-01T00:00:00.000Z" };
|
||||||
|
const res = await push({ body: JSON.stringify(sent) });
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
|
const stored = JSON.parse(fs.readFileSync(usagePath, "utf-8"));
|
||||||
|
const { receivedAt, ...snapshot } = stored;
|
||||||
|
expect(snapshot).toEqual(validUsage());
|
||||||
|
// A client-supplied receivedAt is ignored: the server stamps it.
|
||||||
|
expect(new Date(receivedAt).getTime()).toBeGreaterThanOrEqual(before - 1000);
|
||||||
|
expect(fs.statSync(usagePath).mode & 0o777).toBe(0o600);
|
||||||
|
// Atomic write: no temp file left beside the snapshot.
|
||||||
|
expect(fs.readdirSync(path.dirname(usagePath))).toEqual(["claude-usage.json"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a second push replaces the first", async () => {
|
||||||
|
await push();
|
||||||
|
const second = { ...validUsage(), claudeCodeVersion: "2.1.284" };
|
||||||
|
expect((await push({ body: JSON.stringify(second) })).status).toBe(204);
|
||||||
|
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).claudeCodeVersion).toBe("2.1.284");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("500 when the snapshot cannot be written, and no temp file is left", async () => {
|
||||||
|
// The parent of the file is a regular file: mkdir fails, nothing lands.
|
||||||
|
const blocker = path.join(tmpDir, "blocker");
|
||||||
|
fs.writeFileSync(blocker, "");
|
||||||
|
process.env.CLAUDE_USAGE_PATH = path.join(blocker, "claude-usage.json");
|
||||||
|
await restartServer();
|
||||||
|
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const res = await push();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toBe("Internal error");
|
||||||
|
} finally {
|
||||||
|
error.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Reading ----------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("GET /claude-usage", () => {
|
||||||
|
test("no file -> { status: \"no_data\" }", async () => {
|
||||||
|
const res = await read();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ status: "no_data" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST then GET: identical snapshot, receivedAt, ageSeconds, stale", async () => {
|
||||||
|
const sent = { ...validUsage(), extra: "dropped" };
|
||||||
|
expect((await push({ body: JSON.stringify(sent) })).status).toBe(204);
|
||||||
|
const stored = JSON.parse(fs.readFileSync(usagePath, "utf-8"));
|
||||||
|
|
||||||
|
const res = await read();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual(["ageSeconds", "receivedAt", "snapshot", "stale", "staleAfterSeconds"]);
|
||||||
|
expect(res.body.snapshot).toEqual(validUsage());
|
||||||
|
expect(res.body.receivedAt).toBe(stored.receivedAt);
|
||||||
|
expect(res.body.ageSeconds).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(res.body.ageSeconds).toBeLessThan(5);
|
||||||
|
expect(res.body.stale).toBe(false);
|
||||||
|
expect(res.body.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[DEFAULT_STALE_SECONDS - 1, false],
|
||||||
|
[DEFAULT_STALE_SECONDS, false],
|
||||||
|
[DEFAULT_STALE_SECONDS + 1, true],
|
||||||
|
])("aged %i s -> stale %s (server clock, exact boundary)", async (age, stale) => {
|
||||||
|
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW - age * 1000).toISOString() });
|
||||||
|
const res = await frozenRead();
|
||||||
|
expect(res.body.ageSeconds).toBe(age);
|
||||||
|
expect(res.body.stale).toBe(stale);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLAUDE_USAGE_STALE_SECONDS moves the boundary", async () => {
|
||||||
|
process.env.CLAUDE_USAGE_STALE_SECONDS = "60";
|
||||||
|
await restartServer();
|
||||||
|
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW - 61 * 1000).toISOString() });
|
||||||
|
const res = await frozenRead();
|
||||||
|
expect(res.body.staleAfterSeconds).toBe(60);
|
||||||
|
expect(res.body.stale).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["0", "-5", "abc"])("an invalid CLAUDE_USAGE_STALE_SECONDS (%s) falls back to 2700", async (value) => {
|
||||||
|
process.env.CLAUDE_USAGE_STALE_SECONDS = value;
|
||||||
|
await restartServer();
|
||||||
|
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW).toISOString() });
|
||||||
|
expect((await frozenRead()).body.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a receivedAt in the future reads as age 0, not negative", async () => {
|
||||||
|
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW + 60000).toISOString() });
|
||||||
|
const res = await frozenRead();
|
||||||
|
expect(res.body.ageSeconds).toBe(0);
|
||||||
|
expect(res.body.stale).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the stored file is re-sanitized on the way out", async () => {
|
||||||
|
const raw = validUsage();
|
||||||
|
raw.extra = "<script>";
|
||||||
|
raw.limits.detail = "HTTP 500 body";
|
||||||
|
raw.projects[0].work = -1;
|
||||||
|
writeStored({ ...raw, receivedAt: new Date(FROZEN_NOW).toISOString() });
|
||||||
|
const { body } = await frozenRead();
|
||||||
|
expect(body.snapshot).not.toHaveProperty("extra");
|
||||||
|
expect(body.snapshot.limits).not.toHaveProperty("detail");
|
||||||
|
expect(body.snapshot.projects[0]).not.toHaveProperty("work");
|
||||||
|
expect(body.snapshot).not.toHaveProperty("receivedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("degrades to no_data", () => {
|
||||||
|
let error;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
error.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["a corrupt file", "{corrupt"],
|
||||||
|
["an array", "[]"],
|
||||||
|
["another schemaVersion", JSON.stringify({ ...validUsage(), schemaVersion: 2, receivedAt: "2026-09-27T19:00:00.000Z" })],
|
||||||
|
["no receivedAt", JSON.stringify(validUsage())],
|
||||||
|
["an unusable receivedAt", JSON.stringify({ ...validUsage(), receivedAt: "never" })],
|
||||||
|
])("on %s", async (_label, raw) => {
|
||||||
|
writeStored(raw);
|
||||||
|
const res = await read();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ status: "no_data" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when the path is a directory", async () => {
|
||||||
|
fs.mkdirSync(usagePath, { recursive: true });
|
||||||
|
const res = await read();
|
||||||
|
expect(res.body).toEqual({ status: "no_data" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the server image", () => {
|
||||||
|
test("the Dockerfile COPY line ships claude-usage.js", () => {
|
||||||
|
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
|
||||||
|
const copy = dockerfile.split("\n").find((line) => /^\s*COPY\s/.test(line));
|
||||||
|
expect(copy.trim().split(/\s+/)).toContain("claude-usage.js");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,10 @@ const path = require("node:path");
|
||||||
const child_process = require("node:child_process");
|
const child_process = require("node:child_process");
|
||||||
const { fileURLToPath } = require("node:url");
|
const { fileURLToPath } = require("node:url");
|
||||||
|
|
||||||
// The API's only write surface is HOSTS_DIR (issue #36).
|
// The API's write surfaces are HOSTS_DIR (issue #36) and, since #48, the
|
||||||
|
// directory of CLAUDE_USAGE_PATH, written by POST /claude-usage only. Every
|
||||||
|
// case below allows HOSTS_DIR alone, except the POST /claude-usage one, which
|
||||||
|
// allows its own directory alone.
|
||||||
//
|
//
|
||||||
// Since 2026-09-30 the two Defenseurs mounts are read-only in the container
|
// Since 2026-09-30 the two Defenseurs mounts are read-only in the container
|
||||||
// (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR
|
// (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR
|
||||||
|
|
@ -43,6 +46,7 @@ const { fileURLToPath } = require("node:url");
|
||||||
const REPO_ROOT = path.resolve(__dirname, "..");
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||||
const TOKEN = "test-token";
|
const TOKEN = "test-token";
|
||||||
const INGEST_TOKEN = "test-ingest-token";
|
const INGEST_TOKEN = "test-ingest-token";
|
||||||
|
const CLAUDE_TOKEN = "test-claude-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
|
// The real functions, taken at load time, before any spy: the test's own
|
||||||
|
|
@ -160,9 +164,11 @@ function purgeRuntimeModules() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Equal to HOSTS_DIR or below it. path.relative rather than startsWith, so a
|
// Equal to the allowed directory or below it — HOSTS_DIR unless the case names
|
||||||
// sibling such as `<tmp>/hosts-evil` does not pass. A file descriptor, or any
|
// another one (only POST /claude-usage does, for its own directory, #48).
|
||||||
// argument that is not a path, cannot be proven and fails.
|
// path.relative rather than startsWith, so a sibling such as `<tmp>/hosts-evil`
|
||||||
|
// does not pass. A file descriptor, or any argument that is not a path, cannot
|
||||||
|
// be proven and fails.
|
||||||
function toPath(arg) {
|
function toPath(arg) {
|
||||||
if (typeof arg === "string") return path.resolve(arg);
|
if (typeof arg === "string") return path.resolve(arg);
|
||||||
if (Buffer.isBuffer(arg)) return path.resolve(arg.toString());
|
if (Buffer.isBuffer(arg)) return path.resolve(arg.toString());
|
||||||
|
|
@ -170,8 +176,8 @@ function toPath(arg) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function violations(writes) {
|
function violations(writes, allowedDir = process.env.HOSTS_DIR) {
|
||||||
const hostsDir = path.resolve(process.env.HOSTS_DIR);
|
const hostsDir = path.resolve(allowedDir);
|
||||||
return writes.flatMap(({ fn, targets }) =>
|
return writes.flatMap(({ fn, targets }) =>
|
||||||
targets
|
targets
|
||||||
.filter((arg) => {
|
.filter((arg) => {
|
||||||
|
|
@ -194,6 +200,20 @@ function validSnapshot() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validClaudeUsage() {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timeZone: "America/Toronto",
|
||||||
|
limits: { status: "ok", fetchedAt: "2026-09-30T07:30:00.000Z", items: [] },
|
||||||
|
days: [{ date: "2026-09-30", models: { "claude-opus-5-5": { input: 1, output: 2, requests: 1 } } }],
|
||||||
|
projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function claudeUsageFixturePath() {
|
||||||
|
return path.join(tmpDir, "claude-usage", "claude-usage.json");
|
||||||
|
}
|
||||||
|
|
||||||
function writeJson(file, value) {
|
function writeJson(file, value) {
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
fs.writeFileSync(file, JSON.stringify(value));
|
fs.writeFileSync(file, JSON.stringify(value));
|
||||||
|
|
@ -271,6 +291,11 @@ function writeFixtures() {
|
||||||
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(defenseurs, "agents-map.json");
|
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(defenseurs, "agents-map.json");
|
||||||
process.env.HOSTS_DIR = hosts;
|
process.env.HOSTS_DIR = hosts;
|
||||||
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
|
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
|
||||||
|
// The Claude usage snapshot (#48): its own directory, beside HOSTS_DIR and
|
||||||
|
// not under it, so a write there is checked against its own rule.
|
||||||
|
writeJson(claudeUsageFixturePath(), { ...validClaudeUsage(), receivedAt: new Date().toISOString() });
|
||||||
|
process.env.CLAUDE_USAGE_PATH = claudeUsageFixturePath();
|
||||||
|
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
|
||||||
// Closed port: /health must never reach the real IdP.
|
// Closed port: /health must never reach the real IdP.
|
||||||
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
|
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +351,7 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
|
||||||
expect(host.receivedAt).toEqual(expect.any(String));
|
expect(host.receivedAt).toEqual(expect.any(String));
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)],
|
||||||
];
|
];
|
||||||
|
|
||||||
test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => {
|
test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => {
|
||||||
|
|
@ -357,6 +383,48 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
|
||||||
expect(foreignCommands()).toEqual([]);
|
expect(foreignCommands()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The second write surface (#48). Its directory is the only one this case
|
||||||
|
// allows: a write under HOSTS_DIR from this route would fail it too.
|
||||||
|
test("POST /claude-usage writes, and only in the directory of CLAUDE_USAGE_PATH", async () => {
|
||||||
|
const { status } = await send("/claude-usage", {
|
||||||
|
method: "POST",
|
||||||
|
auth: `Bearer ${CLAUDE_TOKEN}`,
|
||||||
|
body: JSON.stringify(validClaudeUsage()),
|
||||||
|
});
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await settle();
|
||||||
|
const writes = drain();
|
||||||
|
expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"]));
|
||||||
|
expect(violations(writes, path.dirname(process.env.CLAUDE_USAGE_PATH))).toEqual([]);
|
||||||
|
expect(foreignCommands()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /claude-usage rejections and invalid input write nothing at all", async () => {
|
||||||
|
expect(bootWrites).not.toHaveLength(0);
|
||||||
|
const claude = `Bearer ${CLAUDE_TOKEN}`;
|
||||||
|
const rejected = [
|
||||||
|
[401, { auth: null, body: "{}" }],
|
||||||
|
[401, { auth: `Bearer ${INGEST_TOKEN}`, body: JSON.stringify(validClaudeUsage()) }],
|
||||||
|
[400, { auth: claude, body: "{not json" }],
|
||||||
|
[400, { auth: claude, body: JSON.stringify({ ...validClaudeUsage(), schemaVersion: 2 }) }],
|
||||||
|
[413, { auth: claude, body: "x".repeat(66000) }],
|
||||||
|
];
|
||||||
|
for (const [expected, options] of rejected) {
|
||||||
|
const { status } = await send("/claude-usage", { method: "POST", ...options });
|
||||||
|
expect([options.body.length, status]).toEqual([options.body.length, expected]);
|
||||||
|
}
|
||||||
|
// A corrupt stored snapshot: where a "repair it on read" write would go.
|
||||||
|
real.writeFileSync(process.env.CLAUDE_USAGE_PATH, "{corrupt");
|
||||||
|
const { status, body } = await send("/claude-usage");
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({ status: "no_data" });
|
||||||
|
|
||||||
|
await settle();
|
||||||
|
expect(drain()).toEqual([]);
|
||||||
|
expect(foreignCommands()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
// The branches the nominal cases never take, and where the most plausible
|
// The branches the nominal cases never take, and where the most plausible
|
||||||
// future writes would land: quarantining a corrupt file, recreating a
|
// future writes would land: quarantining a corrupt file, recreating a
|
||||||
// missing one, logging a rejection or a refused payload to disk.
|
// missing one, logging a rejection or a refused payload to disk.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue