Ban7 RemoteLab
🎓 Formation certifianteRNCP 6+ École~4 mois au rythme standard

Concepteur Développeur d'Applications

Parcours complet menant au titre RNCP niveau 6.

S'inscrire

Programme complet

Les textes des cours sont en accès libre. Les vidéos et exercices (🔒) sont réservés aux abonnés.

Analyser le besoin de CoWorkNet et rédiger le cahier des charges

  • Présentation du projet fil rouge des TD

    CoWorkNet est le projet fil rouge du parcours Concepteur Développeur d'Applications (CDA, RNCP37873) : une plateforme de réservation d'espaces de coworking multi-sites, développée au fil de 28 TD qui s'enchaînent du premier jour de formation jusqu'au déploiement. Contrairement à des exercices isolés, chaque TD reprend et complète le travail des précédents sur ce même projet — une décision prise ici (nom d'un champ, structure d'une donnée, règle métier) n'est jamais remise à plat plus tard sans raison documentée : elle devient une contrainte pour tous les TD suivants.

    Le fil rouge se déroule en sept phases. Phase 1, Analyse et conception ([[TD1|td-01]] à [[TD4|td-04]]) — celle de ce TD : cahier des charges, maquettes, architecture, schéma de données. Phase 2, Environnement de travail ([[TD5|td-05]] à [[TD7|td-07]]) : Git, VS Code, PostgreSQL conteneurisé. Phase 3, Base de données ([[TD8|td-08]]) : création de la base. Phase 4, Backend/frontend alterné sans sécurité ([[TD9|td-09]] à [[TD14|td-14]]) : les premiers [[CRUD|crud]], pilotés par la méthode [[SDD|sdd]] puis [[TDD|tdd]] — spécifier le comportement attendu avant d'écrire le moindre test.

    Phase 5, Bascule sécurité ([[TD15|td-15]] à [[TD18|td-18]]) : authentification [[JWT|jwt]] puis sécurisation par rôle des endpoints déjà développés, toujours en [[SDD|sdd]] puis [[TDD|tdd]]. Phase 6, Backend/frontend alterné sécurisé ([[TD19|td-19]] à [[TD24|td-24]]) : réservations, cache Redis, back-office gérant — cette fois avec l'authentification exigée dès la conception, pas ajoutée après coup. Phase 7, Déploiement et DevOps ([[TD25|td-25]] à [[TD27|td-27]]) : Docker, procédure de déploiement, [[pipeline|pipeline-cicd]] [[CI/CD|cicd]] GitLab. En parallèle de ces sept phases, le [[TD28|td-28]] (Trello) suit et documente l'avancement du projet du premier au dernier jour.

    La stack est fixée dès ce [[TD1|td-01]] et ne changera pas : Next.js/TypeScript/Tailwind côté front, NestJS côté [[API|api]], PostgreSQL comme base relationnelle, Redis pour le cache, Bruno pour les tests d'intégration [[API|api]], GitLab CI pour l'[[intégration continue|cicd]] et Trello pour le suivi de projet — à partir du chapitre suivant s'y ajoute Obsidian pour la documentation. À partir de la phase 4, chaque fonctionnalité métier s'y construit en deux temps : une spécification ([[SDD|sdd]]) qui fige le comportement attendu avant tout code, puis des tests écrits d'abord ([[TDD|tdd]], [[cycle rouge-vert-refactor|tdd]]) qui la traduisent.

    Ce [[TD1|td-01]] est le point de départ concret de tout le fil rouge : vous allez produire les trois premiers livrables — cahier des charges, [[user stories|user-story]], compte rendu de cadrage — qui serviront de référence stable et non contredite à l'ensemble des 27 TD suivants. Avant d'écrire la première ligne de ces documents, les deux chapitres qui suivent posent le cadre : les bonnes pratiques à respecter tout au long du fil rouge, puis l'outil de rédaction (Obsidian) que vous utiliserez dès le chapitre 4.

  • Note de cadrage sur les TD

    Avant de rédiger le premier livrable, quelques repères sur la façon de suivre ce TD — et tous ceux du fil rouge — sur la plateforme Ban7 RemoteLab. Un chapitre se lit dans l'ordre : les paragraphes de texte expliquent le pourquoi, les captures d'écran et extraits de code qui les suivent montrent le comment. Ne recopiez jamais un exemple sans avoir lu le paragraphe qui le précède — c'est lui qui explique la règle métier ou la contrainte que l'exemple ne fait qu'illustrer.

    Certains chapitres du fil rouge comportent des exercices rattachés (QCM, éditeur de code, [[dépôt Git|depot-git]] à rendre selon les cas) : ils se corrigent automatiquement ou par un formateur, puis vous pouvez vous auto-évaluer une fois la note disponible — un retour à prendre au sérieux, pas une formalité. La plateforme propose aussi, sur certains chapitres, un accompagnement par appel vidéo avec un formateur disponible (« Formateur d'astreinte ») : à privilégier si un blocage résiste malgré une relecture attentive, plutôt que d'avancer sur une incompréhension qui se paiera plus tard dans le fil rouge.

    Ce TD est le premier maillon d'une chaîne de 28 TD : chaque TD suivant présuppose que les précédents sont réellement terminés, pas juste survolés — un cahier des charges incomplet au [[TD1|td-01]] se traduira par des spécifications bancales dès le [[TD11|td-11]]. Formateurs et superviseurs suivent votre avancement dans le parcours ; en cas de retard ou de blocage récurrent, mieux vaut solliciter un accompagnement tôt que de laisser le fil rouge se déliter TD après TD.

    Ce TD se déroule en huit étapes qui s'enchaînent volontairement dans cet ordre. Chapitre 3 installe Obsidian, l'outil de rédaction utilisé pour tous les livrables. Chapitres 4 et 5 posent le vocabulaire nécessaire à la suite (HTTP/[[API|api]] puis méthodologie/[[MVP|mvp]]/[[user story|user-story]]). Chapitre 6 crée `cahier-des-charges.md` et y pose les acteurs et le périmètre. Chapitre 7 y ajoute les contraintes techniques et un aperçu du contrat [[API|api]]. Chapitre 8 rédige `user-stories.md`, les six [[user stories|user-story]] du [[MVP|mvp]]. Chapitre 9 ajoute les obligations [[RGPD|rgpd]], [[RGAA|rgaa]] et d'éco-conception. Chapitre 10, enfin, rédige `compte-rendu-cadrage.md` et clôt le TD par une synthèse en anglais de niveau B1.

    Cet ordre n'est pas arbitraire : l'outil (Obsidian) est présenté avant qu'aucun fichier ne soit créé, le vocabulaire technique et méthodologique (chapitres 4-5) avant qu'il ne serve dans le cahier des charges, le périmètre et les acteurs (chapitre 6) avant les détails techniques qui en découlent (chapitre 7), et les [[user stories|user-story]] (chapitre 8) avant les obligations transverses (chapitre 9) qui viennent compléter le même cahier des charges plutôt que le réécrire. Passez maintenant au chapitre suivant pour installer Obsidian.

  • Installer et prendre en main l'essentiel d'Obsidian

    Un peu plus loin dans ce TD, tous les livrables markdown du fil rouge — cahier des charges, [[user stories|user-story]], comptes rendus — se rédigeront avec un même outil : Obsidian, un éditeur markdown local qui traite un dossier comme un « coffre » (vault) et ajoute la navigation par liens internes entre notes. Ce chapitre a un but unique et volontairement minimal : installer Obsidian et savoir faire les quatre gestes qui suffiront pour tout le fil rouge — créer/éditer une note, basculer entre édition et aperçu, retrouver un fichier via le sélecteur rapide, et poser un lien interne [[...]]. Le dossier réel du projet, `docs/conception/`, ne sera ouvert comme coffre qu'après les deux prochains chapitres de vocabulaire : ici, on découvre l'outil sur un coffre d'exemple.

    Rendez-vous sur https://obsidian.md, cliquez sur le bouton de téléchargement correspondant à Windows, puis exécutez l'installeur téléchargé (`Obsidian-x.y.z.exe`) : il s'installe sans étape à choisir et lance Obsidian automatiquement à la fin.

    Au premier lancement, Obsidian propose plusieurs choix : créer un nouveau coffre, ouvrir un dossier existant comme coffre, ou ouvrir le coffre d'exemple fourni (« Open the sample vault »). Un coffre n'est rien de plus qu'un dossier de fichiers `.md` sur le disque — aucun format propriétaire, aucun compte à créer. Pour ce chapitre, cliquez sur « Open the sample vault » : il contient déjà plusieurs notes reliées entre elles, idéal pour s'entraîner sans risquer de modifier quoi que ce soit du projet CoWorkNet.

    Quatre gestes suffisent pour ce TD : `Ctrl+N` (ou clic droit → New note) crée une note, dont le contenu s'enregistre automatiquement à chaque frappe — pas de `Ctrl+S` ; l'icône en haut à droite bascule entre édition et aperçu (reading view) ; `Ctrl+O` ouvre le sélecteur rapide pour changer de fichier sans quitter le clavier ; et taper [[ suivi du nom d'une note crée un lien interne, complété automatiquement par Obsidian. Entraînez-vous sur ces quatre gestes dans le coffre d'exemple, puis fermez-le : `docs/conception/`, le dossier réel du projet, sera ouvert comme coffre définitif un peu plus loin dans ce TD, une fois le vocabulaire des deux chapitres suivants posé.

  • Comment un front et une API communiquent

    Avant d'aller plus loin, un mot de vocabulaire va revenir sans arrêt dans tout le fil rouge : [[API|api]], [[endpoint|endpoint]], front, back. CoWorkNet est en réalité deux programmes séparés qui communiquent par le réseau, plutôt qu'un unique script comme vous en avez peut-être écrit en Python. Le **front** (Next.js) s'exécute dans le navigateur de l'utilisateur et affiche les écrans. Le **back** (NestJS) est le programme serveur qui détient les données et applique les règles métier ; on l'appelle aussi l'[[API|api]], une porte d'entrée que le front peut interroger à distance.

    Le front interroge un [[endpoint|endpoint]] au moyen d'une requête HTTP, composée d'un verbe qui indique l'intention (`GET` pour lire, `POST` pour créer, `PATCH` pour modifier partiellement, `DELETE` pour supprimer) et d'une réponse qui inclut un [[code de statut|code-statut-http]] à trois chiffres : les 2xx signalent un succès (200 OK, 201 Created), les 4xx une erreur du client (400 données invalides, 401 non authentifié, 403 accès refusé, 404 introuvable), les 5xx une erreur du serveur. Les données échangées sont presque toujours au format [[JSON|json]], une notation texte pour des objets/listes très proche des dictionnaires et listes Python.

    Ces quatre verbes correspondent presque toujours aux quatre opérations de base qu'un [[endpoint|endpoint]] effectue sur une donnée : Create (créer, `POST`), Read (lire, `GET`), Update (modifier, `PATCH`), Delete (supprimer, `DELETE`) — leurs initiales forment l'acronyme [[CRUD|crud]], qui reviendra à chaque [[endpoint|endpoint]] du cahier des charges. Un ensemble d'endpoints qui organise ses URL autour de ressources (`/espaces`, `/reservations`) plutôt que d'actions, et qui s'appuie sur ces verbes HTTP et ces codes de statut normalisés, est dit [[REST|rest]] : c'est le style d'[[API|api]] que CoWorkNet adopte de bout en bout.

  • Exprimer un besoin : méthodologie projet, MVP et user story

    Le cahier des charges que vous allez rédiger, dans ce TD, va mentionner un [[MVP|mvp]] et va s'appuyer sur des [[user stories|user-story]] : avant d'écrire la moindre ligne, il faut comprendre ce que ces mots désignent. Un cahier des charges classique décrit un système ; une [[user story|user-story]] décrit un besoin du point de vue de la personne qui l'utilise, pour ne jamais perdre de vue le « pourquoi ». C'est un format popularisé par les méthodes **Agile** de gestion de projet (par opposition à une méthode séquentielle qui fige tout le besoin avant de coder) : le travail y est découpé en petits besoins utilisateur indépendants, priorisés, puis développés par itérations courtes plutôt qu'en un seul bloc.

    Vous verrez aussi, dans ce TD et les suivants, des codes comme « CP5 » ou « REAC » entre parenthèses : ce sont des renvois au référentiel officiel du titre RNCP Concepteur Développeur d'Applications (le REAC), qui découpe les compétences visées en blocs numérotés CP1 à CP11 — un simple repère pour relier chaque livrable du fil rouge à la compétence professionnelle qu'il démontre, sans incidence sur la façon de le rédiger.

  • Cadrage du projet CoWorkNet : acteurs et périmètre du système

    Le [[dépôt Git|depot-git]] de CoWorkNet n'existe pas encore : il sera initialisé au [[TD5|td-05]]. Pour ce TD, créez simplement le dossier `docs/conception/` à la racine de ce qui deviendra le dépôt, à l'emplacement `C:\Users\<vous>\...\coworknet\docs\conception\` sur votre poste — sans commande git à ce stade.

    Ouvrez ensuite Obsidian, fermez le coffre d'exemple exploré au chapitre précédent si besoin, puis cliquez sur « Open folder as vault » et sélectionnez ce dossier `docs/conception/`. Obsidian l'ouvre comme coffre définitif — celui que vous garderez ouvert pour l'ensemble des TD du fil rouge qui viendront compléter ces mêmes fichiers.

    Le panneau de gauche affiche pour l'instant un coffre vide. Créez une nouvelle note (`Ctrl+N`) nommée `cahier-des-charges` : Obsidian l'enregistre automatiquement sous `docs/conception/cahier-des-charges.md`.

    Dans `cahier-des-charges.md`, ouvrez une section « 2. Acteurs du système ». CoWorkNet distingue quatre acteurs : le visiteur (public, non authentifié), le `MEMBRE` (rôle par défaut à l'inscription), le `GERANT` (rattaché à un site précis via `users.site_id`, qui gère uniquement les espaces et réservations de ce site) et l'`ADMIN` (accès complet, tous sites). Formalisez-les dans un tableau à trois colonnes : acteur, description, périmètre d'action.

    Ouvrez ensuite une section « 3. Limites du système », avec deux sous-sections : « 3.1 Dans le périmètre ([[MVP|mvp]]) » qui reprend exactement les six [[user stories|user-story]] du besoin produit (catalogue public filtrable, inscription/connexion sécurisées, réservation d'un créneau, consultation de ses réservations, validation/refus par le `GERANT`, gestion des espaces), et « 3.2 Hors périmètre », qui liste ce qu'il ne faut jamais implémenter sans nouvelle spec produit — c'est cette seconde liste qui protège le [[MVP|mvp]] contre le sur-périmètre.

    Avant de passer au chapitre suivant, ajoutez tout en bas de `cahier-des-charges.md` un premier lien interne vers les deux fichiers que vous rédigerez plus loin dans ce TD — l'occasion de mettre en pratique la syntaxe [[...]] vue au chapitre 3.

    Ces deux liens restent affichés en pointillés tant que `user-stories.md` et `compte-rendu-cadrage.md` n'existent pas : ce n'est pas une erreur, Obsidian « résout » simplement le lien dès que le fichier ciblé est créé — ce qui arrivera au fil des prochains chapitres de ce TD.

  • Contraintes techniques et contrat API : ce que le cahier des charges doit figer

    Un cahier des charges ne se limite pas au besoin fonctionnel : il fige aussi les contraintes techniques que l'équipe s'engage à respecter, pour que les choix d'architecture ne soient pas rediscutés à chaque TD d'implémentation (CP5/CP6). Ajoutez une section « 5. Contraintes techniques imposées » qui reprend, sans les paraphraser, les quatre contraintes non négociables du projet CoWorkNet : architecture multicouche sans [[ORM|orm]], PostgreSQL comme unique source de vérité, authentification [[JWT|jwt]] (jeton de connexion) avec bcrypt (transformation irréversible du mot de passe, détaillés tous deux au [[TD15|td-15]]), et la règle métier de non-chevauchement des réservations actives sur un même espace.

    Ajoutez enfin une section « 4. Messages échangés avec le système » qui donne, sans encore entrer dans le détail des codes de statut (ce sera fait au [[TD11|td-11]]), la liste des huit requêtes [[REST|rest]] prévues. Ce niveau de détail à ce stade sert uniquement à vérifier que chaque [[user story|user-story]] du [[MVP|mvp]] a bien un message associé — vous vérifierez ce point au chapitre suivant en confrontant cette liste aux [[user stories|user-story]].

  • Formaliser les user stories du MVP et leurs critères d'acceptation

    Créez le fichier `docs/conception/user-stories.md`. Une [[user story|user-story]] se rédige toujours selon le même format fermé : « En tant que <acteur>, je veux <action>, afin de <valeur> », suivi d'une liste de critères d'acceptation vérifiables — ce sont ces critères qui deviendront, à partir du [[TD12|td-12]], la base directe des tests [[TDD|tdd]] écrits en rouge avant le code. Rédigez les six [[user stories|user-story]] US1 à US6 dans cet ordre, une par une, chacune avec ses critères et l'acteur précis qui la porte (CP5).

    US4 mérite une attention particulière : c'est la seule [[user story|user-story]] qui introduit une règle d'autorisation scoping par site, celle qui sera vérifiée en base au [[TD19|td-19]]/[[TD20|td-20]] (un `GERANT` ne voit et n'arbitre que les réservations de son propre site). Rédigez-la en précisant explicitement le code 403 attendu pour un `GERANT` d'un autre site, et le fait qu'une réservation déjà traitée ne peut plus être re-validée.

    US5 (gestion des espaces) demande une précaution inverse : ne pas anticiper une sécurité qui n'arrive que plus tard dans le fil rouge. Les routes `/espaces` resteront accessibles sans authentification jusqu'au [[TD17|td-17]] — c'est un choix pédagogique assumé pour isoler le [[CRUD|crud]] du sujet de l'autorisation, à ne surtout pas « corriger » de vous-même dans ce TD.

  • RGPD, RGAA et éco-conception : les intégrer dès la conception

    Revenez dans `cahier-des-charges.md` pour ajouter les sections 6, 7 et 8, qui couvrent les obligations transverses attendues au CP5 : [[RGPD|rgpd]] (données à caractère personnel traitées par CoWorkNet), [[RGAA|rgaa]] (accessibilité du futur front) et éco-conception. L'objectif est qu'un développeur qui implémentera un TD plus tard n'ait jamais à se demander « est-ce que j'ai le droit de faire ça ? » : la réponse doit déjà être écrite ici.

    Pour la section 6 ([[RGPD|rgpd]]), listez au minimum quatre principes appliqués à CoWorkNet : minimisation des données collectées (email, mot de passe, rôle, site — rien de plus tant qu'aucune fonctionnalité ne le justifie), sécurité du stockage des mots de passe (bcrypt coût 12, jamais en clair), finalité et durée de conservation, et droit d'accès/rectification du `MEMBRE` à ses propres données.

    Pour la section 7 ([[RGAA|rgaa]]), retenez quatre engagements vérifiables dès les maquettes du [[TD2|td-02]] plutôt qu'une liste de bonnes intentions générales : contraste des couleurs, alternatives textuelles, navigation au clavier et structure sémantique. Ces quatre points seront directement contrôlables sur les maquettes produites au [[TD2|td-02]], avant même que la première ligne du front ne soit écrite.

    Pour la section 8 (éco-conception), retenez deux règles directement actionnables dans l'architecture retenue plutôt qu'une liste générique : éviter le sur-fetching (chaque [[endpoint|endpoint]] renvoie exactement les champs nécessaires à l'écran qui le consomme) et mettre en cache les lectures répétitives — en anticipant explicitement le cache Redis introduit au [[TD23|td-23]], avec un TTL (durée de vie d'une donnée en cache avant qu'elle soit recalculée, de l'anglais « time to live ») de 60 secondes, qui réduit la charge serveur et donc la consommation énergétique associée.

  • Compte rendu de cadrage et synthèse bilingue (anglais B1)

    Créez enfin `docs/conception/compte-rendu-cadrage.md`, qui trace les décisions actées lors de cet atelier de cadrage — un compte rendu court, pas une redite du cahier des charges. Listez notamment le séquencement de la sécurité (routes `/espaces` ouvertes jusqu'au [[TD17|td-17]], routes `/reservations` protégées dès leur conception au [[TD19|td-19]]/[[TD20|td-20]]) et le fait que [[RGPD|rgpd]]/[[RGAA|rgaa]]/éco-conception sont actés dès ce TD pour être respectés par construction plus tard.

    Terminez par une section « Short summary in English (B1 level) », exigée au CP5. Le niveau B1 CECRL signifie : phrases simples et claires, temps courants (présent, prétérit), vocabulaire concret sans jargon technique inutile, un seul sujet par phrase. Résumez en cinq à sept phrases ce qu'est CoWorkNet, qui sont ses utilisateurs, ce qui est dans le [[MVP|mvp]], et ce qui ne l'est pas — sans traduire mot à mot le français, mais en reformulant simplement.

    À l'issue de ce TD, vous disposez de trois livrables markdown stables sous `docs/conception/` : `cahier-des-charges.md`, `user-stories.md` et `compte-rendu-cadrage.md`. Ils ne sont pas encore versionnés dans Git — le dépôt CoWorkNet sera initialisé au TD suivant, qui posera l'architecture technique détaillée et la structure du [[monorepo|monorepo]] (`apps/api`, `apps/web`, ports dédiés) sur la base exacte de ce que vous venez de figer ici.

Maquetter les écrans de CoWorkNet

  • Méthode de maquettage retenue pour CoWorkNet

    Ce TD produit trois livrables markdown sous `docs/conception/` : `maquettes-ecrans.md` (six écrans A à F, décrits en wireframe ASCII), `flux-navigation.md` (l'enchaînement de ces écrans en deux parcours, visiteur/MEMBRE et GERANT/ADMIN, avec une checklist de couverture des [[user stories|user-story]]) et `regles-rgaa-ux.md` (les règles [[RGAA|rgaa]] et ergonomiques appliquées à chaque écran). À la fin de la séance, l'enchaînement complet catalogue -> réservation -> back-office gérant est maquetté et validé, sans qu'aucune ligne de code d'implémentation n'ait encore été écrite (CP5).

    Chaque écran de ce TD (A à F) suit donc la même structure en trois temps : un wireframe ASCII, un tableau des éléments interactifs avec leur rôle et leur comportement, puis un rattachement explicite à la ou aux [[user stories|user-story]] du [[TD01|td-01]] qu'il couvre. Les règles transverses de [[RGAA|rgaa]] et d'ergonomie ne sont pas répétées écran par écran : elles sont regroupées dans un document dédié, traité au dernier chapitre de ce TD.

  • Écrans du parcours visiteur et membre (US1, US6, US2, US3)

    Ce chapitre maquette les quatre écrans du parcours public puis MEMBRE, dans l'ordre exact où un utilisateur les rencontre : le catalogue (US1, accessible sans compte), l'inscription et la connexion (US6), la fiche d'un espace avec son formulaire de réservation (US2), puis la liste des réservations de l'utilisateur connecté (US3). Chaque wireframe reprend exactement le contrat [[API|api]] stabilisé au [[TD01|td-01]] : aucun champ affiché ne sort de ce contrat.

    Le catalogue n'affiche aucun bouton « Filtrer » : les deux filtres (site, date) déclenchent immédiatement une nouvelle requête `GET /espaces?siteId=&date=` à leur changement, sans action supplémentaire de l'utilisateur. Cette absence de bouton de validation est un choix ergonomique délibéré, cohérent avec la convention retenue pour tout le projet CoWorkNet : une action qui ne crée pas de nouvelle ressource se déclenche sans confirmation explicite.

    À l'inverse du catalogue, l'inscription et la connexion conservent un bouton de soumission explicite (« Créer mon compte », « Se connecter ») : ces deux formulaires créent une ressource — un compte ou une session — qui n'existe pas encore côté serveur avant le clic. C'est la même règle qui justifiera, au chapitre suivant, le bouton « Créer l'espace » de l'écran F : seule une création initiale garde un bouton explicite, jamais l'édition d'une entité déjà existante.

    La fiche espace (écran C) est accessible uniquement à un MEMBRE connecté : un visiteur qui clique sur « Réserver » depuis le catalogue est d'abord redirigé vers l'écran B, puis ramené automatiquement sur cette même fiche après connexion. Le message d'erreur de chevauchement (400) reste affiché au-dessus du bouton de confirmation plutôt que sur une page d'erreur séparée, pour que l'utilisateur puisse corriger son créneau sans perdre sa saisie.

    L'écran D clôt ce premier parcours : il n'affiche jamais que les réservations de l'utilisateur authentifié, garantie posée côté back-end par la clause `user_id` de la requête (US3), jamais par un filtre côté client. Le statut de chaque réservation est toujours porté par un texte lisible en plus de la couleur du badge, point détaillé au chapitre [[RGAA|rgaa]]/ergonomie de ce TD.

  • Écrans back-office gérant (US4, US5)

    Ce chapitre maquette les deux écrans réservés au rôle GERANT (son site) et à l'ADMIN (tous sites) : l'arbitrage des réservations en attente (US4) et la gestion du catalogue d'espaces (US5). Ces deux écrans forment un espace applicatif distinct du catalogue public, avec sa propre barre de navigation, jamais accessible à un MEMBRE simple.

    Le titre de l'écran E annonce déjà le scoping par site du GERANT (« Réservations en attente — Site : Lyon Part-Dieu »), alors même que la sécurité par rôle n'est implémentée qu'au [[TD17|td-17]]/[[TD18|td-18]]/[[TD19|td-19]]/[[TD20|td-20]] : la maquette anticipe la règle métier US4 pour que l'écran n'ait pas à être redessiné une fois la sécurité ajoutée. Les boutons « Confirmer » et « Refuser » agissent immédiatement au clic, sans formulaire ni bouton « Enregistrer » supplémentaire.

    Sur l'écran F, seule la boîte de dialogue « Ajouter un espace » conserve un bouton de soumission explicite (US5, création initiale) : le champ « Nom » d'un espace déjà existant se sauvegarde automatiquement à la perte de focus, et le bouton « Désactiver » agit immédiatement, sans jamais supprimer l'historique des réservations liées. Ce chapitre termine la maquette des six écrans du fil rouge ; le chapitre suivant les relie entre eux.

  • Enchaînement des écrans dans flux-navigation.md

    Créez à présent `docs/conception/flux-navigation.md`, qui relie les six écrans A à F en deux parcours distincts et vérifie, ligne par ligne, que chaque critère d'acceptation du [[TD01|td-01]] est bien matérialisé par un écran. Ce document ne redessine aucun écran : il se limite aux transitions entre eux.

    Le premier diagramme trace le parcours visiteur puis MEMBRE (catalogue -> connexion -> réservation -> mes réservations), le second le parcours GERANT/ADMIN (connexion -> back-office réservations <-> back-office espaces). Formalisez-les dans une section « Vue d'ensemble », puis rédigez sous chaque diagramme la liste numérotée des étapes qui le détaille, comme dans les extraits suivants.

    Le parcours 1 introduit une règle de navigation importante à ne pas manquer dans votre rédaction : la mémorisation côté client de l'espace ciblé avant redirection vers la connexion, pour que le MEMBRE retrouve sa sélection après authentification plutôt que de tout recommencer depuis le catalogue. Rédigez le parcours 2 (GERANT/ADMIN) selon la même structure en quatre à cinq étapes numérotées.

    Terminez `flux-navigation.md` par une section « Checklist de validation » : un tableau à trois colonnes ([[user story|user-story]], critère d'acceptation clé, écran qui le matérialise) qui reprend chaque critère du [[TD01|td-01]] et le rattache explicitement à un écran. C'est cette checklist, pas une simple relecture visuelle, qui constitue la validation attendue des maquettes avant le [[TD03|td-03]] (définition de l'architecture logicielle).

    Une fois ce tableau complet, vérifiez qu'aucune ligne n'est vide : un critère d'acceptation du [[TD01|td-01]] sans écran associé signale une maquette incomplète, à corriger avant de passer au chapitre suivant sur les règles [[RGAA|rgaa]] et ergonomiques transverses.

  • Règles RGAA et ergonomie transverses, et validation du TD

    Créez enfin `docs/conception/regles-rgaa-ux.md`, qui regroupe les règles transverses valables sur les six écrans : accessibilité [[RGAA|rgaa]] (contraste, navigation clavier, libellés explicites, erreurs non portées par la seule couleur) et règles ergonomiques UX (point d'entrée unique par fonctionnalité, feedback immédiat, distinction création/édition). Ce chapitre mobilise directement les points REAC de ce TD : outil de maquettage, enchaînement des écrans, accessibilité [[RGAA|rgaa]], règles ergonomiques UX (CP5).

    Rédigez ensuite la section « Règles ergonomiques (UX) » selon la même numérotation, en reliant chaque règle à un ou plusieurs écrans précis plutôt qu'à une affirmation générale — c'est ce lien explicite qui rend la règle vérifiable sur un wireframe, sans attendre l'implémentation front.

    La règle 3 des règles ergonomiques distingue explicitement création et édition : seuls les formulaires qui créent une ressource inexistante (inscription, connexion, réservation, ajout d'espace) gardent un bouton de soumission ; tout champ qui modifie une ressource déjà existante se sauvegarde automatiquement à la perte de focus. Terminez le document par une section « Validation de ce TD » qui relie ces règles à la checklist du chapitre précédent.

    À l'issue de ce TD, les trois documents `maquettes-ecrans.md`, `flux-navigation.md` et `regles-rgaa-ux.md` forment un ensemble stable sous `docs/conception/`, non versionné dans Git (le dépôt sera initialisé au [[TD5|td-05]]) : l'enchaînement catalogue -> réservation -> back-office gérant est entièrement maquetté et validé avant toute implémentation. Le TD suivant s'appuiera sur ces six écrans pour définir l'architecture logicielle de CoWorkNet.

Définir l'architecture logicielle de CoWorkNet

  • Schéma d'architecture multicouche et rôle de chaque couche

    Ce TD produit un unique livrable markdown, `docs/conception/architecture-logicielle.md`, qui documente et valide le schéma d'architecture multicouche de CoWorkNet : trois couches côté [[API|api]] (`controller -> service -> repository`, sans [[ORM|orm]] — une bibliothèque qui traduirait des objets du code en requêtes SQL à votre place, cf. chapitre suivant), un front Next.js qui consomme l'[[API|api]] en [[REST|rest]]/[[JSON|json]], PostgreSQL comme source de vérité unique, et un cache Redis introduit plus tard ([[TD23|td-23]]) en lecture seule. À la fin de la séance, ce schéma est validé selon quatre critères explicites (dernier chapitre de ce TD) et sert de référence stable pour l'initialisation du dépôt CoWorkNet au [[TD05|td-05]] (CP6).

    Ce schéma reprend à la lettre l'architecture imposée par la spec produit : trois couches côté [[API|api]], un front qui consomme l'[[API|api]] en [[REST|rest]]/[[JSON|json]], PostgreSQL comme source de vérité unique, Redis introduit seulement au [[TD23|td-23]]. Reproduisez ces ports exacts : 4000 ([[api|api]]), 4100 (web), 5544 (postgres, service docker `'db'`), 6390 (redis, service docker `'cache'`) — réservés à CoWorkNet pour ne jamais entrer en conflit avec ceux de la plateforme ecole (3100/3101/5433/6379). Documentez ensuite, sous ce schéma, le rôle et la frontière de responsabilité de chacune des cinq couches, dans l'ordre où une requête les traverse.

    Cette séparation en cinq couches (§2 approfondi dans le cours magistral, chapitre « Sécuriser une architecture répartie selon le rôle de chaque couche ») n'est pas décorative : elle sera vérifiée en revue de code dès le [[TD06|td-06]], et c'est elle qui rend possible le retrofit de sécurité prévu au [[TD17|td-17]]/[[TD18|td-18]] sans réécrire ni le `controller` ni le `repository`, seulement la couche `service`.

  • Absence volontaire d'ORM

    Avant de justifier une absence, encore faut-il savoir de quoi elle vous prive : un [[ORM|orm]] ([[Object-Relational Mapper|orm]]) est une bibliothèque qui traduit des objets du code (une classe `Espace`) en requêtes SQL, et inversement — Prisma ou TypeORM en sont des exemples en TypeScript. Avec un [[ORM|orm]], on manipule des méthodes (`espace.save()`) sans écrire le SQL sous-jacent ; ce sera l'approche d'ecole elle-même, mais pas celle retenue ici.

    Ajoutez maintenant, sous le schéma, une section « Pourquoi aucun [[ORM|orm]] » qui justifie explicitement ce choix pour CoWorkNet : ce n'est pas une contrainte technique subie, mais une décision pédagogique délibérée pour ce projet précis (CP6, CP7, CP8).

    Ce paragraphe doit rester spécifique à CoWorkNet et à sa décision propre : ne le confondez pas avec un discours général « [[ORM|orm]] vs SQL manuel ». C'est justement l'écriture manuelle de chaque requête paramétrée, dès le [[TD12|td-12]] ([[CRUD|crud]] espaces en [[TDD|tdd]]), puis de la transaction `SERIALIZABLE` anti-chevauchement au [[TD19|td-19]]/[[TD20|td-20]], qui travaille concrètement les compétences CP7, CP8 et CP9 visées par ce fil rouge.

  • Stratégie de sécurité par couche et indicateurs DICP

    Ajoutez une section « Stratégie de sécurité par couche » qui applique le principe de défense en profondeur : aucune couche ne fait confiance à celle qui l'appelle, chacune revalide ce dont elle a besoin. Formalisez-la dans un tableau à trois colonnes (couche, responsabilité de sécurité, exemple concret CoWorkNet), comme ci-dessous.

    Cette table sera la référence de revue à chaque retrofit de sécurité du fil rouge ([[TD15|td-15]] à [[TD20|td-20]]). Rattachez-lui ensuite les indicateurs Disponibilité, Intégrité, Confidentialité, Preuve ([[DICP|dicp]]), déclinés concrètement sur le domaine de CoWorkNet plutôt que de façon abstraite, dans un second tableau.

    La colonne « Preuve » reste volontairement limitée à l'horodatage de création (`created_at`) pour ce [[MVP|mvp]] : un journal d'audit applicatif complet (qui a validé ou refusé quelle réservation, et quand) est une extension hors du périmètre du cahier des charges du [[TD01|td-01]], à ne pas ajouter ici sans nouvelle spec produit.

  • Vulnérabilités des architectures multicouches et patrons de sécurité

    Une architecture multicouche ne protège pas automatiquement contre les vulnérabilités classiques : elle donne seulement un endroit précis où placer chaque contre-mesure. Identifiez, dans une liste numérotée, quatre vulnérabilités propres à CoWorkNet et leur contre-mesure exacte, comme ci-dessous.

    Terminez cette section par un court paragraphe « Patrons de sécurité retenus » qui résume ces quatre contre-mesures en trois patrons transverses, réutilisables sur tout le reste du fil rouge plutôt que propres à une seule route.

    Retenez en particulier le fail-safe default : contrairement à `/espaces` (ouvert en écriture jusqu'au [[TD17|td-17]]/[[TD18|td-18]], retrofit assumé), `/reservations` exige un [[JWT|jwt]] valide dès sa toute première implémentation au [[TD19|td-19]]/[[TD20|td-20]] — ce n'est jamais un correctif ajouté après coup sur cette ressource-là.

  • Éco-conception et validation du schéma d'architecture

    Ajoutez une dernière section « Besoins d'éco-conception », limitée pour ce [[MVP|mvp]] à des choix qui réduisent la consommation de ressources sans complexifier l'architecture retenue plus haut : pas de sur-fetching, cache de lecture ciblé, transactions courtes, et périmètre volontairement restreint.

    Concluez enfin le document par une section « Validation du schéma d'architecture » : une checklist en quatre points, vérifiable sans ambiguïté, qui décide si ce schéma peut servir de référence stable pour le reste du fil rouge.

    Une fois ces quatre points vérifiés, `docs/conception/architecture-logicielle.md` est le schéma d'architecture multicouche validé attendu par ce TD. Il sert de référence stable pour l'initialisation du dépôt CoWorkNet au [[TD05|td-05]] (structure de dossiers `apps/api` et `apps/web`) et pour tous les TD d'implémentation suivants, sans être réécrit.

Concevoir le schéma de données de CoWorkNet

  • Bases d'une base de données relationnelle

    Ce TD emploie un vocabulaire de base de données relationnelle. Une base de données relationnelle range les données dans des **tables** : chaque table a des **colonnes** (les attributs, ex. `email`, `capacity`) et des **lignes** (un enregistrement, une ligne par espace ou par utilisateur) — proche d'une liste de dictionnaires Python où chaque dictionnaire aurait exactement les mêmes clés. Une [[clé primaire|cle-primaire]] identifie une ligne de façon unique dans sa table (ici `id`) ; une [[clé étrangère|cle-etrangere]] est une colonne d'une table qui référence la [[clé primaire|cle-primaire]] d'une autre table, pour matérialiser un lien entre deux lignes de tables différentes (ex. `espaces.site_id` référence `sites.id`).

    Ce schéma sera formalisé selon la [[méthode Merise|methode-merise]], une méthode française d'analyse en trois niveaux de plus en plus concrets : le **MCD** (entités et associations, sans détail technique), le **MLD** (chaque association devient une [[clé étrangère|cle-etrangere]] ou une table de jonction) et le **MPD** (le script SQL exécutable). Chaque association porte une [[cardinalité|cardinalite]] (min,max) de chaque côté, qui borne le nombre de liens possibles — (0,1) signifie « zéro ou un », (1,n) signifie « un ou plusieurs ». Combiner deux tables via leur [[clé étrangère|cle-etrangere]] commune, pour retrouver par exemple l'utilisateur d'une réservation, s'appelle une [[jointure|jointure]] (`JOIN`).

    Le chapitre suivant applique directement ce vocabulaire au MCD de CoWorkNet.

  • MCD — entités et associations

    Ce TD produit un unique livrable markdown, `docs/conception/schema-donnees.md`, qui formalise et valide le modèle de données de CoWorkNet en trois niveaux [[Merise|methode-merise]] successifs : un MCD (entités et associations), un MLD (passage au relationnel) et un MPD (script SQL PostgreSQL exécutable). Le modèle reprend à la lettre les quatre tables `users`, `sites`, `espaces`, `reservations` fixées par le cahier des charges ([[TD01|td-01]]), sans en ajouter ni en retirer. À la fin de la séance, ce schéma est validé selon une checklist en quatre points (dernier chapitre) et devient la référence stable pour l'initialisation du dépôt CoWorkNet au [[TD05|td-05]] (CP7).

    Portez une attention particulière à la [[cardinalité|cardinalite]] (0,1) entre `SITES` et `USERS` : elle traduit fidèlement `users.site_id` nullable de la spec produit, puisque seuls les `GERANT` sont rattachés à un site (un `MEMBRE` ou un `ADMIN` a `site_id` = `NULL`). Les trois autres associations sont toutes de [[cardinalité|cardinalite]] maximale 1 côté « fils », ce qui signifie qu'aucune n'a besoin d'une table de [[jointure|jointure]] au MLD : chaque association se traduira directement par une [[clé étrangère|cle-etrangere]] portée par l'entité fille (`espaces.site_id`, `reservations.espace_id`, `reservations.user_id`).

  • Dictionnaire de données et règles de nommage

    Avant tout passage au MLD, documentez chaque attribut des quatre entités dans un dictionnaire de données : nom, type [[Merise|methode-merise]]/SQL, nullabilité et contrainte d'intégrité. Ce dictionnaire est la condition préalable du CP7 pour garantir qu'aucune colonne du cahier des charges n'est oubliée ni déformée au moment d'écrire le SQL.

    Fixez ensuite, sous ce dictionnaire, les règles de nommage qui s'appliqueront à toutes les tables du fil rouge, pas seulement à celles de ce TD : `snake_case`, [[clé primaire|cle-primaire]] `uuid` nommée `id` générée côté PostgreSQL, [[clé étrangère|cle-etrangere]] nommée `<table_singulier>_id`, horodatage `created_at` en `timestamptz`, et contrainte `CHECK` systématique sur toute colonne à valeurs fermées (`role`, `status`).

    Notez explicitement, à la suite de ces règles, que la règle métier de non-chevauchement des réservations actives (cahier des charges §5) reste absente de ce MPD : elle n'est pas exprimable par un simple `CHECK` de colonne et sera implémentée par une transaction `SERIALIZABLE` au [[TD19|td-19]]/[[TD20|td-20]]. Ce TD de conception ne l'anticipe pas — un réflexe de séquencement déjà pratiqué au [[TD03|td-03]] pour la sécurité par phase.

  • MLD — passage au modèle relationnel

    Traduisez maintenant le MCD en MLD : chaque entité devient une relation [[Merise|methode-merise]], et chaque association dont la [[cardinalité|cardinalite]] maximale côté fils est 1 devient une [[clé étrangère|cle-etrangere]] portée par ce fils, préfixée par #. Comme aucune des quatre associations n'est porteuse d'attribut ni de [[cardinalité|cardinalite]] maximale N-N des deux côtés, aucune table de [[jointure|jointure]] n'est nécessaire dans ce modèle.

    Vérifiez que ce MLD reproduit exactement les quatre tables et leurs colonnes telles que fixées par le cahier des charges, sans ajout ni omission : c'est cette fidélité stricte, et non une optimisation relationnelle supplémentaire, qui est attendue à ce stade de conception (CP7).

  • MPD — script SQL PostgreSQL

    Traduisez enfin le MLD en DDL PostgreSQL exécutable : c'est le MPD. Respectez l'ordre de création imposé par les dépendances de [[clé étrangère|cle-etrangere]] (`sites` avant `users` et `espaces`, `users` et `espaces` avant `reservations`), activez l'extension `pgcrypto` pour générer les `uuid` côté base via `gen_random_uuid()`, et ajoutez un index sur chaque colonne de [[clé étrangère|cle-etrangere]] la plus interrogée par le contrat [[API|api]] stabilisé (`GET /espaces?siteId=`, `GET /reservations/me`, validation par site du `GERANT`).

    Ce script sera exécuté tel quel lors de l'initialisation du dépôt CoWorkNet au [[TD05|td-05]], sans modification de colonne ou de contrainte par rapport à ce document : chaque `CHECK` sur `role` et `status` double la validation applicative future, conformément à l'indicateur Intégrité déjà posé au [[TD03|td-03]] (`architecture-logicielle.md` §4).

  • RGPD sur les données individuelles et sécurité du SGBD

    Le cahier des charges (§6) posait déjà le principe de minimisation et de sécurité du stockage ; déclinez-le maintenant au niveau du schéma physique, colonne par colonne, dans une section « [[RGPD|rgpd]] sur les données individuelles » (CP7).

    Ajoutez ensuite une section « Sécurité du SGBD » qui anticipe, indépendamment de la sécurité applicative des [[TD15|td-15]] à [[TD20|td-20]], les besoins propres au serveur PostgreSQL lui-même : un compte applicatif dédié `coworknet_app`, distinct du superuser `postgres` réservé à l'administration, avec des privilèges strictement limités aux quatre tables du domaine.

    `coworknet_app` ne dispose jamais de droit `CREATE`/`DROP`/`ALTER`, seulement `SELECT`/`INSERT`/`UPDATE`/`DELETE` : principe de moindre privilège déjà posé à l'architecture au [[TD03|td-03]]. Son mot de passe de développement est stocké en clair dans `.env.example`, avec la mention explicite que ce n'est pas une pratique acceptable en production — conformément à la convention du dépôt CoWorkNet.

  • Validation du schéma de données

    Concluez enfin le document par une section « Validation du schéma de données » : une checklist en quatre points, vérifiable sans ambiguïté, qui décide si ce schéma peut servir de référence stable pour le reste du fil rouge.

    Une fois ces quatre points vérifiés, `docs/conception/schema-donnees.md` est le MCD/MLD/MPD validé attendu par ce TD. Il sert de référence stable pour l'initialisation du dépôt CoWorkNet au [[TD05|td-05]] (structure `apps/api` et `apps/web`, premier [[commit|commit]] local) et pour tous les TD d'implémentation SQL suivants, sans être réécrit.

Installer Git et initialiser le dépôt de CoWorkNet

  • Git : à quoi sert un outil de gestion de versions

    Avant la première commande git, un mot sur ce qu'est réellement un outil de gestion de versions et pourquoi CoWorkNet en a besoin. Contrairement à un dossier que l'on sauvegarde par-dessus (comme un simple Ctrl+S sur un script Python), Git conserve un **historique complet** : chaque [[commit|commit]] est un instantané complet du projet à un instant donné, accompagné d'un message qui explique ce qui a changé et pourquoi. On peut ainsi revenir à n'importe quel instantané antérieur ou comprendre qui a changé quoi et quand — un besoin qui devient vite indispensable dès qu'un projet grossit sur plusieurs semaines, comme ce fil rouge.

    Un exemple concret, sur un projet quelconque (pas encore CoWorkNet, dont l'historique commence au chapitre suivant) :

    Entre le moment où vous modifiez un fichier et le moment où ce changement devient un [[commit|commit]], Git distingue trois zones : le [[working directory|working-directory]] (les fichiers tels qu'ils sont sur le disque), la [[staging area|staging-area]] ou **index** (une zone d'attente où `git add` place exactement les fichiers à inclure dans le prochain [[commit|commit]]) et le [[commit|commit]] lui-même, créé par `git commit`. Cette étape intermédiaire permet de composer un [[commit|commit]] précis plutôt que de tout figer d'un bloc.

    Le chapitre suivant applique directement ce vocabulaire à l'initialisation du dépôt CoWorkNet avec `git init`.

  • Initialiser le dépôt Git local

    Ce [[TD05|td-05]] initialise le [[dépôt Git|depot-git]] local de CoWorkNet (CP1) : à la fin de la séance, le dossier racine du projet — déjà présent sur votre disque et contenant `docs/conception/`, les huit livrables markdown des [[TD01|td-01]] à [[TD04|td-04]] — sera un [[dépôt Git|depot-git]] suivi, avec un premier [[commit|commit]] versionnant ces livrables aux côtés d'un `.gitignore` et d'un `README.md` nouvellement créés. Ce dépôt reste strictement local : conformément à la convention du projet, aucun remote n'est ajouté et rien n'est jamais poussé (`push`).

    `git init` crée le sous-dossier caché `.git/` à la racine du projet : il contient tout l'historique (objects, refs, config). Rien n'est envoyé nulle part, l'opération est purement locale au poste. Avant tout [[commit|commit]], il faut identifier l'auteur : par défaut Git utilise la configuration globale de la machine (`~/.gitconfig`), partagée par tous les dépôts ouverts sur ce poste — y compris ceux d'ecole ou d'un autre projet client.

    L'option `--local` (et non `--global`) écrit cette identité uniquement dans `.git/config`, propre à ce dépôt CoWorkNet, sans toucher à la configuration globale utilisée par les autres dépôts du poste. C'est le geste professionnel qui évite qu'un [[commit|commit]] sur ce projet pédagogique ne s'attribue par erreur à une autre identité, ou inversement qu'il écrase la configuration d'un collègue sur un poste partagé : un exemple concret de « ne pas impacter les autres utilisateurs » d'un outil de gestion de versions, point du référentiel travaillé par ce TD (CP1).

    C'est l'erreur que Git renvoie si l'on tente un [[commit|commit]] avant d'avoir configuré une identité, locale ou globale : `fatal: unable to auto-detect email address`. Le message suggère lui-même `--global`, l'option qu'on évite précisément ici — un piège fréquent pour qui suit un tutoriel générique sans relire l'option proposée.

  • Exclure les fichiers indésirables avec .gitignore

    Avant le premier `git add`, il faut écarter du suivi Git tout ce qui ne doit jamais être commité. Deux catégories de risques justifient un `.gitignore` dès l'initialisation. D'une part des fichiers volumineux et régénérables (dépendances pnpm, artefacts de build) qui polluent l'historique. D'autre part, plus critique, des fichiers contenant des secrets : le futur `.env` de `apps/api` portera le mot de passe de développement du compte `coworknet_app` documenté au [[TD04|td-04]]. Committer un `.env` par erreur est la vulnérabilité la plus fréquente des outils de gestion de versions collaboratifs (CP1) : un secret entré dans l'historique reste récupérable via `git log` même après suppression.

    `node_modules/` et `dist/` n'existent pas encore (`apps/api` et `apps/web` seront initialisés à un TD suivant), mais les exclure dès maintenant évite d'y penser plus tard, une fois le [[monorepo|monorepo]] pnpm en place. `.next/` anticipe le build de `apps/web`. `.env` est exclu par principe même si aucun fichier de ce nom n'existe encore : seul `.env.example` (valeurs de dev en clair, avec la mention explicite que ce n'est pas pour la production, cf. [[TD04|td-04]] §7) sera versionné plus tard.

    Piège fréquent : `.gitignore` n'agit que sur les fichiers non encore suivis. Un fichier déjà ajouté à l'index avant d'être ignoré — ici `.env`, ajouté par erreur avant que la règle n'existe — reste suivi malgré `.gitignore` : il faut le désindexer explicitement avec `git rm --cached <fichier>` pour que la règle reprenne effet, l'historique gardant malgré tout une trace du secret dans les anciens commits.

  • Documenter le dépôt avec un README

    Un `README.md` à la racine est la première chose que lit quiconque ouvre le dépôt : même strictement local à ce projet pédagogique, on applique la même discipline que sur un dépôt partagé sur une plateforme collaborative — présenter le projet en quelques lignes avant de plonger dans le code, plutôt que de laisser un nouvel arrivant deviner la stack ou la structure du dossier.

    Ce `README` reste volontairement court : il annonce le pitch, rappelle que le dépôt est local, et esquisse la structure attendue (`apps/api`, `apps/web` n'existent pas encore, ils seront créés aux TD suivants). Il sera complété au fur et à mesure, sans jamais devenir une documentation exhaustive — celle-ci vit dans `docs/conception/` et les futurs cours magistraux référencés par chaque TD.

  • Versionner les livrables de conception et premier commit

    Le dossier de travail contient maintenant `docs/conception/` (huit fichiers des [[TD01|td-01]]-04), `.gitignore` et `README.md`. `git status` permet de vérifier ce que Git voit avant tout `git add` : puisqu'il n'y a encore aucun [[commit|commit]], tous ces fichiers apparaissent comme « `Untracked files` ».

    `git add` liste explicitement chaque fichier et dossier à indexer plutôt qu'un générique `git add .` : sur ce premier [[commit|commit]], on veut être certain de ne rien ajouter d'autre que ce qui est attendu. Le message de [[commit|commit]] suit la convention du dépôt CoWorkNet, appliquée à chaque TD et conservée pour tout le fil rouge : préfixer par TD{n}: , ici [[TD5|td-05]]: — une convention simplifiée propre à ce projet, à ne pas confondre avec le format Conventional Commits `type(scope): description` utilisé dans d'autres contextes professionnels. Cette convention de [[commit|commit]] rejoint les bonnes pratiques collaboratives du référentiel (CP1) : un historique lisible, où chaque [[commit|commit]] est identifiable au TD qui l'a produit, facilite la relecture par un tiers sans lui faire deviner le contexte.

    `working tree clean` confirme qu'aucun fichier suivi n'a de modification en attente, et `git log --oneline` montre le [[commit|commit]] unique attendu. Le dépôt CoWorkNet est désormais initialisé : les livrables des [[TD01|td-01]]-04 sont versionnés, avec un `.gitignore` prêt à recevoir les futurs `apps/api` et `apps/web` sans jamais y exposer de secret. Ce premier [[commit|commit]] sert de socle stable pour le TD suivant, qui installera la structure du [[monorepo|monorepo]] pnpm (`apps/api`, `apps/web`).

Installer et configurer Visual Studio Code pour CoWorkNet

  • Installer Visual Studio Code et ouvrir le dépôt CoWorkNet

    Ce [[TD06|td-06]] installe et configure Visual Studio Code (VSCode 1.101, version fixée pour ce TD) pour travailler sur CoWorkNet (CP1). À la fin de la séance, le dépôt contiendra un dossier `.vscode/` versionné (`settings.json`, `extensions.json`) qui active formatage et correction automatiques du code à chaque Ctrl+S, sans action manuelle. Lire VSCode et ses extensions suppose l'anglais technique du Marketplace, un exercice de compréhension écrite de niveau B1 (CECRL) mobilisé par CP1.

    L'installation par OS est détaillée dans le cours magistral associé : ce TD la suppose acquise, avec `code` disponible dans le `PATH` (dossiers où le système cherche un programme tapé en terminal). Reste à ouvrir le dépôt CoWorkNet du [[TD05|td-05]] comme workspace, depuis sa racine.

    `code .` ouvre VSCode avec la racine du dépôt comme dossier de travail (single-folder workspace) : l'explorateur affiche `docs/conception/`, `.gitignore`, `README.md`, ainsi que le dossier caché `.git/` créé au [[TD05|td-05]].

    Si `code .` renvoie plutôt `command not found` au lieu d'ouvrir VSCode, c'est que `code` n'a pas été ajouté au `PATH` pendant l'installation (fréquent sur macOS, contrairement à Windows où l'installeur le propose par défaut) :

    La palette de commandes de VSCode (`Ctrl+Shift+P` / `Cmd+Shift+P`) propose `Shell Command: Install 'code' command in PATH` : après l'avoir lancée, il faut ouvrir un nouveau terminal, la modification du `PATH` ne s'appliquant pas à celui déjà ouvert.

    Ce même dossier racine accueillera plus tard `apps/api` et `apps/web` du [[monorepo|monorepo]] pnpm — le workspace VSCode n'a pas besoin d'être redéfini quand ces dossiers apparaîtront, il suffit qu'ils soient créés à l'intérieur de la racine déjà ouverte.

  • Configurer settings.json : formatage et lint automatiques à la sauvegarde

    `settings.json` est le fichier de configuration de l'éditeur propre à ce workspace : contrairement aux réglages personnels de VSCode (globaux à la machine), ce qui est écrit dans `.vscode/settings.json` ne s'applique qu'au dépôt CoWorkNet et, une fois commité, s'applique identiquement sur le poste de tout contributeur qui clone le dépôt. L'objectif de ce TD est précis : que Ctrl+S reformate automatiquement le fichier édité et corrige les problèmes de lint réparables, sans jamais dépendre d'une commande manuelle.

    `editor.formatOnSave` couplé à `editor.defaultFormatter` déclenche Prettier (extension `esbenp.prettier-vscode`, installée au chapitre suivant) à chaque enregistrement. `editor.codeActionsOnSave` avec `source.fixAll.eslint: "explicit"` demande à l'extension ESLint de corriger automatiquement, au même moment, les problèmes de lint réparables (imports, variables inutilisées) une fois qu'une configuration ESLint existera dans `apps/api` et `apps/web` — ce fichier `settings.json` est écrit dès maintenant pour que le comportement soit actif dès la création de ces dossiers, sans repasser par cette configuration plus tard.

    `editor.tabSize` et `editor.rulers` (repère à 100 caractères) fixent une convention d'écriture commune. `files.eol` force les fins de ligne Unix (`\n`), pour éviter des diffs polluées par des changements de fin de ligne entre postes. `files.exclude` masque `node_modules`, `dist` et `.next` dans l'explorateur, avant même que ces dossiers n'existent : ils apparaîtront dès les premières installations de dépendances.

    Concrètement, `formatOnSave` normalise l'écriture — ici guillemets doubles, points-virgules et espacement homogène — sans que personne n'ait besoin d'y penser ni de le corriger en revue de code : la même règle s'appliquera au premier fichier `apps/api/src/main.ts` créé aux TD suivants.

    `eslint.workingDirectories` anticipe les deux apps (`apps/api`, `apps/web`) de la conception : ESLint cherchera une config distincte dans chaque dossier `apps/*`, plutôt qu'une seule à la racine. `docker.showStartPage: false` désactive la page d'accueil de l'extension Docker (chapitre suivant) : PostgreSQL tournera en [[conteneur|conteneur-docker]] (service `db`, port 5544) dès un TD à venir, elle ne rendrait pas service ici.

  • Recommander les extensions du projet via extensions.json

    `settings.json` ne sert à rien si les extensions qu'il pilote (Prettier, ESLint) ne sont pas installées sur le poste. `extensions.json` résout ce problème : dès qu'un contributeur ouvre le dépôt CoWorkNet, VSCode lit ce fichier et affiche une bannière proposant d'installer en un clic toutes les extensions recommandées manquantes, plutôt que de laisser chacun deviner ou composer sa propre liste.

    Trois extensions seulement, chacune justifiée par une décision déjà actée de CoWorkNet. `dbaeumer.vscode-eslint` et `esbenp.prettier-vscode` exécutent, dans l'éditeur, exactement ce que `settings.json` a configuré au chapitre précédent (`fixAll.eslint` et `formatOnSave`). `ms-azuretools.vscode-docker` colore les futurs `Dockerfile` et `docker-compose.yml` et donne un explorateur des conteneurs actifs, utile dès que PostgreSQL (service `db`, port 5544) tournera en [[conteneur|conteneur-docker]], comme prévu par les conventions du dépôt.

    Ce socle diffère volontairement de celui de la plateforme ecole : pas d'extension `Prisma.prisma`, parce que CoWorkNet écrit son SQL à la main via le package `pg` (décision produit explicite, pour travailler CP7/CP8/CP9 sur le SQL et les injections) et n'a donc aucun `schema.prisma` à éditer. Pas de GitLens ni de Dev Containers non plus : ce ne sont pas des prérequis pour que le formatage et le lint automatiques fonctionnent, l'objectif précis de ce TD, et ils resteraient des extensions superflues tant qu'aucun TD ne les rend nécessaires.

    Cette même liste s'installe aussi sans passer par la bannière, via `code --install-extension <id>` : utile pour un script d'onboarding automatisé, ou dans l'image d'un futur devcontainer, sans dépendre d'un clic dans l'interface.

  • Committer le dossier .vscode/

    `settings.json` et `extensions.json` existent maintenant sur le disque mais ne profitent à personne d'autre tant qu'ils ne sont pas commités : c'est le même principe que le `.gitignore` et le `README` versionnés au [[TD05|td-05]]. `git status` confirme que Git voit le nouveau dossier `.vscode/` comme non suivi, puisqu'aucun de ses fichiers n'a encore été ajouté à la [[staging area|staging-area]].

    Comme au [[TD05|td-05]], `git add` liste explicitement chaque fichier plutôt qu'un générique `git add .` : sur ce [[commit|commit]] qui touche uniquement la configuration de l'éditeur, on veut être certain de ne rien embarquer d'autre. Le message de [[commit|commit]] suit la même convention de préfixe `TD{n}:` déjà appliquée au dépôt CoWorkNet (une convention simplifiée propre au projet, distincte du format Conventional Commits `type(scope): description`, cf. [[TD05|td-05]]).

    `working tree clean` confirme qu'il ne reste rien en attente, et `git log --oneline` montre les deux commits attendus. Tout contributeur qui clonera désormais le dépôt CoWorkNet héritera, dès l'ouverture dans VSCode, du même formatage Prettier et de la même correction ESLint à la sauvegarde, ainsi que de la bannière proposant les mêmes trois extensions. Le TD suivant peut maintenant créer la structure du [[monorepo|monorepo]] pnpm (`apps/api`, `apps/web`) en s'appuyant sur cet environnement déjà configuré.

Conteneuriser la base de données PostgreSQL de CoWorkNet

  • Docker : image et conteneur

    Ce TD va décrire un service Postgres avec `docker-compose.yml` : avant cela, deux mots qui reviendront partout dans le fil rouge méritent d'être posés clairement. Une [[image Docker|image-docker]] est un modèle figé, en lecture seule, qui contient tout ce qu'il faut pour faire tourner un programme (ici PostgreSQL) — comparable à une classe Python, qui décrit une structure sans encore en être une instance. Un [[conteneur|conteneur-docker]] est une instance en cours d'exécution de cette image, isolée du reste de la machine — comparable à un objet instancié à partir de cette classe : on peut en lancer plusieurs à partir de la même image, chacun avec son propre état.

    Une seule image `nginx:alpine`, deux conteneurs indépendants (`web1`, `web2`), chacun sur son propre port : exactement la relation classe/instance illustrée plus haut, appliquée à Docker plutôt qu'à Python.

    Une image peut être lancée directement en ligne de commande (`docker run ...`), mais dans ce TD vous utiliserez `docker-compose.yml`, un fichier qui décrit un ou plusieurs services (chacun basé sur une image) et remplace une commande `docker run` longue et à retaper par un unique `docker compose up -d`, partagé par toute l'équipe.

  • Comprendre le service Postgres à conteneuriser

    Ce [[TD07|td-07]] conteneurise la base de données PostgreSQL de CoWorkNet (CP1). À la fin de la séance, un fichier `docker-compose.yml` à la racine du dépôt démarrera, en une seule commande `docker compose up`, un service Postgres local exposé sur le port 5544, avec un volume qui conserve les données d'un redémarrage à l'autre. Jusqu'ici, aucune base n'existe encore pour CoWorkNet : ce TD la fait apparaître, avant que le [[TD08|td-08]] n'y applique le schéma conçu au [[TD04|td-04]] (`users`, `sites`, `espaces`, `reservations`).

    Le cours magistral Environnement de développement (chapitres « Docker : images, conteneurs et `Dockerfile` » et « Docker Compose ») approfondit l'ensemble de Docker au-delà de ce qui est nécessaire ici : ce TD se concentre sur un seul service, `db`, sans écrire de `Dockerfile` — l'image officielle `postgres` suffit. Un `docker run -e POSTGRES_PASSWORD=... -p 5544:5432 postgres:16-alpine` démarrerait bien une base, mais la commande devrait être retapée à l'identique par chaque contributeur, sans trace versionnée. `docker-compose.yml` résout ce problème : il décrit le service une fois, dans un fichier commité, remplacé ensuite par un simple `docker compose up -d` exécutable par quiconque clone le dépôt.

    Avant d'écrire le fichier, quatre paramètres sont fixés par les conventions du dépôt CoWorkNet : l'image `postgres:16-alpine` (variante légère de PostgreSQL 16, alignée sur la version cible du projet), le port hôte 5544 (choisi précisément pour ne jamais entrer en conflit avec le Postgres de la plateforme ecole, exposé sur 5433), la base `coworknet_db` avec l'utilisateur `coworknet_app`, et un volume nommé pour que les données survivent à un `docker compose down` suivi d'un `up`.

  • Déclarer les identifiants dans .env.example

    Les identifiants de la base ne doivent jamais être écrits en dur dans `docker-compose.yml` : ce fichier est commité et lu par toute la promotion, alors qu'un mot de passe (même de développement) n'a rien à faire dans un [[historique Git|historique-git]] partagé. La pratique standard consiste à les lire depuis un fichier `.env` local, non commité (déjà exclu par `.gitignore` depuis le [[TD05|td-05]]), et à documenter sa forme attendue dans un `.env.example` versionné, lui, à la racine du dépôt.

    `coworknet_dev_password` est volontairement simple et commenté comme tel dans le fichier : c'est un choix assumé pour ce dépôt strictement local, jamais poussé sur un remote (cf. `README.md`). Chaque contributeur copiera ensuite ce fichier vers `.env` (commande détaillée plus loin dans ce TD), que Docker Compose lit automatiquement s'il se trouve à la racine, sans configuration supplémentaire ni option `--env-file` à ajouter à la commande.

  • Écrire docker-compose.yml

    `docker-compose.yml` décrit un seul service, `db`, dont chaque clé correspond directement à une option qu'un `docker run` aurait prise en argument. La syntaxe `${VARIABLE}` renvoie à `.env` : Docker Compose la résout automatiquement à la lecture du fichier, sans qu'aucune valeur ne soit recopiée en dur ici.

    `container_name` fixe un nom stable (`coworknet-db`) plutôt que le nom généré automatiquement par Compose, pratique pour cibler le [[conteneur|conteneur-docker]] avec `docker exec` sans redevoir le retrouver via `docker ps`. `restart: unless-stopped` relance automatiquement le [[conteneur|conteneur-docker]] si Docker redémarre (par exemple après un reboot du poste), sauf s'il a été explicitement arrêté par `docker compose down`.

    `ports: '5544:5432'` publie le port interne standard de Postgres (5432, celui que le [[conteneur|conteneur-docker]] utilise réellement) sur le port hôte 5544 : c'est ce mapping qui isole CoWorkNet du Postgres de la plateforme ecole (port 5433) sur la même machine. `volumes: coworknet-db-data:/var/lib/postgresql/data` monte un volume nommé, géré par Docker en dehors du [[conteneur|conteneur-docker]], à l'emplacement où Postgres écrit ses fichiers de données : sans cette ligne, un `docker compose down` supprimerait le [[conteneur|conteneur-docker]] et toutes ses données avec lui.

    Erreur [[YAML|yaml]] classique : une tabulation glissée dans l'indentation plutôt qu'une espace, que [[YAML|yaml]] interdit strictement. `"editor.insertSpaces": true` (réglage par défaut de VSCode) l'évite, avec de la vigilance sur l'indentation à deux espaces de ce fichier.

    Enfin, `healthcheck` exécute périodiquement `pg_isready` (utilitaire fourni par l'image Postgres) à l'intérieur du [[conteneur|conteneur-docker]] pour vérifier que le serveur accepte réellement des connexions, et pas seulement que le processus a démarré : cinq tentatives espacées de 5 secondes avant de déclarer le service `unhealthy`. C'est cet état `healthy` que `docker compose ps` affichera au chapitre suivant, et sur lequel l'[[API|api]] NestJS d'un futur TD pourra s'appuyer avant de tenter sa première connexion.

  • Démarrer, vérifier et committer le service

    Deux fichiers existent maintenant sur le disque : `.env.example` et `docker-compose.yml`. Avant de les committer, on vérifie qu'ils fonctionnent réellement. Première étape : copier `.env.example` vers `.env` (fichier local, ignoré par Git), puis démarrer le service en arrière-plan avec l'option `-d`.

    `STATUS` passe de « `health: starting` » à « `Up (healthy)` » quelques secondes après le démarrage, une fois que `pg_isready` a répondu positivement à l'intérieur du [[conteneur|conteneur-docker]] : c'est le signal que la base accepte des connexions sur le port 5544. On peut s'y connecter directement pour le vérifier, sans passer par l'[[API|api]] (qui n'existe pas encore) : `docker exec` ouvre un `psql` à l'intérieur du [[conteneur|conteneur-docker]], avec les identifiants définis dans `.env`.

    Si `STATUS` reste bloqué sur `starting` au-delà de la trentaine de secondes prévue par le `healthcheck` (5 tentatives × 5 secondes), `docker compose logs db` affiche la sortie du [[conteneur|conteneur-docker]] et révèle en général la cause : port déjà occupé par un autre service, volume corrompu d'un essai précédent, ou variable d'environnement manquante faute d'avoir copié `.env.example` vers `.env`.

    `current_database` et `current_user` confirment que le service répond exactement avec la base et l'utilisateur attendus. Reste à vérifier la persistance : un `docker compose down` supprime le [[conteneur|conteneur-docker]] (mais pas le volume, qui n'est pas listé sous le service), puis un nouveau `docker compose up -d` recrée un [[conteneur|conteneur-docker]] vierge qui remonte pourtant les données déjà écrites, puisqu'il remonte le même volume `coworknet-db-data`.

    Le bloc ci-dessous est un **diff** au format unifié : les lignes en `+` ont été ajoutées, celles en `-` supprimées, `@@ ... @@` indique la portion de fichier concernée. `--- /dev/null` signifie que le fichier n'existait pas avant (entièrement nouveau) — un format à recroiser à chaque revue de code.

    Le service vérifié, on commite les deux fichiers versionnés (`docker-compose.yml`, `.env.example`) — jamais `.env`, déjà exclu par `.gitignore` depuis le [[TD05|td-05]]. Le message suit la même convention de préfixe `TD{n}:` déjà appliquée sur le dépôt CoWorkNet (distincte du format Conventional Commits, cf. [[TD05|td-05]]).

    CoWorkNet dispose désormais d'une base PostgreSQL locale démarrable en une commande (`docker compose up -d`), reproductible sur n'importe quel poste qui clone le dépôt et copie `.env.example` vers `.env`, et dont les données survivent aux redémarrages du [[conteneur|conteneur-docker]]. Le [[TD08|td-08]] pourra s'appuyer directement sur ce service `db` pour y créer, en SQL écrit à la main, les tables `users`, `sites`, `espaces` et `reservations` conçues au [[TD04|td-04]].

Créer la base de données de CoWorkNet

  • Écrire et exécuter le script de création du schéma

    Ce TD construit concrètement la base de données de CoWorkNet (CP7) : à la fin, `docker compose exec db psql` exécute avec succès contre le [[conteneur|conteneur-docker]] PostgreSQL conteneurisé au [[TD07|td-07]] un script `db/schema.sql` qui crée les quatre tables du modèle validé en conception ([[TD04|td-04]]), un script `db/seed.sql` qui charge un jeu d'essai complet, et une procédure de sauvegarde/restauration `pg_dump`/`pg_restore` documentée dans le `README` et testée sur un volume Docker vierge.

    Ce script reproduit à la lettre le MPD de `docs/conception/schema-donnees.md` §6, sans ajouter ni retrancher une colonne : même ordre de création (`sites` avant `users` et `espaces`, tous deux avant `reservations`), mêmes contraintes `CHECK` sur `role` et `status`, même choix d'un `id` `uuid` — identifiant unique généré aléatoirement (ex. `3fa85f64-...`), à la différence d'un entier auto-incrémenté — via `gen_random_uuid()` (fourni par l'extension `pgcrypto`, activée en première ligne). Les `CREATE INDEX` en fin de script créent un **index** : une structure annexe qui accélère la recherche sur une colonne, comme l'index d'un livre évite de le relire page par page.

    Le flag `-T` de `docker compose exec` désactive l'allocation d'un pseudo-terminal : indispensable dès qu'on redirige un fichier vers l'entrée standard de la commande (`< db/schema.sql`), sinon Docker refuse la redirection. Chaque instruction `CREATE` renvoyée en sortie confirme la création d'un objet ; une erreur SQL aurait interrompu le script et laissé le schéma incomplet. La commande `\dt` dans une session `psql` interactive liste ensuite les quatre tables pour confirmer visuellement le résultat.

    Un script de création ne vaut que par les contraintes qu'il fait réellement respecter : avant de charger le moindre jeu d'essai, on vérifie que PostgreSQL rejette bien une donnée invalide. Deux essais suffisent : une valeur de `role` hors de l'énumération (`'MEMBRE'`,`'GERANT'`,`'ADMIN'`), et une [[clé étrangère|cle-etrangere]] `site_id` pointant vers un site inexistant.

    Confirme la mise en garde du commentaire d'en-tête de `db/schema.sql` : sans `IF NOT EXISTS`, rejouer le script sur une base déjà initialisée échoue dès la première table. `docker compose down -v` (qui supprime le volume nommé, donc les données) suivi d'un `docker compose up -d` repart d'une base vierge avant de le rejouer — la même procédure que la restauration testée au chapitre 3.

  • Charger un jeu d'essai complet

    Un jeu d'essai « complet » au sens du CP7 ne se limite pas à quelques lignes de convenance : il doit couvrir chaque valeur significative du schéma. Pour CoWorkNet, cela signifie au minimum les trois rôles de `users.role`, les quatre valeurs de `reservations.status`, un espace désactivé (`is_active` = `false`) pour vérifier que l'historique de ses réservations reste consultable, et deux sites distincts pour tester le filtrage `GET /espaces?siteId=` dès qu'il existera. `db/seed.sql` couvre ces neuf combinaisons en 18 lignes réparties sur les quatre tables.

    Deux points méritent d'être justifiés dans ce script. D'abord, aucun couple de réservations `PENDING`/`CONFIRMED` sur un même espace ne partage de créneau horaire : le schéma actuel ne l'interdit pas encore (§4 de `schema-donnees.md`), mais un jeu d'essai qui violerait par avance une règle métier documentée pour le [[TD19|td-19]]/[[TD20|td-20]] serait trompeur. Ensuite, chaque `password_hash` est une chaîne de substitution au format bcrypt mais non fonctionnelle : le [[hachage|hachage]] réel n'arrive qu'au [[TD15|td-15]]/[[TD16|td-16]], et il serait dangereux de laisser croire qu'un mot de passe est déjà vérifiable à ce stade.

    Chaque ligne `INSERT 0 N` confirme le nombre de lignes insérées par la requête correspondante (2 sites, 5 users, 4 espaces, 7 reservations) ; le 0 qui précède est l'OID de l'objet inséré, resté à zéro depuis PostgreSQL 8.1 pour toute table sans OID explicite. Une requête de comptage croisée avec un `GROUP BY` sur `role` puis sur `status` confirme ensuite que les neuf combinaisons attendues sont bien représentées dans le jeu d'essai.

  • Sauvegarder et restaurer avec pg_dump / pg_restore

    Documenter une procédure de sauvegarde sans jamais la tester revient à ne pas en avoir : ce chapitre exécute donc un cycle complet sauvegarde puis restauration, sur un [[conteneur|conteneur-docker]] recréé depuis un volume Docker vide, exactement comme un incident réel de perte de données. `pg_dump` propose plusieurs formats de sortie ; ce TD retient le format personnalisé (`-Fc`), compressé et binaire, qui est le seul à s'utiliser avec `pg_restore` plutôt qu'avec un simple `psql < fichier.sql`.

    La restauration cible une base totalement vierge, recréée avec `docker compose down -v` (qui supprime le volume nommé `coworknet-db-data`, donc toute donnée) suivi de `docker compose up -d`. La commande `pg_restore` accepte alors le dump en entrée standard : `--clean --if-exists` supprime silencieusement les objets déjà présents avant de les recréer, et `--no-owner` ignore les informations de propriétaire du dump, sans pertinence ici puisque le [[conteneur|conteneur-docker]] ne connaît qu'un seul rôle, `coworknet_app`.

    La preuve que la restauration a fonctionné n'est pas l'absence d'erreur de `pg_restore`, mais une requête de comptage qui retrouve exactement les 18 lignes du jeu d'essai sur une base qui, quelques secondes plus tôt, n'avait plus aucune table. C'est ce cycle complet — perte simulée puis récupération vérifiée — qui constitue une procédure de sauvegarde/restauration documentée au sens du CP7, et non la simple existence d'un fichier de commandes jamais exécuté.

    Le dossier `db/backup/` est exclu du [[dépôt Git|depot-git]] (`.gitignore`) : un dump contient l'intégralité des données de `coworknet_db`, y compris les colonnes `password_hash` de la table `users`. Même si ces valeurs ne sont que des placeholders à ce TD, l'habitude à prendre dès maintenant est de ne jamais committer un dump de production ni de développement, et de le faire transiter uniquement par un canal de sauvegarde dédié.

  • Sécuriser l'accès à la base : comptes, mots de passe, réseau

    Le CP7 attend des bonnes pratiques de sécurisation dès la création de la base, pas seulement au moment où l'authentification applicative arrivera ([[TD15|td-15]]/[[TD16|td-16]]). Trois leviers sont actionnables dès ce TD, sans attendre le code de `apps/api` : l'exposition réseau du port PostgreSQL, l'hygiène des secrets versionnés, et la garantie qu'aucun mot de passe utilisateur n'est stocké en clair dans le jeu d'essai. Premier levier, le port : le diff ci-dessous restreint son exposition à la machine hôte elle-même plutôt qu'à tout le réseau local.

    Sans préciser d'adresse, Docker publie un port sur toutes les interfaces réseau de la machine (`0.0.0.0`), y compris celles visibles depuis un réseau local partagé. Préfixer le mapping par `127.0.0.1` restreint l'accès à la machine hôte elle-même : exactement la promesse déjà formulée en conception (`schema-donnees.md` §8, « le port PostgreSQL n'est exposé que sur le réseau Docker local ou `localhost` »). Le [[conteneur|conteneur-docker]] recréé (`docker compose up -d`) reste healthy sur ce nouveau mapping, sans aucun changement côté application locale, qui continue de cibler `localhost:5544`.

    Le second levier concerne le compte PostgreSQL lui-même. Ce projet pédagogique retient un compte applicatif unique, `coworknet_app`, défini par `POSTGRES_USER` et distinct du superutilisateur `postgres` de l'image officielle, jamais utilisé ici. Son mot de passe de développement reste en clair dans `.env.example`, avec la mention explicite que cette pratique n'est pas acceptable en production ; le fichier `.env` réel, lui, n'a jamais quitté `.gitignore` depuis le [[TD05|td-05]].

    `docker port` confirme le mapping restreint à `127.0.0.1` : une machine tierce du réseau local ne peut plus atteindre le port 5544.

    Le troisième levier porte sur les données elles-mêmes : la colonne `users.password_hash` ne contient jamais un mot de passe en clair, ni dans le schéma (nom de colonne explicite) ni dans le jeu d'essai (valeurs de substitution non exploitables, cf. chapitre 2). Le [[hachage|hachage]] bcrypt réel, coût 12, arrive au [[TD15|td-15]]/[[TD16|td-16]] avec la route `POST /auth/register` ; ce TD pose seulement la structure qui le rendra possible sans migration ultérieure. Pour approfondir la gestion des rôles PostgreSQL, les vulnérabilités d'une base relationnelle et les obligations [[RGPD|rgpd]] associées, le cours magistral associé à ce TD les couvre en détail.

Initialiser le projet NestJS de CoWorkNet

  • Initialiser le workspace pnpm et scaffolder l'API NestJS

    Ce TD initialise concrètement (CP1) le projet NestJS de CoWorkNet : à l'issue de ce TD, `apps/api` est un workspace pnpm scaffoldé sous NestJS 11 et TypeScript, connecté à PostgreSQL via le seul package `pg` (aucun [[ORM|orm]], conformément à l'architecture validée au [[TD03|td-03]] — CP6), et expose une route `GET /health` qui répond 200 après avoir vérifié que la base de données répond elle aussi à une requête SQL réelle.

    `pnpm-workspace.yaml` déclare un workspace minimal : seul `apps/*` est inclus, sans `packages/types` partagé — CoWorkNet reste un projet pédagogique volontairement simple. La clé `allowBuilds` n'existe pas par défaut : pnpm 11 bloque par sécurité les scripts d'installation (`postinstall`) des dépendances tierces tant qu'ils ne sont pas explicitement approuvés, pour éviter qu'un paquet compromis n'exécute du code arbitraire au moment du `pnpm install`.

    `unrs-resolver` est une dépendance transitive de `@nestjs/cli` (résolution de modules native). Plutôt que l'invite interactive `pnpm approve-builds` — inadaptée à une exécution non interactive (nouveau clone, CI) —, la valeur est fixée directement dans `pnpm-workspace.yaml` (`allowBuilds.unrs-resolver: true`) : tout `pnpm install` ultérieur retrouve alors exactement le même résultat, sans intervention manuelle.

    Ce `package.json` racine reste volontairement minimal : `private: true` empêche toute publication accidentelle sur le [[registre|registre-images]] npm public, et les deux scripts `api:dev` / `api:test` ne font que déléguer à `pnpm --filter api`, le mécanisme natif de pnpm pour cibler un paquet précis d'un workspace. Aucun outil de build multi-paquets n'est introduit ici : avec un seul paquet actif pour l'instant (`apps/api`), ce serait une dépendance non justifiée par le besoin réel.

    Cette erreur survient quand Corepack n'est pas activé (ou une trop vieille version de [[Node.js|node-runtime]] installée) : pnpm ignore alors le champ `packageManager` (`pnpm@11.9.0`) de ce même fichier et utilise sa propre version globale, ce qui casse la lecture du [[lockfile|lockfile]] plutôt que de simplement afficher un avertissement anodin. Corepack, activé par défaut avec [[Node.js|node-runtime]] 22 (`corepack enable`), lit normalement ce champ pour basculer automatiquement sur la bonne version de pnpm à chaque `pnpm install` — la commande `corepack use pnpm@11.9.0` ci-dessus suffit à retrouver un environnement cohérent.

    `typescript` est figé à 5.9.3 plutôt qu'à la toute dernière version majeure du [[registre|registre-images]] : l'écosystème `ts-jest`/`@types/jest` est connu pour ne plus résoudre fiablement les globals de test (`jest`, `describe`, `expect`) dès qu'on change de branche majeure de TypeScript avant que ces outils ne l'aient officiellement adoptée — rester sur la dernière 5.x évite ce risque dès le premier `pnpm test`. La section `jest` (plutôt qu'un fichier séparé) exécute les `*.spec.ts` de `src/` ; `test/jest-e2e.json`, à venir, cible séparément les tests d'intégration.

    `nest-cli.json` indique à la CLI Nest que le code source part de `src/` (`sourceRoot`) et que `nest build` vide intégralement `dist/` avant chaque compilation (`deleteOutDir`), pour ne jamais laisser un fichier compilé obsolète derrière soi. Le `tsconfig.json` associé active le mode strict complet (`strict`, `strictNullChecks`, `noImplicitAny`) dès ce premier [[commit|commit]], plutôt que de le durcir a posteriori sur un code déjà existant.

    Deux derniers mots de vocabulaire. Un [[monorepo|monorepo]] (comme celui de CoWorkNet, avec `apps/api` et bientôt `apps/web` dans un unique dépôt) regroupe plusieurs applications dans un seul [[dépôt Git|depot-git]] plutôt que dans des dépôts séparés, pour partager plus facilement des conventions communes et faire évoluer plusieurs applications liées de façon cohérente. `pnpm install` génère aussi un fichier [[lockfile|lockfile]] (`pnpm-lock.yaml`, à la racine) qui fige la version exacte de chaque dépendance installée — pas seulement celle demandée dans `package.json` — pour garantir que tout le monde installe rigoureusement les mêmes versions.

  • Décorateurs et injection de dépendances NestJS

    Le chapitre suivant utilise deux mécanismes NestJS sans équivalent direct en Python classique : les [[décorateurs|decorateur]] et l'[[injection de dépendances|injection-dependances]]. Un [[décorateur|decorateur]] (`@Injectable()`, `@Module()`, plus tard `@Controller()`, `@Get()`...) est une annotation posée juste au-dessus d'une classe ou d'une méthode, qui attache des métadonnées lues par NestJS au démarrage — un peu comme un [[décorateur|decorateur]] Python (`@property`, `@staticmethod`) modifie une fonction, mais ici c'est tout un framework qui les exploite pour construire l'application.

    `@Injectable()` marque une classe comme fournissable par NestJS : quand une autre classe déclare ce même type en paramètre de son constructeur (comme `DatabaseService` ci-après), NestJS lui fournit automatiquement une instance déjà construite — c'est l'[[injection de dépendances|injection-dependances]], plutôt que d'écrire vous-même `new DatabaseService(...)`. Le [[pool de connexions|pool-connexions]] que vous allez créer juste après en est un bon exemple : ouvrir une connexion PostgreSQL a un coût, le pool en garde plusieurs déjà ouvertes et les réutilise entre les requêtes.

    Enfin, le script `start:prod` de ce TD (`node dist/main.js`) rappelle que [[Node.js|node-runtime]] est le programme qui exécute du JavaScript compilé côté serveur — l'équivalent, pour ce langage, de l'interpréteur python qui exécute vos scripts `.py` : NestJS et TypeScript sont compilés vers du JavaScript brut (dossier `dist/`), que [[Node.js|node-runtime]] exécute ensuite directement.

  • Connecter l'API à PostgreSQL via pg, sans ORM

    Conformément à `docs/conception/architecture-logicielle.md` §3 (CP6), CoWorkNet n'utilise aucun [[ORM|orm]] : la couche [[repository|depot-git]] (CP7, CP8), qui arrivera au [[TD11|td-11]] avec le [[CRUD|crud]] des espaces, écrira du SQL paramétré à la main via `pg`. Ce TD pose la fondation commune à toute cette future couche : un `DatabaseService` unique, qui détient le [[pool de connexions|pool-connexions]] PostgreSQL et l'expose via une seule méthode `query()`, sur laquelle chaque `*.repository.ts` s'appuiera ensuite.

    Ces cinq variables `DATABASE_*` correspondent exactement aux identifiants du service Docker `db` défini à la racine (`POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` du [[TD07|td-07]]), sur le port 5544 exposé en local. Comme pour tout service NestJS, `DatabaseService` ne lira jamais `process.env` directement : il passe systématiquement par `ConfigService`, injecté via `ConfigModule.forRoot({ isGlobal: true })` déjà déclaré dans `AppModule` (§3 de ce TD).

    La méthode `query<T>()` est générique afin que chaque futur [[repository|depot-git]] puisse typer le résultat de sa propre requête (par exemple `query<EspaceRow>(...)`) sans que `DatabaseService` n'ait à connaître le moindre type métier. `onModuleDestroy()` ferme proprement le pool (`pool.end()`) à l'arrêt de l'application ou en fin de test e2e : sans cela, les connexions PostgreSQL resteraient ouvertes et Jest ne terminerait jamais son processus.

    Le constructeur détaille chacun des cinq champs de connexion (`host`, `port`, `database`, `user`, `password`) pour rester lisible en développement local — mais `pg.Pool` accepte aussi la forme alternative ci-dessus, une seule option `connectionString` (`postgres://user:password@host:port/database`), courante sur les plateformes d'hébergement qui exposent une unique variable `DATABASE_URL` plutôt que cinq variables séparées. Les deux formes sont strictement équivalentes pour `pg` ; CoWorkNet retient les champs séparés uniquement parce que `apps/api/.env.example` documente déjà chaque paramètre individuellement.

    Le [[décorateur|decorateur]] `@Global()` rend `DatabaseService` injectable depuis n'importe quel module de l'application sans avoir à réimporter `DatabaseModule` à chaque fois — indispensable puisque tous les futurs modules métier (espaces, auth, reservations) en dépendront. `HealthModule`, écrit au chapitre suivant, est le premier consommateur de ce service, avant même l'arrivée d'un vrai [[repository|depot-git]].

  • Exposer GET /health en couches controller -> service, avec ses tests unitaires

    `GET /health` ne se contente pas de répondre que le processus [[Node|node-runtime?]] est démarré : il exécute une requête SQL réelle (`SELECT 1`) via `DatabaseService`, afin qu'un 200 prouve que l'[[API|api]] et PostgreSQL communiquent effectivement. La couche controller -> service imposée au [[TD03|td-03]] s'applique dès cette première route : le controller ne fait que traduire HTTP <-> appel de méthode, toute la logique reste dans le service.

    `HealthStatus` est un type littéral volontairement restreint (`{ status: 'ok'; database: 'up' }`) plutôt qu'un simple `string` : à ce TD, la route ne gère qu'un seul chemin de succès (une erreur de connexion PostgreSQL fera échouer la requête et remontera en 500 via le filtre d'exceptions par défaut de Nest, sans traitement spécifique nécessaire pour l'instant).

    Le controller ne contient ni règle métier ni accès aux données : il délègue immédiatement à `HealthService` et retourne directement la `Promise` obtenue. C'est la frontière stricte controller/service documentée au [[TD03|td-03]] (§2.2-2.3 de `architecture-logicielle.md`), qui sera vérifiée en revue de code sur chaque route future.

    `HealthModule` reste local (pas de `@Global()`) : seul `HealthController` a besoin de `HealthService`, contrairement à `DatabaseService` qui doit être visible de tous les futurs modules métier. Il est ensuite câblé dans `AppModule` aux côtés de `ConfigModule` (variables d'environnement) et `DatabaseModule` (connexion PostgreSQL), les trois briques nécessaires à l'application complète.

    Piège fréquent à ce stade : si `DatabaseModule` n'était pas marqué `@Global()` (chapitre 2) et que `HealthModule` oubliait de l'importer explicitement dans `imports`, NestJS échouerait au démarrage avec une erreur de résolution de dépendance plutôt qu'un bug silencieux — le framework refuse de démarrer une application dont le graphe d'injection est incomplet.

    `main.ts` lit le port d'écoute HTTP via `ConfigService` plutôt qu'un `process.env.PORT` direct, avec une valeur par défaut de repli (4000) si la variable est absente — cohérent avec `apps/api/.env.example` écrit au chapitre précédent.

    Chaque service et controller créé ou modifié doit être accompagné d'un [[test unitaire|test-unitaire]] Jest qui mocke ses dépendances (`PrismaService` et co. côté ecole, `DatabaseService` ici) — la Phase [[TDD|tdd]] stricte [[red-green-refactor|tdd]] ne démarre qu'au [[TD12|td-12]], mais un test qui couvre déjà le comportement attendu reste requis dès ce TD. `HealthService.spec.ts` vérifie deux cas : la requête `SELECT 1` est bien exécutée, et une erreur de connexion se propage sans être avalée.

    Le même principe d'injection de dépendance simulée (`useValue`) s'applique côté controller : `HealthController.spec.ts` remplace `HealthService` par un [[mock|mock]] et vérifie uniquement que le controller délègue et restitue le résultat, sans jamais réimplémenter la logique métier déjà testée dans `health.service.spec.ts`.

    Les deux fichiers `.spec.ts` de ce chapitre s'exécutent ensemble via `pnpm test`, qui lance Jest sur tout `src/` : chaque `describe()` y devient une suite de tests, chaque `it()` un test individuel. La sortie ci-dessous confirme que les deux suites (`health.service.spec.ts` et `health.controller.spec.ts`, « 2 passed ») et les trois `it()` qu'elles contiennent au total (« 3 passed ») passent bien.

  • Vérifier bout en bout : test d'intégration, build réel et commit

    Les tests unitaires du chapitre précédent mockent `DatabaseService` : ils prouvent que `HealthService` et `HealthController` sont corrects isolément, mais pas que l'[[API|api]] sait réellement parler à PostgreSQL. C'est le rôle du test d'intégration Jest + Supertest de `apps/api/test/` (convention du projet) : il démarre l'`AppModule` complet, sans aucun [[mock|mock]], contre le PostgreSQL du docker-compose déjà démarré (`coworknet-db`, healthy depuis le [[TD07|td-07]]/[[TD08|td-08]]).

    Le pattern ci-dessus évite précisément ce symptôme : `beforeAll` compile et démarre l'application une seule fois pour toute la suite (plus rapide qu'un redémarrage par test), et `afterAll` appelle `app.close()` à la fin — ce qui referme aussi, via `DatabaseService.onModuleDestroy()` écrit au chapitre 2, le pool PostgreSQL sous-jacent. Sans cet appel, les connexions resteraient ouvertes et le process Jest ne se terminerait jamais.

    Ce fichier est ciblé par une configuration Jest dédiée, `jest-e2e.json`, distincte de la section `jest` du `package.json` (chapitre 1) qui exécute les `*.spec.ts` unitaires : deux commandes (`pnpm test` et `pnpm test:e2e`), deux périmètres, jamais mélangés.

    Lancez à présent `pnpm test:e2e`. Contrairement à `pnpm test` (chapitre précédent, `DatabaseService` mocké), cette suite interroge réellement PostgreSQL à travers toute la chaîne HTTP -> controller -> service -> base : la sortie ci-dessous confirme qu'une requête `GET /health` obtient bien un 200 face à la vraie base de données.

    Un test qui passe ne suffit pas à prouver qu'une [[API|api]] fonctionne en conditions réelles d'exécution (CP1) : ce TD se conclut donc par un build réel (`nest build`, qui compile `src/` vers `dist/` en respectant `tsconfig.build.json`) puis un démarrage du serveur compilé, suivi d'un appel HTTP effectif avec `curl` — exactement le geste qu'un utilisateur ou un service de monitoring ferait en production.

    Ce 200 accompagné du corps `{"status":"ok","database":"up"}` est la preuve finale attendue par ce TD : `apps/api` est démarrable, connecté au PostgreSQL du [[TD07|td-07]]/[[TD08|td-08]] via `pg`, sans [[ORM|orm]]. Les prochains TD s'appuient directement sur cette fondation : le [[TD10|td-10]] scaffoldera `apps/web` (Next.js), puis le [[TD11|td-11]] écrira le premier vrai [[repository|depot-git]] SQL (espaces) au-dessus du même `DatabaseService`, en [[TDD|tdd]] strict. Le cours magistral associé approfondit les choix SGBD/NoSQL et les patrons d'accès aux données.

Initialiser le projet Next.js de CoWorkNet

  • Scaffolder Next.js dans le workspace pnpm existant

    Ce TD initialise concrètement (CP1) `apps/web`, l'application Next.js 16 ([[App Router|app-router]], TypeScript) de CoWorkNet, dans le même workspace pnpm que `apps/api` ([[TD09|td-09]]). À l'issue de ce TD, `pnpm --filter web dev` démarre le front sur http://localhost:4100 et sa page d'accueil interroge réellement `GET http://localhost:4000/health` de l'[[API|api]] pour afficher si elle est en ligne ou non — la toute première interface utilisateur du projet (CP2), volontairement minimale à ce stade.

    Chaque option fixe un choix explicite plutôt que de laisser l'invite interactive décider : `--ts` et `--app` imposent TypeScript et l'[[App Router|app-router]] — dans Next.js, tout fichier `page.tsx` placé sous `app/` devient automatiquement une route accessible par URL, sans configuration de routes à écrire à la main (jamais le Pages Router, une convention plus ancienne équivalente) ; `--no-tailwind`, car aucun système de design n'est retenu pour CoWorkNet à ce stade du fil rouge ; `--no-src-dir` garde `app/` directement sous `apps/web`, cohérent avec le `src/` déjà utilisé côté `apps/api` pour une autre raison (séparation source/build) ; `--use-pnpm` force la génération d'un [[lockfile|lockfile]] pnpm ; `--yes` rend la commande exécutable sans terminal interactif, condition indispensable en script ou en CI.

    Deux problèmes se cumulent ici. D'abord, `sharp` (optimisation d'images) est une nouvelle dépendance transitive dont le script d'installation n'est pas encore approuvé, comme `unrs-resolver` au [[TD09|td-09]] — même cause, même remède : l'ajouter à `allowBuilds`. Ensuite, `create-next-app` a généré `apps/web/pnpm-workspace.yaml` et `apps/web/pnpm-lock.yaml` : un sous-workspace pnpm isolé et imbriqué dans le workspace racine, au lieu de rejoindre celui du [[TD09|td-09]]. Ces deux fichiers doivent être supprimés pour que `apps/web` reste un simple paquet du workspace unique déclaré à la racine.

    Un `pnpm install` relancé depuis la racine, après suppression des deux fichiers imbriqués, résout alors correctement `apps/web` comme second paquet du workspace unique, aux côtés de `apps/api`. Le `package.json` racine gagne deux scripts de délégation symétriques à ceux de l'[[API|api]] (`api:dev`, `api:test`), pour garder un point d'entrée unique et cohérent quel que soit le paquet ciblé.

    Le `package.json` généré dans `apps/web` fixe `next` à 16.2.10 (dernière version stable au moment de ce TD), `react` et `react-dom` en 19.2.4. Deux scripts sont ajustés à la main par rapport à la génération par défaut : `dev` et `start` reçoivent explicitement `-p 4100`, car Next.js ne lit aucune variable `PORT` depuis `.env` au démarrage de sa CLI — contrairement à `apps/api`, qui la lit via `ConfigService` ([[TD09|td-09]]). C'est le port de référence de CoWorkNet pour le front, distinct des ports 4000 ([[API|api]]) et 3100 (plateforme ecole).

    Piège fréquent avec `create-next-app` : oublier `--use-pnpm` (ou lancer la commande depuis un terminal où seul `npm` est détecté) fait générer un `package-lock.json` dans `apps/web` au lieu d'un [[lockfile|lockfile]] pnpm — un fichier qui n'a rien à faire dans un [[monorepo|monorepo]] géré entièrement par pnpm, et que Git ajoute silencieusement au prochain [[commit|commit]] si personne ne le remarque avant.

  • Centraliser la configuration et l'appel à l'API

    Côté NestJS, un service ne lit jamais `process.env` directement : il passe par `ConfigService` ([[TD09|td-09]]). Le même principe s'applique côté Next.js — aucun fichier autre que `lib/env.ts` ne doit lire `process.env` — afin que toute variable d'environnement du front ait un unique point d'accès, testable et remplaçable sans toucher au reste du code. `getApiUrl()` expose une valeur de repli (http://localhost:4000) pour que le développement local fonctionne même sans `.env` copié.

    La variable porte le préfixe `NEXT_PUBLIC_` car la page d'accueil est un [[Server Component|server-component]] qui appelle l'[[API|api]] au moment du rendu (voir chapitre 4) mais un futur écran interactif pourra avoir besoin de rappeler l'[[API|api]] depuis le navigateur : sans ce préfixe, Next.js n'inclurait la variable que dans le bundle serveur, pas dans celui envoyé au client. Le port d'écoute du serveur Next.js lui-même (4100) n'apparaît volontairement pas dans `.env.example` : il est fixé dans `package.json` (chapitre 1), puisque la CLI Next.js ne le lit pas depuis un fichier `.env`.

    Piège fréquent : oublier ce préfixe sur une variable destinée à être lue côté client. `NEXT_PUBLIC_API_URL` fonctionne parce qu'il commence exactement par `NEXT_PUBLIC_` — une variable nommée par erreur `PUBLIC_API_URL` ou `API_URL` (ci-dessus) resterait invisible du navigateur, sans qu'aucune erreur ne le signale au build : `process.env.API_URL` renvoie silencieusement `undefined` côté client, jamais une exception.

    `getHealth()` ne lève jamais d'exception. C'est un choix délibéré : `app/page.tsx` (chapitre 4) est un [[Server Component|server-component]] asynchrone, et une `Promise` rejetée pendant son rendu ferait planter toute la page avec l'écran d'erreur par défaut de Next.js — inapproprié pour une simple indisponibilité réseau de l'[[API|api]], qui doit au contraire s'afficher proprement à l'utilisateur. `getHealth()` encapsule donc le `fetch` dans un `try`/`catch` et retourne toujours un résultat exploitable, que l'appel ait réussi ou non.

    L'option `cache: 'no-store'` est ce qui rend cet appel utile comme indicateur de statut : par défaut, Next.js mettrait en cache le résultat d'un `fetch` côté serveur et servirait une réponse figée à chaque visite. Ici, chaque requête vers la page d'accueil doit réellement interroger l'[[API|api]] au moment où elle est reçue, pour refléter son état courant.

  • Next.js : Server Component et Client Component

    Avant d'écrire `app/page.tsx`, une distinction propre à Next.js [[App Router|app-router]] mérite d'être posée. Par défaut, un composant Next.js est un [[Server Component|server-component]] : son code s'exécute uniquement sur le serveur, jamais envoyé au navigateur, ce qui lui permet par exemple d'appeler directement une [[API|api]] sans exposer aucun secret côté client. Un [[Client Component|client-component]] (marqué par la directive `'use client'` en tête de fichier) est l'exception : son code est envoyé au navigateur et s'y exécute, nécessaire dès qu'un composant doit réagir à un clic, une saisie, ou tout autre événement utilisateur.

    La page d'accueil de ce chapitre sera un [[Server Component|server-component]] : elle appelle l'[[API|api]] directement depuis le serveur Next.js, à chaque requête, sans jamais exposer l'URL interne de l'[[API|api]] au navigateur. Un [[Server Component|server-component]] asynchrone peut simplement `await` un appel réseau dans le corps de sa fonction, là où un [[Client Component|client-component]] aurait besoin d'un hook comme `useEffect` pour déclencher ce même appel après son affichage initial.

  • Construire la page d'accueil en Server Component

    `app/page.tsx` est un [[Server Component|server-component]] asynchrone : la fonction `Home()` est elle-même `async` et appelle directement `getHealth()` avant de retourner le JSX, sans passer par `useEffect` ni par un [[Client Component|client-component]]. C'est le choix le plus simple et le plus correct pour cet écran (CP2) : aucune interactivité n'est nécessaire à ce TD, et un rendu serveur évite d'exposer un écran de chargement transitoire juste pour afficher un statut disponible dès la génération de la page.

    Le rendu conditionnel repose sur `health.ok && health.data` plutôt que sur `health.ok` seul, pour que TypeScript restreigne le type de `health.data` à `HealthStatus` (non nul) dans la branche vraie, sans [[assertion|assertion]] manuelle. L'attribut `data-testid="health-status"` est présent sur les deux branches : il identifie ce noeud de façon stable pour un futur test d'interface, indépendamment du texte affiché.

    Piège fréquent d'ESLint sur ce fichier : une apostrophe brute dans du texte JSX (`d'espaces` au lieu de `d&apos;espaces`) déclenche la règle `react/no-unescaped-entities` d'`eslint-config-next` et fait échouer `pnpm --filter web build`. `page.tsx` échappe donc systématiquement les deux apostrophes du texte affiché (`d&apos;espaces`, `l&apos;API`) plutôt que de désactiver la règle.

    `page.module.css` reste volontairement simple : deux couleurs de statut (vert `.ok`, rouge `.down`) avec leurs variantes en thème sombre via `prefers-color-scheme`, sans dépendance à un système de design externe. `app/layout.tsx`, généré par défaut avec les polices Geist chargées via `next/font/google`, est lui aussi simplifié : ce TD retire ce chargement de police non nécessaire à l'objectif du jour et ajuste seulement le titre et la description affichés dans l'onglet du navigateur.

  • Vérifier bout en bout : test Vitest, build réel et commit

    Le fil rouge n'exige pas de suite Vitest exhaustive côté web, mais `getHealth()` est une fonction pure et isolée (aucun DOM, aucun composant à monter) : un test léger a un coût quasi nul et documente son comportement (CP9). Trois cas sont couverts, en simulant `globalThis.fetch` avec `vi.stubGlobal` plutôt qu'en appelant une vraie [[API|api]] : réponse 200 valide, réponse HTTP en erreur, et échec réseau (`fetch` qui rejette) — les trois chemins déjà écrits dans `getHealth()` au chapitre 2.

    Piège fréquent dans un [[monorepo|monorepo]] pnpm : lancer `pnpm test` seul depuis la racine, en espérant qu'il exécute toutes les suites de tests du projet. Sans script `test` déclaré dans le `package.json` racine (seuls `api:dev`, `api:test`, `web:dev`, `web:build` y figurent), pnpm échoue immédiatement plutôt que de deviner qu'il faut déléguer à `apps/web` : `--filter web` (ou une exécution récursive `pnpm -r test`) reste la seule façon de cibler explicitement cette suite Vitest.

    Trois tests qui passent ne prouvent pas que la page compile ni qu'elle se comporte correctement en conditions réelles (CP1). `pnpm --filter web build` type-check l'ensemble du projet puis génère les routes : la sortie signale `/` avec le symbole ƒ (server-rendered on demand), et non ○ (statique), précisément parce que `cache: 'no-store'` dans `getHealth()` empêche Next.js de préconstruire cette page une fois pour toutes.

    La preuve finale se fait en conditions réelles, comme au [[TD09|td-09]] : PostgreSQL (docker compose du [[TD07|td-07]]/[[TD08|td-08]]) et `apps/api` (`pnpm --filter api start`) démarrés, puis `apps/web` (`pnpm --filter web dev`) sur le port 4100 — un curl sur http://localhost:4100/ montre bien le HTML contenant « [[API|api]] en ligne — base de données : up ». En arrêtant ensuite le processus de l'[[API|api]] et en rechargeant la même page, le même curl montre « [[API|api]] indisponible » : la page reflète fidèlement l'état réel de l'[[API|api]] à chaque requête, sans redémarrage du front.

    `apps/web` est désormais un front Next.js fonctionnel, capable d'observer en temps réel la disponibilité de l'[[API|api]] — la fondation sur laquelle les prochains TD ajouteront de vrais écrans métier (catalogue d'espaces, réservations). Le [[TD11|td-11]] démarre la Phase 4 ([[TDD|tdd]] strict, [[red-green-refactor|tdd]]) avec le premier [[repository|depot-git]] SQL réel : le [[CRUD|crud]] des espaces côté `apps/api`, sans authentification à ce stade. Le cours magistral associé (architecture logicielle) approfondit les patrons multicouche et le choix d'un rendu serveur pour ce type d'écran.

Spécifier le besoin de gestion des espaces de CoWorkNet (SDD)

  • Spécifier avant de coder : objectif du TD et méthode SDD

    Ce TD ouvre la phase 4 du fil rouge ([[CRUD|crud]] réel en [[TDD|tdd]] strict, [[TD12|td-12]]) par une étape de pure spécification : aucune ligne de code n'est écrite ici. Le livrable unique est `docs/specs/spec-espaces.md`, un document commité dans le dépôt CoWorkNet qui fixe, pour la ressource Espace, les champs exposés, les règles de validation, tous les cas d'erreur et le contrat exact des réponses [[JSON|json]] de `GET/POST/PATCH /espaces` — en reprenant à la lettre le contrat déjà stabilisé sans le renégocier. Cette démarche s'appelle le [[Spec-Driven Development|sdd]] ([[SDD|sdd]]) : la spécification précède et gouverne le code, elle n'est jamais reconstituée après coup à partir de ce qui a été implémenté. Ce TD mobilise le CP5 (analyser le besoin, formaliser des règles métier) et, sur la sécurité des champs d'entrée, le CP3.

    Ce document spécifie un [[CRUD|crud]] sans authentification : conformément au séquencement de sécurité du fil rouge, `/espaces` reste accessible sans [[JWT|jwt]], y compris en écriture, jusqu'au [[TD17|td-17]]/[[TD18|td-18]]. Ce n'est pas un oubli mais un choix pédagogique assumé — isoler ici la validation des données et les règles métier de la ressource Espace, avant d'ajouter par-dessus, sans y toucher, la couche d'autorisation (rôle `GERANT`/`ADMIN`, scoping par site). Le contrat des routes ne changera plus de forme après ce TD : seuls des codes 401/403 supplémentaires s'ajouteront plus tard, jamais un renommage de champ ou de route.

  • Dictionnaire des champs de la ressource Espace

    La ressource Espace exposée par l'[[API|api]] reprend exactement les colonnes de la table `espaces` déjà validées au MPD ([[TD04|td-04]]) : ce chapitre ne les invente pas, il les rend exploitables côté [[JSON|json]]. Pour le détail du MCD/MLD/MPD et des index déjà posés, se reporter au cours magistral sur les bases de données relationnelles (CP7) plutôt qu'à une redite ici.

    La conversion `snake_case` (SQL) vers `camelCase` ([[JSON|json]]) est une responsabilité de la couche controller, jamais de la base ni du [[repository|depot-git]] : ce document ne spécifie que la forme [[JSON|json]] exposée par l'[[API|api]], le SQL restant celui déjà validé dans `schema-donnees.md`. Deux colonnes appellent une précision immédiate : `id` n'est jamais fourni par le client (généré côté PostgreSQL par `gen_random_uuid()`, cf. [[TD04|td-04]]) et ignoré silencieusement s'il apparaît dans un corps de requête ; `siteId` est obligatoire à la création mais devient immuable ensuite, une règle détaillée au chapitre suivant.

  • Règles de validation et cas d'erreur, champ par champ

    Chaque règle ci-dessous s'applique intégralement à `POST /espaces`, et à `PATCH /espaces/:id` uniquement pour les champs effectivement présents dans le corps de la requête — un champ absent d'un `PATCH` n'est jamais revalidé, sa valeur en base reste inchangée. `siteId` : [[UUID|uuid]] v4 obligatoire à la création, devant référencer une ligne existante de `sites` ; le [[repository|depot-git]] vérifie explicitement cette existence avant insertion, pour renvoyer un message exploitable plutôt qu'une erreur PostgreSQL brute. `name` : chaîne non vide après trim, 100 caractères maximum.

    Côté cas d'erreur, `POST /espaces` renvoie 400 dès qu'une de ces règles est violée sur un champ obligatoire, ou que `siteId` ne référence aucun site existant, avec un code générique `VALIDATION_ERROR` et un tableau `errors` détaillant chaque champ fautif — un détail acceptable ici, à la différence de `POST /auth/login`, car il n'y a aucun risque d'énumération de comptes. `PATCH /espaces/:id` renvoie 404 si l'`id` de l'URL ne correspond à aucun espace, et 400 pour un corps vide, un champ invalide, ou une tentative de modifier `siteId`.

    Le code 403 du contrat `PATCH /espaces/:id` est réservé à l'autorisation du [[TD17|td-17]]/[[TD18|td-18]] (un `GERANT` qui modifierait un espace d'un autre site que le sien) : à ce stade du fil rouge ([[TD11|td-11]] à [[TD14|td-14]]), aucune requête ne peut recevoir un 403, la route restant ouverte à quiconque connaît l'`id` de l'espace, conformément au séquencement de sécurité rappelé au chapitre 1.

    Un espace appartient à exactement un site pour toute sa durée de vie (association (1,1) du MCD, [[TD04|td-04]]) : `siteId` est donc immuable après création, jamais réévaluable en `PATCH`. Cette règle anticipe aussi le scoping par site du `GERANT` ([[TD17|td-17]]/[[TD18|td-18]]) — la rendre modifiable permettrait de déplacer un espace vers un site que son gérant ne gère pas, sans qu'aucune autorisation ne l'empêche avant cette phase. Toute présence de `siteId` dans un corps de `PATCH` est donc rejetée en 400 (code `SITE_ID_IMMUTABLE`), plutôt que silencieusement ignorée comme `id`, pour que l'erreur soit visible dès l'écriture du client.

    `GET /espaces?siteId=&date=` ne renvoie, elle, jamais de code d'erreur : le contrat stabilisé ne prévoit qu'un 200 pour cette route publique (US1). Un `siteId` syntaxiquement invalide ou un `date` mal formé sont ignorés (traités comme absents) plutôt que rejetés — un visiteur du catalogue ne doit jamais recevoir d'erreur technique. Le filtrage par date reste, à ce TD, limité au statut `isActive` de l'espace : le croisement avec les réservations existantes sera spécifié séparément quand `/reservations` sera implémenté ([[TD19|td-19]]/[[TD20|td-20]]), sans modifier la forme de réponse.

  • Contrat exact des réponses JSON

    Fixer le corps exact des réponses, et pas seulement les codes de statut, évite qu'un test [[TDD|tdd]] du [[TD12|td-12]] n'invente une forme de son côté : trois exemples suffisent à couvrir `GET`, `POST` et `PATCH`.

    L'ordre de tri par `name` croissant n'est pas un détail cosmétique : sans ordre déterministe documenté, un test [[TDD|tdd]] comparant un tableau de résultats deviendrait fragile (l'ordre réel dépendant de l'implémentation du [[repository|depot-git]]), obligeant à trier côté test — ce que ce contrat évite en le fixant une fois pour toutes ici.

    Le champ `errors` de la réponse 400 est un tableau, même quand une seule règle est violée : cette forme reste stable qu'il y ait un ou plusieurs champs fautifs, ce qui simplifie le code client (toujours itérer sur `errors`) sans branche spéciale pour le cas à une seule erreur.

  • Anticiper l'injection SQL dès la spec des champs d'entrée

    La couche [[repository|depot-git]] de `apps/api` écrit du SQL à la main via le package `pg`, sans [[ORM|orm]] : un choix pédagogique délibéré, mais qui rend l'injection SQL une menace réelle dès le premier [[repository|depot-git]] écrit au [[TD12|td-12]], pas une abstraction de cours. Ce chapitre en anticipe la contre-mesure dès la spec des champs, plutôt que de la traiter après coup une fois le code déjà écrit — c'est le point du CP3 explicitement mobilisé par ce TD.

    Ces trois contre-mesures ne sont pas encore du code : elles sont ici des engagements de spec, vérifiables au [[TD12|td-12]] par les tests [[TDD|tdd]] (un test peut par exemple soumettre une valeur `name` contenant `'; DROP TABLE espaces; --` et vérifier qu'elle est stockée comme une chaîne inoffensive, jamais exécutée). Documenter cet engagement avant d'écrire le [[repository|depot-git]] force à concevoir la fonction de requête paramétrée dès le premier jet, plutôt que de corriger une concaténation dangereuse après une revue de code.

  • Rédiger, valider et committer la spécification

    Le document est rédigé dans `docs/specs/`, un dossier créé pour l'occasion à la racine du dépôt CoWorkNet (aux côtés de `docs/conception/` déjà peuplé aux [[TD01|td-01]] à [[TD04|td-04]]) : `docs/specs/` accueillera, à partir de ce TD, une spécification par ressource ou fonctionnalité avant son implémentation, en complément — jamais en substitut — des documents de conception globale déjà validés.

    Une fois `docs/specs/spec-espaces.md` rédigé avec les six sections des chapitres précédents (dictionnaire des champs, règles de validation, cas d'erreur, contrat des réponses, anticipation de l'injection SQL, section finale de validation), il reste à vérifier un par un les quatre critères de validation du document avant de le committer : correspondance exacte avec le MPD, exhaustivité des cas d'erreur par route, respect du séquencement de sécurité par phase, et formulation actionnable de la contre-mesure SQL.

    Le préfixe `TD11:` du message de [[commit|commit]] suit la convention de préfixe `TD{n}:` adoptée depuis le [[TD05|td-05]] (distincte du format Conventional Commits, cf. [[TD05|td-05]]). Ce [[commit|commit]] ne modifie aucun fichier de `apps/api` ni de `apps/web` : c'est le premier [[commit|commit]] du fil rouge à porter uniquement de la documentation, exactement à la mesure de ce TD de spécification pure — le prochain TD (implémentation [[TDD|tdd]] du [[CRUD|crud]] espaces) s'appuiera sur ce document sans le réécrire.

Développer l'API de gestion des espaces de CoWorkNet en TDD

  • Cycle TDD red-green-refactor : but de ce TD et rappel du contrat

    Ce TD ouvre l'implémentation réelle du [[CRUD|crud]] espaces : à la fin de ce TD, `apps/api/src/espaces` contiendra cinq fichiers de production (types, validation, mapper, [[repository|depot-git]], service, controller, module) et leurs tests, tous branchés dans `AppModule` et exposés sur `GET/POST/PATCH /espaces`. Rien n'est improvisé : chaque champ, chaque code d'erreur et chaque forme de réponse reprennent à la lettre `docs/specs/spec-espaces.md` rédigé au [[TD11|td-11]], qui ne sera jamais renégocié ici. Ce TD mobilise le CP3 (coder en style défensif, accès aux données sécurisés), le CP8 (SQL paramétré) et le CP9 ([[TDD|tdd]], jeu de tests unitaires).

    Le [[TDD|tdd]] strict [[red-green-refactor|tdd]] s'applique à chaque brique : rouge, un test qui décrit le comportement attendu et échoue puisque le code n'existe pas encore ; vert, le code minimal qui le fait passer ; refactor, une réorganisation qui ne change jamais le comportement observé par les tests déjà verts — par exemple extraire la conversion `snake_case`/`camelCase` de `espaces.controller.ts` vers `espaces.mapper.ts` une fois les premiers tests de contrôleur verts, sans qu'aucun test n'ait à changer.

    Avant d'appliquer ce cycle au [[CRUD|crud]] espaces, un exemple minimal hors-CoWorkNet l'isole sans le bruit de PostgreSQL ni de NestJS : une fonction `sum` écrite en [[TDD|tdd]] strict, une seule ligne rouge puis verte à la fois.

    L'architecture reste celle validée au [[TD03|td-03]] (`docs/conception/architecture-logicielle.md` §2) : controller -> service -> [[repository|depot-git]], sans court-circuit. Une nuance posée par `spec-espaces.md` §1 s'y ajoute : la conversion `camelCase` ([[JSON|json]]) <-> `snake_case` (SQL) est une responsabilité exclusive du controller, jamais de la base ni du [[repository|depot-git]] — les prochains chapitres respectent cette frontière à la lettre, y compris pour les noms de champs internes aux fonctions.

  • Tester son code : Jest, assertions, mocks

    Ce chapitre écrit le premier [[repository|depot-git]] accompagné de tests Jest : avant cela, un mot sur ce qu'est un [[test unitaire|test-unitaire]]. C'est un petit programme qui vérifie automatiquement qu'une fonction se comporte comme attendu, sans intervention humaine. Avec Jest, `describe('...', () => { ... })` regroupe des tests liés, `it('...', () => { ... })` déclare un cas de test précis, et `expect(valeur).matcher(attendu)` est une [[assertion|assertion]] : elle compare une valeur obtenue à une valeur attendue et fait échouer le test si elles diffèrent — `toBe`, `toEqual`, `toContain` sont des exemples de [[matcher|assertion]].

    Le [[repository|depot-git]] que vous allez écrire au chapitre suivant appelle PostgreSQL : pour tester sa logique sans dépendre d'une vraie base à chaque exécution, on lui substitue un [[mock|mock]] — un faux `DatabaseService` qui simule une réponse SQL préprogrammée plutôt que d'ouvrir une vraie connexion. On appelle aussi ce mécanisme un [[test double|test-double]] : un objet de remplacement, plus rapide et plus prévisible que la dépendance réelle, qui isole la seule logique qu'on veut vraiment tester.

    Ce même vocabulaire reviendra au [[TD14|td-14]], avec un autre outil (Bruno/Chai) qui joue un rôle proche.

  • Repository : SQL paramétré pour lire et écrire les espaces

    Premier fichier écrit (rouge : les tests de [[repository|depot-git]] ci-dessous n'ont d'abord aucune implémentation en face) : les types de la ressource Espace. Deux familles cohabitent volontairement — `EspaceRow` reflète les colonnes SQL (`snake_case`), `EspaceResponse` la forme [[JSON|json]] (`camelCase`) — pour matérialiser dans le typage la frontière posée au chapitre 1.

    `espaces.repository.ts` est la seule couche autorisée à écrire du SQL (`architecture-logicielle.md` §2.4). `GET /espaces` filtre optionnellement par `siteId` et, si un `date` syntaxiquement valide a été fourni, par `is_active = true` (`spec-espaces.md` §3.4) : la clause `WHERE` est construite dynamiquement dans son texte, jamais dans ses valeurs, chaque valeur restant transmise via un paramètre positionnel `$1`, `$2`...

    `siteExists` interroge sites avant toute insertion (le service en aura besoin pour renvoyer un `VALIDATION_ERROR` exploitable plutôt qu'une violation de contrainte `FOREIGN KEY` brute, `spec-espaces.md` §2). `create` insère avec `COALESCE($5, true)` pour appliquer le défaut `isActive` quand il est absent du corps ; `update` ne modifie que les colonnes réellement fournies, chacune paramétrée individuellement.

    Le test rouge écrit en premier pour `findAll` vérifie justement l'absence de concaténation : il inspecte le texte SQL généré et s'assure que la valeur de `siteId` n'y apparaît jamais en clair, seulement sous forme de `$1`, pendant que `params` contient la vraie valeur. C'est ce test qui rend visible, dès la couche la plus sensible à l'injection SQL, l'engagement pris en spec au [[TD11|td-11]] (§5).

    Parmi les 60 tests du module, l'un porte spécifiquement sur ce que le [[repository|depot-git]] ne doit surtout pas faire : masquer une erreur de connexion à PostgreSQL. Si `databaseService.query` rejette, `findAll` doit rejeter à son tour avec la même erreur, sans la capturer ni la transformer silencieusement — une couche supérieure (service, puis un filtre d'exception global) reste seule responsable de la traduire en réponse HTTP.

  • Valider la forme et convertir camelCase/snake_case

    `espaces.validation.ts` porte la validation de forme du corps `POST/PATCH` (`spec-espaces.md` §2) : type natif, bornes de longueur, [[UUID|uuid]], booléen strict. Une fois un corps validé, la fonction produit directement l'objet `snake_case` attendu par le [[repository|depot-git]] — c'est cette conversion, et elle seule, qui matérialise la règle du [[TD11|td-11]] assignant ce rôle au controller (seul appelant de ce module).

    `capacity` doit être un entier natif, jamais une chaîne numérique (`spec-espaces.md` §2) : `Number.isInteger` le garantit, une `capacity` '8' (chaîne) est donc rejetée exactement comme 0 ou 501. Le test rouge correspondant a été écrit avant cette fonction, avec des valeurs volontairement limites (0, 501, une chaîne) pour couvrir les deux bornes en un seul passage vert.

    `name` illustre une limite fréquente de la validation par simple présence : une chaîne composée uniquement d'espaces (`' '`) n'est pas vide au sens JavaScript, mais le devient après `trim()` — la règle « chaîne non vide après trim » (`spec-espaces.md` §2) couvre explicitement ce cas, vérifié par son propre test.

    `espaces.mapper.ts` referme la boucle : une seule fonction, appelée uniquement par le controller, qui transforme une `EspaceRow` renvoyée par le [[repository|depot-git]] en `EspaceResponse` [[JSON|json]]. Aucune autre fonction du module ne renomme `site_id` en `siteId` ou `is_active` en `isActive` — un choix vérifiable dans les specs de [[repository|depot-git]] et de service, qui n'assertent jamais que sur des clés `snake_case`.

  • Service : les règles qui nécessitent un accès aux données

    `espaces.service.ts` (`architecture-logicielle.md` §2.3) ne construit jamais lui-même de SQL : il appelle le [[repository|depot-git]] avec des paramètres déjà validés par le controller, et ne porte que les deux règles qui ont besoin d'un accès aux données pour être tranchées — l'existence du site référencé à la création, et l'existence de l'espace ciblé par un `PATCH`.

    `update` relit l'espace avant d'écrire (`findById`) pour renvoyer un 404 `ESPACE_NOT_FOUND` propre plutôt qu'un `UPDATE` silencieusement sans effet, puis revérifie le résultat de la mise à jour elle-même : entre les deux appels, une suppression concurrente resterait détectée sans jamais renvoyer un objet vide au client. Ce double filet est le seul endroit du service où deux appels au [[repository|depot-git]] se suivent pour une même opération métier.

  • Controller : orchestrer sans logique métier ni SQL

    `espaces.controller.ts` délègue immédiatement au service et ne contient ni requête SQL ni règle métier (`architecture-logicielle.md` §2.2) : son seul travail propre est d'appeler `validateCreateBody`/`validateUpdateBody` avant d'atteindre le service, puis `toEspaceResponse` sur le résultat. `GET` reste sans validation possible (`spec-espaces.md` §3.4) : un `siteId` ou un `date` syntaxiquement invalide sont simplement ignorés, jamais transformés en 400.

    Sur `PATCH`, l'ordre des vérifications est fixé volontairement : `siteId` présent (400 `SITE_ID_IMMUTABLE`) avant corps vide (400 `VALIDATION_ERROR`) avant champs invalides (400 `VALIDATION_ERROR`), toutes trois avant tout appel au service — donc avant le 404 `ESPACE_NOT_FOUND`, qui nécessite une requête à la base. Cet ordre, verrouillé par les tests de contrôleur, évite une lecture de base inutile quand le corps de la requête est de toute façon rejeté.

    Dernière étape de câblage : `EspacesModule` regroupe controller, service et [[repository|depot-git]], puis s'importe dans `AppModule` aux côtés de `HealthModule` déjà présent depuis le [[TD09|td-09]] — sans toucher au reste du fichier.

  • Exécuter les tests, vérifier le contrat, committer

    Chaque brique a été développée en rouge-vert-refactor puis regroupée : il reste à faire tourner l'ensemble des tests unitaires du module espaces avant de committer, jamais l'inverse. La commande cible uniquement le dossier espaces pour une boucle rapide pendant le développement, avant un passage complet.

    Les tests unitaires mockent `DatabaseService` et n'ouvrent donc aucune connexion réelle. Un second niveau de tests, en intégration, vérifie le contrat HTTP complet contre `coworknet_db` (`localhost:5544`) : un site de test dédié est créé en `beforeAll` et entièrement supprimé en `afterAll` (site et espaces créés pendant les tests), pour ne jamais laisser `db/seed.sql` altéré après une exécution.

    63 tests unitaires et 9 tests e2e verts, et `coworknet_db` revenue à son état de seed initial (4 espaces, 2 sites) une fois les tests terminés : le [[CRUD|crud]] espaces est prêt à être commité, toujours sans authentification à ce stade du fil rouge.

Développer la page catalogue de CoWorkNet

  • But de ce TD et périmètre de l'écran catalogue

    Ce TD construit la première page réellement dynamique d'`apps/web` : jusqu'ici, la page d'accueil ([[TD10|td-10]]) ne faisait qu'afficher un statut d'[[API|api]]. À la fin de ce TD, `http://localhost:4100/espaces` affiche le catalogue réel des espaces de `coworknet_db` (nom, site, capacité, type), filtrable par site et par date, en interrogeant `GET /espaces` de l'[[API|api]] validée au [[TD11|td-11]]/[[TD12|td-12]]. Ce TD mobilise le CP2 (développer des interfaces utilisateur) : consommer un service distant [[REST|rest]], adapter l'affichage aux filtres choisis, gérer les événements d'interface (`onChange`), et écarter le risque [[XSS|xss]] sur des données [[API|api]] affichées côté client.

    Ce TD reprend, sans la redessiner, l'écran A de `docs/conception/maquettes-ecrans.md` ([[TD02|td-02]]) : un select « Site », un champ « Date », une grille de cartes. Deux éléments de cette maquette restent volontairement hors périmètre ici : le lien « Réserver » de chaque carte (US2, qui suppose une authentification non encore construite avant [[TD15|td-15]]/[[TD16|td-16]]) et les liens « Se connecter »/« S'inscrire ». Ce TD se limite strictement à l'objectif annoncé : afficher et filtrer le catalogue depuis `GET /espaces`, sans anticiper la réservation.

    `GET /espaces` est une lecture publique sans effet de bord : aucune requête d'écriture n'est déclenchée par cette page, donc aucun jeton anti-[[CSRF|csrf]] n'est nécessaire ici (le [[CSRF|csrf]] protège les requêtes qui modifient un état, pas un GET [[idempotent|idempotent]] — un appel qu'on peut répéter autant de fois qu'on veut sans jamais changer l'état du serveur). Le risque à traiter dans ce TD est le [[XSS|xss]] : afficher `name` ou `type` sans précaution suffirait, si l'[[API|api]] renvoyait un jour du HTML malveillant, à l'exécuter dans le navigateur d'un visiteur. La parade retenue est détaillée au chapitre 3.

  • Consommer GET /espaces comme service distant (lib/api.ts)

    `lib/api.ts` porte déjà `getHealth` ([[TD10|td-10]]) : une fonction qui interroge un service distant et ne lève jamais, quel que soit l'échec (réseau, HTTP, parsing). `getEspaces` reprend exactement cette convention pour `GET /espaces`, en y ajoutant la construction d'une query string optionnelle : `siteId` et `date` ne sont ajoutés à l'URL que s'ils sont réellement fournis et non vides — un filtre vide (« Tous les sites ») ne doit jamais produire `?siteId=` dans l'URL envoyée à l'[[API|api]].

    `URLSearchParams` construit la query string sans jamais interpoler de chaîne à la main : chaque valeur passée à `set()` est encodée automatiquement (un nom de site ou une date contenant un caractère spécial ne casse jamais l'URL générée). `query.toString()` renvoyée vide signifie qu'aucun filtre n'a été fourni : l'URL finale reste alors `/espaces`, sans point d'interrogation, exactement comme un appel direct à l'[[API|api]] sans filtre.

    `cache: 'no-store'` est repris tel quel de `getHealth` : le catalogue change dès qu'un espace est créé, modifié ou désactivé ([[TD12|td-12]]), et cette page est de toute façon rendue dynamiquement à chaque requête puisqu'elle dépend des search params du filtre — un cache HTTP côté Next.js n'aurait ici aucun sens avant l'introduction du cache Redis en lecture seule prévue au [[TD23|td-23]].

    Trois cas sont testés en vitest, à l'identique du style déjà utilisé pour `getHealth` (`lib/api.test.ts`) : un appel sans filtre ne doit ajouter aucun paramètre à l'URL, un appel avec `siteId` et `date` doit les retrouver tous les deux dans l'URL appelée, et un filtre vide (`''`) ne doit jamais apparaître. S'y ajoutent les deux cas déjà couverts pour `getHealth` : réponse HTTP en erreur, et `fetch` qui rejette ([[API|api]] injoignable).

  • Page catalogue en Server Component, sans risque XSS

    `GET /espaces` (`spec-espaces.md` §1) ne renvoie que `siteId`, jamais de nom de site lisible — le contrat [[API|api]] est figé depuis le [[TD11|td-11]] et n'ajoute aucune route `GET /sites`. Plutôt que d'ajouter une route hors contrat, `lib/sites.ts` fige en dur les deux sites du jeu d'essai (`db/seed.sql`, [[TD8|td-08]]) pour le seul usage du libellé affiché : limitation documentée, à lever le jour où une route `GET /sites` rejoindra le contrat.

    `app/espaces/page.tsx` est un [[Server Component|server-component]] asynchrone, comme `app/page.tsx` depuis le [[TD10|td-10]]. En Next.js 16, `searchParams` est une `Promise` qu'il faut attendre avant de lire `siteId`/`date` — une lecture synchrone renverrait toujours `undefined`. `getEspaces` est donc appelée avec les filtres une fois cette `Promise` résolue, et son résultat conditionne trois rendus possibles : erreur ([[API|api]] indisponible), liste vide (aucun résultat pour ces filtres), ou grille de cartes.

    `name` et `type` viennent directement de la réponse [[JSON|json]] de l'[[API|api]], sans aucune validation de contenu côté front : c'est volontairement React qui porte la protection [[XSS|xss]] ici. `{espace.name}` est un texte interpolé en JSX, échappé automatiquement au rendu — jamais `innerHTML`, jamais `dangerouslySetInnerHTML`. Une donnée malveillante (ex. un `name` contenant `<script>`) s'afficherait comme du texte littéral, jamais exécutée par le navigateur.

  • Filtres Site/Date : gérer les événements de l'interface

    `app/espaces/espaces-filters.tsx` est un [[Client Component|client-component]] (`'use client'`) : lui seul a besoin d'écouter des événements navigateur (`onChange`), ce qu'un [[Server Component|server-component]] ne peut pas faire. Conformément à l'écran A (`regles-rgaa-ux.md`) et à la convention du projet (pas de bouton « Enregistrer »), chaque changement de select ou de date est appliqué immédiatement, sans étape de validation supplémentaire : le composant réécrit les search params de l'URL et navigue, ce qui redéclenche le [[Server Component|server-component]] avec le nouveau filtre.

    `updateFilter` part toujours des search params déjà présents dans l'URL (`searchParams.toString()`) avant d'ajouter ou de retirer le filtre modifié : changer la `date` ne doit jamais effacer le `siteId` déjà choisi, et réciproquement. Une valeur vide (site remis à « Tous les sites ») retire la clé plutôt que d'écrire `?siteId=` : c'est la même règle de query string propre que celle déjà posée côté `getEspaces` au chapitre 2.

    Chaque champ porte un `<label>` associé par `htmlFor`/`id` (règle [[RGAA|rgaa]] n°3 de `regles-rgaa-ux.md`) : le select « Site » comme le champ « Date », jamais un simple `placeholder`. Le formulaire entier porte un `aria-label` explicite pour qu'un lecteur d'écran annonce son rôle avant d'en lire le détail. Le champ date utilise `input type="date"`, qui expose nativement un contrôle clavier complet, sans widget personnalisé à rendre accessible soi-même.

  • Vérifier, tester en conditions réelles et committer

    Avant de committer : `pnpm --filter web test` (nouveaux cas de `getEspaces` ajoutés aux 3 déjà existants pour `getHealth`), `tsc --noEmit` sur `apps/web`, puis `next build` pour vérifier que `/espaces` est bien listée comme route dynamique (ƒ), cohérente avec sa dépendance aux search params — jamais prérendue en statique, à la différence d'une route sans filtre.

    Vérification manuelle en conditions réelles, [[conteneur|conteneur-docker]] `coworknet-db` déjà démarré : [[api|api]] et web lancés en local (ports 4000/4100), puis un `curl` sur `/espaces` confirme les 4 espaces du seed (dont l'espace désactivé Salle Confluence), et un `curl` avec `?date=2026-07-14` en affiche 3 — l'espace désactivé disparaît uniquement quand une date valide est fournie, un comportement déjà fixé côté [[API|api]] au [[TD12|td-12]] et simplement restitué ici tel quel, sans aucune logique de filtrage dupliquée côté front.

Tester l'API des espaces de CoWorkNet avec Bruno

  • But de ce TD et cadrage (CP9, fuzzing)

    Ce TD ne modifie aucune ligne de `apps/api` : son livrable est une [[collection Bruno|collection-bruno]] (un ensemble de requêtes HTTP sauvegardées et rejouables, détaillé ci-dessous ; dossier `bruno/CoWorkNet/espaces/`), exécutable via le CLI `bru` (l'outil en ligne de commande de Bruno), qui rejoue contre l'[[API|api]] réelle (docker `coworknet-db` + `pnpm --filter api start:dev`, port 4000) l'intégralité du contrat figé au [[TD11|td-11]] (`docs/specs/spec-espaces.md`) : cas nominaux, bornes exactes des champs, et surtout des valeurs de fuzzing (confusion de type, injection SQL, formats limites) que les tests unitaires Jest du [[TD12|td-12]] ne couvrent pas systématiquement. À la fin de ce TD, `npx @usebruno/cli run espaces -r --env local` exécute 33 requêtes et affiche un résumé PASS, committé dans le dépôt CoWorkNet.

    Avant d'écrire la moindre requête `.bru`, un rappel utile du contrat déjà figé au [[TD11|td-11]] : le premier fichier de la collection (chapitre 3) vérifie précisément cette règle sur `GET /espaces`, jamais en erreur quelle que soit l'entrée.

    Bruno est un [[client API|client-api]] : un outil qui permet d'envoyer soi-même des requêtes HTTP (`GET`, `POST`, `PATCH`...) vers une [[API|api]], sans passer par un navigateur ni par un test automatisé — utile pour explorer ou vérifier manuellement un [[endpoint|endpoint]]. Une **collection** Bruno est un ensemble de ces requêtes sauvegardées et rejouables, organisées en fichiers `.bru` ; un **environnement** (comme `local.bru`) centralise les valeurs qui changent d'un contexte à l'autre (URL de base, identifiants), pour qu'une même collection tourne en local ou ailleurs sans être réécrite.

    Chaque requête `.bru` embarque aussi des assertions écrites avec **Chai**, une bibliothèque différente de Jest ([[TD12|td-12]]) mais qui joue exactement le même rôle : `expect(res.body).to.eql(...)` ou `.to.include(...)` vérifient une valeur de réponse, comme le ferait `expect(...).toEqual(...)` en Jest — seule la syntaxe change.

    Ce TD mobilise le CP9 (préparer et exécuter les plans de tests) sur trois axes distincts des tests Jest déjà écrits au [[TD12|td-12]] : un plan de tests d'intégration manuel/automatisé rejouable via un outil dédié (Bruno) plutôt qu'un framework de test ; une recherche de failles par fuzzing (valeurs volontairement absurdes ou malveillantes : chaînes numériques, flottants, charge SQL, id non-[[UUID|uuid]]) ; et une vérification de conformité champ par champ contre le contrat écrit, indépendante de l'implémentation qui l'a produit.

  • Structurer la collection : environnement et site de test dédié

    Une [[collection Bruno|collection-bruno]] est un dossier de fichiers `.bru` versionnés dans Git, au même titre que du code. `bruno.json` déclare la collection à la racine `bruno/CoWorkNet/`, et `environments/local.bru` centralise l'URL de base et les identifiants de sites réutilisés par toutes les requêtes — jamais codés en dur dans chaque fichier, pour ne changer qu'un seul endroit si le port de l'[[API|api]] évoluait.

    `local.bru` fige trois identifiants de site directement issus du jeu d'essai (`db/seed.sql`, [[TD8|td-08]]) : `parisSiteId` et `lyonSiteId` pour les requêtes de lecture (`GET`) contre le catalogue de démonstration existant, et un troisième `testSiteId` dédié exclusivement aux écritures.

    Le contrat [[API|api]] stabilisé au [[TD11|td-11]] ne définit aucune route `DELETE /espaces` (hors périmètre [[MVP|mvp]]) : tout espace créé par un `POST` de la collection reste donc en base indéfiniment. Écrire directement dans « CoWork Paris Bastille » ou « CoWork Lyon Part-Dieu » polluerait le catalogue de démonstration à chaque exécution. La parade retenue est un troisième site, réservé aux écritures, ajouté à `db/seed.sql` avec un commentaire explicite sur cette limitation et sur la procédure de nettoyage (rejouer `schema.sql` + `seed.sql` sur une base fraîche).

    Ce nouveau site n'utilise pas le même [[UUID|uuid]] que celui créé/détruit dynamiquement par `apps/api/test/espaces.e2e-spec.ts` (99999999-...) : les deux mécanismes de test resteraient sinon en compétition, le nettoyage de l'un supprimant la fixture permanente de l'autre à chaque exécution des tests e2e.

  • Cas nominaux et bornes exactes du contrat

    Chaque requête `.bru` porte un bloc `assert` (sur `res.status`) et un bloc `tests` (assertions Chai plus fines sur le corps). Un premier fichier vérifie la forme exacte de `GET /espaces` sans filtre : tableau non vide, chaque élément avec exactement les six clés camelCase du contrat, et tri par `name` croissant — un point du contrat (`spec-espaces.md` §4.1) qu'aucun test Jest du [[TD12|td-12]] ne vérifiait explicitement sur l'ordre complet de la liste.

    Les bornes exactes de `spec-espaces.md` §2 ("longueur maximale 100 caractères") méritent un test à la limite elle-même, pas seulement au-delà : un `name` de 100 caractères doit être accepté (201), quand 101 est rejeté. Ce couple de fichiers (14 et 15 de la collection) documente les deux côtés de la frontière, chacun capturant l'id créé dans une variable d'environnement pour un usage ultérieur si besoin.

    Le test le plus révélateur du chapitre porte sur l'immuabilité de `siteId` (`spec-espaces.md` §3.3) : envoyer en `PATCH` exactement la même valeur de `siteId` déjà en base doit tout de même être rejeté (400 `SITE_ID_IMMUTABLE`). Ce cas piège une implémentation qui comparerait la valeur envoyée à la valeur existante avant de décider de rejeter, alors que la règle du contrat est « toute présence » du champ, indépendamment de sa valeur.

  • Fuzzing : confusion de types, injection SQL, faille découverte

    Le fuzzing consiste à soumettre des valeurs qu'un développeur n'imagine pas spontanément en écrivant ses propres tests, précisément parce qu'il connaît déjà l'implémentation. `spec-espaces.md` §2 précise que `capacity` doit être un « entier natif (jamais une chaîne numérique) » : une chaîne "8" ressemble à un nombre valide pour un humain pressé, mais viole ce contrat si la validation se contente d'une coercition implicite (`Number("8") === 8`) plutôt que d'un contrôle de type strict.

    `spec-espaces.md` §5 anticipe explicitement l'injection SQL, neutralisée par le paramétrage `pg` (`$1`, `$2`...) de `espaces.repository.ts`. Le fuzzing le plus direct consiste à soumettre une charge utile SQL classique dans `name` : si le [[repository|depot-git]] construisait la requête par concaténation, cette charge exécuterait un `DROP TABLE` ; avec un paramétrage correct, elle est stockée comme une chaîne de caractères ordinaire, vérifiable par une lecture `GET` immédiatement après.

    Un dernier fuzzing cible le paramètre de route `:id` de `PATCH /espaces/:id`, jamais testé au [[TD12|td-12]] avec une valeur qui n'est même pas syntaxiquement un [[UUID|uuid]]. Le contrat (`spec-espaces.md` §3.2) ne documente que 200/400/404/403 : aucun cas n'est prévu pour `"not-a-uuid"`. En pratique, `EspacesRepository.findById` transmet cette valeur telle quelle à PostgreSQL sur une colonne `uuid`, qui rejette la requête ; Nest retombe alors sur son gestionnaire par défaut et répond 500, un code hors contrat.

    Ce test fige le comportement observé (500) au lieu de l'exiger corrigé : la correction (par exemple valider `:id` avec `isValidUuid` avant d'appeler le [[repository|depot-git]], pour renvoyer 404 uniformément) toucherait `espaces.controller.ts` et `espaces.repository.ts`, hors périmètre de ce TD centré sur l'écriture de la [[collection Bruno|collection-bruno]] elle-même. La découverte est documentée dans les commentaires du fichier `.bru` et signalée explicitement au TD suivant qui modifierait ces fichiers.

  • Exécuter la collection, vérifier la non-régression et committer

    Avec l'[[API|api]] démarrée (port 4000) et le site de test déjà inséré en base, la collection s'exécute entièrement avec `bru run` en mode récursif, dans l'ordre défini par le champ `seq` de chaque fichier (nécessaire ici : les requêtes `POST`/`PATCH` dépendent des id capturés par les fichiers précédents via `bru.setVar`).

    La [[collection Bruno|collection-bruno]] ne remplace pas les tests Jest du [[TD12|td-12]] : elle vérifie l'[[API|api]] depuis l'extérieur, en conditions réelles. Avant de committer, les suites Jest existantes sont rejouées pour confirmer qu'aucune régression n'a été introduite (aucun fichier `apps/api` n'a pourtant été modifié dans ce TD, cette vérification reste malgré tout systématique) : 63 tests unitaires et 9 tests e2e restent au vert.

    Le [[commit|commit]] rassemble la [[collection Bruno|collection-bruno]] et l'unique modification de `db/seed.sql` (ajout du site de test), sans toucher à aucun fichier de `apps/api` ni de `apps/web`.

    Le cours magistral associé (CP9, préparation et exécution des plans de tests) approfondit la théorie des plans de tests et des campagnes de fuzzing au-delà de cet exemple appliqué à `/espaces`.

Spécifier l'authentification de CoWorkNet (SDD)

  • Spécifier avant de coder : objectif du TD et rappel du séquencement

    Ce TD ouvre la phase « Bascule sécurité », obligatoire à partir d'ici, par une étape de pure spécification, comme au [[TD11|td-11]] pour les espaces : aucune ligne de code n'est écrite. Le livrable unique est `docs/specs/spec-auth.md`, un document commité dans le dépôt CoWorkNet qui fixe la politique de mot de passe, le [[hachage|hachage]] `bcrypt`, le contrat exact de `POST /auth/register` et `POST /auth/login`, ainsi que le format et la durée de vie du [[JWT|jwt]] — en reprenant à la lettre les règles de sécurité [[TD15|td-15]]/[[TD16|td-16]] déjà fixées par la spécification produit, sans les renégocier. Ce TD mobilise le CP3 : bases de la cryptographie et mécanismes de sécurité (hashage jamais en clair, salage), guide ANSSI et OWASP, règles de sécurisation (politique de mots de passe, gestion des comptes).

    Ce document ne touche ni `/espaces` ni `/reservations` : conformément au séquencement de sécurité par phase, la protection par [[JWT|jwt]] de `/espaces` est un retrofit réservé au [[TD17|td-17]]/[[TD18|td-18]], et celle de `/reservations` sera posée dès sa conception au [[TD19|td-19]]/[[TD20|td-20]]. Ce [[TD15|td-15]] spécifie uniquement le socle d'authentification lui-même — créer un compte, obtenir un jeton — pas encore son usage pour protéger une autre route. Le contrat des deux routes `/auth` ne changera plus de forme après ce TD : le [[TD16|td-16]] les implémente en [[TDD|tdd]] strict sans renégocier un champ ni un code d'erreur.

  • Politique de mot de passe et hachage bcrypt

    Avant tout [[hachage|hachage]], un mot de passe doit être validé contre une politique de complexité fixée par la spécification produit. Cette politique n'est pas une contrainte arbitraire : chacune de ses règles répond à un scénario d'attaque documenté par l'OWASP (mot de passe trop court ou trop simple, force brute facilitée), et sa formalisation précise avant le [[TD16|td-16]] évite qu'un test [[TDD|tdd]] n'invente sa propre regex de validation.

    Avant `bcrypt`, un mot sur le [[hachage|hachage]]. Contrairement à un chiffrement (réversible avec une clé), une [[fonction de hachage|hachage]] transforme une donnée en une empreinte impossible à reconvertir vers l'original — d'où le nom `password_hash` plutôt que le mot de passe en clair : même en cas de fuite, l'attaquant récupère des empreintes, pas des mots de passe exploitables.

    La borne haute de 72 caractères n'est pas symétrique à la borne basse par souci d'esthétique : elle protège contre une limitation réelle de `bcrypt`, qui tronque silencieusement toute entrée au-delà de 72 octets. Deux mots de passe partageant les mêmes 72 premiers octets produiraient alors le même [[hash|hachage]], sans qu'aucun message n'avertisse l'utilisateur. Plutôt que de laisser la bibliothèque tronquer en silence, la spécification rejette explicitement tout mot de passe trop long en amont, avec une erreur de validation normale.

    L'absence de colonne `salt` dédiée dans le MPD n'est donc pas un oubli du [[TD04|td-04]] : `bcrypt` embarque déjà son sel dans la chaîne stockée, une seule colonne `password_hash` suffit. En contrepartie, le mot de passe en clair ne doit jamais franchir certaines frontières, listées explicitement ci-dessous pour guider la revue de code du [[TD16|td-16]].

    Sans ce sel aléatoire, deux utilisateurs choisissant le même mot de passe produiraient le même [[hash|hachage]] — visible à l'œil nu en cas de fuite de la base, et surtout cassable en une seule fois pour tous les comptes concernés via une table précalculée (« rainbow table »). Le sel rend cette précalculation inutilisable : chaque [[hash|hachage]] doit être attaqué individuellement, même à mot de passe identique.

  • Contrat POST /auth/register : champs et anti-escalade de privilège

    `POST /auth/register` est la seule route d'inscription publique du périmètre [[MVP|mvp]] (US6) : elle ne permet de créer que des comptes `MEMBRE`. Le dictionnaire ci-dessous ne liste que deux champs réellement acceptés en entrée — tout le reste (`id`, `role`, `siteId`) est soit généré serveur, soit ignoré silencieusement si le client tente de le fournir, une décision détaillée plus loin dans ce chapitre.

    La réponse 201 ne renvoie que `id` et `email`, jamais le mot de passe ni son [[hash|hachage]], conformément au contrat [[API|api]] stabilisé `POST /auth/register -> 201 {id,email}`.

    Côté erreurs, `EMAIL_ALREADY_USED` signale un email déjà présent en base (vérifié explicitement par le [[repository|depot-git]] avant insertion, même logique que `siteId` inexistant sur `POST /espaces`), et `VALIDATION_ERROR` couvre un email mal formé ou un mot de passe qui viole une règle du chapitre précédent, avec un tableau `errors` détaillé field par field. Révéler qu'un email est déjà pris à l'inscription n'est pas une fuite : le contrat le prévoit explicitement, contrairement à la connexion (chapitre suivant).

    `role` et `siteId` ne sont jamais lus depuis le corps de la requête, même s'ils y figurent : le rôle créé est toujours `MEMBRE` et `site_id` toujours `NULL`, quelle que soit la valeur soumise. Sans cette règle, n'importe quel visiteur pourrait s'auto-attribuer le rôle `ADMIN` en ajoutant simplement `"role":"ADMIN"` à sa requête d'inscription. Un compte `GERANT` ou `ADMIN` est donc nécessairement créé hors de cette route, par insertion directe en base par une personne habilitée — cohérent avec l'absence, dans le périmètre [[MVP|mvp]], de toute route d'administration des comptes.

  • Contrat POST /auth/login : erreur générique et anti-timing-attack

    Le contrat [[API|api]] stabilisé ne prévoit que deux issues pour `POST /auth/login` : 200 ou 401. Aucun 400 n'existe pour cette route, y compris pour un corps de requête vide, mal formé ou dont les champs sont d'un type inattendu : toute défaillance, quelle que soit sa nature, converge vers la même réponse 401. Ce choix simplifie délibérément la surface d'erreur exposée par la route d'authentification la plus sensible de l'[[API|api]] : il n'existe qu'un seul état d'échec observable de l'extérieur.

    L'unique réponse 401, `{ "code": "INVALID_CREDENTIALS" }`, couvre indifféremment un email qui ne correspond à aucune ligne de `users`, un `password` incorrect pour un email existant, ou un corps de requête malformé. Le message ne doit jamais distinguer laquelle de ces causes s'est produite, ni dans le code, ni dans un champ `errors`, ni dans les logs applicatifs accessibles côté client. Cette règle reprend directement la contre-mesure à la fuite d'information de `architecture-logicielle.md` §6 : une erreur trop précise permettrait à un attaquant d'énumérer les comptes existants par tâtonnement.

    Renvoyer le même corps de réponse pour les deux cas ne suffit pas si le temps de réponse diffère : calculer un [[hash|hachage]] `bcrypt` (coût 12) prend un temps mesurable, donc un service qui compare le mot de passe uniquement quand l'email existe répond plus vite pour un email inconnu. Cet écart de latence permettrait à un attaquant d'inférer l'existence d'un compte sans lire un message différent. Contre-mesure retenue : lorsqu'aucun utilisateur ne correspond à l'email fourni, le service exécute tout de même une comparaison `bcrypt.compare()` contre un [[hash|hachage]] factice de coût 12 fixé en constante applicative, avant de renvoyer le 401 générique — le temps de traitement reste du même ordre de grandeur que pour un mot de passe réellement incorrect.

  • Format et durée de vie du JWT

    Le jeton est signé en `HS256` (algorithme symétrique à clé partagée) via le package `@nestjs/jwt`, avec un secret unique lu depuis la variable d'environnement `JWT_SECRET` au travers de `ConfigService` — jamais codé en dur, jamais lu directement via `process.env`. Ce choix diffère volontairement de la plateforme ecole (Keycloak, [[JWT|jwt]] `RS256` asymétrique) : la spécification produit de CoWorkNet impose `HS256` pour rester un projet pédagogique autonome, sans dépendance à un serveur d'identité externe.

    Un [[JWT|jwt]] est une chaîne de trois segments base64url séparés par des points : `header.payload.signature`. Point de sécurité important : le payload (les claims ci-dessous) n'est pas chiffré, seulement encodé — n'importe qui peut le décoder et lire `role` ou `siteId` sans connaître `JWT_SECRET`. Seule la signature est infalsifiable sans ce secret : elle garantit l'intégrité du jeton, pas la confidentialité de son contenu.

    `role` et `siteId` sont embarqués dans le jeton pour que le [[TD17|td-17]]/[[TD18|td-18]] (retrofit d'autorisation sur `/espaces`) puisse scoper un `GERANT` à son site sans requête supplémentaire à `users` sur chaque appel protégé — une optimisation déjà anticipée ici, sans en implémenter l'usage. L'expiration est fixée à 2 heures (`expiresIn: '2h'`), sans aucun mécanisme de rafraîchissement, hors périmètre du [[MVP|mvp]]. Le jeton est renvoyé une seule fois dans le corps de la réponse 200 de `POST /auth/login`, jamais dans un cookie côté CoWorkNet ; son transport par en-tête `Authorization: Bearer` sera spécifié quand une première route l'exigera, au [[TD17|td-17]]/[[TD18|td-18]].

  • Rédiger, valider et committer la spécification

    Le dossier `docs/specs/` existe déjà depuis le [[TD11|td-11]] (`spec-espaces.md`) : ce TD y ajoute simplement `spec-auth.md`, sans créer de nouveau dossier. Une fois le document rédigé avec les six sections des chapitres précédents (dictionnaire des champs, politique de mot de passe et [[hachage|hachage]], contrat `POST /auth/register`, contrat `POST /auth/login`, format du [[JWT|jwt]], section finale de validation), il reste à vérifier un par un les quatre critères de validation avant de le committer.

    Le préfixe `TD15:` suit la convention de préfixe `TD{n}:` adoptée depuis le [[TD05|td-05]] (distincte du format Conventional Commits, cf. [[TD05|td-05]]). Ce [[commit|commit]] ne modifie aucun fichier de `apps/api` ni de `apps/web` : comme le [[TD11|td-11]], c'est un TD de spécification pure. Le prochain TD ([[TD16|td-16]]) implémentera `POST /auth/register` et `POST /auth/login` en [[TDD|tdd]] strict [[red-green-refactor|tdd]], en s'appuyant sur ce document sans le réécrire — puis le [[TD17|td-17]]/[[TD18|td-18]] reviendra protéger `/espaces` par [[JWT|jwt]] et rôle, sans jamais faire régresser les tests [[TD12|td-12]].

Développer l'authentification de CoWorkNet en TDD

  • Cycle TDD et but de ce TD : implémenter le socle d'authentification

    Ce TD referme la boucle ouverte au [[TD15|td-15]] : à la fin de cette séance, `apps/api/src/auth` contiendra six fichiers de production (types, validation, [[repository|depot-git]], service, controller, module) et leurs tests, tous branchés dans `AppModule` et exposés sur `POST /auth/register` et `POST /auth/login`. Rien n'est improvisé — chaque champ, chaque code d'erreur et chaque claim du [[JWT|jwt]] reprennent à la lettre `docs/specs/spec-auth.md` rédigé au [[TD15|td-15]], qui ne sera jamais renégocié ici. Ce TD mobilise le CP3 (sécurité côté serveur, gestion des identités) et le CP9 ([[TDD|tdd]], plan de tests) : [[hachage|hachage]] `bcrypt`, [[JWT|jwt]], validation des entrées, et mentions légales [[RGPD|rgpd]] sur les données personnelles collectées (email, mot de passe haché) à garder en tête pour un futur [[registre|registre-images]] de traitement.

    Le [[TDD|tdd]] strict [[red-green-refactor|tdd]] s'applique à chaque brique, exactement comme au [[TD12|td-12]] : un test qui décrit le comportement attendu et échoue puisque le code n'existe pas encore, puis le code minimal qui le fait passer, puis un refactor qui ne change jamais le comportement observé. Deux dépendances manquent encore au [[monorepo|monorepo]] pour écrire ce module : `bcrypt` ([[hachage|hachage]], §2.3 de `spec-auth.md`) et `@nestjs/jwt` (signature HS256, §5.1). Elles sont installées avant d'écrire la première ligne de test.

    `bcrypt` embarque un binaire natif compilé à l'installation : pnpm refuse par défaut d'exécuter son script de build (`ERR_PNPM_IGNORED_BUILDS`), pour la même raison de sécurité par défaut que `msgpackr-extract` sur la plateforme ecole. Il faut l'autoriser explicitement dans `pnpm-workspace.yaml` avant de relancer `pnpm install`.

  • Valider la forme : email et politique de mot de passe

    `auth.validation.ts` ne contient qu'une fonction de validation publique, `validateRegisterBody`, exactement comme `espaces.validation.ts` au [[TD12|td-12]] : `id`, `role` et `siteId`, s'ils sont présents dans le corps, ne sont simplement jamais lus par ce module. C'est la première brique de l'anti-[[escalade de privilège|escalade-privilege]] du §3.3 — la seconde vit dans le [[repository|depot-git]] (chapitre suivant). Aucune fonction de validation n'existe en revanche pour `login` : le contrat §4.1 ne prévoit aucun 400, un corps malformé doit converger vers le même 401 que des identifiants incorrects, ce qui se traite entièrement dans le service.

    Les quatre règles cumulatives se lisent comme quatre conditions indépendantes plutôt qu'une seule regex illisible : `lengthOk` couvre à la fois la borne basse (10) et la borne haute (72, troncature `bcrypt` du §2.2), puis trois tests `/[A-Z]/`, `/[0-9]/` et `/[^A-Za-z0-9]/` vérifient respectivement la majuscule, le chiffre et le caractère spécial. Le message d'erreur est unique et ne distingue jamais laquelle des quatre règles a échoué — contrairement à `POST /espaces` où chaque règle de `capacity/name/type` produit un message dédié — car révéler « il manque une majuscule » à l'inscription n'a pas la même sensibilité que révéler un email déjà pris ou non.

    `value` ne contient jamais que `email` et `password`, jamais les autres clés éventuellement présentes dans `body` : c'est cette construction explicite de l'objet retourné, plutôt qu'un simple spread de `source`, qui garantit qu'aucun champ additionnel ne peut atteindre le service puis le [[repository|depot-git]]. Le test rouge écrit en premier envoyait `{ email, password, role: 'ADMIN' }` et vérifiait que la valeur validée valait strictement `{ email, password }` — le vert a consisté à ne construire l'objet qu'à partir des deux champs légitimes.

  • Repository : lire/écrire users sans jamais exposer role ni site_id

    `auth.repository.ts` reste, comme `espaces.repository.ts`, la seule couche autorisée à écrire du SQL pour l'authentification : toute valeur transite exclusivement par des paramètres positionnels pg (`$1`, `$2`...), jamais par interpolation. `findByEmail` sert à la fois à `login` (retrouver le [[hash|hachage]] à comparer) et indirectement à `emailExists` (vérification d'unicité avant insertion, §3.2).

    Le paramètre positionnel `$1` n'est jamais une concaténation de chaîne : `pg` l'envoie séparément de la requête SQL elle-même, qui reste figée. Un `email` malveillant construit pour de l'injection SQL est donc traité comme une simple valeur de comparaison, jamais comme du SQL exécutable — contrairement à une requête construite par interpolation de template string, qui exécuterait le fragment injecté.

    `create` est la méthode qui porte matériellement l'anti-[[escalade de privilège|escalade-privilege]] du §3.3 : même si un bug futur laissait `role` ou `siteId` fuiter jusqu'ici depuis le service, cette requête ne les insère de toute façon jamais. `role` et `site_id` gardent leurs valeurs par défaut SQL fixées par `db/schema.sql` (`'MEMBRE'` et `NULL`) — la sécurité ne repose donc pas sur une seule couche qui pourrait être contournée, mais sur la structure même de l'`INSERT`.

    Le test e2e de ce chapitre l'a vérifié contre `coworknet_db` réel plutôt que par simple lecture de code : après un `POST /auth/register` avec `role: 'ADMIN'` et `siteId` d'un site existant dans le corps de la requête, une requête `SELECT role, site_id FROM users WHERE email = $1` confirme bien `role = 'MEMBRE'` et `site_id = NULL` en base — pas seulement dans la réponse [[JSON|json]], mais dans la ligne réellement écrite.

  • Service : hachage bcrypt à l'inscription, comparaison à temps constant à la connexion

    `auth.service.ts` porte les deux règles de sécurité qui ne peuvent pas vivre dans le controller : le [[hachage|hachage]] avant écriture, et la comparaison à temps constant à la connexion. `register` est la brique la plus simple des deux : vérifier l'unicité de l'email (accès aux données, donc hors du controller), puis hacher, puis déléguer l'insertion — dans cet ordre précis, jamais l'inverse, pour ne jamais hacher un mot de passe qui sera de toute façon rejeté.

    Le [[hachage|hachage]] n'intervient qu'après la vérification d'unicité de l'email : `bcrypt.hash(input.password, BCRYPT_COST)` ne s'exécute jamais pour un email déjà pris, ce qui évite un calcul coûteux (le coût 12 prend de l'ordre de quelques centaines de millisecondes sur du matériel courant) pour une requête de toute façon vouée à l'échec. Le [[repository|depot-git]] ne reçoit ensuite que le [[hash|hachage]], jamais le mot de passe en clair — un [[test unitaire|test-unitaire]] vérifie que `authRepository.create` est appelé avec le [[hash|hachage]] retourné par `bcrypt.hash`, jamais avec `input.password`.

    `hashToCompare` vaut toujours quelque chose de comparable — le [[hash|hachage]] réel de l'utilisateur trouvé, ou le [[hash|hachage]] factice sinon — de sorte que `bcrypt.compare()` s'exécute systématiquement, avec le même coût 12 dans les deux cas : c'est ce qui égalise le temps de réponse observable entre un email inconnu et un mot de passe faux sur un email existant, sans jamais introduire de délai artificiel (`setTimeout`) qui serait fragile et facilement contourné.

    Le `payload` reprend exactement les quatre claims métier du §5.2 (`sub`, `email`, `role`, `siteId`) ; `iat` et `exp` sont ajoutés automatiquement par `jwtService.signAsync` à partir de la configuration `expiresIn: '2h'` du module (chapitre suivant), jamais calculés à la main ici. Un [[test unitaire|test-unitaire]] vérifie l'appel exact à `signAsync` avec ces quatre clés, et le test e2e décode le [[JWT|jwt]] reçu pour vérifier `alg: 'HS256'` et `exp - iat === 7200` (2 heures en secondes).

  • Controller, module JWT et variable d'environnement

    `auth.controller.ts` reste, comme `espaces.controller.ts`, une couche sans SQL ni règle métier : `register` valide la forme puis délègue, `login` délègue le corps brut sans même le regarder — c'est la spec du §4.1 qui l'impose, aucun 400 pour cette route. Les deux méthodes utilisent `@HttpCode` explicite (201 et 200) plutôt que de compter sur les valeurs par défaut de Nest, pour que le contrat reste lisible directement dans le contrôleur.

    `auth.module.ts` configure `JwtModule.registerAsync` plutôt que `JwtModule.register` : la version asynchrone est indispensable pour lire `JWT_SECRET` via `ConfigService` (jamais `process.env` directement, §5.1), qui n'est disponible qu'après résolution des providers. `signOptions` fixe `algorithm: 'HS256'` et `expiresIn: '2h'` une fois pour toutes — aucune route de ce module ne signera jamais un jeton avec une configuration différente.

    Reste à documenter la variable d'environnement que `JwtModule` attend : `JWT_SECRET` est ajoutée à `apps/api/.env.example` (jamais codée en dur, cf. commentaire du fichier), avec une valeur de développement en clair explicitement signalée comme non réutilisable en production — même convention que `DATABASE_PASSWORD` juste au-dessus dans ce fichier.

  • Exécuter les tests, vérifier le contrat, committer

    Chaque brique a été développée en rouge-vert-refactor puis regroupée : il reste à faire tourner l'ensemble des tests unitaires du module auth avant de committer, jamais l'inverse — même discipline qu'au [[TD12|td-12]]. La commande cible d'abord uniquement ce module pour une boucle rapide, puis la suite complète pour vérifier l'absence de régression sur `/espaces` et `/health`.

    Les tests unitaires mockent `AuthRepository` et `JwtService` (et `bcrypt` lui-même, via `jest.mock`, pour ne jamais dépendre du temps réel de [[hachage|hachage]] dans une [[assertion|assertion]]) : ils n'ouvrent donc aucune connexion réelle. Le test d'intégration e2e, lui, vérifie le contrat HTTP complet contre `coworknet_db` (`localhost:5544`) : les comptes créés pendant les tests portent un préfixe d'email dédié à ce fichier, entièrement supprimé en `afterAll`, pour ne jamais laisser `db/seed.sql` altéré après une exécution.

    89 tests unitaires et 17 tests e2e verts, `coworknet_db` revenue à son état initial une fois les tests terminés, et aucune régression sur les 63 tests unitaires et 9 tests e2e du [[CRUD|crud]] espaces hérités du [[TD12|td-12]] : l'authentification est prête à être commitée. `/espaces` et `/reservations` restent volontairement non protégées à ce stade — retrofit réservé au [[TD17|td-17]]/[[TD18|td-18]], conception directe au [[TD19|td-19]]/[[TD20|td-20]].

Spécifier la sécurisation de l'API des espaces de CoWorkNet (SDD)

  • Spécifier avant de coder : but du TD et répartition des responsabilités

    Ce TD ouvre la seconde moitié de la phase « Bascule sécurité », amorcée au [[TD15|td-15]]/[[TD16|td-16]] : il ne code rien, il spécifie. Le but final et unique de ce TD est un document, `docs/specs/spec-securisation-espaces.md`, committé dans le dépôt CoWorkNet, qui fixe précisément comment `/espaces` bascule d'un [[CRUD|crud]] ouvert ([[TD11|td-11]] à [[TD14|td-14]]) vers un [[CRUD|crud]] protégé par [[JWT|jwt]] et rôle : qui peut créer, qui peut modifier, quel code d'erreur exact dans chaque cas. Ce document mobilise le CP3 : principales failles de sécurité web ([[XSS|xss]], [[CSRF|csrf]]) et leurs parades, guide ANSSI — abordées en fin de spécification (§6).

    Contrainte non négociable : les 63 tests unitaires et 9 tests e2e de la ressource Espace, verts depuis le [[TD12|td-12]], ne doivent plus jamais régresser. Ce document est écrit pour que le [[TD18|td-18]] ajoute des tests `401`/`403` sans modifier un seul test existant — chaque cas déjà couvert (`200`/`201`/`400`/`404`) garde exactement le même déclencheur et le même corps de réponse. Il ne renégocie ni les noms de route ni la forme des réponses `200`/`201` déjà stabilisées par `spec-espaces.md` ([[TD11|td-11]]).

    La contrainte la plus structurante de ce document vient de `architecture-logicielle.md` §2.2/§2.3 : le controller ne fait qu'extraire l'identité du [[JWT|jwt]], jamais décider d'un rejet `403` — cette décision reste dans le service, qui connaît déjà le rôle insuffisant ou le site différent. Ce TD en tire deux conséquences non négociables pour le [[TD18|td-18]] : l'authentification (`401`) est portée par un `Guard` NestJS exécuté avant même la validation de forme, tandis que l'autorisation (`403`, rôle et site) reste décidée par `EspacesService`, jamais par un `Guard` déclaratif générique ni par le controller.

  • Authentification — le JwtAuthGuard

    Le garde `JwtAuthGuard` est spécifié pour vivre dans le module auth déjà existant (`apps/api/src/auth/jwt-auth.guard.ts`), aux côtés de `JwtPayload` (`auth.types.ts`, défini au [[TD16|td-16]], réutilisé sans le redéfinir), puis exporté par `AuthModule` pour être réutilisable sans duplication par `EspacesModule` ici, et par le futur `ReservationsModule` au [[TD19|td-19]]/[[TD20|td-20]]. Il est appliqué route par route via `@UseGuards(JwtAuthGuard)`, jamais globalement sur le contrôleur : `GET /espaces` reste sans garde, seuls `POST /espaces` et `PATCH /espaces/:id` le portent.

    La vérification proprement dite s'appuie sur `JwtService.verifyAsync` (le même service qui signe le jeton au [[TD16|td-16]]), avec l'option `algorithms: ['HS256']` fixée explicitement — ne jamais laisser la bibliothèque déduire l'algorithme depuis l'en-tête du jeton, ce qui neutraliserait par construction une attaque par confusion d'algorithme. Trois causes d'échec sont couvertes par ce garde, converties vers la même réponse `401` sans distinction :

    Concrètement : un attaquant modifierait l'en-tête d'un jeton pour lui faire annoncer un autre algorithme (`none`, ou `RS256` en réutilisant sa clé publique comme secret HMAC). Si la vérification faisait confiance à cette valeur plutôt qu'à une liste fixée côté serveur, il pourrait forger un jeton valide sans jamais connaître `JWT_SECRET`.

    Comme pour `POST /auth/login` (`spec-auth.md` §4.2), un seul code générique `UNAUTHORIZED` couvre les trois causes : distinguer jeton expiré de signature invalide n'apporte aucun bénéfice à un client légitime, qui doit de toute façon se reconnecter via `POST /auth/login` dans les deux cas. Ce `401` est strictement identique quelle que soit la route protégée, y compris `/reservations` au [[TD19|td-19]]/[[TD20|td-20]] : un seul garde, un seul contrat d'erreur.

    Une fois le jeton vérifié, le garde attache le `JwtPayload` décodé (claims `sub`/`email`/`role`/`siteId`, déjà définis au [[TD16|td-16]]) à la requête. Un [[décorateur|decorateur]] de paramètre dédié, `@CurrentUser()`, spécifié pour vivre dans `apps/api/src/auth/current-user.decorator.ts` et également exporté par `AuthModule`, permet au controller de récupérer cette identité typée sans jamais manipuler l'objet `Request` brut d'Express. Le controller transmet ensuite cet objet tel quel au service, seul habilité à décider si le rôle et le site qu'il porte suffisent.

  • Autorisation par rôle et scoping par site — décidée dans le service

    `EspacesService.create` reçoit désormais, en plus du corps déjà validé (`spec-espaces.md` §2), l'identité authentifiée extraite par le garde. L'autorisation est évaluée avant toute requête à la base de données, à partir des seules claims du jeton et du `siteId` déjà présent dans le corps validé — un `GERANT` ne doit jamais apprendre, par la différence entre un `403` et un `400 VALIDATION_ERROR` (`siteId` inexistant), qu'un site donné existe ou non s'il n'a pas le droit d'écrire dessus (moindre privilège, `architecture-logicielle.md` §6). Quatre cas sont distingués :

    `EspacesService.update` doit à l'inverse consulter la base avant de pouvoir décider : le `site_id` de l'espace ciblé n'est connu qu'après lecture de la ligne, `siteId` étant de toute façon immuable et absent du corps `PATCH` (`spec-espaces.md` §3.3). L'existence de l'espace est donc vérifiée en premier, exactement comme depuis le [[TD12|td-12]] (`ESPACE_NOT_FOUND`, `404`, inchangé), puis seulement ensuite le rôle et le site sont comparés à ceux du jeton — l'ordre exact et sa justification vis-à-vis de la non-fuite d'information sont détaillés au chapitre suivant.

    La matrice ci-dessous synthétise ces règles et sert de référence de test directe pour le [[TD18|td-18]] : chaque cellule doit correspondre à un test e2e distinct, avec un jeton réellement émis par `POST /auth/login` pour un utilisateur `MEMBRE`, `GERANT` ou `ADMIN` de site connu.

    Contrairement à `POST /auth/login` (`spec-auth.md` §4.2, code générique unique), `ROLE_FORBIDDEN` et `SITE_FORBIDDEN` sont deux codes distincts et délibérément différenciés. Ce choix ne contredit pas le principe retenu au [[TD15|td-15]] : là où l'ambiguïté protégeait contre l'énumération de comptes sur une route d'authentification, ici la ressource Espace est déjà intégralement publique en lecture (`GET /espaces`, sans authentification) — distinguer un rôle insuffisant d'un site incorrect n'offre aucune information nouvelle à un attaquant, et permet à `apps/web` d'afficher un message exploitable à un `GERANT` légitime qui aurait ciblé le mauvais site.

  • Ordre d'évaluation des erreurs et non-régression de GET /espaces

    L'ordre d'évaluation des erreurs est normatif pour ce retrofit — le [[TD18|td-18]] ne peut pas le réorganiser sans renégocier ce document. Il diffère volontairement entre `POST` et `PATCH`, pour la même raison de non-fuite d'information déjà retenue au chapitre précédent : l'autorisation doit toujours être décidée avant qu'une information supplémentaire (existence d'un site, existence d'un espace) ne soit révélée à un appelant qui n'y a de toute façon pas droit.

    `GET /espaces?siteId=&date=` ne porte aucun `@UseGuards`, ne lit jamais l'en-tête `Authorization`, et son contrat (`spec-espaces.md` §4.1, tri par `name`, `[]` si vide) reste identique, y compris pour un appelant authentifié : le rôle présent dans un jeton éventuellement fourni sur cette route, s'il y en a un, n'est jamais lu ni vérifié. Les tests [[TD12|td-12]]/[[TD13|td-13]]/[[TD14|td-14]] sur cette route — contrôleur, page catalogue `apps/web`, [[collection Bruno|collection-bruno]] — restent valides sans aucune modification.

  • Menaces web transverses (CP3) : XSS et CSRF appliqués au retrofit

    Ce TD mobilise directement le CP3 : principales failles de sécurité web ([[XSS|xss]], [[CSRF|csrf]]) et leurs parades, guide ANSSI. Le [[CSRF|csrf]] exploite l'envoi automatique par le navigateur d'un cookie de session sur une requête inter-site déclenchée par un site tiers malveillant, à l'insu de l'utilisateur. Ce retrofit n'introduit aucun cookie : le jeton d'authentification est, depuis sa spécification au [[TD15|td-15]] (`spec-auth.md` §5.4), transporté exclusivement dans l'en-tête `Authorization: Bearer <token>`, jamais dans un cookie. Un site tiers ne peut pas forcer le navigateur de la victime à émettre cet en-tête, qu'il ne connaît pas et ne peut pas deviner — contrairement à un cookie, qu'il enverrait automatiquement sans le lire.

    `spec-espaces.md` §5 avait déjà noté que `name` et `type` sont des champs texte libres, sans échappement au niveau de l'[[API|api]]. Ce retrofit ne change pas cette analyse mais en augmente l'enjeu : ces champs ne sont plus seulement affichés par une inscription en base ouverte à quiconque (avant ce TD), mais désormais réservés à un `GERANT`/`ADMIN` authentifié — si le jeton de ce dernier était exfiltré via une [[XSS|xss]] ailleurs sur `apps/web`, un attaquant hériterait de son droit d'écriture. Deux parades, aucune nouvelle par rapport à ce qui est déjà acquis ou prévu, sont réaffirmées comme non négociables :

    Aucune de ces deux parades n'ajoute de route ni de champ au contrat [[API|api]] stabilisé : elles s'appliquent à la couche `apps/web`, pas à `apps/api` — ce document reste centré sur la sécurisation de l'[[API|api]] elle-même.

  • Rédiger, valider et committer la spécification

    Le [[TD18|td-18]] doit ajouter, sans modifier un seul test déjà vert : des tests unitaires du garde (`401` sur en-tête absent, mal formé, signature invalide, jeton expiré), des tests unitaires du service (`403` `ROLE_FORBIDDEN`/`SITE_FORBIDDEN` pour `create` et `update`, sur la matrice du chapitre 3), et des tests e2e couvrant au minimum une ligne de chaque colonne de cette matrice, avec de vrais jetons émis par `POST /auth/login` contre des utilisateurs `MEMBRE`/`GERANT`/`ADMIN` de sites différents.

    Le dossier `docs/specs/` existe déjà depuis le [[TD11|td-11]] : ce TD y ajoute simplement `spec-securisation-espaces.md`, sans créer de nouveau dossier. Une fois le document rédigé et les quatre critères de validation vérifiés un par un, il reste à le committer avec le préfixe conventionnel.

    Le préfixe `TD17:` suit la convention de préfixe `TD{n}:` adoptée depuis le [[TD05|td-05]] (distincte du format Conventional Commits, cf. [[TD05|td-05]]). Ce [[commit|commit]] ne modifie aucun fichier de `apps/api` ni de `apps/web` : comme le [[TD11|td-11]] et le [[TD15|td-15]], c'est un TD de spécification pure. Le prochain TD ([[TD18|td-18]]) implémentera `JwtAuthGuard`, le [[décorateur|decorateur]] `CurrentUser`, et l'autorisation par rôle/site dans `EspacesService` en s'appuyant sur ce document sans le réécrire, sans jamais faire régresser les 63 tests unitaires et 9 tests e2e de la ressource Espace hérités du [[TD12|td-12]].

Sécuriser l'API des espaces de CoWorkNet en TDD (retrofit)

  • But de ce TD : faire passer au vert le retrofit spécifié au TD17

    Ce TD referme la boucle ouverte au [[TD17|td-17]] : `apps/api/src/auth` s'enrichit de deux nouveaux fichiers, `jwt-auth.guard.ts` et `current-user.decorator.ts`, `EspacesModule` importe `AuthModule` pour les réutiliser, `EspacesController` porte `@UseGuards(JwtAuthGuard)` sur `POST` et `PATCH` uniquement (jamais sur `GET`), et `EspacesService` décide désormais, à partir des seules claims du jeton, un rejet 403 `ROLE_FORBIDDEN` ou `SITE_FORBIDDEN` selon la matrice rôle × site figée par `docs/specs/spec-securisation-espaces.md` §3.3. À la fin de cette séance, 17 nouveaux tests unitaires et 12 nouveaux tests e2e sont verts, sans qu'un seul des tests 200/201/400/404 hérités du [[TD12|td-12]] n'ait changé de déclencheur ni de réponse — et l'ensemble est commité avec le préfixe [[TD18|td-18]].

    Comme au [[TD12|td-12]] et au [[TD16|td-16]], chaque brique suit le [[cycle rouge-vert-refactor|tdd]] : un test qui décrit le comportement attendu des §2/§3 de `spec-securisation-espaces.md` et échoue faute de code, puis l'implémentation minimale qui le fait passer, puis un refactor qui ne change jamais le comportement observé. Ce document rédigé au [[TD17|td-17]] n'est pas renégocié ici : ni les codes d'erreur (`UNAUTHORIZED`, `ROLE_FORBIDDEN`, `SITE_FORBIDDEN`), ni l'ordre d'évaluation §4, ni la matrice §3.3 ne changent. Ce TD mobilise le CP3 (sécurité applicative : authentification, autorisation, moindre privilège) et le CP9 ([[TDD|tdd]], non-régression d'une suite de tests existante face à un refactoring).

  • JwtAuthGuard : authentifier sans jamais autoriser

    `JwtAuthGuard` porte une seule responsabilité, l'authentification : vérifier que le jeton est présent, bien formé, signé avec le bon secret et non expiré, puis attacher l'identité décodée à la requête — jamais décider si cette identité a le droit d'écrire (§1 de `spec-securisation-espaces.md`). Il vit dans `apps/api/src/auth/jwt-auth.guard.ts`, aux côtés de `JwtPayload` (`auth.types.ts`, [[TD16|td-16]], réutilisé tel quel sans le redéfinir). Le premier test rouge vérifie qu'une requête sans en-tête `Authorization` est rejetée sans jamais appeler `JwtService.verifyAsync`.

    Six autres tests suivent le même patron : schéma "`Basic`" au lieu de "`Bearer`", casse "`bearer`" incorrecte, deux espaces séparant le schéma du jeton, signature invalide (`JwtService.verifyAsync` rejette), jeton expiré, et un dernier cas de succès qui vérifie que `request.user` est bien posé. Les trois premiers échouent avant même d'appeler `verifyAsync` — la forme de l'en-tête est vérifiée par le garde lui-même, jamais déléguée à la librairie [[JWT|jwt]].

    `algorithms: ['HS256']` est fixé explicitement plutôt que laissé à la valeur par défaut de la librairie : c'est la contre-mesure anti confusion d'algorithme du §2.2 — un jeton ne peut jamais être accepté avec `alg: none` ou un autre algorithme que celui choisi côté serveur, même si son en-tête prétend le contraire. Les trois causes de rejet (absent/malformé, signature invalide, expiré) convergent toutes vers le même 401 générique, sans jamais distinguer laquelle a échoué. `extractToken` porte la vérification de forme exacte du schéma.

    `BEARER_PREFIX` vaut exactement `'Bearer '` (majuscule, un seul espace) : `startsWith` rejette "`basic`", "`bearer`" en minuscule ou tout autre schéma, puis `token.includes(' ')` rejette un deuxième espace superflu entre le schéma et le jeton — c'est ce test qui couvre le cas "`Bearer abc123`" à double espace. Reste `@CurrentUser()`, qui expose ce `request.user` au controller sans jamais lui faire manipuler l'objet `Request` brut d'Express. `createParamDecorator` n'étant pas directement invocable en test, la logique d'extraction est isolée dans une fonction pure, `extractCurrentUser`, testée séparément.

  • Brancher le garde : AuthModule, EspacesModule, controller

    `JwtAuthGuard` dépend de `JwtService`, déjà fourni par `JwtModule` dans `AuthModule` ([[TD16|td-16]]) : il suffit de le déclarer comme provider de ce même module, puis de l'exporter aux côtés de `JwtModule` pour qu'`EspacesModule` (et demain `ReservationsModule`, [[TD19|td-19]]/[[TD20|td-20]]) puisse l'utiliser sans le redéfinir. C'est exactement le patron déjà appliqué à `AuthService`/`AuthRepository` : un composant transverse vit dans le module qui possède ses dépendances, et s'exporte pour être réutilisé ailleurs.

    Côté espaces, `EspacesModule` n'avait jusqu'ici aucun import : il gagne `imports: [AuthModule]`, qui rend `JwtAuthGuard` résoluble par le [[conteneur|conteneur-docker]] d'[[injection de dépendances|injection-dependances]] d'`EspacesController` sans jamais dupliquer sa déclaration. Le fichier reste minimal — seule la liste `imports` change.

    `EspacesController` applique `@UseGuards(JwtAuthGuard)` route par route, jamais globalement sur la classe (§2.1 : `GET /espaces` reste sans garde). `@CurrentUser()` récupère l'identité déjà posée par le garde et la transmet telle quelle au service — le controller ne l'inspecte jamais, ne décide jamais d'un 403 lui-même : c'est la limite stricte posée par `architecture-logicielle.md` §2.2 et rappelée au §1 de `spec-securisation-espaces.md`.

    `update` reçoit exactement le même traitement : le garde et le [[décorateur|decorateur]] s'ajoutent sans toucher à la validation de forme déjà en place depuis le [[TD12|td-12]] (`SITE_ID_IMMUTABLE`, corps vide, champs invalides), et l'identité n'est ajoutée qu'au tout dernier appel, celui qui délègue au service — jamais utilisée pour construire une réponse ou un message d'erreur dans le controller lui-même.

  • Autoriser dans EspacesService : ROLE_FORBIDDEN et SITE_FORBIDDEN

    L'autorisation reste entièrement dans `EspacesService`, jamais dans le controller ni dans un garde déclaratif générique (§1/§3). `assertCanWriteToSite` porte la matrice rôle × site du §3.3 en trois lignes : un `MEMBRE` est toujours rejeté (`ROLE_FORBIDDEN`), un `GERANT` seulement si le `siteId` concerné diffère du sien — y compris si le sien est `null` au jeton, traité en échec par défaut — et un `ADMIN` est toujours autorisé.

    `create` appelle cette méthode avant tout accès base (§3.1 : un `GERANT` non autorisé ne doit jamais apprendre, via un 400 `VALIDATION_ERROR` sur `siteId`, qu'un site existe ou non). `update`, lui, doit d'abord lire l'espace pour connaître son `site_id` — l'existence de l'espace (404) est donc vérifiée avant l'autorisation (403), car elle est déjà publique via `GET /espaces` et ne fuite rien (§4), contrairement au `siteId` de `create`.

    `create` et `update` prennent désormais un `currentUser` obligatoire : les six tests hérités du [[TD12|td-12]] (200/201/400/404) doivent donc être adaptés pour continuer à compiler et à passer — jamais leurs assertions de statut ou de corps, seulement l'ajout du paramètre manquant. Une fixture `ADMIN_USER`, autorisée pour n'importe quel site, est réutilisée par tous ces tests hérités : elle ne restreint jamais rien, donc leur comportement observé reste rigoureusement identique.

    Huit tests nouveaux couvrent la matrice : quatre pour `create` (`GERANT` autorisé sur son site, `MEMBRE` toujours rejeté sans jamais consulter la base, `GERANT` rejeté sur un autre site sans jamais consulter la base, `GERANT` sans `siteId` au jeton rejeté par défaut) et quatre pour `update` (`GERANT` autorisé sur son site, 404 vérifié avant 403, `ROLE_FORBIDDEN` pour `MEMBRE`, `SITE_FORBIDDEN` pour un `GERANT` d'un autre site). Chaque cas 403 vérifie explicitement que le [[repository|depot-git]] n'a pas été appelé.

    Côté controller, le [[test unitaire|test-unitaire]] appelle directement `controller.create(body, user)` : aucune requête HTTP réelle n'est jouée, donc `JwtAuthGuard` n'est jamais exercé ici (il l'est par `jwt-auth.guard.spec.ts` et par la matrice e2e du chapitre suivant). Mais `Test.createTestingModule` tente quand même de résoudre les dépendances du garde déclaré via `@UseGuards` : `overrideGuard` neutralise cette résolution sans changer le comportement testé.

  • Tests e2e de la matrice rôle × site, non-régression, commit

    La matrice §3.3 se teste en conditions réelles, avec de vrais jetons émis par `POST /auth/login` plutôt que signés à la main (`spec-securisation-espaces.md` §7). Or `POST /auth/register` force toujours `role: 'MEMBRE'` (anti-[[escalade de privilège|escalade-privilege]], [[TD16|td-16]]) : un nouveau fichier e2e, `espaces-security.e2e-spec.ts`, inscrit donc un `MEMBRE`, un `GERANT` et un `ADMIN` via `/auth/register`, élève ensuite `role`/`site_id` en base par une requête SQL directe, puis appelle `/auth/login` pour obtenir le jeton réellement utilisé dans les requêtes suivantes.

    Deux sites de test dédiés (site A, site B) et un espace par site permettent de couvrir chaque colonne du §3.3 : `MEMBRE` rejeté en 403 `ROLE_FORBIDDEN` sur `POST` et `PATCH` quel que soit le site, `GERANT` du site A autorisé (201/200) sur le site A et rejeté en 403 `SITE_FORBIDDEN` sur le site B, `ADMIN` toujours autorisé sur les deux sites, et 401 `UNAUTHORIZED` sans en-tête ou avec un jeton falsifié. Douze tests couvrent cette grille, entièrement nettoyés en `afterAll`.

    `espaces.e2e-spec.ts` ([[TD12|td-12]]) n'a besoin d'aucun nouveau test : `ADMIN` étant autorisé pour n'importe quel site, il suffit qu'un jeton `ADMIN` accompagne chaque appel `POST/PATCH` déjà écrit pour que ces neuf tests retrouvent exactement leurs codes 201/200/400/404 d'origine. `beforeAll` inscrit un unique compte `ADMIN` via `/auth/register` puis `/auth/login`, comme dans le fichier de sécurité.

    Aucune ligne d'`expect` n'est touchée par ce diff : seul un `.set('Authorization', adminAuthHeader)` s'intercale entre `.post()/.patch()` et `.send()`, sur chacun des appels d'écriture déjà présents. `GET /espaces` reste appelé sans en-tête, conformément au §5 qui l'exempte de toute vérification de jeton. La suite complète confirme l'absence de régression, module par module puis dans son ensemble.

    106 tests unitaires (+17) et 29 tests e2e (+12) sont verts, et `coworknet_db` revient à son état initial une fois les tests terminés (sites, utilisateurs et espaces de test nettoyés en `afterAll`). `/reservations` reste hors périmètre : sa protection [[JWT|jwt]] sera conçue dès le premier test au [[TD19|td-19]]/[[TD20|td-20]], jamais en retrofit — le retrofit était réservé à `/espaces`, déjà public en écriture depuis le [[TD11|td-11]]. Le retrofit est prêt à être commité.

Spécifier le besoin de réservation de CoWorkNet (SDD)

  • But de ce TD : la ressource Reservation, authentifiée dès sa conception

    Ce TD ouvre la spécification de la dernière ressource du [[MVP|mvp]] CoWorkNet : `Reservation`. Comme les [[TD11|td-11]], [[TD15|td-15]] et [[TD17|td-17]], il ne code rien, il spécifie. Le but final et unique de ce TD est un document, `docs/specs/spec-reservations.md`, committé dans le dépôt CoWorkNet, qui fixe précisément le dictionnaire des champs de la ressource, les règles de validation, la règle métier critique de non-chevauchement des créneaux, le mécanisme de gestion de la concurrence, et le contrat exact des réponses des trois routes `/reservations`. Ce document mobilise le CP5 (analyser le besoin et formaliser les règles métier) et le CP8 (connaissance des transactions, de leurs niveaux d'isolation et du verrouillage des données).

    La différence structurante avec `/espaces` se joue dès l'introduction du document. `/espaces` est resté ouvert sans authentification jusqu'au [[TD17|td-17]], puis a été sécurisé par retrofit au [[TD18|td-18]] : une phase ouverte, suivie d'un ajout de sécurité. `/reservations` applique au contraire le patron fail-safe default annoncé par `architecture-logicielle.md` §6/§8.3 dès sa conception : les trois routes exigent un [[JWT|jwt]] valide dans leur toute première version testée au [[TD20|td-20]], sans aucune phase intermédiaire non authentifiée. Ce document ne redéfinit pas l'authentification : il réutilise tel quel le socle déjà spécifié et implémenté au [[TD17|td-17]]/[[TD18|td-18]].

    Le dictionnaire des champs reprend exactement les colonnes du MPD validé (`schema-donnees.md` §6, table `reservations`) et leur forme [[JSON|json]] `camelCase`, sur le modèle déjà appliqué à `Espace` (`spec-espaces.md` §1) : la conversion `snake_case`/`camelCase` reste une responsabilité exclusive du controller, jamais du [[repository|depot-git]] ni de la base.

    Un choix de forme mérite d'être justifié avant même de parler des routes : `userId` figure dans toutes les réponses (`POST`, `GET /me`, `PATCH .../validate`), sans variante allégée par [[endpoint|endpoint]]. C'est le même choix qui a guidé `EspaceResponse` : une forme unique de `ReservationResponse` est plus simple à tester au [[TD20|td-20]] et à consommer côté `apps/web` qu'une forme différente selon la route appelée.

  • Autorisation route par route : deux routes sans rôle, une route scopée par site

    Les trois routes `/reservations` ne se protègent pas de la même façon. `POST /reservations` et `GET /reservations/me` n'imposent aucune restriction de rôle : le contrat stabilisé (`cahier-des-charges.md` §4) ne prévoit d'ailleurs aucun `403` pour ces deux routes (`201|400|401` et `200|401` seulement). `PATCH /reservations/:id/validate`, à l'inverse, reprend la matrice `GERANT`/`ADMIN` déjà connue de `/espaces`, avec `403` `ROLE_FORBIDDEN` et `SITE_FORBIDDEN`.

    `GET /reservations/me` suit le même principe de non-restriction par rôle, mais son enjeu de sécurité porte sur le scoping : aucun `MEMBRE` ne doit pouvoir consulter les réservations d'un autre utilisateur, quelle que soit la manipulation tentée sur l'URL ou la query string.

    `PATCH /reservations/:id/validate` diffère structurellement : `reservations` ne porte pas de colonne `site_id` (`schema-donnees.md` §6), le site d'une réservation est celui de l'espace qu'elle référence. Toute vérification de site passe donc par une [[jointure|jointure]] vers `espaces.site_id`, jamais par une colonne dupliquée sur `reservations` — l'espace référencé par une réservation étant de toute façon immuable.

  • Règles de validation d'entrée : espaceId, créneaux et verdict

    La création d'une réservation ne peut viser qu'un espace qui existe réellement et qui est disponible. US2 précise « réserver un créneau sur un espace disponible » : un espace désactivé (`spec-espaces.md` §4.3) n'est jamais disponible, même s'il reste visible dans l'historique des réservations déjà créées avant sa désactivation. Ces deux vérifications donnent deux codes `400` distincts, sans risque de fuite d'information puisque `GET /espaces` est déjà public.

    Les bornes de durée sur `startAt`/`endAt` ne proviennent pas du cahier des charges : elles sont ajoutées par ce document, sur le même principe que la borne `capacity<=500` déjà retenue pour `Espace` (`spec-espaces.md` §2), pour écarter une saisie aberrante sans provenir d'une exigence produit.

    Trois exemples ancrés dans CoWorkNet lèvent toute ambiguïté sur ces bornes avant le [[TD20|td-20]], y compris la borne basse exacte (`15` minutes, acceptée) plutôt que juste en dessous.

    Le dernier champ à trancher, `verdict`, révèle une lacune volontaire du contrat terse : `PATCH /reservations/:id/validate` ne prévoit que `200|401|403|404`, sans aucun `400` disponible pour un `verdict` malformé. Plutôt que d'inventer un cinquième [[code de statut|code-statut-http]] absent du contrat stabilisé, ce document choisit de replier ce cas sur le `404` déjà défini pour une réservation introuvable ou déjà traitée (§6.3).

  • Règle métier critique : non-chevauchement des créneaux (CP5)

    Ce chapitre reprend, sans la modifier d'un mot, la règle métier critique fixée par la spec produit du fil rouge : deux réservations `PENDING` ou `CONFIRMED` sur le même espace ne peuvent jamais se chevaucher temporellement. C'est la règle qui mobilise directement le CP5 (formaliser une règle métier de façon exploitable pour l'implémentation du [[TD20|td-20]]) avant même de parler de concurrence.

    Formaliser « chevaucher » exige de fixer précisément les bornes des intervalles, sans quoi le [[TD20|td-20]] écrirait des tests ambigus sur les créneaux adjacents (une réservation qui se termine exactement quand une autre commence).

    Cette requête, exécutée seule, ne suffit pas à garantir l'absence de chevauchement sous des accès concurrents : deux transactions pourraient toutes deux la lire avant qu'aucune n'ait encore inséré sa propre réservation. C'est précisément l'objet du chapitre suivant, cœur du CP8 de ce TD.

  • Concurrence sur un créneau : verrou explicite plutôt que SERIALIZABLE (CP8)

    `architecture-logicielle.md` §2.4/§7 avait volontairement laissé ouvert, pour ce TD précis, le choix entre une transaction `SERIALIZABLE` et un verrou explicite documenté. C'est le cœur du CP8 de ce [[TD19|td-19]] : trancher ce choix, le justifier, et fournir la transaction SQL exacte qui l'implémente, sans laisser cette décision au [[TD20|td-20]].

    Le verrou explicite l'emporte sur les trois critères : aucune boucle de nouvelle tentative à écrire au [[TD20|td-20]], aucun risque d'interblocage puisqu'une transaction `POST /reservations` n'acquiert jamais qu'un seul verrou, et un SQL de contre-mesure entièrement lisible en revue de code — cohérent avec le refus d'[[ORM|orm]] déjà acté pour raisonner à la main sur ce type de garantie.

    Avant d'appliquer ce raisonnement à `reservations`, un exemple générique, hors CoWorkNet, isole le phénomène en jeu : deux transactions qui créditent le même compte bancaire en parallèle, sans verrou explicite, illustrent le classique problème de la « mise à jour perdue » (*lost update*) — la même catégorie de bug qu'un chevauchement de créneaux non détecté.

    Reste à justifier pourquoi ce verrou suffit sans jamais monter au niveau `SERIALIZABLE` : la réponse tient à un comportement précis de PostgreSQL au niveau `READ COMMITTED` par défaut, propre aux instructions de verrouillage de ligne, qu'il faut nommer explicitement pour que le [[TD20|td-20]] ne le redécouvre pas par surprise en test.

    Ce raisonnement se traduit par une transaction complète et directement transposable en [[repository|depot-git]] au [[TD20|td-20]] : verrouiller la ligne de l'espace, vérifier le chevauchement, insérer, valider — dans cet ordre précis et jamais un autre.

  • Transition d'état, contrat exact des réponses, hors périmètre et commit

    La transition `PENDING` vers `CONFIRMED`/`REJECTED` n'a pas besoin du même appareil que la création : une unique instruction `UPDATE` conditionnée sur l'état courant est déjà atomique par construction PostgreSQL, sans bloc `BEGIN`/`COMMIT` explicite ni verrou posé à l'avance.

    Cette clause `WHERE ... AND status = 'PENDING'` n'est pas qu'une vérification défensive : elle résout à elle seule la course entre deux validations concurrentes de la même réservation, sans code d'erreur supplémentaire ni verrou explicite à écrire au [[TD20|td-20]].

    L'ordre complet d'évaluation rassemble authentification, existence, autorisation par rôle et site, puis état courant de la réservation, dans une séquence normative que le [[TD20|td-20]] ne peut pas réorganiser sans renégocier ce document — chaque étape ayant déjà été justifiée individuellement dans les chapitres précédents.

    Reste à fixer le contrat exact des réponses des trois routes, avec un exemple [[JSON|json]] complet pour chaque statut documenté — la référence directe que le [[TD20|td-20]] recopiera dans ses assertions de test.

    Ce document se referme par un rappel de sécurité transverse ([[IDOR|idor]], injection SQL, non-fuite d'information par les codes d'erreur) et par les éléments explicitement écartés — deux sections qui n'introduisent aucune nouvelle contre-mesure, mais qui documentent leur application précise à `reservations` plutôt que de les laisser silencieusement sous-entendues.

    Le document se conclut par cinq critères de validation, vérifiés un par un avant de considérer la spécification stable pour le [[TD20|td-20]] — même discipline que `spec-espaces.md` et `spec-securisation-espaces.md` aux [[TD11|td-11]] et [[TD17|td-17]].

    Le dossier `docs/specs/` existe déjà depuis le [[TD11|td-11]] : ce TD y ajoute simplement `spec-reservations.md`, sans créer de nouveau dossier. Une fois le document rédigé et les cinq critères de validation vérifiés un par un, il reste à le committer avec le préfixe conventionnel, exactement comme au [[TD17|td-17]].

Développer l'API de réservation de CoWorkNet en TDD

  • But de ce TD : coder /reservations, sécurisé dès la conception

    Ce TD construit un nouveau module `apps/api/src/reservations` (types, validation, mapper, [[repository|depot-git]], service, controller, module) qui expose les trois routes figées au [[TD19|td-19]] : `POST /reservations`, `GET /reservations/me` et `PATCH /reservations/:id/validate`. La différence majeure avec `/espaces` (public en écriture jusqu'au [[TD17|td-17]], sécurisé par retrofit au [[TD18|td-18]]) : ici, `JwtAuthGuard` est posé dès le tout premier test rouge de chaque route, jamais ajouté après coup. À la fin de la séance, le module compile, la suite unitaire (client `pg` mocké) et un test e2e qui exerce une vraie course concurrente contre PostgreSQL sont tous verts, et l'ensemble est commité avec le préfixe [[TD20|td-20]].

    `docs/specs/spec-reservations.md`, rédigé au [[TD19|td-19]], n'est pas renégocié ici : ni le dictionnaire des champs, ni l'ordre d'évaluation §6.3 de la validation, ni le choix du verrou explicite `FOR UPDATE` plutôt que `SERIALIZABLE` (§5) ne changent. Chaque brique suit le [[cycle rouge-vert-refactor|tdd]] déjà appliqué au [[TD12|td-12]]/[[TD16|td-16]]/[[TD18|td-18]] : un test qui décrit un comportement du document, puis l'implémentation minimale qui le fait passer. Ce TD mobilise le CP3 (sécurité dès la conception), le CP8 (accès aux données, gestion de la concurrence) et le CP9 ([[TDD|tdd]]).

  • Types et validation d'entrée

    `reservations.types.ts` reprend la même séparation que `espaces.types.ts` et `auth.types.ts` : les types `*Row` reflètent les colonnes SQL en snake_case (seuls [[repository|depot-git]] et service les manipulent), `ReservationResponse` est l'unique forme [[JSON|json]] exposée par les trois routes (`spec-reservations.md` §1, jamais de variante allégée par route). `CreateReservationResult` est un [[type discriminé|type-discrimine]] : le [[repository|depot-git]] ne lève jamais lui-même d'exception HTTP, c'est au service de traduire chaque outcome en [[code de statut|code-statut-http]].

    `CreateReservationResult` est un [[type discriminé|type-discrimine]] : ses quatre membres partagent un champ `outcome` dont la valeur (`'CREATED'`, `'ESPACE_NOT_FOUND'`...) diffère à chaque fois — ce champ sert de repère à TypeScript pour **restreindre** le type dans un if/switch (`result.row` n'existe et n'est accessible que dans la branche `'CREATED'`, où le compilateur le sait). Il porte les quatre issues de la transaction verrouillée du chapitre 3 : succès, ou l'un des trois échecs métier de `spec-reservations.md` §3.1/§4. `Verdict`, lui, restreint le corps de `PATCH .../validate` aux deux seules valeurs autorisées par le §3.3.

    `reservations.validation.ts` réutilise `isValidUuid` (`espaces.validation.ts`) et `isRecord` (`auth.validation.ts`) plutôt que de les redéfinir. Le premier test rouge porte sur `startAt` : `spec-reservations.md` §3.2 exige un fuseau explicite (`Z` ou `+HH:MM`), qu'une simple `new Date('2026-07-14T09:00:00')` sans offset accepterait pourtant silencieusement en heure locale du serveur — c'est donc une regex dédiée, pas `Date`, qui fait ce premier filtre avant toute conversion.

    `validateCreateBody` reçoit un second paramètre, `now : Date`, plutôt que d'appeler `new Date()` en son sein — c'est ce qui rend testable, avec un instant figé, la règle « `startAt` strictement postérieur à l'instant de réception » (§3.2 dernier point). Le controller (chapitre 5) est le seul appelant réel et lui passera `new Date()` ; les tests, eux, passent un `now` arbitraire pour couvrir sans attendre les cas limites (égal à `now`, déjà passé).

    Les cas limites méritent des tests dédiés, pas seulement les cas centraux : la borne basse exacte (`15` minutes, acceptée) et la valeur immédiatement sous cette borne (`14` minutes `59`, rejetée) sont deux tests bien distincts, de même qu'un `startAt` strictement égal à `now` (rejeté, non « strictement postérieur »).

    `isValidVerdict` et `extractVerdict` restent volontairement de simples fonctions pures, sans jamais lever d'exception : le contrat de `PATCH .../validate` (200 | 401 | 403 | 404) ne réserve aucun 400 à un verdict malformé (§3.3). C'est donc `ReservationsService.validate`, au chapitre 4, qui décide du moment exact — après autorisation — où un verdict invalide devient un 404 `RESERVATION_NOT_FOUND`, jamais ce module de validation.

  • Repository : la transaction verrouillée (cœur CP8)

    La transaction du §5.2 (`BEGIN`, `FOR UPDATE`, vérification de chevauchement, `INSERT`, `COMMIT`) doit s'exécuter sur une seule et même connexion PostgreSQL : `DatabaseService.query()` seul ne le garantit pas, chaque appel pouvant emprunter une connexion différente du pool. `DatabaseService` gagne donc une méthode `getClient()`, qui expose un `PoolClient` dédié — utilisée uniquement par `reservations.repository.ts`, jamais ailleurs à ce TD.

    `createWithLock` ouvre la transaction puis verrouille la ligne `espaces` ciblée avec `FOR UPDATE`, filtrée sur `is_active = true` : c'est ce verrou qui sérialise toute création concurrente de réservation sur ce même espace (`spec-reservations.md` §5), sans jamais bloquer une transaction portant sur un autre espace. Le test correspondant mocke un client `{ query, release }` et vérifie l'ordre exact des appels : `BEGIN`, puis le `SELECT` verrouillé avec ses paramètres positionnels.

    Si ce `SELECT` verrouillé ne renvoie aucune ligne, `spec-reservations.md` §3.1 exige de distinguer un id absent (`ESPACE_NOT_FOUND`) d'un id présent mais désactivé (`ESPACE_INACTIVE`) — une information que le filtre `is_active = true` a justement effacée. Une seconde lecture, sans verrou cette fois, résout l'ambiguïté avant le `ROLLBACK` : elle ne fait courir aucun risque de course supplémentaire, la transaction étant de toute façon annulée dans les deux cas.

    Une fois le verrou obtenu, la vérification de chevauchement (§4, intervalles semi-ouverts, adjacence autorisée) s'exécute forcément après : sous `READ COMMITTED`, un `FOR UPDATE` qui a dû attendre relit la ligne dans son état le plus récent une fois le verrou libéré, garantissant que cette lecture voit toute réservation déjà commitée par une transaction concurrente qui aurait tenu ce même verrou juste avant (`spec-reservations.md` §5).

    L'`INSERT` force toujours `status: 'PENDING'` (jamais transmis par le client), et un `catch`/`finally` entoure toute la transaction : la moindre erreur en cours de route déclenche un `ROLLBACK` défensif (avalé s'il échoue à son tour) avant de relancer l'erreur d'origine, et `client.release()` rend systématiquement la connexion au pool, succès ou échec. Un test dédié vérifie ce chemin en simulant une requête qui rejette au milieu de la transaction.

    Les trois autres méthodes du [[repository|depot-git]] n'ont besoin d'aucune transaction : `findByUserId` scope strictement par `user_id` paramétré (§2.2, contre-mesure [[IDOR|idor]]) trié par `startAt` décroissant ; `findWithSiteById` joint `reservations` et `espaces` pour obtenir le `site_id` nécessaire à l'autorisation du chapitre 4, puisque `reservations` ne porte pas cette colonne (§2.3) ; `updateStatus`, enfin, ne transitionne que depuis `PENDING`.

  • Service : create sans restriction, validate dans l'ordre normatif

    `ReservationsService.create` ne vérifie ni rôle ni site avant de déléguer au [[repository|depot-git]] : contrairement à `EspacesService.create`, `spec-reservations.md` §2.1 ne réserve aucun 403 à cette route — tout rôle authentifié peut réserver un créneau pour lui-même. `userId` provient exclusivement de `currentUser.sub` (`JwtPayload`), jamais d'un champ du corps — la même contre-mesure [[IDOR|idor]] déjà appliquée à `EspacesService`, appliquée ici à l'identité plutôt qu'au site.

    `validate`, à l'inverse, porte l'ordre normatif complet du §6.3 : existence de la réservation (404 avant toute autorisation), puis rôle (403 `ROLE_FORBIDDEN` pour un `MEMBRE`), puis site (403 `SITE_FORBIDDEN` pour un `GERANT` d'un autre site), puis seulement alors la validité du verdict — un verdict malformé devient un 404 `RESERVATION_NOT_FOUND`, jamais avant que l'autorisation n'ait tranché, pour ne fuiter aucune information à un rôle non autorisé.

    Un test explicite verrouille cet ordre : un `MEMBRE` qui envoie un verdict malformé doit recevoir 403 `ROLE_FORBIDDEN`, pas 404 — preuve que l'autorisation est bien évaluée avant la validité du verdict, jamais l'inverse. Le même 404 `RESERVATION_NOT_FOUND` couvre trois causes distinctes (id inconnu, verdict invalide, transition déjà consommée), volontairement, pour ne jamais distinguer ces cas à un appelant non autorisé (§3.3, §6.3).

  • Controller et module : JWT dès la conception sur les trois routes

    Contrairement à `EspacesController` (`JwtAuthGuard` ajouté en retrofit au [[TD18|td-18]] sur `POST`/`PATCH` uniquement, `GET` restant public), `ReservationsController` porte `@UseGuards(JwtAuthGuard)` sur ses trois routes dès leur toute première version — `GET /reservations/me` y compris, car `spec-reservations.md` §2.2 ne prévoit aucun accès public. `create` valide la forme du corps puis délègue, exactement comme `EspacesController.create`.

    `findMine` ne fait que déléguer et convertir le tableau en camelCase (`toReservationResponse`, même mapper que les deux autres routes, §1). `validate`, lui, ne valide jamais lui-même la forme de verdict : le corps brut, extrait par `extractVerdict`, est transmis tel quel au service — c'est ce dernier qui décide seul, après autorisation, du moment où un verdict malformé devient un 404 (chapitre 4).

    `ReservationsModule` importe `AuthModule` pour réutiliser `JwtAuthGuard` sans le redéfinir, exactement comme `EspacesModule` — et `AppModule` l'enregistre aux côtés des modules déjà présents. Aucun autre fichier existant n'est modifié au-delà de cette liste `imports` d'`AppModule` et de l'ajout de `getClient()` dans `DatabaseService` (chapitre 3).

  • Tests e2e : la course concurrente qui prouve le verrou, puis commit

    Un [[test unitaire|test-unitaire]], même avec un client `pg` mocké fidèlement, ne peut jamais prouver qu'un vrai verrou PostgreSQL empêche réellement deux transactions concurrentes de créer des réservations qui se chevauchent — seul un test contre une base réelle, avec deux requêtes HTTP effectivement simultanées, le peut. `reservations.e2e-spec.ts` ajoute donc, aux côtés de la matrice rôle/site déjà connue depuis [[TD18|td-18]], un test qui envoie exactement le même créneau via `Promise.all` depuis deux comptes `MEMBRE` distincts.

    `Promise.all([a, b])` lance `a` et `b` en parallèle et attend les deux, contrairement à `await a; await b;` qui attendrait la fin de la première avant même de démarrer la seconde — ce parallélisme réel est ce qui simule ici une vraie concurrence réseau.

    Le test va plus loin qu'un simple compte de statuts : il vérifie aussi que la requête perdante reçoit exactement `{ code: 'RESERVATION_OVERLAP' }`, puis interroge directement `reservations` pour confirmer qu'une seule ligne `PENDING`/`CONFIRMED` existe sur ce créneau précis — la preuve qu'aucun double `INSERT` n'a pu se produire, même sous une vraie concurrence réseau. Dix-neuf tests couvrent au total les trois routes : 401 sur chacune, chevauchement, adjacence, et la matrice rôle/site déjà connue pour `PATCH .../validate`.

    48 tests e2e (29 déjà connus depuis [[TD18|td-18]] + 19 nouveaux) et 156 tests unitaires sont verts, sans qu'un seul test hérité n'ait changé de déclencheur. Le périmètre hors-scope figé au §9 de `spec-reservations.md` (liste `GERANT` par site, annulation `MEMBRE`, auto-validation, cache Redis [[TD23|td-23]]) reste inchangé : rien de tout cela n'a été implémenté ici. Le code est prêt à être commité, en deux temps (module applicatif, puis suite e2e).

Développer le formulaire de réservation de CoWorkNet

  • But de ce TD et périmètre du parcours protégé

    Ce TD ouvre la consommation front-end du contrat `/reservations` stabilisé et sécurisé aux [[TD19|td-19]]/[[TD20|td-20]] : à la fin de ce TD, un MEMBRE ouvrant `http://localhost:4100/espaces` peut cliquer sur « Réserver » depuis une carte du catalogue, se connecter si nécessaire (écran B), choisir un créneau sur la fiche de l'espace visé (écran C) et obtenir une réservation réelle au statut `PENDING` dans `coworknet_db`. Le [[jeton JWT|jwt]] renvoyé par `POST /auth/login` est désormais stocké côté client et envoyé en `Authorization: Bearer` sur chaque appel à `POST /reservations` : ce parcours devient protégé par connexion, ce qu'il n'était pas avant ce TD.

    Ce TD mobilise le CP2 (développer des interfaces utilisateur) : validation systématique des champs Début/Fin avant tout appel réseau, restitution intégrale de chaque erreur renvoyée par l'[[API|api]] — dont le chevauchement de créneau (`RESERVATION_OVERLAP`) — et respect des règles [[RGAA|rgaa]] déjà posées au [[TD02|td-02]] (`regles-rgaa-ux.md`) : `fieldset`/`legend`, labels associés, jamais une erreur portée par la seule couleur. Le contrat [[API|api]] n'est pas renégocié : `POST /auth/login`, `POST /auth/register` et `POST /reservations` restent exactement ceux stabilisés et testés aux [[TD16|td-16]] et [[TD20|td-20]].

    Ce TD s'en tient strictement à ce périmètre : il construit les écrans B (connexion/inscription) et C (fiche espace/réservation) du flux visiteur -> MEMBRE de `flux-navigation.md`, mais laisse volontairement de côté l'écran D (« Mes réservations », US3, `GET /reservations/me`), hors périmètre de ce TD précis — après un succès, l'utilisateur reste sur l'écran C avec un message de confirmation, plutôt que d'être redirigé vers un écran D qui n'existe pas encore.

  • Étendre lib/api.ts et stocker le jeton côté client

    `lib/api.ts` porte déjà `getHealth` ([[TD10|td-10]]) et `getEspaces` ([[TD13|td-13]]) : deux fonctions qui interrogent un service distant et ne lèvent jamais, quel que soit l'échec. `login`, `register` et `createReservation` reprennent exactement cette convention, en y ajoutant un résultat discriminé par `ok` et `code` : jamais de `throw`, l'appelant décide de l'affichage à partir d'un code d'erreur stable plutôt que d'un message déjà mis en forme.

    `spec-auth.md` §4.2 impose un seul code d'erreur générique pour `POST /auth/login`, quelle que soit la cause (email inconnu ou mot de passe incorrect) : `LoginResult` reflète cette contrainte à l'identique côté front, un seul code `INVALID_CREDENTIALS`, jamais de variante par cause. Toute réponse non-200 (y compris un corps inattendu) est donc traitée comme des identifiants incorrects, sans distinction observable par l'appelant.

    `createReservation` ajoute `Authorization: Bearer <accessToken>` à la requête (US2, MEMBRE connecté) et distingue explicitement un 401 (jeton absent/expiré, l'appelant doit renvoyer l'utilisateur se reconnecter) des 400 métier (`VALIDATION_ERROR`, `ESPACE_NOT_FOUND`, `ESPACE_INACTIVE`, `RESERVATION_OVERLAP`), qui restent affichables directement dans le formulaire sans déconnecter personne.

    Le jeton lui-même doit survivre à un rafraîchissement de page (`spec-auth.md` §5.4 : renvoyé une seule fois par `POST /auth/login`, jamais dans un cookie côté CoWorkNet) : `lib/auth-storage.ts` le garde dans `localStorage` — un espace de stockage clé-valeur propre au navigateur et à ce site, qui persiste tant qu'on ne l'efface pas explicitement, contrairement à un cookie (jamais renvoyé automatiquement au serveur) ou à une simple variable en mémoire qui serait perdue au moindre F5. Chaque fonction de ce module vérifie `typeof window === 'undefined'` avant de le toucher : les composants qui l'utilisent sont des Client Components, mais Next.js les rend malgré tout une première fois côté serveur, où `localStorage` n'existe pas.

    Un composant React qui lit directement `window.localStorage.getItem(...)` dans le corps de son rendu casserait l'hydratation Next.js (le rendu serveur ne connaît pas cette valeur) ; la lire dans un `useEffect` puis appeler `setState` fonctionne, mais provoque un rendu supplémentaire évitable et un flash « non connecté » plus long que nécessaire. `useSyncExternalStore` (React 19) est le hook conçu précisément pour synchroniser React à une source de vérité externe comme `localStorage`, sans ce compromis.

    `useSyncExternalStore` prend trois fonctions : `subscribe` (s'abonner aux changements de la source externe, renvoie une fonction de désabonnement), `getSnapshot` (lire la valeur actuelle côté client) et `getServerSnapshot` (valeur à utiliser pour le premier rendu serveur). L'exemple canonique hors CoWorkNet est un hook `useOnlineStatus`, qui suit `navigator.onLine` plutôt que `localStorage` — il isole le principe général avant de l'appliquer au jeton.

    `getServerStoredToken` renvoie toujours `null` : le premier rendu client (hydratation) affiche donc exactement ce que le serveur a rendu, sans erreur d'hydratation, puis React relit `getStoredToken` côté client dès que possible et met à jour l'affichage. `subscribeToStoredToken` écoute aussi un événement synthétique `coworknet:auth-changed`, déclenché par `setStoredToken`/`clearStoredToken` : l'événement natif `storage` du navigateur ne se déclenche que pour les autres onglets, jamais pour celui qui vient d'écrire.

  • Écran B : connexion, inscription et en-tête transverse

    `app/connexion/page.tsx` reprend l'écran B (connexion) de `maquettes-ecrans.md` : un bouton de soumission explicite est conservé ici, contrairement à la règle générale « pas de bouton Enregistrer » du projet — ce formulaire ne modifie aucune ressource déjà existante, il obtient un jeton pour une session qui n'existe pas encore, l'exception « création »/« action de connexion » documentée dans `CLAUDE.md`. Le paramètre `?redirect=` mémorise l'écran d'origine pour y revenir après connexion.

    `<Suspense fallback={...}>` est une frontière qui affiche un contenu de repli tant que le composant qu'elle entoure n'a pas fini de se préparer, puis le remplace une fois prêt. `useSearchParams()` exige justement cette frontière en Next.js 16 pour une page pré-rendue statiquement (`next build` échoue sinon avec « should be wrapped in a suspense boundary ») : le composant qui lit `?redirect=` est donc isolé dans `ConnexionForm`, monté via `<Suspense>` par le `default export`. Même motif côté `app/inscription/page.tsx`.

    `spec-auth.md` §3.1 confirme que `POST /auth/register` ne renvoie jamais de jeton (seuls `id` et `email`) : `app/inscription/page.tsx` ne peut donc pas connecter automatiquement l'utilisateur après un 201, ce qui serait inventé hors du contrat. Il redirige plutôt vers `/connexion`, en conservant `redirect` et en signalant `registered=1` pour afficher un message de confirmation.

    `components/site-header.tsx` affiche, sur toutes les pages via `app/layout.tsx`, les liens « Se connecter »/« S'inscrire » ou « Bonjour, <email> »/« Déconnexion » : ces éléments sont « toujours visibles, non conditionnés à un état de session déjà chargé » (maquette écran A). Il réutilise `useStoredToken` plutôt qu'un `useState`+`useEffect` manuel, pour la même raison qu'au chapitre 2 — synchroniser proprement une donnée externe à React.

    `useMemo(() => calcul(token), [token])` mémorise le résultat de `parseStoredUser(token)` et ne le recalcule que si `token` change d'une passe de rendu à l'autre — utile ici pour éviter de re-décoder le jeton à chaque rendu de `SiteHeader` sans raison, alors que `token` ne change qu'à la connexion ou à la déconnexion.

  • Écran C : fiche espace et formulaire de réservation protégé

    Le contrat [[API|api]] stabilisé au [[TD11|td-11]] ne définit aucune route `GET /espaces/:id` : `app/espaces/[id]/page.tsx` réutilise donc `GET /espaces` (déjà consommé par le catalogue) sans filtre, puis trouve l'espace ciblé par `id` côté serveur — ajouter une route hors contrat pour ce seul besoin d'affichage aurait été une régression par rapport à la discipline tenue depuis le [[TD11|td-11]].

    `notFound()` (Next.js) rend le cas d'un `id` qui ne correspond à aucun espace, y compris un espace désactivé (`GET /espaces` ne renvoie jamais `isActive=false`, `spec-espaces.md`) : cette fiche ne peut donc jamais afficher un espace déjà retiré du catalogue, exactement le même filtre que celui déjà garanti côté [[API|api]] pour la page `/espaces` au [[TD13|td-13]], sans logique dupliquée côté front.

    `reservation-form.tsx` est un [[Client Component|client-component]] : lui seul peut lire `localStorage` et écouter la soumission du formulaire. `isAuthenticated` dérive de `useStoredToken() !== null` ; un effet redirige vers `/connexion?redirect=<pathname>` dès que cette valeur est fausse, sans jamais afficher le formulaire à un visiteur non connecté — jamais un formulaire affiché puis rejeté après coup par un 401 évitable.

    La validation côté interface (CP2) reprend, en miroir, les bornes déjà posées côté [[API|api]] par `reservations.validation.ts` ([[TD20|td-20]], `spec-reservations.md` §3.2) : `startAt` strictement dans le futur, `endAt` strictement postérieur à `startAt`. Elle ne dispense jamais l'[[API|api]] de sa propre validation (défense en profondeur) ; elle évite seulement un aller-retour réseau pour une saisie déjà invalide, avant même d'envoyer la requête.

    Le champ `datetime-local` ne porte pas de fuseau explicite, alors que `spec-reservations.md` §3.2 l'exige ("Z" ou "+02:00") : `new Date(startAt).toISOString()` résout ce point en interprétant la saisie dans le fuseau du navigateur puis en la restituant en UTC suffixée Z, un format que le regex `ISO_WITH_OFFSET_FORMAT` côté [[API|api]] accepte déjà tel quel, sans négociation supplémentaire du contrat.

    `OVERLAP_MESSAGE` reprend mot pour mot le texte imposé par `maquettes-ecrans.md` pour l'écran C : jamais une bordure rouge sans texte associé (`regles-rgaa-ux.md`, règle [[RGAA|rgaa]] 4). Un 401 en cours de soumission (jeton expiré entre l'ouverture de la fiche et l'envoi du formulaire) efface le jeton et renvoie vers la connexion, exactement comme l'absence de jeton au montage.

    `<fieldset>` regroupe sémantiquement plusieurs champs liés (ici Début et Fin du créneau) sous une légende commune, portée par `<legend>` (« Créneau souhaité ») : un lecteur d'écran annonce cette légende avant de lire chaque champ du groupe, ce qui évite de répéter « Créneau » dans chaque `<label>`.

    Le catalogue (`app/espaces/page.tsx`, [[TD13|td-13]]) reçoit son seul changement de ce TD : chaque carte porte désormais un lien « Réserver » vers `/espaces/[id]`, conformément à `maquettes-ecrans.md` (« clic sur toute la carte ou sur Réserver ouvre l'écran C »). Aucune autre ligne de ce fichier n'est modifiée — le rendu du catalogue lui-même (US1) reste celui figé au [[TD13|td-13]].

  • Vérifier en conditions réelles et committer

    Avant de committer : `pnpm --filter web test` (20 tests, dont les 12 nouveaux cas de `login`/`register`/`createReservation` ajoutés aux 8 déjà existants pour `getHealth`/`getEspaces` depuis le [[TD13|td-13]]), `tsc --noEmit` et `eslint` sans erreur, puis `next build` pour vérifier que `/connexion` et `/inscription` restent statiques (leur `Suspense` les rend éligibles) alors que `/espaces/[id]` reste dynamique.

    Vérification manuelle en conditions réelles, [[conteneur|conteneur-docker]] `coworknet-db` déjà démarré, [[api|api]] et web lancés en local (ports 4000/4100) : inscription d'un compte réel, connexion, `POST /reservations` sur un espace actif du seed (201, statut `PENDING`), tentative de réservation du même créneau sur le même espace (400 `RESERVATION_OVERLAP`), et un appel sans jeton (401 `UNAUTHORIZED`) — les quatre résultats attendus par `spec-reservations.md` §7.1 observés tels quels.

    Aucune régression côté `apps/api` : ce TD ne modifie aucun fichier de ce répertoire, `pnpm --filter api test` reste à 156 tests verts ([[TD20|td-20]]). Le [[commit|commit]] regroupe l'ensemble des fichiers front ajoutés ou modifiés par ce TD, avec le préfixe imposé par les conventions du dépôt CoWorkNet.

Tester l'authentification et les permissions de l'API de CoWorkNet avec Bruno

  • But de ce TD et cadrage (CP9, matrice de sécurité)

    Ce TD ne modifie aucune ligne de `apps/api` : son livrable est une [[collection Bruno|collection-bruno]] (`bruno/CoWorkNet/auth-permissions/`), 29 requêtes séquentielles qui rejouent contre l'[[API|api]] réelle (docker coworknet-db + `pnpm --filter api start:dev`, port 4000) la matrice rôle/site déjà figée par les [[TD15|td-15]]/17/19 — sans jamais toucher au code qui l'implémente. À la fin de ce TD, `bru run auth-permissions -r --env local` exécute ces 29 requêtes et affiche un résumé PASS.

    Cette matrice, écrite au [[TD17|td-17]] et vérifiée par les tests Jest e2e du [[TD18|td-18]] (`espaces-security.e2e-spec.ts`), n'avait encore jamais été rejouée par un outil externe au framework de test lui-même. `spec-reservations.md` transpose exactement la même logique (rôle avant site, §6.3) à `PATCH /reservations/:id/validate` : ce TD construit donc deux matrices jumelles, l'une pour `/espaces`, l'autre pour `/reservations`, dans une seule collection.

    Ce TD mobilise le CP9 sur un axe que le [[TD14|td-14]] (fuzzing de `/espaces`, sans authentification) ne couvrait pas encore : le fuzzing des champs d'authentification eux-mêmes — jeton absent, jeton syntaxiquement invalide, et surtout un jeton syntaxiquement valide dont seule la signature est altérée, le cas le plus révélateur d'une vérification `HS256` réellement exécutée plutôt qu'un simple contrôle de format.

  • Cinq comptes de test dédiés, élevés directement en base

    Les 401 se testent avec n'importe quel compte inexistant, mais les 403 exigent de vrais jetons `GERANT` et `ADMIN`. Or `POST /auth/register` force toujours le rôle `MEMBRE` (`spec-auth.md` §3.3, anti-[[escalade de privilège|escalade-privilege]]) : impossible de créer un `GERANT` ou un `ADMIN` depuis l'[[API|api]] elle-même. La stratégie retenue par `apps/api/test/espaces-security.e2e-spec.ts` ([[TD18|td-18]]) — enregistrer un `MEMBRE` puis élever son rôle directement en base — est transposée ici en dehors de Jest, sous forme de comptes permanents ajoutés à `db/seed.sql` plutôt que créés et détruits à chaque exécution.

    Deux `GERANT` distincts sont nécessaires : l'un rattaché au site de test `testSiteId` (déjà défini dans `environments/local.bru` par le [[TD14|td-14]]), l'autre à `parisSiteId` — un site du catalogue de démonstration, jamais écrit, seulement référencé par `site_id`. C'est ce second compte qui matérialise systématiquement le cas `SITE_FORBIDDEN` de la matrice, sans avoir à créer un troisième site rien que pour ce test. `membreB`, lui, ne crée jamais de réservation : il sert uniquement au test de scoping du chapitre 5.

    Les quatre autres connexions (02 à 05) suivent exactement ce patron : un `POST /auth/login` sans en-tête (`auth: none`, l'authentification par mot de passe n'a rien à voir avec le porteur de jeton des requêtes suivantes), et un `script:post-response` qui capture `accessToken` dans une variable Bruno propre à chaque rôle — `gerantTestSiteToken`, `gerantAutreSiteToken`, `membreAToken`, `membreBToken`. Ces cinq variables sont ensuite réutilisées telles quelles, via {{...}} dans un bloc `headers`, par toutes les requêtes de la matrice.

  • Matrice /espaces : 401, 403 puis un contrôle positif

    La matrice `/espaces` réutilise `testSiteId`, déjà défini dans `environments/local.bru` par le [[TD14|td-14]] — aucune modification de ce fichier n'est nécessaire. Chaque bloc (POST puis PATCH) suit le même schéma : 401 sans jeton, 401 avec un jeton syntaxiquement invalide, 403 `ROLE_FORBIDDEN` pour un `MEMBRE`, 403 `SITE_FORBIDDEN` pour un `GERANT` d'un autre site, puis un contrôle positif qui prouve que le rejet ne vient pas d'une route cassée pour tout le monde.

    Le fichier 07 (non reproduit ici) répète ce test avec un en-tête `Authorization: Bearer un.jeton.invalide` : une chaîne qui n'est même pas un [[JWT|jwt]] syntaxiquement bien formé, distincte du cas plus subtil du chapitre 4 (un vrai jeton dont seule la signature est altérée). Les deux convergent vers le même 401 `UNAUTHORIZED` — jamais de distinction observable entre un format invalide et une signature fausse (`spec-securisation-espaces.md` §2.3).

    Le contrôle positif qui clôt ce bloc (10) crée réellement l'espace du site de test avec le jeton `gerantTestSiteToken` : il capture l'id renvoyé dans `espaceIdAuthPermissions`, réutilisé par tous les `PATCH /espaces` qui suivent (11-14) et par toutes les réservations du chapitre 4. Sans ce contrôle, un guard qui bloquerait indistinctement toute requête POST donnerait exactement les mêmes 401/403 que ceux vérifiés ci-dessus — la preuve de bon fonctionnement ne peut venir que d'un cas qui réussit.

    Les quatre fichiers PATCH (11-14, non reproduits) répètent exactement ce même schéma sur l'espace capturé en 10 : 401 sans jeton, 403 `ROLE_FORBIDDEN`, 403 `SITE_FORBIDDEN`, puis 200 pour le `GERANT` du site de test qui augmente la capacité — confirmant que `spec-securisation-espaces.md` §3.2 (le PATCH lit d'abord l'espace visé, mais rejette un `MEMBRE` avant même de considérer son site) se vérifie aussi depuis l'extérieur de Jest.

  • Matrice /reservations : validate, fuzzing de signature, contrôles positifs

    `PATCH /reservations/:id/validate` suit un ordre d'évaluation normatif distinct de celui de `/espaces` : l'existence de la réservation précède l'autorisation (§6.3), alors que `POST /espaces` vérifie l'autorisation avant l'existence du site (`spec-securisation-espaces.md` §4). Ce TD ne teste pas cet ordre lui-même (déjà couvert par `reservations.service.spec.ts`, [[TD20|td-20]]) mais s'appuie dessus : chaque réservation ciblée existe réellement, créée juste avant par `MEMBRE` A.

    Après la création d'une première réservation `PENDING` par `MEMBRE` A (fichier 17, sur l'espace du chapitre 3), les fichiers 18 et 19 vérifient les deux 401 déjà vus sur `/espaces` (sans jeton, jeton syntaxiquement invalide). Le fichier 20 introduit le cas le plus subtil de cette collection : un jeton par ailleurs parfaitement autorisé, mais dont la signature a été altérée par le script `pre-request` lui-même.

    Le dernier caractère d'un jeton `HS256` encode les derniers bits de la signature : le remplacer garantit, en pratique, une signature différente — la comparaison échoue et `JwtAuthGuard` lève la même exception générique que pour un jeton absent (`spec-securisation-espaces.md` §2.3). Ce test aurait échoué (200 ou une erreur non gérée) si l'implémentation ne vérifiait la signature qu'à l'émission, ou décodait le payload sans jamais appeler `jwtService.verifyAsync`.

    Le fichier 22 (non reproduit) répète ce test avec `gerantAutreSiteToken` sur la même réservation, pour le 403 `SITE_FORBIDDEN`. Deux nouvelles réservations sont ensuite créées (23, 25, sur des créneaux distincts pour ne jamais chevaucher `spec-reservations.md` §4) : chacune sert de cible à un contrôle positif unique, pour ne jamais transitionner deux fois la même ressource et fausser un test suivant.

  • Scoping anti-IDOR, exécution complète et commit

    `GET /reservations/me` ne prend aucun paramètre d'id : sa seule contre-mesure [[IDOR|idor]] possible est un scoping total par l'identité extraite du jeton, jamais par une valeur fournie par le client (`spec-reservations.md` §8). Le chapitre précédent a créé trois réservations pour `MEMBRE` A ; ce chapitre vérifie que `MEMBRE` B, qui n'en a créé aucune, ne voit jamais celles d'un autre utilisateur.

    Un dernier fichier (29, non reproduit) vérifie la non-régression inverse : `GET /espaces` reste public sans aucun jeton, même après le retrofit [[TD17|td-17]]/18 — seule sa version en écriture a été sécurisée (`spec-securisation-espaces.md` §5). Avec l'[[API|api]] démarrée et les cinq comptes déjà en base, la collection s'exécute intégralement en mode récursif, dans l'ordre du champ `seq` de chaque fichier.

    Contrairement à `/espaces` ([[TD14|td-14]]), qui n'a pas de route `DELETE`, l'espace fixture et les réservations créés par cette collection s'accumulent d'une exécution à l'autre dans le site de test — deux exécutions consécutives ont été rejouées ici pour confirmer que cette accumulation ne casse aucune [[assertion|assertion]] (chaque exécution crée son propre espace et ses propres réservations, jamais de collision d'id).

    Avant de committer, les suites Jest existantes sont rejouées pour confirmer qu'aucune régression n'a été introduite (aucun fichier `apps/api` n'a pourtant été modifié) : 156 tests unitaires et 48 tests e2e restent au vert.

    Le [[commit|commit]] rassemble la [[collection Bruno|collection-bruno]] et l'unique modification de `db/seed.sql` (ajout des cinq comptes de test), sans toucher à aucun fichier de `apps/api` ni de `apps/web`.

    Le cours magistral associé (CP9, préparation et exécution des plans de tests) approfondit la théorie des tests de sécurité et des campagnes de fuzzing au-delà de cet exemple appliqué à l'authentification et aux permissions de CoWorkNet.

Mettre en cache les disponibilités des espaces avec Redis

  • But de ce TD et infrastructure Redis

    Ce TD ajoute un cache Redis en lecture devant `GET /espaces?siteId=&date=` : à la fin de la séance, ce catalogue répond effectivement depuis un vrai serveur Redis (`localhost:6390`) plutôt que de resolliciter PostgreSQL à chaque affichage, et toute réservation créée invalide immédiatement l'entrée de cache concernée — jamais seulement au bout du TTL de 60 secondes. La règle exacte (clé, TTL, invalidation) est déjà figée par la spec produit et par `architecture-logicielle.md` §2.5/§7 depuis la conception ([[TD03|td-03]]) : ce TD ne l'invente pas, il l'implémente, en la complétant d'abord par écrit ([[SDD|sdd]]) sur les points qu'elle ne tranche pas, avant d'écrire le moindre test (CP8).

    Premier geste concret : ajouter un service 'cache' au `docker-compose.yml` existant ([[TD07|td-07]]), sur le port dédié 6390 pour ne jamais entrer en conflit avec le Redis de la plateforme ecole (6379). Aucun volume n'est monté : un cache n'a pas besoin de survivre à un redémarrage, sa perte de données au reboot est acceptable et même souhaitable pour ce rôle.

    `docs/specs/spec-cache-espaces.md` est ensuite rédigé avant toute ligne de code applicatif ([[SDD|sdd]], même démarche qu'aux [[TD11|td-11]]/[[TD17|td-17]]/[[TD19|td-19]]) : il reprend à la lettre le format de clé et le TTL déjà imposés par la spec produit, puis tranche trois points qu'elle laisse ouverts — la sérialisation retenue (`EspaceRow[]` en [[JSON|json]], jamais la forme camelCase du controller), la résilience (aucune indisponibilité Redis ne doit jamais transformer un 200/201 en erreur), et le calcul des dates à invalider quand un créneau de réservation chevauche minuit (les deux dates UTC, dédupliquées, jamais une seule).

    Ce document sert ensuite de référence stable pour tout le reste du TD, exactement comme `spec-reservations.md` l'a été pour le [[TD20|td-20]] : les chapitres suivants ne renégocient ni le format de clé, ni le TTL, ni la règle des deux dates — ils les implémentent en [[TDD|tdd]] à échelle réduite (un test, puis le code minimal qui le fait passer, chapitre par chapitre plutôt que fichier par fichier).

  • CacheService : un client Redis qui ne casse jamais une requête

    `pnpm --filter api add ioredis` ajoute le client Redis retenu (`docs/specs/spec-cache-espaces.md` §5). `apps/api/src/cache/cache-keys.ts` centralise le format de clé et le TTL : `EspacesService` (lecture) et `ReservationsService` (invalidation) partagent cette unique fonction plutôt que de reconstruire chacun de leur côté la chaîne `espaces:dispo:{siteId}:{date}`.

    `CacheService` reprend exactement le modèle de `DatabaseService` (configuration via `ConfigService`, jamais `process.env` direct) mais s'en écarte sur un point central (`spec-cache-espaces.md` §4) : chaque méthode absorbe elle-même ses erreurs. Un Redis indisponible ne doit jamais remonter jusqu'au controller — `get` renvoie `null` (équivalent à une absence de cache), `set`/`del` avalent silencieusement l'échec après un simple avertissement journalisé.

    Le premier test rouge de `cache.service.spec.ts` porte justement sur cette résilience : un client ioredis mocké dont `get()` rejette doit tout de même laisser `CacheService.get()` résoudre `null`, jamais lever. `get`/`set` utilisent respectivement `JSON.parse`/`JSON.stringify`, puisque le client Redis manipule des chaînes.

    `CacheModule`, comme `DatabaseModule`, est un module global (`@Global()`) : `EspacesModule` et `ReservationsModule` injecteront `CacheService` sans jamais l'importer explicitement. Il ne reste qu'à l'ajouter une seule fois dans `AppModule`, aux côtés de `DatabaseModule`.

  • Cache de lecture sur GET /espaces

    `FindAllEspacesFilters` gagne un champ `date` optionnel, distinct d'`onlyActive` : `onlyActive` continue, inchangé depuis avant ce TD, à filtrer la requête SQL, tandis que `date` sert uniquement à `EspacesService` à construire la clé de cache — la `spec-cache-espaces.md` §1 exige que `siteId` ET `date` soient tous deux syntaxiquement valides pour qu'une clé existe, sinon le [[repository|depot-git]] est interrogé directement.

    `EspacesController.findAll` transmet désormais `date` au service en plus d'`onlyActive` dérivé, sans toucher au contrat `GET /espaces?siteId=&date=` -> 200 [...] : ce controller ignore totalement l'existence du cache, il se contente de propager une valeur déjà validée.

    `EspacesService.findAll` porte enfin la logique de cache : construction de la clé seulement si `siteId` et `date` sont tous deux fournis, lecture Redis avant tout accès PostgreSQL, puis peuplement du cache (TTL 60s) uniquement en cas d'absence — la valeur mise en cache est `EspaceRow[]` (forme SQL), jamais la forme [[JSON|json]] du controller.

    Le test le plus important de ce chapitre vérifie qu'un cache hit ne déclenche jamais le [[repository|depot-git]] : `repository.findAll` ne doit tout simplement pas être appelé quand `cacheService.get` renvoie une valeur non nulle. À l'inverse, quand `siteId` est fourni seul (sans `date`), aucun appel à Redis ne doit avoir lieu — la clé produit exactement le format `espaces:dispo:{siteId}:{date}` imposé par la spec produit, jamais une variante à une seule composante.

  • Invalidation explicite lors de POST /reservations

    L'invalidation exige de connaître le site de l'espace réservé — une donnée que `reservations` ne porte pas (`spec-reservations.md` §2.3). La transaction verrouillée du [[TD20|td-20]] (`createWithLock`) lit déjà la ligne `espaces` ciblée par un `SELECT ... FOR UPDATE` : ce TD étend simplement cette même requête pour renvoyer aussi `site_id`, sans requête supplémentaire ni allongement de la transaction (transactions courtes, `architecture-logicielle.md` §7).

    `CreateReservationResult`, le [[type discriminé|type-discrimine]] déjà utilisé au [[TD20|td-20]], gagne un champ `site_id` sur son issue `CREATED` — le [[repository|depot-git]] continue de ne jamais lever d'exception HTTP ni de connaître Redis, il transmet seulement la donnée nécessaire au service.

    `ReservationsService.create` appelle désormais `invalidateDispoCache` après un `COMMIT` réussi, jamais avant, et jamais sur les trois issues d'échec (aucune réservation n'a été créée, rien à invalider) : un échec de `CacheService.del`, déjà absorbé en interne par `CacheService` (§4), ne peut donc jamais faire échouer le 201 déjà acquis.

    `invalidateDispoCache` calcule les deux dates UTC de `startAt` et `endAt` (dédupliquées via un `Set`, `spec-cache-espaces.md` §3.2) et invalide une clé par date distincte — jamais une seule, pour couvrir un créneau qui chevauche minuit, cas testé explicitement dans `reservations.service.spec.ts` avec un créneau 23:30 -> 00:15.

  • Preuve en conditions réelles (Redis + PostgreSQL) et commit

    Un [[test unitaire|test-unitaire]], même avec `CacheService` mocké fidèlement, ne peut pas prouver qu'une clé est effectivement écrite dans un vrai serveur Redis avec le bon TTL, ni qu'elle est effectivement supprimée par un vrai `POST /reservations`. `cache-espaces.e2e-spec.ts` s'y connecte donc réellement (`localhost:6390`) en plus de la base `coworknet_db` déjà utilisée par les autres suites e2e.

    Le test de cache hit va plus loin qu'un simple contrôle de présence : il falsifie directement la valeur en cache via le client Redis du test (en dehors de tout appel HTTP), puis vérifie que le prochain `GET /espaces` renvoie cette valeur falsifiée plutôt que l'état réel de PostgreSQL — la seule preuve possible qu'une lecture provient bien du cache, pas d'une nouvelle requête SQL qui aurait par coïncidence renvoyé le même résultat.

    Le dernier test du fichier prouve l'invalidation de bout en bout : un `GET` peuple la clé, puis un `POST /reservations` réussi sur ce même site et cette même date doit la faire disparaître de Redis, vérifié par un `redis.get` direct qui doit renvoyer `null` immédiatement après le 201 — jamais après avoir attendu le TTL.

    174 tests unitaires et 53 tests e2e (48 déjà connus depuis le [[TD20|td-20]] + 5 nouveaux) sont verts, sans qu'aucun déclencheur hérité n'ait changé — le contrat `GET /espaces` / `POST /reservations` reste identique à la lettre. Le code est prêt à être commité en un seul [[commit|commit]], spec comprise.

Développer le back-office gérant de CoWorkNet

  • But de ce TD et rappel de l'autorisation déjà posée

    Ce TD ferme le seul point explicitement laissé hors périmètre par `spec-reservations.md` §9 : depuis le [[TD20|td-20]], un `GERANT` peut déjà valider une réservation via `PATCH /reservations/:id/validate`, mais rien ne lui permet encore de savoir laquelle valider — aucune page ne liste les réservations `PENDING` de son site. À la fin de ce TD, un `GERANT` authentifié ouvrant `http://localhost:4100/back-office/reservations` voit cette liste (espace, créneau, email du demandeur) et confirme ou refuse chaque réservation d'un clic, la ligne disparaissant aussitôt ; un `ADMIN` y ajoute un sélecteur de site.

    Ce TD mobilise CP2 (développer des interfaces utilisateur) et CP3 (développer des composants métier) : la règle d'autorisation elle-même n'est pas réinventée, elle réutilise à l'identique le schéma posé au [[TD17|td-17]] (rôle avant site, `GERANT` restreint à son site, `ADMIN` sans restriction) et déjà appliqué à `PATCH /reservations/:id/validate` aux [[TD19|td-19]]/[[TD20|td-20]] — ce TD y ajoute seulement une route de lecture et l'écran qui la consomme.

    Ce TD s'en tient strictement à cet écran E : il ne construit pas l'onglet « Espaces » (écran F, gestion des espaces côté back-office, hors périmètre) ni une redirection automatique après connexion vers ce back-office — un `GERANT`/`ADMIN` y accède pour l'instant en naviguant explicitement, via le seul lien exposé dans l'en-tête. Le contrat des trois routes `/reservations` déjà stabilisées (`POST`, `GET .../me`, `PATCH .../validate`) n'est pas renégocié : une quatrième route s'y ajoute, documentée séparément.

  • Spécifier puis implémenter GET /reservations côté API

    `spec-reservations.md` §9 tranchait déjà la forme attendue de cette route ("`GET /reservations?siteId=&status=PENDING`") sans la spécifier entièrement : `docs/specs/spec-reservations-site.md` la spécifie maintenant en détail, sans modifier une seule ligne des trois routes déjà stabilisées. Sa décision centrale : cette route ne renvoie jamais qu'un statut `PENDING` (un paramètre `status` générique aurait été une fonctionnalité non demandée), et une forme de réponse dédiée porte les champs supplémentaires dont l'écran E a besoin.

    `findPendingBySite` ([[repository|depot-git]]) joint `reservations` à `espaces` (nom, site) et `users` (email) : une [[jointure|jointure]] absente de `findByUserId`, car `GET /reservations/me` n'a jamais besoin d'afficher l'email de son propre titulaire ni le nom de l'espace pour l'écran D. Le tri est croissant par `start_at`, à l'inverse de `GET /reservations/me` (décroissant) — l'écran E est une file d'arbitrage, la réservation la plus proche dans le temps est la plus urgente à traiter.

    `$1::uuid IS NULL OR e.site_id = $1` exprime en une seule requête paramétrée les deux cas de §2 : `NULL` ne filtre rien (`ADMIN` sans site choisi), une valeur filtre exactement ce site — jamais de requête SQL différente construite selon le cas, donc aucune interpolation conditionnelle à risque. `ReservationsService.findPending` décide seul de la valeur transmise à ce paramètre : le [[repository|depot-git]] ignore totalement qui appelle, `MEMBRE`/`GERANT`/`ADMIN`.

    `siteIdParam` n'est même pas regardé quand `currentUser.role === 'GERANT'` : ce n'est pas une vérification d'égalité suivie d'un rejet (comme `SITE_FORBIDDEN` pour `PATCH .../validate`), mais un remplacement pur et simple par `currentUser.siteId` — un `GERANT` ne peut obtenir, par aucune valeur de `siteId` envoyée, la liste d'un autre site que le sien. Le controller reste une couche de forme : il ne valide `siteId` que syntaxiquement (`UUID`), sans connaître la règle d'autorisation elle-même.

  • Tester GET /reservations (unitaire et e2e)

    Les quatre fichiers de tests du module `reservations` (déjà présents depuis le [[TD19|td-19]]/[[TD20|td-20]]) reçoivent chacun un nouveau `describe` : le [[repository|depot-git]] vérifie la requête SQL exacte (jointures, `PENDING`, tri, paramètre `NULL`), le service vérifie l'autorisation (`ROLE_FORBIDDEN`, [[IDOR|idor]] côté `GERANT`, comportement `ADMIN`), le controller vérifie la conversion `camelCase` et la validation syntaxique de `siteId`, sans jamais réintroduire de logique métier dans cette couche.

    Le cas le plus important côté service n'est pas un rejet, mais une substitution silencieuse : on prouve qu'un `GERANT` qui transmet le `siteId` d'un autre site se voit malgré tout servir la liste de son propre site, jamais celle demandée — c'est la même discipline de test que l'[[IDOR|idor]] déjà vérifié pour `GET /reservations/me` au [[TD20|td-20]] (jamais confiance en une valeur transmise par le client pour scoper une lecture).

    Le [[test unitaire|test-unitaire]] mocke le [[repository|depot-git]] ; seul un test e2e, contre la vraie `coworknet_db`, prouve que la [[jointure|jointure]] retourne effectivement la réservation d'un autre site quand elle existe, pour vérifier que le `GERANT` ne la reçoit jamais. Ce scénario crée un second espace sur un second site, y réserve un créneau, puis demande explicitement ce site via `?siteId=` en tant que `GERANT` du premier site.

  • Construire l'écran E côté apps/web

    `lib/api.ts` reçoit deux nouvelles fonctions sur le modèle déjà posé au [[TD21|td-21]] (jamais d'exception, résultat discriminé par `ok` et `code`) : `getPendingReservations` (`GET /reservations`, distingue 401/403/reste) et `validateReservation` (`PATCH .../validate`, distingue en plus 404). Un nouveau type `PendingReservation` reflète la forme dédiée de `spec-reservations-site.md` §1, jamais confondu avec `Reservation` (`POST`/`GET .../me`/`PATCH .../validate`).

    `app/back-office/reservations/page.tsx` est un [[Client Component|client-component]], comme `reservation-form.tsx` au [[TD21|td-21]] : lui seul peut lire le jeton stocké en `localStorage`. Un visiteur sans jeton, ou un `MEMBRE` authentifié, est redirigé sans jamais voir la liste, même brièvement — la vérification de rôle réelle reste entièrement côté [[API|api]] (§2 de `spec-reservations-site.md`), cette redirection n'étant qu'un confort de navigation (`regles-rgaa-ux.md`, règle UX 5).

    Le titre « Site : <nom> » (`GERANT`) réutilise `getSiteName` (`lib/sites.ts`, déjà écrit au [[TD13|td-13]]) à partir du `siteId` désormais aussi décodé par `parseStoredUser` ; un `ADMIN` voit à la place un `<select>` alimenté par `SITES`, sans bouton de validation supplémentaire (`onChange` déclenche aussitôt un nouveau chargement, même convention que `EspacesFilters` au [[TD13|td-13]]). Les boutons Confirmer/Refuser portent un `aria-label` nommé par leur effet (`regles-rgaa-ux.md`, règle [[RGAA|rgaa]] 6).

    Une validation réussie retire la ligne immédiatement (`regles-rgaa-ux.md` règle UX 2), sans recharger toute la liste ; `NOT_FOUND` (§6.3 de `spec-reservations.md`, réservation déjà traitée entretemps) retire la ligne de la même façon, puisqu'elle a de toute façon disparu du côté serveur — seuls `FORBIDDEN` et une erreur réseau affichent un message au plus près de la ligne concernée, jamais un toast global (règle formulaires de `CLAUDE.md`).

  • Vérifier en conditions réelles et committer

    Avant de committer : `tsc --noEmit` sur `apps/api` et `apps/web` sans erreur, `pnpm --filter api test` (186 passed) et `test:e2e` (60 passed) rejoués deux fois consécutivement sans régression ni flakiness, `pnpm --filter web test` (34 passed, dont les 14 nouveaux cas de `getPendingReservations`/`validateReservation` ajoutés aux 20 déjà existants depuis le [[TD21|td-21]]), `eslint` sans erreur des deux côtés, et `next build` confirmant `/back-office/reservations` comme route statique supplémentaire.

    Vérification manuelle en conditions réelles, `coworknet-db` et `coworknet-cache` déjà démarrés, [[api|api]] lancée en local (port 4000) : inscription d'un `MEMBRE` et d'un `GERANT` réels, promotion du second en base (`role=GERANT`, `site_id` d'un site existant), `POST /reservations` par le `MEMBRE`, puis `GET /reservations` par le `GERANT` (liste non vide, un autre `siteId` fourni explicitement toujours ignoré) et `PATCH .../validate` — la réservation confirmée disparaît bien de la liste rejouée.

    Les comptes et la réservation créés pour cette vérification manuelle sont supprimés de `coworknet_db` aussitôt après (`DELETE FROM reservations/users`), pour ne laisser aucune donnée de test parasite dans la base de développement partagée par les TD suivants. Le [[commit|commit]] regroupe l'ensemble des fichiers ajoutés ou modifiés par ce TD, avec le préfixe imposé par les conventions du dépôt CoWorkNet.

Dockeriser l'application CoWorkNet

  • Pourquoi dockeriser toute la stack

    Ce [[TD25|td-25]] dockerise l'application CoWorkNet dans son ensemble (CP1, CP11). Jusqu'ici, seuls les services `db` ([[TD7|td-07]]) et `cache` ([[TD23|td-23]]) tournaient dans `docker-compose.yml` : l'[[API|api]] NestJS et le front Next.js s'exécutaient directement sur la machine hôte, via `pnpm --filter api start:dev` et `pnpm --filter web dev`. À la fin de cette séance, deux [[Dockerfile|dockerfile]] multi-stage (`apps/api/Dockerfile`, `apps/web/Dockerfile`) construiront des images de production légères, et `docker-compose.yml` étendu démarrera les 4 services — `db`, `cache`, `api`, `web` — d'une seule commande `docker compose up -d --build`, sans qu'aucun outil autre que Docker ne soit nécessaire sur la machine hôte.

    Ce basculement complète la phase Déploiement et DevOps du projet (CP10, CP11) : le cours magistral Environnement de développement (chapitre « Docker Compose : reconstituer l'environnement de production ») situe cette étape comme le moyen de se rapprocher de la topologie de production sur le poste de développement, en éliminant le classique « ça marche chez moi » — un `pnpm --filter api start:dev` suppose [[Node.js|node-runtime]], pnpm et les bonnes versions installés localement, alors qu'une [[image Docker|image-docker]] embarque tout ce qu'il faut pour s'exécuter à l'identique sur n'importe quelle machine dotée de Docker.

    Ce TD ne touche à aucune route ni forme de réponse : le contrat [[API|api]] stabilisé dès le [[TD11|td-11]] (`GET /espaces`, `POST /auth/register`, `POST /reservations`, etc.) reste inchangé — seule la façon dont l'[[API|api]] et le front s'exécutent change, pas ce qu'ils exposent.

  • Écrire le Dockerfile multi-stage de l'API

    Comme vu au [[TD07|td-07]] (image vs [[conteneur|conteneur-docker]]), on passe ici à la notion de build multi-stage : un [[Dockerfile|dockerfile]] qui construit l'image en plusieurs étages successifs, chacun pouvant repartir du résultat du précédent sans embarquer ses outils de build dans l'image finale. Le cours magistral Environnement de développement (chapitre « Docker : images, conteneurs et [[Dockerfile|dockerfile]] ») approfondit cette notion au-delà de ce qui est nécessaire ici. `apps/api/Dockerfile` se construit depuis la RACINE du dépôt (et non `apps/api/`), car CoWorkNet est un workspace pnpm — `pnpm-workspace.yaml` et `pnpm-lock.yaml` n'existent qu'à cet endroit. Il compte 4 étages, enchaînés ci-dessous.

    L'étage `deps` installe l'intégralité du workspace (dépendances de développement incluses, nécessaires à `nest build`), avec `corepack prepare pnpm@11.9.0` pour figer exactement la version utilisée sur le reste du projet. Il copie aussi `apps/web/package.json`, alors que seule l'[[API|api]] sera construite : pnpm a besoin de connaître tous les membres du workspace pour résoudre correctement ses filtres (`--filter api`), même s'il n'installe que ce dont `apps/api` dépend réellement.

    L'étage `build` ne fait qu'une chose : `pnpm --filter api build`, qui exécute `nest build` et produit `apps/api/dist`. Aucun test n'y est rejoué — ils ont déjà validé le code en [[TDD|tdd]] avant ce TD ([[TD12|td-12]], [[TD16|td-16]], [[TD18|td-18]], [[TD20|td-20]]) ; ce [[Dockerfile|dockerfile]] package du code déjà vérifié, il ne le revérifie pas.

    L'étage `deploy` utilise `pnpm deploy`, la commande pensée par pnpm pour extraire d'un workspace un dossier autonome ne contenant que les dépendances d'un seul package. Un premier essai sans `--legacy` échoue (ci-dessus) : depuis pnpm v10, le mode par défaut suppose des dépendances internes « injectées » entre packages du workspace. CoWorkNet n'en a aucune (`api` et `web` ne dépendent pas l'un de l'autre, pas de `packages/` partagé) : `--legacy` suffit donc, sans toucher à `.npmrc`. `--prod` exclut `ts-jest`, `@nestjs/testing` et le reste des `devDependencies` de l'image finale.

    L'étage `runtime` repart d'une image `node:22-alpine` vierge et ne copie que `dist/` et le `node_modules` de production issu de `deploy` : ni le code source TypeScript, ni les `devDependencies` n'atterrissent dans l'image finale. Point d'attention pour `bcrypt` (coût 12, [[TD16|td-16]]) : le dossier `prebuilds/` ci-dessus montre qu'il fournit un binaire précompilé pour `linux-x64` en variante musl (`bcrypt.musl.node`, exactement la libc d'Alpine) — `node-gyp-build` le sélectionne automatiquement à l'installation. Aucune chaîne de compilation (`python3`, `make`, `g++`) n'est donc nécessaire, ni ici ni dans les étages précédents.

  • Écrire le Dockerfile multi-stage du Web

    Next.js propose son propre équivalent de `pnpm deploy` pour réduire une image de production : l'option `output: 'standalone'` de `next.config.ts`. Une fois activée, `next build` produit, en plus du dossier `.next` habituel, un dossier `.next/standalone` contenant un serveur [[Node.js|node-runtime]] autonome et uniquement le sous-ensemble de `node_modules` réellement utilisé par les routes de l'application — jamais la totalité du `node_modules` du workspace.

    CoWorkNet étant un workspace pnpm, `.next/standalone` reproduit l'arborescence du [[monorepo|monorepo]] : le serveur généré se trouve à `.next/standalone/apps/web/server.js`, et non directement à sa racine — un détail qui conditionne les chemins de `apps/web/Dockerfile` ci-dessous.

    Les étages `base`/`deps` sont identiques à `apps/api/Dockerfile` (même version de pnpm figée, même installation du workspace complet). L'étage `build` reçoit `NEXT_PUBLIC_API_URL` comme `ARG` : Next.js inline cette variable dans le bundle au moment de `next build`, elle doit donc être connue au moment de construire l'image, pas seulement au lancement du [[conteneur|conteneur-docker]] — d'où sa présence ici plutôt que dans un simple `environment:` de `docker-compose.yml` (détaillé au chapitre suivant).

    L'étage `runtime` copie trois éléments distincts produits par `next build` : `.next/standalone` (le serveur), `.next/static` (les assets JS/CSS versionnés, non inclus dans `standalone`) et `public/` (fichiers statiques bruts). `PORT=4100` aligne le serveur autonome sur le port CoWorkNet habituel du front — `server.js` lit `process.env.PORT`, sans quoi il écouterait sur le 3000 par défaut de Next.js.

  • Deux URLs d'API : navigateur et réseau Docker interne

    Un piège attend ce [[Dockerfile|dockerfile]] une fois branché à `docker-compose.yml` : `app/page.tsx` et `app/espaces/page.tsx` sont des Server Components qui appellent `getHealth()` et `getEspaces()` (`lib/api.ts`) directement lors du rendu, DANS le [[conteneur|conteneur-docker]] `web`. Or `NEXT_PUBLIC_API_URL` est inlinée au build (chapitre précédent) pour que le navigateur atteigne l'[[API|api]] via le port publié sur l'hôte (`http://localhost:4000`) — une valeur qu'un [[conteneur|conteneur-docker]] ne peut pas utiliser pour joindre un autre [[conteneur|conteneur-docker]], faute de résoudre « `localhost` » vers le bon service.

    `getServerApiUrl()` résout ce conflit sans dupliquer `lib/api.ts` : `API_INTERNAL_URL` n'a délibérément pas le préfixe `NEXT_PUBLIC_`, donc Next.js ne l'inline jamais — elle reste lue dans `process.env` à chaque appel, à l'exécution du [[conteneur|conteneur-docker]]. `docker-compose.yml` (chapitre 5) la fixera à `http://api:4000`, le nom du service Docker de l'[[API|api]] sur le réseau interne. En dehors de Docker, elle est simplement absente : `getServerApiUrl()` retombe alors sur `getApiUrl()`, un seul processus valant pour le navigateur et le serveur, comme avant ce TD.

    Seuls `getHealth` et `getEspaces` changent, car seuls eux s'exécutent côté serveur (Server Components, sans `'use client'`). `register`, `login`, `createReservation`, `getPendingReservations` et `validateReservation` restent sur `getApiUrl()` : ils s'exécutent dans le navigateur (composants clients, [[TD16|td-16]]/[[TD20|td-20]]/[[TD21|td-21]]/[[TD24|td-24]]), qui doit continuer à joindre l'[[API|api]] via le port publié sur l'hôte.

    `pnpm --filter web test` couvre ces deux fonctions : `getApiUrl()` retombe sur `localhost:4000` en l'absence de `NEXT_PUBLIC_API_URL`, et `getServerApiUrl()` donne la priorité à `API_INTERNAL_URL` quand elle est définie, sinon délègue à `getApiUrl()`. Les 34 tests déjà existants de `lib/api.test.ts` continuent de passer sans modification : ils ne mockent que `fetch`, pas la fonction d'URL sous-jacente.

  • Étendre docker-compose.yml et lancer toute la stack

    `docker-compose.yml` ([[TD7|td-07]] pour `db`, [[TD23|td-23]] pour `cache`) s'étend maintenant avec deux nouveaux services, `api` et `web`, construits depuis les [[Dockerfile|dockerfile]] des chapitres précédents plutôt que depuis une image publique. Une opportunité s'ouvre aussi pour `db` : charger `schema.sql` et `seed.sql` ([[TD8|td-08]]) automatiquement, via le mécanisme `/docker-entrypoint-initdb.d` de l'image officielle postgres, qui exécute tout script `.sql` qui s'y trouve monté — mais UNIQUEMENT quand le volume de données est encore vide, jamais sur un volume déjà peuplé.

    Le service `api` rejoint `db` et `cache` par leur NOM DE SERVICE (`db`, `cache`) sur le réseau Docker par défaut du projet, avec leurs ports internes standards (5432, 6379) — distincts des ports 5544/6390 publiés sur l'hôte, qui ne concernent que des outils tournant HORS Docker. `depends_on` avec `condition: service_healthy` retarde le démarrage d'`api` jusqu'à ce que les `healthcheck` de `db` et `cache` (déjà en place depuis [[TD7|td-07]]/[[TD23|td-23]]) passent réellement au vert, pas seulement jusqu'à ce que leurs conteneurs existent.

    Le service `web` illustre concrètement le chapitre précédent : `NEXT_PUBLIC_API_URL` reste `http://localhost:4000` en `args` (build, pour le navigateur), tandis qu'`API_INTERNAL_URL` vaut `http://api:4000` en `environment` (exécution, pour les Server Components du [[conteneur|conteneur-docker]] `web`). `depends_on: [api]` ordonne simplement le démarrage ; il ne garantit pas qu'`api` réponde déjà, mais `getHealth()`/`getEspaces()` ne lèvent jamais ([[TD10|td-10]]/[[TD13|td-13]]) — une requête trop précoce affiche « [[API|api]] indisponible » plutôt que de faire planter le rendu.

    `JWT_SECRET`, jusqu'ici propre à `apps/api/.env`, doit désormais aussi exister dans le `.env.example` à la RACINE du dépôt (à côté de `POSTGRES_DB/USER/PASSWORD`) : c'est ce fichier racine que `docker-compose.yml` lit pour résoudre `${JWT_SECRET}` dans la section `environment` du service `api`. `apps/web/.env.example` documente en parallèle `API_INTERNAL_URL`, commentée par défaut puisqu'elle ne sert qu'en [[conteneur|conteneur-docker]].

    CoWorkNet démarre désormais intégralement via `docker compose up -d --build` : plus besoin de [[Node.js|node-runtime]], pnpm ni d'aucune installation locale sur la machine hôte pour faire tourner l'application dans son ensemble, base de données et jeu d'essai compris dès le premier démarrage.

Préparer le déploiement de CoWorkNet

  • Pourquoi documenter et fiabiliser le déploiement maintenant

    Ce [[TD26|td-26]] prépare le déploiement de CoWorkNet (CP10, CP11) : `docker-compose.yml` ([[TD25|td-25]]) sait déjà démarrer toute la stack en local, mais rien ne documente comment la déployer sur une autre machine, ni comment appliquer un changement de schéma sans tout reconstruire. À la fin de cette séance, `docs/deploiement.md` décrira les dépendances du composant, une procédure de déploiement en 5 étapes et les environnements DEV -> [[SIT|sit]] ([[System Integration Testing|sit]], vérifier que les composants s'assemblent correctement) -> [[UAT|uat]] ([[User Acceptance Testing|uat]], faire valider le comportement métier par un rôle non technique) -> production, et `db/migrate.sh` rejouera `db/schema.sql` de façon idempotente — sans erreur, que la base soit neuve ou déjà à jour.

    Ce constat, obtenu en rejouant `db/schema.sql` ([[TD08|td-08]]) tel quel sur la base déjà initialisée par le [[TD25|td-25]], est exactement la situation qu'un vrai déploiement rencontrerait : le volume `coworknet-db-data` persiste entre deux déploiements, donc rejouer le schéma à la main échoue sur des objets déjà existants. Un script de migration doit être rejouable sans risque, faute de quoi personne n'ose l'exécuter deux fois. Le cours magistral Déploiement en production (c-cda-15) détaille le vocabulaire des environnements et des migrations ; ce TD applique ces notions concrètement à CoWorkNet, sans en reproduire le contenu exhaustif.

  • Rendre db/schema.sql idempotent

    La correction est directe : ajouter `IF NOT EXISTS` à chaque `CREATE TABLE` et `CREATE INDEX` de `db/schema.sql`, exactement comme `CREATE EXTENSION IF NOT EXISTS pgcrypto` le fait déjà depuis le [[TD08|td-08]]. PostgreSQL n'échoue alors plus sur un objet déjà présent : il émet un simple `NOTICE` et poursuit l'exécution du script. Aucune colonne, contrainte ni index n'est ajouté ou retiré par rapport au MPD validé en conception (`docs/conception/schema-donnees.md` §6) — seule la façon dont le script réagit à une ré-exécution change.

    Rejouer ce même `db/schema.sql`, désormais [[idempotent|idempotent]], sur la base déjà initialisée du chapitre précédent confirme la correction : chaque instruction se termine par un `NOTICE` « already exists, skipping » plutôt qu'une `ERROR`, et la commande se termine avec un code de sortie 0.

  • Écrire db/migrate.sh, le script de migration

    `db/schema.sql` [[idempotent|idempotent]], il reste à en faire un script explicite plutôt qu'une commande à retaper. `db/migrate.sh` se contente d'une seule migration (le schéma n'a pas changé depuis le [[TD08|td-08]], pas de suite numérotée 001/002 à inventer avant que le schéma n'évolue réellement), connecté via `docker compose exec` sur le service `db` — exactement comme les commandes de sauvegarde/restauration déjà documentées dans `README.md`, sans jamais exiger de client `psql` sur la machine qui lance le script.

    `SCRIPT_DIR` se recalcule depuis l'emplacement réel du script (via `BASH_SOURCE`) pour que `./db/migrate.sh` fonctionne même appelé depuis un autre dossier. `-v ON_ERROR_STOP=1` fait échouer `psql` (et donc le script, grâce à `set -euo pipefail`) à la première vraie erreur, pour ne jamais masquer un problème de connexion derrière un succès apparent. `DATABASE_USER` et `DATABASE_NAME` reprennent les noms de `apps/api/.env.example` : un environnement [[SIT|sit]]/[[UAT|uat]]/production dont le nom de base diffère peut les surcharger sans modifier le script.

    Ce premier essai, contre la base `coworknet_db` déjà peuplée par le [[TD25|td-25]], prouve l'[[idempotence|idempotent]] sur une base existante. Il reste à vérifier l'autre cas : une base réellement neuve. Un volume PostgreSQL jetable, créé et détruit uniquement pour ce test (jamais `coworknet-db-data`, pour ne prendre aucun risque sur les données déjà en place), confirme que le script crée bien les 4 tables et 4 index dès le premier lancement, puis ne fait plus rien au second.

  • Documenter dépendances, procédure et environnements

    `db/migrate.sh` vérifié, `docs/deploiement.md` rassemble maintenant la procédure complète (CP10) : les dépendances du composant à déployer, la procédure de déploiement elle-même, les scripts de migration (chapitres précédents) et les types d'environnements. Le cours magistral Déploiement en production (c-cda-15) reste la référence pour le vocabulaire général ; ce document l'applique concrètement à CoWorkNet, sans en reprendre le contenu exhaustif.

    La procédure de déploiement (§2 du document) reprend 5 étapes fixes, dans cet ordre : récupérer le code, configurer `.env` avec des valeurs propres à l'environnement cible, construire et démarrer la stack, appliquer la migration, puis vérifier par un smoke test. Les deux commandes centrales, déjà éprouvées aux chapitres précédents, sont extraites ci-dessous telles qu'écrites dans `docs/deploiement.md`.

    Le §4 du document distingue quatre environnements sur un même axe DEV -> [[SIT|sit]] -> [[UAT|uat]] -> production (sigles épelés au chapitre 1) : les mêmes commandes techniques (schéma, migration, images Docker du [[TD25|td-25]]) s'appliquent partout, seuls le contenu de `.env`, le jeu de données et le public autorisé changent d'un environnement à l'autre. Le tableau ci-dessous, reproduit tel quel depuis `docs/deploiement.md`, en résume les colonnes essentielles.

    Ce dépôt étant strictement local (jamais poussé vers un remote, aucune infrastructure réelle provisionnée), [[SIT|sit]]/[[UAT|uat]]/production restent des environnements conceptuels : la procédure du §2 est écrite pour être directement applicable le jour où une machine réelle leur est dédiée. Concrètement, sur ce poste, chacun peut être simulé en clonant le dépôt dans un dossier distinct et en démarrant sa propre stack Docker Compose.

  • Mettre à jour la procédure des tests d'intégration/acceptation, puis committer

    Dernier point du CP10 mobilisé par ce TD : mettre à jour la procédure des tests d'intégration/acceptation. Les collections Bruno ([[TD14|td-14]]/[[TD22|td-22]]) et la suite Jest e2e ciblaient jusqu'ici uniquement `http://localhost:4000` (DEV). Deux nouveaux environnements Bruno, `sit.bru` et `uat.bru`, sont ajoutés sous `bruno/CoWorkNet/environments/`, calqués sur `local.bru` — mêmes identifiants fixes de `db/seed.sql` (`testSiteId`, `parisSiteId`, `lyonSiteId`), seul `baseUrl` change.

    `uat.bru` reprend exactement la même forme (`baseUrl` `http://uat.coworknet.local:4000`, mêmes trois identifiants de site). La procédure diffère ensuite par environnement (`docs/deploiement.md` §5) : sur [[SIT|sit]], rejouer l'intégralité des collections Bruno avec l'environnement [[sit|sit]] sélectionné, plus `pnpm --filter api test:e2e` pointé vers la base [[SIT|sit]] — un déploiement [[SIT|sit]] n'est validé que si les deux suites passent. Sur [[UAT|uat]], les collections Bruno ne suffisent plus : place à une checklist manuelle calquée sur les [[user stories|user-story]] US1 à US6 du cahier des charges, rejouée par le référent pédagogique avant toute promotion vers la production, où seul le smoke test (`GET /health`, `GET /espaces`) est repris.

    Avant de committer, toute la suite de tests est rejouée pour vérifier qu'aucune régression n'a été introduite par le passage de `db/schema.sql` en `IF NOT EXISTS` : `pnpm --filter api test` (186 tests), `pnpm --filter api test:e2e` (60 tests, contre le PostgreSQL et le Redis réels du docker-compose), `pnpm --filter web test` (38 tests, dont les 4 de `env.test.ts` ajoutés au [[TD25|td-25]]) et `tsc --noEmit` côté [[api|api]]. Les quatre passent sans changement, confirmant que l'[[idempotence|idempotent]] ajoutée ne modifie aucun comportement observable de l'[[API|api]] ni du front.

Mettre en place une pipeline CI/CD pour CoWorkNet avec GitLab CI

  • CI/CD : pipeline, jobs, étapes

    Avant d'écrire `.gitlab-ci.yml`, un mot sur ce que [[CI/CD|cicd]] désigne. **CI** ([[Continuous Integration|cicd]] / [[intégration continue|cicd]]) consiste à vérifier automatiquement, à chaque changement de code poussé sur le dépôt, que le projet compile, que ses tests passent et que sa qualité de code est respectée — plutôt que de ne le découvrir qu'au moment de la mise en production. **CD** ([[Continuous Deployment|cicd]]/Delivery / [[déploiement continu|cicd]]) prolonge cette automatisation jusqu'au déploiement lui-même, une fois les vérifications passées.

    Ce TD décrit une [[pipeline|pipeline-cicd]] GitLab CI à 4 stages (`lint`, `test`, `build`, `deploy`) : chaque stage regroupe un ou plusieurs jobs (par exemple `lint:api` et `lint:web` au sein du stage `lint`), exécutés par un [[runner|runner-cicd]].

  • Pourquoi une pipeline CI/CD, sans instance GitLab réelle

    Ce [[TD27|td-27]] outille CoWorkNet d'une véritable démarche [[CI/CD|cicd]] (CP11) : à la fin de cette séance, `.gitlab-ci.yml` décrira une [[pipeline|pipeline-cicd]] GitLab CI à 4 stages — `lint`, `test`, `build`, `deploy` — et chaque étape aura été réellement exécutée sur ce poste via `scripts/ci-local.sh`, faute d'instance GitLab disponible pour ce dépôt strictement local. Concrètement : ESLint contrôle enfin la qualité du code de `apps/api` (pas seulement `apps/web` déjà équipé depuis `create-next-app`), les 284 tests existants tournent en [[pipeline|pipeline-cicd]], les deux applications compilent en mode production, et un déploiement simulé reconstruit les images Docker ([[TD25|td-25]]) puis rejoue le smoke test de `docs/deploiement.md`.

    Ce découpage en 4 stages fixé, une contrainte demeure : ce dépôt n'a jamais eu de remote (`README.md` « Dépôt pédagogique local »), donc aucune instance GitLab ne peut réellement exécuter ce fichier. La réponse retenue n'est pas de laisser `.gitlab-ci.yml` comme un artefact jamais vérifié, mais d'écrire à côté `scripts/ci-local.sh`, qui reproduit les mêmes 4 stages sur ce poste avec les outils déjà en place (`docker-compose.yml`, `db/migrate.sh`), sans simuler de services GitLab CI. C'est ce script qui a servi, tout au long du TD, à vérifier que la [[pipeline|pipeline-cicd]] fonctionne pour de vrai.

    Le cours magistral [[Intégration continue|cicd]] avec Gitea (c-cda-14) approfondit ce vocabulaire (vu au chapitre 1) au-delà du nécessaire ici ; ce TD l'applique directement à CoWorkNet.

  • Ajouter un outil de qualité de code à l'API : ESLint 9

    Avant d'écrire le stage `lint` de la [[pipeline|pipeline-cicd]], encore faut-il qu'`apps/api` ait un outil de qualité de code à exécuter : `apps/web` en a un depuis `create-next-app` (`eslint.config.mjs`, `eslint-config-next`), mais `apps/api` n'a jamais eu d'ESLint. Ce chapitre ajoute une configuration flat config ESLint 9 minimale, volontairement légère (règles recommandées de `typescript-eslint`, deux règles en avertissement plutôt qu'en erreur) : l'objectif est un vrai outil de qualité de code exécuté en CI, pas une refonte des conventions du projet ni l'introduction de Prettier, hors périmètre de ce TD.

    `ignores` exclut `dist/`, `coverage/` et `node_modules/` de l'analyse ; `parserOptions.project: false` évite d'exiger un programme TypeScript complet (plus rapide, suffisant pour des règles qui n'ont pas besoin d'informations de types). Les deux règles `no-unused-vars`/`no-explicit-any` restent en `warn` : elles signalent un problème sans jamais faire échouer la [[pipeline|pipeline-cicd]] sur le code déjà écrit dans les TD précédents, qui n'avait jamais été passé au crible d'ESLint jusqu'ici.

    Le script `lint` cible `{src,test}/**/*.ts` (le code applicatif et les tests e2e, pas `dist/` déjà exclu par `ignores`). Exécuté sur le code existant des [[TD09|td-09]] à [[TD26|td-26]], il se termine sans le moindre avertissement : la base était déjà propre, ce qui confirme que les règles choisies sont réalistes pour ce projet plutôt que purement décoratives.

  • Écrire .gitlab-ci.yml : stages lint et test

    ESLint en place côté [[api|api]], place à `.gitlab-ci.yml` lui-même. Il déclare les 4 stages puis un bloc `variables` : des identifiants PostgreSQL/[[JWT|jwt]] dédiés à la CI (`coworknet_ci_*`), distincts de ceux de développement (`.env.example`) ou d'un environnement déployé (`docs/deploiement.md` §4, un secret par environnement) — jamais une valeur réutilisée d'un contexte à l'autre. `DATABASE_HOST`/`REDIS_HOST` valent respectivement `postgres`/`redis`, les alias des futurs services du job d'intégration.

    Le gabarit `.node_pnpm` (préfixé d'un point, donc ignoré par GitLab comme job direct) factorise l'image `node:22-alpine` et l'installation pnpm, réutilisées par `extends:` dans chaque job `lint`/`test`/`build` — cohérent avec le `CLAUDE.md` racine (ne jamais dupliquer une configuration). `lint:api`/`lint:web` et `test:api-unit`/`test:web-unit` n'ajoutent qu'une seule ligne de `script` chacun, la commande pnpm déjà éprouvée localement.

    Le job `test:api-e2e` est le plus délicat : `CLAUDE.md` CoWorkNet interdit de mocker PostgreSQL en intégration, et Redis n'est jamais la source de vérité ([[TD23|td-23]]) mais doit tout de même être réellement présent. GitLab CI répond à ce besoin avec `services:` — deux conteneurs `postgres:16-alpine`/`redis:7-alpine` démarrés pour la seule durée du job, aliasés `postgres`/`redis`. Chaque exécution reçoit des conteneurs fraîchement créés, sans volume persistant : rejouer `db/schema.sql` puis `db/seed.sql` y est donc toujours sûr, exactement comme sur un volume Docker Compose vide (`README.md` « Schéma et jeu d'essai »), jamais un conflit de clé déjà utilisée.

    Un piège fréquent avec `services:` : l'`alias` déclaré sur chaque service (`alias: postgres`, `alias: redis`) doit correspondre EXACTEMENT à la valeur de `DATABASE_HOST`/`REDIS_HOST` lue par l'[[API|api]] — un nom de service GitLab CI mal recopié (`postgresql` au lieu de `postgres`, par exemple) ne casse ni ESLint ni la compilation TypeScript : rien ne le détecte avant l'exécution réelle du job.

  • Build et déploiement simulé

    Le stage `build` valide que les deux applications compilent réellement en mode production (`nest build`, `next build`) — un gate distinct des tests, qui mockent ou tournent en développement. Chaque job conserve son résultat (`apps/api/dist`, `apps/web/.next`) en `artifacts`, une journée : de quoi les inspecter depuis l'interface GitLab, ou les enchaîner vers un futur job de publication d'image, sans avoir à reconstruire deux fois.

    Reste le stage `deploy`, forcément **simulé** : `docs/deploiement.md` §1 est formel, ce dépôt strictement local n'a aucune machine cible réelle. Le job `deploy:simulate` ne pousse donc aucune image vers un [[registre|registre-images]] ni ne se connecte à un serveur distant. Il utilise Docker-in-Docker (image `docker:27-cli` + service `docker:27-dind`) pour reconstruire les images de production ([[TD25|td-25]]), écrit son propre `.env` de CI (jamais les valeurs de développement), démarre la pile complète comme le ferait la procédure de déploiement (§2 étapes 3-4), puis rejoue le smoke test de l'étape 5 — la seule vérification qui ait un sens sans machine cible réelle. `after_script` détruit la pile éphémère à la fin du job.

  • scripts/ci-local.sh : l'exécuter réellement, documenter, committer

    `.gitlab-ci.yml` écrit, il reste — faute d'instance GitLab — à prouver qu'il fonctionne. `scripts/ci-local.sh` reproduit exactement les 4 mêmes stages, dans le même ordre, mais adapté à la réalité de ce poste : au lieu des `services:` éphémères de `test:api-e2e`, il démarre (ou réutilise) la pile Docker Compose habituelle du projet (`db`/`cache`, [[TD07|td-07]]/[[TD23|td-23]]), dont le jeu d'essai `db/seed.sql` est déjà chargé, puis applique `db/migrate.sh` ([[TD26|td-26]]) pour garantir un schéma à jour. Le stage `deploy` reconstruit les images et rejoue le même smoke test, mais laisse la pile démarrée en sortie plutôt que de la détruire.

    `./scripts/ci-local.sh` (sans argument) enchaîne les 4 stages et s'arrête à la première erreur (`set -e`) ; `./scripts/ci-local.sh lint` (ou `test`/`build`/`deploy`) n'en rejoue qu'un seul, pratique pour itérer sans tout relancer. Les 4 stages ont été exécutés réellement, dans l'ordre, sur ce poste : `lint` (0 avertissement des deux côtés), `test` (186 tests [[api|api]] + 34 web, puis 60 en e2e contre PostgreSQL/Redis réels), `build` (les deux compilent), et `deploy` (images reconstruites, 4 conteneurs healthy, smoke test réussi).

    Dernière étape : `README.md` gagne une section « Intégration et déploiement continus ([[TD27|td-27]]) » (mêmes conventions que les sections [[TD25|td-25]]/[[TD26|td-26]] déjà présentes), plus deux lignes dans le tableau « Structure » listant `.gitlab-ci.yml` et `scripts/ci-local.sh`. Aucune régression n'étant possible sur du code applicatif (seuls des fichiers de configuration/CI et de la documentation ont été ajoutés), la vérification `tsc --noEmit` côté [[api|api]] est passée avant de committer, en plus des 4 stages déjà rejoués ci-dessus.

Piloter le projet CoWorkNet avec Trello

  • Un board Trello unique pour piloter CoWorkNet

    Ce [[TD28|td-28]] outille CoWorkNet d'une méthode de pilotage de projet (CP4) : à la fin de cette séance, `docs/trello-board.md` décrira un board Trello de référence nommé « CoWorkNet — Fil rouge CDA », à cinq colonnes (Backlog, À faire, En cours, En revue, Terminé) avec des limites WIP, un gabarit de carte identique pour chacun des 28 TD du fil rouge, huit étiquettes de domaine, et deux cas concrets tirés de l'historique réel du dépôt. Ce dépôt étant strictement local (`README.md` « Dépôt pédagogique local »), aucun compte Trello n'existe réellement : seule la méthode est enseignée, pas son automatisation.

    Une seule méthode est retenue ici, volontairement fermée (pas de comparatif Jira/Asana/GitHub Projects) : un board à cinq colonnes, chacune avec une définition précise de ce qu'elle contient, et deux d'entre elles portant une limite WIP (Work In Progress) explicite. Cette limite n'est pas décorative : sur un fil rouge mené par un seul alternant, dépasser une carte « En cours » signifie systématiquement du travail commencé puis interrompu, retrouvé incomplet des jours plus tard.

    Une seule règle de circulation existe entre ces colonnes : une carte ne remonte de « En revue » vers « En cours » que si la revue échoue (critère de succès non rempli, test cassé) — jamais de saut de colonne inverse ailleurs, et jamais de colonne sautée dans le sens direct. Le cours magistral Gestion de projet informatique (c-cda-02) détaille les démarches séquentielle et agile en général ; ce TD applique concrètement l'une d'elles (un board Kanban à limites WIP) à CoWorkNet, sans en reprendre le panorama complet.

  • Modèle de carte et étiquettes de domaine

    Pour que 28 cartes restent gérables sans confusion, chacune reprend rigoureusement le même gabarit : un titre reprenant l'intitulé exact du TD, une description du but métier concret (avec un lien vers la spec [[SDD|sdd]] quand le TD en a une), une checklist calquée sur les critères de succès du TD, une étiquette de domaine, une échéance et un membre assigné. La pièce jointe finale ([[hash|hachage]] de [[commit|commit]] Git) n'apparaît qu'une fois la carte en colonne « Terminé ».

    Huit étiquettes de domaine classent les 28 cartes, chacune associée à une couleur Trello fixe pour que le board reste lisible d'un coup d'œil : Conception, Outillage, Backend, Frontend, Sécurité, Tests, DevOps, et Transversal (cette dernière réservée à ce [[TD28|td-28]]). Un TD de spécification ([[SDD|sdd]], ex. [[TD11|td-11]]/[[TD15|td-15]]/[[TD17|td-17]]/[[TD19|td-19]]) porte l'étiquette du domaine qu'il prépare, jamais une étiquette « Spec » séparée : la spécification et son implémentation forment une seule paire fonctionnelle sur ce board.

  • État du board à l'issue du TD27

    Le board devient utile dès qu'on peut y lire, d'un coup d'œil, l'état réel du projet plutôt qu'une simple liste de tâches. Ce chapitre fige cet état à l'issue du [[TD27|td-27]] : les 27 TD déjà livrés occupent la colonne Terminé, chacun avec son étiquette de domaine, et seule la carte de ce [[TD28|td-28]] occupe la colonne En cours — respectant ainsi la limite WIP de 1 fixée au chapitre 1.

    Ce tableau n'est pas qu'un historique : c'est la preuve, colonne par colonne, que la méthode a été suivie. Aucune carte n'a sauté « En revue », aucune carte de domaine Backend n'est passée en Terminé sans que ses tests ne passent (règle générale du projet, `CLAUDE.md` CoWorkNet), et la carte [[TD28|td-28]] elle-même respecte la limite WIP de 1 puisqu'elle est actuellement la seule en colonne En cours.

  • Cas concrets : alerter en cas de retard et résoudre un problème

    Une méthode de pilotage ne vaut que si elle sait signaler un dérapage plutôt que de le masquer. Règle du board : une carte franchissant son échéance sans être passée en « En revue » reçoit immédiatement l'étiquette rouge additionnelle « ⚠ Retard » (cumulable avec l'étiquette de domaine) et un commentaire daté expliquant la cause — jamais un simple déplacement silencieux de l'échéance.

    Exemple réel tiré de ce dépôt : la carte [[TD20|td-20]] (Développer l'[[API|api]] de réservation en [[TDD|tdd]]) avait une échéance à une session. Le verrou explicite `FOR UPDATE` protégeant le chevauchement de créneaux (`docs/specs/spec-reservations.md`) exigeait un scénario de course concurrente difficile à fiabiliser en un seul passage : la carte est restée en « En cours » au-delà de son échéance, déclenchant la règle ci-dessus.

    La carte n'est passée en « En revue » que le lendemain, une fois la ligne de checklist « Tests e2e » cochée — visible dans l'[[historique Git|historique-git]] par les deux commits distincts `ae418c1` (implémentation) et `1a57833` (tests e2e), tous deux rattachés à la même carte plutôt que scindés en deux cartes séparées : une carte, c'est un TD, quel que soit le nombre de commits qu'il produit.

    Toute carte qui découvre un défaut (régression, faille de sécurité) applique la même checklist en cinq étapes, tracée dans les commentaires de la carte plutôt que résolue « de tête » : Reproduire, Isoler, Corriger, Tester, Documenter.

    En détail : Reproduire (écrire ou relancer le test qui matérialise le défaut), Isoler (identifier la route précise en cause, sans corriger plus large que nécessaire), Corriger (le correctif minimal qui fait passer le test), Tester (rejouer la suite complète, pas seulement le test isolé) et Documenter (mettre à jour la spec ou le README concernés).

    Exemple réel : la carte [[TD18|td-18]] (Sécuriser l'[[API|api]] des espaces en [[TDD|tdd]], retrofit). Le défaut à corriger n'était pas un bug mais un manque volontairement assumé jusque-là (`POST`/`PATCH` `/espaces` ouverts sans authentification, `CLAUDE.md` CoWorkNet § Règles de sécurité par phase) — la démarche en cinq étapes s'applique aussi à un manque de sécurité planifié, pas seulement à un bug imprévu.

  • Veille technologique, comptes rendus et commit

    Le pilotage d'un projet ne se limite pas au suivi des tâches en cours : il doit aussi capitaliser les choix techniques évalués avant adoption, pour ne jamais introduire une dépendance sans la signaler explicitement (`CLAUDE.md` racine). Une liste séparée du flux de travail, « 🔭 Veille », porte cette mémoire : une carte y reste tant qu'elle n'a pas débouché sur une décision, puis est archivée avec un lien vers la spec ou le [[commit|commit]] qui documente le choix retenu.

    Chaque revue de sprint (colonne « En revue » vidée en fin de semaine) se clôture par un court compte rendu, rédigé en anglais (niveau B1), déposé en commentaire sur la dernière carte terminée de la semaine — pratique du projet réel où l'équipe et le client ne partagent pas toujours le français comme langue de travail. En voici un exemple réel, couvrant la semaine des [[TD19|td-19]] et [[TD20|td-20]] :

    `docs/trello-board.md` écrit, `README.md` gagne une ligne dans le tableau « Structure » et une section « Pilotage de projet ([[TD28|td-28]]) » (mêmes conventions que les sections [[TD26|td-26]]/[[TD27|td-27]] déjà présentes). Aucun code applicatif n'étant touché par ce TD (uniquement de la documentation), aucune suite de tests n'est concernée ni à rejouer — cohérent avec le [[TD26|td-26]], purement documentaire lui aussi, qui n'avait modifié aucun test.

Tarifs

Prix mensuel

29 € / mois

Durée estimée

4 mois

au rythme standard (154 chapitres)

Coût total estimé

116 €

prix mensuel × durée estimée

Commencer maintenant