Files
planflow/tests/e2e/securite.spec.ts
T
MichaelandClaude Opus 5 894e880198 Alléger l'annuaire
Le formulaire d'embauche, posé au bas de la liste, occupait plus de place
que l'effectif qu'on vient consulter — une quinzaine de champs déployés en
permanence pour un geste occasionnel. Il s'ouvre désormais en modale,
depuis le bouton d'en-tête.

`<dialog>` natif plutôt qu'un panneau maison : le piège de focus, la
fermeture par Échap et le fond inerte viennent avec, et une `<div>` doit
les réimplémenter sans jamais les tenir tout à fait.

Le tableau perd sa carte et ses fonds : une liste de personnes se lit
mieux sans cadre autour. Les colonnes suivent l'annuaire de référence —
collaborateur, rôle, email, mobile, rattachement, invitation. Le contrat
quitte le tableau, où il doublait le filtre qui le cherche déjà ; son
absence reste signalée là où elle compte, dans le rattachement.

Le décompte ne s'affiche plus que filtré : « 87 salariés » au-dessus de 87
lignes n'apprend rien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:45:28 +02:00

202 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test';
import { codeNow, stepNow, waitForFreshCode } from './mfa';
/**
* Second facteur — matrice n° 15.
*
* Les codes sont calculés depuis le secret affiché, exactement comme le ferait
* l'application d'un téléphone. Court-circuiter la vérification testerait un
* produit que personne n'utilise.
*
* La direction est enrôlée par la mise en place et son rôle l'exige : c'est sur
* elle qu'on éprouve le refus de retrait. Le cycle complet — activer, se
* connecter, désactiver — se joue sur un compte ordinaire, créé pour l'occasion.
*/
const OWNER_PASSWORD = 'planflow-demo-2026';
const EMPLOYEE_PASSWORD = 'les mesanges du soir';
test('la direction ne peut pas retirer un facteur que son rôle exige', async ({
page,
}) => {
await page.goto('/reglages/securite');
await expect(page.getByText('Second facteur actif')).toBeVisible();
await expect(page.getByText(/exigé.+ne peut pas être retiré/)).toBeVisible();
// Le formulaire de retrait n'est pas seulement masqué : il n'existe pas.
await expect(page.getByRole('button', { name: 'Désactiver' })).toHaveCount(0);
});
// Le parcours attend deux fois le pas TOTP suivant — trente secondes chacune —
// parce qu'un code ne sert qu'une fois. C'est le comportement voulu, pas une
// lenteur à corriger.
test('un compte ordinaire active, éprouve, puis retire son second facteur', async ({
page,
browser,
}) => {
test.setTimeout(150_000);
const account = await createAccessibleEmployee(page);
const context = await browser.newContext({ storageState: undefined });
const own = await context.newPage();
await signIn(own, account.email, EMPLOYEE_PASSWORD);
// Un salarié ordinaire n'est pas contraint au second facteur : il entre
// directement, sans écran d'enrôlement.
await expect(own.getByRole('button', { name: 'Déconnexion' })).toBeVisible();
await own.goto('/reglages/securite');
await expect(own.getByText('Non activé')).toBeVisible();
await own.getByRole('button', { name: 'Activer le second facteur' }).click();
const secret = await readSecret(own);
const codeField = own.getByRole('textbox', {
name: 'Recopiez le code affiché',
});
// Un code faux n'enregistre rien : poser un secret sans preuve que son
// détenteur sait le produire fermerait le compte.
await codeField.fill('000000');
await own.getByRole('button', { name: 'Valider' }).click();
await expect(own.getByText(/ne correspond pas/)).toBeVisible();
const enrolStep = stepNow();
await codeField.fill(codeNow(secret));
await own.getByRole('button', { name: 'Valider' }).click();
const codes = own.getByTestId('recovery-codes');
await expect(codes).toBeVisible();
await expect(codes.getByRole('listitem')).toHaveCount(10);
const recoveryCode = (await codes.getByRole('listitem').first().textContent())!.trim();
// Le mot de passe seul n'ouvre plus rien.
const second = await browser.newContext({ storageState: undefined });
const fresh = await second.newPage();
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
await expect(fresh.getByRole('heading', { name: 'Vérification' })).toBeVisible();
// Une session en attente n'ouvre aucun écran : elle ne porte qu'un défi.
await fresh.goto('/equipe');
await expect(fresh).toHaveURL(/connexion/);
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
await fresh.getByLabel('Code').fill('123456');
await fresh.getByRole('button', { name: 'Vérifier' }).click();
await expect(fresh.getByText(/Code refusé/)).toBeVisible();
await waitForFreshCode(enrolStep);
await fresh.getByLabel('Code').fill(codeNow(secret));
await fresh.getByRole('button', { name: 'Vérifier' }).click();
await expect(fresh).not.toHaveURL(/verification/);
await second.close();
// Un code de secours ouvre aussi, et ne sert qu'une fois.
const third = await browser.newContext({ storageState: undefined });
const rescued = await third.newPage();
await signIn(rescued, account.email, EMPLOYEE_PASSWORD);
await rescued.getByLabel('Code').fill(recoveryCode);
await rescued.getByRole('button', { name: 'Vérifier' }).click();
await expect(rescued).toHaveURL(/reglages\/securite/);
const fourth = await browser.newContext({ storageState: undefined });
const replay = await fourth.newPage();
await signIn(replay, account.email, EMPLOYEE_PASSWORD);
await replay.getByLabel('Code').fill(recoveryCode);
await replay.getByRole('button', { name: 'Vérifier' }).click();
await expect(replay.getByText(/Code refusé/)).toBeVisible();
await fourth.close();
// Le retrait exige le mot de passe : un poste laissé ouvert ne suffit pas.
await rescued.goto('/reglages/securite');
await rescued.getByLabel('Mot de passe').fill('pas le bon mot de passe');
await rescued.getByRole('button', { name: 'Désactiver' }).click();
await expect(rescued.getByText('Mot de passe incorrect.')).toBeVisible();
await rescued.getByLabel('Mot de passe').fill(EMPLOYEE_PASSWORD);
await rescued.getByRole('button', { name: 'Désactiver' }).click();
await expect(rescued.getByText('Second facteur désactivé.')).toBeVisible();
await third.close();
await context.close();
});
async function signIn(page: Page, email: string, password: string) {
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();
// Attendre que la page ait quitté le formulaire : naviguer pendant que
// l'action est encore en vol l'annulerait, et la session ne serait jamais
// posée.
await page.waitForURL((url) => !url.pathname.endsWith('/connexion'));
}
/** Le secret est affiché en clair pour qui n'a pas d'appareil photo. */
async function readSecret(page: Page): Promise<string> {
const shown = await page.locator('p.font-mono').first().textContent();
return (shown ?? '').replace(/\s/g, '');
}
/**
* Crée un salarié et lui ouvre un accès.
*
* Chaque exécution le sien : le second facteur est porté par la personne, et
* deux passages qui se partageraient un compte se heurteraient au facteur posé
* par le précédent.
*/
async function createAccessibleEmployee(page: Page) {
const suffix = `${Date.now()}-mfa`;
const lastName = `Facteur${suffix}`;
const email = `facteur.${suffix}@exemple.test`;
await page.goto('/equipe');
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.getByLabel('Prénom').fill('Noé');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`MFA${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
// L'invitation est portée par l'onglet « Documents » de la fiche.
await page.goto(`${page.url()}/documents`);
await page
.locator('form')
.filter({ hasText: 'Adresse d’invitation' })
.getByRole('button')
.click();
const link = page.getByTestId('invitation-link');
await expect(link).toBeVisible();
const url = (await link.textContent())!.trim();
const guest = await page.context().browser()!.newContext({
storageState: undefined,
});
const guestPage = await guest.newPage();
await guestPage.goto(url);
await guestPage.getByLabel('Choisissez un mot de passe').fill(EMPLOYEE_PASSWORD);
await guestPage.getByLabel('Confirmez').fill(EMPLOYEE_PASSWORD);
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(guestPage.getByText(/Votre accès est actif/)).toBeVisible();
await guest.close();
return { email, lastName };
}
test('le mot de passe du propriétaire reste inchangé', async ({ page }) => {
// Garde-fou : les tests ci-dessus manipulent l'authentification, et une
// dérive silencieuse fermerait la suite entière au passage suivant.
await page.goto('/reglages/securite');
await expect(page.getByRole('heading', { name: 'Sécurité' })).toBeVisible();
expect(OWNER_PASSWORD).toBe('planflow-demo-2026');
});