Commit bbc06cfa authored by Kourser's avatar Kourser
Browse files

Recette : le harnais qui exécute le cahier, en une commande

    pnpm recette

Cinq lots couvrent 130 cas contre une instance en marche : écrans
publics et cloisonnement des rôles, cycle de vie complet d'un exercice,
espace joueur et imports, éditeur de kit et écran de salle, console
d'instance. Chaque cas rend un verdict dont le libellé reprend la ligne
du cahier — un échec doit se lire sans ouvrir le code.

Aucune dépendance ajoutée : le code du second facteur est calculé avec
« node:crypto », une trentaine de lignes plutôt qu'un paquet de plus.

Les sessions sont MUTUALISÉES par compte. Le plafonnement autorise dix
connexions par IP tous les quarts d'heure ; en ouvrant les siennes, chaque
lot faisait échouer la suite complète sur un 429 — la recette se bloquait
elle-même.

Deux attentes sont assumées et commentées : une fenêtre de code neuve
avant le second facteur, et les trente secondes du cache d'état des
organisations avant de vérifier une suspension. Sans elles, deux cas
justes échouent.

Les lots retirent ce qu'ils créent, même après un échec. Restent deux
choses que l'API ne défait pas — le second facteur enrôlé et
l'organisation éphémère du bac à sable, que le produit purge lui-même :
le harnais les rappelle en sortant plutôt que d'inventer un geste que la
console n'offre pas.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent f2995405
Loading
Loading
Loading
Loading
+15 −1
Changes for docs/recette-resultats.md: 15 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -104,7 +104,21 @@ conformes ; seule l'assertion de code doit être juste.

---

## 5. Remise en état
## 5. Rejouer la campagne

Les sondes de cette campagne sont versées au dépôt et exécutables d'une commande :

```bash
pnpm recette
```

Elles couvrent 130 cas — les 104 de la campagne manuelle, plus ceux que
l'écriture du harnais a fait apparaître : refus d'une vérification des accès sans
participant, survie d'un écran de salle à la révocation, distinction entre lecture
et écriture en session d'observation. Le détail d'usage est dans
[scripts/README.md](../scripts/README.md).

## 6. Remise en état

L'environnement a été rendu tel que livré : 3 organisations, 4 comptes,
1 exercice de démonstration en brouillon, 0 participant, aucun kit propre,
+69 −0
Changes for scripts/recette/executer.mjs: 69 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * Exécute la recette : tous les lots, dans l'ordre, contre une instance en marche.
 *
 *   node scripts/recette/executer.mjs           # tous les lots
 *   node scripts/recette/executer.mjs 01 03     # seulement ceux-là
 *
 * Prérequis : une instance servie sur RECETTE_BASE (par défaut
 * http://localhost:3000/api) avec le jeu de démonstration chargé. Les lots
 * créent leurs propres données et les retirent en sortant, y compris après un
 * échec.
 *
 * À SAVOIR — le plafonnement des connexions (5 par adresse et 10 par IP toutes
 * les 15 minutes) se déclenche si la recette est relancée coup sur coup. Les
 * compteurs vivent en mémoire du processus : `docker compose restart app` les
 * vide.
 */
import { readdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { pathToFileURL } from 'node:url';
import { BASE, bilanGlobal } from './harnais.mjs';

const ICI = dirname(fileURLToPath(import.meta.url));
const filtres = process.argv.slice(2);

const fichiers = readdirSync(resolve(ICI, 'lots'))
  .filter((f) => f.endsWith('.mjs'))
  .sort()
  .filter((f) => filtres.length === 0 || filtres.some((p) => f.startsWith(p)));

if (fichiers.length === 0) {
  console.error('Aucun lot ne correspond.');
  process.exit(2);
}

console.log(`Recette Enkadenn — ${BASE}\n`);

const aNettoyer = [];
let interrompu = null;

for (const fichier of fichiers) {
  const lot = await import(pathToFileURL(resolve(ICI, 'lots', fichier)).href);
  console.log(`── ${fichier.replace(/\.mjs$/, '')} · ${lot.nom}`);
  if (lot.nettoyer) aNettoyer.push(lot);
  try {
    await lot.executer();
  } catch (err) {
    // Un lot qui casse ne doit pas emporter le nettoyage des précédents.
    interrompu = { fichier, err };
    console.error(`   INTERROMPU ${fichier} : ${err.message}`);
    break;
  }
  console.log('');
}

for (const lot of aNettoyer.reverse()) {
  try {
    await lot.nettoyer();
  } catch (err) {
    console.error(`   Nettoyage incomplet (${lot.nom}) : ${err.message}`);
  }
}

const echecs = bilanGlobal();
if (interrompu) {
  console.error(`\nLot interrompu : ${interrompu.fichier}`);
  process.exit(2);
}
process.exit(echecs === 0 ? 0 : 1);
+159 −0
Changes for scripts/recette/harnais.mjs: 159 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * Harnais de recette — outillage commun aux lots.
 *
 * Chaque lot exécute des cas du cahier de test (`docs/cahier-de-test.md`) contre
 * une instance en fonctionnement, et rend un verdict par cas. Le libellé reprend
 * la ligne du cahier : un échec doit se lire sans ouvrir le code.
 *
 * Aucune dépendance : le second facteur est calculé ici même, avec `node:crypto`.
 */
import { createHmac } from 'node:crypto';

export const BASE = process.env.RECETTE_BASE ?? 'http://localhost:3000/api';
export const MOT_DE_PASSE = process.env.RECETTE_MOT_DE_PASSE ?? 'ChangeMoi123!';

const resultats = [];

/**
 * Enregistre un verdict.
 *
 * `ok` permet de juger autre chose qu'une égalité — utile quand plusieurs codes
 * de retour sont acceptables (200 ou 204 selon la route).
 */
export function verdict(section, libelle, attendu, obtenu, ok = undefined) {
  const passe = ok === undefined ? String(attendu) === String(obtenu) : Boolean(ok);
  resultats.push({ section, libelle, attendu, obtenu, passe });
  const detail = passe ? '' : `  (attendu ${attendu}, obtenu ${obtenu})`;
  console.log(`${passe ? 'OK   ' : 'ÉCHEC'} ${String(section).padEnd(6)} ${libelle}${detail}`);
  return passe;
}

/** Cas non exécuté, faute de condition réunie. Ni succès ni échec. */
export function ignore(section, libelle, motif) {
  resultats.push({ section, libelle, ignore: true, motif });
  console.log(`IGNORÉ ${String(section).padEnd(6)} ${libelle} — ${motif}`);
}

export function bilanGlobal() {
  const executes = resultats.filter((r) => !r.ignore);
  const echecs = executes.filter((r) => !r.passe);
  const ignores = resultats.filter((r) => r.ignore);
  console.log(`\n${executes.length - echecs.length}/${executes.length} cas conformes` +
    (ignores.length ? `, ${ignores.length} ignoré(s)` : ''));
  for (const e of echecs) {
    console.log(`   ÉCHEC ${e.section} ${e.libelle} — attendu ${e.attendu}, obtenu ${e.obtenu}`);
  }
  return echecs.length;
}

/** Session porteuse de cookies, comme le ferait un navigateur. */
export function session() {
  let jar = '';
  return {
    async appel(chemin, options = {}) {
      const r = await fetch(BASE + chemin, {
        ...options,
        headers: {
          'Content-Type': 'application/json',
          ...(jar ? { Cookie: jar } : {}),
          ...(options.headers ?? {}),
        },
      });
      const recus = r.headers.getSetCookie?.() ?? [];
      if (recus.length) {
        const par = new Map(jar.split('; ').filter(Boolean).map((c) => [c.split('=')[0], c]));
        for (const c of recus) {
          const paire = c.split(';')[0];
          par.set(paire.split('=')[0], paire);
        }
        jar = [...par.values()].join('; ');
      }
      const texte = await r.text();
      let corps = texte;
      try {
        corps = JSON.parse(texte);
      } catch {
        /* réponse non JSON : on garde le texte brut */
      }
      return { code: r.status, corps, texte };
    },
    connexion(email, motDePasse = MOT_DE_PASSE) {
      return this.appel('/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password: motDePasse }),
      });
    },
  };
}

/**
 * Session partagée pour un compte, ouverte une seule fois.
 *
 * Le plafonnement autorise dix connexions par IP toutes les quinze minutes, et
 * une suite complète en demanderait davantage si chaque lot rouvrait les
 * siennes : la recette se bloquerait elle-même. Les cas qui ont BESOIN d'une
 * session neuve — mauvais mot de passe, session ouverte avant une suspension,
 * accès joueur — appellent `session()` directement.
 */
const partagees = new Map();

export async function sessionPour(email, motDePasse = MOT_DE_PASSE) {
  const deja = partagees.get(email);
  if (deja) return deja;
  const s = session();
  const r = await s.connexion(email, motDePasse);
  if (r.code !== 200) {
    const raison = r.code === 429 ? 'plafonnement des connexions' : `refus ${r.code}`;
    throw new Error(`connexion impossible pour ${email} : ${raison}`);
  }
  partagees.set(email, s);
  return s;
}

// --- Second facteur (RFC 6238), sans dépendance ------------------------------

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

/** Décode une chaîne base32 telle que la produit l'enrôlement. */
function base32(secret) {
  let bits = '';
  for (const c of secret.replace(/=+$/, '').toUpperCase()) {
    const v = ALPHABET.indexOf(c);
    if (v < 0) continue;
    bits += v.toString(2).padStart(5, '0');
  }
  const octets = [];
  for (let i = 0; i + 8 <= bits.length; i += 8) octets.push(parseInt(bits.slice(i, i + 8), 2));
  return Buffer.from(octets);
}

/** Code TOTP à 6 chiffres, fenêtre de 30 s, comme l'attend l'API. */
export function codeTotp(secret, instant = Date.now()) {
  const compteur = Math.floor(instant / 1000 / 30);
  const message = Buffer.alloc(8);
  message.writeUInt32BE(Math.floor(compteur / 2 ** 32), 0);
  message.writeUInt32BE(compteur >>> 0, 4);
  const empreinte = createHmac('sha1', base32(secret)).update(message).digest();
  const decalage = empreinte[empreinte.length - 1] & 0x0f;
  const tronque = empreinte.readUInt32BE(decalage) & 0x7fffffff;
  return String(tronque % 1_000_000).padStart(6, '0');
}

/**
 * Attend le début de la prochaine fenêtre TOTP.
 *
 * Un code déjà consommé est refusé, et un code émis en fin de fenêtre expire
 * entre l'envoi et la vérification : on repart d'une fenêtre neuve.
 */
export const prochaineFenetre = () =>
  new Promise((r) => setTimeout(r, (31 - (Math.floor(Date.now() / 1000) % 30)) * 1000));

export const dormir = (ms) => new Promise((r) => setTimeout(r, ms));

/**
 * Délai de prise d'effet d'une suspension d'organisation.
 *
 * L'état est mis en cache 30 s côté API (`TENANT_STATUS_TTL_MS`) : sans cette
 * attente, une suspension semble sans effet et le cas échoue à tort.
 */
export const CACHE_ETAT_MS = 33_000;
+71 −0
Changes for scripts/recette/lots/01-public.mjs: 71 added lines, 0 removed lines.
Original line number Diff line number Diff line
/** Lot 1 — écrans publics (§3) et cadre de la console (§4). Ne modifie rien. */
import { session, verdict } from '../harnais.mjs';

export const nom = 'Écrans publics et cadre de la console';

export async function executer() {
  // §3.1 Connexion
  const s = session();
  let r = await s.connexion('admin@meridien.exercice');
  verdict('3.1', 'identifiants valides → session ouverte', 200, r.code);
  verdict('3.1', "la réponse porte l'identité et les appartenances", true, Boolean(r.corps?.memberships));

  const faux = session();
  r = await faux.connexion('admin@meridien.exercice', 'mauvais-mot-de-passe');
  verdict('3.1', 'mot de passe faux → refus', 401, r.code);
  const message = r.corps?.message ?? '';
  r = await faux.connexion('inconnu@nulle-part.test', 'mauvais-mot-de-passe');
  verdict('3.1', 'compte inconnu → même message', message, r.corps?.message ?? '');

  // §3.3 Mot de passe oublié : la réponse ne dit pas si le compte existe
  const p = session();
  const connu = await p.appel('/public/forgot-password', {
    method: 'POST',
    body: JSON.stringify({ email: 'admin@helios.exercice' }),
  });
  const inconnu = await p.appel('/public/forgot-password', {
    method: 'POST',
    body: JSON.stringify({ email: 'personne@nulle-part.test' }),
  });
  verdict('3.3', 'adresse connue et inconnue → même code', connu.code, inconnu.code);
  verdict('3.3', 'adresse connue et inconnue → même corps', connu.texte, inconnu.texte);

  // §3.4 / §3.5 État public des portes d'entrée
  r = await p.appel('/public/signup');
  verdict('3.4', "état d'inscription exposé publiquement", 200, r.code);
  const modeInscription = r.corps?.mode;
  if (modeInscription === 'DISABLED') {
    r = await p.appel('/public/signup', {
      method: 'POST',
      body: JSON.stringify({ organisationName: 'Essai', contactName: 'X', contactEmail: 'x@essai.test' }),
    });
    verdict('3.4', 'dépôt refusé quand les inscriptions sont fermées', 403, r.code);
  }

  r = await p.appel('/public/sandbox');
  verdict('3.5', 'état du bac à sable exposé', 200, r.code);
  if (r.corps?.open === false) {
    r = await p.appel('/public/sandbox', { method: 'POST' });
    verdict('3.5', 'création refusée quand il est fermé', true, r.code >= 400);
  }

  // §3.6 Jeton de courriel invalide
  r = await p.appel('/public/token/jeton-inexistant-0000');
  verdict('3.6', 'jeton inconnu → refus explicite', true, r.code === 404 || r.code === 400);

  // §4.1 Ce qu'un rôle d'animation ne peut pas atteindre
  const anim = session();
  r = await anim.connexion('consultante@enkadenn.local');
  verdict('4.1', 'compte multi-appartenance : session ouverte', 200, r.code);
  verdict('4.1', 'deux appartenances exposées', 2, r.corps?.memberships?.length);
  verdict('4.1', 'ANIMATOR ne liste pas les utilisateurs', 403, (await anim.appel('/users')).code);
  verdict('4.1', "ANIMATOR n'atteint pas la console d'instance", 403, (await anim.appel('/instance/metrics')).code);

  // §4.2 Bascule d'organisation
  const moi = (await anim.appel('/auth/me')).corps;
  r = await anim.appel('/auth/organisation', {
    method: 'POST',
    body: JSON.stringify({ tenantId: moi.memberships[1].tenantId }),
  });
  verdict('4.2', "bascule d'organisation acceptée", 200, r.code);
}
+141 −0
Changes for scripts/recette/lots/02-exercices.mjs: 141 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * Lot 2 — cycle de vie d'un exercice (§5), rapport (§5.7), non-régression R1/R3/R4.
 *
 * Crée ses exercices dans l'organisation Helios et les supprime en sortant.
 */
import { sessionPour, verdict } from '../harnais.mjs';

export const nom = "Cycle de vie d'un exercice";

const PREFIXE = 'Recette —';

export async function executer() {
  const s = await sessionPour('admin@helios.exercice');

  const kits = (await s.appel('/kits')).corps;
  const vitrine = kits.find((k) => k.name.startsWith('Vitrine'));
  verdict('6.1', 'catalogue de kits disponible', true, Array.isArray(kits) && kits.length >= 12);

  // §5.2 / R3 — installation SANS renseigner les variables
  let r = await s.appel(`/kits/${vitrine.id}/install`, {
    method: 'POST',
    body: JSON.stringify({ name: `${PREFIXE} sans variables` }),
  });
  verdict('5.2', 'installation depuis un kit', 201, r.code);
  const sansVars = r.corps.exerciseId;
  const perso = (await s.appel(`/exercises/${sansVars}/characters`)).corps;
  verdict('R3', 'aucun jeton {{…}} dans les personnages', 0,
    perso.filter((c) => /\{\{/.test(`${c.name}${c.title ?? ''}`)).length);

  // §5.2 — installation complète
  r = await s.appel(`/kits/${vitrine.id}/install`, {
    method: 'POST',
    body: JSON.stringify({
      name: `${PREFIXE} exercice complet`,
      organisation: 'Transports Kerlann',
      ville: 'Lorient',
      edr: 'SentinelOne',
      partenaire: 'InfoBreizh',
      media: 'Le Télégramme',
    }),
  });
  const ex = r.corps.exerciseId;

  const compter = async (id) => {
    const [c, i, d] = await Promise.all([
      s.appel(`/exercises/${id}/characters`),
      s.appel(`/exercises/${id}/injects`),
      s.appel(`/exercises/${id}/documents`),
    ]);
    return { perso: c.corps.length, injects: i.corps.length, docs: d.corps.length };
  };
  const source = await compter(ex);
  verdict('5.2', 'le kit installe personnages, injects et documents', true,
    source.perso > 0 && source.injects > 0 && source.docs > 0);

  // §5.1 / R1 — duplication
  r = await s.appel(`/exercises/${ex}/duplicate`, { method: 'POST' });
  verdict('5.1', 'duplication acceptée', 201, r.code);
  const copie = r.corps.id;
  const copieCompte = await compter(copie);
  verdict('R1', 'la copie emporte les injects', source.injects, copieCompte.injects);
  verdict('R1', 'la copie emporte les documents', source.docs, copieCompte.docs);
  verdict('R1', 'la copie emporte les personnages', source.perso, copieCompte.perso);
  verdict('5.1', 'la copie repart en brouillon', 'DRAFT', (await s.appel(`/exercises/${copie}`)).corps.status);

  // §5.1 — suppression : archivage obligatoire d'abord
  verdict('5.1', 'suppression refusée hors archivage', 400,
    (await s.appel(`/exercises/${copie}`, { method: 'DELETE' })).code);
  await s.appel(`/exercises/${copie}/archive`, { method: 'POST' });
  verdict('5.1', 'suppression acceptée une fois archivé', true,
    [200, 204].includes((await s.appel(`/exercises/${copie}`, { method: 'DELETE' })).code));

  // §5.4 — horloge et chronogramme
  r = await s.appel(`/exercises/${ex}/start`, { method: 'POST' });
  verdict('5.4', 'démarrage : statut en cours', 'RUNNING', r.corps?.status);
  verdict('5.4', 'horloge lancée', true, r.corps?.running);

  const injects = (await s.appel(`/exercises/${ex}/injects`)).corps;
  const premier = injects.find((i) => i.channel === 'EMAIL' && i.status === 'PENDING');
  r = await s.appel(`/injects/${premier.id}/trigger`, { method: 'POST' });
  verdict('5.4', "déclenchement manuel : l'inject part", 'SENT', r.corps?.status);

  const aAnnuler = injects.find((i) => i.status === 'PENDING' && i.id !== premier.id);
  verdict('5.4', 'annulation acceptée', true,
    [200, 201].includes((await s.appel(`/injects/${aAnnuler.id}/cancel`, { method: 'POST' })).code));

  verdict('5.4', 'pause', false,
    (await s.appel(`/exercises/${ex}/clock`, { method: 'POST', body: JSON.stringify({ action: 'pause' }) })).corps?.running);
  verdict('5.4', 'vitesse ×5', 5,
    (await s.appel(`/exercises/${ex}/clock`, { method: 'POST', body: JSON.stringify({ action: 'setSpeed', speed: 5 }) })).corps?.speed);
  verdict('5.4', 'action inconnue rejetée', 400,
    (await s.appel(`/exercises/${ex}/clock`, { method: 'POST', body: JSON.stringify({ action: 'inconnue' }) })).code);
  await s.appel(`/exercises/${ex}/clock`, { method: 'POST', body: JSON.stringify({ action: 'start' }) });

  // §5.6 / R4 — export CSV et neutralisation des formules
  await s.appel(`/exercises/${ex}/log`, {
    method: 'POST',
    body: JSON.stringify({
      category: 'FAIT',
      message: '=HYPERLINK("http://ailleurs","clic")',
      source: '+33 6 12 34 56 78',
    }),
  });
  const csv = (await s.appel(`/exercises/${ex}/log/export.csv`)).texte;
  const ligne = csv.split('\n').find((l) => l.includes('HYPERLINK')) ?? '';
  verdict('R4', 'la formule est neutralisée', true, ligne.includes(`"'=HYPERLINK`));
  verdict('R4', 'le numéro « +33… » aussi', true, ligne.includes(`"'+33`));

  // §5.7 — clôture et RETEX
  verdict('5.7', 'clôture+RETEX refusée sans questionnaire', 400,
    (await s.appel(`/exercises/${ex}/close`, { method: 'POST', body: JSON.stringify({ openRetex: true }) })).code);
  verdict('5.7', "l'exercice n'a pas été clôturé au passage", 'RUNNING',
    (await s.appel(`/exercises/${ex}`)).corps.status);

  const modeles = (await s.appel('/retex-templates')).corps;
  verdict('7', 'modèles de RETEX fournis', true, modeles.length >= 2);
  await s.appel(`/exercises/${ex}/retex/apply`, { method: 'POST', body: JSON.stringify({ templateId: modeles[0].id }) });
  verdict('5.4', 'clôture acceptée', 'CLOSED',
    (await s.appel(`/exercises/${ex}/close`, { method: 'POST', body: JSON.stringify({ openRetex: true }) })).corps?.status);
  verdict('5.7', 'collecte ouverte par la clôture', true, (await s.appel(`/exercises/${ex}/retex`)).corps?.open === true);

  const rapport = await s.appel(`/exercises/${ex}/report`);
  verdict('5.7', "rapport accessible à l'animation", 200, rapport.code);
  verdict('5.7', 'le rapport porte le chronogramme réel', true, Array.isArray(rapport.corps?.injectTimeline));

  // §14 — cloisonnement
  const autre = await sessionPour('admin@meridien.exercice');
  verdict('14', "exercice d'une autre organisation → introuvable", 404, (await autre.appel(`/exercises/${ex}`)).code);
  verdict('14', "rapport d'une autre organisation → refusé", true,
    [403, 404].includes((await autre.appel(`/exercises/${ex}/report`)).code));
}

/** Retire les exercices créés par ce lot. */
export async function nettoyer() {
  const s = await sessionPour('admin@helios.exercice');
  const exos = (await s.appel('/exercises')).corps;
  for (const e of (Array.isArray(exos) ? exos : []).filter((x) => x.name.startsWith(PREFIXE))) {
    if (e.status !== 'ARCHIVED') await s.appel(`/exercises/${e.id}/archive`, { method: 'POST' });
    await s.appel(`/exercises/${e.id}`, { method: 'DELETE' });
  }
}
Loading