Chapitre 826 minIntermédiaire
Les évolutions types et leur méthode
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- appliquer une méthode commune à toute évolution ;
- ajouter une fonctionnalité, un champ ou un formulaire validé ;
- écrire un test et corriger un bug ;
- corriger une faille de sécurité.
1 exercice corrigé
Une évolution, à l'E6 SLAM, est la modification que l'expression des besoins te demande d'apporter à ta réalisation. Les sujets varient, mais ils retombent presque toujours dans six familles : une nouvelle fonctionnalité, un changement de base de données, un formulaire avec validation, un test à écrire, une correction de bug, une amélioration de sécurité. Ce chapitre donne pour chacune une méthode et un exemple travaillé, sur les deux réalisations fictives de ce cours. Pour la démarche de maintenance elle-même (lire une stack trace, déboguer, écrire le test de non-régression), le chapitre Maintenance corrective et évolutive à l'E6 SLAM du cours Maintenance applicative propose des cas corrigés complets.
Les deux applications des exemples
Sorbier Frais est une application web de notes de frais : une API REST Node.js et Express, un front React, une base MySQL 8.4. Les salariés saisissent leurs frais, les comptables contrôlent et valident. Le code Node.js est écrit en CommonJS (require), avec la bibliothèque mysql2 et des requêtes préparées. La base :
CREATE TABLE service (
id_service INT AUTO_INCREMENT PRIMARY KEY,
libelle VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE utilisateur (
id_utilisateur INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(50) NOT NULL,
prenom VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
mdp_hash VARCHAR(255) NOT NULL,
role ENUM('salarie', 'comptable') NOT NULL DEFAULT 'salarie',
id_service INT NOT NULL,
CONSTRAINT fk_utilisateur_service FOREIGN KEY (id_service) REFERENCES service (id_service)
);
CREATE TABLE note_frais (
id_note INT AUTO_INCREMENT PRIMARY KEY,
id_utilisateur INT NOT NULL,
mois CHAR(7) NOT NULL, -- '2027-03'
etat ENUM('saisie', 'cloturee', 'validee', 'remboursee') NOT NULL DEFAULT 'saisie',
date_cloture DATETIME NULL,
montant_valide DECIMAL(8,2) NULL,
CONSTRAINT uq_note_mois UNIQUE (id_utilisateur, mois),
CONSTRAINT fk_note_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id_utilisateur)
);
CREATE TABLE ligne_frais (
id_ligne INT AUTO_INCREMENT PRIMARY KEY,
id_note INT NOT NULL,
date_frais DATE NOT NULL,
categorie ENUM('km', 'repas', 'hotel', 'autre') NOT NULL,
libelle VARCHAR(100) NOT NULL,
montant DECIMAL(8,2) NOT NULL,
CONSTRAINT ck_ligne_montant CHECK (montant > 0),
CONSTRAINT fk_ligne_note FOREIGN KEY (id_note) REFERENCES note_frais (id_note) ON DELETE CASCADE
);
Les routes protégées passent par deux middlewares existants. Le premier vérifie le jeton JWT, le second le rôle :
// middlewares/auth.js
import jwt from 'jsonwebtoken';
export function requireAuth(req, res, next) {
const [type, jeton] = (req.headers.authorization ?? '').split(' ');
if (type !== 'Bearer' || !jeton) return res.status(401).json({ erreur: 'Jeton manquant' });
try {
req.user = jwt.verify(jeton, process.env.JWT_SECRET); // { id, role, iat, exp }
next();
} catch {
return res.status(401).json({ erreur: 'Jeton invalide ou expiré' });
}
}
export const requireRole = (...roles) => (req, res, next) =>
roles.includes(req.user.role) ? next() : res.status(403).json({ erreur: 'Accès refusé' });
L'application tourne sur Express 5 : une erreur levée dans une fonction async part toute seule vers le middleware d'erreur, qui répond 500. Avec Express 4, il faut entourer chaque contrôleur d'un try/catch. Le code est écrit en modules ES ("type": "module" dans le package.json) : chaque fichier importe ce qu'il utilise avec import et partage ses fonctions avec export, et les chemins relatifs portent l'extension .js (les modules ES). Les tests tournent avec Vitest et supertest.
GestParc est une application de bureau C# WinForms (.NET 10) reliée par le pilote MySqlConnector à la base parc du cours SQL : postes, logiciels, installations.
La méthode commune
Toutes les évolutions suivent la même boucle. Seul le contenu de chaque étape change.
- Observer l'existant : lancer l'application, reproduire le comportement actuel.
- Localiser : trouver le fichier, la table, la fonction qui ressemble le plus à la demande.
- Changer le minimum : un petit pas, puis un test.
- Prouver : un test automatisé, ou un appel qui montre le résultat et un appel qui montre le refus.
- Documenter : README, dictionnaire de données, CHANGELOG, documentation demandée.
- Commiter avec un message qui dit ce qui a changé.
Une nouvelle fonctionnalité : l'export CSV
La demande. « Les comptables veulent exporter au format CSV la liste des notes validées d'un mois, pour la transmettre au cabinet qui gère la paie. »
La méthode. Partir d'une route qui ressemble (la liste des notes), la réserver au bon rôle, valider l'entrée, écrire la requête, puis formater la sortie.
// routes/notes.js : avant la route GET '/:id', sinon Express prend « export » pour un identifiant
router.get('/export', requireAuth, requireRole('comptable'), exporterNotes);
// controllers/notes.js
import { pool } from '../db.js';
const FORMAT_MOIS = /^\d{4}-(0[1-9]|1[0-2])$/;
// Un champ qui contient ; " ou un saut de ligne est mis entre guillemets, ses guillemets doublés.
function champCsv(valeur) {
const texte = String(valeur ?? '');
return /[;"\r\n]/.test(texte) ? `"${texte.replaceAll('"', '""')}"` : texte;
}
export async function exporterNotes(req, res) {
const { mois } = req.query;
if (typeof mois !== 'string' || !FORMAT_MOIS.test(mois)) {
return res.status(400).json({ erreur: 'Paramètre mois attendu au format AAAA-MM' });
}
const [notes] = await pool.execute(
`SELECT n.id_note, u.nom, u.prenom, n.mois, n.montant_valide
FROM note_frais n
JOIN utilisateur u ON u.id_utilisateur = n.id_utilisateur
WHERE n.etat = 'validee' AND n.mois = ?
ORDER BY u.nom, u.prenom`,
[mois],
);
const lignes = notes.map((n) =>
[n.id_note, n.nom, n.prenom, n.mois, String(n.montant_valide).replace('.', ',')]
.map(champCsv)
.join(';'),
);
res.attachment(`notes-validees-${mois}.csv`);
res.type('text/csv; charset=utf-8');
res.send(['id_note;nom;prenom;mois;montant_valide', ...lignes].join('\r\n'));
}
Les choix à savoir justifier :
- Le point-virgule : un tableur réglé en français sépare les colonnes par des points-virgules, et la virgule y sert de séparateur décimal. mysql2 renvoie les
DECIMALsous forme de chaîne ('212.40'), d'où le simple remplacement du point. champCsv: un libellé qui contient un point-virgule casserait les colonnes. La règle des guillemets doublés vient de la norme du format CSV (RFC 4180).res.attachmentpose l'en-têteContent-Disposition: attachment, qui fait télécharger le fichier au lieu de l'afficher.- Aucune note validée : un fichier avec la seule ligne d'en-tête et un code 200. Ce n'est pas une erreur.
curl -i -H "Authorization: Bearer $JETON_COMPTA" "http://localhost:3000/api/notes/export?mois=2027-03"
HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="notes-validees-2027-03.csv"
id_note;nom;prenom;mois;montant_valide
41;Benali;Sofiane;2027-03;212,40
38;Lambert;Chloé;2027-03;87,00
Un changement de base de données : rattacher les notes à un service
La demande. « La direction veut le total des frais remboursés par service. Une note doit rester comptée dans le service du salarié au moment de la note, même s'il change de service ensuite. »
La méthode. Une colonne ajoutée à une table qui contient déjà des lignes se fait en trois temps : ajouter la colonne en acceptant NULL, remplir l'existant, puis poser les contraintes. Sauvegarde la base avant, et écris le script d'annulation.
-- migrations/2027-03-note-service.sql
-- 1. La colonne, facultative pour ne pas bloquer les notes existantes
ALTER TABLE note_frais ADD COLUMN id_service INT NULL;
-- 2. Reprise de l'existant : le service actuel du salarié
UPDATE note_frais n
JOIN utilisateur u ON u.id_utilisateur = n.id_utilisateur
SET n.id_service = u.id_service;
-- Vérification avant l'étape 3 : doit afficher 0
SELECT COUNT(*) FROM note_frais WHERE id_service IS NULL;
-- 3. Les contraintes
ALTER TABLE note_frais
MODIFY id_service INT NOT NULL,
ADD CONSTRAINT fk_note_service FOREIGN KEY (id_service) REFERENCES service (id_service);
-- migrations/2027-03-note-service-annulation.sql
ALTER TABLE note_frais DROP FOREIGN KEY fk_note_service, DROP COLUMN id_service;
La création d'une note doit maintenant enregistrer le service. Une seule requête suffit, qui lit le service du salarié au moment de l'insertion :
await pool.execute(
`INSERT INTO note_frais (id_utilisateur, mois, id_service)
SELECT id_utilisateur, ?, id_service FROM utilisateur WHERE id_utilisateur = ?`,
[mois, req.user.id],
);
Puis la requête demandée :
SELECT s.libelle, COUNT(*) AS nb_notes, SUM(n.montant_valide) AS total_rembourse
FROM note_frais n
JOIN service s ON s.id_service = n.id_service
WHERE n.etat = 'remboursee' AND n.mois LIKE '2027-%'
GROUP BY s.id_service, s.libelle
ORDER BY total_rembourse DESC;
L'argument à donner en entretien : copier le service dans la note est une historisation, pas une redondance. La jointure par utilisateur donnerait le service actuel, et un salarié muté ferait changer les totaux des années passées. Pense aussi à mettre à jour le MLD et le dictionnaire de données : c'est un indicateur de la maintenance.
Un formulaire avec validation : ajouter un poste dans GestParc
La demande. « Le technicien doit pouvoir ajouter un poste. Le numéro d'inventaire suit le format PC-0001, la date d'achat ne peut pas être dans le futur, le prix ne peut pas être négatif. »
La méthode. Trois niveaux de contrôle, chacun avec son rôle : le formulaire guide l'utilisateur, le code applique les règles, la base refuse ce qui passerait quand même. Le prix se saisit dans un NumericUpDown dont le Minimum vaut 0 : la règle est garantie par le composant. Le reste se vérifie au clic, avec un ErrorProvider (nommé erreurs) qui affiche une icône à côté du champ fautif.
// FrmPoste.cs
using System.Text.RegularExpressions;
using MySqlConnector;
private static readonly Regex FormatInventaire = new(@"^PC-\d{4}$");
private bool SaisieValide()
{
erreurs.Clear();
bool ok = true;
if (!FormatInventaire.IsMatch(txtNumInventaire.Text.Trim()))
{
erreurs.SetError(txtNumInventaire, "Format attendu : PC-0001");
ok = false;
}
if (cboType.SelectedItem is null)
{
erreurs.SetError(cboType, "Choisis fixe ou portable");
ok = false;
}
if (string.IsNullOrWhiteSpace(txtMarque.Text) || string.IsNullOrWhiteSpace(txtModele.Text))
{
erreurs.SetError(txtModele, "La marque et le modèle sont obligatoires");
ok = false;
}
if (dtpDateAchat.Value.Date > DateTime.Today)
{
erreurs.SetError(dtpDateAchat, "La date d'achat ne peut pas être dans le futur");
ok = false;
}
return ok;
}
private void btnEnregistrer_Click(object sender, EventArgs e)
{
if (!SaisieValide()) return;
var poste = new Poste
{
NumInventaire = txtNumInventaire.Text.Trim(),
TypePoste = (string)cboType.SelectedItem!,
Marque = txtMarque.Text.Trim(),
Modele = txtModele.Text.Trim(),
DateAchat = dtpDateAchat.Value.Date,
PrixAchat = numPrix.Value
};
try
{
_posteDao.Ajouter(poste);
DialogResult = DialogResult.OK; // ferme le formulaire, la liste se recharge
}
catch (MySqlException ex) when (ex.ErrorCode == MySqlErrorCode.DuplicateKeyEntry) // 1062 : doublon sur la clé UNIQUE
{
erreurs.SetError(txtNumInventaire, "Ce numéro d'inventaire existe déjà");
}
}
// PosteDao.cs
public void Ajouter(Poste p)
{
using var conn = new MySqlConnection(_chaineConnexion);
conn.Open();
using var cmd = new MySqlCommand(
@"INSERT INTO poste (num_inventaire, type_poste, marque, modele, date_achat, prix_achat)
VALUES (@num, @type, @marque, @modele, @date, @prix)", conn);
cmd.Parameters.AddWithValue("@num", p.NumInventaire);
cmd.Parameters.AddWithValue("@type", p.TypePoste);
cmd.Parameters.AddWithValue("@marque", p.Marque);
cmd.Parameters.AddWithValue("@modele", p.Modele);
cmd.Parameters.AddWithValue("@date", p.DateAchat);
cmd.Parameters.AddWithValue("@prix", p.PrixAchat);
cmd.ExecuteNonQuery();
}
Le doublon n'est pas vérifié par une requête préalable : la contrainte UNIQUE de la base le détecte, même si deux techniciens saisissent le même numéro au même moment. Le code 1062 de MySQL, ER_DUP_ENTRY (MySqlErrorCode.DuplicateKeyEntry dans MySqlConnector), est traduit en message clair. Le cours C# et WinForms détaille les contrôles et l'accès à MySQL.
Un test : prouver un calcul et un contrôle d'accès
La demande. « Ajoutez les tests qui prouvent que le total d'une note est juste et qu'un salarié ne peut pas exporter les notes. »
La méthode. Un calcul se teste mieux dans une fonction pure, sortie du contrôleur : mêmes entrées, même sortie, aucune base. Un contrôle d'accès se teste sur la route elle-même, avec supertest, qui appelle l'application en mémoire sans démarrer de serveur. Chaque test couvre un cas nominal et un cas limite.
// services/calculs.js
// Les montants sont additionnés en centimes : 0.1 + 0.2 vaut 0.30000000000000004 en virgule flottante.
export function totalNote(lignes) {
const centimes = lignes.reduce((somme, l) => somme + Math.round(Number(l.montant) * 100), 0);
return centimes / 100;
}
// tests/calculs.test.js
import { expect, test } from 'vitest';
import { totalNote } from '../services/calculs.js';
test('additionne les montants sans erreur d\'arrondi', () => {
expect(totalNote([{ montant: '0.10' }, { montant: '0.20' }])).toBe(0.3);
});
test('renvoie 0 pour une note sans ligne', () => {
expect(totalNote([])).toBe(0);
});
// tests/export.test.js
import jwt from 'jsonwebtoken';
import request from 'supertest';
import { expect, test } from 'vitest';
import { app } from '../app.js'; // l'application Express, exportée sans app.listen()
// JWT_SECRET est défini pour les tests dans un fichier d'environnement dédié.
const jeton = (role) => jwt.sign({ id: 1, role }, process.env.JWT_SECRET, { expiresIn: '5m' });
test('un salarié ne peut pas exporter', async () => {
const res = await request(app)
.get('/api/notes/export?mois=2027-03')
.set('Authorization', `Bearer ${jeton('salarie')}`);
expect(res.status).toBe(403);
});
test('un mois mal formé est refusé', async () => {
const res = await request(app)
.get('/api/notes/export?mois=mars')
.set('Authorization', `Bearer ${jeton('comptable')}`);
expect(res.status).toBe(400);
});
$ npm test
✓ tests/calculs.test.js (2 tests) 2ms
✓ tests/export.test.js (2 tests) 24ms
Test Files 2 passed (2)
Tests 4 passed (4)
Aucun de ces quatre tests n'a besoin de la base : le refus a lieu avant toute requête. Pour qu'ils passent, app.js doit exporter l'application (export const app = express();) sans appeler listen, que seul server.js appelle. Le script test du package.json lance vitest run. Le cours Tests explique ces tests d'intégration d'une API.
Une correction : le bug du dernier jour du mois
La demande. « Ticket n° 12 : une note clôturée le 31 mars à 17 h 42 n'apparaît pas dans la liste des notes clôturées en mars. »
La méthode. Reproduire avant de corriger, écrire le test qui échoue, corriger, vérifier que le test passe et que les autres n'ont pas bougé, puis documenter.
- Reproduire. Mettre en base une note avec
date_cloture = '2027-03-31 17:42:00', appelerGET /api/notes/cloturees?mois=2027-03: elle manque. - Localiser. La requête calcule les bornes du mois et filtre avec
BETWEEN:
WHERE n.date_cloture BETWEEN ? AND ? -- '2027-03-01' et '2027-03-31'
Comparée à un DATETIME, la chaîne '2027-03-31' vaut '2027-03-31 00:00:00'. Toute la journée du 31 est exclue, sauf minuit pile.
- Corriger avec une borne haute exclue, le premier jour du mois suivant, calculée par une fonction testable :
// services/dates.js
export function bornesDuMois(mois) { // '2027-03'
const [annee, m] = mois.split('-').map(Number);
const suivant = m === 12 ? `${annee + 1}-01` : `${annee}-${String(m + 1).padStart(2, '0')}`;
return [`${mois}-01`, `${suivant}-01`];
}
WHERE n.date_cloture >= ? AND n.date_cloture < ? -- '2027-03-01' et '2027-04-01'
- Prouver. Le test de non-régression, et le cas de décembre que la correction aurait pu casser :
// tests/dates.test.js
import { expect, test } from 'vitest';
import { bornesDuMois } from '../services/dates.js';
test('la borne haute est le premier jour du mois suivant', () => {
expect(bornesDuMois('2027-03')).toEqual(['2027-03-01', '2027-04-01']);
});
test('décembre passe à janvier de l\'année suivante', () => {
expect(bornesDuMois('2027-12')).toEqual(['2027-12-01', '2028-01-01']);
});
- Documenter : le CHANGELOG (« 1.1 : notes clôturées le dernier jour du mois, ticket n° 12 »), le rapport d'incident si l'expression des besoins le demande, puis le commit
fix: notes clôturées le dernier jour du mois (ticket n° 12).
Une amélioration de sécurité : une note visible par tous
La demande. « Un salarié a vu la note de frais d'un collègue en changeant le numéro dans l'adresse. Corrigez. »
La méthode. C'est une faille de contrôle d'accès, la première du classement OWASP Top 10 de 2025 (A01, Broken Access Control). Reproduire avec le jeton d'un salarié, corriger côté serveur, prouver par un test, puis vérifier les autres routes qui prennent un identifiant.
Le code fautif vérifie l'authentification, mais pas la propriété :
export async function lireNote(req, res) {
const [notes] = await pool.execute('SELECT * FROM note_frais WHERE id_note = ?', [req.params.id]);
if (notes.length === 0) return res.status(404).json({ erreur: 'Note introuvable' });
res.json(notes[0]);
}
La correction :
export async function lireNote(req, res) {
const [notes] = await pool.execute(
'SELECT id_note, id_utilisateur, mois, etat, montant_valide FROM note_frais WHERE id_note = ?',
[req.params.id],
);
const note = notes[0];
const autorise = note && (req.user.role === 'comptable' || note.id_utilisateur === req.user.id);
if (!autorise) return res.status(404).json({ erreur: 'Note introuvable' });
res.json(note);
}
Trois choix à défendre :
- Le contrôle est sur le serveur. Masquer un lien dans le front n'empêche pas un appel direct.
- 404 plutôt que 403. Un 403 confirmerait que la note existe ; un 404 ne dit rien à celui qui essaie des numéros au hasard. Un 403 se défend aussi, s'il est justifié : dis lequel tu as choisi et pourquoi.
- Des colonnes nommées plutôt que
SELECT *. La réponse ne contient que ce dont l'écran a besoin : c'est le principe de minimisation du RGPD.
Le test qui le prouve : un salarié qui demande la note d'un autre reçoit 404 ; le propriétaire et un comptable reçoivent 200.
Dans GestParc, l'équivalent est une recherche écrite par concaténation, "SELECT * FROM poste WHERE marque = '" + txtMarque.Text + "'". C'est une injection (A05 dans le Top 10 de 2025). La correction est une requête paramétrée, comme dans la méthode Ajouter du formulaire de poste. Le cours Sécurité applicative traite les deux failles en détail.
Exercices
Exercice Choisir la bonne méthode Réussi À revoir
Pour chaque demande, dis à quelle famille elle appartient et quelle est ta première action concrète.
- « Le total affiché sur une note de 3 lignes est faux de 1 centime. »
- « Un comptable doit pouvoir saisir un commentaire interne sur une note, invisible du salarié. »
- « Les catégories de frais doivent devenir une table, pour en ajouter sans modifier le code. »
- « La recherche de postes par marque plante quand on tape une apostrophe. »
Voir la réponseMasquer la réponse de l'exercice
- Correction. Reproduire avec les trois montants de la note, puis écrire le test qui échoue sur la fonction de calcul. La cause probable : une addition en virgule flottante ou un arrondi ligne par ligne.
- Changement de base et fonctionnalité. Ajouter une colonne
commentaire_interne(ou une table si une note peut recevoir plus d'un commentaire), puis vérifier toutes les routes qui renvoient une note à un salarié : aucune ne doit exposer ce champ. C'est aussi un sujet de contrôle d'accès. - Changement de base. Créer la table
categorie, la remplir avec les quatre valeurs de l'ENUM, puis remplacer la colonne par une clé étrangère en trois temps : ajouter, remplir, contraindre. Sauvegarde et script d'annulation d'abord. - Sécurité. Une apostrophe qui fait planter une requête trahit une concaténation : c'est une injection SQL. Première action : retrouver la requête et la passer en requête paramétrée, puis tester avec
O'Neil.
Tu avais trouvé ?