Files
Inventory/TICKETS.md
T

22 KiB
Raw Blame History

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 :
    git rm --cached .mcp.json
    echo ".mcp.json" >> .gitignore
    
  4. Créer .mcp.json.example avec des placeholders :
    {
        "mcpServers": {
            "inventory": {
                "type": "http",
                "url": "https://inventory.malio-dev.fr/_mcp",
                "headers": {
                    "X-Profile-Id": "<PROFILE_ID>",
                    "X-Profile-Password": "<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 :
    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 :
    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) :
    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 :
    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) :
    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 :
    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 :
    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 :
    // 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 <url-du-repo> ; 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 <mot-de-passe-prod> (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) :

// 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 :

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 (VERSIONconfig/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<string | null> 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 :

cp infra/dev/.env.docker.local infra/dev/.env.docker.local.example
# Dans le .example : remplacer les valeurs par des placeholders <CHANGE_ME>
# 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).