Files
planflow/tests/e2e/roles.spec.ts
T
Claude 72809cefb6 Créer le rôle applicatif à chaque démarrage, pas seulement au premier
Les migrations échouaient sur « Authentication failed for planflow_app ». Le
script qui crée ce rôle était monté dans /docker-entrypoint-initdb.d, lequel ne
s'exécute qu'à la **toute première** initialisation du volume : une pile dont le
volume existait déjà — créé par une tentative antérieure — n'a jamais vu passer
le rôle. Je l'avais noté dans le README au lieu de le traiter.

Un service `db-init` rejoue désormais le script à chaque démarrage, et le script
est rendu rejouable : il crée le rôle ou aligne son mot de passe, transfère la
propriété de la base, du schéma et des objets déjà présents. Les identifiants du
superutilisateur restent dans ce service ; les confier au conteneur applicatif
lui donnerait de quoi contourner la row-level security, ce que tout ce montage
cherche à empêcher.

Éprouvé sur une base créée par le superutilisateur, comme celle du client :
rôle posé en NOSUPERUSER NOBYPASSRLS, propriétaire de la base et des tables
préexistantes, capable de se connecter et d'exécuter du DDL ; les 22 migrations
s'appliquent ; la RLS le filtre — deux comptes visibles par le superutilisateur,
zéro par lui hors périmètre. Rejoué une seconde fois sans effet de bord.

Non vérifiable ici : l'authentification par mot de passe, mon cluster de
développement étant en mode `trust`.

Corrigé au passage une interférence entre tests : celui qui retire « Gérer les
rôles » aux rôles semés, pour atteindre le refus de verrouillage, s'exécutait en
parallèle de ceux qui s'appuient sur cette capacité et les faisait échouer par
intermittence. Le fichier passe en série.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 15:50:23 +00:00

210 lines
8.2 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 { slugifyRoleKey } from '../../src/domain/access/role-editing';
/**
* Rôles et permissions — critère d'acceptation de WP-01.
*
* « Un rôle personnalisé créé par un client modifie effectivement l'accès, sans
* changement de code. » Le vérifier suppose d'aller jusqu'au bout : créer le
* rôle, l'attribuer, et constater qu'un écran s'ouvre ou se ferme en
* conséquence. Un test qui s'arrêterait à « la case est cochée » ne dirait rien.
*
* En série, et il le faut : l'un de ces tests retire momentanément « Gérer les
* rôles » aux rôles semés pour atteindre le refus de verrouillage. Joué en
* parallèle des autres, qui s'appuient sur cette même capacité, il les fait
* échouer par intermittence — et la cause n'est visible nulle part.
*/
test.describe.configure({ mode: 'serial' });
const OWNER_PASSWORD = 'planflow-demo-2026';
function roleName(tag: string): string {
return `Rôle ${tag} ${Date.now()}`;
}
/**
* Carte d'un rôle, par sa **clé**.
*
* Ni par son nom ni par un texte : les libellés de capacités contiennent
* « propriétaire », et une recherche textuelle — insensible à la casse —
* désignerait plusieurs cartes. La clé d'un rôle semé n'est d'ailleurs pas
* dérivée de son nom : « Propriétaire » porte la clé `owner`.
*/
function roleCard(page: Page, key: string) {
return page.getByTestId(`role-${key}`);
}
test('l’écran énonce les deux garde-fous', async ({ page }) => {
await page.goto('/reglages/roles');
await expect(
page.getByRole('heading', { name: 'Rôles et permissions' }),
).toBeVisible();
await expect(
page.getByText(/accorder que les capacités que vous détenez/),
).toBeVisible();
await expect(page.getByText(/Au moins un rôle doit conserver/)).toBeVisible();
});
test('un rôle naît sans aucune capacité', async ({ page }) => {
const name = roleName('vierge');
await page.goto('/reglages/roles');
await page.getByLabel('Nom du rôle').fill(name);
await page.getByRole('button', { name: 'Créer' }).click();
// Un rôle neuf qui hériterait des capacités de son créateur distribuerait des
// droits que personne n'a demandés.
await expect(page.getByText(/sans aucune capacité/)).toBeVisible();
await page.reload();
const card = roleCard(page, slugifyRoleKey(name));
await expect(card.getByText('0 capacité')).toBeVisible();
});
test('un rôle système ne s’efface pas', async ({ page }) => {
await page.goto('/reglages/roles');
const card = roleCard(page, 'owner');
await expect(card.getByText('Rôle système')).toBeVisible();
// Le code et le semis y font référence par sa clé.
await expect(page.getByTestId('role-delete-owner')).toHaveCount(0);
});
test('retirer la dernière gestion des droits est refusé', async ({ page }) => {
await page.goto('/reglages/roles');
// `owner` et `admin` sont les seuls rôles semés à porter la capacité : il
// faut la retirer aux deux pour atteindre le refus.
const admin = roleCard(page, 'admin');
await admin.getByRole('checkbox', { name: /Gérer les rôles/ }).uncheck();
await admin.getByRole('button', { name: 'Enregistrer' }).click();
await expect(admin.getByText('Capacités enregistrées.')).toBeVisible();
await page.reload();
const owner = roleCard(page, 'owner');
await owner.getByRole('checkbox', { name: /Gérer les rôles/ }).uncheck();
await owner.getByRole('button', { name: 'Enregistrer' }).click();
// Le second retrait est celui qui fermerait la porte.
await expect(
page.getByText(/fermerait la gestion des droits à tout le monde/),
).toBeVisible();
// Remettre la capacité : la suite de tests dépend de son existence.
await page.reload();
const restored = roleCard(page, 'admin');
await restored.getByRole('checkbox', { name: /Gérer les rôles/ }).check();
await restored.getByRole('button', { name: 'Enregistrer' }).click();
await expect(restored.getByText('Capacités enregistrées.')).toBeVisible();
});
test('un rôle client change effectivement l’accès', async ({
page,
browser,
}) => {
test.setTimeout(90_000);
const name = roleName('acces');
await page.goto('/reglages/roles');
await page.getByLabel('Nom du rôle').fill(name);
await page.getByRole('button', { name: 'Créer' }).click();
await expect(page.getByText(/sans aucune capacité/)).toBeVisible();
await page.reload();
// Une seule capacité : voir l'annuaire. Rien d'autre.
const card = roleCard(page, slugifyRoleKey(name));
await card.getByRole('checkbox', { name: 'Voir l’annuaire' }).check();
await card.getByRole('button', { name: 'Enregistrer' }).click();
await expect(card.getByText('Capacités enregistrées.')).toBeVisible();
const account = await inviteWithRole(page, name);
const guest = await browser.newContext({ storageState: undefined });
const guestPage = await guest.newPage();
await guestPage.goto('/connexion');
await guestPage.getByLabel('Adresse électronique').fill(account.email);
await guestPage.getByLabel('Mot de passe').fill(account.password);
await guestPage.getByRole('button', { name: 'Se connecter' }).click();
await guestPage.waitForURL((url) => !url.pathname.endsWith('/connexion'));
// Ce que le rôle accorde s'ouvre.
await guestPage.goto('/equipe');
await expect(guestPage.getByRole('heading', { name: 'Équipe' })).toBeVisible();
// Ce qu'il n'accorde pas reste fermé — c'est la moitié qui compte.
await guestPage.goto('/reglages/roles');
await expect(
guestPage.getByRole('heading', { name: 'Rôles et permissions' }),
).toHaveCount(0);
await guest.close();
});
/**
* Crée un salarié, lui attribue le rôle donné en base de l'annuaire, et ouvre
* son accès.
*
* Le rôle se choisit au moment de l'invitation faute d'écran d'affectation :
* l'attribution passe donc par la création, qui pose le rôle « employee », puis
* par une bascule directe — le test vise l'effet du rôle, pas le chemin qui y
* mène.
*/
async function inviteWithRole(page: Page, roleLabel: string) {
const suffix = `${Date.now()}-role`;
const email = `role.${suffix}@exemple.test`;
const password = 'les hirondelles de mars';
const lastName = `Role${suffix}`;
await page.goto('/equipe');
const create = page.locator('form').filter({ hasText: 'Ajouter' });
await create.getByLabel('Prénom').fill('Alix');
await create.getByLabel('Nom', { exact: true }).fill(lastName);
await create.getByLabel('Matricule').fill(`ROL${suffix}`);
await create.getByLabel('Adresse électronique').fill(email);
await create.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
// Attendre la fiche avant de lire l'URL : lue trop tôt, elle vaut encore
// celle de l'annuaire et l'identifiant récupéré ne désigne personne.
await expect(
page.getByRole('heading', { name: new RegExp(lastName) }),
).toBeVisible();
const membershipId = page.url().split('/').pop()!;
const { assignRole } = await import('./support/db');
await assignRole(membershipId, roleLabel);
await page
.locator('form')
.filter({ hasText: 'Adresse d’invitation' })
.getByRole('button')
.click();
const url = (await page.getByTestId('invitation-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(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();
await guest.close();
return { email, password, membershipId };
}
test('le mot de passe de démonstration reste celui attendu', async ({
page,
}) => {
// Garde-fou : ces tests remanient les droits, et une dérive fermerait la
// suite entière au passage suivant.
await page.goto('/reglages/roles');
await expect(page.getByRole('heading', { name: 'Rôles et permissions' })).toBeVisible();
expect(OWNER_PASSWORD).toBe('planflow-demo-2026');
});