diff --git a/.dockerignore b/.dockerignore index 425f6f962..dce02b4e9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,9 @@ design-artifacts/ docs/ charts/ .claude/ +# Sources des montages par défaut des compose (15-13b, AC 8 e) — hors du +# contexte de build (la sauvegarde pré-import est un secret). +log/ +inbox/ +documents/ +backup/ diff --git a/.env.example b/.env.example index c2de54f6d..ed8ffa5ac 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,9 @@ KESH_PORT=80 # # KESH_PORT=3000 # Alternatives : (i) lancer `cargo run` avec `sudo` (déconseillé), (ii) # ajouter la capability via `sudo setcap 'cap_net_bind_service=+ep' target/debug/kesh-api`. +# Hors Docker, poser aussi KESH_ADMIN_BACKUP_DIR sur un dossier inscriptible +# par l'utilisateur (son défaut /data/backup ne l'est pas) : sans quoi tout +# import d'installation est refusé, avant toute suppression. # --- Compte admin initial (Story v011-5 — double-usage) --- # @@ -179,9 +182,12 @@ KESH_BANK_IMPORT_MAX_MB=10 #KESH_ADMIN_EXPORT_INMEM_MB=50 # Taille max d'un upload .keshbackup à l'import (MiB). Borne [1, 10240], défaut 512. #KESH_ADMIN_IMPORT_MAX_MB=512 -# Répertoire du backup automatique pré-import (filet avant restore destructeur). -# Défaut /tmp (absente ou vide) — PERDU au redémarrage du conteneur ; monter un -# volume persistant. +# Dossier de sauvegarde de sécurité prise avant chaque import d'installation +# (filet avant le restore destructeur ; fichiers SECRETS, en 0600). +# Défaut /data/backup (absente ou vide), monté sur ./backup par les deux compose +# fournis. Une autre valeur doit être un chemin MONTÉ, sans quoi la sauvegarde +# retourne dans le système de fichiers éphémère du conteneur. Hors Docker : un +# dossier inscriptible par l'utilisateur qui lance Kesh. #KESH_ADMIN_BACKUP_DIR=/data/backup # --- Chemins internes (hors Docker seulement) --- @@ -209,7 +215,8 @@ KESH_BANK_IMPORT_MAX_MB=10 # --- Chemins HOST des bind mounts (docker-compose.yml) --- # Sans effet avec `docker-compose.prod.yml` (installation Synology du manuel) : # ses montages sont fixes — `./inbox`, `./documents`, `./log` dans le répertoire -# du compose (issue #558). +# du compose (issue #558), et `./backup` (sauvegarde pré-import, sans variable +# d'hôte). # Avec `docker-compose.yml` : pour rendre l'inbox accessible depuis votre PC # (SMB / Synology Drive), montez ces dossiers sur un dossier PARTAGÉ du NAS — # créez un dossier partagé (ex. « kesh ») avec sous-dossiers inbox/ et @@ -242,7 +249,8 @@ RUST_LOG=info # Chemin HOST du répertoire de logs (bind mount de docker-compose.yml). Pointe # vers un dossier partagé du NAS pour consulter les logs depuis le PC (SMB/Drive). # Sans effet avec `docker-compose.prod.yml` : ses montages sont fixes — `./inbox`, -# `./documents`, `./log` dans le répertoire du compose (issue #558). +# `./documents`, `./log` dans le répertoire du compose (issue #558), et `./backup` +# (sauvegarde pré-import, sans variable d'hôte). #KESH_LOG_HOST_DIR=/volume1/kesh/log # (défaut sans .env : ./log dans le dossier du projet) # diff --git a/.gitignore b/.gitignore index 892fcb9b1..96fa4a74c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,14 @@ Thumbs.db *.log # Story v011-1 : répertoire de logs fichier monté par docker-compose.prod.yml log/ +# Sources des montages par défaut des compose (15-13b, AC 8 e) : justificatifs, +# factures déposées et sauvegarde pré-import (SECRET) ne se versionnent jamais. +# Motifs ANCRÉS à la racine : `backup/` seul ignorerait aussi la route versionnée +# frontend/src/routes/(app)/admin/backup/. (`log/` reste non ancré : tolérance +# nommée, aucun dossier log versionné — test montages_hors_du_depot.) +/inbox/ +/documents/ +/backup/ # LaTeX build artifacts (manuels docs/manual/) — ne versionner que .tex + .pdf # (aligne admin-manual sur la convention marketing-brochure ; Story 17-3f) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1544f0c..da653c18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,14 +58,20 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Une opération qui croise un rapprochement bancaire ne finit plus en erreur interne ([#463](https://github.com/guycorbaz/kesh/issues/463), [#491](https://github.com/guycorbaz/kesh/issues/491), [#536](https://github.com/guycorbaz/kesh/issues/536)).** Un règlement client, l'annulation d'un règlement client, la validation d'une facture, la saisie d'une facture fournisseur et l'enregistrement des réglages de facturation qui croisent une autre opération (un rapprochement bancaire, notamment) ne finissent plus en erreur interne : le serveur rejoue l'opération, comme il le faisait déjà pour l'acceptation d'un rapprochement. Il en va de même de toutes les opérations courantes qui écrivent au journal — hors la restauration d'une sauvegarde et l'effacement des données de démonstration. Seul un interblocage répété trois fois de suite rend encore une erreur ; rien n'est alors écrit et l'opération peut être relancée telle quelle. - **Les manuels ne prétendent plus que la numérotation des factures se fait dans une transaction `SERIALIZABLE` ([#484](https://github.com/guycorbaz/kesh/issues/484)).** Ils décrivent le verrou du compteur, la contrainte d'unicité et le rejeu. -- **La configuration posée dans `.env` atteint enfin Kesh ([#550](https://github.com/guycorbaz/kesh/issues/550)).** Les deux compose fournis ne transmettaient au conteneur que les variables listées sous `environment:` — Docker Compose lit `.env` pour remplir le compose, il ne le transmet pas. Seize variables que `.env.example` proposait de régler restaient donc **sans effet, sans aucun message** : les e-mails sortants (`KESH_SMTP_*`, `KESH_PUBLIC_BASE_URL`), le mot de passe oublié par e-mail, la sortie de la démonstration, la langue, la longueur minimale des mots de passe, les plafonds d'import et de sauvegarde, et les cookies `Secure` avec `docker-compose.prod.yml`. Elles sont désormais toutes transmises, et un test empêche qu'un compose en oublie une. Le message de démarrage qui renvoyait à une variable inexistante (`KESH_ADMIN_` + `RESET`) nomme le vrai recours (`KESH_ADMIN_USERNAME` / `KESH_ADMIN_PASSWORD`). `docker-compose.yml` s'installe seul : il désigne l'image publiée au lieu d'exiger les sources. Un `.env` sans `KESH_ADMIN_PASSWORD` ne fait plus refuser le démarrage avec `docker-compose.yml`, ni avertir Compose avec `docker-compose.prod.yml` : l'onboarding `/setup` recommandé par le manuel fonctionne, et le retrait des variables après un break-glass aussi. `KESH_JWT_SECRET` reste obligatoire. **Inchangé** : avec `docker-compose.prod.yml`, les montages restent `./documents`, `./inbox`, `./log`, et les `KESH_*_HOST_DIR` y sont sans effet ([#558](https://github.com/guycorbaz/kesh/issues/558)). +- **La configuration posée dans `.env` atteint enfin Kesh ([#550](https://github.com/guycorbaz/kesh/issues/550)).** Les deux compose fournis ne transmettaient au conteneur que les variables listées sous `environment:` — Docker Compose lit `.env` pour remplir le compose, il ne le transmet pas. Seize variables que `.env.example` proposait de régler restaient donc **sans effet, sans aucun message** : les e-mails sortants (`KESH_SMTP_*`, `KESH_PUBLIC_BASE_URL`), le mot de passe oublié par e-mail, la sortie de la démonstration, la langue, la longueur minimale des mots de passe, les plafonds d'import et de sauvegarde, et les cookies `Secure` avec `docker-compose.prod.yml`. Elles sont désormais toutes transmises, et un test empêche qu'un compose en oublie une. Le message de démarrage qui renvoyait à une variable inexistante (`KESH_ADMIN_` + `RESET`) nomme le vrai recours (`KESH_ADMIN_USERNAME` / `KESH_ADMIN_PASSWORD`). `docker-compose.yml` s'installe seul : il désigne l'image publiée au lieu d'exiger les sources. Un `.env` sans `KESH_ADMIN_PASSWORD` ne fait plus refuser le démarrage avec `docker-compose.yml`, ni avertir Compose avec `docker-compose.prod.yml` : l'onboarding `/setup` recommandé par le manuel fonctionne, et le retrait des variables après un break-glass aussi. `KESH_JWT_SECRET` reste obligatoire. **Inchangé** : avec `docker-compose.prod.yml`, les montages restent `./documents`, `./inbox`, `./log` (la 0.13.0 y ajoute `./backup`, sauvegarde pré-import, [#552](https://github.com/guycorbaz/kesh/issues/552)), et les `KESH_*_HOST_DIR` y sont sans effet ([#558](https://github.com/guycorbaz/kesh/issues/558)). - ⚠️ **Action requise sur une installation existante.** La nouvelle image seule ne corrige rien : le défaut est dans le fichier compose que vous détenez, et le compose de la 0.13.0 s'emploie avec l'image 0.13.0. **Re-téléchargez le compose** et reportez-y vos seules adaptations locales (port, réseau, **chemins des montages** — un montage modifié à la main et non reporté revient à `./documents`, et justificatifs et PDF paraissent disparus : ils sont toujours dans l'ancien dossier), puis **`docker compose pull`** et **`docker compose up -d`**. Pour qui garde son fichier, le manuel d'administration décrit les gestes, fichier par fichier (§ *Passer à la 0.13.0*). **Relisez votre `.env` avant de mettre à jour** : des lignes jusqu'ici sans effet le deviennent. Avec `docker-compose.yml`, c'est d'abord **Compose** qui refuse, au terminal et avant tout démarrage, si `MARIADB_ROOT_PASSWORD` ou `MARIADB_PASSWORD` manque à `.env` (cf. **Sécurité**, [#551](https://github.com/guycorbaz/kesh/issues/551)). Kesh **refuse de démarrer** si `KESH_SMTP_TLS` ou `KESH_FEATURE_FORGOT_PASSWORD` portent une valeur autre que `true`/`false`/`1`/`0`, si `KESH_COOKIE_SECURE` en porte une avec `docker-compose.prod.yml`, si le mot de passe oublié est activé avec un SMTP incomplet ou mal formé, ou si, activé, le mailer SMTP ne se construit pas, ou encore si `KESH_JWT_SECRET` ou `KESH_ADMIN_PASSWORD` est resté à un placeholder (cf. **Sécurité**) — le message nomme la variable (`docker compose logs kesh-api`). Sans refus : `KESH_PRODUCTION_RESET` réautorise la réinitialisation **d'une installation restée en démonstration** ; les quatre `KESH_SMTP_HOST`/`_USER`/`_PASSWORD`/`_FROM` posées activent l'envoi de factures, et avec `KESH_FEATURE_FORGOT_PASSWORD=true` la réinitialisation du mot de passe par e-mail ; `KESH_COOKIE_SECURE` agit enfin avec `docker-compose.prod.yml`, de même que `KESH_ADMIN_BACKUP_DIR`, `KESH_LANG`… ; une ligne `KESH_LOG_FILE_PATH=` vide désactive désormais le journal fichier. Une valeur qui contient `$` se met entre apostrophes simples (`KESH_SMTP_PASSWORD='pa$word'`), sinon elle est tronquée. Une variable que `.env` ne pose pas, ou laisse vide, prend son défaut sans avertissement — hormis, avec `docker-compose.yml`, `MARIADB_ROOT_PASSWORD` et `MARIADB_PASSWORD`, désormais obligatoires — ; la sauvegarde pré-import reste dans `/tmp`. Un `.env` copié du gabarit sans modification ne change rien d'autre que le refus du placeholder du secret JWT et, avec `docker-compose.yml`, le sort des mots de passe MariaDB : copié du gabarit 0.13.0, où ils sont commentés, il fait refuser Compose ; copié d'un gabarit antérieur, qui posait `kesh_dev`, il fait émettre à Kesh l'avertissement « mot de passe publié ». + ⚠️ **Action requise sur une installation existante.** La nouvelle image seule ne corrige rien : le défaut est dans le fichier compose que vous détenez, et le compose de la 0.13.0 s'emploie avec l'image 0.13.0. **Re-téléchargez le compose** et reportez-y vos seules adaptations locales (port, réseau, **chemins des montages** — un montage modifié à la main et non reporté revient à `./documents`, et justificatifs et PDF paraissent disparus : ils sont toujours dans l'ancien dossier), puis **`docker compose pull`** et **`docker compose up -d`**. Pour qui garde son fichier, le manuel d'administration décrit les gestes, fichier par fichier (§ *Passer à la 0.13.0*). **Relisez votre `.env` avant de mettre à jour** : des lignes jusqu'ici sans effet le deviennent. Avec `docker-compose.yml`, c'est d'abord **Compose** qui refuse, au terminal et avant tout démarrage, si `MARIADB_ROOT_PASSWORD` ou `MARIADB_PASSWORD` manque à `.env` (cf. **Sécurité**, [#551](https://github.com/guycorbaz/kesh/issues/551)). Kesh **refuse de démarrer** si `KESH_SMTP_TLS` ou `KESH_FEATURE_FORGOT_PASSWORD` portent une valeur autre que `true`/`false`/`1`/`0`, si `KESH_COOKIE_SECURE` en porte une avec `docker-compose.prod.yml`, si le mot de passe oublié est activé avec un SMTP incomplet ou mal formé, ou si, activé, le mailer SMTP ne se construit pas, ou encore si `KESH_JWT_SECRET` ou `KESH_ADMIN_PASSWORD` est resté à un placeholder (cf. **Sécurité**) — le message nomme la variable (`docker compose logs kesh-api`). Sans refus : `KESH_PRODUCTION_RESET` réautorise la réinitialisation **d'une installation restée en démonstration** ; les quatre `KESH_SMTP_HOST`/`_USER`/`_PASSWORD`/`_FROM` posées activent l'envoi de factures, et avec `KESH_FEATURE_FORGOT_PASSWORD=true` la réinitialisation du mot de passe par e-mail ; `KESH_COOKIE_SECURE` agit enfin avec `docker-compose.prod.yml`, de même que `KESH_ADMIN_BACKUP_DIR`, `KESH_LANG`… ; une ligne `KESH_LOG_FILE_PATH=` vide désactive désormais le journal fichier. Une valeur qui contient `$` se met entre apostrophes simples (`KESH_SMTP_PASSWORD='pa$word'`), sinon elle est tronquée. Une variable que `.env` ne pose pas, ou laisse vide, prend son défaut sans avertissement — hormis, avec `docker-compose.yml`, `MARIADB_ROOT_PASSWORD` et `MARIADB_PASSWORD`, désormais obligatoires — ; pour la sauvegarde pré-import, `./backup` avec le compose de la 0.13.0 ; avec un compose gardé sans ce montage, `/data/backup` du conteneur, éphémère comme l'était `/tmp`. Un `.env` copié du gabarit sans modification ne change rien d'autre que le refus du placeholder du secret JWT et, avec `docker-compose.yml`, le sort des mots de passe MariaDB : copié du gabarit 0.13.0, où ils sont commentés, il fait refuser Compose ; copié d'un gabarit antérieur, qui posait `kesh_dev`, il fait émettre à Kesh l'avertissement « mot de passe publié ». **Exploitant de `docker-compose.yml` qui construit depuis un clone** : après `git pull`, `docker compose up -d` **tire** `gcorbaz/kesh:latest` au lieu de construire les sources — `docker compose up -d --build` pour construire, et `docker compose pull kesh-api` pour revenir à l'image publiée après un build local qui l'a étiquetée `latest`. +- **La sauvegarde prise avant un import d'installation survit au conteneur ([#552](https://github.com/guycorbaz/kesh/issues/552)).** Elle était écrite dans `/tmp` **du conteneur** et disparaissait à sa recréation — `docker compose up -d` après une modification de `.env`, une mise à jour d'image —, c'est-à-dire au moment même où l'on en a besoin après un import raté. Son défaut devient `/data/backup`, que les deux compose fournis montent sur `./backup` dans le répertoire du compose ; le fichier est créé en mode `0600` (c'est un secret : condensés des mots de passe, jetons de session), écrit sous un nom `.partial` puis renommé, jamais par-dessus un fichier existant. La restaurer demande de la rapatrier en SSH (`sudo cp` puis `sudo chown`, manuel d'administration, § *La sauvegarde de sécurité pré-import*). **Hors Docker**, poser `KESH_ADMIN_BACKUP_DIR` sur un dossier inscriptible : sans quoi tout import est refusé, avant toute suppression. **Avec un compose gardé**, ajouter le montage `./backup:/data/backup` (§ *Passer à la 0.13.0*). + +- **Un import dont la sauvegarde n'a pas pu être écrite le dit ([#576](https://github.com/guycorbaz/kesh/issues/576)).** Tout échec survenu avant que la sauvegarde de sécurité soit sur disque — dossier non inscriptible, disque plein, base de données momentanément inaccessible — annonçait « un backup automatique a été créé avant l'opération », alors qu'il n'en existait aucun. Il rend désormais `ADMIN_PRE_IMPORT_BACKUP_FAILED` : aucune sauvegarde n'a été créée, rien n'a été supprimé, et le message nomme les deux pistes, le dossier de sauvegarde et la base de données. + ### Sécurité +- **Les dossiers montés par défaut ne se versionnent plus et n'entrent plus dans l'image ([#552](https://github.com/guycorbaz/kesh/issues/552)).** `./backup` (sauvegardes pré-import, un secret), `./inbox` et `./documents` (justificatifs) étaient créés dans le clone par `docker compose up` sans être ignorés : un `git add -A` les aurait versionnés. Ils sont désormais ignorés de git (motifs ancrés à la racine) et du contexte de build, comme `./log`. + - **Le secret JWT et le mot de passe admin laissés au placeholder du gabarit, ou à un gabarit entre chevrons du manuel, sont refusés au démarrage ([#557](https://github.com/guycorbaz/kesh/issues/557)).** Jusqu'ici, `KESH_JWT_SECRET=` recopié tel quel de `.env.example` était **accepté** : les jetons de session étaient signés avec un secret publié dans le dépôt. De même, `KESH_ADMIN_PASSWORD=` décommenté tel quel, ou `KESH_ADMIN_PASSWORD=` recopié du manuel, créait sur une base vide un administrateur au mot de passe publié. Toute valeur qui contient `GENERATE_ME` (sans égard à la casse) ou qui est de la forme `<…>` est désormais refusée pour ces deux variables, avant le contrôle de longueur. **Action pour une installation existante**, avant la mise à jour, le compose final en place et, avec `docker-compose.yml`, les deux mots de passe MariaDB posés (entrée suivante) : `sudo docker compose config -q && echo 'compose lisible'` doit afficher `compose lisible` — si Compose refuse le fichier, la vérification qui suit ne reçoit rien et reste muette comme un succès —, puis la vérification du manuel d'administration (§ *Passer à la 0.13.0*), `sudo docker compose config | grep -iE 'KESH_(JWT_SECRET|ADMIN_PASSWORD): .? *(<|.*generate_me)'`, doit rester muette. Si elle montre `KESH_JWT_SECRET`, remplacez la valeur par la sortie de `openssl rand -hex 32` (toutes les sessions ouvertes sont invalidées, chacun se reconnecte) ; si elle montre `KESH_ADMIN_PASSWORD`, choisissez un vrai mot de passe ou recommentez la ligne — **et**, si l'administrateur a été créé avec ce placeholder, **changez son mot de passe** dans Kesh (le refus au démarrage ne change pas un compte déjà créé). Puis `docker compose up -d`. Sans cette action, Kesh 0.13.0 refuse de démarrer, avec un message qui nomme la variable. - **MariaDB n'est plus joignable depuis le réseau, et n'a plus de mots de passe publiés ([#551](https://github.com/guycorbaz/kesh/issues/551)).** `docker-compose.yml` publiait le port 3306 de MariaDB sur **toutes** les interfaces de l'hôte, avec des mots de passe par défaut écrits dans le dépôt (`kesh_dev_root` pour root, `kesh_dev` pour Kesh) — et un port publié par Docker **contourne le pare-feu de l'hôte** (UFW) : le pare-feu recommandé par le manuel ne le fermait pas. Le port n'est plus publié (Kesh passe par le réseau interne de Docker) ; `MARIADB_ROOT_PASSWORD` et `MARIADB_PASSWORD` n'ont plus de défaut : sans eux, Compose refuse de démarrer en nommant la variable ; `.env.example` ne les pose plus (lignes commentées). Kesh **avertit au démarrage** quand le mot de passe de `DATABASE_URL` est un mot de passe publié (`kesh_dev`, `kesh_dev_root`) ou un gabarit `<…>` recopié du manuel, et un refus d'identifiant de MariaDB (erreur 1045) est désormais suivi d'un **indice**. Le manuel fait générer le mot de passe MariaDB de l'installation Synology en hexadécimal (`openssl rand -hex 32`) : la sortie base64 qu'il prescrivait contenait une fois sur deux un `/`, qui rendait la `DATABASE_URL` inexploitable. diff --git a/CLAUDE.md b/CLAUDE.md index 3cba091e8..73d8f20b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,7 +178,7 @@ juge pas au nombre : il se juge fichier par fichier contre cette liste. KESH_TEST_MODE=true KESH_HOST=127.0.0.1 KESH_COOKIE_SECURE=false \ DATABASE_URL='mysql://kesh:kesh_dev@127.0.0.1:3306/kesh_e2e' \ KESH_JWT_SECRET='<32+ octets>' KESH_ADMIN_PASSWORD='<12+ caractères>' \ - KESH_PORT=3000 KESH_STATIC_DIR=frontend/build \ + KESH_PORT=3000 KESH_STATIC_DIR=frontend/build KESH_ADMIN_BACKUP_DIR=target/kesh-backup \ cargo run -p kesh-api # terminal 2 diff --git a/DOCKER_START.md b/DOCKER_START.md index 892b2be73..4d717c5ca 100644 --- a/DOCKER_START.md +++ b/DOCKER_START.md @@ -194,3 +194,4 @@ docker compose up -d - Les données sont persistées dans le volume `kesh-mariadb-data` du compose, que `docker volume ls` affiche `_kesh-mariadb-data` (préfixe du projet Compose, par défaut le nom du répertoire) +- Dossier `./backup` : créé par Docker au premier `docker compose up`, il reçoit la sauvegarde de sécurité prise avant chaque import d'installation (fichiers `0600`, propriétaire `root`). **Secret**, ignoré de git ; à purger (une sauvegarde par import). Restauration : manuel d'administration, § *La sauvegarde de sécurité pré-import*. diff --git a/README.md b/README.md index 23aa3e683..2fe1764ce 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ npm install npm run dev ``` -L'application est accessible sur http://localhost:5173 (frontend dev) et http://localhost (API en mode Docker, port 80 ; en mode `cargo run` natif sur Linux non-root, lancer le backend avec `KESH_PORT=3000` ET le frontend avec `KESH_BACKEND_URL=http://localhost:3000 npm run dev` pour aligner le proxy vite). +L'application est accessible sur http://localhost:5173 (frontend dev) et http://localhost (API en mode Docker, port 80 ; en mode `cargo run` natif sur Linux non-root, lancer le backend avec `KESH_PORT=3000` — et `KESH_ADMIN_BACKUP_DIR` sur un dossier inscriptible, sans quoi tout import d'installation est refusé — ET le frontend avec `KESH_BACKEND_URL=http://localhost:3000 npm run dev` pour aligner le proxy vite). ### Image Docker (production) diff --git a/_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md b/_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md index 66fd687d8..e0703ae5e 100644 --- a/_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md +++ b/_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md @@ -181,3 +181,9 @@ les tableaux). fille : 15-13a **1 MEDIUM** distinct (R4-1 = F1), 15-13b **4 MEDIUM** distincts (R4-2 = F-P4-1) ; 0 CRITICAL/HIGH. Remédiation dans chaque fille (Change Log) ; choix C-15-13-21 à C-15-13-26. Ici : ligne « Inventaire » du recompte réduite aux totaux (R4-7). +- 2026-10-09 — **Validation P5 de la 15-13b** (agent remédiateur, Opus 5.5). Deux lentilles **Opus 5.5** : + **2 MEDIUM** distincts (R5-1, né de la remédiation P4 ; F-P5-1, d'origine P1), 13 LOW (12 distincts : R5-3 = F-P5-8) ; 0 CRITICAL/HIGH. + Trend de la 15-13b : P4 4 → P5 2. Remédiation dans la fiche fille (Change Log) ; choix C-15-13-27 à + C-15-13-29. Ici : union des mutations des deux filles portée à **51** (M1 à M51 : la 15-13b ajoute M47 à + M51) ; la phrase « deux gestes » de `CHANGELOG.md:46` est **partagée** entre les deux filles (R5-5, + ventilation écrite dans la 15-13b seule). diff --git a/_bmad-output/implementation-artifacts/15-13b-review-prompt-p1.md b/_bmad-output/implementation-artifacts/15-13b-review-prompt-p1.md new file mode 100644 index 000000000..00ea9a2ec --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13b-review-prompt-p1.md @@ -0,0 +1,37 @@ +# Prompt — revue de code P1, Story 15-13b + +*Versionné le 2026-10-09. Trois lentilles (Sonnet), contexte frais chacune, en lecture seule (bmad-code-review : +Blind Hunter, Edge Case Hunter, Acceptance Auditor). Rotation D6 : passes complètes Sonnet ↔ Opus ; Haiku en passe ciblée.* + +Worktree `/home/gcorbaz/devel/kesh-15-13b`, branche `story/15-13b-sauvegarde-persistante`. **Le diff à revoir** : `git -C /home/gcorbaz/devel/kesh-15-13b diff 200f5e79..7ff477ab` (un seul diff aplati ; le code, +les tests, les manuels, la doc — les fiches `_bmad-output/` sont le contexte, pas l'objet). **Fiche** : +`_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md` (AC, tâches, Dev Agent Record, Change Log). Issues (par +`gh api repos/guycorbaz/kesh/issues/N`) : #552, #576 (#558 pour le contexte). Registre : `epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Choix C-15-13-1 à 29, C-15-13b-1 à 4. Code touché : config.rs (KESH_ADMIN_BACKUP_DIR via env_nonempty, DEFAULT_ADMIN_BACKUP_DIR /data/backup), routes/admin.rs (write_backup_file : .partial 0600, sync_all, vérification du nom final, rename ; dossier 0700 ; AdminPreImportBackupFailed et avant_sauvegarde aux sites antérieurs), errors.rs, catalogues 4 locales (le message nomme les deux pistes), compose (montage ./backup), .gitignore/.dockerignore ancrés, tests (configuration_transmise.rs, tests 11, 12, 15, 16, 19, 20), manuel et PDF, DOCKER_START, recettes (CLAUDE.md : une ligne), README, CHANGELOG. Axes : write_backup_file — le .partial est-il nettoyé dans tous les chemins d'échec et JAMAIS celui d'un autre écrivain ? la sauvegarde n'est-elle jamais visible sous son nom final incomplète ? ; le tri pré/post-sauvegarde des sites ; la garde lexicale du test 20 ; le montage et la constante liés par test ; le manuel (rapatriement sudo cp) et le PDF aplati ; la ligne modifiée de CLAUDE.md (une seule). + +## Lentilles + +- **B — Blind Hunter** : le diff seul, sans la fiche. Défauts de correction, régressions, erreurs de concurrence + (verrous, REPEATABLE READ, ordre d'acquisition), erreurs rendues au client, tests qui passeraient à vide (un test qui + ne mord pas sur la mutation qu'il prétend couvrir), code mort, duplication (DRY), doc-comments devenus faux. +- **E — Edge Case Hunter** : chaque branche et chaque borne du code modifié — entrées vides, nulles, multiples, en + doublon, d'une autre société, archivées ; chemins par clé d'API ; lots partiellement en échec ; locales ; et les + **chemins NON modifiés qui devraient l'être** (inventorier les sites non résolus de la même famille par `grep`). +- **A — Acceptance Auditor** : chaque AC de la fiche contre le code ET les tests (un AC sans test qui le prouve est + un finding) ; le Dev Agent Record ne déclare-t-il que ce qui a tourné (chiffres recomptés : `grep -c '#\[sqlx::test\]\|#\[test\]\|#\[tokio::test\]'` aux deux bornes) ; + le **manuel** (`docs/manual/fr/*.tex` **et PDF aplatis** : `pdftotext -nopgbrk f.pdf - | tr '\n' ' ' | tr -s ' '` vers + `target/gate-logs/`, ligatures ff/fi/fl normalisées), `docs/api-external.md`, CHANGELOG, i18n 4 locales. + +## Ce que tu rends + +Rapport complet dans `target/gate-logs/15-13b-review-p1-.md` : findings numérotés, sévérité +(CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` pour toute affirmation de présence ou +d'absence ; code cité relu), correction proposée ; ⛔ **axes exercés ET non exercés**. Dernier message : le chemin, le +bilan par sévérité, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni +aucune commande qui compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, +`latexmk`, `git commit`/`add`/`checkout`/`stash`/`apply`, `sqlx`, `cargo`, `npm`, `npx`, +`gh issue create`/`comment`/`edit`, SQL d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, +`gh api` en lecture, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-13b-review-prompt-p2-ciblee.md b/_bmad-output/implementation-artifacts/15-13b-review-prompt-p2-ciblee.md new file mode 100644 index 000000000..606d771c9 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13b-review-prompt-p2-ciblee.md @@ -0,0 +1,38 @@ +# Prompt — revue de code P2 ciblée, Story 15-13b + +*Versionné le 2026-10-09. Passe ciblée (CLAUDE.md § « La passe ciblée ») : une seule lentille (Haiku), contexte +frais, en lecture seule, braquée sur le seul commit de la remédiation P1.* + +Worktree `/home/gcorbaz/devel/kesh-15-13b`, branche `story/15-13b-sauvegarde-persistante`. **Objet** : +`git show 7b1db902` (diff unique ; ouvre `crates/kesh-api/src/routes/admin.rs` à `HEAD` autour des hunks). +Rapports remédiés : `target/gate-logs/15-13b-review-p1-{B,E,A}.md`. + +## Lentille unique — chasseur de régressions de la remédiation + +1. **La seule ligne de production touchée** : la chaîne du `warn!` de `write_backup_file` (continuation `\`). + Le message rendu est-il exactement celui voulu (un espace entre « sauvegarde » et « pré-import », aucun + autre) ? Le format `{e}` est-il intact ? +2. **`aucun_blanc_parasite_dans_le_code`** : sur quel périmètre lit-il le code (un seul fichier ? tout le + crate ?) ; peut-il rougir à tort sur un alignement légitime, une chaîne littérale, un commentaire ou un + tableau ? Peut-il passer à vide (assertion de montage) ? Un test de style aussi large est-il justifié pour + un seul message — ou faut-il le restreindre aux littéraux de journal ? +3. **`echecs_avant_sauvegarde_tous_convertis`** : la lecture lexicale (« avant `write_pre_import_backup` ») + est-elle robuste à rustfmt et aux commentaires ? Compte-t-elle bien trois constructions directes et une + conversion ? Peut-elle passer à vide ? +4. **`write_pre_import_backup_dossiers_crees_et_existants`** : l'umask du processus de test peut-il fausser les + modes attendus (`0700`, `0755`) ? Le dossier temporaire est-il nettoyé ? +5. **Doc-comments** neufs (angles morts de `write_backup_file`, conversion nommée sur `AdminFullImportFailed`, + `check_schema_compat`, `build_keshbackup`) : exacts contre le code ? + +## Ce que tu rends + +Findings numérotés (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` ; code relu cité), +correction proposée. ⛔ **Liste des axes exercés ET non exercés** — un « 0 » sans elle ne compte pas. Rapport +complet dans `target/gate-logs/15-13b-review-p2-ciblee.md` ; dernier message : le chemin, le bilan, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni qui +compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `docker`, +`git commit`/`add`/`checkout`/`switch`/`stash`/`reset`, `sqlx`, `cargo`, `npm`, `npx`, `gh issue create`/`comment`/`edit`, +SQL d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`/`grep`. diff --git a/_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md b/_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md index 860079f30..04b3e9dc4 100644 --- a/_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md +++ b/_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md @@ -1,6 +1,6 @@ # Story 15.13b : la sauvegarde prise avant un import survit au redémarrage, et son échec est annoncé tel quel -Status: ready-for-dev +Status: done **Issues** : **ferme #552 et #576**. La PR porte `closes #552` **et** `closes #576` (mots-clés dans le titre @@ -32,7 +32,15 @@ l'autre ajoute. Elles touchent les **mêmes fichiers**, à des endroits distinct et les **PDF versionnés** `admin-manual.pdf` et `marketing-brochure.pdf`, que les deux régénèrent. La seconde mergée rebase et **recompte les gestes** du paragraphe « Pour qui garde son fichier compose » (AC 11 f) ; un conflit sur un PDF ne se rebase pas : on fusionne les `.tex`, puis on **régénère** les PDF -(`make fr`) et on rejoue les contrôles aplatis des **deux** fiches (R4-3 de la validation P4 de la 15-13a). **Ordre suggéré : après la 15-13a** (#551 est P2, sécurité ; #552 et #576 P3) ; l'ordre inverse +(`make fr`) et on rejoue les contrôles aplatis des **deux** fiches (R4-3 de la validation P4 de la 15-13a). +**Conflits certains au merge** (F-P6-1 de la validation P6) : `CHANGELOG.md` — le paragraphe d'une seule +ligne « ⚠️ Action requise » (`:46` à la spécification, `:56` au `HEAD` `200f5e79`) porte les cinq phrases +des deux fiches, dont **une même phrase** (« Une variable que `.env` ne pose pas… prend son défaut sans +avertissement — la sauvegarde pré-import reste dans `/tmp` ») réécrite par la 15-13a au début et par la +15-13b à la fin — et `admin-manual.tex`, le paragraphe « Pour qui garde son fichier compose » +(`:1793` à la spécification, `:1796` au `HEAD`). Git fusionne par lignes : la **seconde** mergée résout à la +main, en relisant la phrase « défaut sans avertissement » **entière** et en refaisant le décompte des gestes. +**Ordre suggéré : après la 15-13a** (#551 est P2, sécurité ; #552 et #576 P3) ; l'ordre inverse est possible sans autre effet que le recompte. Release visée : **v0.13.0**, la même que la 15-11a — la section du manuel « Passer à la 0.13.0 » et l'entrée `[0.13.0]` du CHANGELOG sont **complétées**, pas doublées. @@ -87,7 +95,7 @@ laisser lisible par tous (AC 9, C-15-13-4). | `crates/kesh-api/src/routes/admin.rs:236`, `:240`, `:246` ; `admin_backup/import.rs:204` (via `check_schema_compat`, appelé `admin.rs:172`) | échecs **antérieurs** à la sauvegarde, même variante, même message | | `crates/kesh-api/src/admin_backup/export.rs:103-105` (`map_db` → `AdminFullExportFailed`), appelé par `build_keshbackup` (`admin.rs:259`) | échec de lecture de la base pendant la sauvegarde : message « l'**export** n'a pas pu être généré », alors que l'utilisateur importait | -Le frontend affiche le `message` du corps pour un 500 (`AdminRestorePanel.svelte:82-84`) : aucun code client +Le frontend affiche le `message` du corps pour un 500 (`frontend/src/lib/features/admin-restore/AdminRestorePanel.svelte:81-84`) : aucun code client ne dépend de `ADMIN_FULL_IMPORT_FAILED` (`grep -rn ADMIN_FULL_IMPORT frontend/src` → vide). Les échecs **postérieurs** à l'écriture (`admin.rs:283` à `:462`, douze sites) disent vrai : la sauvegarde existe et la transaction est annulée. @@ -112,7 +120,9 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* défaut `/data/backup`, monté par les compose distribués sur `./backup` ; **hors Docker**, poser la variable sur un dossier inscriptible (sinon l'import est refusé avant toute suppression, la création du dossier échouant, avec le message de l'AC 15). Les recettes du dépôt qui lancent un backend hors - Docker posent la variable (AC 16). + Docker posent la variable (AC 16). Le doc-comment du test 6 a (`config.rs:1643-1646`, « `/tmp` (et non + un chemin vide, qui ferait écrire le backup pré-import dans le répertoire courant) ») devient faux avec + le défaut neuf : il nomme `/data/backup` (R5-7 de la validation P5). (c) Les constructeurs de test (`config.rs:508`, `:1508`) **gardent** `/tmp` : ce sont des configurations de test, pas le défaut. @@ -137,8 +147,9 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* (d) `docker-compose.dev.yml` n'est **pas** modifié (pile de développement non distribuée ; le conteneur y crée `/data/backup` dans son système de fichiers — comportement de développement assumé). (e) **Les dossiers d'hôte montés par défaut restent hors du dépôt et du contexte de build.** - `docker compose up` se lance depuis un clone (`DOCKER_START.md:27`, `:37`) : `./backup` y est créé au - premier import, et un `git add -A` y versionnerait une sauvegarde complète — un **secret**. Le symptôme + `docker compose up` se lance depuis un clone (`DOCKER_START.md:27`, `:37`) : `./backup` y est créé (vide) + par Docker au premier `docker compose up` — la forme courte d'un montage fait créer la source absente + par le démon —, rempli au premier import (F-P5-5 de la validation P5), et un `git add -A` y versionnerait une sauvegarde complète — un **secret**. Le symptôme vaut pour chaque source de montage par défaut, pas pour `./backup` seul : `./inbox` et `./documents` (justificatifs) ne sont pas ignorés non plus (`git check-ignore` muet, seul `log/` l'est, `.gitignore:34`). Donc, **pour chaque entrée de `MONTAGES`** (sources `./log`, `./inbox`, `./documents`, `./backup`) : @@ -163,7 +174,8 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* C-15-13-8.) (a) `write_pre_import_backup` (`routes/admin.rs:471`) garde le choix du nom (inchangé) et délègue l'écriture à une fonction factorisée **sur un chemin donné**, `write_backup_file(path: &Path, bytes: - &[u8]) -> Result<(), AppError>` (privée au module, appelée par les tests 11 et 12). **Écriture par + &[u8]) -> Result<(), AppError>` (privée au module ; appelée directement par le test 12, à travers `write_pre_import_backup` par le + test 11 — F-P6-3 de la validation P6). **Écriture par fichier temporaire, puis renommage** (F-P4-6 de la validation P4, décision de l'orchestrateur, C-15-13-21) : elle crée `.partial` — **même dossier**, donc même système de fichiers — par `tokio::fs::OpenOptions` avec `create_new(true)` et, sous `#[cfg(unix)]`, `mode(0o600)` ; écrit les @@ -172,10 +184,15 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* handler à un `.await`), OOM, `docker stop` — ne laisse donc qu'un `.partial`, **jamais un fichier nommé comme une sauvegarde**. **Refus d'écraser** : `rename` remplace une cible existante sur Unix ; la fonction vérifie donc, avant le renommage, que `` n'existe pas (`tokio::fs::try_exists`) et, sinon, - échoue (`.partial` supprimé au mieux). La fenêtre entre la vérification et le renommage n'est pas + échoue (`.partial` supprimé au mieux) ; un `try_exists` qui rend `Err` (droits sur le dossier) vaut + **échec**, jamais « absent » — le traiter comme absent rouvrirait l'écrasement (R5-3/F-P5-8 de la + validation P5). La fenêtre entre la vérification et le renommage n'est pas fermée — le nom est unique par construction (horodatage, pid, compteur) et un second écrivain du même nom serait déjà un défaut : angle mort écrit. Le journal `info` « backup pré-import écrit » suit le renommage - réussi. Si `write_all`, `sync_all` ou `rename` échoue, le `.partial` est supprimé au mieux + réussi. **On ne supprime que ce qu'on a créé** (F-P5-3) : un échec de `create_new` — `.partial` déjà présent, + écriture interrompue ou autre écrivain — ne supprime **rien**, et ce `.partial` préexistant garde son + contenu. Si `write_all`, `sync_all`, la vérification du nom final ou `rename` échoue, le `.partial` **créé + par l'appel** est supprimé au mieux (`remove_file`, échec journalisé en `warn!` sans masquer l'erreur d'origine) ; si `remove_file` échoue à son tour, le `warn!` **nomme le chemin** du `.partial` et dit qu'il n'est pas une sauvegarde valide. Dans tous ces cas, le message de l'AC 15 c (« aucune sauvegarde n'a été créée ») reste **vrai** : aucun @@ -184,9 +201,16 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* d'écraser et la variante d'erreur. (b) Le dossier, quand Kesh le crée, l'est en `0o700` (`tokio::fs::DirBuilder`, `recursive(true)`, `mode(0o700)` sous `#[cfg(unix)]`) ; un dossier **existant** (le montage de l'hôte) n'est pas modifié. - (c) Une erreur rend `AppError::AdminPreImportBackupFailed` (AC 15) avec le détail actuel - (« création répertoire backup '…' : … », « écriture backup '…' : … ») : aucun import sans sauvegarde - réussie (DC5 inchangé). + Le mode s'applique à **tous les niveaux créés** par l'appel récursif, parents compris (R6-3 = F-P6-6 de + la validation P6) : un `KESH_ADMIN_BACKUP_DIR` sous un parent absent crée ce parent en `0700` aussi — + sans conséquence dans le conteneur (root), écrit au doc-comment et aux angles morts. + (c) Une erreur rend `AppError::AdminPreImportBackupFailed` (AC 15) avec un détail **par étape**, qui + nomme le chemin (R5-3/F-P5-8 de la validation P5 : le journal est le seul diagnostic, le corps n'expose + rien) — « création répertoire backup '…' : … » (inchangé), « création fichier partiel '…' : … » + (`create_new`), « écriture backup '…' : … » (`write_all`), « synchronisation backup '…' : … » + (`sync_all`), « vérification nom final '…' : … » (`try_exists` en `Err`), « nom final déjà présent + '…' » (refus d'écraser), « renommage backup '…' → '…' : … » (`rename`) : aucun import sans sauvegarde + réussie (DC5 inchangé). Les libellés sont du journal, non testés à la lettre. (d) Le commentaire `routes/admin.rs:475-477` (« le backup pré-import est pris avant le verrou FOR UPDATE ») est **faux** — il est pris sous le verrou (`:226-261`) — et corrigé au passage. @@ -198,10 +222,15 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* le contrôle de `docker-compose.yml` exige l'**égalité** quand la source attendue ne commence pas par `${` (aujourd'hui `starts_with` : `./backups` passerait) — les trois entrées existantes gardent le préfixe `${KESH_…_HOST_DIR:-`. Le message de refus pour `docker-compose.prod.yml` reste celui de #558. + Le message de refus pour `docker-compose.yml` (`configuration_transmise.rs:512-514`, « doit avoir pour + source `{prefixe_y}…}` ») est faux pour une source **exacte** — il imprimerait « `./backup…}` » : pour + une entrée dont la source attendue ne commence pas par `${`, le message devient « doit être exactement + `./backup` (montage fixe), trouvé `…` » (R5-6 de la validation P5) ; celui des trois entrées variables + est inchangé. **La comparaison est extraite en fonction pure** (F-P4-2 de la validation P4, C-15-13-24) : `source_conforme(compose: Compose, source: &str, attendue_y: &str, exacte_p: &str) -> bool` — pour `Y`, `starts_with(attendue_y)` si `attendue_y` commence par `${`, égalité sinon ; pour `P`, égalité —, que la - boucle de `controle_transmission` (`configuration_transmise.rs:509-519`) appelle. Sans elle, la règle + boucle de `controle_transmission` (`configuration_transmise.rs:509-521`) appelle. Sans elle, la règle d'égalité vit dans une boucle que l'auto-test (S) n'atteint pas, et rien ne la garde : les vrais compose portent `./backup`, que `starts_with("./backup")` accepte aussi. **Lien avec le défaut du code** (R4-1, C-15-13-24) : le test `transmission` exige en outre qu'une @@ -228,7 +257,11 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* tableaux de `sec:env-vars` restent sans `Overfull \hbox`** (acquis de la 15-11a). Le paragraphe du mode `cargo run` natif (`:194`, « Mode `cargo run` natif (hors Docker) sur Linux non-root », qui pose `KESH_PORT`) gagne une incise : hors Docker, `KESH_ADMIN_BACKUP_DIR` sur un dossier inscriptible, sans - quoi tout import est refusé (AC 15). + quoi tout import est refusé (AC 15). **Aucune de ces incises (`:757`, `:194`) ne donne d'exemple sous + `/tmp`** (R5-4 de la validation P5) : à moins de 90 caractères de `BACKUP_DIR`, il ferait rougir le + contrôle négatif `BACKUP_DIR.{0,90}/tmp` de l'AC 11 l, qui vise l'ancien défaut et non un exemple — et + l'exemple naturel à recopier, `/tmp/kesh-e2e/backup` des recettes de `docs/testing.md` (AC 16 b), est + précisément celui-là. Un exemple, s'il en faut un, se prend hors de `/tmp` (`$HOME/kesh-backup`). (e) **`sec:backup-ui-keshbackup`** (`:1664`, `:1667-1672`, `:1699`) : la sauvegarde de sécurité est dans le dossier `backup/` du répertoire du compose (`/volume1/docker/kesh/backup` sur Synology), nommée `kesh-pre-import--….keshbackup`, **créée en mode `0600`, propriétaire `root`** (le @@ -237,20 +270,30 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* depuis elle** (C-15-13-8) : l'import se fait par le navigateur, et ni File Station, ni un partage SMB, ni un `scp` sans `sudo` ne lisent un fichier `root` en `0600` ; le manuel donne donc le geste de rapatriement, en SSH : - `sudo cp backup/kesh-pre-import-<…>.keshbackup /volume1//`, puis - `sudo chown /volume1//kesh-pre-import-<…>.keshbackup`, l'import par l'écran, + `sudo cp backup/kesh-pre-import-<...>.keshbackup /volume1//`, puis + `sudo chown /volume1//kesh-pre-import-<...>.keshbackup`, l'import par l'écran, puis **la suppression de la copie** (secret) — geste **rejoué au T0** sur un conteneur jetable. La liste « L'import refuse le fichier et préserve l'installation si » (titre `:1667`, liste `:1668-1672` — **pas** `:1676-1690`, qui est la liste « Trois propriétés » des reprises de données) gagne l'entrée de - l'AC 15 : la sauvegarde de sécurité ne peut pas être écrite (dossier non inscriptible, disque plein) - — le message le dit, aucune sauvegarde n'est créée, rien n'est supprimé. Le défaut `/tmp` disparaît + l'AC 15 : la sauvegarde de sécurité ne peut pas être prise ni écrite — **deux pistes**, comme le + message (AC 15 c, F-P5-7 de la validation P5) : le dossier de sauvegarde (non inscriptible, disque + plein) ou la base de données (momentanément inaccessible) — le message le dit, aucune sauvegarde n'est + créée, rien n'est supprimé. Le défaut `/tmp` disparaît de `:1664` et de `:1699`. **Fichier `.partial`** (AC 9 a, C-15-13-21) : le manuel dit qu'un `kesh-pre-import-….keshbackup.partial` est une écriture interrompue, **pas** une sauvegarde, et qu'il peut être supprimé. **Forme du rapatriement** (F-P4-8) : le bloc `sudo cp` / `sudo chown` est introduit par « depuis le répertoire du compose » (le chemin `backup/…` est relatif) et posé dans un `lstlisting` ordinaire (76 caractères), **pas** dans un `keshwarning` (70) : la ligne - `sudo chown /volume1//kesh-pre-import-<…>.keshbackup` en mesure 74 ; si la mise - en page l'exige ailleurs, elle est coupée par `\`. **Ouverture de la section** (`:1643`, « sans accès + `sudo chown /volume1//kesh-pre-import-<...>.keshbackup` en mesure **76** + (`printf '%s' … | wc -m`, remesuré en remédiation P5 avec `<...>`), soit la limite exacte ; si la mise + en page l'exige, elle est coupée par `\`. **Le bloc est en ASCII** (F-P5-6 de la validation P5) : `<...>` + et non `<…>` (U+2026) — **par prudence** : le contrôle aplati ne dépend ainsi d'aucun rendu de glyphe + par `listings` (R6-1 de la validation P6 : dix `lstlisting` du manuel portent déjà du non-ASCII ; la + règle vaut pour ce bloc, non pour le manuel) ; la prose, elle, garde `…`. Contrôle aplati positif ajouté à l'AC 11 l + pour la ligne entière — **si la ligne est coupée** à la mise en page (`breaklines`, ou `\`), le contrôle + suit la forme coupée relevée au T5, écrite au Dev Agent Record (F-P6-5 de la validation P6). + **Vocabulaire** (F-P6-8) : aux sites que la story réécrit (`:757`, `:1699` à la spécification), le + manuel dit « dossier de sauvegarde », le mot même du message de l'AC 15. **Ouverture de la section** (`:1643`, « sans accès SSH ni ligne de commande ») : vraie pour l'export et l'import par l'écran, mais la section reçoit désormais un geste en SSH ; une phrase la précise — la sauvegarde de sécurité **pré-import** fait exception, son rapatriement demande un accès SSH (voir plus bas) — (F-P4-7). Le tableau `:1712` @@ -292,7 +335,12 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* fois au PDF d'avant et ne discrimine pas) ; tous vérifiés **absents** du PDF aplati à `468ce610` (`target/gate-logs/15-13-p1-R-admin-plat.txt`) en remédiation P1 ; `keshbackup.partial` et `target: /data/backup` (vérifiés absents, en remédiation P4, de - `target/gate-logs/15-13b-p4-F-admin-manual-plat.txt` : `grep -c partial` → 0) ; **négatifs** — + `target/gate-logs/15-13b-p4-F-admin-manual-plat.txt` : `grep -c partial` → 0) ; la ligne `sudo chown` + **entière**, en ASCII (`grep -cF 'sudo chown /volume1//kesh-pre-import-<...>.keshbackup'` + → 1 : elle vérifie le rendu du bloc par `listings`, F-P5-6) et l'entrée de la liste des refus qui nomme + la base (`grep -cE 'base de données.{0,40}inaccessible'` ≥ 1, F-P5-7) — les deux vérifiés absents, en + remédiation P5, de `target/gate-logs/15-13b-p5-F-admin-plat.txt` (`grep -cF 'kesh-pre-import-<...>'` + → 0 ; le motif de la base → 0) ; **négatifs** — `grep -E 'BACKUP_DIR.{0,90}/tmp'` vide (la cellule du tableau, `:757`, que `défaut /tmp` ne voit pas) ; `défaut /tmp` absent ; `deux gestes` absent (présent **une** fois au PDF d'avant, relevé en validation P3 — discriminant). Au `.log`, aucun `Overfull \hbox` **nouveau** (comparé au `.log` d'avant, relevé au @@ -305,8 +353,12 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* de la brochure est régénéré par le même `make fr`. 12. **`DOCKER_START.md`** (partie sauvegarde). - (a') Une ligne sur le dossier `./backup` : sauvegarde de sécurité prise avant un import, **secret**, - ignorée de git (AC 8 e), à purger. *(Le reste de l'AC 12 — prérequis, étape `.env`, accès à la base, + (a') Une ligne sur le dossier `./backup` : créé par Docker au premier `docker compose up`, il reçoit la + sauvegarde de sécurité prise avant chaque import ; **secret**, ignoré de git (AC 8 e), à purger. + **Emplacement** (F-P5-4 de la validation P5) : le fichier n'a pas de section « dossiers » et ne nomme + aucun montage (`grep -n "inbox\|documents\|log/\|backup" DOCKER_START.md` → vide) ; la ligne est une puce + de plus à la fin de § *Notes* (`DOCKER_START.md:140`, après celle du volume `mariadb_data`, que la + 15-13a peut retoucher : conflit d'une ligne au rebase, sans autre effet). *(Le reste de l'AC 12 — prérequis, étape `.env`, accès à la base, volume, « Base de données ne s'initialise pas », retrait d'`init-demo.sh` — est dans la 15-13a.)* 13. **CHANGELOG `[0.13.0]`** (parties sauvegarde). @@ -316,13 +368,20 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* le dit, au lieu d'annoncer une sauvegarde qui n'existe pas. Rubrique **Sécurité** : les dossiers montés par défaut (`backup`, `inbox`, `documents`, `log`) sont ignorés de git et du contexte de build (AC 8 e). *(La rubrique **Retiré** `init-demo.sh` : 15-13a.)* - (c) **Propagation** : `CHANGELOG.md:46` porte **cinq** phrases que la 15-13 rend fausses (F-P3-1) ; - **deux** relèvent de la 15-13b, réécrites : « — la sauvegarde pré-import reste dans `/tmp` » (avec le - compose de la 0.13.0, elle va dans `./backup` ; avec un compose gardé sans le montage, dans - `/data/backup` du conteneur, éphémère) ; « le manuel d'administration décrit les **deux gestes** » (le - décompte par fichier de l'AC 11 f — trois et trois, ou cinq et trois si la 15-13a est mergée — ou un - renvoi sans nombre). Les trois autres (refus de Compose, défaut sans avertissement des mots de passe, - `.env` copié du gabarit) sont de la 15-13a. + (c) **Propagation** : `CHANGELOG.md:46` porte **cinq** phrases que la 15-13 rend fausses (F-P3-1). + Ventilation (R5-5 de la validation P5 : la phrase des gestes était revendiquée par les deux fiches, + 4 + 2 = 6 ≠ 5) : **une** relève de la 15-13b seule ; **trois** de la 15-13a seule (refus de Compose, + défaut sans avertissement des mots de passe, `.env` copié du gabarit) ; **une est partagée**, « le + manuel d'administration décrit les **deux gestes** » — soit 1 + 3 + 1 partagée = 5. Les « quatre » + phrases de l'AC 13 c de la 15-13a comptent la partagée ; la fiche sœur n'est pas retouchée (close, en + développement), le décompte se lit ici. + — La phrase **propre** à la 15-13b, réécrite : « — la sauvegarde pré-import reste dans `/tmp` » (avec + le compose de la 0.13.0, elle va dans `./backup` ; avec un compose gardé sans le montage, dans + `/data/backup` du conteneur, éphémère). + — La phrase **partagée**, « deux gestes » : réécrite par la **première** des deux fiches mergée, + son décompte **refait** par la seconde au rebase (§ *Dépendances*) — le décompte par fichier de + l'AC 11 f (trois et trois avec la 15-13b seule, cinq et trois avec les deux) ou un renvoi sans nombre, + qui n'a alors rien à recompter. **Et `CHANGELOG.md:44`** (entrée #550 de `[0.13.0]`, F-P4-3 de la validation P4) : « **Inchangé** : avec `docker-compose.prod.yml`, les montages restent `./documents`, `./inbox`, `./log` » devient faux dans la même version — la phrase gagne « ; la 0.13.0 y ajoute `./backup` (sauvegarde pré-import, #552) ». @@ -338,8 +397,9 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* (a) Variante neuve `AppError::AdminPreImportBackupFailed(String)` (`errors.rs`) : HTTP 500, code `ADMIN_PRE_IMPORT_BACKUP_FAILED`, clé `error-admin-pre-import-backup-failed`, détail journalisé et jamais exposé (comme `AdminFullImportFailed`). Elle couvre **tout** échec antérieur à l'écriture - réussie du fichier : `routes/admin.rs:236`, `:240`, `:246` (transaction, verrou), `:473`, `:487` - (dossier, fichier — et `write_backup_file` de l'AC 9). Le doc-comment de `AdminFullImportFailed` + réussie du fichier : `routes/admin.rs:236`, `:240`, `:246` (transaction, verrou, ligne `_kesh_version` + absente), `:473`, `:487` (dossier, fichier — et `write_backup_file` de l'AC 9) ; avec les deux appels + de l'AC 15 b (`:172`, `:259`), **sept** sites antérieurs à l'écriture (R5-2 de la validation P5). Le doc-comment de `AdminFullImportFailed` (`errors.rs:448-452`, « backup pré-import impossible ») est corrigé : elle ne couvre plus que les échecs **postérieurs** à la sauvegarde (`admin.rs:283` à `:462`, douze sites, inchangés). (b) Les deux sites qui naissent **hors** du module — `check_schema_compat` (`admin_backup/import.rs:204`, @@ -348,13 +408,32 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* `routes/admin.rs`, par une fonction pure `avant_sauvegarde(AppError) -> AppError` qui change `AdminFullImportFailed` et `AdminFullExportFailed` en `AdminPreImportBackupFailed` et rend toute autre variante telle quelle (`ImportSchemaMismatch` reste un 400). `admin_backup` n'est pas modifié. + **Forme prescrite de l'appel** : `….await.map_err(avant_sauvegarde)?` (rustfmt la répartit sur + plusieurs lignes : sans effet, le test 20 lit l'instruction jusqu'au `;` — R6-2 de la validation P6), dans l'instruction même de + l'appel — pas de fermeture `|e| avant_sauvegarde(e)`, pas de conversion différée. **Le branchement est + gardé** (F-P5-1 de la validation P5, C-15-13-28) : les deux sources ne sont pas injectables à bon + compte (une table supprimée fait d'abord rougir `check_schema_compat` en 400), si bien que le test 14 + n'établit que la fonction pure ; le test 20, **lexical**, vérifie qu'elle est appliquée aux deux appels + de l'import, et **pas** à celui de l'export (`full_export`, `:39`, où un échec reste un échec + d'export). (c) Texte, **quatre locales** (`fr-CH`, `de-CH`, `en-CH`, `it-CH`) et **repli Rust** de `errors.rs` (rendu quand aucun catalogue n'est chargé) : l'import a échoué **avant** la sauvegarde de sécurité ou pendant son écriture ; **aucune sauvegarde n'a été créée** ; **rien n'a été supprimé** ; vérifier les journaux du serveur, puis réessayer — **sans orienter vers une seule cause** (F-P4-5 de la validation P4, - C-15-13-25) : sur les cinq sites antérieurs à l'écriture, trois sont des pannes de **base** (transaction, - verrou, lecture du schéma ou des données), deux du **dossier** ; le texte nomme donc les deux pistes, à - égalité (« dossier de sauvegarde inscriptible, base de données accessible »), ou n'en nomme aucune. La + C-15-13-25) : sur les **sept** sites antérieurs à l'écriture (AC 15 a-b, R5-2 de la validation P5), + **cinq** relèvent de la **base** (`:172` lecture du schéma, `:236` transaction, `:240` verrou, `:246` + ligne `_kesh_version` absente, `:259` export de la base — lecture des données ou assemblage du ZIP + de `build_keshbackup`, aucune erreur `serde_json` n'y naît, R6-4 de la validation P6), **deux** du **dossier** (`:473`, `:487` devenu `write_backup_file`, avec ses étapes + `.partial`). **Le texte nomme toujours les deux pistes, à égalité** (R5-1 de la validation P5, + C-15-13-27, qui rectifie C-15-13-25 : l'option « n'en nommer aucune » est **retirée**) — le dossier de + sauvegarde (inscriptible ?) **et** la base de données (joignable ?) —, dans les quatre locales **et** le + repli Rust. C'est ce qui fonde l'écartement du contrôle du dossier au démarrage (angle mort, + C-15-13-9) : le message nomme les deux causes possibles au moment où elles comptent ; un texte qui n'en + nommerait aucune laisserait le cas nominal de #576 (instance hors Docker sans la variable) sans + piste, et l'écartement sans fondement. **Jetons des pistes, à la lettre** (vérifiés par les tests 15 + et 16) : `fr-CH` « dossier de sauvegarde » et « base de données » ; `de-CH` « Sicherungsordner » et + « Datenbank » ; `en-CH` « backup folder » et « database » ; `it-CH` « cartella di backup » et + « database » ; repli Rust : ceux de `fr-CH`. La **négation** est la forme attendue (« aucune sauvegarde n'a été créée », « Es wurde keine Sicherung erstellt », « No backup was created », « Non è stato creato alcun backup ») : le test 16 n'interdit pas les mots « créé » / @@ -367,8 +446,10 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* seulement l'absence de la promesse** (F-P4-4) : le test 16 vérifie que le texte de chaque locale **contient** sa négation (les quatre formulations ci-dessus, à la lettre) — un texte neuf « Échec de l'import. » ne dirait plus qu'aucune copie n'existe, ce qui est l'objet de #576 ; le test 15 vérifie de - même que le repli Rust contient « aucune sauvegarde n'a été créée ». - (d) Frontend **non touché** : un 500 affiche le `message` du corps (`AdminRestorePanel.svelte:82-84`), + même que le repli Rust contient « aucune sauvegarde n'a été créée ». **Les deux pistes sont exigées de + même** (R5-1) : le test 16 vérifie que chaque texte **contient** ses deux jetons, le test 15 que le + repli Rust contient « dossier de sauvegarde » et « base de données » (mutations **M49**, **M50**). + (d) Frontend **non touché** : un 500 affiche le `message` du corps (`frontend/src/lib/features/admin-restore/AdminRestorePanel.svelte:81-84`), aucun code client ne lit `ADMIN_FULL_IMPORT_FAILED`. Le test de parité des catalogues (`kesh-i18n/src/loader.rs:729-800`) reste vert sans entrée neuve dans `dette-parite-connue.txt`. (e) Manuel : la liste des refus de `sec:backup-ui-keshbackup` (AC 11 e). @@ -423,51 +504,51 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* ## Tasks / Subtasks -- [ ] **T0 — Mesures avant d'écrire** (AC 11 e, 11 f, 11 l) - - [ ] Rapatriement de la sauvegarde (AC 11 e) rejoué sur un **conteneur jetable** : un conteneur root +- [x] **T0 — Mesures avant d'écrire** (AC 11 e, 11 f, 11 l) + - [x] Rapatriement de la sauvegarde (AC 11 e) rejoué sur un **conteneur jetable** : un conteneur root écrit un fichier `0600` dans un dossier monté de l'hôte ; vérifier qu'il est illisible de l'utilisateur, que `sudo cp` + `sudo chown` le rendent lisible, puis supprimer copie et dossier. - - [ ] Graphie de `docker compose config` pour un montage (forme longue `target: /data/backup`), pour la + - [x] Graphie de `docker compose config` pour un montage (forme longue `target: /data/backup`), pour la ligne de contrôle de l'AC 11 f ; puis le contrôle **rouge** : sur une copie du compose **sans** le montage, avec `KESH_ADMIN_BACKUP_DIR=/data/backup` posé dans l'environnement, `docker compose config | grep -c 'target: /data/backup'` → **0** (alors que `grep -c '/data/backup'` rend 1 : la ligne d'environnement) — R4-2/F-P4-1 de la validation P4. - - [ ] `make fr` sur l'état d'avant : garder le `.log` (référence des `Overfull`) et le PDF aplati + - [x] `make fr` sur l'état d'avant : garder le `.log` (référence des `Overfull`) et le PDF aplati (référence des contrôles de l'AC 11 l, chacun **rouge** dessus). -- [ ] **T1 — Compose, gabarit, fichiers d'exclusion** (AC 8) - - [ ] `docker-compose.yml` et `docker-compose.prod.yml` : montage `./backup` + commentaire (rien d'autre +- [x] **T1 — Compose, gabarit, fichiers d'exclusion** (AC 8) + - [x] `docker-compose.yml` et `docker-compose.prod.yml` : montage `./backup` + commentaire (rien d'autre dans `docker-compose.prod.yml`). - - [ ] `.gitignore` (`/inbox/`, `/documents/`, `/backup/` — **ancrés**) et `.dockerignore` (`log/`, + - [x] `.gitignore` (`/inbox/`, `/documents/`, `/backup/` — **ancrés**) et `.dockerignore` (`log/`, `inbox/`, `documents/`, `backup/`) — AC 8 e ; `git ls-files` sous ces dossiers à la racine → vide ; `git check-ignore -v --no-index "frontend/src/routes/(app)/admin/backup/+page.svelte"` → **non** ignoré (sans `--no-index`, git ne rapporte jamais un fichier suivi : le contrôle ne pourrait pas rougir — R4-3 de la validation P4 ; avec `backup/` non ancré, la même commande le rend ignoré). - - [ ] `.env.example` : commentaire `KESH_ADMIN_BACKUP_DIR` (`:179-182`), paragraphe (d) du mode + - [x] `.env.example` : commentaire `KESH_ADMIN_BACKUP_DIR` (`:179-182`), paragraphe (d) du mode `cargo run` natif (`:77-82`), et les deux énumérations des montages fixes (`:208-210`, `:241-242`, AC 8 c). -- [ ] **T2 — Code** (AC 7, 9, 15) - - [ ] `config.rs` : `pub const DEFAULT_ADMIN_BACKUP_DIR`, doc-comments. - - [ ] `routes/admin.rs` : `write_backup_file` (`.partial` en `create_new` + `0o600`, `sync_all`, refus si +- [x] **T2 — Code** (AC 7, 9, 15) + - [x] `config.rs` : `pub const DEFAULT_ADMIN_BACKUP_DIR`, doc-comments. + - [x] `routes/admin.rs` : `write_backup_file` (`.partial` en `create_new` + `0o600`, `sync_all`, refus si le nom final existe, `rename` ; `.partial` supprimé au mieux en cas d'échec) ; dossier créé en `0o700` ; commentaire `:475-477` corrigé ; sites antérieurs à la sauvegarde sur `AdminPreImportBackupFailed` ; `avant_sauvegarde` aux appels de - `check_schema_compat` et `build_keshbackup`. - - [ ] `errors.rs` : variante `AdminPreImportBackupFailed`, son bras de réponse et son repli ; doc et + `check_schema_compat` et `build_keshbackup` (forme `.map_err(avant_sauvegarde)`, AC 15 b, test 20). + - [x] `errors.rs` : variante `AdminPreImportBackupFailed`, son bras de réponse et son repli ; doc et repli d'`AdminFullImportFailed` (AC 15 a, c). - - [ ] `crates/kesh-i18n/locales/{fr-CH,de-CH,en-CH,it-CH}/messages.ftl` : clé + - [x] `crates/kesh-i18n/locales/{fr-CH,de-CH,en-CH,it-CH}/messages.ftl` : clé `error-admin-pre-import-backup-failed`, à côté de `error-admin-full-import-failed`. -- [ ] **T3 — Tests Rust** (AC 7, 8 e, 9, 10 c-e, 15) — voir § *Tests et mutations*. Dans +- [x] **T3 — Tests Rust** (AC 7, 8 e, 9, 10 c-e, 15) — voir § *Tests et mutations*. Dans `configuration_transmise.rs` : quatrième entrée de `MONTAGES`, fonction pure `source_conforme` (égalité exacte pour une source sans `${`), lien `DEFAULT_ADMIN_BACKUP_DIR` ↔ cible de `MONTAGES`, textes - « trois » mis à jour (AC 10 c, e) ; test 19 (AC 8 e). -- [ ] **T5 — Manuel et brochure** (AC 11) : sites (d) (ligne `KESH_ADMIN_BACKUP_DIR`, montages, `:194`), + « trois » mis à jour, message de refus `Y` d'une source exacte (AC 10 c, e) ; test 19 (AC 8 e). +- [x] **T5 — Manuel et brochure** (AC 11) : sites (d) (ligne `KESH_ADMIN_BACKUP_DIR`, montages, `:194`), (e), (f) (paragraphe sauvegarde, `:1773`, `:1793`), (i), puis relecture de la brochure (n) ; `make fr` (trois PDF), contrôles (k)–(l). -- [ ] **T6 — `DOCKER_START.md`, recettes, CHANGELOG** (AC 12 a', 13, 16) — `CLAUDE.md` : la ligne de +- [x] **T6 — `DOCKER_START.md`, recettes, CHANGELOG** (AC 12 a', 13, 16) — `CLAUDE.md` : la ligne de commande de la recette et la mention de date de `:174` (AC 16 a), rien d'autre ; CHANGELOG `:44` (montages de `docker-compose.prod.yml`) et `:46` (AC 13 c). -- [ ] **T8 — Mutations** : chacune appliquée, test rouge relevé, fichier restauré **et touché** +- [x] **T8 — Mutations** : chacune appliquée, test rouge relevé, fichier restauré **et touché** (`touch`, mémoire « mutation restaurée, binaire périmé ») ; tableau au Dev Agent Record. -- [ ] **T9 — Propagation et inventaire** (AC 14) — exclusions communes +- [x] **T9 — Propagation et inventaire** (AC 14) — exclusions communes `--exclude-dir={target,node_modules,.git,_bmad-output,_bmad,.claude,.svelte-kit}` : (1) `grep -rnIE "BACKUP_DIR|pré-import|pre-import|admin-full-import-failed|AdminFullImportFailed|/tmp|(compose|décrit les)[^.]{0,20}deux gestes|backup/" …` — chaque résultat est dans l'inventaire (résolu ou angle mort), sinon il y entre (le jeton `/tmp` @@ -485,7 +566,7 @@ des AC 10 à 14 qui portent sur MariaDB aussi.)* `grep -rnIE '\./(inbox|documents|log)\b' …` — tout site qui énumère les montages de `docker-compose.prod.yml` (ou de `kesh-api`) en entier gagne `./backup`, les autres sont triés (inventaire, ligne « grep (4) »). -- [ ] **T10 — Gates** : `scripts/test-fast.sh` complet (base du worktree remise à zéro avant — +- [x] **T10 — Gates** : `scripts/test-fast.sh` complet (base du worktree remise à zéro avant — `DROP`/`CREATE` de **ses** bases, jamais un redémarrage du conteneur), frontend non touché (gate frontend tout de même, CLAUDE.md), **E2E complet au dernier commit de code** (D7), lancé avec la recette corrigée de l'AC 16 ; la story ne touche aucune migration (exception `kesh-db` sans objet). @@ -503,25 +584,30 @@ Numérotation de la fiche unique conservée. | 5 | `s_sources_de_montage` (existant, complété) — exerce la fonction pure `source_conforme` (AC 10 d) : en `Y` avec `"./backup"`, `./backup` accepté et `./backups` **refusé** ; en `Y` avec un préfixe `${…:-`, la source variable acceptée ; en `P`, égalité | AC 10 c-d | **M14** `source_conforme` revient à `starts_with` pour toutes les entrées (le cas `./backups` en `Y` passe) | | 6 | `from_env_empty_or_blank_vars_take_code_default_silently` (existant, `config.rs:1648`, assertion par la constante — 6 a) + `from_env_absent_backup_dir_takes_data_backup` (nouveau — 6 b, assertion sur le **littéral** `"/data/backup"`, seul littéral voulu côté test : il fixe la valeur dont #552 dépend) | AC 7 | **M15** **valeur de la constante** `DEFAULT_ADMIN_BACKUP_DIR` changée en `"/tmp"` (geste naturel : 6 a, qui compare la constante à elle-même, reste vert ; 6 b et le test 3 rougissent) | | 11 | `write_pre_import_backup_en_0600` (nouveau, `#[cfg(unix)]`, `mod tests` neuf de `routes/admin.rs`, `tempfile`) — `write_pre_import_backup` sur un sous-dossier absent d'un `TempDir` : dossier créé en `0700`, fichier en `0600`, contenu identique ; **assertions de montage** : un fichier témoin écrit par `std::fs::write` dans le même dossier n'est **pas** en `0600`, **et** un dossier témoin créé par `std::fs::create_dir` à côté n'est **pas** en `0700` (R4-9 de la validation P4 : sans ce second témoin, M25 passe sous umask `077`) — sinon l'umask rend le test non discriminant : échec explicite, dont le message **nomme la cause** (« umask de l'environnement trop restrictif (077 ?) : le test ne peut pas distinguer le mode posé par le code »), et non un défaut du code ; sous l'umask `022` du poste de dev et de la CI, sans effet. Après l'écriture, aucun `.partial` ne reste dans le dossier | AC 9 a-b | **M24** `.mode(0o600)` retiré · **M25** `DirBuilder` sans `mode` (dossier en `0755`) | -| 12 | `write_backup_file_n_ecrase_pas` (nouveau, même module) — `write_backup_file` sur un chemin final **déjà occupé** échoue en `AppError::AdminPreImportBackupFailed`, le fichier existant garde son contenu, et aucun `.partial` ne reste ; **+** `write_backup_file_passe_par_un_partiel` (nouveau, même module, C-15-13-21) — un `.partial` **préexistant** (écriture interrompue) fait échouer l'appel en `AdminPreImportBackupFailed`, et le nom final **n'existe pas** après l'échec : le nom de la sauvegarde n'apparaît qu'au terme d'une écriture complète passée par le `.partial` | AC 9 a, 9 c | **M26** vérification d'existence du nom final retirée (le `rename` écrase) · **M36** `write_backup_file` rend `AdminFullImportFailed` · **M46** écrire directement le nom final, sans `.partial` (le second test rougit : l'appel réussit, le nom final existe) | +| 12 | `write_backup_file_n_ecrase_pas` (nouveau, même module) — `write_backup_file` sur un chemin final **déjà occupé** échoue en `AppError::AdminPreImportBackupFailed`, le fichier existant garde son contenu, et aucun `.partial` ne reste ; **+** `write_backup_file_passe_par_un_partiel` (nouveau, même module, C-15-13-21) — un `.partial` **préexistant** (écriture interrompue) fait échouer l'appel en `AdminPreImportBackupFailed`, le nom final **n'existe pas** après l'échec, et ce `.partial` préexistant **garde son contenu** (on ne supprime que ce qu'on a créé, F-P5-3 de la validation P5) : le nom de la sauvegarde n'apparaît qu'au terme d'une écriture complète passée par le `.partial` | AC 9 a, 9 c | **M26** vérification d'existence du nom final retirée (le `rename` écrase) · **M36** `write_backup_file` rend `AdminFullImportFailed` · **M46** écrire directement le nom final, sans `.partial` (le second test rougit : l'appel réussit, le nom final existe) · **M51** nettoyage du `.partial` étendu à l'échec de `create_new` (le second test rougit : le `.partial` préexistant a disparu) | | 14 | `avant_sauvegarde_convertit_les_echecs_anterieurs` (nouveau, unitaire, `routes/admin.rs`) — `AdminFullImportFailed` et `AdminFullExportFailed` → `AdminPreImportBackupFailed`, détail conservé ; `ImportSchemaMismatch` et `InvalidBackupStructure` rendus tels quels | AC 15 b | **M30** `avant_sauvegarde` rend l'erreur telle quelle · **M31** `avant_sauvegarde` convertit toute variante (un 400 devient un 500) | -| 15 | `pre_import_backup_failed_maps_to_500_without_promise` (nouveau, `errors.rs`, repli Rust — aucun catalogue n'est chargé dans les tests de la bibliothèque, `init_error_i18n` n'y est jamais appelé) — 500, `ADMIN_PRE_IMPORT_BACKUP_FAILED`, message sans le détail et sans promesse de sauvegarde, et qui **contient** « aucune sauvegarde n'a été créée » (F-P4-4) ; **témoin** : `AdminFullImportFailed` rend `ADMIN_FULL_IMPORT_FAILED` et un repli **différent** | AC 15 a, c | **M32** le bras de la variante neuve rend le code et la clé d'`AdminFullImportFailed` | -| 16 | `catalogues_distinguent_l_echec_de_sauvegarde` (nouveau, `errors.rs`, `I18nBundle::load` sur `concat!(env!("CARGO_MANIFEST_DIR"), "/../kesh-i18n/locales")` — chemin indépendant du répertoire courant (F-P3-10) —, **sans** toucher au catalogue global) — pour chacune des quatre locales : `error-admin-pre-import-backup-failed` ≠ `error-admin-full-import-failed` de la même locale ; hors `fr-CH`, ≠ le texte `fr-CH` (sinon `format` est retombé sur le français, `loader.rs:116-121`) ; ne contient pas la **promesse** de l'ancien texte, phrase intérieure de sa parenthèse **sans les parenthèses** (R3-9) : `un backup automatique a été créé avant l'opération`, `vor dem Vorgang wurde automatisch ein Backup erstellt`, `an automatic backup was created before the operation`, `prima dell'operazione è stato creato un backup automatico` — **pas** des sous-chaînes que la négation prescrite contient (`a été créé` est préfixe de « n'a été créée ») ; **assertion de montage** : chaque interdit figure dans `error-admin-full-import-failed` de **sa** locale (texte inchangé), faute de quoi l'interdit est une coquille et le test passe à vide. Vérifié en remédiation P3 : `grep -cF` de chaque interdit (sans parenthèses) → 1 dans le `messages.ftl` de sa locale (`fr-CH:1478`, `de-CH`/`en-CH`/`it-CH:1407`), et dans aucun autre ; `printf '%s' "" \| grep -cF -- ""` → 0 pour les vingt couples (quatre formulations de l'AC 15 c et « Non è stato creato un backup automatico », contre les quatre interdits) ; **et** (F-P4-4 de la validation P4) chaque texte neuf **contient** la négation de sa locale, à la lettre (`aucune sauvegarde n'a été créée`, `Es wurde keine Sicherung erstellt`, `No backup was created`, `Non è stato creato alcun backup`) | AC 15 c | **M33** texte `de-CH` remplacé par celui d'`error-admin-full-import-failed` · **M34** clé retirée d'`it-CH` (rouge aussi à `parity_between_locales`) · **M38** texte `fr-CH` neuf qui recopie la parenthèse de l'ancien · **M45** texte `fr-CH` neuf réduit à « Échec de l'import. » (sans négation ni promesse) | -| 17 | `full_import_refuses_when_backup_cannot_be_written` (nouveau, `admin_full_import_e2e.rs`) — `admin_backup_dir` = chemin d'un **fichier** existant (la création du dossier échoue même sous root : en CI comme en local) ; `spawn_app` factorisé en `spawn_app_with(pool, config)` (DRY) ; 500, `ADMIN_PRE_IMPORT_BACKUP_FAILED`, message = repli (ce binaire n'appelle pas `init_error_i18n`) ; installation intacte (société et administrateur d'origine, comme le test 18) | AC 15 a, 9 c | **M35** `admin.rs:473` (création du dossier) rend `AdminFullImportFailed` | -| 18 | `full_import_rolls_back_on_insert_failure` (existant, `admin_full_import_e2e.rs:559-605`, complété) — échec **après** la sauvegarde : code `ADMIN_FULL_IMPORT_FAILED` | AC 15 a | **M37** `admin.rs:291` (`restore :`) rend `AdminPreImportBackupFailed` | +| 15 | `pre_import_backup_failed_maps_to_500_without_promise` (nouveau, `errors.rs`, repli Rust — aucun catalogue n'est chargé dans les tests de la bibliothèque, `init_error_i18n` n'y est jamais appelé) — 500, `ADMIN_PRE_IMPORT_BACKUP_FAILED`, message sans le détail et sans promesse de sauvegarde, et qui **contient** « aucune sauvegarde n'a été créée » (F-P4-4) ainsi que les deux pistes, « dossier de sauvegarde » **et** « base de données » (R5-1 de la validation P5) ; **témoin** : `AdminFullImportFailed` rend `ADMIN_FULL_IMPORT_FAILED` et un repli **différent** | AC 15 a, c | **M32** le bras de la variante neuve rend le code et la clé d'`AdminFullImportFailed` · **M50** repli Rust de la variante neuve sans « base de données » (une seule piste) | +| 16 | `catalogues_distinguent_l_echec_de_sauvegarde` (nouveau, `errors.rs`, `I18nBundle::load` sur `concat!(env!("CARGO_MANIFEST_DIR"), "/../kesh-i18n/locales")` — chemin indépendant du répertoire courant (F-P3-10) —, **sans** toucher au catalogue global) — pour chacune des quatre locales : `error-admin-pre-import-backup-failed` ≠ `error-admin-full-import-failed` de la même locale ; hors `fr-CH`, ≠ le texte `fr-CH` (sinon `format` est retombé sur le français, `loader.rs:116-121`) ; ne contient pas la **promesse** de l'ancien texte, phrase intérieure de sa parenthèse **sans les parenthèses** (R3-9) : `un backup automatique a été créé avant l'opération`, `vor dem Vorgang wurde automatisch ein Backup erstellt`, `an automatic backup was created before the operation`, `prima dell'operazione è stato creato un backup automatico` — **pas** des sous-chaînes que la négation prescrite contient (`a été créé` est préfixe de « n'a été créée ») ; **assertion de montage** : chaque interdit figure dans `error-admin-full-import-failed` de **sa** locale (texte inchangé), faute de quoi l'interdit est une coquille et le test passe à vide. Vérifié en remédiation P3 : `grep -cF` de chaque interdit (sans parenthèses) → 1 dans le `messages.ftl` de sa locale (`fr-CH:1478`, `de-CH`/`en-CH`/`it-CH:1407`), et dans aucun autre ; `printf '%s' "" \| grep -cF -- ""` → 0 pour les vingt couples (quatre formulations de l'AC 15 c et « Non è stato creato un backup automatico », contre les quatre interdits) ; **et** (F-P4-4 de la validation P4) chaque texte neuf **contient** la négation de sa locale, à la lettre (`aucune sauvegarde n'a été créée`, `Es wurde keine Sicherung erstellt`, `No backup was created`, `Non è stato creato alcun backup`) ; **et** (R5-1 de la validation P5) chaque texte neuf **contient** ses deux jetons de piste, à la lettre (`dossier de sauvegarde`/`base de données`, `Sicherungsordner`/`Datenbank`, `backup folder`/`database`, `cartella di backup`/`database`) | AC 15 c | **M33** texte `de-CH` remplacé par celui d'`error-admin-full-import-failed` · **M34** clé retirée d'`it-CH` (rouge aussi à `parity_between_locales`) · **M38** texte `fr-CH` neuf qui recopie la parenthèse de l'ancien · **M45** texte `fr-CH` neuf réduit à « Échec de l'import. » (sans négation ni promesse) · **M49** texte `en-CH` neuf sans « database » (une seule piste, le dossier) | +| 17 | `full_import_refuses_when_backup_cannot_be_written` (nouveau, `admin_full_import_e2e.rs`) — `admin_backup_dir` = chemin d'un **fichier** existant (la création du dossier échoue même sous root : en CI comme en local) ; `spawn_app` factorisé en `spawn_app_with(pool, config)` (DRY) ; 500, `ADMIN_PRE_IMPORT_BACKUP_FAILED`, message = repli (ce binaire n'appelle pas `init_error_i18n`) ; installation intacte (société et administrateur d'origine, comme le test 18) | AC 15 a, 9 c | **M35** la création du dossier dans `write_pre_import_backup` (`admin.rs:473` à la spécification, réécrite en `DirBuilder` : F-P6-7) rend `AdminFullImportFailed` | +| 18 | `full_import_rolls_back_on_insert_failure` (existant, `admin_full_import_e2e.rs:559-605`, complété) — échec **après** la sauvegarde : code `ADMIN_FULL_IMPORT_FAILED` | AC 15 a | **M37** le site `restore :` de `run_backup_and_restore` (`admin.rs:291`) rend `AdminPreImportBackupFailed` | | 19 | `montages_hors_du_depot` (nouveau, `configuration_transmise.rs`) — pour **chaque** entrée de `MONTAGES`, le dossier de sa source `docker-compose.prod.yml` (`./x` → `x`) a sa ligne dans `.gitignore` sous la forme **ancrée** `/x/` — **seule tolérance** : `log`, pour qui `log/` (ligne existante, `.gitignore:34`) est admise, la liste des tolérances étant une constante qui ne compte que `log` (assertion) — et dans `.dockerignore` (`x/`) ; la liste est **dérivée** de `MONTAGES`, pas recopiée (un cinquième montage ajouté demain est contrôlé sans retouche) ; **assertion de montage** : `MONTAGES` compte **au moins** quatre entrées, dont celle de cible `/data/backup` (R4-5 de la validation P4, C-15-13-26 : un compte exact obligerait à retoucher le test au cinquième montage, contre la phrase précédente), et chaque ligne est cherchée **entière** (après `trim`), pas en sous-chaîne (`log/` ne doit pas satisfaire `/backup/`, ni `backup/` satisfaire `/backup/`) | AC 8 e | **M39** retirer `/backup/` de `.gitignore` · **M40** retirer `backup/` de `.dockerignore` · **M41** `/backup/` remplacé par `backup/` (non ancré) dans `.gitignore` (R3-1) | +| 20 | `avant_sauvegarde_branchee_aux_appels_de_l_import` (nouveau, `mod tests` de `routes/admin.rs`, F-P5-1 de la validation P5, C-15-13-28) — **garde lexicale** : lit `include_str!("admin.rs")` tronqué à la première ligne `#[cfg(test)]` (le module de test ne se lit pas lui-même), lignes dont le `trim_start` commence par `//` écartées (les doc-comments nomment `build_keshbackup` : `:129`, `:256`) ; repère chaque **appel** `check_schema_compat(` et `build_keshbackup(` (identifiant suivi de `(`, précédé ni de `fn ` ni d'un `use`), le rattache à la fonction de premier niveau qui l'entoure (dernière ligne `pub async fn ` / `async fn ` en colonne 0 qui le précède) et lit son **instruction** (texte jusqu'au `;` suivant, retours à la ligne compris : `rustfmt` peut la couper) — dans `full_import` et `run_backup_and_restore`, l'instruction **contient** `.map_err(avant_sauvegarde)` ; dans `full_export`, elle **ne le contient pas** ; **assertion de montage** : exactement **un** appel de `check_schema_compat` et **deux** de `build_keshbackup` (un par route), sinon échec qui nomme le décompte trouvé — un garde qui ne trouve rien passerait à vide, et un appel ajouté demain doit être trié | AC 15 b | **M47** `.map_err(avant_sauvegarde)` retiré à l'appel de `check_schema_compat` (`:172`) · **M48** retiré à l'appel de `build_keshbackup` (`:259`) · **M52** `.map_err(avant_sauvegarde)` **ajouté** à l'appel de `build_keshbackup` de `full_export` (`:39`) — branche négative (F-P6-2 de la validation P6) | -**Décompte** (recompté sur le tableau) : 11 lignes, toutes côté Rust, qui portent **13 fonctions de test** -(9 neuves : lignes 6 b, 11, 12 ×2, 14, 15, 16, 17, 19 ; 4 existantes complétées : lignes 3, 5, 6 a, 18), dans +**Décompte** (recompté sur le tableau) : 12 lignes, toutes côté Rust, qui portent **14 fonctions de test** +(10 neuves : lignes 6 b, 11, 12 ×2, 14, 15, 16, 17, 19, 20 ; 4 existantes complétées : lignes 3, 5, 6 a, 18), dans cinq fichiers existants (`configuration_transmise.rs`, `config.rs`, `routes/admin.rs`, `errors.rs`, -`admin_full_import_e2e.rs`) ; **22 mutations** (M9–M11, M14, M15, M24–M26, M30–M41, M45, M46), toutes +`admin_full_import_e2e.rs`) ; **28 mutations** (M9–M11, M14, M15, M24–M26, M30–M41, M45–M52 — M52 ajoutée au T0, F-P6-2), toutes attendues rouges. M11 rougit **deux** familles ((T) et (F)), M15 et M34 deux tests chacune : attendu, le relever. **M26** change de sens avec l'écriture par `.partial` : `create_new` porte désormais sur le `.partial`, et c'est la vérification d'existence du nom final qui empêche l'écrasement. Non couverts par un test, écrits comme angles morts : les sites `admin.rs:236`, `:240`, `:246` (panne de base avant la -sauvegarde, non injectable sans abstraction) et la suppression du `.partial` de l'AC 9 a après un échec de -`write_all`, `sync_all` ou `rename` (non injectable) — y compris l'échec de cette suppression (`.partial` -laissé, nommé par le `warn!`). +sauvegarde, non injectable sans abstraction) ; les pannes **réelles** derrière `:172` et `:259` (lecture du +schéma, lecture des données — non injectables, F-P5-1) : leur **branchement** sur `avant_sauvegarde` est +gardé par le test 20 (M47, M48 rouges), mais aucun test n'en fait passer une panne effective jusqu'à la +réponse HTTP ; et la suppression du `.partial` de l'AC 9 a après un échec de +`write_all`, `sync_all`, de la vérification du nom final ou de `rename` (non injectable) — y compris l'échec +de cette suppression (`.partial` laissé, nommé par le `warn!`). En revanche, qu'un échec de `create_new` +**ne supprime pas** le `.partial` préexistant est vérifié par le test 12 (F-P5-3 de la validation P5). Le test **(L)** (`garde_lecture_du_code`) et **(E)** (`env_example`) doivent rester verts **sans modification** de leurs listes : la story n'ajoute aucune lecture d'environnement ni aucune variable. @@ -607,7 +693,7 @@ validation P3. **Résolu** = un AC le traite ; **assumé** = angle mort écrit. | grep (4) du T9, reste : `docker-compose.yml:127-143`, `docker-compose.prod.yml:112-168` (commentaires et montages un par un), `docker-compose.dev.yml:34`, `.env.example:216`, `:237`, `:244`, `:263`, `:266`, `CHANGELOG.md:46` (« revient à `./documents` »), `:674`, `crates/kesh-api/tests/configuration_transmise.rs:200-205`, `:367`, `docs/kesh-specifications.txt:1739`, `admin-manual.tex:819-821`, `:837`, `:853`, `:884`, `:898`, `:1421`, `:1516`, `:1555`, `:1778` | un montage nommé seul, ou une variable d'hôte — aucune énumération de **tous** les montages | **assumé** — restent vrais avec le quatrième (`:1421`, `:1555` : périmètre de sauvegarde, AC 11 i ; `:200-205` : `MONTAGES`, résolu par l'AC 10 c ; `:819-821` : tableau des variables d'hôte, sans ligne pour `./backup`, AC 11 d) | | `.github/workflows/release.yml:91-101` | lance l'image publiée (root, `/data/backup` inscriptible), sans import | **assumé** (AC 16) | | `.gitignore` (seul `log/`, `:34`), `.dockerignore` (aucun) | dossiers montés par défaut (`./backup`, `./inbox`, `./documents`, `./log`) non ignorés dans un clone | résolu (AC 8 e, test 19) ; `log/` non ancré : tolérance nommée | -| `frontend/src/routes/(app)/admin/backup/+page.svelte`, `+page.ts` (versionnés) | homonyme du dossier `backup` | **assumé** — protégé par l'ancrage `/backup/` (AC 8 e, M41) | +| `frontend/src/routes/(app)/admin/backup/+page.svelte`, `+page.ts` (versionnés) ; rendus par le jeton `backup/` du grep (1) (F-P5-2 de la validation P5) : `frontend/src/routes/(app)/admin/restore/+page.ts:1`, `frontend/src/lib/features/admin-backup/AdminBackupPanel.svelte:4`, `crates/kesh-db/migrations/20260915000001_audit_log_company_id.sql:22` | homonyme du dossier `backup` (route, commentaires ; la migration ne se modifie pas — P8) | **assumé** — protégé par l'ancrage `/backup/` (AC 8 e, M41) ; les trois derniers ne parlent pas du dossier de sauvegarde | | `crates/kesh-api/src/config.rs:250-256`, `:939-947`, `:1643-1661` | défaut `/tmp` | résolu (AC 7) | | `crates/kesh-api/src/config.rs:508`, `:1508` | `/tmp` des constructeurs de test | **assumé**, voulu (AC 7 c) | | `crates/kesh-api/src/config.rs:1586`, `:1601`, `:1748`, `:1769` | `KESH_ADMIN_BACKUP_DIR` comme variable témoin des tests (`reset_env`, `VIDE_EGALE_DEFAUT`, `env_nonempty`) | **assumé** — restent vrais, aucune valeur `/tmp` | @@ -615,7 +701,7 @@ validation P3. **Résolu** = un AC le traite ; **assumé** = angle mort écrit. | `crates/kesh-api/src/routes/admin.rs:475-477` | commentaire faux (« avant le verrou ») | résolu (AC 9 d) | | `crates/kesh-api/src/routes/admin.rs:42`, `:129-130`, `:174`, `:219`, `:227`, `:481`, `:492` | doc et journal « backup pré-import » | **assumé** — restent vrais (`:481` est le nom du fichier, inchangé) | | `crates/kesh-api/src/routes/admin.rs:467-470` | doc de `write_pre_import_backup` (« Échec d'écriture → 500 ») | résolu (AC 9 a : mode `0600`, refus d'écraser, variante d'erreur) | -| `crates/kesh-api/src/routes/admin.rs:236`, `:240`, `:246`, `:473`, `:487` ; `:172`, `:259` (appels) | échecs antérieurs à la sauvegarde | résolu (AC 15 a-b) | +| `crates/kesh-api/src/routes/admin.rs:236`, `:240`, `:246`, `:473`, `:487` ; `:172`, `:259` (appels) | échecs antérieurs à la sauvegarde (sept sites) | résolu (AC 15 a-b) ; branchement de `:172` et `:259` gardé par le test 20 ; `admin.rs:39` (`build_keshbackup` de l'**export**) **sans** `avant_sauvegarde`, voulu (test 20) | | `crates/kesh-api/src/routes/admin.rs:283` à `:462` (douze sites `AdminFullImportFailed`) | échecs postérieurs | **inchangé, voulu** (AC 15 a) | | `crates/kesh-api/src/errors.rs:448-452`, `:1819-1828` | variante et message de #576 | résolu (AC 15 a, c) | | `crates/kesh-api/src/admin_backup/import.rs:204`, `admin_backup/export.rs:103-105` | variantes nées hors du module | résolu **à l'appel** (AC 15 b) ; fichiers non modifiés | @@ -630,10 +716,10 @@ validation P3. **Résolu** = un AC le traite ; **assumé** = angle mort écrit. | `frontend/tests/e2e/password-recovery.spec.ts:16`, `frontend/tests/e2e/global-setup.ts:34` | `cargo run -p kesh-api` | **assumé** (AC 16 c) | | `DOCKER_START.md:131`, `.env.example:32`, `:186`, `:249`, `docker-compose.dev.yml:2`, `frontend/vite.config.ts:20`, `admin-manual.tex:668`, `:1315`, `docs/testing.md:61`, `CHANGELOG.md:655` | mentions du mode `cargo run`, sans recette de lancement (second grep de l'AC 16) | **assumé** (AC 16 c) | | `scripts/prepare-release.sh:109`, `crates/kesh-db/src/post_restore.rs:1168`, `crates/kesh-db/tests/test_schema_guard.rs:548`, `CLAUDE.md:798`, `docs/kesh-specifications.txt:1110` | `cargo run` d'autres binaires, ou prose | **assumé** (AC 16 c) | -| `DOCKER_START.md` — section « dossiers » (ligne neuve) | `./backup` | résolu (AC 12 a') | +| `DOCKER_START.md` § *Notes* (`:140`, puce neuve — le fichier n'a pas de section « dossiers ») | `./backup` | résolu (AC 12 a', F-P5-4) | | `admin-manual.tex:757`, `:807-822` (dont `:812`, énumération des montages fixes) | `sec:env-vars` (ligne `KESH_ADMIN_BACKUP_DIR`, montages) | résolu (AC 11 d, F-P4-3) | | `admin-manual.tex:1643` (« sans accès SSH ni ligne de commande », ouverture de `sec:backup-ui-keshbackup`) | contradiction avec le rapatriement en SSH de la même section | résolu (AC 11 e, F-P4-7 : phrase d'exception) | -| `admin-manual.tex:1712` (« restauration self-service sans SSH »), `website/roadmap.html:231` (« SSH-free migration and restore ») | promesse de l'export/import par l'écran | **assumé** — vraie pour un `.keshbackup` exporté ; la sauvegarde pré-import en fait exception, dite à `:1643` (F-P4-7) | +| `admin-manual.tex:1712` (« restauration self-service sans SSH »), `website/roadmap.html:231` (« SSH-free migration and restore »), `README.md:48` (« sans accès SSH ✓ », F-P6-4 de la validation P6) | promesse de l'export/import par l'écran | **assumé** — vraie pour un `.keshbackup` exporté ; la sauvegarde pré-import en fait exception, dite à `:1643` (F-P4-7) | | `admin-manual.tex:1417-1425`, `:1664`, `:1667-1672`, `:1699` | sauvegarde | résolu (AC 11 e, i) | | `admin-manual.tex:1452-1477`, `:2312-2317` (`BACKUP_DIR` du script de sauvegarde) | homonyme : variable de shell, pas de Kesh | **assumé** — hors sujet (le `cd` et `exec db` du même script : 15-13a, AC 11 h) | | `admin-manual.tex:1757-1841` — paragraphe sauvegarde, `:1773` (« Effets sans refus »), `:1793` (« deux gestes », « Sous `environment:` ») | mise à jour 0.13.0 — parties sauvegarde | résolu (AC 11 f) | @@ -651,9 +737,23 @@ existante. `0600` ; non mesurées depuis le poste (AC 11 e). - **Échecs antérieurs à la sauvegarde sans test** : `routes/admin.rs:236`, `:240`, `:246` (panne de base avant le verrou) et la suppression du `.partial` de l'AC 9 a — non injectables sans abstraction ; - relus en revue, écrits ici. + relus en revue, écrits ici. *(Revue de code P1, E5 : leur conversion est désormais gardée + **lexicalement** par `echecs_avant_sauvegarde_tous_convertis` — aucune `AdminFullImportFailed` ni + `AdminFullExportFailed` avant l'appel de `write_pre_import_backup`, autant de `?` que de conversions ; + la panne effective, elle, reste non injectée.)* **Échecs de `write_all`, `sync_all`, `try_exists` et + `rename`** (revue de code P1, B4) : aucun n'est exercé individuellement ; ils convergent vers la même + suppression du `.partial`, dont le retrait est attrapé par le test 12 a, mais une mutation du détail ou de + la variante d'une seule de ces branches ne ferait rougir aucun test. **`:172` et `:259`** (F-P5-1 de la validation P5) : la panne réelle (lecture + du schéma, lecture des données, `zip`) n'est pas injectable non plus ; seul leur **branchement** sur + `avant_sauvegarde` est gardé, **lexicalement**, par le test 20 (M47, M48) — un garde qui lit le source + et non un comportement : une écriture que le test ne reconnaît pas (fermeture, conversion différée) + le fait rougir à tort, ce qui est voulu (forme prescrite à l'AC 15 b) ; aucun test ne fait traverser + une panne effective jusqu'à la réponse HTTP. L'extraction d'une fonction `sauvegarde_pre_import` + testable sur une base dégradée a été écartée (C-15-13-28). - **`.partial` non supprimé** : si l'écriture échoue après la création **et** que `remove_file` échoue à - son tour — ou si le processus est arrêté pendant l'écriture —, un `….keshbackup.partial` reste dans le + son tour — ou si le processus est arrêté pendant l'écriture, ou encore si la **requête est annulée** + (client qui coupe la connexion : le futur du handler est abandonné, ni `remove_file` ni le `warn!` ne + s'exécutent ; revue de code P1, B2 = E1) —, un `….keshbackup.partial` reste dans le dossier de sauvegarde ; il ne porte pas le nom d'une sauvegarde, le message « aucune sauvegarde n'a été créée » reste vrai, le `warn!` nomme le chemin, et le manuel dit qu'un `.partial` peut être supprimé (AC 9 a, 11 e, C-15-13-21). Suppression non injectable, non testée. @@ -663,7 +763,11 @@ existante. un second écrivain du même nom serait déjà un défaut (C-15-13-21). - **Durabilité du renommage** : le dossier n'est pas synchronisé après `rename` ; une coupure de courant juste après pourrait perdre l'entrée du dossier — le fichier était complet et synchronisé, seul le nom - serait en cause. Non traité. + serait en cause. Non traité ; écrit aussi au doc-comment de `write_backup_file` (revue de code P1, + B3 = E4). +- **`try_exists` suit les liens symboliques** (revue de code P1, E6) : un lien pendant au nom final est vu + « absent », et `rename` remplace le lien (pas sa cible). Sans portée : nom unique par construction, + dossier `0700` créé par Kesh. Écrit au doc-comment, non traité. - **SELinux** : le montage `./backup` n'a pas de suffixe `:z` (les trois montages existants non plus) ; sur un hôte SELinux en mode `enforcing`, l'écriture pourrait être refusée — l'import le dirait alors (AC 15). Non mesuré. @@ -672,8 +776,17 @@ existante. - **Emplacement non communiqué à l'écran** : la réponse de l'import ne porte pas le nom du fichier (`backupCreated` seulement) ; écarté (C-15-13-6). - **Contrôle au démarrage du dossier de sauvegarde** (un `warn!` si `/data/backup` n'est pas - inscriptible) : écarté (C-15-13-9) — le message de l'AC 15 dit la cause au moment où elle compte, et les - recettes hors Docker posent la variable (AC 16). + inscriptible) : écarté (C-15-13-9, rectifié par C-15-13-27) — le message de l'AC 15 **nomme les deux + causes possibles**, le dossier de sauvegarde et la base de données, au moment où elles comptent (il ne + peut pas dire laquelle : le journal le dit), les tests 15 et 16 l'exigent (M49, M50), et les recettes + hors Docker posent la variable (AC 16). Ce fondement tient **tant que** le message nomme le dossier : + s'il cessait de le faire, l'écartement serait à rouvrir. +- **Parents créés en `0700`** (R6-3 = F-P6-6) : `DirBuilder` récursif pose `0700` sur chaque niveau qu'il + crée ; un `KESH_ADMIN_BACKUP_DIR` sous un parent absent (hors Docker, sous root : `/data`) le crée fermé. + Sans conséquence dans le conteneur (root) ; écrit au doc-comment, non traité. *(Revue de code P1, + E3 : le comportement est désormais **testé** sur deux niveaux par + `write_pre_import_backup_dossiers_crees_et_existants`, qui vérifie aussi qu'un dossier existant n'est + pas modifié — A-4.)* - **Montage d'hôte `./backup` créé `root:root` `0755`** : l'exploitant qui le veut fermé fait `chmod 700 backup` (manuel) ; les fichiers sont `0600` de toute façon. @@ -711,12 +824,128 @@ converties à l'appel (AC 15 b), précisément pour ne pas ouvrir un module de p ### Agent Model Used +Opus 5.5 (agent de développement, en autonomie), worktree `kesh-15-13b`, cible +`CARGO_TARGET_DIR=kesh-15-13b/target`, bases `kesh_1513b` et `kesh_e2e_1513b`, port E2E 3018. + ### Debug Log References +Journaux sous `target/gate-logs/` du worktree (non versionnés) : `15-13b-t0-*` (mesures du T0, +rapatriement, PDF d'avant aplati, `.log` d'avant), `15-13b-mutations.txt` (banc), `15-13b-gate-backend.txt`, +`15-13b-gate-frontend.txt`, `15-13b-e2e.txt`, `15-13b-e2e-backend.log`, `15-13b-admin-apres-plat.txt`, +`15-13b-admin-apres.log`. + ### Completion Notes List +- **Gates réellement exécutés, au dernier commit de code `50cada06`** (les commits suivants ne portent que + documentation, PDF et comptes rendus) : + - remise à zéro de **mes** bases (`DROP`/`CREATE` de `kesh_1513b` et `kesh_e2e_1513b`, 75 migrations, + seed `scripts/seed-dev-db.sql`) après `wait-kesh.sh` ; + - `scripts/test-fast.sh` (fmt + clippy `-D warnings` + nextest, `DATABASE_URL` sur `kesh_1513b`) : + **2983 exécutés, 2983 passés, 4 ignorés**, rc 0 ; + - frontend : `npm run check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` PASS, + `test:unit` **1095/1095** (112 fichiers), `build` vert ; + - **E2E complet** (`npm run test:e2e`, 10,0 min, lancé à 06:09 UTC) avec la recette **modifiée** du + `CLAUDE.md` (`KESH_ADMIN_BACKUP_DIR=target/kesh-backup`), secrets générés (`openssl rand`), + `KESH_TEST_MODE=true` des deux côtés, `KESH_COOKIE_SECURE=false`, les quatre `KESH_SMTP_*` + (`/health` → `smtpConfigured:true`), `KESH_INBOX_DIR`/`KESH_DOCUMENTS_DIR` du worktree : **245 passés, + 9 échecs, 19 ignorés**. Les 9, jugés fichier par fichier contre `docs/testing.md` § *Les échecs + attendus* : 7 KF-029 (#97 — `mode-expert:26`, `:41`, `onboarding-path-b:65`, `:92`, `onboarding:57`, + `:77`, `:150`) et 2 KF-045 (#421 — `invoices.spec.ts:415`, `:439`, run avant 12:00 UTC). **Aucun hors + liste.** Backend arrêté par son PID. + - **Import réel** sur ce backend après la suite (export puis import par l'API, admin du seed) : 200, + `backupCreated:true` ; `target/kesh-backup` créé en **700**, sauvegarde `kesh-pre-import-…keshbackup` + en **600**, aucun `.partial`. Dossier supprimé ensuite. +- **Total de départ des tests** (revue de code P1, A-5) : **2973** à `200f5e79`, **déduit** et non mesuré — + 2983 exécutés au commit `50cada06`, moins 10 attributs de test ajoutés et 0 retiré entre les deux bornes + (`git diff 200f5e79..50cada06 -- crates | grep -cE '^\+.*(#\[test\]|#\[tokio::test\]|#\[sqlx::test)'` + → 10 ; même motif en `^-` → 0). Aucun nextest n'a tourné à `200f5e79` dans ce worktree. +- **Remédiation de la revue de code P1** (Opus 5.5) : **une seule ligne de production touchée**, le texte + du `warn!` de `write_backup_file` (continuation `\` rétablie, plus de suite d'espaces dans le message) ; + **3 tests neufs** dans `mod tests` de `routes/admin.rs` (périmètre : de `7ff477ab` au commit de + remédiation ; `#[test]` ×2, `#[tokio::test]` ×1, aucun retiré) — `aucun_blanc_parasite_dans_le_code` + (garde lexicale, indépendante du texte : aucune ligne de code de production ne porte deux espaces + consécutives après son indentation ; **rouge avant la correction**, vert après), + `echecs_avant_sauvegarde_tous_convertis` (E5 : aucune `AdminFullImportFailed`/`AdminFullExportFailed` + avant `write_pre_import_backup` dans `run_backup_and_restore`, autant de `?` que de conversions, montage + (3, 1)), `write_pre_import_backup_dossiers_crees_et_existants` (E3 : parent intermédiaire **et** feuille + en `0700` ; A-4 : un dossier existant `0755` reste `0755`) ; le test 20 et le neuf partagent + `code_de_production()` (DRY, sans changement de comportement). **Mutations jouées** (fichier restauré par + copie **et touché**, `cmp` vérifié) : begin → `AdminFullImportFailed` et verrou → `map_db_error` rougissent + `echecs_avant_sauvegarde_tous_convertis` ; `builder.mode(0o755)` rougit le test 11 et le neuf ; un + `create_dir_all` du parent avant le `DirBuilder` (parent en `0755`) rougit **le seul** neuf ; un + `set_permissions(dir, 0700)` avant la création rougit le seul neuf. Doc-comments complétés : + `write_backup_file` (angles morts annulation, durabilité du renommage, `try_exists` et liens + symboliques), `AdminFullImportFailed`, `check_schema_compat`, `build_keshbackup` (conversion à l'appel + par `avant_sauvegarde`, A-3). **Gate ciblé réellement exécuté**, après remise à zéro de **ma** base + `kesh_1513b` (`DROP`/`CREATE`, 75 migrations, seed) et `wait-kesh.sh` : `cargo fmt --all -- --check` + vert ; `cargo clippy --workspace --all-targets -- -D warnings` vert (relancé après les doc-comments) ; + `cargo nextest run -p kesh-api -E 'test(/routes::admin::tests/) | binary(configuration_transmise) | + binary(/^admin_/)'` : **90 exécutés, 90 passés** (`routes::admin::tests` 8, `configuration_transmise` 23, + `admin_full_import_e2e` 33, `admin_pat_denied_e2e` 16, `admin_full_export_e2e` 7, `admin_backup_e2e` 3 ; + journal `target/gate-logs/15-13b-review-p1-remediation-cible.txt`). **Gate complet et E2E : non rejoués** + — à faire au dernier commit de code (D7). +- **Mutations (T8)** — banc `target/mut/mutations.py`, chaque mutation appliquée, test ciblé, fichier + restauré par `git checkout` **et touché** : **28/28 rouges**, aucune par erreur de compilation. M11 rougit + `transmission` **et** `fantomes` ; M15 `from_env_absent_backup_dir_takes_data_backup` **et** `transmission` + (le test 6 a, qui compare à la constante, reste vert : attendu) ; M34 `catalogues_distinguent_…` **et** + `kesh-i18n parity_between_locales`. Formes retenues là où la fiche décrit l'intention : **M30** retrait + du bras de conversion (seul `autre => autre` reste) ; **M31** `autre => + AdminPreImportBackupFailed(format!("{autre}"))` ; **M36** variante rendue au refus d'écraser ; **M46** + `partial = path` **et** vérification du nom final neutralisée (sans la seconde, l'appel échoue quand même + sur le nom final — la mutation ne représenterait pas « écrire directement le nom final ») ; **M51** + `remove_file(&partial)` ajouté dans l'erreur de `create_new`. +- **Manuel** : `make fr` (trois PDF), contrôles de l'AC 11 l sur le PDF aplati final — positifs : + `/data/backup` ×8, `sudo cp backup/` ×1, `kesh-pre-import` ×3, `keshbackup.partial` ×1, + `target: /data/backup` ×1, ligne `sudo chown` **entière** ×1 (non coupée), `base de données.{0,40}inaccessible` + ×1 ; négatifs : `BACKUP_DIR.{0,90}/tmp` 0, `défaut /tmp` 0, `deux gestes` 0 — chacun rouge sur le PDF + d'avant (T0). `Overfull \hbox` : **55**, liste identique à l'avant (trois débordements apparus à la + première compilation corrigés, C-15-13b-4). `user-manual.pdf` et `marketing-brochure.pdf` régénérés + **identiques à l'octet** (`.tex` inchangés) : non modifiés. **Brochure** (AC 11 n) : relue, laissée telle + quelle (C-15-13b-4). +- **Contrôle de l'AC 11 f** : motif `target: /data/backup` confirmé au T0 (forme longue de + `docker compose config`), rouge (0) sur un compose sans montage avec la variable posée. +- **Rapatriement (AC 11 e)** : non mesurable au T0 (démon Docker bloqué, C-15-13b-1), **mesuré à 07:48** + après son retour, conteneurs `debian:bookworm-slim` jetables : Docker crée `./backup` en `root:root + 0755` ; le fichier `0600 root` est illisible de l'utilisateur (`cat`, `cp` → *Permission denied*) ; + `cp` puis `chown ` **en root** — joués par un conteneur root, `sudo` n'étant pas utilisable sans mot + de passe depuis l'agent — rendent la copie lisible (`0600`, propriétaire l'utilisateur) ; copie et + dossier supprimés. Le geste `sudo` lui-même, sur un NAS, reste à la recette de Guy. +- **Écarts à la fiche** : (1) `CLAUDE.md` — seule la ligne de commande change, pas la mention de date + de l'AC 16 a (consigne de l'orchestrateur, C-15-13b-2) ; (2) CHANGELOG — la phrase « deux gestes » + devient un renvoi sans nombre (option de l'AC 13 c, C-15-13b-3) ; (3) le nom du fichier est décrit + au manuel, non écrit en entier (C-15-13b-4) ; (4) le test 17 exige en plus la négation dans le message. +- **Test 3** : le lien `DEFAULT_ADMIN_BACKUP_DIR` ↔ `MONTAGES` est porté par `controle_transmission` + (donc par `transmission`). +- **Inventaire (AC 14)** : greps du T9 rejoués sur l'arbre final — (1) `BACKUP_DIR.{0,90}/tmp` ne rend + que les trois recettes `/tmp/kesh-e2e/backup` de `docs/testing.md` (voulues, AC 16 b) ; le jeton des + « deux gestes » restreint ne rend plus rien ; `pré-import|pre-import` ne rend que des sites de + l'inventaire et la clé neuve ; (2) `cargo run -p kesh-api` : les sites de l'AC 16 ; (3) `git ls-files` + sous les quatre dossiers à la racine : vide ; (4) par la valeur `\./(inbox|documents|log)\b` : les deux + énumérations complètes (`.env.example:214`, `admin-manual.tex:812`) se poursuivent par `./backup` à la + ligne suivante ; les autres sont des défauts de montages variables. Aucun site hors inventaire. +- **Issues** : la PR devra porter `closes #552` et `closes #576` (titre ou corps) ; `refs #558`. +- **Conflits certains** avec la 15-13a au merge : `CHANGELOG.md` (paragraphe « Action requise ») et + `admin-manual.tex` (« Pour qui garde son fichier compose ») — § *Dépendances* ; le manuel compte ici + « Trois gestes pour chacun des deux compose » (15-13b seule) : à recompter si la 15-13a passe avant. +- **Clôture (rebase sur la 15-13a, `bcded0c8`)** : conflits résolus comme annoncés, le décompte du manuel + **recompté** — « Cinq gestes pour `docker-compose.yml`, trois pour `docker-compose.prod.yml` » (deux lignes + d'`environment:` dans les deux fichiers, le port et les mots de passe MariaDB pour le premier seul, le + montage `./backup` dans les deux) ; le CHANGELOG garde « les gestes, fichier par fichier » de la 15-13a, + sans nombre. Gates rejoués sur l'état rebasé : voir le Change Log. + ### File List +- `.dockerignore`, `.gitignore`, `.env.example`, `docker-compose.yml`, `docker-compose.prod.yml` +- `crates/kesh-api/src/config.rs`, `crates/kesh-api/src/routes/admin.rs`, `crates/kesh-api/src/errors.rs` +- `crates/kesh-api/tests/configuration_transmise.rs`, `crates/kesh-api/tests/admin_full_import_e2e.rs` +- `crates/kesh-i18n/locales/{fr-CH,de-CH,en-CH,it-CH}/messages.ftl` +- `docs/manual/fr/admin-manual.tex`, `docs/manual/fr/admin-manual.pdf` +- `CHANGELOG.md`, `CLAUDE.md` (ligne de commande de la recette E2E), `DOCKER_START.md`, `README.md`, + `docs/testing.md` +- `_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md`, + `_bmad-output/implementation-artifacts/sprint-status.yaml`, + `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md` + ## Change Log - 2026-10-09 — **Création par découpage de la 15-13, et remédiation de la validation P3** (agent remédiateur, @@ -792,3 +1021,184 @@ converties à l'appel (AC 15 b), précisément pour ne pas ouvrir un module de p M24–M26, M30–M41, M45, M46 ; `sort -u` → 22) ; **3 lignes** au tableau des cas ; **41 lignes** d'inventaire ; **10 angles morts** ; **4 modules de code** (inchangé : `config`, `routes/admin`, `errors`, catalogues `kesh-i18n`). Union avec la 15-13a : 46 mutations, M1 à M46 sans trou ni doublon. +- 2026-10-09 — **Remédiation de la validation P5** (agent remédiateur, Opus 5.5, en autonomie). Passe P5 : + deux lentilles **Opus 5.5** en contexte frais (prompt `15-13b-validate-prompt-p5.md`) — R (regression + hunter) **0 CRITICAL / 0 HIGH / 1 MEDIUM / 6 LOW**, F (adversaire plein périmètre) **0 / 0 / 1 MEDIUM / + 7 LOW** ; aucun doublon MEDIUM — soit **2 MEDIUM distincts** ; LOW en partie communs (R5-3 = F-P5-8). + **Trend** (fiche unique puis 15-13b) : P1 (Opus 5.5 ×2) 8 MEDIUM distincts → P2 (Sonnet ×2) 6 → P3 + (Opus 5.5 ×2) 5 → découpage → P4 (Sonnet ×2) 4 → P5 (Opus 5.5 ×2) **2** ; 0 CRITICAL/HIGH aux cinq passes. + **Signal D5** (sévérité égale, MEDIUM → MEDIUM), déclaré au Project Lead via l'orchestrateur : + **R5-1 naît de la remédiation P4** (« ou n'en nomme aucune », ajouté par `3ebedca9` pour F-P4-5) — forme + de recyclage ; **F-P5-1** est d'origine (introduction d'`avant_sauvegarde`, remédiation P1 `07e168e1`). + Trois LOW naissent aussi de la P4 (R5-2, R5-3/F-P5-8, R5-4) et un de C-15-13-21 (F-P5-3). Décroissance + monotone, un défaut recyclé sur deux : pas de découpage proposé par le remédiateur — l'arbitrage revient + à l'orchestrateur. + - **MEDIUM** : **R5-1** (décision de l'orchestrateur, C-15-13-27) — le message d'échec antérieur à la + sauvegarde nomme **toujours** les deux pistes, dossier de sauvegarde et base de données, dans les quatre + locales et le repli Rust, par des jetons fixés à la lettre ; tests 15 et 16 complétés, **M49**, **M50** ; + angle mort « contrôle au démarrage » réécrit (le message nomme les deux causes) ; rectifie C-15-13-25 + (« ou aucune » retiré) et le motif de C-15-13-9. **F-P5-1** (décision de l'orchestrateur, tranchée par + le remédiateur, C-15-13-28) — un garde lexical **est** retenu : test 20 + `avant_sauvegarde_branchee_aux_appels_de_l_import` (lecture de `admin.rs`, appels de l'import convertis, + appel de l'export `:39` non converti, assertion de montage sur le nombre d'appels) ; forme d'appel + prescrite à l'AC 15 b ; **M47**, **M48** couvertes ; la panne réelle derrière `:172`/`:259` reste à + l'angle mort et à la phrase de clôture du tableau des tests. + - **LOW** : **R5-2** (sept sites antérieurs, cinq de base et deux de dossier, AC 15 a et c) ; + **R5-3/F-P5-8** (AC 9 a-c : un détail par étape ; `try_exists` en `Err` = échec ; C-15-13-29) ; + **R5-4** (AC 11 d : aucune incise ne donne d'exemple sous `/tmp`) ; **R5-5** (AC 13 c : « deux gestes » + **partagée**, ventilation 1 + 3 + 1 partagée = 5 ; la fiche 15-13a, close, n'est pas retouchée) ; + **R5-6** (AC 10 c : message « doit être exactement `./backup` » pour une source exacte) ; **R5-7** (AC 7 b : + doc-comment du test 6 a ; boucle `configuration_transmise.rs:509-521`, message `Y` `:512-514`, revérifiés) ; + **F-P5-2** (trois homonymes du jeton `backup/` à l'inventaire, ligne de l'homonyme) ; **F-P5-3** (AC 9 a + et test 12 : l'échec de `create_new` ne supprime rien, le `.partial` préexistant garde son contenu, + **M51** ; C-15-13-29) ; **F-P5-4** (AC 12 a' : puce à la fin de § *Notes*, `DOCKER_START.md:140` ; + inventaire) ; **F-P5-5** (AC 8 e : `./backup` créé par Docker au premier `docker compose up`, rempli au + premier import) ; **F-P5-6** (AC 11 e : bloc `lstlisting` en ASCII, `<...>` — ligne `sudo chown` + remesurée à **76**, la limite ; contrôle aplati positif de la ligne entière à l'AC 11 l) ; **F-P5-7** + (AC 11 e : l'entrée de la liste des refus nomme les deux pistes ; contrôle aplati positif à l'AC 11 l). + Les deux contrôles aplatis neufs vérifiés absents de `target/gate-logs/15-13b-p5-F-admin-plat.txt` + (`grep -cF` → 0 chacun). + - **Grep du symptôme par la valeur** (fiche) : `ou aucune`, `n'en nomme aucune`, `cinq (sites|échecs)`, + `<…>`, `\b74\b`, `509-519`, `premier import`, `dit la cause`, `section « dossiers »` — hors Change Log, ne + restent que des textes neufs et justes : « rempli au premier import » (AC 8 e), « et non `<…>` » + (AC 11 e), « n'a pas de section « dossiers » » (AC 12 a', inventaire). + - **Règles changées** : (1) **le texte de #576** nomme obligatoirement les deux pistes (test 15/16, M49, + M50) — l'option « aucune » disparaît ; (2) **l'appel d'`avant_sauvegarde`** a une forme prescrite, gardée + lexicalement (test 20) ; (3) **le nettoyage du `.partial`** ne porte que sur celui créé par l'appel, et + `try_exists` en erreur vaut échec. Les trois touchent la production : une passe P6 **complète** reste + justifiée, ou, si l'orchestrateur juge la convergence acquise, une passe ciblée sur ces trois points. + - **Recompte** (depuis cette fiche, `grep`) : **10 AC**, **9 tâches**, **12 lignes de test** portant + **14 fonctions Rust** (10 neuves, 4 existantes complétées) ; **27 mutations** (M9–M11, M14, M15, + M24–M26, M30–M41, M45–M51 ; `sort -u` → 27) ; **3 lignes** au tableau des cas ; **41 lignes** + d'inventaire (inchangé : lignes enrichies) ; **10 angles morts** (inchangé) ; **4 modules de code** + (inchangé). Union avec la 15-13a (24 mutations) : **51**, M1 à M51 sans trou ni doublon. + +- **2026-10-09 — validation P6 (Sonnet ×2, prompt `15-13b-validate-prompt-p6.md`)** : R 0 au-dessus de LOW + (4 LOW), F 0 au-dessus de LOW (8 LOW) ; axes exercés et non exercés déclarés par les deux lentilles. + **Validation close.** Trend (15-13 puis 15-13b) : P1 8 MEDIUM → P2 6 → P3 5 (découpage, C-15-13-16) → P4 4 + → P5 2 → P6 0. Modèles : Opus ×2, Sonnet ×2, Opus ×2, Sonnet ×2, Opus ×2, Sonnet ×2. Les 12 LOW de la P6 + (`target/gate-logs/15-13b-p6-{R,F}.md`) sont **à appliquer au T0 du développement** : R6-1 (justification + « aucun `lstlisting` hors ASCII » fausse : dix listings en portent), R6-2 (forme de l'AC 15 b sur plusieurs + lignes après rustfmt), R6-3 = F-P6-6 (`DirBuilder` récursif en `0700` applique le mode aux parents créés : + l'écrire), R6-4 (pas de `serde_json` dans `export.rs`), F-P6-1 (conflit certain avec la 15-13a sur + `CHANGELOG.md:46` et `admin-manual.tex:1793` : à écrire aux Dépendances), F-P6-2 (M52 : branche négative du + test 20), F-P6-3, F-P6-4 (`README.md:48`), F-P6-5, F-P6-7, F-P6-8 (« dossier » contre « répertoire »). + +- **2026-10-09 — T0 du développement** (agent de développement, Opus 5.5, en autonomie ; worktree `kesh-15-13b`, + base `200f5e79` + `dd9c3b1f`). + - **Les 12 LOW de la P6 appliqués** : R6-1 (AC 11 e, motif ASCII « par prudence »), R6-2 (AC 15 b, rustfmt), + R6-3 = F-P6-6 (AC 9 b et angle mort « parents créés en `0700` »), R6-4 (AC 15 c, ZIP et non `serde_json`), + F-P6-1 (§ *Dépendances* : conflits certains sur le paragraphe « Action requise » du CHANGELOG et sur + « Pour qui garde son fichier compose »), F-P6-2 (**M52**, branche négative du test 20 ; **28 mutations**, + union avec la 15-13a : 52, M52 absente de la 15-13a — vérifié), F-P6-3 (AC 9 a), F-P6-4 (`README.md:48` à + l'inventaire, ligne assumée — **42 lignes d'inventaire inchangées**, ligne enrichie), F-P6-5 (AC 11 e : + forme coupée), F-P6-7 (M35, M37 désignées par le texte ; chemin et lignes `:81-84` d'`AdminRestorePanel`), + F-P6-8 (AC 11 e : « dossier de sauvegarde » au manuel). Angles morts : **11**. + - **Relecture contre `200f5e79`** (la 15-7a2, la 15-12a et la 15-6a sont mergées depuis la spécification) : + **aucun écart de fond**, seulement des numéros de ligne décalés. `routes/admin.rs`, `config.rs`, + `configuration_transmise.rs`, `admin_full_import_e2e.rs`, les compose, `.env.example`, `docs/testing.md`, + `README.md`, `DOCKER_START.md`, `.gitignore`, `.dockerignore`, `CLAUDE.md` : **inchangés** (`git diff --stat + de285ea8 200f5e79` vide sur eux) — tous les numéros de la fiche y valent. Décalés : `errors.rs` +13 + (`AdminFullExportFailed` `:459`, `AdminFullImportFailed` `:467`, doc `:461-466`, bras `:1874-1884`) ; + catalogues +5 (`error-admin-full-import-failed` `fr-CH:1483`, `de/en/it-CH:1412`) ; `admin-manual.tex` +3 + (`:1646` ouverture de la section, `:1796` « Pour qui garde… ») ; `CHANGELOG.md` +10 (`:54` entrée #550 + « Inchangé », `:56` paragraphe « Action requise »). Les AC citent la ligne de la spécification ; le + développement se repère au texte. + - **Mesures** (`target/gate-logs/15-13b-t0-*`) : (1) **graphie** de `docker compose config` (client seul, + sans démon) pour `- ./backup:/data/backup` : forme longue `type: bind` / `source: …/backup` / + `target: /data/backup` / `bind: create_host_path: true`, **identique** pour les deux compose ; + `grep -c 'target: /data/backup'` → **1** avec le montage ; sur les compose **sans** montage avec + `KESH_ADMIN_BACKUP_DIR=/data/backup` dans `.env` → **0** (alors que `grep -c '/data/backup'` → 1, la ligne + d'environnement) : le contrôle de l'AC 11 f est discriminant, motif inchangé. (2) **`make fr` d'avant** : + `.log` de référence (55 lignes `Overfull`), PDF aplatis ; contrôles de l'AC 11 l sur le PDF d'avant : + positifs tous à **0** (`/data/backup`, `sudo cp backup/`, `kesh-pre-import`, `keshbackup.partial`, + `target: /data/backup`, ligne `sudo chown` entière, `base de données.{0,40}inaccessible`) ; négatifs + présents (`défaut /tmp` ×2, `deux gestes` ×1, `BACKUP_DIR.{0,90}/tmp` ×3) — tous discriminants. PDF + versionnés restaurés (`git checkout`). (3) **Rapatriement sur conteneur jetable** : **non mesuré au T0** — + le démon Docker de la station est bloqué au noyau (tâches `dockerd` en état D sur un rw-semaphore depuis + 07:06, `docker run` expire à 60 s), et le relancer toucherait `kesh-mariadb-dev` (interdit). Reporté au + T10, choix C-15-13b-1. + - **Registre** : `15-13` (`split`) et `15-13b` (`in-progress`) ajoutées à `sprint-status.yaml`. +- **2026-10-09 — Développement** (Opus 5.5, en autonomie) : T1 à T10 faits ; commits `fcd711e4` (T1), + `50cada06` (T2-T3, dernier commit de code), `969c1ee2` (T5-T6). Gates au dernier commit de code : + backend 2983/2983, frontend 1095/1095, E2E 245 passés / 9 échecs attendus (7 KF-029, 2 KF-045) ; + 28/28 mutations rouges ; PDF contrôlé aplati, 55 `Overfull` inchangés. Choix C-15-13b-1 à 4. Statut + `review`. +- **2026-10-09 — Revue de code P1** (Sonnet ×3 : lentilles B, E, A ; rapports + `target/gate-logs/15-13b-review-p1-{B,E,A}.md`) : **0 CRITICAL, 0 HIGH, 0 MEDIUM, 15 LOW** (B 4, E 6, + A 5 ; B1 = E2 = A-1, B2 = E1, B3 = E4 se recouvrent). Remédiation (Opus 5.5) : **une seule ligne de + production touchée, le texte du `warn!`** de `write_backup_file` (B1 = E2 = A-1) ; 3 tests neufs (garde du + blanc parasite, E5, E3 + A-4) ; doc-comments (B2 = E1, B3 = E4, E6, A-3) ; angles morts de la fiche + (B2 = E1, B3 = E4, B4, E6 ; **12** désormais, +1 : `try_exists` et liens symboliques) ; total de départ + des tests au record (A-5). **A-2** laissé en l'état : écart assumé et consigné (C-15-13b-2). Gate ciblé + vert (fmt, clippy, 90/90 sur les binaires d'admin et `configuration_transmise`) ; gate complet et E2E au + dernier commit de code. +- **2026-10-09 — Revue de code P2 ciblée** (Haiku, une lentille, prompt versionné + `15-13b-review-prompt-p2-ciblee.md`, commit du prompt `a534db0a` avant rebase, `21741f70` après) : selon le + journal de l'orchestrateur, **0 finding au-dessus de LOW**. ⚠️ **Le rapport lui-même est perdu** : il était + écrit sous `target/gate-logs/15-13b-review-p2-ciblee.md`, effacé par un `cargo clean` après un crash ; ni + le détail des LOW éventuels ni la liste des axes exercés et non exercés ne peuvent être relus. Ce bilan + n'est donc adossé qu'au journal de l'orchestrateur, non au rapport. Aucun patch n'a suivi. + **Revue de code close.** Trend : P1 (Sonnet ×3, lentilles B, E, A) 0 CRITICAL / 0 HIGH / 0 MEDIUM / 15 LOW + → remédiation `7b1db902` (une ligne de production, le texte d'un `warn!` ; trois tests) → P2 ciblée (Haiku) + 0 au-dessus de LOW. Aucun reclassement. +- **2026-10-09 — Clôture et intégration** (Opus 5.5, en autonomie). Branche de sauvegarde + `backup/15-13b-avant-rebase-bcded0c8` (tête d'avant : `a534db0a`), puis **rebase sur `origin/main` = + `bcded0c8`** (15-6b, 15-12b, 15-13a mergées depuis la base `200f5e79`). + - **Conflits** : fiches 15-13 et 15-13b (ajout/ajout — `main` portait la version d'avant la validation P5, + reçue de la branche de la 15-13a ; version de la branche retenue, aucune ligne propre à `main` perdue, + vérifié au diff) ; registre des choix et `sprint-status.yaml` **par union** (C-15-13-27 à -29 et + C-15-13b-1 à -4 à côté des C-15-13a-1 à -4 ; entrées 15-12b, 15-13, 15-13a de `main`, entrée 15-13b de la + branche) ; `CHANGELOG.md` (paragraphe « Action requise » de `main` — refus de Compose, mots de passe + MariaDB — avec la phrase de la sauvegarde pré-import de la 15-13b à la place de « reste dans `/tmp` » ; + rubrique Sécurité : l'entrée « dossiers montés » de la 15-13b suivie des entrées #557 et #551 de `main`) ; + `DOCKER_START.md` (volume `kesh-mariadb-data` de `main` et ligne `./backup`) ; `admin-manual.tex` (les + deux paragraphes « La sauvegarde de sécurité pré-import » et « Exercices clôturés dans le désordre » ; + **décompte recompté** : cinq gestes pour `docker-compose.yml`, trois pour `docker-compose.prod.yml` ; le + montage `./backup` en dernier geste, après le port et les mots de passe MariaDB ; la phrase « Puis + `docker compose config -q` » réunit le contrôle `ports` de la 15-13a et le compte de `target: + /data/backup` de la 15-13b, son `lstlisting` placé avant le « Contrôle de fin de mise à jour »). + Fusionnés sans conflit et relus : les deux compose (port MariaDB fermé, mots de passe `:?`, **et** + `./backup:/data/backup`), `.env.example`, `CLAUDE.md`, `README.md`, `docs/testing.md`. Choix C-15-13b-5. + - **PDF** : `admin-manual.pdf` régénéré (`make -B admin`) sur le `.tex` fusionné ; 55 `Overfull`, aucun + dans les deux zones fusionnées ; contrôles aplatis : `/data/backup` ×8, `sudo cp backup/` ×1, + `kesh-pre-import` ×3, `keshbackup.partial` ×1, `target: /data/backup` ×1, ligne `sudo chown` entière ×1, + `Cinq gestes` ×1, `Exercices clôturés dans le désordre` ×2, `Changer un mot de passe MariaDB` ×2 ; + négatifs `défaut /tmp`, `deux gestes`, `Trois gestes`, `Quatre gestes` : 0. Manuel utilisateur et + brochure non touchés par la story : PDF de `main` conservés. + - **Gates sur l'état rebasé** (cibles cargo nettoyées, compilation **à froid**, + `CARGO_TARGET_DIR=kesh-15-13b/target` ; bases `kesh_1513b` et `kesh_e2e_1513b` recréées, 75 migrations, + seed sur `kesh_1513b`) : `scripts/test-fast.sh` — fmt vert, clippy `-D warnings` vert, nextest **3046 + exécutés, 3046 passés, 4 ignorés** ; frontend — `check` 0 erreur (27 avertissements préexistants), + `lint-i18n-ownership` PASS, `test:unit` **1139/1139** (114 fichiers), `build` vert ; **E2E complet** + (port 3013, recette de `docs/testing.md` avec `KESH_ADMIN_BACKUP_DIR`, `smtpConfigured:true`, lancé vers + 09:24 UTC) : **245 passés, 9 échecs, 19 ignorés** — les 9 jugés fichier par fichier contre + `docs/testing.md` § *Les échecs attendus* : 7 KF-029 (#97 : `mode-expert:26`, `:41`, + `onboarding-path-b:65`, `:92`, `onboarding:57`, `:77`, `:150`) et 2 KF-045 (#421 : `invoices.spec.ts:415`, + `:439`, run avant 12:00 UTC) ; aucun hors liste. Import réel par l'API sur ce backend après la suite : + 200, `backupCreated:true`, sauvegarde `kesh-pre-import-…keshbackup` en `0600`. Journaux sous + `/home/gcorbaz/devel/kesh-gate-logs/15-13b-cloture-*`. Statut **done**. +- **2026-10-09 — Second rebase, sur `245b91ee` (15-7b1)** (Opus 5.5, en autonomie). Branche de sauvegarde + `backup/15-13b-avant-rebase-245b91ee` (tête d'avant : `b2cf4ee7`, celle de la PR #584), puis rebase des + 11 commits sur `origin/main` = `245b91ee`. + - **Conflits** : registre des choix (C-15-7b1-1 à -3 de `main`, puis C-15-13b-1 de la branche — union, aucun + doublon de numéro) ; `admin-manual.pdf` **régénéré** (`make -B admin`, 83 pages) sur le `.tex` fusionné, + jamais fusionné ; `sprint-status.yaml` par union, ligne `last_updated` de la 15-13b renumérotée (42) après + la (41) de la 15-7b1. `CHANGELOG.md` et `admin-manual.tex` fusionnés sans conflit et relus. Contrôle de + perte : les 973 lignes ajoutées par `245b91ee` confrontées aux lignes retirées par la branche — deux lignes + génériques communes (`.await`, `})?;`), sans perte, `git range-diff` donnant les commits de code + identiques. PDF aplati : `105 des 112` ×1 (texte de `main`), `104 des 112` ×0, `/data/backup` ×8, + `kesh-pre-import` ×3, `keshbackup.partial` ×1, `sudo cp backup/` ×1. Manuel utilisateur touché par `main` + seul : son PDF conservé. + - **Gates sur l'état rebasé** (`CARGO_TARGET_DIR=kesh-15-13b/target` ; `kesh_1513b` et `kesh_e2e_1513b` + recréées, 75 migrations, seed sur `kesh_1513b`) : `scripts/test-fast.sh` — fmt vert, clippy `-D warnings` + vert, nextest **3057 exécutés, 3057 passés, 4 ignorés** ; frontend — `check` 0 erreur (27 avertissements + préexistants), `lint-i18n-ownership` PASS, `test:unit` **1139/1139** (114 fichiers), `build` vert. + **E2E complet** — un premier lancement a échoué au `globalSetup` (500, `1114 The table 'credit_note_lines' + is full` : tmpfs de MariaDB plein, `ibdata1` à 3,5 Go, sans rapport avec la branche) ; après redémarrage de + MariaDB par l'orchestrateur et reconstruction des deux bases, relancé (port 3013, recette de + `docs/testing.md` avec `KESH_ADMIN_BACKUP_DIR` inscriptible, `smtpConfigured:true`, 11:11–11:21 UTC) : + **245 passés, 9 échecs, 19 ignorés** — jugés fichier par fichier contre `docs/testing.md` § *Les échecs + attendus* : 7 KF-029 (#97 : `mode-expert:26`, `:41`, `onboarding-path-b:65`, `:92`, `onboarding:57`, + `:77`, `:150`) et 2 KF-045 (#421 : `invoices.spec.ts:415`, `:439`, run avant 12:00 UTC) ; aucun hors liste. + Journaux : `/home/gcorbaz/devel/kesh-gate-logs/15-13b-rebase-245b91ee-*`. Statut **done** maintenu. diff --git a/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p6.md b/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p6.md new file mode 100644 index 000000000..771901269 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p6.md @@ -0,0 +1,35 @@ +# Prompt — validation P6 de la spec, Story 15-13b + +*Versionné le 2026-10-09. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Fiche** : `_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md`. La dernière remédiation est le commit `e590935f` (`git show e590935f`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #552, #576. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : la branche elle-même (`de285ea8` + les fiches). Index `15-13-mariadb-et-sauvegarde.md`, sœur `15-13a-…` (close, en développement ailleurs : n'en signale rien sauf contradiction de frontière). Choix C-15-13-1 à 29. Passe 6 sur 8 : ne classe en MEDIUM que ce qui ferait livrer un comportement faux, un texte faux ou un test qui ne mord pas. **Axes prioritaires** : (1) le test 20 (garde lexicale `avant_sauvegarde`) — survit-il à rustfmt (forme `….await.map_err(avant_sauvegarde)?` sur plusieurs lignes) ? peut-il passer à vide ? distingue-t-il l'appel de l'export (`admin.rs:39`) ? ; (2) les mots des deux pistes dans les 4 locales et le repli Rust, et les tests 15/16 qui les exigent (M49, M50) ; (3) M51 et le `.partial` d'un autre écrivain ; (4) les détails de journal par étape et `try_exists` en `Err` ; (5) la ventilation partagée des « gestes » ; (6) le manuel et son PDF aplati (ligne `sudo chown` à 76 caractères, `lstlisting` ASCII). +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show e590935f`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la +sortie d'un `grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-13b-p6-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une +base : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md index 2380d525d..e9ee8d7ec 100644 --- a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md +++ b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md @@ -5550,6 +5550,29 @@ l'import (#458–#461). - **Rectificatifs** (les entrées d'origine ne sont pas réécrites) : **C-15-13-9** — « cinq modules » ne vaut plus depuis le découpage (C-15-13-16) : la 15-13a en compte deux (`config`, `main`), la 15-13b quatre (`config`, `routes/admin`, `errors`, catalogues `kesh-i18n`) — R4-10 de la validation P4 de la 15-13b. **C-15-13-14** — « un montage ajouté demain est contrôlé sans retouche » n'était vrai que si l'assertion de montage du test 19 ne fixait pas le nombre d'entrées : elle exige désormais « au moins quatre », dont `/data/backup` (R4-5). **Inventaire de la 15-13a** — la ventilation « 29 + 6 → 36 » de la fiche index ne se refaisait pas ; seuls les totaux sont gardés (R4-7). - **Réversible** : oui. +## C-15-13-27 — 15-13b (validation P5) : le message de #576 nomme toujours les deux pistes — rectifie C-15-13-25 et le motif de C-15-13-9 + +- **Contexte** : R5-1 de la validation P5, **né de la remédiation P4**. C-15-13-25 admettait un texte qui « nomme les deux pistes à égalité, ou aucune ». Or l'écartement du contrôle du dossier au démarrage (C-15-13-9, angle mort de la 15-13b) repose sur un message qui « dit la cause au moment où elle compte » : avec l'option « aucune », le cas que #576 rend nominal (instance hors Docker sans `KESH_ADMIN_BACKUP_DIR`) rendrait un 500 qui ne mentionne plus le dossier, et l'écartement n'aurait plus de fondement — sans qu'aucun test le voie. +- **Retenu** (décision de l'orchestrateur) : le message d'échec antérieur à la sauvegarde nomme **toujours** les deux pistes — le dossier de sauvegarde (inscriptible ?) **et** la base de données (joignable ?) — dans les quatre locales et le repli Rust, par des jetons fixés à la lettre (`dossier de sauvegarde`/`base de données`, `Sicherungsordner`/`Datenbank`, `backup folder`/`database`, `cartella di backup`/`database`). Les tests 15 (repli) et 16 (catalogues) exigent leur présence ; mutations **M49** (`en-CH` sans « database ») et **M50** (repli sans « base de données »). La liste des refus du manuel nomme les mêmes deux pistes (F-P5-7). +- **Rectificatifs** (entrées d'origine non réécrites) : **C-15-13-25** — « ou aucune » est retiré ; « trois des cinq échecs antérieurs » devient « cinq des **sept** » (R5-2 : `:172`, `:236`, `:240`, `:246`, `:259` pour la base ; `:473`, `:487` pour le dossier). **C-15-13-9** — le motif d'écartement du `warn!` au démarrage se lit désormais : « le message **nomme les deux causes possibles** au moment où elles comptent » ; il tient tant que le message nomme le dossier. +- **Écartées** : garder « ou aucune » et rouvrir le contrôle au démarrage (plus coûteux, et un dossier monté tardivement ferait mentir le `warn!`) ; deux variantes dossier/base (déjà écartées par C-15-13-25). +- **Réversible** : oui (quatre catalogues, un repli, deux assertions). + +## C-15-13-28 — 15-13b (validation P5) : le branchement d'`avant_sauvegarde` est gardé par un test lexical + +- **Contexte** : F-P5-1 de la validation P5 (d'origine P1, `07e168e1`). Le test 14 exerce la fonction pure `avant_sauvegarde`, mais rien ne vérifiait qu'elle est appliquée aux appels de `check_schema_compat` (`routes/admin.rs:172`) et de `build_keshbackup` (`:259`) : un `.map_err(avant_sauvegarde)` oublié laissait tous les tests verts et rendait le message faux de #576 (ou « l'export n'a pas pu être généré »). Les deux pannes ne sont pas injectables à bon compte. +- **Retenu** : un garde **lexical**, test 20 `avant_sauvegarde_branchee_aux_appels_de_l_import` (`mod tests` de `routes/admin.rs`) — `include_str!("admin.rs")` tronqué au `#[cfg(test)]`, commentaires écartés, chaque appel des deux fonctions rattaché à sa fonction de premier niveau ; dans `full_import` et `run_backup_and_restore`, l'instruction (jusqu'au `;`) contient `.map_err(avant_sauvegarde)` ; dans `full_export` (`:39`), elle ne le contient pas ; assertion de montage : exactement un appel de `check_schema_compat` et deux de `build_keshbackup`. Forme d'appel prescrite à l'AC 15 b (`….await.map_err(avant_sauvegarde)?`). Mutations **M47** (`:172`) et **M48** (`:259`) **couvertes**. +- **Pourquoi c'est fiable** : la forme est unique et prescrite ; toute écriture que le test ne reconnaît pas (fermeture, conversion différée, alias) le fait rougir **à tort**, jamais passer à tort — un faux rouge coûte une ligne, un faux vert est muet. Le précédent du dépôt est le contrôle (L) de `configuration_transmise.rs`, lexical lui aussi. L'appel de l'export est contrôlé dans l'autre sens, pour que le garde ne pousse pas à convertir partout. Reste angle mort : aucune panne effective ne traverse jusqu'à la réponse HTTP. +- **Écartées** : l'angle mort seul, avec mutation déclarée non couverte (le garde est bon marché et ferme la mutation) ; extraire `sauvegarde_pre_import(pool, dir)` et la tester sur une base dégradée (lourd, change la frontière du verrou, et une table supprimée fait d'abord rougir `check_schema_compat` en 400). +- **Réversible** : oui (un test). + +## C-15-13-29 — 15-13b (validation P5) : précisions de C-15-13-21 — on ne supprime que ce qu'on a créé, `try_exists` en erreur vaut échec + +- **Contexte** : F-P5-3, R5-3/F-P5-8 de la validation P5 (nés de la remédiation P4). C-15-13-21 ne disait pas si l'échec de `create_new` nettoyait le `.partial` (qui n'est alors pas celui de l'appel), ni ce que vaut un `try_exists` en `Err`, ni les détails journalisés des étapes neuves. +- **Retenu** : l'échec de `create_new` ne supprime **rien** (test 12 : le `.partial` préexistant garde son contenu ; mutation **M51**) ; seul le `.partial` créé par l'appel est nettoyé ; `try_exists` en `Err` = **échec** (jamais « absent », qui rouvrirait l'écrasement) ; un détail journalisé par étape, qui nomme le chemin (AC 9 c), non testé à la lettre. +- **Écartées** : nettoyer sur toute erreur (supprimerait le fichier d'un autre écrivain) ; traiter `Err` comme absent. +- **Réversible** : oui. + ## C-15-13a-1 — 15-13a (T0) : le manuel dit le refus de Compose tel qu'il est mesuré — `config`, `pull` et `up` refusent, `ps`, `logs`, `exec`, `stop` restent utilisables, et la variable nommée change d'un lancement à l'autre - **Contexte** : l'AC 11 f affirmait, sous réserve de mesure au T0, que « le refus de Compose frappe toute sous-commande » (`ps`, `logs`, `exec`, `stop`, `down`) et que le script de sauvegarde lancé par `cron` échoue dès que `.env` perd une des deux lignes ; l'AC 11 j, que Compose nomme « la première » variable et que « la seconde apparaît au lancement suivant ». Mesuré le 2026-10-09 (Docker Compose 2.40.3, projet jetable `kesh1513a-t0`, service `mariadb` en marche, `env -u MARIADB_ROOT_PASSWORD -u MARIADB_PASSWORD`) : `config`, `pull`, `up -d` et `up -d kesh-api` sortent en code 1 avec `required variable … is missing a value` ; `ps`, `logs`, `exec -T mariadb true` et `stop` sortent en 0 et font leur travail. Et, sur 12 lancements de la même commande, la variable nommée varie : `MARIADB_ROOT_PASSWORD`, `MARIADB_PASSWORD` ou `DATABASE_URL` (de `kesh-api`, qui nomme `MARIADB_PASSWORD`) — ordre non déterministe. @@ -5598,3 +5621,38 @@ l'import (#458–#461). - **Retenu** : dette écrite à la fiche (« Ce que la story ne fait pas »), rattachée à **#538** — l'atomicité des quatre premières validations fait disparaître l'état non relançable, et le message redevient juste. Atteinte pratiquement impossible avec le plan PME embarqué. B-2 (boucle `InactiveOrInvalidAccounts`) reste à l'arbitrage de Guy (C-15-7-14). - **Écarté** : changer le texte maintenant (code de production en remédiation de fin de boucle, qui rouvrirait la boucle de revue pour un LOW). - **Réversible** : oui (une chaîne). + +## C-15-13b-1 — 15-13b (T0) : le rapatriement n'est pas rejoué au T0, le démon Docker étant bloqué + +- **Contexte** : le T0 prescrit de rejouer, sur un conteneur jetable, l'écriture d'un fichier `0600` par un conteneur root dans un dossier monté, puis le rapatriement `sudo cp` + `sudo chown`. Le 2026-10-09 vers 07:10, toute création de conteneur expire (`docker run` → 124 après 60 s) : le noyau signale des tâches `dockerd` en état D, bloquées sur un rw-semaphore (`journalctl`, « blocked for more than 122 seconds »). Le démon sert encore `ps`/`info`. Les créations interrompues laissent des noms réservés sans conteneur. `sudo` n'est pas utilisable sans mot de passe depuis l'agent. +- **Retenu** : faire au T0 ce qui ne dépend pas du démon (`docker compose config`, client seul : graphie et contrôle rouge mesurés ; `make fr`), écrire le manuel selon la fiche, et **retenter** le rapatriement au T10 ; s'il est toujours impossible, l'écrire au Dev Agent Record comme **non mesuré**, à rejouer par l'orchestrateur ou en recette. `sudo` y est joué par un conteneur root (même effet de droits), ce qui sera dit. +- **Écartées** : redémarrer `dockerd` (emporterait `kesh-mariadb-dev` et les bases des autres agents — interdit) ; attendre sans fin ; déclarer le geste vérifié par raisonnement (« une hypothèse éliminée par raisonnement n'est pas une hypothèse testée »). +- **Réversible** : oui — la mesure se rejoue à tout moment. + +## C-15-13b-2 — 15-13b (T6) : `CLAUDE.md` — seule la ligne de commande de la recette change, pas la mention de date + +- **Contexte** : l'AC 16 a prescrit deux modifications du `CLAUDE.md` (la variable ajoutée à la recette E2E, et une mention « `KESH_ADMIN_BACKUP_DIR` ajoutée le … » accolée à « vérifié le 2026-08-04 »). La consigne de l'orchestrateur pour ce développement dit : « ne change QUE la ligne de commande de la recette E2E, rien d'autre dans ce fichier ». +- **Retenu** : la consigne de l'orchestrateur prime sur la fiche — `KESH_ADMIN_BACKUP_DIR=target/kesh-backup` ajoutée à la ligne `KESH_PORT=3000 KESH_STATIC_DIR=frontend/build`, rien d'autre. La date de vérification reste celle du 2026-08-04 ; le Dev Agent Record dit que la recette modifiée a tourné au gate E2E. +- **Écartées** : appliquer l'AC 16 a à la lettre (contrevient à la consigne). +- **Réversible** : oui (une phrase à ajouter si l'orchestrateur le souhaite). + +## C-15-13b-3 — 15-13b (T6) : la phrase « deux gestes » du CHANGELOG devient un renvoi sans nombre + +- **Contexte** : AC 13 c — la phrase partagée avec la 15-13a (« le manuel d'administration décrit les **deux gestes** ») est réécrite par la première fiche mergée, soit avec le décompte par fichier, soit par un renvoi sans nombre. +- **Retenu** : « décrit les gestes à faire, fichier par fichier (§ *Passer à la 0.13.0*) » — un renvoi qui n'a rien à recompter au rebase de la 15-13a ; le manuel, lui, porte le décompte (« Trois gestes pour chacun des deux compose » avec la 15-13b seule). +- **Écartées** : « trois gestes pour chacun » au CHANGELOG (à recompter par la seconde mergée, conflit certain sur la même ligne). +- **Réversible** : oui. + +## C-15-13b-4 — 15-13b (T5) : la brochure reste telle quelle ; le nom du fichier est décrit, non écrit en entier + +- **Contexte** : AC 11 n (brochure `:398`, « restauration sans accès SSH ») et AC 11 e (nom `kesh-pre-import--….keshbackup`). Écrit d'un bloc dans la prose, ce nom insécable produisait un `Overfull \hbox` de 155 pt ; deux autres débordements (14 et 18 pt) venaient de la ligne `KESH_ADMIN_BACKUP_DIR` de la liste des variables et d'une incise ajoutée au premier geste. +- **Retenu** : brochure **inchangée** — elle vend l'export/import par l'écran, qui reste sans SSH ; l'exception de la sauvegarde pré-import est dite au manuel d'administration (ouverture de la section). Le nom est décrit « commence par `kesh-pre-import-` (suivi de l'horodatage) et finit par `.keshbackup` » ; `\sloppy` sur l'entrée de la liste ; l'incise « sous `environment:` » du premier geste retirée (la phrase d'ouverture le dit). Résultat : 55 `Overfull`, la même liste qu'avant, aucun nouveau. +- **Écartées** : nuancer la brochure (promesse vraie pour ce qu'elle vend) ; garder le nom entier avec des `\allowbreak` (rendu haché, contrôle aplati fragilisé). +- **Réversible** : oui. + +## C-15-13b-5 — 15-13b (clôture) : rebasée sur `origin/main` (`bcded0c8`, 15-13a) ; union partout, décompte du manuel recompté à cinq et trois + +- **Contexte** : clôture de la 15-13b après la revue de code (P1 Sonnet ×3, 15 LOW → remédiation `7b1db902` → P2 ciblée Haiku 0 au-dessus de LOW, rapport perdu au `cargo clean`). Elle devait passer **après** la 15-13a, mergée en `bcded0c8`. Conflits annoncés aux Dépendances de la fiche : paragraphe « Action requise » du CHANGELOG et « Pour qui garde son fichier compose » du manuel ; s'y sont ajoutés les fiches 15-13/15-13b (ajout/ajout), le registre, `sprint-status.yaml`, `DOCKER_START.md` et le PDF. +- **Retenu** : rebase (branche de sauvegarde `backup/15-13b-avant-rebase-bcded0c8`). Fiches : version de la branche (celle de `main` était l'état d'avant la validation P5, sans ligne propre). Registre et `sprint-status.yaml` par union. CHANGELOG : paragraphe de `main`, la phrase « la sauvegarde pré-import reste dans `/tmp` » remplacée par celle de la 15-13b ; l'entrée Sécurité « dossiers montés » placée avant #557 et #551. Manuel : les deux paragraphes conservés ; « Cinq gestes pour `docker-compose.yml`, trois pour `docker-compose.prod.yml` », montage `./backup` en dernier ; une seule phrase « Puis `docker compose config -q` » qui réunit les deux contrôles (aucun `ports` sous `mariadb`, `target: /data/backup` compté à 1). PDF régénéré, contrôlé aplati. Gates complets et E2E rejoués sur l'état rebasé, compilation à froid. +- **Écartées** : merge de `main` dans la branche (historique moins lisible) ; garder l'un des deux PDF (il aurait omis l'apport de l'autre) ; deux phrases « Puis … » successives (deux `config -q` à lancer, l'un sans l'autre). +- **Réversible** : oui (rebase ; branche non poussée, sauvegarde gardée). diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 3d17fd2d0..8c8851ca7 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,3 +1,4 @@ +# last_updated: 2026-10-09 (42) (renuméroté au rebase sur 245b91ee, (41) sur bcded0c8) (15-13b done — revue de code close ; rebasée sur 245b91ee (15-7b1) après bcded0c8 (15-13a) — registre et sprint-status par union, PDF d'administration régénéré ; gates sur l'état final : backend 3057/3057, Vitest 1139/1139, E2E 245 / 7 KF-029 + 2 KF-045) # last_updated: 2026-10-09 (41) (15-7b1 done — revue de code close : P1 Sonnet ×3 1 MEDIUM distinct (E-1 = A-1) → P2 ciblée Haiku 0 ; rebasée sur bcded0c8 (15-13a) après f8b2accd (15-6b) et dc4bc58b (15-12b) — registre et sprint-status par union, PDF régénérés ; partition 105/5/2 = 112 recomptée ; gates sur l'état final, compilation à froid : backend 3044/3044, Vitest 1139/1139, E2E 246 / 7 KF-029 + 2 KF-045 + KF-052 (#424, rouge rejoué seul)) # last_updated: 2026-10-09 (40) (15-13a intégrée sur dc4bc58b (15-6b, 15-12b) — registre et sprint-status par union, PDF du manuel d'administration régénéré ; gates sur l'état rebasé : backend 3033/3033, Vitest 1139/1139, E2E 244 / 7 KF-029 + 2 KF-045 + sidebar-navigation:75 (KF-052, rejoué vert)) # last_updated: 2026-10-09 (39) (renuméroté au rebase sur dc4bc58b, (36) sur 200f5e79) (15-13a done — revue de code close : P1 Sonnet ×3 1 MEDIUM distinct → P2 ciblée Haiku 0 ; base 200f5e79, rebase sans objet ; gates : backend 2980/2980, Vitest 1095/1095, E2E 245 / 7 KF-029 + 2 KF-045 du matin) @@ -379,7 +380,7 @@ development_status: 15-12b-filet-sous-un-bilan-clos: done # 2026-10-09 — closes #543 (P1). DONE : revue de code close (P1 Sonnet ×3 : 0 au-dessus de LOW, 16 LOW traités sans ligne de production ; B-1 = E-1 suivi par #569 ; C-15-12b-4) ; rebasée sur 200f5e79 (15-7a2) ; gates sur l'état rebasé : backend 2993/2993, Vitest 1116/1116, E2E 246 / 7 KF-029 + 2 KF-045 + 1 pollution rejouée verte. DÉVELOPPÉE (worktree kesh-15-12b) : gates backend 2970/2970, Vitest 1115/1115, E2E 245 / 7 KF-029 + 2 KF-045 ; mutations (v) (vi) (vii-a) (vii-b) tuées ; choix C-15-12b-1 à 3. Historique : Créée au découpage de la 15-12 (C107), EN VALIDATION. ⛔ DÉPEND de 15-12a. Filet LATER_FISCAL_YEAR_CLOSED aux deux points de passage du journal (create_in_tx_inner sans verrou, delete_in_tx sans condition — la dévalidation), inventaire fermé des écrivains, lot de rapprochement aux trois voies dont accept_one_rule (C108), bandeau d'état hérité et réparation LIFO, autres écrans, prédicteurs d'annulation en angle mort (C120). 11 AC (numéros d'origine), 9 tâches ; 10 modules (dérogation écrite, C121). Avant la 15-1a de préférence (delete_in_tx partagé, C112 ; précédence C117). P3 (Opus ×2) remédiée le 2026-10-09. Prochaine : P4 ciblée (une lentille) sur le diff de la remédiation P3. 15-13-mariadb-et-sauvegarde: split # 2026-10-09 DÉCOUPÉE après la validation P3 (signal D5 de recyclage levé deux passes de suite, C-15-13-16) en 15-13a (#551, MariaDB) + 15-13b (#552, #576, sauvegarde). Fiche devenue index ; fiche unique au commit 8a9bcd27. 15-13a-mariadb-non-publiee-mots-de-passe-obligatoires: done # 2026-10-09 — closes #551 (P2, sécurité), refs #577, refs #578. DONE : revue de code close (P1 Sonnet ×3 1 MEDIUM distinct, E-1 = A-1 → remédiation 7faa16bb sans code Rust de production → P2 ciblée Haiku 0) ; base 200f5e79 inchangée (rebase sans objet) ; gates sur 7e471fd4 : backend 2980/2980, Vitest 1095/1095, E2E 245 / 7 KF-029 + 2 KF-045 du matin, étape CI « Validate compose files » rejouée verte. (worktree kesh-15-13a, branche story/15-13a-mariadb-non-publiee). Développement : 24 mutations rouges ; recette MariaDB rejouée sur conteneurs jetables (T7). Écarts T0 : refus de Compose limité à config/pull/up, variable nommée non déterministe (C-15-13a-1) ; étape CI renforcée, M27 survivait (C-15-13a-2) ; recette ALTER USER refondue (C-15-13a-3), volume laissé à #575 (C-15-13a-4). Validation close (P1 8 MEDIUM → P2 6 → P3 5 → découpage → P4 1 → P5 ciblée 0). - 15-13b-sauvegarde-avant-import-persistante: ready-for-dev # 2026-10-09 — #552, #576. Créée au découpage de la 15-13 (C-15-13-16) ; indépendante de la 15-13a en code, mêmes fichiers (rebase et recompte des gestes du manuel). + 15-13b-sauvegarde-avant-import-persistante: done # 2026-10-09 — closes #552, closes #576, refs #558. DONE : rebasée sur 245b91ee (15-7b1) — gates sur l'état final : backend 3057/3057, Vitest 1139/1139, E2E 245 / 7 KF-029 + 2 KF-045 (run avant midi UTC), aucun hors liste. Avant : revue de code close (P1 Sonnet ×3 0 au-dessus de LOW / 15 LOW → remédiation 7b1db902 → P2 ciblée Haiku 0 au-dessus de LOW selon le journal de l'orchestrateur, rapport perdu au cargo clean) ; rebasée sur bcded0c8 (15-13a) ; manuel recompté (cinq gestes / trois) ; gates sur l'état rebasé, à froid : backend 3046/3046, Vitest 1139/1139, E2E 245 / 7 KF-029 + 2 KF-045 du matin ; C-15-13b-5. Historique : 2026-10-09 DEV FAITE — closes #552 #576, refs #558. Gate au dernier commit de code 50cada06 : backend 2983/2983, frontend 1095/1095, E2E 245/9 (7 KF-029 + 2 KF-045 #421, run avant midi UTC) ; 28/28 mutations rouges ; choix C-15-13b-1..4. Validation close à la P6 (trend 8→6→5→découpage→4→2→0 MEDIUM). epic-15-retrospective: optional # --- v0.2 : Epics ajoutés au scoping 2026-06-04 (cf. _bmad-output/planning-artifacts/epic-17.md) --- # Ordre d'exécution v0.2 : E17 (infra) DÉMARRE, puis E11 TVA, E16 Facturation, E12, E13, E14, E15. diff --git a/crates/kesh-api/src/admin_backup/export.rs b/crates/kesh-api/src/admin_backup/export.rs index 70ca08b96..ad0fd3847 100644 --- a/crates/kesh-api/src/admin_backup/export.rs +++ b/crates/kesh-api/src/admin_backup/export.rs @@ -37,6 +37,11 @@ pub struct KeshBackupMeta { /// DC8 : l'assemblage est in-memory ; le handler décide ensuite de la /// livraison (in-memory vs spill fichier temporaire + streaming) selon le /// plafond `KESH_ADMIN_EXPORT_INMEM_MB`. +/// +/// Erreurs : [`AppError::AdminFullExportFailed`]. ⚠️ L'import l'appelle pour +/// **produire** la sauvegarde pré-import : il convertit l'échec par +/// `routes::admin::avant_sauvegarde` en `AdminPreImportBackupFailed` +/// (Story 15-13b, #576) ; l'export, lui, le rend tel quel. pub async fn build_keshbackup(pool: &MySqlPool) -> Result<(Vec, KeshBackupMeta), AppError> { let min_required = read_min_required(pool).await.map_err(map_db)?; let instance_id = read_instance_id(pool).await.map_err(map_db)?; diff --git a/crates/kesh-api/src/admin_backup/import.rs b/crates/kesh-api/src/admin_backup/import.rs index 494c5b4fe..0a2467987 100644 --- a/crates/kesh-api/src/admin_backup/import.rs +++ b/crates/kesh-api/src/admin_backup/import.rs @@ -191,7 +191,12 @@ pub fn parse_and_verify(bytes: &[u8]) -> Result { /// - (c2) chaque colonne destination `NOT NULL` sans défaut, non-générée, /// non-auto-increment ⊆ colonnes source (sinon `missingRequiredColumns`). /// -/// Retourne [`AppError::ImportSchemaMismatch`] (→400) au premier écart. +/// Retourne [`AppError::ImportSchemaMismatch`] (→400) au premier écart, et +/// [`AppError::AdminFullImportFailed`] si la lecture du schéma échoue. +/// ⚠️ Appelée **avant** la sauvegarde pré-import : l'appelant de l'import la +/// convertit par `routes::admin::avant_sauvegarde` (Story 15-13b, #576), sans +/// quoi le message annoncerait une sauvegarde qui n'existe pas. Tout nouvel +/// appelant d'avant sauvegarde doit reprendre cette conversion. pub async fn check_schema_compat(pool: &MySqlPool, parsed: &ParsedBackup) -> Result<(), AppError> { for (table, data) in &parsed.tables { // `onboarding_state` n'est jamais restaurée (DC11) → inutile de valider diff --git a/crates/kesh-api/src/config.rs b/crates/kesh-api/src/config.rs index 4c1895303..24a465574 100644 --- a/crates/kesh-api/src/config.rs +++ b/crates/kesh-api/src/config.rs @@ -249,10 +249,16 @@ pub struct Config { /// `KESH_ADMIN_IMPORT_MAX_MB` ; défaut 512, borne [1, 10240]. pub admin_import_max_mib: u32, - /// Story 17-3c (DC5) — répertoire où écrire le **backup automatique - /// pré-import** (`.keshbackup` de l'état courant, filet de sécurité avant - /// le restore destructeur). Lu depuis `KESH_ADMIN_BACKUP_DIR` ; défaut - /// `/tmp`. Créé s'il n'existe pas. La purge est à la charge de l'opérateur. + /// Story 17-3c (DC5) — dossier où écrire la **sauvegarde de sécurité + /// pré-import** (`.keshbackup` de l'état courant, filet avant le restore + /// destructeur). Lu depuis `KESH_ADMIN_BACKUP_DIR` ; défaut + /// [`DEFAULT_ADMIN_BACKUP_DIR`] (`/data/backup`), que les compose distribués + /// montent sur `./backup` (Story 15-13b, #552 — l'ancien défaut `/tmp` + /// mourait avec le conteneur). **Hors Docker**, poser la variable sur un + /// dossier inscriptible : sinon la création du dossier échoue et l'import + /// est refusé avant toute suppression (`AdminPreImportBackupFailed`). + /// Créé (en `0700`) s'il n'existe pas ; fichiers en `0600`. La purge est à + /// la charge de l'exploitant. pub admin_backup_dir: String, /// Story 12-5b (#194) — répertoire de stockage des **justificatifs** @@ -942,15 +948,17 @@ impl Config { None => 512, }; - // Story 17-3c (DC5) — KESH_ADMIN_BACKUP_DIR : répertoire du backup - // automatique pré-import. Défaut `/tmp`. Créé si absent (au moment de - // l'import, pas au boot). Pas de borne (chemin arbitraire opérateur). - // Story 15-11a (revue P1, B1/E-1) — vide ou blanc = absente : les - // compose transmettent `${KESH_ADMIN_BACKUP_DIR:-}`, et un chemin vide - // ferait écrire le backup pré-import dans le répertoire courant - // (`/app` dans l'image) au lieu de `/tmp`. - let admin_backup_dir = - env_nonempty("KESH_ADMIN_BACKUP_DIR").unwrap_or_else(|| "/tmp".to_string()); + // Story 17-3c (DC5) — KESH_ADMIN_BACKUP_DIR : dossier de la sauvegarde + // de sécurité pré-import. Défaut `DEFAULT_ADMIN_BACKUP_DIR` + // (`/data/backup`, monté sur `./backup` par les compose distribués — + // Story 15-13b, #552 ; hors Docker, poser la variable). Créé si absent + // (au moment de l'import, pas au boot). Pas de borne (chemin arbitraire + // opérateur). Story 15-11a (revue P1, B1/E-1) — vide ou blanc = absente : + // les compose transmettent `${KESH_ADMIN_BACKUP_DIR:-}`, et un chemin vide + // ferait écrire la sauvegarde dans le répertoire courant (`/app` dans + // l'image) au lieu du défaut. + let admin_backup_dir = env_nonempty("KESH_ADMIN_BACKUP_DIR") + .unwrap_or_else(|| DEFAULT_ADMIN_BACKUP_DIR.to_string()); // Story 12-5b (#194) — KESH_DOCUMENTS_DIR : stockage des justificatifs // importés. Défaut `/data/documents` (volume persistant Docker, PAS /tmp). @@ -1257,6 +1265,14 @@ impl LogFormat { /// Nombre de fichiers de log conservés par défaut (rotation). const DEFAULT_LOG_MAX_FILES: usize = 7; +/// Story 15-13b (#552) — défaut de `KESH_ADMIN_BACKUP_DIR` : dossier de la +/// sauvegarde de sécurité prise avant chaque import d'installation. Les deux +/// compose distribués le montent sur `./backup` (le test `transmission` de +/// `configuration_transmise.rs` exige qu'une entrée de `MONTAGES` ait cette +/// cible) ; sa valeur est fixée par le test +/// `from_env_absent_backup_dir_takes_data_backup`. Publique pour ce lien. +pub const DEFAULT_ADMIN_BACKUP_DIR: &str = "/data/backup"; + fn parse_max_files(raw: &str, warnings: &mut Vec) -> usize { match raw.trim().parse::() { Ok(0) => { @@ -1745,8 +1761,9 @@ mod tests { /// Story 15-11a (revue P1, B1/E-1) — une variable transmise VIDE ou BLANCHE /// par les compose (`${NOM:-}`) prend le défaut du code, **sans** - /// avertissement : `/tmp` (et non un chemin vide, qui ferait écrire le - /// backup pré-import dans le répertoire courant), `fr`, 12, 10, 50, 512, 587. + /// avertissement : `/data/backup` (et non un chemin vide, qui ferait écrire + /// la sauvegarde pré-import dans le répertoire courant), `fr`, 12, 10, 50, + /// 512, 587. #[test] fn from_env_empty_or_blank_vars_take_code_default_silently() { for raw in ["", " "] { @@ -1761,7 +1778,10 @@ mod tests { } let (result, logs) = from_env_with_logs(); let config = result.expect("Config should load"); - assert_eq!(config.admin_backup_dir, "/tmp", "raw={raw:?}"); + assert_eq!( + config.admin_backup_dir, DEFAULT_ADMIN_BACKUP_DIR, + "raw={raw:?}" + ); assert_eq!(config.locale, kesh_i18n::Locale::FrCh, "raw={raw:?}"); assert_eq!(config.password_min_length, 12, "raw={raw:?}"); assert_eq!(config.bank_import_max_mib, 10, "raw={raw:?}"); @@ -1788,6 +1808,25 @@ mod tests { } } + /// Story 15-13b (#552) — `KESH_ADMIN_BACKUP_DIR` absente → `/data/backup`. + /// Seul littéral voulu côté test : il fixe la valeur dont dépend la + /// persistance de la sauvegarde (le montage `./backup:/data/backup` des + /// compose). Le test précédent compare à la constante, qui ne se garde pas + /// elle-même (mutation M15). + #[test] + fn from_env_absent_backup_dir_takes_data_backup() { + let _guard = env_lock(); + reset_env(); + set_minimum_required(); + unsafe { + env::set_var("KESH_HOST", "127.0.0.1"); + env::remove_var("KESH_ADMIN_BACKUP_DIR"); + } + let config = Config::from_env().expect("Config should load"); + assert_eq!(config.admin_backup_dir, "/data/backup"); + reset_env(); + } + /// Témoin de la précédente : une valeur NON vide invalide garde son /// comportement — défaut ET avertissement « invalide » / « non reconnue ». #[test] diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index a69e50a3d..c540ae09c 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -458,14 +458,31 @@ pub enum AppError { #[error("Échec génération export installation : {0}")] AdminFullExportFailed(String), - /// Story 17-3c — échec de l'**import** complet d'installation (panne DB - /// pendant le restore transactionnel, backup pré-import impossible, dataset - /// source sans aucun compte Admin). HTTP 500 ; sur échec transactionnel, - /// la destination reste intacte (rollback) + backup pré-import disponible. + /// Story 17-3c — échec de l'**import** complet d'installation **après** + /// l'écriture réussie de la sauvegarde pré-import (panne DB pendant le + /// restore transactionnel, rejeu des backfills, dataset source sans aucun + /// compte Admin, commit). HTTP 500 ; la destination reste intacte + /// (rollback) et la sauvegarde pré-import existe. Story 15-13b (#576) : un + /// échec **antérieur** à la sauvegarde rend `AdminPreImportBackupFailed`. + /// ⚠️ Cette variante est encore **construite** avant la sauvegarde par + /// `admin_backup::import::check_schema_compat` (lecture du schéma) : c'est + /// la **conversion à l'appel** (`routes::admin::avant_sauvegarde`) qui la + /// change en `AdminPreImportBackupFailed` — tout nouvel appelant d'avant + /// sauvegarde doit la reprendre (revue P1, A-3). /// Détail loggé, jamais exposé en HTTP body. #[error("Échec import installation : {0}")] AdminFullImportFailed(String), + /// Story 15-13b (#576) — échec de l'import d'installation **avant** que la + /// sauvegarde de sécurité soit sur disque : transaction, verrou, lecture du + /// schéma ou des données, création du dossier, écriture du fichier. Rien + /// n'a été supprimé et **aucune sauvegarde n'existe** : le message ne la + /// promet pas, et nomme les deux pistes (dossier de sauvegarde, base de + /// données). HTTP 500, code `ADMIN_PRE_IMPORT_BACKUP_FAILED`, clé + /// `error-admin-pre-import-backup-failed`. Détail loggé, jamais exposé. + #[error("Échec sauvegarde pré-import : {0}")] + AdminPreImportBackupFailed(String), + /// Story 17-4b — échec d'envoi d'un email transactionnel via SMTP /// (connexion SMTP, auth, build du message, panne réseau). HTTP 500, i18n /// key `error-smtp-send-failed`. Détail loggé `tracing::error!`, jamais @@ -1878,7 +1895,25 @@ impl IntoResponse for AppError { "ADMIN_FULL_IMPORT_FAILED", &t( "error-admin-full-import-failed", - "Échec de l'import de l'installation. L'état précédent a été préservé.", + "Échec de l'import de l'installation. L'état précédent a été préservé ; \ + une sauvegarde de sécurité a été écrite avant l'opération.", + ), + ) + } + + // Story 15-13b (#576) — échec antérieur à la sauvegarde pré-import. + AppError::AdminPreImportBackupFailed(detail) => { + tracing::error!("admin pre-import backup failed: {detail}"); + build_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ADMIN_PRE_IMPORT_BACKUP_FAILED", + &t( + "error-admin-pre-import-backup-failed", + "L'import de l'installation a échoué avant ou pendant l'écriture de la \ + sauvegarde de sécurité : aucune sauvegarde n'a été créée et rien n'a été \ + supprimé. Vérifiez que le dossier de sauvegarde est inscriptible et que la \ + base de données est joignable (les journaux du serveur indiquent la cause), \ + puis réessayez.", ), ) } @@ -3824,6 +3859,108 @@ mod tests { (parts.status, json) } + /// Story 15-13b (#576, test 15) — l'échec antérieur à la sauvegarde rend + /// un 500 `ADMIN_PRE_IMPORT_BACKUP_FAILED` dont le repli (aucun catalogue + /// n'est chargé dans ces tests) ne promet aucune sauvegarde, dit qu'aucune + /// n'a été créée et nomme les deux pistes. Témoin : `AdminFullImportFailed` + /// garde son code et un repli différent. + #[tokio::test] + async fn pre_import_backup_failed_maps_to_500_without_promise() { + let resp = + AppError::AdminPreImportBackupFailed("détail-interne-xyz".into()).into_response(); + let (status, body) = response_body(resp).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body["error"]["code"], "ADMIN_PRE_IMPORT_BACKUP_FAILED"); + let message = body["error"]["message"].as_str().unwrap().to_string(); + assert!( + !message.contains("détail-interne-xyz"), + "détail exposé : {message}" + ); + assert!(!message.contains("a été préservé"), "promesse : {message}"); + for jeton in [ + "aucune sauvegarde n'a été créée", + "dossier de sauvegarde", + "base de données", + ] { + assert!(message.contains(jeton), "« {jeton} » absent : {message}"); + } + let resp = AppError::AdminFullImportFailed("d".into()).into_response(); + let (status, body) = response_body(resp).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body["error"]["code"], "ADMIN_FULL_IMPORT_FAILED"); + assert_ne!(body["error"]["message"].as_str().unwrap(), message); + } + + /// Story 15-13b (#576, test 16) — les quatre catalogues distinguent l'échec + /// antérieur à la sauvegarde. Chargés par `I18nBundle::load` sur un chemin + /// indépendant du répertoire courant, sans toucher au catalogue global. + #[test] + fn catalogues_distinguent_l_echec_de_sauvegarde() { + let dir = + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../kesh-i18n/locales")); + let bundle = I18nBundle::load(dir).expect("catalogues"); + // (locale, promesse interdite, négation exigée, pistes exigées) + let attendus = [ + ( + Locale::FrCh, + "un backup automatique a été créé avant l'opération", + "aucune sauvegarde n'a été créée", + ["dossier de sauvegarde", "base de données"], + ), + ( + Locale::DeCh, + "vor dem Vorgang wurde automatisch ein Backup erstellt", + "Es wurde keine Sicherung erstellt", + ["Sicherungsordner", "Datenbank"], + ), + ( + Locale::EnCh, + "an automatic backup was created before the operation", + "No backup was created", + ["backup folder", "database"], + ), + ( + Locale::ItCh, + "prima dell'operazione è stato creato un backup automatico", + "Non è stato creato alcun backup", + ["cartella di backup", "database"], + ), + ]; + let neuf_fr = bundle.format(&Locale::FrCh, "error-admin-pre-import-backup-failed", None); + for (locale, interdit, negation, pistes) in attendus { + let neuf = bundle.format(&locale, "error-admin-pre-import-backup-failed", None); + let ancien = bundle.format(&locale, "error-admin-full-import-failed", None); + // Assertion de montage : l'interdit est bien la promesse de l'ancien + // texte de SA locale — sinon il serait une coquille et le test + // passerait à vide. + assert!( + ancien.contains(interdit), + "{locale:?} : interdit absent de l'ancien texte" + ); + assert_ne!( + neuf, ancien, + "{locale:?} : texte neuf = texte d'après sauvegarde" + ); + if locale != Locale::FrCh { + assert_ne!(neuf, neuf_fr, "{locale:?} : retombé sur le français"); + } + assert!( + !neuf.contains(interdit), + "{locale:?} : promesse reprise : {neuf}" + ); + assert!( + neuf.contains(negation), + "{locale:?} : négation absente : {neuf}" + ); + for piste in pistes { + assert!( + neuf.contains(piste), + "{locale:?} : piste « {piste} » absente : {neuf}" + ); + } + } + } + #[tokio::test] async fn invalid_credentials_maps_to_401() { let resp = AppError::InvalidCredentials.into_response(); diff --git a/crates/kesh-api/src/routes/admin.rs b/crates/kesh-api/src/routes/admin.rs index 34072c4c4..9a5b058f0 100644 --- a/crates/kesh-api/src/routes/admin.rs +++ b/crates/kesh-api/src/routes/admin.rs @@ -169,7 +169,11 @@ pub async fn full_import( } // 2c. Compat colonnes bidirectionnelle (AC12c) → 400 IMPORT_SCHEMA_MISMATCH. - check_schema_compat(&state.pool, &parsed).await?; + // Antérieur à la sauvegarde : un échec de lecture du schéma ne promet + // aucune copie (Story 15-13b, #576 — `avant_sauvegarde`). + check_schema_compat(&state.pool, &parsed) + .await + .map_err(avant_sauvegarde)?; // 3 + 4 + 5. Backup pré-import + restore transactionnel. La sérialisation // des imports destructeurs concurrents est assurée par un verrou @@ -229,21 +233,19 @@ async fn run_backup_and_restore( // Un 2e import bloque ici jusqu'au COMMIT/rollback du 1er. `_kesh_version` // n'est jamais supprimée (table système), la row id=1 existe toujours // (migration). Auto-relâché avec la transaction (robuste aux panics). - let mut tx = state - .pool - .begin() - .await - .map_err(|e| AppError::AdminFullImportFailed(format!("ouverture transaction : {e}")))?; + let mut tx = state.pool.begin().await.map_err(|e| { + AppError::AdminPreImportBackupFailed(format!("ouverture transaction : {e}")) + })?; sqlx::query("SELECT id FROM _kesh_version WHERE id = 1 FOR UPDATE") .fetch_optional(&mut *tx) .await - .map_err(|e| AppError::AdminFullImportFailed(format!("verrou installation : {e}")))? + .map_err(|e| AppError::AdminPreImportBackupFailed(format!("verrou installation : {e}")))? .ok_or_else(|| { // Row id=1 absente = installation incohérente (le boot l'aurait // refusée via check_downgrade_protection::RowMissing). Sans elle, le // FOR UPDATE ne verrouille rien → refuser l'import plutôt que de // risquer une course entre imports concurrents (review Pass 2). - AppError::AdminFullImportFailed( + AppError::AdminPreImportBackupFailed( "verrou installation : row _kesh_version id=1 absente (installation incohérente)" .into(), ) @@ -255,8 +257,12 @@ async fn run_backup_and_restore( // lecture (ils bloquent au FOR UPDATE ci-dessus) ⇒ pas de torn-read // inter-imports. `build_keshbackup` lit via le pool (connexions // séparées) ; le SELECT non-bloquant sur `_kesh_version` ne deadlocke - // pas avec le FOR UPDATE détenu par cette transaction. - let (backup_bytes, _meta) = build_keshbackup(&state.pool).await?; + // pas avec le FOR UPDATE détenu par cette transaction. Tout échec + // jusqu'à l'écriture réussie du fichier est `AdminPreImportBackupFailed` + // (Story 15-13b, #576) : aucune sauvegarde n'existe encore. + let (backup_bytes, _meta) = build_keshbackup(&state.pool) + .await + .map_err(avant_sauvegarde)?; let backup_created = write_pre_import_backup(&state.config.admin_backup_dir, &backup_bytes).await?; @@ -464,17 +470,44 @@ async fn run_backup_and_restore( Ok((backup_created, tables_restored, rows_restored)) } -/// Écrit le backup pré-import sur disque (filet de sécurité rollback). Le -/// chemin est **loggé serveur uniquement** (jamais exposé en réponse — l'admin -/// n'a pas d'accès disque sans SSH). Échec d'écriture → 500 (jamais d'import -/// sans backup réussi). +/// Story 15-13b (#576) — convertit, **à l'appel**, un échec né hors de ce +/// module **avant** que la sauvegarde pré-import soit sur disque +/// (`check_schema_compat`, `build_keshbackup`) : `AdminFullImportFailed` et +/// `AdminFullExportFailed` deviennent `AdminPreImportBackupFailed`, détail +/// conservé ; toute autre variante est rendue telle quelle +/// (`ImportSchemaMismatch` reste un 400). Forme d'appel prescrite : +/// `….await.map_err(avant_sauvegarde)?`, gardée lexicalement par le test +/// `avant_sauvegarde_branchee_aux_appels_de_l_import`. +fn avant_sauvegarde(err: AppError) -> AppError { + match err { + AppError::AdminFullImportFailed(detail) | AppError::AdminFullExportFailed(detail) => { + AppError::AdminPreImportBackupFailed(detail) + } + autre => autre, + } +} + +/// Écrit la sauvegarde de sécurité pré-import sur disque (filet avant le +/// restore destructeur). Le chemin est **journalisé côté serveur uniquement** +/// (jamais exposé en réponse). Story 15-13b (#552) : le dossier, quand Kesh le +/// crée, l'est en `0700` — **chaque niveau créé**, parents compris +/// (`DirBuilder` récursif) ; un dossier existant (montage de l'hôte) n'est pas +/// modifié. Le fichier est écrit par [`write_backup_file`] : mode `0600`, +/// sous `.partial` puis renommé, jamais par-dessus un fichier existant. +/// Tout échec rend `AdminPreImportBackupFailed` : aucun import sans +/// sauvegarde réussie (DC5). async fn write_pre_import_backup(dir: &str, bytes: &[u8]) -> Result { - tokio::fs::create_dir_all(dir).await.map_err(|e| { - AppError::AdminFullImportFailed(format!("création répertoire backup '{dir}' : {e}")) + let mut builder = tokio::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + builder.mode(0o700); + builder.create(dir).await.map_err(|e| { + AppError::AdminPreImportBackupFailed(format!("création répertoire backup '{dir}' : {e}")) })?; - // Nom unique même pour deux backups dans la même milliseconde (le backup - // pré-import est pris avant le verrou FOR UPDATE) : pid + compteur atomique - // process-global, comme `stream_via_tempfile` (review Pass 2). + // Nom unique même pour deux sauvegardes dans la même milliseconde : pid + + // compteur atomique process-global, comme `stream_via_tempfile` (review + // Pass 2). (La sauvegarde est prise SOUS le verrou `FOR UPDATE` de + // `run_backup_and_restore` — un ancien commentaire disait « avant ».) use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); let path = std::path::Path::new(dir).join(format!( @@ -483,9 +516,7 @@ async fn write_pre_import_backup(dir: &str, bytes: &[u8]) -> Result Result.partial` (même dossier, donc même système de fichiers) en +/// `create_new` et, sous Unix, mode `0600` — un `.partial` déjà présent +/// fait échouer l'appel **sans rien supprimer** (on ne supprime que ce +/// qu'on a créé) ; +/// 2. écrit, puis `sync_all` ; +/// 3. vérifie que `path` n'existe pas (`try_exists` en erreur vaut échec, +/// jamais « absent ») — `rename` remplacerait une cible existante ; +/// 4. renomme `.partial` en `path` (atomique sur un même système de +/// fichiers). +/// +/// Si 2, 3 ou 4 échouent, le `.partial` créé est supprimé au mieux (un échec +/// de suppression est journalisé en `warn!` avec son chemin, sans masquer +/// l'erreur d'origine). Toute erreur : `AdminPreImportBackupFailed`, avec un +/// détail par étape qui nomme le chemin (journal seulement). +/// +/// Angles morts écrits (non traités) : +/// - la fenêtre entre la vérification 3 et le renommage 4 n'est pas fermée +/// (le nom est unique par construction) ; +/// - **annulation** : si le futur est abandonné pendant 2 à 4 (client qui +/// coupe la connexion), ni la suppression du `.partial` ni le `warn!` +/// n'ont lieu — le `.partial` (0600) reste, sans jamais porter le nom +/// d'une sauvegarde ; le manuel dit qu'il peut être supprimé ; +/// - **durabilité** : le dossier n'est pas synchronisé après le renommage — +/// après une coupure de courant, l'entrée du dossier peut manquer alors +/// que le contenu était synchronisé ; +/// - `try_exists` **suit** les liens symboliques : un lien pendant au nom +/// final est vu absent, et `rename` remplace le lien (pas sa cible) ; sans +/// portée (nom unique, dossier `0700` créé par Kesh). +async fn write_backup_file(path: &std::path::Path, bytes: &[u8]) -> Result<(), AppError> { + use tokio::io::AsyncWriteExt; + + let mut partial_os = path.as_os_str().to_owned(); + partial_os.push(".partial"); + let partial = std::path::PathBuf::from(partial_os); + + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options.open(&partial).await.map_err(|e| { + AppError::AdminPreImportBackupFailed(format!( + "création fichier partiel '{}' : {e}", + partial.display() + )) + })?; + + let resultat: Result<(), AppError> = async { + file.write_all(bytes).await.map_err(|e| { + AppError::AdminPreImportBackupFailed(format!( + "écriture backup '{}' : {e}", + partial.display() + )) + })?; + file.sync_all().await.map_err(|e| { + AppError::AdminPreImportBackupFailed(format!( + "synchronisation backup '{}' : {e}", + partial.display() + )) + })?; + drop(file); + match tokio::fs::try_exists(path).await { + Ok(false) => {} + Ok(true) => { + return Err(AppError::AdminPreImportBackupFailed(format!( + "nom final déjà présent '{}'", + path.display() + ))); + } + Err(e) => { + return Err(AppError::AdminPreImportBackupFailed(format!( + "vérification nom final '{}' : {e}", + path.display() + ))); + } + } + tokio::fs::rename(&partial, path).await.map_err(|e| { + AppError::AdminPreImportBackupFailed(format!( + "renommage backup '{}' → '{}' : {e}", + partial.display(), + path.display() + )) + }) + } + .await; + + if resultat.is_err() + && let Err(e) = tokio::fs::remove_file(&partial).await + { + tracing::warn!( + path = %partial.display(), + "fichier partiel non supprimé après un échec d'écriture de la sauvegarde \ + pré-import — ce n'est PAS une sauvegarde valide, il peut être supprimé : {e}" + ); + } + resultat +} + /// Émet `audit_log` `action='admin.full_export'`, `entity_type='installation'` /// (best-effort, snake_case `details_json` pour les JSON paths SQL). async fn emit_full_export_audit( @@ -536,3 +667,318 @@ async fn emit_full_export_audit( ); } } + +#[cfg(test)] +mod tests { + //! Story 15-13b (#552, #576) — sauvegarde pré-import : mode `0600`, + //! écriture par `.partial`, refus d'écraser, conversion des échecs + //! antérieurs à la sauvegarde, et branchement de cette conversion. + + use super::*; + + /// Test 11 — `write_pre_import_backup` sur un sous-dossier absent : dossier + /// créé en `0700`, fichier en `0600`, contenu identique, aucun `.partial` + /// restant. Assertions de montage : un fichier et un dossier témoins créés + /// par `std::fs` ne sont PAS en `0600`/`0700` — sinon l'umask rend le test + /// non discriminant. + #[cfg(unix)] + #[tokio::test] + async fn write_pre_import_backup_en_0600() { + use std::os::unix::fs::PermissionsExt; + let racine = tempfile::tempdir().expect("tempdir"); + let dir = racine.path().join("sauvegardes"); + let ecrit = write_pre_import_backup(dir.to_str().unwrap(), b"contenu-de-test") + .await + .expect("écriture de la sauvegarde"); + assert!(ecrit); + + let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let temoin_fichier = dir.join("temoin.txt"); + std::fs::write(&temoin_fichier, b"t").unwrap(); + let temoin_dossier = racine.path().join("temoin-dossier"); + std::fs::create_dir(&temoin_dossier).unwrap(); + assert_ne!( + mode(&temoin_fichier), + 0o600, + "umask de l'environnement trop restrictif (077 ?) : le test ne peut pas distinguer le mode posé par le code" + ); + assert_ne!( + mode(&temoin_dossier), + 0o700, + "umask de l'environnement trop restrictif (077 ?) : le test ne peut pas distinguer le mode posé par le code" + ); + + assert_eq!(mode(&dir), 0o700, "dossier de sauvegarde créé par Kesh"); + let entrees: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| *p != temoin_fichier) + .collect(); + assert_eq!( + entrees.len(), + 1, + "une seule sauvegarde attendue : {entrees:?}" + ); + let sauvegarde = &entrees[0]; + let nom = sauvegarde.file_name().unwrap().to_str().unwrap(); + assert!( + nom.starts_with("kesh-pre-import-") && nom.ends_with(".keshbackup"), + "nom inattendu : {nom}" + ); + assert_eq!(mode(sauvegarde), 0o600, "sauvegarde en 0600"); + assert_eq!(std::fs::read(sauvegarde).unwrap(), b"contenu-de-test"); + } + + /// Revue P1 (E3, A-4) — `write_pre_import_backup` sur deux niveaux absents : + /// **chaque** niveau créé est en `0700`, le parent intermédiaire compris + /// (`DirBuilder` récursif) ; et un dossier **existant** n'est pas modifié + /// (`0755` reste `0755`, cas du `./backup` créé par Docker). Assertion de + /// montage : un dossier témoin créé par `std::fs` n'est pas en `0700`. + #[cfg(unix)] + #[tokio::test] + async fn write_pre_import_backup_dossiers_crees_et_existants() { + use std::os::unix::fs::PermissionsExt; + let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let racine = tempfile::tempdir().expect("tempdir"); + let temoin = racine.path().join("temoin-dossier"); + std::fs::create_dir(&temoin).unwrap(); + assert_ne!( + mode(&temoin), + 0o700, + "umask de l'environnement trop restrictif (077 ?) : le test ne peut pas distinguer le mode posé par le code" + ); + + let parent = racine.path().join("a"); + let feuille = parent.join("b"); + assert!( + write_pre_import_backup(feuille.to_str().unwrap(), b"x") + .await + .expect("écriture sous deux niveaux absents") + ); + assert_eq!(mode(&parent), 0o700, "parent intermédiaire créé par Kesh"); + assert_eq!(mode(&feuille), 0o700, "dossier de sauvegarde créé par Kesh"); + + let existant = racine.path().join("existant"); + std::fs::create_dir(&existant).unwrap(); + std::fs::set_permissions(&existant, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + write_pre_import_backup(existant.to_str().unwrap(), b"y") + .await + .expect("écriture dans un dossier existant") + ); + assert_eq!( + mode(&existant), + 0o755, + "un dossier existant n'est pas modifié" + ); + } + + /// Test 12 (a) — un nom final déjà occupé fait échouer l'écriture en + /// `AdminPreImportBackupFailed` ; le fichier existant garde son contenu et + /// aucun `.partial` ne reste. + #[tokio::test] + async fn write_backup_file_n_ecrase_pas() { + let racine = tempfile::tempdir().expect("tempdir"); + let path = racine.path().join("kesh-pre-import-x.keshbackup"); + std::fs::write(&path, b"existant").unwrap(); + let err = write_backup_file(&path, b"nouveau") + .await + .expect_err("le nom final existe : échec attendu"); + assert!( + matches!(err, AppError::AdminPreImportBackupFailed(_)), + "variante inattendue : {err:?}" + ); + assert_eq!(std::fs::read(&path).unwrap(), b"existant"); + assert!( + !racine + .path() + .join("kesh-pre-import-x.keshbackup.partial") + .exists(), + "le .partial créé par l'appel doit être supprimé" + ); + } + + /// Test 12 (b) — un `.partial` préexistant (écriture interrompue) fait + /// échouer l'appel ; le nom final n'apparaît pas, et ce `.partial`, qui + /// n'est pas celui de l'appel, garde son contenu (on ne supprime que ce + /// qu'on a créé). + #[tokio::test] + async fn write_backup_file_passe_par_un_partiel() { + let racine = tempfile::tempdir().expect("tempdir"); + let path = racine.path().join("kesh-pre-import-y.keshbackup"); + let partial = racine.path().join("kesh-pre-import-y.keshbackup.partial"); + std::fs::write(&partial, b"interrompu").unwrap(); + let err = write_backup_file(&path, b"complet") + .await + .expect_err("un .partial préexistant doit faire échouer l'appel"); + assert!( + matches!(err, AppError::AdminPreImportBackupFailed(_)), + "variante inattendue : {err:?}" + ); + assert!(!path.exists(), "le nom final ne doit pas apparaître"); + assert_eq!(std::fs::read(&partial).unwrap(), b"interrompu"); + } + + /// Test 14 — `avant_sauvegarde` convertit les deux variantes nées avant la + /// sauvegarde, détail conservé, et rend les autres telles quelles. + #[test] + fn avant_sauvegarde_convertit_les_echecs_anterieurs() { + for err in [ + AppError::AdminFullImportFailed("d1".into()), + AppError::AdminFullExportFailed("d1".into()), + ] { + match avant_sauvegarde(err) { + AppError::AdminPreImportBackupFailed(d) => assert_eq!(d, "d1"), + autre => panic!("conversion attendue, trouvé {autre:?}"), + } + } + match avant_sauvegarde(AppError::ImportSchemaMismatch { + table: "t".into(), + unknown_columns: vec![], + missing_required_columns: vec!["c".into()], + }) { + AppError::ImportSchemaMismatch { table, .. } => assert_eq!(table, "t"), + autre => panic!("ImportSchemaMismatch doit rester un 400, trouvé {autre:?}"), + } + match avant_sauvegarde(AppError::InvalidBackupStructure("s".into())) { + AppError::InvalidBackupStructure(d) => assert_eq!(d, "s"), + autre => panic!("InvalidBackupStructure doit rester tel quel, trouvé {autre:?}"), + } + } + + /// Test 20 — garde LEXICALE (C-15-13-28) : `avant_sauvegarde` est appliquée + /// aux appels de `check_schema_compat` et `build_keshbackup` de l'import + /// (`full_import`, `run_backup_and_restore`), et PAS à celui de l'export + /// (`full_export`). Lit ce fichier, tronqué à la première ligne + /// `#[cfg(test)]`, commentaires `//` écartés ; une instruction court + /// jusqu'au `;` suivant (rustfmt peut la couper). Assertion de montage : + /// un appel de `check_schema_compat`, deux de `build_keshbackup`. + #[test] + fn avant_sauvegarde_branchee_aux_appels_de_l_import() { + let code = code_de_production(); + let fonction_englobante = |pos: usize| -> String { + code[..pos] + .lines() + .rev() + .find_map(|l| { + l.strip_prefix("pub async fn ") + .or_else(|| l.strip_prefix("async fn ")) + .map(|reste| reste.split('(').next().unwrap_or("").to_string()) + }) + .unwrap_or_default() + }; + let mut appels: Vec<(&str, String, String)> = Vec::new(); + for ident in ["check_schema_compat", "build_keshbackup"] { + let motif = format!("{ident}("); + for (pos, _) in code.match_indices(&motif) { + let avant = &code[..pos]; + if avant + .chars() + .last() + .is_some_and(|c| c.is_alphanumeric() || c == '_') + || avant.ends_with("fn ") + { + continue; + } + let fin = code[pos..].find(';').map_or(code.len(), |i| pos + i); + appels.push((ident, fonction_englobante(pos), code[pos..fin].to_string())); + } + } + let compte = |ident: &str| appels.iter().filter(|(i, _, _)| *i == ident).count(); + assert_eq!( + (compte("check_schema_compat"), compte("build_keshbackup")), + (1, 2), + "assertion de montage : un appel de check_schema_compat et deux de build_keshbackup attendus, trouvé {appels:#?}" + ); + for (ident, fonction, instruction) in &appels { + let convertie = instruction.contains(".map_err(avant_sauvegarde)"); + match fonction.as_str() { + "full_import" | "run_backup_and_restore" => assert!( + convertie, + "{fonction} : l'appel de {ident} doit porter `.map_err(avant_sauvegarde)` : {instruction}" + ), + "full_export" => assert!( + !convertie, + "full_export : un échec d'export reste un échec d'export : {instruction}" + ), + autre => { + panic!("appel de {ident} dans une fonction non triée `{autre}` : {instruction}") + } + } + } + } + /// Code de production de ce fichier (avant la première ligne + /// `#[cfg(test)]`), lignes de commentaire `//` écartées — base des gardes + /// lexicales ci-dessous et du test 20. + fn code_de_production() -> String { + include_str!("admin.rs") + .lines() + .take_while(|l| l.trim() != "#[cfg(test)]") + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n") + } + + /// Revue P1 (E5) — garde LEXICALE des conversions directes : dans + /// `run_backup_and_restore`, tout ce qui précède l'appel de + /// `write_pre_import_backup` (ouverture de transaction, verrou `FOR + /// UPDATE`, ligne `_kesh_version` absente, `build_keshbackup`) ne construit + /// ni `AdminFullImportFailed` ni `AdminFullExportFailed`, et chaque `?` y + /// est couvert par une conversion : autant de `?` que de + /// `AdminPreImportBackupFailed` construites plus de `.map_err(avant_sauvegarde)`. + /// Assertion de montage : trois constructions directes, une conversion par + /// `avant_sauvegarde`. Un nouveau site d'échec dans ce segment fait rougir + /// le test : il est à trier, puis le décompte à mettre à jour. + #[test] + fn echecs_avant_sauvegarde_tous_convertis() { + let code = code_de_production(); + let debut = code + .find("async fn run_backup_and_restore(") + .expect("run_backup_and_restore introuvable"); + let fin = debut + + code[debut..] + .find("write_pre_import_backup(") + .expect("appel de write_pre_import_backup introuvable"); + let segment = &code[debut..fin]; + for interdit in ["AdminFullImportFailed", "AdminFullExportFailed"] { + assert!( + !segment.contains(interdit), + "{interdit} construite avant la sauvegarde : le message promettrait une sauvegarde absente" + ); + } + let directes = segment.matches("AdminPreImportBackupFailed").count(); + let par_avant_sauvegarde = segment.matches(".map_err(avant_sauvegarde)").count(); + assert_eq!( + (directes, par_avant_sauvegarde), + (3, 1), + "assertion de montage : trois constructions directes et une conversion attendues" + ); + assert_eq!( + segment.matches('?').count(), + directes + par_avant_sauvegarde, + "un `?` avant la sauvegarde n'est pas couvert par une conversion" + ); + } + + /// Revue P1 (B1 = E2 = A-1) — aucune ligne de code de production de ce + /// fichier ne porte deux espaces consécutives après son indentation. Le + /// formatage n'en produit jamais hors commentaires : une telle suite ne + /// peut venir que d'un littéral de chaîne dont la continuation `\` s'est + /// perdue (l'indentation de la ligne suivante entre alors dans le texte + /// journalisé). Le test ne dépend d'aucun message. Assertion de montage : + /// le prédicat mord sur une ligne fabriquée. + #[test] + fn aucun_blanc_parasite_dans_le_code() { + let parasite = |l: &str| l.trim_start().contains(" "); + assert!( + parasite(" \"de la sauvegarde pré-import\""), + "assertion de montage : le prédicat doit reconnaître une suite d'espaces" + ); + let code = code_de_production(); + let fautives: Vec<&str> = code.lines().filter(|l| parasite(l)).collect(); + assert!( + fautives.is_empty(), + "suite d'espaces dans le code (continuation `\\` perdue ?) : {fautives:#?}" + ); + } +} diff --git a/crates/kesh-api/tests/admin_full_import_e2e.rs b/crates/kesh-api/tests/admin_full_import_e2e.rs index e250f2c86..e2370c6c8 100644 --- a/crates/kesh-api/tests/admin_full_import_e2e.rs +++ b/crates/kesh-api/tests/admin_full_import_e2e.rs @@ -70,7 +70,12 @@ fn test_config() -> Config { } async fn spawn_app(pool: MySqlPool) -> TestApp { - let config = test_config(); + spawn_app_with(pool, test_config()).await +} + +/// Story 15-13b — `spawn_app` sur une configuration donnée (test 17 : dossier +/// de sauvegarde inutilisable). +async fn spawn_app_with(pool: MySqlPool, config: Config) -> TestApp { let rate_limiter = kesh_api::middleware::rate_limit::RateLimiter::new(&config); let i18n = Arc::new( kesh_i18n::I18nBundle::load( @@ -585,6 +590,10 @@ async fn full_import_rolls_back_on_insert_failure(pool: MySqlPool) { let resp = post_import(&app, &keep.jwt, forged).await; assert_eq!(resp.status(), 500, "INSERT invalide → 500 (rollback)"); + // Story 15-13b (#576, test 18) — échec APRÈS la sauvegarde : le code reste + // celui de l'import, dont le message dit vrai (la sauvegarde existe). + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "ADMIN_FULL_IMPORT_FAILED"); // Destination intacte : la company KeepMe d'origine est toujours là. let names: Vec = sqlx::query_scalar("SELECT name FROM companies ORDER BY name") @@ -604,6 +613,46 @@ async fn full_import_rolls_back_on_insert_failure(pool: MySqlPool) { assert_eq!(admin_count, 1, "admin d'origine préservé après rollback"); } +// ============================================================ +// Story 15-13b (#576, test 17) — la sauvegarde ne peut pas être écrite +// ============================================================ + +/// Le dossier de sauvegarde est le chemin d'un FICHIER existant : sa création +/// échoue même sous root (en CI comme en local). L'import est refusé en +/// `ADMIN_PRE_IMPORT_BACKUP_FAILED`, avec le repli Rust (ce binaire n'appelle +/// pas `init_error_i18n`) qui ne promet aucune sauvegarde, et l'installation +/// reste intacte. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn full_import_refuses_when_backup_cannot_be_written(pool: MySqlPool) { + let occupe = tempfile::NamedTempFile::new().expect("fichier témoin"); + let mut config = test_config(); + config.admin_backup_dir = occupe.path().to_str().unwrap().to_string(); + let app = spawn_app_with(pool.clone(), config).await; + let keep = seed_admin(&pool, "KeepMe").await; + let backup = export_backup(&app, &keep.jwt).await; + + let resp = post_import(&app, &keep.jwt, backup).await; + assert_eq!(resp.status(), 500, "sauvegarde impossible → 500"); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "ADMIN_PRE_IMPORT_BACKUP_FAILED"); + let message = body["error"]["message"].as_str().unwrap(); + assert!( + message.contains("aucune sauvegarde n'a été créée"), + "message : {message}" + ); + + let names: Vec = sqlx::query_scalar("SELECT name FROM companies ORDER BY name") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(names, vec!["CI KeepMe".to_string()], "installation intacte"); + let admin_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'Admin'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(admin_count, 1, "admin d'origine préservé"); +} + // ============================================================ // DC11 — onboarding forcé « done » post-import (anti catch-22 #120) // ============================================================ diff --git a/crates/kesh-api/tests/configuration_transmise.rs b/crates/kesh-api/tests/configuration_transmise.rs index b49c23807..7812a8e09 100644 --- a/crates/kesh-api/tests/configuration_transmise.rs +++ b/crates/kesh-api/tests/configuration_transmise.rs @@ -208,9 +208,12 @@ const MARIADB: &[&str] = &[ "MARIADB_USER", ]; -/// Les trois montages de `kesh-api` : (cible dans le conteneur, source exigée -/// dans `docker-compose.yml` — préfixe —, source exigée dans -/// `docker-compose.prod.yml` — exacte). +/// Les montages de `kesh-api` : (cible dans le conteneur, source exigée dans +/// `docker-compose.yml` — préfixe si elle commence par `${`, exacte sinon —, +/// source exigée dans `docker-compose.prod.yml` — exacte). Voir +/// [`source_conforme`]. Story 15-13b (#552) : quatrième entrée, la sauvegarde +/// pré-import, montage fixe dans les deux compose (cible +/// `kesh_api::config::DEFAULT_ADMIN_BACKUP_DIR`, contrôlé par (T)). const MONTAGES: &[(&str, &str, &str)] = &[ ("/var/log/kesh", "${KESH_LOG_HOST_DIR:-", "./log"), ("/data/inbox", "${KESH_INBOX_HOST_DIR:-", "./inbox"), @@ -219,8 +222,14 @@ const MONTAGES: &[(&str, &str, &str)] = &[ "${KESH_DOCUMENTS_HOST_DIR:-", "./documents", ), + ("/data/backup", "./backup", "./backup"), ]; +/// Story 15-13b (AC 8 e) — sources de montage admises dans `.gitignore` sous +/// une forme **non ancrée** : `log` seul (ligne `log/` historique, aucun +/// dossier `log` versionné dans le dépôt). Toute autre source exige `/x/`. +const TOLERANCES_NON_ANCREES: &[&str] = &["log"]; + /// Le marqueur que le commentaire d'une variable de [`VIDE_SIGNIFIANT`] doit /// porter dans `.env.example`. const MARQUEUR_VIDE_SIGNIFIANT: &str = "contrairement aux autres variables"; @@ -395,8 +404,8 @@ fn source_montage<'a>(entree: &'a str, cible: &str) -> Option<&'a str> { None } -/// Les sources des trois montages de [`MONTAGES`] dans un service, par cible. -/// Une entrée en forme longue, ou dont la cible n'est pas l'une des trois, +/// Les sources des montages de [`MONTAGES`] dans un service, par cible. +/// Une entrée en forme longue, ou dont la cible n'est pas l'une d'elles, /// rougit (« forme de montage non reconnue ») ; chaque cible doit être trouvée /// exactement une fois. fn sources_montages(svc: &Service) -> Result, Vec> { @@ -458,6 +467,19 @@ fn dockerfile_fixe(dockerfile: &str, nom: &str) -> bool { .any(|l| l.trim_start().starts_with(&attendu)) } +/// Story 15-13b (AC 10 c) — la source `source` d'un montage est-elle celle +/// qu'exige [`MONTAGES`] ? Pour `docker-compose.yml` : préfixe si la source +/// attendue commence par `${` (montage variable), **égalité** sinon (montage +/// fixe : `./backups` ne vaut pas `./backup`) ; pour +/// `docker-compose.prod.yml` : égalité. +fn source_conforme(compose: Compose, source: &str, attendue_y: &str, exacte_p: &str) -> bool { + match compose { + Compose::Y if attendue_y.starts_with("${") => source.starts_with(attendue_y), + Compose::Y => source == attendue_y, + Compose::P => source == exacte_p, + } +} + /// Contrôle (T) sur les deux services : transmission, exceptions, clés /// inconnues, `env_file`, structure. fn controle_transmission( @@ -528,20 +550,35 @@ fn controle_transmission( Ok(sources) => { for (cible, prefixe_y, exacte_p) in MONTAGES { let source = &sources[cible]; + if source_conforme(compose, source, prefixe_y, exacte_p) { + continue; + } match compose { - Compose::Y if !source.starts_with(prefixe_y) => erreurs.push(format!( + Compose::Y if prefixe_y.starts_with("${") => erreurs.push(format!( "{f} : le montage de `{cible}` doit avoir pour source `{prefixe_y}…}}`, trouvé `{source}`" )), - Compose::P if source != exacte_p => erreurs.push(format!( + Compose::Y => erreurs.push(format!( + "{f} : le montage de `{cible}` doit être exactement `{prefixe_y}` (montage fixe), trouvé `{source}`" + )), + Compose::P => erreurs.push(format!( "{f} : le montage de `{cible}` doit rester `{exacte_p}` (montage fixe), trouvé `{source}` — \ le rendre configurable exige la procédure de déplacement des données de l'issue #558 (choix C83, AC 4)" )), - _ => {} } } } } } + // Story 15-13b (R4-1) — le défaut du code est la cible d'un montage : sans + // ce lien, une constante changée renverrait la sauvegarde dans le système + // de fichiers éphémère du conteneur sans qu'aucun contrôle ne rougisse. + let defaut = kesh_api::config::DEFAULT_ADMIN_BACKUP_DIR; + if !MONTAGES.iter().any(|(cible, _, _)| *cible == defaut) { + erreurs.push(format!( + "`DEFAULT_ADMIN_BACKUP_DIR` vaut `{defaut}`, qui n'est la cible d'aucun montage de `MONTAGES` : \ + la sauvegarde pré-import ne survivrait pas au conteneur (#552)" + )); + } erreurs } @@ -1852,6 +1889,55 @@ fn transmission() { ); } +/// Story 15-13b (AC 8 e, test 19) — les sources des montages par défaut ne se +/// versionnent pas et n'entrent pas dans le contexte de build : pour chaque +/// entrée de [`MONTAGES`] (liste **dérivée**, un montage ajouté demain est +/// contrôlé sans retouche), `.gitignore` porte la ligne ancrée `/x/` (seule +/// tolérance : [`TOLERANCES_NON_ANCREES`]) et `.dockerignore` la ligne `x/`. +/// Lignes comparées **entières** (après `trim`), jamais en sous-chaîne. +#[test] +fn montages_hors_du_depot() { + assert_eq!( + TOLERANCES_NON_ANCREES, + &["log"], + "seule tolérance admise : log" + ); + assert!( + MONTAGES.len() >= 4, + "assertion de montage : au moins quatre montages attendus, trouvé {}", + MONTAGES.len() + ); + assert!( + MONTAGES + .iter() + .any(|(cible, _, _)| *cible == "/data/backup"), + "assertion de montage : le montage de la sauvegarde pré-import manque" + ); + let gitignore = lire(".gitignore"); + let dockerignore = lire(".dockerignore"); + let lignes = + |texte: &str| -> BTreeSet { texte.lines().map(|l| l.trim().to_string()).collect() }; + let (g, d) = (lignes(&gitignore), lignes(&dockerignore)); + let mut erreurs = Vec::new(); + for (_, _, exacte_p) in MONTAGES { + let dossier = exacte_p + .strip_prefix("./") + .unwrap_or_else(|| panic!("source de montage inattendue : {exacte_p}")); + let ancree = format!("/{dossier}/"); + let tolere = + TOLERANCES_NON_ANCREES.contains(&dossier) && g.contains(&format!("{dossier}/")); + if !g.contains(&ancree) && !tolere { + erreurs.push(format!( + ".gitignore : ligne `{ancree}` absente (motif ancré exigé)" + )); + } + if !d.contains(&format!("{dossier}/")) { + erreurs.push(format!(".dockerignore : ligne `{dossier}/` absente")); + } + } + echouer_si(erreurs, "montages hors du dépôt"); +} + #[test] fn valeurs() { let (y, p, _) = services(); @@ -2188,6 +2274,44 @@ fn s_sources_de_montage() { sources_montages(&s).is_err(), "une forme longue doit rougir" ); + // Story 15-13b (AC 10 d) — `source_conforme` : égalité exacte pour un + // montage fixe, préfixe pour un montage variable (M14). + assert!(source_conforme( + Compose::Y, + "./backup", + "./backup", + "./backup" + )); + assert!(!source_conforme( + Compose::Y, + "./backups", + "./backup", + "./backup" + )); + assert!(!source_conforme( + Compose::Y, + "${X:-./backup}", + "./backup", + "./backup" + )); + assert!(source_conforme( + Compose::Y, + "${KESH_INBOX_HOST_DIR:-./inbox}", + "${KESH_INBOX_HOST_DIR:-", + "./inbox" + )); + assert!(source_conforme( + Compose::P, + "./backup", + "./backup", + "./backup" + )); + assert!(!source_conforme( + Compose::P, + "./backups", + "./backup", + "./backup" + )); } // --------------------------------------------------------------------------- diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 83e78d56b..0fb0e90cf 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -1422,6 +1422,7 @@ export-global-souverainete-note = Ihre Daten gehören Ihnen. Kesh erstellt keine error-global-export-failed = Der globale Export konnte nicht erstellt werden. Wenn das Problem weiterhin besteht, wenden Sie sich an den Support. error-admin-full-export-failed = Der vollständige Export der Installation konnte nicht erstellt werden. Versuchen Sie es in Kürze erneut; wenn das Problem weiterhin besteht, wenden Sie sich an den Support. error-admin-full-import-failed = Der Import der Installation ist fehlgeschlagen. Der vorherige Zustand wurde beibehalten (vor dem Vorgang wurde automatisch ein Backup erstellt). Prüfen Sie die Serverprotokolle und versuchen Sie es erneut. +error-admin-pre-import-backup-failed = Der Import der Installation ist vor oder während des Schreibens der Sicherheitskopie fehlgeschlagen. Es wurde keine Sicherung erstellt und nichts gelöscht. Prüfen Sie, ob der Sicherungsordner beschreibbar und die Datenbank erreichbar ist (die Serverprotokolle nennen die Ursache), und versuchen Sie es erneut. error-invalid-backup-structure = Die Sicherungsdatei ist ungültig oder beschädigt (unerwartete Struktur oder fehlgeschlagene Integritätsprüfung). Stellen Sie sicher, dass es sich um eine von Kesh erzeugte .keshbackup-Datei handelt. error-import-schema-mismatch = Das Schema dieses Backups ist mit dieser Kesh-Version nicht kompatibel. Aktualisieren Sie Kesh oder verwenden Sie ein kompatibles Backup. error-import-client-number-collision = Das Backup enthält kollidierende Kundennummern (identisch nach Angleichung von Gross-/Kleinschreibung, Akzenten und unsichtbaren Zeichen). Korrigieren Sie die im Bericht genannten Kontakte, exportieren Sie erneut und versuchen Sie es nochmals. diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 6f0a9005b..3b6285c5d 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -1422,6 +1422,7 @@ export-global-souverainete-note = Your data belongs to you. Kesh keeps no copy o error-global-export-failed = The global export could not be generated. If the problem persists, please contact support. error-admin-full-export-failed = The full installation export could not be generated. Please try again shortly; if the problem persists, contact support. error-admin-full-import-failed = The installation import failed. The previous state was preserved (an automatic backup was created before the operation). Check the server logs, then try again. +error-admin-pre-import-backup-failed = The installation import failed before or while writing the safety backup. No backup was created and nothing was deleted. Check that the backup folder is writable and that the database is reachable (the server logs give the cause), then try again. error-invalid-backup-structure = The backup file is invalid or corrupted (unexpected structure or failed integrity check). Make sure it is a .keshbackup file produced by Kesh. error-import-schema-mismatch = This backup's schema is incompatible with this version of Kesh. Update Kesh or use a compatible backup. error-import-client-number-collision = The backup contains colliding client numbers (identical once case, accents and invisible characters are folded). Fix the contacts named in the report, re-export, then try again. diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 45f674bb3..4b69fed81 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -1493,6 +1493,7 @@ export-global-souverainete-note = Vos données vous appartiennent. Kesh ne fait error-global-export-failed = L'export global n'a pas pu être généré. Si le problème persiste, contactez le support. error-admin-full-export-failed = L'export de l'installation n'a pas pu être généré. Réessayez dans quelques instants ; si le problème persiste, contactez le support. error-admin-full-import-failed = L'import de l'installation a échoué. L'état précédent a été préservé (un backup automatique a été créé avant l'opération). Vérifiez les logs serveur, puis réessayez. +error-admin-pre-import-backup-failed = L'import de l'installation a échoué avant ou pendant l'écriture de la sauvegarde de sécurité : aucune sauvegarde n'a été créée et rien n'a été supprimé. Vérifiez que le dossier de sauvegarde est inscriptible et que la base de données est joignable (les journaux du serveur indiquent la cause), puis réessayez. error-invalid-backup-structure = Le fichier de sauvegarde est invalide ou corrompu (structure inattendue ou contrôle d'intégrité échoué). Vérifiez qu'il s'agit bien d'un fichier .keshbackup produit par Kesh. error-import-schema-mismatch = Le schéma de ce backup est incompatible avec cette version de Kesh. Mettez à jour Kesh ou utilisez un backup compatible. error-import-client-number-collision = Le backup contient des numéros de client en collision (identiques une fois la casse, les accents et les caractères invisibles repliés). Corrigez les fiches nommées dans le rapport, ré-exportez, puis réessayez. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 18921a9fa..061fb7336 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -1422,6 +1422,7 @@ export-global-souverainete-note = I vostri dati vi appartengono. Kesh non effett error-global-export-failed = L'esportazione globale non è potuta essere generata. Se il problema persiste, contattate l'assistenza. error-admin-full-export-failed = L'esportazione completa dell'installazione non è potuta essere generata. Riprovate tra poco; se il problema persiste, contattate l'assistenza. error-admin-full-import-failed = L'importazione dell'installazione non è riuscita. Lo stato precedente è stato preservato (prima dell'operazione è stato creato un backup automatico). Controllate i log del server e riprovate. +error-admin-pre-import-backup-failed = L'importazione dell'installazione non è riuscita prima o durante la scrittura del backup di sicurezza. Non è stato creato alcun backup e nulla è stato eliminato. Verificate che la cartella di backup sia scrivibile e che il database sia raggiungibile (i log del server indicano la causa), poi riprovate. error-invalid-backup-structure = Il file di backup non è valido o è danneggiato (struttura inattesa o controllo di integrità fallito). Assicuratevi che sia un file .keshbackup prodotto da Kesh. error-import-schema-mismatch = Lo schema di questo backup non è compatibile con questa versione di Kesh. Aggiornate Kesh o utilizzate un backup compatibile. error-import-client-number-collision = Il backup contiene numeri cliente in collisione (identici dopo la normalizzazione di maiuscole, accenti e caratteri invisibili). Correggete le schede indicate nel rapporto, riesportate e riprovate. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 25ada71ae..ad9ee2655 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -166,6 +166,12 @@ services: # justificatifs archivés vivent dans ./documents. - ./inbox:/data/inbox - ./documents:/data/documents + # Sauvegarde de sécurité prise avant chaque import d'installation (#552) — + # défaut du code /data/backup, monté ici sur ./backup (montage fixe, sans + # variable d'hôte). SECRET (condensés des mots de passe, jetons de session) : + # fichiers en 0600. Dans le périmètre de sauvegarde du dossier du compose + # (Hyper Backup) ; à purger par l'exploitant (une sauvegarde par import). + - ./backup:/data/backup networks: - frontend healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 9039b66e0..9cd00db7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -151,6 +151,12 @@ services: # Défauts (dev, sans .env) : sous-dossiers ./inbox et ./documents du projet. - ${KESH_INBOX_HOST_DIR:-./inbox}:/data/inbox - ${KESH_DOCUMENTS_HOST_DIR:-./documents}:/data/documents + # Sauvegarde de sécurité prise avant chaque import d'installation (#552) — + # défaut du code /data/backup, monté ici sur ./backup (montage fixe, sans + # variable d'hôte). SECRET (condensés des mots de passe, jetons de session) : + # fichiers en 0600. Dans le périmètre de sauvegarde du dossier du compose + # (Hyper Backup) ; à purger par l'exploitant (une sauvegarde par import). + - ./backup:/data/backup networks: - kesh-network healthcheck: diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index c4b01523f..119081653 100644 Binary files a/docs/manual/fr/admin-manual.pdf and b/docs/manual/fr/admin-manual.pdf differ diff --git a/docs/manual/fr/admin-manual.tex b/docs/manual/fr/admin-manual.tex index 330cac4e9..369e6e567 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -193,7 +193,7 @@ \subsubsection{Changer le port d'écoute (conflit port 80, ex. Synology DSM Web \item \textbf{Container sur une IP dédiée (Docker macvlan/ipvlan)} — configurer le container avec sa propre IP via réseau \texttt{macvlan} ; le mapping host devient inutile (le container répond directement sur son \texttt{IP:80}). Procédure spécifique à votre infrastructure réseau. - \item \textbf{Mode \keshcommand{cargo run} natif (hors Docker) sur Linux non-root} — Linux n'autorise pas les utilisateurs non-root à bind un port \texttt{<1024} (\emph{security feature}). Dans ce contexte, set \textbf{obligatoirement} \keshcommand{KESH\_PORT=3000} (ou un autre port \texttt{≥ 1024}) dans votre \keshpath{.env} local. Alternatives possibles mais déconseillées : lancer avec \keshcommand{sudo}, ou ajouter la \emph{capability} via \keshcommand{sudo setcap 'cap\_net\_bind\_service=+ep' target/debug/kesh-api}. + \item \textbf{Mode \keshcommand{cargo run} natif (hors Docker) sur Linux non-root} — Linux n'autorise pas les utilisateurs non-root à bind un port \texttt{<1024} (\emph{security feature}). Dans ce contexte, set \textbf{obligatoirement} \keshcommand{KESH\_PORT=3000} (ou un autre port \texttt{≥ 1024}) dans votre \keshpath{.env} local. Alternatives possibles mais déconseillées : lancer avec \keshcommand{sudo}, ou ajouter la \emph{capability} via \keshcommand{sudo setcap 'cap\_net\_bind\_service=+ep' target/debug/kesh-api}. Hors Docker, posez aussi \keshcommand{KESH\_ADMIN\_BACKUP\_DIR} sur un dossier inscriptible par l'utilisateur qui lance Kesh (son défaut \texttt{/data/backup} ne l'est pas) : sans quoi tout import d'installation est refusé (\S\ref{sec:backup-ui-keshbackup}). \end{enumerate} \begin{keshtip} @@ -768,7 +768,7 @@ \subsection{Variables d'environnement complètes}\label{sec:env-vars} KESH\_BANK\_IMPORT\_MAX\_MB & Taille max fichier bank-import (CAMT.053/CSV), MiB. Borne [1, 100]. & \texttt{10} \\ KESH\_ADMIN\_EXPORT\_INMEM\_MB & Plafond in-memory de l'export \texttt{.keshbackup} (au-delà : streaming). Borne [1, 2048]. & \texttt{50} \\ KESH\_ADMIN\_IMPORT\_MAX\_MB & Taille max upload \texttt{.keshbackup} à l'import. Borne [1, 10240]. & \texttt{512} \\ - KESH\_ADMIN\_BACKUP\_DIR & Répertoire du backup automatique pré-import. Vide = défaut. & \texttt{/tmp} \\ + KESH\_ADMIN\_BACKUP\_DIR & Dossier de sauvegarde de sécurité pré-import, monté sur \texttt{./backup} par les compose fournis. Hors Docker : un dossier inscriptible. Vide = défaut. & \texttt{/data/backup} \\ \bottomrule \end{tabularx} @@ -824,8 +824,11 @@ \subsection{Variables d'environnement complètes}\label{sec:env-vars} \textbf{seul} : pointez alors vers un dossier partagé DSM (\texttt{/volume1/...}). Avec \keshpath{docker-compose.prod.yml} (installation Synology de ce manuel), les montages sont fixes --- \keshpath{./inbox}, \keshpath{./documents}, \keshpath{./log} -dans le répertoire du compose --- et ces trois variables sont \textbf{sans effet} -(une version configurable est suivie par l'issue \#558).\par\noindent +et \keshpath{./backup} dans le répertoire du compose --- et ces trois variables sont +\textbf{sans effet} (une version configurable est suivie par l'issue \#558). Les deux +compose montent en outre \keshpath{./backup} sur \texttt{/data/backup} (sauvegarde de +sécurité pré-import, \S\ref{sec:backup-ui-keshbackup}), \textbf{sans} variable +d'hôte : ce montage n'a pas de ligne dans le tableau.\par\noindent \begin{tabularx}{\linewidth}{>{\bfseries\ttfamily}lXl} \toprule \keshtableheader{Variable} & \keshtableheader{Monte sur} & \keshtableheader{Défaut host} \\ @@ -1436,6 +1439,7 @@ \subsection{Stratégie recommandée} \begin{itemize} \item \textbf{Quotidienne} : \keshcommand{mariadb-dump} complet, conservé 30 jours, stocké en local + offsite (cloud). \item \textbf{Quotidienne, avec la base} : copie du répertoire des documents (\texttt{KESH\_DOCUMENTS\_DIR}, monté depuis \keshpath{./documents} avec \keshpath{docker-compose.prod.yml} ; avec \keshpath{docker-compose.yml}, depuis le dossier de \texttt{KESH\_DOCUMENTS\_HOST\_DIR} s'il est posé) --- il porte les \textbf{PDF des factures émises}, que ni le dump ni l'export ne contiennent (cf. \S\ref{sec:pdf-factures-sauvegarde}). + \item \textbf{Avec le répertoire du compose} : le dossier \keshpath{./backup}, où Kesh écrit la sauvegarde de sécurité prise avant chaque import d'installation, est dans le périmètre du répertoire du compose --- il contient des \textbf{secrets} (\S\ref{sec:backup-ui-keshbackup}). \item \textbf{Hebdomadaire} : snapshot du volume Docker \keshpath{/var/lib/docker/volumes/kesh\_db\_data} (gel cohérent via \keshcommand{docker compose stop} puis snapshot, ou via LVM/ZFS). \item \textbf{Mensuelle} : export ZIP de souveraineté (Story 9-2b) pour chaque company depuis l'interface utilisateur. \item \textbf{Annuelle} : archivage long terme conforme OLICo Art. 9 (support non-modifiable type CD-R, ou hash signé sur support modifiable WORM). @@ -1660,7 +1664,7 @@ \subsubsection{Stratégie 3-2-1 sur DSM} \subsection{Export/import d'installation via l'interface Kesh}\label{sec:backup-ui-keshbackup} -Depuis la version v0.2, Kesh permet d'\textbf{exporter et de réimporter l'intégralité d'une installation} directement depuis l'interface web d'administration, sans accès SSH ni ligne de commande. L'export produit un fichier unique \keshcommand{.keshbackup} (conteneur compressé) contenant \textbf{toutes les données d'installation} : l'ensemble des sociétés, les utilisateurs, et les données système --- \textbf{à l'exception des fichiers} du répertoire des documents (justificatifs importés, PDF des factures émises), qui se sauvegardent à part (\S\ref{sec:pdf-factures-sauvegarde}, issue \#503). Cette fonction est \textbf{réservée au rôle Administrateur} et \textbf{inaccessible via une clé API (PAT)} — les opérations d'infrastructure ne transitent jamais par une intégration externe. +Depuis la version v0.2, Kesh permet d'\textbf{exporter et de réimporter l'intégralité d'une installation} directement depuis l'interface web d'administration, sans accès SSH ni ligne de commande. L'export produit un fichier unique \keshcommand{.keshbackup} (conteneur compressé) contenant \textbf{toutes les données d'installation} : l'ensemble des sociétés, les utilisateurs, et les données système --- \textbf{à l'exception des fichiers} du répertoire des documents (justificatifs importés, PDF des factures émises), qui se sauvegardent à part (\S\ref{sec:pdf-factures-sauvegarde}, issue \#503). Cette fonction est \textbf{réservée au rôle Administrateur} et \textbf{inaccessible via une clé API (PAT)} — les opérations d'infrastructure ne transitent jamais par une intégration externe. La \textbf{sauvegarde de sécurité pré-import} fait exception à l'absence de SSH : la rapatrier pour la restaurer demande un accès SSH (voir plus bas). \begin{keshnote} À ne pas confondre avec l'\textbf{export global per-société} (menu \textbf{Administration → Export global}, fichier CSV/ZIP) qui n'extrait les données comptables que d'\emph{une seule} entreprise pour transmission à un tiers. L'export/import \keshcommand{.keshbackup} décrit ici porte sur l'\textbf{installation complète} (toutes entreprises + comptes), pour la migration ou la restauration. @@ -1681,16 +1685,28 @@ \subsubsection{Importer (restaurer la même installation)} \textbf{Cette fonction sert à restaurer \emph{votre} installation} --- la remettre dans un état antérieur, ou la déplacer telle quelle vers un autre serveur. Ce n'est \textbf{pas} un outil pour verser les données d'une \emph{autre} installation dans la vôtre~: c'est techniquement possible, et les conséquences sur le journal d'audit sont décrites plus bas (section \emph{Conformité OLICo}). \begin{keshwarning} -\textbf{Opération destructrice et irréversible.} L'import \textbf{remplace l'intégralité des données} de l'installation actuelle par celles du fichier. À la fin, vous êtes \textbf{déconnecté} et devez vous reconnecter avec les \textbf{identifiants tels qu'ils étaient au moment de la sauvegarde} --- tout compte créé, renommé ou dont le mot de passe a changé \emph{depuis} cette sauvegarde revient à son état d'alors, et les comptes créés depuis n'existent plus. Avant toute suppression, Kesh crée \textbf{automatiquement une sauvegarde de l'état courant} côté serveur (répertoire \keshcommand{KESH\_ADMIN\_BACKUP\_DIR}, défaut \keshpath{/tmp}) en filet de sécurité ; conservez ce répertoire à l'abri et purgez-le selon votre politique. +\textbf{Opération destructrice et irréversible.} L'import \textbf{remplace l'intégralité des données} de l'installation actuelle par celles du fichier. À la fin, vous êtes \textbf{déconnecté} et devez vous reconnecter avec les \textbf{identifiants tels qu'ils étaient au moment de la sauvegarde} --- tout compte créé, renommé ou dont le mot de passe a changé \emph{depuis} cette sauvegarde revient à son état d'alors, et les comptes créés depuis n'existent plus. Avant toute suppression, Kesh crée \textbf{automatiquement une sauvegarde de l'état courant} côté serveur, en filet de sécurité --- dans le dossier de sauvegarde \keshcommand{KESH\_ADMIN\_BACKUP\_DIR}, défaut \keshpath{/data/backup}, monté sur \keshpath{./backup} par les compose fournis (voir \emph{La sauvegarde de sécurité pré-import} ci-dessous). \end{keshwarning} L'import \textbf{refuse} le fichier et préserve l'installation si : \begin{itemize} \item le fichier est \textbf{corrompu} ou n'est pas un \keshcommand{.keshbackup} valide (contrôle d'intégrité SHA-256) ; \item le \textbf{schéma} de la sauvegarde est incompatible avec la version de Kesh installée ; - \item la sauvegarde \textbf{exige une version minimale de Kesh supérieure} à celle de l'installation cible (protection anti-downgrade : ce seuil n'est relevé que par les migrations qui changent le schéma de façon incompatible) — mettez à jour Kesh avant de réimporter. + \item la sauvegarde \textbf{exige une version minimale de Kesh supérieure} à celle de l'installation cible (protection anti-downgrade : ce seuil n'est relevé que par les migrations qui changent le schéma de façon incompatible) — mettez à jour Kesh avant de réimporter ; + \item la \textbf{sauvegarde de sécurité} ne peut pas être prise ni écrite --- dossier de sauvegarde non inscriptible ou disque plein, ou base de données momentanément inaccessible : le message le dit, \textbf{aucune sauvegarde n'est créée et rien n'est supprimé} ; les journaux du serveur (\keshcommand{docker compose logs kesh-api}) indiquent la cause. \end{itemize} +\paragraph{La sauvegarde de sécurité pré-import} + +Elle est écrite dans le dossier \keshpath{backup/} du répertoire du compose (\keshpath{/volume1/docker/kesh/backup} sur Synology), sous un nom qui commence par \keshcommand{kesh-pre-import-} (suivi de l'horodatage) et finit par \keshcommand{.keshbackup} : elle \textbf{survit} au conteneur, à sa recréation et à la mise à jour de l'image. Le fichier est créé en mode \texttt{0600}, propriétaire \texttt{root} (le conteneur tourne en \texttt{root}) ; sur Synology, les ACL du dossier partagé peuvent s'ajouter à ces droits --- limite non mesurée. C'est un \textbf{secret} (mêmes contenus qu'un export). Il s'en ajoute une à chaque import, de la taille de l'installation : leur \textbf{purge} est à votre charge. Un fichier \keshcommand{….keshbackup.partial} est une écriture interrompue, \textbf{pas} une sauvegarde : il peut être supprimé. + +\textbf{Restaurer depuis elle.} L'import se fait par le navigateur, et ni File Station, ni un partage SMB, ni un \keshcommand{scp} sans \keshcommand{sudo} ne lisent un fichier \texttt{root} en \texttt{0600}. Rapatriez-la donc en SSH vers un dossier partagé, depuis le répertoire du compose : +\begin{lstlisting}[language=bash] +sudo cp backup/kesh-pre-import-<...>.keshbackup /volume1// +sudo chown /volume1//kesh-pre-import-<...>.keshbackup +\end{lstlisting} +puis importez la copie par l'écran (ci-dessus), et \textbf{supprimez-la} ensuite : c'est un secret, et l'original reste dans \keshpath{backup/}. + \paragraph{Exercices clôturés dans le désordre} Une sauvegarde produite par une version antérieure à la v0.13.0 peut porter un exercice \textbf{ouvert suivi d'un exercice clôturé} --- état que Kesh ne laisse plus se former, les exercices se clôturant désormais dans l'ordre. L'import l'\textbf{accepte} tel quel~: il restaure votre installation, il ne la corrige pas. Après l'import, l'écran \emph{Exercices comptables} affiche un \textbf{bandeau d'avertissement}, et \textbf{rien ne s'enregistre} dans l'exercice ouvert concerné --- ni saisie, ni règlement, ni dévalidation, ni modification, ni suppression~: le bilan étant cumulatif, toute écriture y changerait le bilan de l'exercice clôturé qui le suit. La réparation est celle que le bandeau indique, \textbf{dans cet ordre}~: @@ -1727,7 +1743,7 @@ \subsubsection{Importer (restaurer la même installation)} \begin{itemize} \item \keshcommand{KESH\_ADMIN\_IMPORT\_MAX\_MB} — taille maximale d'un fichier \keshcommand{.keshbackup} importé, en Mio (défaut \textbf{512}, bornes [1, 10240]). \item \keshcommand{KESH\_ADMIN\_EXPORT\_INMEM\_MB} — plafond d'assemblage en mémoire de l'export ; au-delà, l'export est streamé via un fichier temporaire (défaut \textbf{50}, bornes [1, 2048]). - \item \keshcommand{KESH\_ADMIN\_BACKUP\_DIR} — répertoire de la sauvegarde automatique pré-import (défaut \keshpath{/tmp}, y compris quand la variable est vide ; créé s'il n'existe pas). + \item \sloppy\keshcommand{KESH\_ADMIN\_BACKUP\_DIR} — dossier de sauvegarde de sécurité pré-import (défaut \keshpath{/data/backup}, y compris quand la variable est vide, monté sur \keshpath{./backup} par les compose fournis ; créé en \texttt{0700} s'il n'existe pas). Une autre valeur doit être un chemin \textbf{monté}, sans quoi la sauvegarde retourne dans le conteneur ; hors Docker, un dossier inscriptible. \end{itemize} \subsubsection{Quelle méthode de sauvegarde choisir ?} @@ -1804,12 +1820,12 @@ \subsubsection{Passer à la 0.13.0 : le compose et le fichier \texttt{.env}}\lab \item \texttt{MARIADB\_PASSWORD} changé dans \keshpath{.env} sans \texttt{ALTER USER} : « Base de données indisponible … 1045 … », suivi d'un indice (\S\ref{sec:mariadb-mdp}). \end{itemize} -\textbf{Effets sans refus} : \texttt{KESH\_PRODUCTION\_RESET} posé lors d'une ancienne démonstration réautorise, \textbf{sur une installation restée en démonstration}, la réinitialisation, qui vide toutes les données de la société ; les quatre \texttt{KESH\_SMTP\_HOST}/\texttt{\_USER}/\texttt{\_PASSWORD}/\texttt{\_FROM} posées « pour plus tard » activent l'envoi de factures par e-mail, et avec \texttt{KESH\_FEATURE\_FORGOT\_PASSWORD=true} (et \texttt{KESH\_PUBLIC\_BASE\_URL}) la réinitialisation du mot de passe par e-mail --- quiconque connaît l'adresse d'un utilisateur peut alors demander un lien ; \texttt{KESH\_COOKIE\_SECURE=false} sur \keshpath{docker-compose.prod.yml} émet des cookies sans \texttt{Secure} ; \texttt{KESH\_ADMIN\_BACKUP\_DIR} (un chemin non monté fait écrire dans le conteneur) ; \texttt{KESH\_LANG} ; \texttt{KESH\_PASSWORD\_MIN\_LENGTH} sur \keshpath{docker-compose.yml} s'applique côté serveur, alors que trois écrans gardent 12 en dur : au-dessous de 12, les écrans refusent quand même ; au-dessus, l'écran accepte et le serveur refuse ; une ligne \texttt{KESH\_LOG\_FILE\_PATH=} \textbf{vide} désactive désormais le journal fichier ; et plus généralement toute ligne décommentée de ces seize variables. +\textbf{Effets sans refus} : \texttt{KESH\_PRODUCTION\_RESET} posé lors d'une ancienne démonstration réautorise, \textbf{sur une installation restée en démonstration}, la réinitialisation, qui vide toutes les données de la société ; les quatre \texttt{KESH\_SMTP\_HOST}/\texttt{\_USER}/\texttt{\_PASSWORD}/\texttt{\_FROM} posées « pour plus tard » activent l'envoi de factures par e-mail, et avec \texttt{KESH\_FEATURE\_FORGOT\_PASSWORD=true} (et \texttt{KESH\_PUBLIC\_BASE\_URL}) la réinitialisation du mot de passe par e-mail --- quiconque connaît l'adresse d'un utilisateur peut alors demander un lien ; \texttt{KESH\_COOKIE\_SECURE=false} sur \keshpath{docker-compose.prod.yml} émet des cookies sans \texttt{Secure} ; \texttt{KESH\_ADMIN\_BACKUP\_DIR} (un chemin non monté fait écrire dans le conteneur ; sans cette ligne, la sauvegarde pré-import va désormais, avec le compose de la 0.13.0, dans \keshpath{./backup} --- avec un compose gardé sans ce montage, dans \texttt{/data/backup} du conteneur, éphémère comme avant) ; \texttt{KESH\_LANG} ; \texttt{KESH\_PASSWORD\_MIN\_LENGTH} sur \keshpath{docker-compose.yml} s'applique côté serveur, alors que trois écrans gardent 12 en dur : au-dessous de 12, les écrans refusent quand même ; au-dessus, l'écran accepte et le serveur refuse ; une ligne \texttt{KESH\_LOG\_FILE\_PATH=} \textbf{vide} désactive désormais le journal fichier ; et plus généralement toute ligne décommentée de ces seize variables. \textbf{Un \keshpath{.env} copié du gabarit sans modification} ne change pas de comportement --- les seules lignes actives du gabarit parmi ces variables valent leur défaut (\texttt{KESH\_LANG=fr}, \texttt{KESH\_PASSWORD\_MIN\_LENGTH=12}, \texttt{KESH\_BANK\_IMPORT\_MAX\_MB=10}, \texttt{KESH\_COOKIE\_SECURE=true}) --- \textbf{sauf} le secret JWT laissé au placeholder, désormais refusé, \textbf{et} les mots de passe MariaDB avec \keshpath{docker-compose.yml} : copié du gabarit \textbf{0.13.0}, où ils sont commentés et sans valeur, il fait refuser Compose ; copié d'un gabarit \textbf{antérieur}, qui posait \texttt{kesh\_dev}, il fait émettre à Kesh l'avertissement « mot de passe publié ». Un \texttt{KESH\_ADMIN\_PASSWORD} absent ou vide ne change rien avec \keshpath{docker-compose.prod.yml}, et cesse de faire refuser le démarrage avec \keshpath{docker-compose.yml}. \end{keshwarning} -\textbf{Voie recommandée : re-télécharger le compose} --- \keshpath{docker-compose.yml}, ou, sur Synology, \keshpath{docker-compose.prod.yml} enregistré sous \keshpath{docker-compose.yml} (\S\ref{sec:synology}) --- \textbf{et y reporter vos seules adaptations locales} (port \texttt{8080:80}, réseau, chemins des montages). Un montage modifié à la main et non reporté revient à \keshpath{./documents} (ou \keshpath{./inbox}, \keshpath{./log}) : justificatifs et PDF paraissent alors disparus --- ils sont toujours dans l'ancien dossier. Les commandes s'exécutent depuis le répertoire du compose (\keshpath{/volume1/docker/kesh} sur Synology) ; avec un autre nom de fichier, ajoutez \keshcommand{-f } à chaque commande \keshcommand{docker compose}. Puis \keshcommand{docker compose pull} \textbf{avant} \keshcommand{docker compose up -d}. +\textbf{Voie recommandée : re-télécharger le compose} --- \keshpath{docker-compose.yml}, ou, sur Synology, \keshpath{docker-compose.prod.yml} enregistré sous \keshpath{docker-compose.yml} (\S\ref{sec:synology}) --- \textbf{et y reporter vos seules adaptations locales} (port \texttt{8080:80}, réseau, chemins des montages). Un montage modifié à la main et non reporté revient à \keshpath{./documents} (ou \keshpath{./inbox}, \keshpath{./log}) : justificatifs et PDF paraissent alors disparus --- ils sont toujours dans l'ancien dossier. Les commandes s'exécutent depuis le répertoire du compose (\keshpath{/volume1/docker/kesh} sur Synology) ; avec un autre nom de fichier, ajoutez \keshcommand{-f } à chaque commande \keshcommand{docker compose}. Puis \keshcommand{docker compose pull} \textbf{avant} \keshcommand{docker compose up -d}. Le compose de la 0.13.0 monte en outre \keshpath{./backup} (sauvegarde de sécurité pré-import, \S\ref{sec:backup-ui-keshbackup}) : le re-télécharger suffit pour ce montage. \paragraph{MariaDB (\keshpath{docker-compose.yml}) : port fermé, mots de passe obligatoires.}\mbox{}\\ \begin{sloppypar} @@ -1831,7 +1847,7 @@ \subsubsection{Passer à la 0.13.0 : le compose et le fichier \texttt{.env}}\lab \end{sloppypar} \paragraph{Pour qui garde son fichier compose.}\mbox{}\\ -Quatre gestes pour \keshpath{docker-compose.yml}, deux pour \keshpath{docker-compose.prod.yml}. Les deux premiers, dans les deux fichiers, sous \texttt{environment:} du service \texttt{kesh-api} : +Cinq gestes pour \keshpath{docker-compose.yml}, trois pour \keshpath{docker-compose.prod.yml}. Les deux premiers, dans les deux fichiers, sous \texttt{environment:} du service \texttt{kesh-api} ; le dernier, dans les deux fichiers aussi, sous ses \texttt{volumes:} : \begin{enumerate} \item \textbf{ajouter} les lignes \textbf{nouvelles}, et elles seules --- pour \keshpath{docker-compose.yml} : \begin{lstlisting}[language=bash] @@ -1878,10 +1894,17 @@ \subsubsection{Passer à la 0.13.0 : le compose et le fichier \texttt{.env}}\lab MARIADB_PASSWORD: ${MARIADB_PASSWORD:?à poser dans .env} \end{lstlisting} Exemple abrégé : le message du compose re-téléchargé ajoute « installation existante, reprendre la valeur avec laquelle la base a été créée, jamais une neuve », que Compose affiche au refus. Le plus sûr est de recopier ces trois lignes, message compris, depuis le compose re-téléchargé. + \item \textbf{ajouter} sous \texttt{volumes:} du service \texttt{kesh-api}, dans les deux compose, le montage de la sauvegarde de sécurité pré-import --- sans lui, elle reste dans le conteneur et disparaît à sa recréation : +\begin{lstlisting}[language=bash] + - ./backup:/data/backup +\end{lstlisting} \end{enumerate} \begin{sloppypar} -Puis \keshcommand{docker compose config -q} (qui refuse une clé en double, et l'absence des deux mots de passe MariaDB), vérifier que \keshcommand{docker compose config} montre \texttt{KESH\_SMTP\_HOST} et \texttt{KESH\_PRODUCTION\_RESET}, et \textbf{aucune} entrée \texttt{ports} sous le service \texttt{mariadb} (Compose l'écrirait sous sa forme développée, \texttt{published: "3306"}), et \keshcommand{docker compose up -d}. +Puis \keshcommand{docker compose config -q} (qui refuse une clé en double, et l'absence des deux mots de passe MariaDB), vérifier que \keshcommand{docker compose config} montre \texttt{KESH\_SMTP\_HOST} et \texttt{KESH\_PRODUCTION\_RESET}, et \textbf{aucune} entrée \texttt{ports} sous le service \texttt{mariadb} (Compose l'écrirait sous sa forme développée, \texttt{published: "3306"}), que la commande ci-dessous affiche \texttt{1} --- elle compte la \textbf{cible} du montage, pas la variable, qu'un \keshpath{.env} peut poser sans montage ---, et \keshcommand{docker compose up -d}. \end{sloppypar} +\begin{lstlisting}[language=bash] +sudo docker compose config | grep -c 'target: /data/backup' +\end{lstlisting} \paragraph{Contrôle de fin de mise à jour (MariaDB, \keshpath{docker-compose.yml}).}\mbox{}\\ Il se fait \textbf{sur la base}, pas sur \keshpath{.env} : un \keshcommand{grep} sur \keshcommand{docker compose config} ne lit que \keshpath{.env}, et resterait muet alors que la base garde l'ancien mot de passe. Une fois les mots de passe changés (\S\ref{sec:mariadb-mdp}) et \keshcommand{docker compose up -d} passé : diff --git a/docs/testing.md b/docs/testing.md index 50e4b0ddd..ce805c56d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -163,10 +163,11 @@ Le défaut applicatif de `KESH_HOST` est passé de `0.0.0.0` à `127.0.0.1` (Sto docker compose -f docker-compose.dev.yml up -d mariadb cd /path/to/kesh (cd frontend && npm run build) # KESH_STATIC_DIR pointe sur le résultat -mkdir -p /tmp/kesh-e2e/inbox /tmp/kesh-e2e/documents +mkdir -p /tmp/kesh-e2e/inbox /tmp/kesh-e2e/documents /tmp/kesh-e2e/backup KESH_TEST_MODE=true KESH_HOST=127.0.0.1 KESH_COOKIE_SECURE=false \ KESH_PORT=3000 KESH_STATIC_DIR="$PWD/frontend/build" \ KESH_INBOX_DIR=/tmp/kesh-e2e/inbox KESH_DOCUMENTS_DIR=/tmp/kesh-e2e/documents \ + KESH_ADMIN_BACKUP_DIR=/tmp/kesh-e2e/backup \ KESH_ADMIN_USERNAME=admin KESH_ADMIN_PASSWORD=e2e-admin-password-12chars \ KESH_SMTP_HOST=smtp.invalid KESH_SMTP_USER=e2e \ KESH_SMTP_PASSWORD=e2e KESH_SMTP_FROM=kesh@example.invalid \ @@ -281,6 +282,7 @@ Le `globalSetup` Playwright (`tests/e2e/global-setup.ts`) appelle `seedTestState KESH_TEST_MODE=true KESH_HOST=127.0.0.1 KESH_PORT=8181 \ KESH_ADMIN_USERNAME=admin KESH_ADMIN_PASSWORD=e2e-admin-password-12chars \ KESH_FEATURE_FORGOT_PASSWORD=true KESH_PUBLIC_BASE_URL=http://127.0.0.1:8181 \ + KESH_ADMIN_BACKUP_DIR=/tmp/kesh-e2e/backup \ KESH_SMTP_HOST=127.0.0.1 KESH_SMTP_PORT=2525 KESH_SMTP_USER=e2e \ KESH_SMTP_PASSWORD=e2e KESH_SMTP_FROM=kesh@example.invalid \ DATABASE_URL="mysql://root:kesh_dev_root@127.0.0.1:3306/kesh" \ @@ -301,6 +303,7 @@ KESH_TEST_MODE=true KESH_HOST=127.0.0.1 KESH_PORT=8181 \ KESH_SMTP_HOST=smtp.invalid KESH_SMTP_USER=e2e \ KESH_SMTP_PASSWORD=e2e KESH_SMTP_FROM=kesh@example.invalid \ KESH_STATIC_DIR=../frontend/build KESH_COOKIE_SECURE=false \ + KESH_ADMIN_BACKUP_DIR=/tmp/kesh-e2e/backup \ DATABASE_URL="mysql://kesh:kesh_dev@127.0.0.1:3306/kesh_e2e" \ KESH_JWT_SECRET="dev-secret-at-least-32-bytes-long-for-testing" \ cargo run -p kesh-api