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:
Claude committed 2026-08-07 22:45:24 +00:00
1 parent 11763142d5
commit 2c0e9e8dd4
12 files changed
+361 -19

No files matched your search

+19
View File
@@ -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 });
});
+58
View File
@@ -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$/);
});
+7
View File
@@ -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';