Files
planflow/tests/e2e/auth.spec.ts
T
Claude 2047d7f5cc Exiger un second facteur des rôles administrateur et RH
La matrice n° 15 l'impose ; les colonnes existaient en base depuis WP-01 mais
rien ne les remplissait. TOTP (RFC 6238) plutôt qu'un code envoyé par message :
le second facteur ne doit pas dépendre du canal qui sert déjà à réinitialiser le
mot de passe, faute de quoi un accès à la boîte donnerait les deux d'un coup.

L'algorithme est écrit ici plutôt qu'emprunté — trente lignes, et une dépendance
de moins sur le chemin d'authentification. Il est éprouvé contre les vecteurs
publiés de la RFC, ce qui distingue « le code change toutes les trente
secondes » de « le code est celui qu'attend l'application du téléphone ».

L'obligation est adossée aux capacités, pas à des rôles nommés qu'un client
renomme librement : distribuer les droits, ou lire les rémunérations. Elle est
tenue au point de passage de toutes les routes applicatives, où l'écran
d'enrôlement remplace le contenu. Il remplace plutôt qu'il ne redirige : une
redirection depuis un layout se joue aussi pendant la navigation qui suit la
connexion, et Next y répond par une page vide.

Le secret n'est enregistré qu'après qu'un code en a été tiré — l'enregistrer
d'avance laisserait des comptes porteurs d'un facteur que leur détenteur ne sait
pas produire, c'est-à-dire des comptes fermés. Le rejeu d'un code est refusé
dans sa propre fenêtre : sans cela le facteur protège d'un mot de passe volé,
pas d'un code lu par-dessus l'épaule.

Dix codes de secours accompagnent chaque activation, conservés hachés. Et parce
que PlanFlow est auto-hébergé et qu'il n'y a pas d'éditeur à appeler, un retrait
depuis le serveur existe — l'accès « break glass » que demande la même ligne de
la matrice. Sans lui, le second facteur deviendrait le risque principal plutôt
que la protection.

Un défaut trouvé par les tests, et il aurait été grave : la réactualisation de
la route après activation remplaçait l'écran par celui d'un compte déjà enrôlé,
emportant les codes de secours avant que leur destinataire ait pu les noter.

La suite de tests suit le produit : la mise en place enrôle réellement le compte
de direction et calcule les codes comme le ferait un téléphone. Les écrans qui
n'éprouvent pas l'authentification ont migré vers la session commune — deux
connexions parallèles sur un même compte se heurtent au refus de rejeu, ce qui
est le comportement voulu et non un défaut à contourner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-08 22:05:57 +00:00

72 lines
3.0 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { answerChallenge, rememberedSecret, waitForFreshCode } from './mfa';
const EMAIL = 'direction@example.test';
const PASSWORD = 'planflow-demo-2026';
test('une route applicative redirige vers la connexion', async ({ page }) => {
await page.goto('/planning/semaine');
await expect(page).toHaveURL(/\/connexion$/);
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
});
test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) => {
async function attempt(email: string): Promise<string> {
// Une page neuve par tentative : le message précédent resterait sinon à
// l'écran et le test comparerait deux fois le même.
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(email);
await page.getByLabel('Mot de passe').fill('mauvais-mot-de-passe');
await page.getByRole('button', { name: 'Se connecter' }).click();
// Restreint au formulaire : Next pose un annonceur de route qui porte lui
// aussi role="alert" et qui est vide.
const alert = page.locator('form').getByRole('alert');
await expect(alert).toBeVisible();
return (await alert.textContent()) ?? '';
}
const knownAccount = await attempt(EMAIL);
const unknownAccount = await attempt('inconnu@example.test');
// Un message différent laisserait énumérer les adresses du personnel.
expect(unknownAccount).toBe(knownAccount);
expect(knownAccount).toContain('Identifiants incorrects');
});
test('connexion, navigation, puis déconnexion', async ({ page }) => {
const remembered = rememberedSecret();
// Un code ne sert qu'une fois : attendre le pas suivant celui qu'a employé la
// mise en place, plutôt que de se heurter au refus de rejeu. L'attente est
// nulle dès que trente secondes se sont écoulées entre-temps.
await waitForFreshCode(remembered.usedStep);
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Le rôle de la direction exige un second facteur : le mot de passe seul
// n'ouvre rien.
await expect(page.getByRole('heading', { name: 'Vérification' })).toBeVisible();
await answerChallenge(page, remembered.secret);
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// L'identité affichée vient de la base, pas d'un libellé en dur.
await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible();
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Déconnexion' }).click();
await expect(page).toHaveURL(/\/connexion$/);
// La session est révoquée en base : revenir en arrière ne doit pas rouvrir
// l'application.
await page.goto('/equipe');
await expect(page).toHaveURL(/\/connexion$/);
});