Critère d'acceptation de WP-01 resté sans écran : « un rôle personnalisé créé par un client modifie effectivement l'accès, sans changement de code ». Le catalogue de capacités existait, l'autorisation s'y adossait déjà, mais seul le semis pouvait attribuer quoi que ce soit. Deux dangers opposés, et il faut se protéger des deux. L'escalade d'abord : on ne peut accorder qu'une capacité qu'on détient soi-même, sans quoi la première personne autorisée à éditer un rôle s'accorde l'accès aux rémunérations en trois clics et le catalogue ne sert plus à rien. Le retrait, lui, reste permis même sur une capacité qu'on n'a pas — réduire un droit n'a jamais élargi le sien, et l'interdire empêcherait de corriger un rôle trop large. Le verrouillage ensuite : au moins un rôle doit conserver la gestion des droits. La condition se juge sur l'ensemble des rôles après coup, non sur celui qu'on édite — ce qui compte n'est pas que ce rôle-ci garde la capacité, mais qu'un rôle la garde. Sans cela une organisation se fermerait dehors, et le seul recours serait une intervention en base. Un rôle naît sans aucune capacité : en hériter de celles de son créateur distribuerait des droits que personne n'a demandés. Le niveau propriétaire se délègue à part. Un rôle système ne se supprime pas, un rôle attribué non plus — ses membres se retrouveraient dehors sans que personne l'ait décidé pour eux. Le test va jusqu'au bout : créer le rôle, l'attribuer, se connecter, constater qu'un écran s'ouvre et qu'un autre reste fermé. S'arrêter à « la case est cochée » n'aurait rien dit du critère. Trois défauts d'isolation des tests corrigés au passage, tous du même genre — un état qui s'accumule d'une exécution à l'autre : - la file des congés désignait « la première ligne de ce salarié » pour son nettoyage, et tombait sur une ligne laissée par un passage antérieur ; - l'écran de conservation tronquait à quarante pièces triées par ancienneté, faisant disparaître les pièces échues derrière celles dont il n'y a rien à dire — corrigé dans le produit, pas seulement dans le test ; - le test de verrouillage de période consommait un mois par exécution sans jamais le rendre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
202 lines
8.4 KiB
TypeScript
202 lines
8.4 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
||
|
||
import { backdateDocument } from './support/db';
|
||
|
||
/**
|
||
* Durées de conservation — PLAN.md §12.5, matrice n° 21.
|
||
*
|
||
* Deux garanties opposées à tenir simultanément : ne rien purger faute de règle
|
||
* déclarée, et purger effectivement dès qu'une règle échue s'applique. Les
|
||
* traiter séparément laisserait passer une purge qui n'efface jamais rien.
|
||
*/
|
||
|
||
const PDF = Buffer.from('%PDF-1.4\n%%EOF\n', 'ascii');
|
||
|
||
/** Objet propre à chaque exécution : les politiques s'accumulent en base. */
|
||
function objectType(tag: string): string {
|
||
return `Test:${tag}:${Date.now()}`;
|
||
}
|
||
|
||
async function declarePolicy(
|
||
page: Page,
|
||
values: {
|
||
objectType: string;
|
||
durationMonths: string;
|
||
justification: string;
|
||
effectiveFrom: string;
|
||
},
|
||
) {
|
||
await page.goto('/reglages/conservation');
|
||
const form = page.locator('form').filter({ hasText: 'Justification' });
|
||
await form.getByLabel('Objet', { exact: true }).fill(values.objectType);
|
||
await form.getByLabel('Durée en mois').fill(values.durationMonths);
|
||
await form.getByLabel('En vigueur à partir du').fill(values.effectiveFrom);
|
||
await form.getByLabel('Justification').fill(values.justification);
|
||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||
}
|
||
|
||
test('l’écran affirme qu’aucune durée n’est appliquée par défaut', async ({
|
||
page,
|
||
}) => {
|
||
await page.goto('/reglages/conservation');
|
||
|
||
await expect(
|
||
page.getByRole('heading', { name: 'Durées de conservation' }),
|
||
).toBeVisible();
|
||
// La matrice interdit « cinq ans partout » : le dire à l'écran est ce qui
|
||
// empêche de le rétablir par inadvertance.
|
||
await expect(page.getByText(/aucune durée par défaut/i)).toBeVisible();
|
||
// Le journal d'audit doit survivre aux données qu'il décrit.
|
||
await expect(page.getByText(/journaux d’audit échappent à la purge/i)).toBeVisible();
|
||
});
|
||
|
||
test('une durée sans justification est refusée', async ({ page }) => {
|
||
await page.goto('/reglages/conservation');
|
||
|
||
const form = page.locator('form').filter({ hasText: 'Justification' });
|
||
await form.getByLabel('Objet', { exact: true }).fill(objectType('sans-motif'));
|
||
await form.getByLabel('Durée en mois').fill('12');
|
||
await form.getByLabel('En vigueur à partir du').fill('2026-01-01');
|
||
// Le `minLength` du navigateur bloquerait une saisie manifestement courte :
|
||
// on éprouve ici le contrôle serveur, avec une forme qui passe le premier —
|
||
// des espaces, que le serveur retire avant de mesurer.
|
||
await form.getByLabel('Justification').fill(' ');
|
||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||
|
||
// Une durée sans motif est une durée qu'on ne saura pas défendre.
|
||
await expect(form.getByText(/Justifiez la durée/)).toBeVisible();
|
||
});
|
||
|
||
test('une durée déclarée apparaît avec sa justification', async ({ page }) => {
|
||
const type = objectType('declare');
|
||
await declarePolicy(page, {
|
||
objectType: type,
|
||
durationMonths: '36',
|
||
justification: 'Décompte des jours de forfait : trois ans minimum.',
|
||
effectiveFrom: '2026-01-01',
|
||
});
|
||
|
||
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
|
||
await page.reload();
|
||
|
||
const row = page.getByRole('row', { name: new RegExp(type) });
|
||
await expect(row).toBeVisible();
|
||
await expect(row.getByText('36 mois')).toBeVisible();
|
||
await expect(row.getByText(/trois ans minimum/)).toBeVisible();
|
||
});
|
||
|
||
test('un point de départ non calculable est signalé comme tel', async ({
|
||
page,
|
||
}) => {
|
||
const type = objectType('depart');
|
||
await page.goto('/reglages/conservation');
|
||
const form = page.locator('form').filter({ hasText: 'Justification' });
|
||
await form.getByLabel('Objet', { exact: true }).fill(type);
|
||
await form.getByLabel('Durée en mois').fill('60');
|
||
await form.getByLabel('Point de départ').selectOption('employee_departure');
|
||
await form.getByLabel('En vigueur à partir du').fill('2026-01-01');
|
||
await form
|
||
.getByLabel('Justification')
|
||
.fill('Registre du personnel : cinq ans après le départ.');
|
||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
|
||
|
||
await page.reload();
|
||
// PlanFlow ne modèle pas de date de départ : le dire plutôt que de laisser
|
||
// croire que la politique s'applique.
|
||
const row = page.getByRole('row', { name: new RegExp(type) });
|
||
await expect(row.getByText('non calculable')).toBeVisible();
|
||
});
|
||
|
||
test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
|
||
// Le salarié et sa pièce sont propres à l'exécution : la purge est globale au
|
||
// compte, et une pièce partagée verrait son sort décidé par un autre test.
|
||
const suffix = `${Date.now()}-garde`;
|
||
await page.goto('/equipe');
|
||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||
await create.getByLabel('Prénom').fill('Garde');
|
||
await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`);
|
||
await create.getByLabel('Matricule').fill(`RET${suffix}`);
|
||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||
|
||
await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click();
|
||
const upload = page.locator('form').filter({ hasText: 'Déposer' });
|
||
await upload.getByLabel('Catégorie').selectOption('OTHER');
|
||
await upload
|
||
.getByLabel('Fichier')
|
||
.setInputFiles({
|
||
name: `sans-politique-${suffix}.pdf`,
|
||
mimeType: 'application/pdf',
|
||
buffer: PDF,
|
||
});
|
||
await upload.getByRole('button', { name: 'Déposer' }).click();
|
||
await expect(page.getByText(/déposé\./)).toBeVisible();
|
||
|
||
// La catégorie « Autre » n'est visée par aucune politique semée.
|
||
await expect(page.getByText(/échéance de conservation non fixée/)).toBeVisible();
|
||
|
||
await page.goto('/reglages/conservation');
|
||
await expect(
|
||
page.getByText(new RegExp(`sans-politique-${suffix}`)),
|
||
).toBeVisible();
|
||
await expect(
|
||
page
|
||
.locator('li')
|
||
.filter({ hasText: `sans-politique-${suffix}` })
|
||
.getByText('Aucune politique déclarée'),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test('une pièce échue est effectivement effacée', async ({ page }) => {
|
||
const suffix = `${Date.now()}-purge`;
|
||
const fileName = `a-purger-${suffix}.pdf`;
|
||
|
||
// Politique propre au test : viser une catégorie partagée ferait purger les
|
||
// pièces des autres exécutions.
|
||
await declarePolicy(page, {
|
||
objectType: 'Document:REGISTER',
|
||
durationMonths: '1',
|
||
justification: 'Durée courte, déclarée pour éprouver la purge.',
|
||
effectiveFrom: '2020-01-01',
|
||
});
|
||
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
|
||
|
||
await page.goto('/equipe');
|
||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||
await create.getByLabel('Prénom').fill('Purge');
|
||
await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`);
|
||
await create.getByLabel('Matricule').fill(`PUR${suffix}`);
|
||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||
|
||
await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click();
|
||
const dossier = page.url();
|
||
const upload = page.locator('form').filter({ hasText: 'Déposer' });
|
||
await upload.getByLabel('Catégorie').selectOption('REGISTER');
|
||
await upload
|
||
.getByLabel('Fichier')
|
||
.setInputFiles({ name: fileName, mimeType: 'application/pdf', buffer: PDF });
|
||
await upload.getByRole('button', { name: 'Déposer' }).click();
|
||
await expect(page.getByText(`« ${fileName} » déposé.`)).toBeVisible();
|
||
|
||
// Fraîchement déposée, elle n'est pas échue. Constaté sur le dossier, qui ne
|
||
// liste que les pièces de ce salarié : l'écran de conservation tronque, et la
|
||
// pièce d'un test neuf n'y figure pas tant qu'elle n'appelle aucune décision.
|
||
await expect(page.getByText(/conservée jusqu’au/)).toBeVisible();
|
||
|
||
// Seule l'échéance déclenche l'effacement, et elle se compte en mois.
|
||
await backdateDocument(fileName, 3);
|
||
await page.goto('/reglages/conservation');
|
||
await expect(
|
||
page.locator('li').filter({ hasText: fileName }).getByText('À purger'),
|
||
).toBeVisible();
|
||
|
||
await page.getByRole('button', { name: 'Purger les pièces échues' }).click();
|
||
await expect(page.getByText(/effacée/)).toBeVisible();
|
||
|
||
// Effacée du dossier, et le contenu avec.
|
||
await page.goto(dossier);
|
||
await expect(page.getByRole('link', { name: fileName })).toHaveCount(0);
|
||
});
|