Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

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

js
// 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.

  1. Observer l'existant : lancer l'application, reproduire le comportement actuel.
  2. Localiser : trouver le fichier, la table, la fonction qui ressemble le plus à la demande.
  3. Changer le minimum : un petit pas, puis un test.
  4. Prouver : un test automatisé, ou un appel qui montre le résultat et un appel qui montre le refus.
  5. Documenter : README, dictionnaire de données, CHANGELOG, documentation demandée.
  6. 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.

js
// routes/notes.js : avant la route GET '/:id', sinon Express prend « export » pour un identifiant
router.get('/export', requireAuth, requireRole('comptable'), exporterNotes);
js
// 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 DECIMAL sous 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.attachment pose l'en-tête Content-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.
bash
curl -i -H "Authorization: Bearer $JETON_COMPTA" "http://localhost:3000/api/notes/export?mois=2027-03"
text
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.

sql
-- 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);
sql
-- 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 :

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

sql
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.

csharp
// 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à");
    }
}
csharp
// 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.

js
// 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;
}
js
// 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);
});
js
// 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);
});
text
$ 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.

  1. Reproduire. Mettre en base une note avec date_cloture = '2027-03-31 17:42:00', appeler GET /api/notes/cloturees?mois=2027-03 : elle manque.
  2. Localiser. La requête calcule les bornes du mois et filtre avec BETWEEN :
sql
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.

  1. Corriger avec une borne haute exclue, le premier jour du mois suivant, calculée par une fonction testable :
js
// 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`];
}
sql
WHERE n.date_cloture >= ? AND n.date_cloture < ?   -- '2027-03-01' et '2027-04-01'
  1. Prouver. Le test de non-régression, et le cas de décembre que la correction aurait pu casser :
js
// 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']);
});
  1. 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é :

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

js
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.

  1. « Le total affiché sur une note de 3 lignes est faux de 1 centime. »
  2. « Un comptable doit pouvoir saisir un commentaire interne sur une note, invisible du salarié. »
  3. « Les catégories de frais doivent devenir une table, pour en ajouter sans modifier le code. »
  4. « La recherche de postes par marque plante quand on tape une apostrophe. »
Voir la réponseMasquer la réponse de l'exercice
  1. 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.
  2. 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.
  3. 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.
  4. 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é ?

Chapitre terminé !

Fais l'exercice avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : La recette et l'entretien d'explicitationRevoir le sommaire du cours