# Tickets correctifs — Projet Inventory > Liste de tâches issues de la review du 2026-06-11 (`REVIEW.md`). > Chaque ticket est autonome : contexte, ce qu'il faut faire, fichiers concernés. > Commence par les P0, puis P1, etc. Convention de commit : `fix(T-XXX) : description courte`. --- ## P0 — Urgents (sécurité) ### T-001 — Révoquer et retirer les credentials de `.mcp.json` **Pourquoi :** le fichier `.mcp.json` est dans git et contient le mot de passe admin MCP de production (`A123`) et un token Lesstime valide. Toute personne avec accès au dépôt (ou à son historique) peut se connecter aux deux systèmes. Supprimer le fichier ne suffit pas : git garde l'historique — il faut **changer les secrets**. **À faire :** 1. Changer le mot de passe du profil `admin-default-profile` sur `inventory.malio-dev.fr` (et choisir un vrai mot de passe, pas `A123`). 2. Régénérer le bearer token Lesstime côté Lesstime. 3. Sortir le fichier de git sans le supprimer du disque : ```bash git rm --cached .mcp.json echo ".mcp.json" >> .gitignore ``` 4. Créer `.mcp.json.example` avec des placeholders : ```json { "mcpServers": { "inventory": { "type": "http", "url": "https://inventory.malio-dev.fr/_mcp", "headers": { "X-Profile-Id": "", "X-Profile-Password": "" } } } } ``` 5. Remettre les nouveaux secrets dans ton `.mcp.json` local (désormais ignoré). **Fichiers :** `.mcp.json`, `.gitignore`, `.mcp.json.example` (nouveau) ### T-002 — Supprimer `create_test_user.php` et vérifier la prod **Pourquoi :** ce script de debug, commité à la racine, crée un compte `ROLE_ADMIN` avec `admin@admin.com` / `admin123` — un mot de passe devinable en quelques essais. S'il a déjà tourné en production, un compte admin faible existe peut-être en ce moment. **À faire :** 1. Vérifier en prod qu'aucun profil `admin@admin.com` n'est actif : ```sql SELECT id, email, is_active, roles FROM profiles WHERE email = 'admin@admin.com'; ``` S'il existe : le désactiver ou changer son mot de passe immédiatement. 2. Supprimer le script : ```bash git rm create_test_user.php ``` 3. (Optionnel) Si le besoin « créer un admin de dev » existe encore, créer une commande Symfony `app:create-admin` qui prend le mot de passe en argument — ne jamais le hardcoder. **Fichiers :** `create_test_user.php` ### T-003 — Changer le mot de passe PG prod et archiver les scripts `*-prod-*.php` **Pourquoi :** 9 scripts dans `scripts/` contiennent le mot de passe de la base de production en clair (`fermerecette`). Ce sont des scripts de réparation one-shot qui ont déjà servi : ils n'ont plus de raison d'être dans le dépôt avec des secrets dedans. **À faire :** 1. Changer le mot de passe de l'utilisateur PG concerné sur le serveur de prod. 2. Archiver les scripts (le dossier `_archives/` est déjà dans le `.gitignore`) : ```bash mkdir -p _archives/scripts-prod git rm scripts/check-prod-values.php scripts/check-prod-audit-dates.php \ scripts/check-prod-missing-piece-cfs.php scripts/check-prod-orphaned-detail.php \ scripts/fix-prod-all.php scripts/fix-prod-recreate-and-migrate.php \ scripts/migrate-orphaned-custom-fields.php scripts/restore-custom-field-values.php \ scripts/verify-prod-health.php # (les copies locales peuvent aller dans _archives/scripts-prod si tu veux les garder) ``` 3. Si l'un d'eux doit rester utilisable : remplacer les credentials en dur par `getenv('DATABASE_URL')`. **Fichiers :** `scripts/*-prod-*.php`, `scripts/migrate-orphaned-custom-fields.php`, `scripts/restore-custom-field-values.php` --- ## P1 — Importants ### T-004 — Ajouter une CI qui lance tests et lint **Pourquoi :** aujourd'hui, rien ne lance les tests automatiquement : la CI Gitea ne fait que tagger et builder l'image Docker, et le hook pre-commit est contourné avec `--no-verify` (trop lent). On peut donc livrer en prod avec des tests rouges sans aucune alerte. **À faire :** 1. Créer `.gitea/workflows/ci.yml` : ```yaml name: CI on: pull_request: push: branches: [develop] jobs: backend: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_USER: root POSTGRES_PASSWORD: root POSTGRES_DB: inventory_test ports: ["5432:5432"] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: { php-version: '8.4', extensions: 'pdo_pgsql, intl' } - run: composer install --no-interaction --prefer-dist - run: vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes - run: php bin/console doctrine:schema:create --env=test env: { DATABASE_URL: "postgresql://root:root@localhost:5432/inventory_test" } - run: vendor/bin/phpunit env: { DATABASE_URL: "postgresql://root:root@localhost:5432/inventory_test" } frontend: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - run: npm ci working-directory: frontend - run: npx eslint . working-directory: frontend - run: npx nuxi typecheck working-directory: frontend - run: npm run build working-directory: frontend ``` > Adapter les détails (version PHP exacte, env de test) au premier run — l'important est que les 4 vérifications (cs-fixer, PHPUnit, ESLint, typecheck) tournent. 2. Dans Gitea, marquer ce workflow comme requis pour merger une PR vers `develop`. **Fichiers :** `.gitea/workflows/ci.yml` (nouveau) ### T-005 — Définir une limite de taille d'upload à tous les niveaux **Pourquoi :** aucune limite n'est choisie nulle part. Conséquence double : en prod, nginx applique son défaut de **1 Mo** (les gros PDF sont sans doute rejetés avec une erreur brute), et côté application rien n'empêcherait de remplir le disque si les limites infra étaient relevées. Décision à prendre : 50 Mo max (à ajuster au métier). **À faire :** 1. Check applicatif dans `DocumentUploadProcessor::handleMultipartUpload()` (après la validation MIME, ligne ~79) : ```php private const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 Mo if ($file->getSize() > self::MAX_UPLOAD_BYTES) { throw new BadRequestHttpException('Fichier trop volumineux (max 50 Mo).'); } ``` 2. Même check dans `CommentController::create()` dans la boucle `foreach ($files as $file)` (ligne ~106) — renvoyer `$this->json(['message' => 'Fichier trop volumineux (max 50 Mo).'], 400)`. 3. `infra/dev/php.ini` — ajouter : ```ini upload_max_filesize = 50M post_max_size = 55M ``` Et vérifier que l'image prod (infra/prod/Dockerfile) reçoit la même config. 4. `infra/prod/nginx.conf` — dans le bloc `server` : ```nginx client_max_body_size 55m; ``` (idem dans `nginx-proxy.conf` si le proxy frontal est aussi versionné ici). 5. Tester : upload d'un PDF de ~5 Mo en local, et vérifier le message d'erreur propre au-delà de 50 Mo. **Fichiers :** `src/State/DocumentUploadProcessor.php`, `src/Controller/CommentController.php`, `infra/dev/php.ini`, `infra/prod/nginx.conf`, `infra/prod/Dockerfile` ### T-006 — Corriger le fallback d'URL API du frontend **Pourquoi :** si la variable `NUXT_PUBLIC_API_BASE_URL` est vide en prod, tous les appels API partent vers `http://localhost:3000` — c'est-à-dire vers la machine de l'utilisateur. L'app casse silencieusement. Un fallback vide = « même origine », ce qui est le comportement correct. **À faire :** 1. `frontend/app/composables/useApi.ts` ligne 18 : ```ts // AVANT const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000' // APRÈS (chaîne vide = même origine ; useApi ajoute déjà /api) const API_BASE_URL = (publicConfig.apiBaseUrl as string) || '' ``` 2. `frontend/nuxt.config.ts` ligne ~49 : remplacer le fallback `'http://localhost/api'` par `''` (valeur SSR jamais utilisée, SSR off — autant ne pas mentir). 3. Vérifier en dev que tout fonctionne encore (la variable est définie en dev, donc rien ne doit changer), puis `npx nuxi typecheck`. **Fichiers :** `frontend/app/composables/useApi.ts`, `frontend/nuxt.config.ts` ### T-007 — Purger la doc « submodule » (le frontend est dans le monorepo) **Pourquoi :** le frontend a été rapatrié dans le repo principal, mais README, DEPLOY, RELEASE et le README frontend décrivent encore le clonage `--recurse-submodules` et le workflow de commit en deux temps. Un nouveau dev (ou toi en incident de prod) suit des étapes qui n'existent plus. **À faire :** 1. `README.md` : lignes 49-50 → `git clone ` ; section « workflow » lignes ~291-296 → décrire « un seul commit depuis la racine couvre backend + frontend ». 2. `DEPLOY.md` : supprimer les `git submodule update --init --recursive` (lignes 58, 62, 208). 3. `RELEASE.md` : supprimer les étapes submodule (lignes 49, 117). 4. `frontend/README.md` : remplacer la section « submodule » (lignes 150-154) par « ce dossier fait partie du monorepo Inventory ». **Fichiers :** `README.md`, `DEPLOY.md`, `RELEASE.md`, `frontend/README.md` ### T-008 — Corriger l'utilisateur PG dans `DEPLOY.md` **Pourquoi :** les commandes de DEPLOY.md utilisent `ferme_user` / `fermerecette` — copié-collé du projet Ferme. Le vrai utilisateur est `inventory_user`. En situation d'incident, suivre la doc ferait taper des commandes qui échouent ou visent la mauvaise base. **À faire :** 1. Remplacer les 6 occurrences de `ferme_user` par `inventory_user`. 2. Remplacer le mot de passe en clair par un placeholder `` (le mot de passe n'a rien à faire dans la doc). **Fichiers :** `DEPLOY.md` ### T-009 — Durcir le garde anti path-traversal de `DocumentStorageService` **Pourquoi :** le contrôle `realpath()` (qui vérifie que le chemin final est bien dans le dossier de stockage) est sauté quand le fichier n'existe pas, car `realpath()` renvoie `false` dans ce cas. Le risque actuel est faible (le chemin vient de la base, pas de l'utilisateur), mais le contrôle se veut une protection — autant qu'il protège vraiment. **À faire :** dans `getAbsolutePath()` (`src/Service/DocumentStorageService.php:28-42`) : ```php // AVANT $absolutePath = $this->storageDir.'/'.$relativePath; $realPath = realpath($absolutePath); if (false !== $realPath && !str_starts_with($realPath, realpath($this->storageDir))) { throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath)); } // APRÈS — valider sur le dossier parent, qui existe toujours pour un fichier servi $absolutePath = $this->storageDir.'/'.$relativePath; $realParent = realpath(dirname($absolutePath)); $realStorage = realpath($this->storageDir); if (false === $realStorage || false === $realParent || !str_starts_with($realParent.'/', $realStorage.'/')) { throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath)); } ``` Garder le check `str_contains($relativePath, '..')` existant en première ligne. Ajouter un test unitaire avec un chemin contenant `../` et un chemin absolu. **Fichiers :** `src/Service/DocumentStorageService.php`, `tests/` (nouveau test) --- ## P2 — Consolidation ### T-010 — Hardening CSRF : header `X-Requested-With` obligatoire sur les écritures **Pourquoi :** l'auth est par cookie, et la seule protection contre le CSRF (un site malveillant qui fait faire des requêtes à ton navigateur connecté) est l'attribut `SameSite=Lax` du cookie. Une deuxième barrière peu coûteuse : exiger un header custom, qu'un formulaire HTML cross-site ne peut pas envoyer. **À faire :** 1. Côté front, dans `useApi.ts`, ajouter `'X-Requested-With': 'XMLHttpRequest'` aux headers de toutes les requêtes. 2. Côté back, créer un listener `kernel.request` qui renvoie 403 si la méthode n'est pas GET/HEAD/OPTIONS, que le chemin matche `^/api` (hors `/api/session/profile` pour le login) et que le header est absent. 3. Supprimer la clé morte `csrfToken` de `nuxt.config.ts` (elle laisse croire qu'une protection CSRF existe). 4. Adapter les tests API (`AbstractApiTestCase`) pour envoyer le header. **Fichiers :** `frontend/app/composables/useApi.ts`, `src/EventListener/` (nouveau), `frontend/nuxt.config.ts`, `tests/AbstractApiTestCase.php` ### T-011 — Test fonctionnel : l'ID de session change au login **Pourquoi :** la protection automatique contre la fixation de session est désactivée (`session_fixation_strategy: none`, choix documenté pour la SPA) et compensée par un `$session->migrate(true)` manuel au login. Si quelqu'un supprime ce `migrate` un jour, plus rien ne protège — un test doit le verrouiller. **À faire :** écrire un test API qui : récupère le cookie de session avant login → se loggue → vérifie que l'ID de session a changé. Ajouter un commentaire dans `SessionProfileController::activateProfile()` pointant vers `security.yaml:5`. **Fichiers :** `tests/Api/SessionProfileTest.php` (ou équivalent), `src/Controller/SessionProfileController.php` ### T-012 — MCP : remplacer le mot de passe par un token Bearer + HTTPS **Pourquoi :** le mot de passe du profil circule en clair dans les headers de chaque requête MCP (et l'URL configurée est en `http://`). Les headers finissent dans les logs des proxys. Un token dédié révocable, transmis en `Authorization: Bearer`, est le pattern déjà utilisé par Lesstime. **À faire :** ajouter un champ `mcpToken` (hashé) sur Profile ou une table `api_tokens`, générer via une commande console, adapter `McpHeaderAuthenticator` pour valider le Bearer (garder le rate-limiting), mettre à jour `.mcp.json.example`, et servir `/_mcp` en HTTPS uniquement. **Fichiers :** `src/Mcp/Security/McpHeaderAuthenticator.php`, `src/Entity/Profile.php` ou nouvelle entité, migration, `.mcp.json.example` ### T-013 — Maintenance : faire respecter le flag côté backend **Pourquoi :** le toggle admin écrit `var/maintenance`, mais seul le **frontend** (middleware) le vérifie. Quelqu'un qui appelle l'API directement (curl, MCP, script) passe à travers. Et le second mécanisme (`maintenance.on` lu par nginx, posé par `deploy.sh`) n'est documenté nulle part. **À faire :** 1. Listener `kernel.request` backend : si `var/maintenance` existe et que l'utilisateur n'est pas admin → 503 JSON (sauf `/api/maintenance/check` et `/api/session/*`). 2. Documenter les deux niveaux (applicatif vs nginx/deploy) dans `DEPLOY.md`. **Fichiers :** `src/EventListener/` (nouveau), `DEPLOY.md` ### T-014 — Résoudre l'incohérence provider/email nullable **Pourquoi :** le user provider Symfony charge les profils par `email`, mais l'email est nullable (profils « kiosque »). Ça marche par chance (l'authenticator charge par ID), mais tout futur usage du provider standard cassera sur ces profils. **À faire :** décision à prendre — option A : email obligatoire + email technique généré pour les kiosques ; option B : provider custom qui charge par id ou email. Documenter le choix dans `docs/BACKEND.md`. **Fichiers :** `config/packages/security.yaml`, `src/Entity/Profile.php`, éventuellement `src/Security/` ### T-015 — Supprimer `@nuxtjs/tailwindcss` **Pourquoi :** dépendance non utilisée (le projet utilise `@tailwindcss/vite`, Tailwind 4) qui installe en plus un Tailwind 3 parallèle — bloat et risque de conflit de résolution. **À faire :** ```bash cd frontend && npm uninstall @nuxtjs/tailwindcss && npm run build && npx nuxi typecheck ``` **Fichiers :** `frontend/package.json`, `frontend/package-lock.json` ### T-016 — Mettre à jour la doc de versioning (`VERSION` → `config/version.yaml`) **Pourquoi :** `RELEASE.md` (lignes 17, 50, 80, 82) et l'arbre projet de `CLAUDE.md` référencent un fichier `VERSION` qui n'existe plus — la version vit dans `config/version.yaml`. **À faire :** remplacer toutes les références ; vérifier au passage que la description du footer frontend (« lit VERSION au build ») correspond au mécanisme réel. **Fichiers :** `RELEASE.md`, `CLAUDE.md` ### T-017 — Sortir les données client de la racine + gitignore défensif **Pourquoi :** `customer.json`, `Company (1).json`, `Ensemble simple rotor.pdf` (données client réelles → enjeu RGPD) traînent à la racine, non protégés par le `.gitignore` : un `git add .` distrait les commiterait. **À faire :** 1. Déplacer ces fichiers hors du dépôt (ex. `~/imports/inventory/`). Supprimer aussi `inventory_prod (2).sql.gz` et le `node_modules/` orphelin de la racine. 2. Ajouter au `.gitignore` racine : ``` /node_modules/ /*.json /*.pdf ``` (les `.json` légitimes du projet sont dans des sous-dossiers ou explicitement trackés — `composer.json` etc. restent suivis car déjà trackés ; pour les nouveaux, `git add -f` reste possible). **Fichiers :** `.gitignore`, racine du projet ### T-018 — Uniformiser la gestion d'erreur frontend (état d'erreur au lieu de `console.error`) **Pourquoi :** en cas d'échec de chargement, le pattern actuel est `console.error(...)` puis la page s'affiche à moitié vide, sans message. L'utilisateur ne sait pas que quelque chose a raté ni comment réessayer. **À faire :** règle : `useApi` est le seul à toaster ; les composables exposent un `error: Ref` que la page affiche (bandeau avec bouton Réessayer). Commencer par les 3 pages principales : détail machine (`useMachineDetailData.ts:372,385`), détail composant, détail pièce. Étendre ensuite au reste. **Fichiers :** `frontend/app/composables/useMachineDetailData.ts`, `useComponentEdit.ts`, `usePieceEdit.ts`, pages correspondantes ### T-019 — Cacher le résultat de `/maintenance/check` (TTL) **Pourquoi :** chaque navigation d'un non-admin déclenche un appel API pour vérifier la maintenance — de la latence sur toutes les transitions de page pour un état qui ne change presque jamais. **À faire :** dans `profile.global.ts`, stocker le résultat dans un `useState` avec timestamp et ne re-fetcher que si > 60 s. **Fichiers :** `frontend/app/middleware/profile.global.ts` ### T-020 — Détracker `infra/dev/.env.docker.local` + fournir un `.example` **Pourquoi :** le fichier est dans le `.gitignore` mais a été commité avant l'ajout de la règle — git continue donc de le suivre. Chaque dev qui le modifie crée du diff, et ses secrets (même de dev) sont versionnés. **À faire :** ```bash cp infra/dev/.env.docker.local infra/dev/.env.docker.local.example # Dans le .example : remplacer les valeurs par des placeholders # et supprimer les variables JWT_* (inutilisées, cf. T-023) git rm --cached infra/dev/.env.docker.local git add infra/dev/.env.docker.local.example ``` Mettre à jour le README (section installation) pour mentionner la copie du `.example`. **Fichiers :** `infra/dev/.env.docker.local`, `infra/dev/.env.docker.local.example` (nouveau), `README.md` --- ## P3 — Nice to have ### T-021 — Décision métier : commentaires (et pièces jointes) en `ROLE_VIEWER` ? **Pourquoi :** seule écriture accessible aux lecteurs. Si c'est voulu (« tout le monde commente »), le documenter dans `docs/BACKEND.md` ; sinon passer à `ROLE_GESTIONNAIRE` (`CommentController.php:33`). **Fichiers :** `src/Controller/CommentController.php`, `docs/BACKEND.md` ### T-022 — Revoir les pagination max (2000/1000/500) **Pourquoi :** `Constructeur` (2000), `ConstructeurCategorie` (1000), `Document` (500) — vérifier le besoin réel du front et redescendre, ou commenter pourquoi. **Fichiers :** `src/Entity/Constructeur.php`, `src/Entity/ConstructeurCategorie.php`, `src/Entity/Document.php` ### T-023 — Nettoyer les variables JWT et les secrets `changeme` **Pourquoi :** `JWT_SECRET_KEY`/`JWT_PUBLIC_KEY`/`JWT_PASSPHRASE` ne servent à rien (auth session). `APP_SECRET=changeme_…` mérite une vraie valeur aléatoire locale. (Fusionne naturellement avec T-020.) **Fichiers :** `infra/dev/.env.docker.local` ### T-024 — Supprimer la config morte de `nuxt.config.ts` **Pourquoi :** `csrfToken`, `requestTimeout`, `enableDebug`, `enableAnalytics`, `logLevel` ne sont consommés nulle part — de la config qui ment. (`csrfToken` est déjà traité par T-010.) **Fichiers :** `frontend/nuxt.config.ts:56-59` ### T-025 — Nettoyer le Dockerfile dev (blocs Oracle/IMAP/MySQL commentés) **Pourquoi :** restes d'un template générique sans rapport avec un projet PostgreSQL. **Fichiers :** `infra/dev/Dockerfile:48-53,82-100` ### T-026 — Renommer les 3 utils `.js` en `.ts` **Pourquoi :** `documentPreview.js`, `fileIcons.js`, `printTemplates/machineReport.js` sont importés depuis du TypeScript sans types. Tâche mécanique (bon candidat pour Codex). **Fichiers :** `frontend/app/utils/documentPreview.js`, `frontend/app/utils/fileIcons.js`, `frontend/app/utils/printTemplates/machineReport.js` ### T-027 — Ajouter `nosniff` + `CSP: sandbox` à `download()` **Pourquoi :** `serve()` envoie ces deux headers de protection, `download()` non — asymétrie gratuite. **À faire :** copier les deux `headers->set(...)` de `serve()` dans `download()` (`DocumentServeController.php:110-116`). **Fichiers :** `src/Controller/DocumentServeController.php` ### T-028 — Smoke test de l'image Docker avant push **Pourquoi :** `build-docker.yml` pousse `latest` sans vérifier que l'image démarre. **À faire :** entre build et push : `docker run --rm gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} php bin/console about`. **Fichiers :** `.gitea/workflows/build-docker.yml` > **Hors tickets :** la dette d'architecture (smartMatch dupliqué, double flush, `pendingStructure`, God controller à 1121 lignes, `any` ×179…) a déjà son plan d'action chiffré dans `docs/REVIEW_ARCHITECTURE.md`. Recommandation forte : exécuter sa **Phase 1** (4 corrections effort S, sans impact d'interface) avant qu'elle ne prenne encore 3 mois — voir `REVIEW.md` §4. --- ## Résumé | Priorité | Tickets | Estimation | |----------|---------|------------| | **P0** | T-001 à T-003 | ~2h (+ rotations de secrets) | | **P1** | T-004 à T-009 | ~1,5 j | | **P2** | T-010 à T-020 | ~2,5 j | | **P3** | T-021 à T-028 | ~0,5 j | | **Total** | 28 tickets | ~5 j | > Commence par **T-001** — tant que les secrets ne sont pas révoqués, tout le reste est secondaire. > Pour chaque ticket, fais un commit dédié avec le numéro dans le message (ex. `fix(T-001) : retirer .mcp.json du dépôt`).