Files
planflow/tests/e2e/invitation.spec.ts
T
Claude 874c3fcd80 Ouvrir un accès à un salarié par invitation
Jusqu'ici personne ne pouvait entrer dans l'application : le modèle Invitation
existait en base mais aucun code ne s'en servait, et le seul moyen d'obtenir un
compte était le jeu de données de démonstration.

Le lien vaut autant qu'un mot de passe le temps de sa validité, d'où trois
règles : sept jours, un seul usage, et une révocation possible sans attendre
l'expiration. Seule l'empreinte du jeton est conservée — renvoyer une
invitation émet donc un nouveau lien et invalide le précédent, ce qui est aussi
la bonne réponse à « il a perdu le message ». Deux liens vivants pour un même
accès, ce sont deux portes dont une seule est tracée comme ayant servi.

Le compte est porté par le lien lui-même, préfixé au secret. La table est
protégée par RLS, laquelle exige de connaître le compte avant toute lecture :
sans ce préfixe il aurait fallu ouvrir la politique aux requêtes sans compte —
c'est-à-dire la vider de son sens. Divulguer un identifiant opaque à qui est
membre du compte ne coûte rien.

Le lien est aussi rendu une fois, à l'écran de celui qui l'émet. Sans cela un
déploiement neuf ne peut inviter personne : configurer le serveur d'envoi
demande d'être connecté, et être connecté demande une invitation. Il n'est ni
conservé ni journalisé.

Deux cas à l'acceptation, un seul demande un mot de passe. Si aucun compte
n'existe pour l'adresse, il est créé ; s'il en existe un, le salarié est
rattaché sans qu'on touche à son mot de passe — détenir le lien prouve l'accès
à la boîte, ce qui suffit à rattacher un accès mais ne justifie pas de
réinitialiser l'authentification d'un compte existant. Pas de connexion
automatique non plus : un mot de passe qu'on vient de choisir se fixe en s'en
servant.

`members.invite` est une capacité distincte de `members.edit` : ouvrir un accès
n'est pas modifier un dossier, et tel client voudra confier l'un sans l'autre.

Deux défauts trouvés par les tests plutôt qu'en production : le contrôle qui
refuse un mot de passe contenant le nom du salarié laissait passer « riviere »
pour « Rivière » faute de replier les accents — soit exactement la variante
qu'on tape au clavier ; et les contextes « visiteur » des tests héritaient de
la session du responsable, si bien que le parcours anonyme n'était pas éprouvé.

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

205 lines
8.0 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';
/**
* Entrée d'un salarié dans l'application.
*
* C'est le seul chemin d'accès : sans lui, personne ne peut se connecter à un
* déploiement neuf. Le parcours complet est éprouvé — invitation, choix du mot
* de passe, puis connexion réelle avec ce mot de passe.
*
* Chaque test crée **son** salarié : l'état d'une invitation est porté par la
* personne, et deux tests qui se partageraient un salarié se marcheraient
* dessus au deuxième passage.
*/
const password = 'les mesanges du matin';
async function createEmployee(page: Page, tag: string) {
const suffix = `${Date.now()}-${tag}`;
const firstName = 'Sacha';
const lastName = `Test${suffix}`;
const email = `sacha.${suffix}@exemple.test`;
await page.goto('/equipe');
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.getByLabel('Prénom').fill(firstName);
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`E2E${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
await form.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
const row = page.getByRole('link', { name: new RegExp(lastName) });
await expect(row).toBeVisible();
await row.click();
await expect(
page.getByRole('heading', { name: `${firstName} ${lastName}` }),
).toBeVisible();
return { firstName, lastName, email, url: page.url() };
}
function panel(page: Page) {
return page.locator('form').filter({ hasText: 'Adresse d’invitation' });
}
/** Le libellé bascule de « Inviter » à « Renvoyer » : viser le rôle, pas le mot. */
function inviteButton(page: Page) {
return panel(page).getByRole('button');
}
/** Le lien n'est rendu qu'une fois, après l'émission. */
function shownLink(page: Page) {
return page.getByTestId('invitation-link');
}
test('un salarié invité choisit son mot de passe et se connecte', async ({
page,
browser,
}) => {
const employee = await createEmployee(page, 'complet');
await inviteButton(page).click();
// Le lien est rendu une fois à l'émetteur : sans serveur d'envoi configuré,
// c'est le seul moyen d'amorcer un déploiement neuf.
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
// Navigateur vierge. `storageState: undefined` est indispensable : sans lui
// le contexte hérite de la session du projet, et le test se déroulerait
// sous l'identité du responsable au lieu de celle du destinataire.
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(url);
await expect(guestPage.getByText(employee.email)).toBeVisible();
await guestPage.getByLabel('Choisissez un mot de passe').fill(password);
await guestPage.getByLabel('Confirmez').fill(password);
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(guestPage.getByText(/Votre accès est actif/)).toBeVisible();
// La preuve que l'accès existe n'est pas le message de confirmation, c'est
// une connexion réussie avec le mot de passe qui vient d'être choisi.
await guestPage.goto('/connexion');
await guestPage.getByLabel('Adresse électronique').fill(employee.email);
await guestPage.getByLabel('Mot de passe').fill(password);
await guestPage.getByRole('button', { name: 'Se connecter' }).click();
await expect(guestPage.getByRole('heading').first()).toBeVisible();
await guest.close();
});
test('un lien d’invitation ne sert qu’une fois', async ({ page, browser }) => {
const employee = await createEmployee(page, 'unique');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
const first = await browser.newContext({ storageState: undefined });
const firstPage = await first.newPage();
await firstPage.goto(url);
await firstPage.getByLabel('Choisissez un mot de passe').fill(password);
await firstPage.getByLabel('Confirmez').fill(password);
await firstPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(firstPage.getByText(/Votre accès est actif/)).toBeVisible();
await first.close();
// Rejouer le lien ne doit rien rouvrir : un message transféré ou une boîte
// compromise ne donnerait sinon un second accès au même compte.
const second = await browser.newContext({ storageState: undefined });
const secondPage = await second.newPage();
await secondPage.goto(url);
await expect(secondPage.getByText('Invitation acceptée')).toBeVisible();
await expect(
secondPage.getByLabel('Choisissez un mot de passe'),
).toHaveCount(0);
await second.close();
expect(employee.email).toContain('@');
});
test('renvoyer une invitation invalide la précédente', async ({
page,
browser,
}) => {
await createEmployee(page, 'renvoi');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const first = (await shownLink(page).textContent())!.trim();
await inviteButton(page).click();
// Le second lien remplace le premier à l'écran : attendre qu'il ait changé
// plutôt que de lire trop tôt.
await expect(shownLink(page)).not.toHaveText(first);
const second = (await shownLink(page).textContent())!.trim();
expect(second).not.toBe(first);
// Deux liens vivants pour un même accès, c'est deux portes dont une seule
// est tracée comme ayant servi.
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(first);
await expect(guestPage.getByText('Invitation révoquée')).toBeVisible();
await guest.close();
});
test('une invitation révoquée refuse le lien', async ({ page, browser }) => {
await createEmployee(page, 'revoc');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
await page.getByRole('button', { name: 'Révoquer l’invitation' }).click();
await expect(page.getByText(/Le lien ne fonctionne plus/)).toBeVisible();
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(url);
await expect(guestPage.getByText('Invitation révoquée')).toBeVisible();
await guest.close();
});
test('un lien fabriqué est refusé sans rien révéler', async ({ browser }) => {
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
// L'identifiant de compte est porté par le lien ; un identifiant inventé ne
// doit pas produire d'erreur serveur, seulement un refus.
await guestPage.goto('/invitation/cmxxxxxxxxxxxxxxxxxxxxxx.jeton-invente');
await expect(guestPage.getByText(/n’est pas valide/)).toBeVisible();
await guestPage.goto('/invitation/sans-separateur');
await expect(guestPage.getByText(/n’est pas valide/)).toBeVisible();
await guest.close();
});
test('un mot de passe contenant le nom du salarié est refusé', async ({
page,
browser,
}) => {
const employee = await createEmployee(page, 'motdepasse');
await inviteButton(page).click();
await expect(shownLink(page)).toBeVisible();
const url = (await shownLink(page).textContent())!.trim();
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto(url);
const weak = `${employee.lastName}-2026-abcdef`;
await guestPage.getByLabel('Choisissez un mot de passe').fill(weak);
await guestPage.getByLabel('Confirmez').fill(weak);
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(guestPage.getByText(/ne doit pas contenir votre nom/)).toBeVisible();
// Le refus ne consomme pas l'invitation : le formulaire reste utilisable.
await expect(guestPage.getByLabel('Choisissez un mot de passe')).toBeVisible();
await guest.close();
});