feat(backups): GET /backups and POST /backups/pull #60

Merged
maximus merged 2 commits from issue-58-backups-routes into main 2026-10-12 23:15:44 +00:00
2 changed files with 12 additions and 3 deletions
Showing only changes of commit 3f0abc1871 - Show all commits

View file

@ -32,7 +32,7 @@ Consequence voulue : un appelant non authentifie sur une route inconnue voit 404
- `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()` - `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`, `HOSTS_INGEST_TOKEN`, `CLAUDE_USAGE_INGEST_TOKEN` et `BACKUPS_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".
## Config ## Config
@ -77,8 +77,8 @@ 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) — 865 cas - `npm test` (vitest) — 866 cas
- `__tests__/backups.test.js` — `GET /backups`, `POST /backups/pull` et les deux validateurs de `backups.js` (101 cas, issue #58 : contrat du script VPS rendu tel quel, `rotation` et `retentionDays` compris, et idempotence ; `null` garde ; champ inconnu jete a chaque niveau, `__proto__` compris ; code d'erreur hors liste jete avec `ok: false` garde, et chacun des huit codes garde ; valeur invalide jetee, jamais forcee ; nom de base refuse jete, l'entree gardee ; borne de 64 ; refus entier (pas un objet, `schemaVersion` != 1, `generatedAt` absent ou incomplet) ; corps du poste strict (16 refus) ; 503 nommant la variable, 401 avec chacun des trois autres tokens, le token des sauvegardes n'ouvrant rien d'autre, `404 (not 401)` sur `GET /backups/pull` et `POST /backups`, espion `timingSafeEqual` ; 204 et fichier en mode 600, `receivedAt` du client ignore, 400 qui laisse l'ancien fichier en place, 204 a 1 024 octets et 413 a 1 025, temporaire supprime quand le `rename` echoue et journal sans chemin ; lecture : `no_data`, chaque bloc seul, fraicheur a la seconde pres sous horloge figee (36 h pile frais, + 1 s perime), age du poste sur `receivedAt` et non `pulledAt`, `generatedAt` futur -> 0, `BACKUPS_STALE_SECONDS` et son repli ; fichier casse de chaque cote -> bloc `null` + code, l'autre servi, et jamais masque derriere `no_data` ; dossier a la place du fichier -> `unreadable`, `EISDIR` journalise sans chemin ; `backups.js` dans le `COPY`) - `__tests__/backups.test.js` — `GET /backups`, `POST /backups/pull` et les deux validateurs de `backups.js` (102 cas, issue #58 : contrat du script VPS rendu tel quel, `rotation` et `retentionDays` compris, et idempotence ; `null` garde ; champ inconnu jete a chaque niveau, `__proto__` compris ; code d'erreur hors liste jete avec `ok: false` garde, et chacun des huit codes garde ; valeur invalide jetee, jamais forcee ; nom de base refuse jete, l'entree gardee ; borne de 64 ; refus entier (pas un objet, `schemaVersion` != 1, `generatedAt` absent ou incomplet) ; corps du poste strict (16 refus) ; 503 nommant la variable, 401 avec chacun des trois autres tokens, le token des sauvegardes n'ouvrant rien d'autre, `404 (not 401)` sur `GET /backups/pull` et `POST /backups`, espion `timingSafeEqual` ; 204 et fichier en mode 600, `receivedAt` du client ignore, 400 qui laisse l'ancien fichier en place, 204 a 1 024 octets et 413 a 1 025, temporaire supprime quand le `rename` echoue et journal sans chemin ; lecture : `no_data`, chaque bloc seul, fraicheur a la seconde pres sous horloge figee (36 h pile frais, + 1 s perime), age du poste sur `receivedAt` et non `pulledAt`, `generatedAt` futur -> 0, `BACKUPS_STALE_SECONDS` et son repli ; fichier casse de chaque cote -> bloc `null` + code, l'autre servi, et jamais masque derriere `no_data` ; dossier a la place du fichier -> `unreadable`, `EISDIR` journalise sans chemin ; `backups.js` dans le `COPY`)
- `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (123 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, et nettoyage du temporaire quand le `rename` echoue apres son ecriture (#54 : cible = dossier non vide) ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture, `receivedAt` compris s'il n'est pas une date ISO complete avec decalage (#54 : `"1"`, date seule, heure sans decalage) ; journalisation des rejets ; `claude-usage.js` dans le `COPY`) - `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (123 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, et nettoyage du temporaire quand le `rename` echoue apres son ecriture (#54 : cible = dossier non vide) ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture, `receivedAt` compris s'il n'est pas une date ISO complete avec decalage (#54 : `"1"`, date seule, heure sans decalage) ; journalisation des rejets ; `claude-usage.js` dans le `COPY`)
- `__tests__/claude-usage-history.test.js` — archive des jours clos et `GET /claude-usage/history` (76 cas, issue #55 : arithmetique des mois ; `closedDays` (date la plus recente et dates posterieures a la reception ecartees) ; `mergeDays` avec ses deux regles (dernier gagne, date archivee gagne), resultat trie, entrees intactes ; regroupement par le prefixe de la chaine ; relecture d'un fichier mensuel par la liste blanche (champ inconnu, `__proto__`, valeur invalide, date hors du mois, fichier hors format -> illisible) ; `parseRange` (defauts, 36 mois pile, 37 refuses, `from` seul trop ancien, formats) ; POST : tout sauf le dernier jour, mode 600, rien pour un snapshot d'un jour, second POST identique octet pour octet, jour remplace, jour sorti de la fenetre garde, deux mois, ordre d'envoi et date repetee, jour date apres la reception jamais archive, mois illisible ou hors format jamais ecrase (204, snapshot ecrit, autre mois ecrit, journal sans contenu), dossier impossible a creer, `rename` en echec sur un mois (temporaire supprime, l'autre mois ecrit), rien d'archive sur un POST refuse ou un snapshot non ecrit ; route : defauts, selection, mois illisible omis et journalise, re-sanitisation, fichier mensuel futur et nom de fichier parasite ignores comme fin par defaut, 400, token de lecture seul, 404 sur POST, aller-retour ; import lance en vrai processus : date archivee jamais remplacee, seul `history/` change, entree standard, fichier invalide (pas du JSON, sans `days`, jour invalide, date repetee) qui n'ecrit rien, mois illisible saute, fichier absent, erreurs d'usage ; `claude-usage-history.js` dans le `COPY`) - `__tests__/claude-usage-history.test.js` — archive des jours clos et `GET /claude-usage/history` (76 cas, issue #55 : arithmetique des mois ; `closedDays` (date la plus recente et dates posterieures a la reception ecartees) ; `mergeDays` avec ses deux regles (dernier gagne, date archivee gagne), resultat trie, entrees intactes ; regroupement par le prefixe de la chaine ; relecture d'un fichier mensuel par la liste blanche (champ inconnu, `__proto__`, valeur invalide, date hors du mois, fichier hors format -> illisible) ; `parseRange` (defauts, 36 mois pile, 37 refuses, `from` seul trop ancien, formats) ; POST : tout sauf le dernier jour, mode 600, rien pour un snapshot d'un jour, second POST identique octet pour octet, jour remplace, jour sorti de la fenetre garde, deux mois, ordre d'envoi et date repetee, jour date apres la reception jamais archive, mois illisible ou hors format jamais ecrase (204, snapshot ecrit, autre mois ecrit, journal sans contenu), dossier impossible a creer, `rename` en echec sur un mois (temporaire supprime, l'autre mois ecrit), rien d'archive sur un POST refuse ou un snapshot non ecrit ; route : defauts, selection, mois illisible omis et journalise, re-sanitisation, fichier mensuel futur et nom de fichier parasite ignores comme fin par defaut, 400, token de lecture seul, 404 sur POST, aller-retour ; import lance en vrai processus : date archivee jamais remplacee, seul `history/` change, entree standard, fichier invalide (pas du JSON, sans `days`, jour invalide, date repetee) qui n'ecrit rien, mois illisible saute, fichier absent, erreurs d'usage ; `claude-usage-history.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)

View file

@ -288,6 +288,15 @@ describe("sanitizeBackupsStatus — the whitelist", () => {
expect(status.retentionDays).toEqual({ offsite: 30 }); expect(status.retentionDays).toEqual({ offsite: 30 });
}); });
test("an invalid offsite retention is dropped, local kept", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.retentionDays = { local: 7, offsite: "30" };
expect(sanitizeBackupsStatus(payload).status.retentionDays).toEqual({ local: 7 });
payload.retentionDays = { local: 7, offsite: -1 };
expect(sanitizeBackupsStatus(payload).status.retentionDays).toEqual({ local: 7 });
});
test("a database name the VPS script refused is dropped, the entry and its code kept", () => { test("a database name the VPS script refused is dropped, the entry and its code kept", () => {
const { sanitizeBackupsStatus } = freshModule(); const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus(); const payload = validStatus();