Aller au contenu principal

Jouer contre le serveur

Réécrire le jeu pour que chaque réponse soit corrigée par la fonction repondre, puis fermer la faille en cachant est_correcte.

Objectifs de la séance​

  • appeler des fonctions SQL avec supabase.rpc et vérifier la forme du JSON reçu ;
  • faire évoluer la machine à états de la partie (nouvel état verification) ;
  • protéger l'application contre le double envoi, la réponse tardive et l'écran quitté pendant une requête ;
  • supprimer le code devenu inutile ;
  • retirer le droit de lire est_correcte et rejouer l'attaque de la séance 30 pour constater qu'elle échoue.

Petite vérification​

Avant de commencer, vérifiez si vous savez...

  1. dans le tableau de bord Supabase, ouvrir Database > Functions et lire les noms des fonctions que vous avez créées à la séance précédente
  2. dans le SQL Editor, exécuter select public.demarrer_partie('reseaux', 1, 3); et lire le résultat
  3. citer le mot-clé qui fait s'exécuter une fonction avec les droits de son créateur
📌 Réponse attendue

TP pour réfléchir et résoudre des problèmes​

L'application utilise encore l'ancienne méthode : elle télécharge toutes les questions avec les bonnes réponses, les corrige elle-même et calcule son score. Votre mission : la faire jouer contre le serveur. Chaque réponse partira vers la fonction repondre, qui renvoie la correction. Vous finirez par fermer la faille de la séance 30. Restez sur la branche feature/securite et gardez le serveur de développement lancé.

Ordre des opérations : l'application d'abord, la base ensuite

Le script SQL qui cache est_correcte (étape 10) fait cesser de fonctionner l'ancienne version de l'application, qui lit cette colonne. Vous modifiez donc d'abord l'application (étapes 1 à 9), et vous ne lancez le script qu'ensuite.

Étape 1 — Décrire les nouvelles données (types)​

Dans src/types/quiz.ts, supprimez le type ReponseDonnee (la correction ne sera plus calculée par l'application) et ajoutez à la fin du fichier les types de ce que le serveur renvoie. Remarquez que QuestionDePartie ne contient pas de propriété estCorrecte : TypeScript empêche d'écrire du code qui lirait une information que le serveur n'envoie plus. Complétez la propriété reponses :


Bonne pratique - Les types racontent l'architecture

Un type sans estCorrecte documente (et fait respecter) la règle « le client ne connaît pas la bonne réponse ». Si quelqu'un tente de l'utiliser, npx tsc --noEmit signale l'erreur avant même l'exécution.

Étape 2 — Vérifier le JSON reçu avant de l'utiliser​

Le JSON renvoyé par supabase.rpc est de type unknown pour TypeScript : l'application n'a aucune garantie sur sa forme. Dans src/logic/conversions.ts, remplacez tout ce qui concernait les questions (LigneQuestion, convertirQuestion, estNiveau, l'import de estQuestionValide...) par les fonctions du corrigé. Elles renvoient null si la forme est incorrecte. Commencez par la petite fonction de contrôle :

Puis, complétez la conversion de la correction (la fonction repondre renvoie reponse_id, est_correcte, bonne_reponse_id et explication) :

Les autres fonctions (convertirQuestionDePartie, convertirPartieDemarree) suivent la même logique : elles vérifient chaque champ et refusent tout si une seule question est mal formée. Leur code est dans le corrigé du TP.


Bonne pratique - Ne jamais croire un JSON reçu

Même venant de votre propre serveur, une donnée qui traverse le réseau peut être incomplète, d'une ancienne version, ou modifiée. Une conversion vérifiée transforme un unknown en un type sûr, ou en null. Le reste du code n'a plus à se méfier.

Étape 3 — Le service qui appelle les fonctions SQL​

Créez src/services/partieService.ts : c'est le seul endroit qui appelle supabase.rpc pour jouer. Il traduit les réglages de l'application ("tous" devient null pour la base), et choisit le message à afficher. Complétez l'appel de demarrer_partie :

La fonction messagePourUtilisateur (en tête du fichier) affiche tel quel le message d'une erreur de code P0001 (celles de nos raise exception, écrites pour le joueur) et un message générique pour toute autre erreur. Le code complet est dans le corrigé.


Bonne pratique - Séparer ce qu'on montre et ce qu'on garde

Le détail technique d'une erreur part dans console.warn. Au joueur, on montre soit le message prévu pour lui (P0001), soit une phrase simple (« Impossible d'envoyer la réponse. »). Un message technique de base de données ne doit jamais s'afficher à l'écran.

Étape 4 — Ajouter l'état « verification » au reducer​

La partie est une machine à états. Il lui faut un nouvel état : entre le moment où le joueur choisit sa réponse et celui où le serveur répond, la partie est en vérification.

"question" --REPONSE_CHOISIE--> "verification" --CORRECTION_RECUE--> "correction"
^ | |
+------CORRECTION_ECHOUEE--------+ |
+<-----------------QUESTION_SUIVANTE--------------------------------+
(après la dernière : "terminee")

Remplacez le contenu de src/logic/partieReducer.ts par la version du corrigé. Observez surtout le traitement de CORRECTION_RECUE. Complétez la ligne qui enregistre la réponse retenue :

Remplacez aussi tests/partieReducer.test.ts par la version du corrigé, puis lancez npm test.


Bonne pratique - Une transition interdite est ignorée, pas une erreur

Une correction qui arrive quand l'état n'est plus verification, ou pour une autre question (réponse tardive), est ignorée : le reducer renvoie l'état tel quel. Une partie ne peut pas se retrouver dans un état incohérent, même si les événements arrivent dans un ordre inattendu.

Étape 5 — Un bouton « choisie » pendant l'attente​

Pendant la vérification, le bouton de la réponse choisie doit être mis en évidence, sans montrer la bonne réponse (qu'on ne connaît pas encore). Dans src/logic/etatBouton.ts, un état "choisie" est ajouté. Complétez le cas « choix fait, correction non reçue » :

Ajoutez dans BoutonReponse.tsx le style choisie (un contour de la couleur principale, voir le corrigé) et la ligne etat === "choisie" && styles.choisie, dans la liste des styles. Remplacez aussi tests/etatBouton.test.ts par la version du corrigé.


Bonne pratique - La logique d'affichage dans une fonction pure

Savoir « quel aspect pour quel bouton ? » est un calcul sans effet de bord : calculerEtatBouton est une fonction pure, testée avec node --test. Le composant se contente d'appliquer le résultat.

Étape 6 — Démarrer la partie sur le serveur​

Remplacez le hook useQuestionsPartie par src/hooks/useDemarrerPartie.ts : il demande au serveur de créer la partie, puis mélange l'ordre des réponses de chaque question (le serveur les renvoie toujours dans le même ordre). Le patron est celui du tutoriel 7 : AbortController, test de signal.aborted, bouton « Réessayer ». Complétez le mélange :

Modifiez ensuite src/components/ChargementPartie.tsx pour utiliser ce hook (il renvoie etatPartie au lieu de etatQuestions, et le composant PartieEnCours reçoit la prop partie). Le code est dans le corrigé.


Bonne pratique - Ne pas muter, copier

{ ...q, reponses: melanger(q.reponses) } crée un nouvel objet pour chaque question. L'objet reçu du serveur n'est jamais modifié, comme au tutoriel 2 (Fisher-Yates ne modifie pas son entrée).

Étape 7 — Envoyer la réponse et se protéger du double envoi​

Dans src/components/PartieEnCours.tsx, le joueur n'ajoute plus lui-même sa réponse à un tableau : il demande au serveur. La fonction choisirReponse doit résister à un double clic. Elle utilise deux useRef : envoiEnCours (le verrou) et controleurEnCours (pour annuler la requête). Complétez la ligne qui pose le verrou :

Les protections contre le double clic sont quatre, empilées :

  1. le bouton est disabled dès que le statut n'est plus question ;
  2. le reducer ignore REPONSE_CHOISIE si le statut n'est plus question ;
  3. le verrou useRef bloque un deuxième appel avant le prochain rendu ;
  4. côté SQL, for update et « la première réponse est conservée » rendent repondre idempotente.

Bonne pratique - Ne jamais compter sur une seule protection

Si l'interface se trompe, le reducer protège. Si le reducer est contourné, la base de données protège. Une protection côté client améliore l'expérience ; seule celle du serveur fait foi.

Étape 8 — Quitter l'écran pendant la requête​

Que se passe-t-il si le joueur quitte l'écran alors que la réponse est en cours d'envoi ? Sans précaution, la requête se termine et tente de modifier l'état d'un écran qui n'existe plus. Le nettoyage d'un useEffect annule la requête en cours. Complétez la ligne de nettoyage :

Mettez aussi à jour l'affichage de PartieEnCours (voir le corrigé) : le message « Vérification de votre réponse... » pendant l'état verification, la correction et l'explication reçues du serveur, les boutons désactivés tant que le statut n'est pas question, et un MessageErreur si l'envoi a échoué (le joueur peut alors réessayer).


Bonne pratique - Annuler ce qui n'est plus utile

Chaque signal.aborted testé avant un dispatch évite un message d'erreur absurde sur un écran fermé. Le serveur peut quand même avoir enregistré la réponse : c'est sans conséquence, car repondre est idempotente.

Étape 9 — Supprimer le code devenu inutile​

Plus rien n'utilise l'ancienne méthode. Supprimez, avec git rm (qui supprime et prépare la suppression d'un coup) :

git rm src/logic/score.ts src/logic/preparerPartie.ts src/hooks/useQuestionsPartie.ts
git rm tests/score.test.ts tests/preparerPartie.test.ts

Dans src/services/questionsService.ts, supprimez aussi la fonction chargerQuestions et l'import de convertirQuestion : il ne reste que compterQuestions. Remplacez enfin tests/conversions.test.ts par la version du corrigé. Vérifiez : npm test, puis npx tsc --noEmit.


Bonne pratique - Supprimer plutôt que commenter

Du code mort (fonction jamais appelée, test d'une fonction disparue) coûte du temps de lecture et peut masquer des bugs. Le dépôt Git conserve l'historique : rien n'est perdu. Si tsc signale une erreur, c'est qu'il reste un import vers un fichier supprimé.

Étape 10 — Fermer la faille : cacher est_correcte​

L'application n'a plus besoin de lire est_correcte. Créez sql/07_proteger_reponses.sql. On retire d'abord le droit de lecture sur la table entière, puis on le redonne colonne par colonne, sauf pour est_correcte. Complétez la ligne qui redonne le droit :

Exécutez le script dans le SQL Editor et ajoutez la ligne suivante au tableau de sql/README.md :

| `07_proteger_reponses.sql` | la colonne `est_correcte` n'est plus lisible depuis l'application |

Bonne pratique - Les fonctions SQL gardent l'accès

Nos fonctions demarrer_partie et repondre sont en security definer : elles s'exécutent avec les droits de leur créateur et peuvent donc toujours lire est_correcte. Le joueur, lui, n'a plus aucun moyen de la lire ; il la découvre seulement dans la correction d'une question à laquelle il a déjà répondu.

Étape 11 — Rejouer l'attaque de la séance 30​

C'est le moment de vérité. Refaites la commande de la séance 30 :

curl "https://VOTRE-PROJET.supabase.co/rest/v1/reponse?select=question_id,texte,est_correcte&est_correcte=eq.true" \
-H "apikey: VOTRE_CLE_PUBLISHABLE"

Réponse attendue : une erreur « permission denied for table reponse ». Essayez aussi select=* (même refus, car * inclut est_correcte), puis select=id,texte (cette fois la requête fonctionne : les colonnes sans risque restent lisibles).


Bonne pratique - Prouver qu'une faille est réparée

Une correction n'est validée que lorsque l'attaque qui réussissait échoue. Rejouez toujours le scénario d'origine : c'est un « test de non-régression » de sécurité.

Étape 12 — Jouer, et casser volontairement l'application​

Lancez l'application (npx expo start, touche w) et jouez une partie complète. Puis essayez de la mettre en difficulté :

  1. double clic très rapide sur une réponse : une seule réponse est prise en compte ;
  2. coupez votre connexion Internet (mode avion), choisissez une réponse : un message d'erreur s'affiche et vous pouvez réessayer une fois la connexion revenue ;
  3. quittez l'écran (retour) juste après avoir cliqué sur une réponse : aucune erreur n'apparaît ;
  4. dans le Table Editor, ouvrez la table partie_question : chaque question répondue a un reponse_id, un est_correcte et un repondu_le ; les questions non répondues ont ces trois colonnes à null.

Bonne pratique - Tester les cas qui cassent

Mode avion, double clic, retour arrière : ce sont les situations réelles d'un téléphone. Une application n'est robuste que si elle a été essayée dans ces conditions.

Étape 13 — Enregistrer votre travail​

git add -A
git status
git commit -m "Jouer contre le serveur : réponses corrigées par la fonction repondre"
git push

Bonne pratique - Relire git status

Ce commit contient des suppressions de fichiers : git status doit les montrer (« deleted »). Vérifiez aussi que .env.local n'y figure pas.

📌 Une solution

Notions théoriques​

Le nouveau fonctionnement​

Application Serveur (Supabase)
| rpc("demarrer_partie", réglages) -------> tire les questions au hasard
| <------- questions SANS est_correcte
|
| rpc("repondre", partie, question, réponse) vérifie, enregistre, corrige
| <------- correction (bonne réponse + explication)
| ... question suivante ...

L'application ne sait plus qu'une chose : ce que le serveur lui a dit. Le serveur ne révèle la bonne réponse d'une question qu'après avoir enregistré la réponse du joueur (et cette réponse ne peut plus être modifiée) : ce n'est donc pas une fuite.

Les droits par colonne​

Un select sur une table demande le droit de lecture sur chaque colonne demandée. Après revoke select puis grant select (id, question_id, texte) :

RequêteRésultat
select=id,textefonctionne
select=est_correctepermission denied for table reponse (code 42501)
select=*refusé aussi, car * inclut est_correcte

C'est le seul moyen de cacher une colonne : la RLS, elle, filtre des lignes. Le code de l'application ne doit plus jamais écrire select("*") sur la table reponse.

Appeler une fonction : supabase.rpc​

supabase.rpc("nom", { parametre: valeur }) renvoie { data, error }. data est du JSON de type unknown ; l'application le vérifie (convertirCorrection...) avant de l'utiliser. En cas d'erreur, le champ error.code permet de distinguer une erreur voulue de nos fonctions (P0001, le message est destiné au joueur) d'une erreur technique (le détail part en console.warn).

La machine à états version 2​

StatutSignificationBoutons
questionune question attend une réponseactifs
verificationla réponse est partie, on attend le serveurdésactivés
correctionla correction du serveur est affichéedésactivés
termineetoutes les questions ont été traitées(écran de fin)
ActionEffetIgnorée si...
REPONSE_CHOISIEpasse en verificationle statut n'est pas question
CORRECTION_RECUEpasse en correctionle statut n'est pas verification, ou question différente
CORRECTION_ECHOUEEretour à question avec un messageidem
QUESTION_SUIVANTEquestion suivante ou termineele statut n'est pas correction

Les trois pièges d'une requête réseau​

  1. Le double envoi : deux appuis rapprochés. Quatre protections empilées (bouton, reducer, verrou useRef, idempotence SQL).
  2. La réponse tardive : la correction d'une ancienne requête arrive alors que la partie a changé d'état ou de question. Le reducer la compare à la question en cours (questionId) et l'ignore.
  3. L'écran quitté : la requête se termine sur un écran fermé. Le contrôleur est annulé dans le nettoyage de l'effet, et chaque dispatch est précédé d'un test signal.aborted.

Le score affiché n'est que de l'affichage​

nbBonnesReponses dans le reducer sert à afficher « Score : 3 » pendant la partie. Il est calculé à partir des corrections reçues du serveur, mais le score officiel sera calculé et enregistré par le serveur à la séance suivante.

Supprimer le code mort​

Après une refonte, score.ts, preparerPartie.ts, useQuestionsPartie.ts et chargerQuestions n'ont plus aucun appelant. Les supprimer (avec leurs tests) évite de maintenir du code inutile et de laisser une porte ouverte : chargerQuestions tentait de lire est_correcte, et aurait échoué à la moindre réutilisation.


Test de mémorisation/compréhension​


Pourquoi faut-il modifier l'application avant d'exécuter le script 07_proteger_reponses.sql ?


Que signifie l'erreur 42501 « permission denied for table reponse » après le script 07 ?


Dans le reducer, que fait-on d'une action CORRECTION_RECUE qui arrive quand le statut n'est plus « verification » ?


À quoi sert le verrou useRef envoiEnCours dans PartieEnCours ?


Que se passe-t-il quand le joueur quitte l'écran pendant la vérification d'une réponse ?


Pourquoi convertit-on le JSON renvoyé par rpc(...) avec convertirCorrection au lieu de l'utiliser tel quel ?


Que signifie le code P0001 renvoyé par une de nos fonctions SQL ?



Exercices complémentaires​

Exercice A : un test supplémentaire pour le reducer​

Ajoutez dans tests/partieReducer.test.ts un test qui vérifie que CORRECTION_ECHOUEE reçue pour la mauvaise question (par exemple questionId: 2 pendant la question 1) est ignorée.

Exercice B : afficher le numéro de la partie​

Affichez le numéro de la partie (etat.partieId) en petit sous le titre de PartieEnCours. Pourquoi ce numéro n'est-il pas un secret, alors que l'identifiant d'une réponse correcte l'aurait été ?


Ce qu'il faut retenir​

NotionRésumé
supabase.rpcAppelle une fonction SQL ; le JSON reçu est unknown et doit être vérifié.
Droits par colonnerevoke select puis grant select (colonnes) : la seule façon de cacher une colonne.
État verificationLa réponse est partie ; les boutons sont désactivés jusqu'à la correction du serveur.
Double envoiBouton désactivé, reducer, verrou useRef, idempotence SQL.
Réponse tardiveIgnorée par le reducer (question différente ou statut incorrect).
Écran quittéAbortController annulé dans le nettoyage, signal.aborted testé avant chaque dispatch.
Code mortSupprimé avec ses tests (git rm).

Aperçu de la prochaine séance​

Les réponses sont désormais corrigées par le serveur, mais le score final est encore transmis à l'écran de résultat par l'application (dans l'adresse, falsifiable comme vous l'avez montré à la séance 30). Dans la prochaine séance, vous écrirez la fonction terminer_partie, qui calcule le score et la durée sur le serveur, et vous fusionnerez le travail du tutoriel par une Pull Request.