Wire authentication into the application
Adds the sign-in screen, sign-out, and a server-side guard on every application route. The guard lives in the layout rather than the proxy because the proxy cannot query the database to check whether a session was revoked — and revocation is the reason sessions are stored there. Sign-in returns one message for an unknown account and for a wrong password, and verifies a dummy hash when the account does not exist, so neither the wording nor the timing enumerates staff addresses. An end-to-end test compares the two messages rather than trusting the code to keep them aligned. The shell now shows the signed-in person and their role from the database instead of hardcoded initials. Playwright signs in once in a setup project and shares the cookie; argon2 is deliberately slow, and logging in per test would also drive the shared failed-attempt counter toward a lockout. The seed resets that counter so repeated local runs cannot lock the demo account. Two test locators had to be scoped to the form: Next's route announcer carries role="alert" and an empty string, which silently satisfied the assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
12 files changed
+361
-19
No files matched your search
@@ -0,0 +1,19 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
import { STORAGE_STATE } from './storage';
|
||||
|
||||
/**
|
||||
* Ouvre une session une fois et enregistre le cookie pour les autres tests.
|
||||
*
|
||||
* Chaque test se connecterait sinon, ce qui coûterait un argon2 par test —
|
||||
* volontairement lent — et ferait grimper le compteur d'échecs partagé.
|
||||
*/
|
||||
setup('authentifie la direction', async ({ page }) => {
|
||||
await page.goto('/connexion');
|
||||
await page.getByLabel('Adresse électronique').fill('direction@example.test');
|
||||
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
|
||||
await page.context().storageState({ path: STORAGE_STATE });
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
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 }) => {
|
||||
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();
|
||||
|
||||
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 33/ }),
|
||||
).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$/);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Emplacement de l'état de session partagé entre les tests.
|
||||
*
|
||||
* Dans son propre module : le fichier de configuration Playwright ne peut pas
|
||||
* importer un fichier qui appelle `test()`.
|
||||
*/
|
||||
export const STORAGE_STATE = 'test-results/.auth/direction.json';
|
||||
Reference in new issue
Block a user