feat(backups): pass the files[] block of status.json through GET /backups #62
3 changed files with 144 additions and 6 deletions
|
|
@ -16,7 +16,7 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
|
||||||
- `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` en date ISO complete avec decalage, meme regle que `GET /hosts` depuis #54). 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.
|
- `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` en date ISO complete avec decalage, meme regle que `GET /hosts` depuis #54). 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.
|
||||||
- `GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM` — jours archives (#55), token de lecture. `{ from, to, days }`, jours tries du plus ancien au plus recent. Les deux parametres sont optionnels : `to` vaut par defaut le mois du dernier fichier archive (le mois courant UTC si rien n'est archive ou si ce fichier est date apres le mois courant, ou `from` s'il est plus tard), `from` vaut `to` moins 11 mois (12 mois, bornes comprises). 400 si un format est invalide (mois `01`-`12`, chaine vide comprise), si `from > to`, ou au-dela de 36 mois. Un mois absent est vide ; un mois illisible est omis et journalise ; chaque fichier est **re-sanitise** a la lecture par le nettoyeur de jour du snapshot (`sanitizeDay`, exporte par `claude-usage.js`), et un jour date hors de son mois est jete. Rattrapage ponctuel des jours deja sortis de la fenetre : `agent/claude-usage/backfill-history.js` sur le poste, puis `docker exec -i <conteneur> node claude-usage-history.js --import - < fichier`, ou **une date deja archivee gagne** (procedure dans `agent/README.md`).
|
- `GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM` — jours archives (#55), token de lecture. `{ from, to, days }`, jours tries du plus ancien au plus recent. Les deux parametres sont optionnels : `to` vaut par defaut le mois du dernier fichier archive (le mois courant UTC si rien n'est archive ou si ce fichier est date apres le mois courant, ou `from` s'il est plus tard), `from` vaut `to` moins 11 mois (12 mois, bornes comprises). 400 si un format est invalide (mois `01`-`12`, chaine vide comprise), si `from > to`, ou au-dela de 36 mois. Un mois absent est vide ; un mois illisible est omis et journalise ; chaque fichier est **re-sanitise** a la lecture par le nettoyeur de jour du snapshot (`sanitizeDay`, exporte par `claude-usage.js`), et un jour date hors de son mois est jete. Rattrapage ponctuel des jours deja sortis de la fenetre : `agent/claude-usage/backfill-history.js` sur le poste, puis `docker exec -i <conteneur> node claude-usage-history.js --import - < fichier`, ou **une date deja archivee gagne** (procedure dans `agent/README.md`).
|
||||||
- `POST /backups/pull` — **troisieme surface d'ecriture** (issue #58) : compte rendu du tirage quotidien des sauvegardes PostgreSQL chiffrees par l'agent du ThinkPad (`agent/pg-backup-pull/`, la-compagnie-maximus#220). Auth par `BACKUPS_INGEST_TOKEN`, son propre token (aucun des trois autres n'y passe, lui n'ouvre rien d'autre). Corps `{ pulledAt, files, ok, error? }` valide **strictement** par `validateBackupsPull()` (module pur `backups.js`) : `pulledAt` ISO complet avec decalage, `files` entier sur >= 0, `ok` booleen, `error` exige si `ok` est faux et refuse sinon, dans `pull_failed|rotation_failed`. Tout champ invalide ou manquant -> 400 (contrairement a `/claude-usage`, qui jette la valeur : le corps n'a que quatre champs, un agent casse doit l'apprendre) ; un champ inconnu, `receivedAt` du client compris, est jete. Codes : 204 / 400 / 401 / 413 corps > 1 Kio (1 024 octets recus ; l'issue disait 400, garde 413 comme les deux autres ingestions, l'agent n'examine qu'un 2xx) / 503 `BACKUPS_INGEST_TOKEN not configured`. `receivedAt` pose par le serveur, ecriture atomique par `writeJsonAtomic()` (temporaire `.pull.json.<pid>.<ms>.tmp`, mode 600, `renameSync`), le meme helper que `POST /claude-usage`.
|
- `POST /backups/pull` — **troisieme surface d'ecriture** (issue #58) : compte rendu du tirage quotidien des sauvegardes PostgreSQL chiffrees par l'agent du ThinkPad (`agent/pg-backup-pull/`, la-compagnie-maximus#220). Auth par `BACKUPS_INGEST_TOKEN`, son propre token (aucun des trois autres n'y passe, lui n'ouvre rien d'autre). Corps `{ pulledAt, files, ok, error? }` valide **strictement** par `validateBackupsPull()` (module pur `backups.js`) : `pulledAt` ISO complet avec decalage, `files` entier sur >= 0, `ok` booleen, `error` exige si `ok` est faux et refuse sinon, dans `pull_failed|rotation_failed`. Tout champ invalide ou manquant -> 400 (contrairement a `/claude-usage`, qui jette la valeur : le corps n'a que quatre champs, un agent casse doit l'apprendre) ; un champ inconnu, `receivedAt` du client compris, est jete. Codes : 204 / 400 / 401 / 413 corps > 1 Kio (1 024 octets recus ; l'issue disait 400, garde 413 comme les deux autres ingestions, l'agent n'examine qu'un 2xx) / 503 `BACKUPS_INGEST_TOKEN not configured`. `receivedAt` pose par le serveur, ecriture atomique par `writeJsonAtomic()` (temporaire `.pull.json.<pid>.<ms>.tmp`, mode 600, `renameSync`), le meme helper que `POST /claude-usage`.
|
||||||
- `GET /backups` — `{ vps, lenovo, vpsError?, lenovoError? }`, token de lecture (#58). `vps` = `status.json` ecrit par `backup-postgres.sh` sur le VPS (contrat `schemaVersion: 1` : `generatedAt`, `ok`, `expectedCount`, `databases[]`, `globals`, `mirror`, `rotation`, `retentionDays`), reconstruit par `sanitizeBackupsStatus()` en liste blanche : champ inconnu jete, valeur invalide jetee (jamais forcee), `null` garde sur `dumpedAt` et `uploadedAt`, `error` hors de la liste fermee (`dump_failed`, `dump_empty`, `restore_list_failed`, `invalid_name`, `encrypt_failed`, `mirror_upload_failed`, `mirror_purge_failed`, `rotation_failed`) jete en gardant `ok: false` (un stderr de `pg_dump`/`rclone` ne doit jamais atteindre le dashboard), nom de base hors `^[a-z0-9_]{1,63}$` jete (une entree `invalid_name` peut porter n'importe quel texte), `databases` borne a 64. `lenovo` = dernier `POST /backups/pull`, revalide a la lecture. Chaque bloc recoit du **serveur** `ageSeconds`, `stale` (`ageSeconds > BACKUPS_STALE_SECONDS`) et `staleAfterSeconds` : age du VPS sur `generatedAt`, du poste sur `receivedAt`, **jamais** sur `pulledAt`. La carte `/admin` et `/warmup` lisent `stale` tel quel. Fichier present mais inutilisable -> bloc `null` + `vpsError`/`lenovoError` (`unreadable`, `invalid_json`, `unsupported_schema`, `invalid_contract`), l'autre bloc servi, jamais de 500 ; `{ status: "no_data" }` seulement si les deux fichiers sont absents. Le journal ne porte que des codes, ni contenu ni chemin.
|
- `GET /backups` — `{ vps, lenovo, vpsError?, lenovoError? }`, token de lecture (#58). `vps` = `status.json` ecrit par `backup-postgres.sh` sur le VPS (contrat `schemaVersion: 1` : `generatedAt`, `ok`, `expectedCount`, `databases[]`, `globals`, `files[]`, `mirror`, `rotation`, `retentionDays`), reconstruit par `sanitizeBackupsStatus()` en liste blanche : champ inconnu jete, valeur invalide jetee (jamais forcee), `null` garde sur `dumpedAt` et `uploadedAt`, `error` hors de la liste fermee (`dump_failed`, `dump_empty`, `restore_list_failed`, `invalid_name`, `encrypt_failed`, `mirror_upload_failed`, `mirror_purge_failed`, `rotation_failed`) jete en gardant `ok: false` (un stderr de `pg_dump`/`rclone` ne doit jamais atteindre le dashboard), nom de base hors `^[a-z0-9_]{1,63}$` jete (une entree `invalid_name` peut porter n'importe quel texte), `databases` borne a 64. `files[]` (#61, une entree par dossier hote archive par le script, la-compagnie-maximus#295 : `{ name, sizeBytes, archivedAt, ok, error? }`, `archivedAt` `null` garde) a sa **propre** liste fermee (`source_missing`, `archive_failed`, `encrypt_failed`, `invalid_name`) : un code de base est jete sur un fichier et l'inverse. Nom hors `^[a-z0-9_-]{1,63}$` jete (tiret admis, borne 63 propre a l'API), `files` borne a 16, liste vide gardee, absent (script anterieur) -> pas de cle et pas d'erreur. `lenovo` = dernier `POST /backups/pull`, revalide a la lecture. Chaque bloc recoit du **serveur** `ageSeconds`, `stale` (`ageSeconds > BACKUPS_STALE_SECONDS`) et `staleAfterSeconds` : age du VPS sur `generatedAt`, du poste sur `receivedAt`, **jamais** sur `pulledAt`. La carte `/admin` et `/warmup` lisent `stale` tel quel. Fichier present mais inutilisable -> bloc `null` + `vpsError`/`lenovoError` (`unreadable`, `invalid_json`, `unsupported_schema`, `invalid_contract`), l'autre bloc servi, jamais de 500 ; `{ status: "no_data" }` seulement si les deux fichiers sont absents. Le journal ne porte que des codes, ni contenu ni chemin.
|
||||||
|
|
||||||
## Routage et auth
|
## Routage et auth
|
||||||
|
|
||||||
|
|
@ -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) — 866 cas
|
- `npm test` (vitest) — 879 cas
|
||||||
- `__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__/backups.test.js` — `GET /backups`, `POST /backups/pull` et les deux validateurs de `backups.js` (115 cas, issue #58 : contrat du script VPS rendu tel quel, `rotation`, `retentionDays` et `files` (#61) 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 ; #61 : chacun des quatre codes de fichier garde, code de base jete sur un fichier et l'inverse, valeur invalide jetee, nom refuse jete l'entree gardee et tiret admis, borne de 16, liste vide gardee, `files` absent sans erreur, de bout en bout par la route ; 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)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ let server;
|
||||||
let baseUrl;
|
let baseUrl;
|
||||||
|
|
||||||
// What backup-postgres.sh writes, rotation block included (issue comment of
|
// What backup-postgres.sh writes, rotation block included (issue comment of
|
||||||
// 2026-10-10), one failed database among them.
|
// 2026-10-10), one failed database among them, and the files block of #61 in
|
||||||
|
// the script's real shapes: a failed encryption keeps the archive's time and
|
||||||
|
// size, a missing source has neither.
|
||||||
function validStatus() {
|
function validStatus() {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
|
@ -48,6 +50,11 @@ function validStatus() {
|
||||||
{ name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false, error: "dump_failed" },
|
{ name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false, error: "dump_failed" },
|
||||||
],
|
],
|
||||||
globals: { sizeBytes: 4096, ok: true },
|
globals: { sizeBytes: 4096, ok: true },
|
||||||
|
files: [
|
||||||
|
{ name: "claude-usage", sizeBytes: 2466, archivedAt: "2026-10-11T03:00:43Z", ok: true },
|
||||||
|
{ name: "roadtrip-uploads", sizeBytes: 0, archivedAt: null, ok: false, error: "source_missing" },
|
||||||
|
{ name: "hosts", sizeBytes: 512, archivedAt: "2026-10-11T03:00:44Z", ok: false, error: "encrypt_failed" },
|
||||||
|
],
|
||||||
mirror: { ok: true, uploadedAt: "2026-10-11T03:04:10Z" },
|
mirror: { ok: true, uploadedAt: "2026-10-11T03:04:10Z" },
|
||||||
rotation: { ok: true },
|
rotation: { ok: true },
|
||||||
retentionDays: { local: 7, offsite: 30 },
|
retentionDays: { local: 7, offsite: 30 },
|
||||||
|
|
@ -226,6 +233,8 @@ describe("sanitizeBackupsStatus — the whitelist", () => {
|
||||||
const payload = JSON.parse(JSON.stringify(validStatus()));
|
const payload = JSON.parse(JSON.stringify(validStatus()));
|
||||||
payload.stderr = "pg_dump: connection to host 10.0.0.5 failed";
|
payload.stderr = "pg_dump: connection to host 10.0.0.5 failed";
|
||||||
payload.databases[0].host = "10.0.0.5";
|
payload.databases[0].host = "10.0.0.5";
|
||||||
|
payload.files[0].path = "/data/claude-usage";
|
||||||
|
payload.files[1] = JSON.parse(`{"__proto__": {"polluted": true}, ${JSON.stringify(payload.files[1]).slice(1)}`);
|
||||||
payload.globals.path = "/var/backups/postgres/globals.sql";
|
payload.globals.path = "/var/backups/postgres/globals.sql";
|
||||||
payload.mirror.endpoint = "http://minio:9000";
|
payload.mirror.endpoint = "http://minio:9000";
|
||||||
payload.rotation.removed = ["2026-10-01"];
|
payload.rotation.removed = ["2026-10-01"];
|
||||||
|
|
@ -323,11 +332,91 @@ describe("sanitizeBackupsStatus — the whitelist", () => {
|
||||||
expect(status.databases[63].name).toBe("db_63");
|
expect(status.databases[63].name).toBe("db_63");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the file codes are a list of their own, restated here", () => {
|
||||||
|
const { FILE_ERROR_CODES, MAX_FILES } = freshModule();
|
||||||
|
expect(FILE_ERROR_CODES).toEqual(["source_missing", "archive_failed", "encrypt_failed", "invalid_name"]);
|
||||||
|
expect(MAX_FILES).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["source_missing", "archive_failed", "encrypt_failed", "invalid_name"])("the file code %s is kept", (code) => {
|
||||||
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
|
const payload = validStatus();
|
||||||
|
payload.files[1].error = code;
|
||||||
|
expect(sanitizeBackupsStatus(payload).status.files[1].error).toBe(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a database code is dropped on a file, a file code on a database", () => {
|
||||||
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
|
const payload = validStatus();
|
||||||
|
payload.files[1].error = "dump_failed";
|
||||||
|
payload.files[2].error = "tar: claude-usage/history: file removed before we read it";
|
||||||
|
payload.databases[1].error = "source_missing";
|
||||||
|
const { status } = sanitizeBackupsStatus(payload);
|
||||||
|
expect(status.files[1]).toEqual({ name: "roadtrip-uploads", sizeBytes: 0, archivedAt: null, ok: false });
|
||||||
|
expect(status.files[2]).toEqual({ name: "hosts", sizeBytes: 512, archivedAt: "2026-10-11T03:00:44Z", ok: false });
|
||||||
|
expect(status.databases[1]).toEqual({ name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an invalid file value is dropped, never coerced", () => {
|
||||||
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
|
const payload = validStatus();
|
||||||
|
payload.files[0] = { name: "claude-usage", sizeBytes: -1, archivedAt: "2026-10-11", ok: 1 };
|
||||||
|
payload.files[1] = { name: "hosts", sizeBytes: "512", archivedAt: "2026-10-11T03:00:44", ok: "false" };
|
||||||
|
const { status } = sanitizeBackupsStatus(payload);
|
||||||
|
expect(status.files[0]).toEqual({ name: "claude-usage" });
|
||||||
|
expect(status.files[1]).toEqual({ name: "hosts" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file name the script would refuse is dropped, the entry kept; a hyphen passes", () => {
|
||||||
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
|
const payload = validStatus();
|
||||||
|
payload.files = [
|
||||||
|
{ name: "Bad;Name", sizeBytes: 0, archivedAt: null, ok: false, error: "invalid_name" },
|
||||||
|
{ name: "x".repeat(64), sizeBytes: 1, archivedAt: null, ok: true },
|
||||||
|
{ name: "", sizeBytes: 1, archivedAt: null, ok: true },
|
||||||
|
{ name: "a-b_c-9", sizeBytes: 1, archivedAt: null, ok: true },
|
||||||
|
];
|
||||||
|
const { status } = sanitizeBackupsStatus(payload);
|
||||||
|
expect(status.files[0]).toEqual({ sizeBytes: 0, archivedAt: null, ok: false, error: "invalid_name" });
|
||||||
|
expect(status.files[1]).not.toHaveProperty("name");
|
||||||
|
expect(status.files[2]).not.toHaveProperty("name");
|
||||||
|
expect(status.files[3].name).toBe("a-b_c-9");
|
||||||
|
expect(status.files).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("files past 16 entries keep the first 16, non-objects are skipped", () => {
|
||||||
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
|
const payload = validStatus();
|
||||||
|
payload.files = [
|
||||||
|
"claude-usage",
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
...Array.from({ length: 20 }, (_, i) => ({ name: `dir-${i}`, sizeBytes: i, archivedAt: null, ok: true })),
|
||||||
|
];
|
||||||
|
const { status } = sanitizeBackupsStatus(payload);
|
||||||
|
expect(status.files).toHaveLength(16);
|
||||||
|
expect(status.files[0].name).toBe("dir-0");
|
||||||
|
expect(status.files[15].name).toBe("dir-15");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty files list (FILE_SOURCES empty) is kept as []", () => {
|
||||||
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
|
const payload = { ...validStatus(), files: [] };
|
||||||
|
expect(sanitizeBackupsStatus(payload).status.files).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a status from a script older than #61 has no files key and no error", () => {
|
||||||
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
|
const { files: _dropped, ...older } = validStatus();
|
||||||
|
expect(sanitizeBackupsStatus(older)).toEqual({ status: older });
|
||||||
|
});
|
||||||
|
|
||||||
test("a component that is not an object is dropped", () => {
|
test("a component that is not an object is dropped", () => {
|
||||||
const { sanitizeBackupsStatus } = freshModule();
|
const { sanitizeBackupsStatus } = freshModule();
|
||||||
const payload = { ...validStatus(), globals: [], mirror: "ok", rotation: null, retentionDays: 7, databases: {} };
|
const payload = { ...validStatus(), globals: [], mirror: "ok", rotation: null, retentionDays: 7, databases: {}, files: { name: "claude-usage" } };
|
||||||
const { status } = sanitizeBackupsStatus(payload);
|
const { status } = sanitizeBackupsStatus(payload);
|
||||||
for (const key of ["globals", "mirror", "rotation", "retentionDays", "databases"]) {
|
expect(status.generatedAt).toBe(validStatus().generatedAt);
|
||||||
|
for (const key of ["globals", "mirror", "rotation", "retentionDays", "databases", "files"]) {
|
||||||
expect(status).not.toHaveProperty(key);
|
expect(status).not.toHaveProperty(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -633,6 +722,22 @@ describe("GET /backups", () => {
|
||||||
expect(body.lenovo.ageSeconds).toBeLessThanOrEqual(1);
|
expect(body.lenovo.ageSeconds).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a status without files (script older than #61) is served with no files key and no vpsError", async () => {
|
||||||
|
const { files: _dropped, ...older } = validStatus();
|
||||||
|
writeFile(statusPath, older);
|
||||||
|
const { status, body } = await read();
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).not.toHaveProperty("vpsError");
|
||||||
|
expect(body.vps).not.toHaveProperty("files");
|
||||||
|
expect(body.vps.databases).toEqual(older.databases);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the files block reaches the response as the script wrote it", async () => {
|
||||||
|
writeFile(statusPath, validStatus());
|
||||||
|
const { body } = await read();
|
||||||
|
expect(body.vps.files).toEqual(validStatus().files);
|
||||||
|
});
|
||||||
|
|
||||||
test("VPS only: lenovo is null, with no lenovoError", async () => {
|
test("VPS only: lenovo is null, with no lenovoError", async () => {
|
||||||
writeFile(statusPath, validStatus());
|
writeFile(statusPath, validStatus());
|
||||||
const { body } = await read();
|
const { body } = await read();
|
||||||
|
|
|
||||||
33
backups.js
33
backups.js
|
|
@ -42,10 +42,22 @@ const VPS_ERROR_CODES = [
|
||||||
];
|
];
|
||||||
const PULL_ERROR_CODES = ["pull_failed", "rotation_failed"];
|
const PULL_ERROR_CODES = ["pull_failed", "rotation_failed"];
|
||||||
|
|
||||||
|
// The host directories the VPS script archives (FILE_SOURCES, issue #61): a
|
||||||
|
// list of its own, exactly what the script can write for a file entry, so a
|
||||||
|
// database code never passes on a file and the reverse.
|
||||||
|
const FILE_ERROR_CODES = ["source_missing", "archive_failed", "encrypt_failed", "invalid_name"];
|
||||||
|
|
||||||
|
// One directory today (claude-usage); the bound only caps a runaway file.
|
||||||
|
const MAX_FILES = 16;
|
||||||
|
|
||||||
// Same pattern as the VPS script's own check. A name it rejected is reported
|
// Same pattern as the VPS script's own check. A name it rejected is reported
|
||||||
// with `invalid_name` and may hold arbitrary text: that name is dropped here.
|
// with `invalid_name` and may hold arbitrary text: that name is dropped here.
|
||||||
const DATABASE_NAME_RE = /^[a-z0-9_]{1,63}$/;
|
const DATABASE_NAME_RE = /^[a-z0-9_]{1,63}$/;
|
||||||
|
|
||||||
|
// The script's own file pattern, hyphen allowed. The script sets no length;
|
||||||
|
// the 63 cap is this API's bound, as for databases.
|
||||||
|
const FILE_NAME_RE = /^[a-z0-9_-]{1,63}$/;
|
||||||
|
|
||||||
function isPlainObject(value) {
|
function isPlainObject(value) {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +82,10 @@ function cleanDatabaseName(value) {
|
||||||
return typeof value === "string" && DATABASE_NAME_RE.test(value) ? value : undefined;
|
return typeof value === "string" && DATABASE_NAME_RE.test(value) ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanFileName(value) {
|
||||||
|
return typeof value === "string" && FILE_NAME_RE.test(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Copy `key` into `out` only when the cleaner kept a value.
|
// Copy `key` into `out` only when the cleaner kept a value.
|
||||||
function put(out, key, value) {
|
function put(out, key, value) {
|
||||||
if (value !== undefined) out[key] = value;
|
if (value !== undefined) out[key] = value;
|
||||||
|
|
@ -102,6 +118,18 @@ function sanitizeDatabase(value) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// archivedAt is null when no archive was made (source_missing, archive_failed),
|
||||||
|
// and set on an encrypt_failed entry, whose tar did succeed.
|
||||||
|
function sanitizeFile(value) {
|
||||||
|
const out = {};
|
||||||
|
put(out, "name", cleanFileName(value.name));
|
||||||
|
put(out, "sizeBytes", cleanCount(value.sizeBytes));
|
||||||
|
putNullable(out, "archivedAt", value.archivedAt, cleanTimestamp);
|
||||||
|
put(out, "ok", cleanBoolean(value.ok));
|
||||||
|
put(out, "error", cleanCode(value.error, FILE_ERROR_CODES));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeRetention(value) {
|
function sanitizeRetention(value) {
|
||||||
if (!isPlainObject(value)) return undefined;
|
if (!isPlainObject(value)) return undefined;
|
||||||
const out = {};
|
const out = {};
|
||||||
|
|
@ -124,6 +152,9 @@ function sanitizeBackupsStatus(payload) {
|
||||||
if (Array.isArray(payload.databases)) {
|
if (Array.isArray(payload.databases)) {
|
||||||
status.databases = payload.databases.filter(isPlainObject).slice(0, MAX_DATABASES).map(sanitizeDatabase);
|
status.databases = payload.databases.filter(isPlainObject).slice(0, MAX_DATABASES).map(sanitizeDatabase);
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(payload.files)) {
|
||||||
|
status.files = payload.files.filter(isPlainObject).slice(0, MAX_FILES).map(sanitizeFile);
|
||||||
|
}
|
||||||
put(status, "globals", sanitizeComponent(payload.globals, (out, value) => {
|
put(status, "globals", sanitizeComponent(payload.globals, (out, value) => {
|
||||||
put(out, "sizeBytes", cleanCount(value.sizeBytes));
|
put(out, "sizeBytes", cleanCount(value.sizeBytes));
|
||||||
}));
|
}));
|
||||||
|
|
@ -166,6 +197,8 @@ module.exports = {
|
||||||
BACKUPS_MAX_BODY_BYTES,
|
BACKUPS_MAX_BODY_BYTES,
|
||||||
SCHEMA_VERSION,
|
SCHEMA_VERSION,
|
||||||
MAX_DATABASES,
|
MAX_DATABASES,
|
||||||
|
MAX_FILES,
|
||||||
VPS_ERROR_CODES,
|
VPS_ERROR_CODES,
|
||||||
|
FILE_ERROR_CODES,
|
||||||
PULL_ERROR_CODES,
|
PULL_ERROR_CODES,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue