Chapitre 1517 minIntermédiaire
Exercices corrigés Node.js et Express type E6 et E7
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- lire un code Express inconnu et prédire ses réponses ;
- faire évoluer l'API comme pendant la réalisation de l'E6 ;
- repérer et corriger les failles d'une API, comme à l'E7.
9 exercices corrigés
Ce chapitre rassemble des exercices corrigés sur Node.js et Express dans l'esprit des épreuves du BTS SIO SLAM : lire et comprendre un code inconnu, le faire évoluer comme pendant l'heure de réalisation de l'E6, et repérer puis corriger ses failles comme dans les dossiers de l'écrit E7 « Cybersécurité des services informatiques ». Tous s'appuient sur l'API AppliFrais construite dans le cours et sur son jeu de données (Lucas Martin, visiteur n° 7 ; Sarah Vincent, visiteuse n° 8 ; Anne Durand, comptable ; fiches 11, 12 et 13).
Ce que les épreuves attendent
À l'E6, tu présentes ta propre application et la commission te demande une évolution à réaliser en une heure : une route de plus, un filtre, une règle de gestion, un contrôle de droits, une correction de bogue, avec son test (les évolutions types). Il faut savoir où intervenir dans le code (quelle couche, quel fichier) et prouver que ça marche.
À l'E7 SLAM, l'écrit de quatre heures fournit des extraits de code et des journaux : on te demande d'expliquer un mécanisme, de repérer une vulnérabilité, de la nommer, de proposer une correction et un moyen de la détecter (API, jetons et authentification à l'E7). Le langage varie selon les sujets ; la démarche, elle, ne change pas.
La méthode qui marche pour les deux : situer (quelle route, quelle couche), nommer (la notion, la faille, le code de statut), expliquer (ce qui se passe, avec les données du sujet), corriger (le code, puis la preuve : un test, une ligne de journal).
Lire du code
Exercice 1 sur 9 Prédire les réponses Réussi À revoir
const materiels = [
{ id: 1, libelle: 'Portable Dell', etat: 'disponible' },
{ id: 2, libelle: 'Vidéoprojecteur', etat: 'prete' },
];
const app = express();
app.use(express.json());
app.use((req, res, next) => {
if (!req.get('X-Cle')) return res.status(401).json({ erreur: 'Clé absente' });
next();
});
app.get('/v1/materiels', (req, res) => {
res.json(materiels.filter((m) => !req.query.etat || m.etat === req.query.etat));
});
app.get('/v1/materiels/:id', (req, res) => {
const materiel = materiels.find((m) => m.id === req.params.id);
if (!materiel) return res.status(404).json({ erreur: 'Introuvable' });
res.json(materiel);
});
Donne le code de statut et le corps de chaque réponse.
GET /v1/materiels?etat=prete, sans en-têteX-Cle.GET /v1/materiels?etat=prete, avecX-Cle: abc.GET /v1/materiels/1, avecX-Cle: abc.GET /v1/materiels?etat=PRETE, avecX-Cle: abc.
Corrige le défaut révélé par la question 3. Que penses-tu du contrôle de la clé ?
Voir la réponseMasquer la réponse de l'exercice 1
401,{"erreur":"Clé absente"}: le middleware répond avant la route.200,[{"id":2,"libelle":"Vidéoprojecteur","etat":"prete"}].404,{"erreur":"Introuvable"}:req.params.idvaut la chaîne"1", et1 === "1"est faux. Aucun matériel n'est jamais trouvé.200,[]: la comparaison respecte la casse, aucun matériel n'a l'étatPRETE.
Correction : materiels.find((m) => m.id === Number(req.params.id)), ou mieux, valider le paramètre avec Zod (z.coerce.number().int().positive()) et refuser abc avec 422.
Le contrôle de clé vérifie seulement la présence de l'en-tête : n'importe quelle valeur passe. Il faut comparer la clé à une valeur secrète (stockée hachée, comme un mot de passe), ou utiliser une vraie authentification par jeton.
Tu avais trouvé ?
Exercice 2 sur 9 Suivre une requête dans l'API Réussi À revoir
Lucas envoie PUT /v1/fiches/12/lignes-forfait avec son jeton et le corps {"ETP": 4, "KM": 420, "NUI": 2, "REP": 6}. Cite, dans l'ordre, chaque middleware et chaque fonction de l'API AppliFrais que traverse la requête, avec le fichier où elle se trouve, jusqu'à la réponse. Quel est le nouveau total de la fiche ?
Voir la réponseMasquer la réponse de l'exercice 2
journalRequetes(src/logger.js) : identifiant de requête, journal.helmet(),cors(…),express.json()(src/app.js) : en-têtes, CORS, lecture du corps.- Le routeur des fiches, branché sur
/v1/fiches(src/routes/fiches.routes.js) :authentifier(src/middlewares/authentifier.js) vérifie le jeton et remplitreq.utilisateur;exigerRole('visiteur')(src/middlewares/autoriser.js) ;valider({ params: schemaId, body: schemaLignesForfait })(src/middlewares/valider.js) remplitreq.valide. - Le contrôleur
saisirForfait(src/controllers/fiches.controller.js) appelle le service. - Le service
saisirForfait(src/services/fiches.service.js) :chargerFichevérifie l'existence et le propriétaire,exigerEnCoursvérifie l'étatCR. - Le dépôt
enregistrerQuantites(src/depots/fiches.depot.js) : quatreUPDATEdans une transaction. - Le service relit la fiche (
lireFiche) et calcule le total ; le contrôleur répond200avec la fiche.
Nouveau total : 4 × 110 + 420 × 0,62 + 2 × 80 + 6 × 25 + 23,50 = 440 + 260,40 + 160 + 150 + 23,50 = 1 033,90 €.
Tu avais trouvé ?
Faire évoluer l'API (type E6)
Exercice 3 sur 9 Évolution 1 : des statistiques pour les comptables Réussi À revoir
Le service comptable veut GET /v1/stats?mois=2026-10, qui renvoie le nombre de fiches par état pour un mois, réservé aux comptables. Écris la requête SQL du dépôt, le schéma Zod, la ligne de route et le contrôleur. Quelle réponse donne le jeu de données ?
Voir la réponseMasquer la réponse de l'exercice 3
// src/depots/fiches.depot.js
export async function compterParEtat(mois) {
const [lignes] = await pool.execute(
'SELECT etat, COUNT(*) AS nombre FROM fiche_frais WHERE mois = ? GROUP BY etat ORDER BY etat',
[mois],
);
return lignes;
}
// src/schemas/fiches.schemas.js
export const schemaStats = z.object({ mois });
// src/routes/stats.routes.js, branché avec app.use('/v1/stats', routeurStats)
export const routeurStats = Router();
routeurStats.get('/', authentifier, exigerRole('comptable'), valider({ query: schemaStats }), async (req, res) => {
res.json(await depot.compterParEtat(req.valide.query.mois));
});
Réponse pour octobre 2026 : [{"etat":"CR","nombre":1},{"etat":"CL","nombre":1}] (la fiche 12 de Lucas en cours, la 13 de Sarah clôturée). Avec MySQL, ORDER BY etat sur une colonne ENUM trie dans l'ordre de déclaration (CR, CL, VA, MP), pas dans l'ordre alphabétique : c'est justement l'ordre du cycle de vie. Pour une route aussi simple, le service est facultatif ; s'il y a une règle (masquer les mois futurs, par exemple), elle va dans un service.
Tu avais trouvé ?
Exercice 4 sur 9 Évolution 2 : le total de chaque fiche dans la liste du comptable Réussi À revoir
Les comptables veulent voir, pour un mois, chaque fiche avec le nom du visiteur et son total. Écris la requête SQL, en une seule requête (pas de N+1), et donne son résultat pour octobre 2026.
Voir la réponseMasquer la réponse de l'exercice 4
SELECT f.id, u.nom, u.prenom, f.etat,
(SELECT COALESCE(SUM(l.quantite * ff.montant), 0)
FROM ligne_frais_forfait l JOIN frais_forfait ff ON ff.code = l.code_forfait
WHERE l.id_fiche = f.id)
+ (SELECT COALESCE(SUM(h.montant), 0) FROM frais_hors_forfait h WHERE h.id_fiche = f.id) AS total
FROM fiche_frais f
JOIN utilisateur u ON u.id = f.id_visiteur
WHERE f.mois = ?
ORDER BY u.nom
| id | nom | prenom | etat | total |
|---|---|---|---|---|
| 12 | Martin | Lucas | CR | 898.90 |
| 13 | Vincent | Sarah | CL | 567.00 |
Deux sous-requêtes corrélées plutôt que deux jointures : joindre en même temps les lignes de forfait (4 par fiche) et les frais hors forfait multiplierait les lignes, et les sommes seraient fausses (chaque frais hors forfait compté 4 fois). Le calcul se fait en DECIMAL, exact. Avec decimalNumbers: true, mysql2 renvoie 898.9 et 567.
Tu avais trouvé ?
Exercice 5 sur 9 Évolution 3 : un commentaire à la validation Réussi À revoir
Le comptable doit pouvoir ajouter un commentaire facultatif (255 caractères au plus) quand il valide une fiche, par exemple « Repas du 14 refusé : justificatif illisible ». Liste toutes les modifications, couche par couche, et écris le test qui prouve l'évolution.
Voir la réponseMasquer la réponse de l'exercice 5
- Base :
ALTER TABLE fiche_frais ADD COLUMN commentaire VARCHAR(255) NULL;(dans un script de migration numéroté, versionné). - Validation :
schemaValidationdevientz.strictObject({ montantValide: …, commentaire: z.string().trim().max(255).optional() }). - Dépôt :
changerEtataccepte le commentaire et l'écrit (SET etat = ?, montant_valide = COALESCE(?, montant_valide), commentaire = COALESCE(?, commentaire)), avecnullpar défaut ;versFicheet leSELECTexposentcommentaire. - Service et contrôleur :
validerFiche(utilisateur, id, montantValide, commentaire)transmet la valeur ; le contrôleur litreq.valide.body.commentaire. - Documentation : le schéma de la requête de validation dans
openapi.yaml.
it('enregistre le commentaire de validation', async () => {
const reponse = await request(app)
.post('/v1/fiches/13/validations')
.set('Authorization', await jetonDe('adurand', 'Comptable#2026'))
.send({ montantValide: 555, commentaire: 'Parking refusé : sans justificatif' });
assert.equal(reponse.status, 201);
assert.equal(reponse.body.commentaire, 'Parking refusé : sans justificatif');
});
Pense aussi au test du refus : un commentaire de 300 caractères doit donner 422.
Tu avais trouvé ?
Exercice 6 sur 9 Évolution 4 : corriger un bogue signalé Réussi À revoir
Un visiteur signale : « J'ai mis 0 repas sur ma fiche, mais l'appli garde l'ancienne valeur. » Voici le dépôt en cause. Trouve le bogue, corrige-le, et écris le test qui l'aurait détecté.
export async function enregistrerQuantites(id, quantites) {
for (const [code, quantite] of Object.entries(quantites)) {
if (!quantite) continue; // on ignore les champs vides
await pool.execute(
'UPDATE ligne_frais_forfait SET quantite = ? WHERE id_fiche = ? AND code_forfait = ?',
[quantite, id, code],
);
}
}
Voir la réponseMasquer la réponse de l'exercice 6
!quantite est vrai pour 0 : la quantité zéro est ignorée, et l'ancienne valeur reste. Le test « champ vide » est inutile, puisque le schéma Zod exige déjà les quatre quantités, entières et positives ou nulles. Correction : supprimer la ligne if, et en profiter pour regrouper les quatre mises à jour dans une transaction (version du chapitre 6).
it('enregistre une quantité nulle', async () => {
const jeton = await jetonDe('lmartin', 'Visiteur#2026');
const reponse = await request(app)
.put('/v1/fiches/12/lignes-forfait')
.set('Authorization', jeton)
.send({ ETP: 3, KM: 420, NUI: 2, REP: 0 });
assert.equal(reponse.status, 200);
assert.equal(reponse.body.lignesForfait.find((l) => l.code === 'REP').quantite, 0);
});
Le cas limite (zéro, chaîne vide, liste vide) est l'endroit où se cachent les bogues : c'est le premier à tester.
Tu avais trouvé ?
Sécuriser l'API (type E7)
Exercice 7 sur 9 Revue de code : six failles Réussi À revoir
Un stagiaire a écrit ces routes pour une autre version de l'API. Pour chaque faille, donne son nom, la ligne en cause, ses conséquences, et la correction.
app.use(cors({ origin: true, credentials: true }));
app.use((req, res, next) => {
const jeton = req.get('Authorization')?.split(' ')[1];
req.utilisateur = jeton ? jwt.decode(jeton) : null;
next();
});
app.get('/v1/fiches/:id', async (req, res) => {
const [lignes] = await pool.execute('SELECT * FROM fiche_frais WHERE id = ?', [req.params.id]);
res.json(lignes[0]);
});
app.patch('/v1/fiches/:id', async (req, res) => {
await pool.query('UPDATE fiche_frais SET ? WHERE id = ?', [req.body, req.params.id]);
res.sendStatus(204);
});
app.use((err, req, res, next) => {
res.status(500).json({ erreur: err.message, sql: err.sql, pile: err.stack });
});
Voir la réponseMasquer la réponse de l'exercice 7
- Jeton non vérifié (
jwt.decode) :decodelit la charge utile sans vérifier la signature. N'importe qui fabrique un jeton qui dit"role": "comptable". Correction :jwt.verify(jeton, secret, { algorithms: ['HS256'] }), et un401si le jeton est absent ou invalide (API2, authentification défaillante). - Pas d'authentification obligatoire : sans jeton,
req.utilisateurvautnullet les routes s'exécutent quand même. Correction : le middlewareauthentifiersur toutes les routes protégées. - BOLA sur
GET /v1/fiches/:id: aucune vérification du propriétaire ; tout le monde lit toutes les fiches en changeant l'identifiant. Correction : contrôle du propriétaire dans le service,404sinon (API1). - Exposition de données :
SELECT *renvoie toutes les colonnes ; et une fiche absente renvoie200avec un corps vide au lieu de404(API3). - Affectation de masse sur
PATCH: avecSET ?, mysql2 transforme tout l'objetreq.bodyen colonnes à modifier. Un visiteur envoie{"etat":"VA","montant_valide":5000}et valide lui-même sa fiche. Pas de contrôle de rôle ni de transition non plus. Correction : schéma Zod strict ({ etat: z.enum(['CL','MP']) }), contrôle du rôle et de la transition dans le service, requête écrite colonne par colonne (API3 et API5). - Fuite d'informations dans le gestionnaire d'erreurs : message, requête SQL et pile d'appels renvoyés au client. Correction : message générique, numéro d'incident, détail dans le journal (API8).
Et la configuration CORS (origin: true, credentials: true) autorise n'importe quel site à lire les réponses avec les cookies de l'utilisateur : liste blanche des origines (API8).
Tu avais trouvé ?
Exercice 8 sur 9 Analyser un journal Réussi À revoir
Voici un extrait du journal de l'API, simplifié (une ligne par requête).
10:02:01 ip=203.0.113.24 POST /v1/auth/connexion 401 login=adurand
10:02:01 ip=203.0.113.24 POST /v1/auth/connexion 401 login=adurand
… 8 lignes identiques …
10:02:03 ip=203.0.113.24 POST /v1/auth/connexion 429
10:14:40 ip=198.51.100.7 utilisateur=7 GET /v1/fiches/12 200
10:14:41 ip=198.51.100.7 utilisateur=7 GET /v1/fiches/13 404
10:14:41 ip=198.51.100.7 utilisateur=7 GET /v1/fiches/14 404
10:14:41 ip=198.51.100.7 utilisateur=7 GET /v1/fiches/15 404
… les identifiants continuent jusqu'à 400, tous en 404 …
- Qu'observes-tu à 10 h 02 ? Quelle protection a fonctionné ?
- Qu'observes-tu à partir de 10 h 14 ? L'API a-t-elle bien réagi ?
- Quelles mesures proposes-tu ?
Voir la réponseMasquer la réponse de l'exercice 8
- Une attaque par force brute (ou par dictionnaire) sur le compte
adurand, la comptable : dix échecs en deux secondes depuis la même adresse, puis429. La limitation de débit de la route de connexion a bloqué la suite. Le message unique et le temps de réponse identique empêchent en plus de savoir si le login existe. - L'utilisateur 7 (Lucas, ou quelqu'un qui a volé son jeton) parcourt les identifiants de fiches un par un : c'est une tentative d'exploitation d'un BOLA. L'API a bien réagi :
404pour toutes les fiches qui ne sont pas les siennes, aucune donnée n'a fuité. - Détecter : une alerte quand un même utilisateur accumule des
404sur des ressources existantes, ou quand une adresse cumule les429. Réagir : contacter Lucas, révoquer ses jetons et lui faire changer son mot de passe si le jeton a été volé ; bloquer temporairement l'adresse203.0.113.24. Renforcer : limitation de débit par utilisateur sur les routes authentifiées, double authentification pour les comptables, conservation de ces journaux pour l'enquête.
Tu avais trouvé ?
Exercice 9 sur 9 De la matrice d'habilitations aux routes Réussi À revoir
GSB ajoute la réservation des véhicules de service. La matrice d'habilitations :
| Opération | Visiteur | Comptable | Responsable de flotte |
|---|---|---|---|
| Lister les véhicules | oui | oui | oui |
| Réserver un véhicule | oui | non | oui |
| Annuler sa réservation | oui | non | oui |
| Annuler n'importe quelle réservation | non | non | oui |
| Ajouter un véhicule | non | non | oui |
Écris les lignes de route (avec authentifier, exigerRole et valider), puis la règle du service pour l'annulation. Comment s'appelle le rôle à ajouter en base, et où ?
Voir la réponseMasquer la réponse de l'exercice 9
routeurVehicules.use(authentifier);
routeurVehicules.get('/', vehicules.lister); // tous les rôles
routeurVehicules.post('/', exigerRole('flotte'), valider({ body: schemaVehicule }), vehicules.ajouter);
routeurReservations.use(authentifier);
routeurReservations.post('/', exigerRole('visiteur', 'flotte'), valider({ body: schemaReservation }), reservations.creer);
routeurReservations.delete('/:id', exigerRole('visiteur', 'flotte'), valider({ params: schemaId }), reservations.annuler);
// service : le rôle ne suffit pas, il faut aussi le propriétaire
export async function annulerReservation(utilisateur, id) {
const reservation = await depot.trouverParId(id);
const autorise = reservation && (utilisateur.role === 'flotte' || reservation.utilisateurId === utilisateur.id);
if (!autorise) throw new ErreurApi(404, 'reservation-introuvable', 'Réservation introuvable', `La réservation ${id} n'existe pas.`);
await depot.supprimer(id);
}
Le rôle flotte s'ajoute à l'énumération de la colonne role de la table utilisateur (ALTER TABLE utilisateur MODIFY role ENUM('visiteur','comptable','flotte') NOT NULL DEFAULT 'visiteur') et au type Role si l'on utilise Prisma. Les lignes « annuler sa réservation » et « annuler n'importe laquelle » ne peuvent pas être traduites par exigerRole seul : elles demandent un contrôle sur la donnée, dans le service.
Tu avais trouvé ?