Déclarer et appliquer les durées de conservation
La table RetentionPolicy existait et cinq durées y étaient semées depuis la matrice ; rien ne les appliquait. Une durée déclarée que personne n'exécute est une conformité de papier. Aucune durée par défaut n'est appliquée, et c'est le point central : la matrice interdit explicitement d'aligner tout sur cinq ans. Un objet sans politique déclarée se conserve, et l'écran le signale plutôt que de le taire. Symétrie inverse, tout aussi importante : effacer faute de règle serait aussi fautif que garder indéfiniment. La justification est obligatoire au niveau du serveur. Une durée sans motif est une durée qu'on ne saura pas défendre le jour d'un contrôle. Les politiques sont effectif-datées comme le reste de l'application : une pièce déposée en mars relève de la règle en vigueur en mars. Sans cela, un durcissement rétroactif purgerait ce que la règle du moment autorisait à garder. La résolution va du précis au général — Document:SICK_NOTE avant Document — car un arrêt de travail et un contrat n'ont aucune raison de se conserver aussi longtemps. Trois refus distincts plutôt qu'un seul : absence de politique, conservation suspendue à titre probatoire, échéance non atteinte. Les confondre sous « rien à purger » empêcherait de vérifier que la conservation est réellement tenue. Un quatrième existe : employee_departure est déclaré mais non calculable, PlanFlow ne modélisant pas de date de départ — purger sur une date inventée serait pire que ne pas purger, et l'écran l'affiche comme tel. La purge s'exécute en ligne de commande pour une tâche planifiée, la matrice demandant des purges automatiques ; un bouton qu'il faut penser à presser n'en est pas une. Elle passe par le client scopé et la RLS, compte par compte. Les tables append-only en sont exclues par construction : le journal d'audit doit survivre aux données qu'il décrit, sans quoi on ne pourrait plus démontrer que la purge a eu lieu. L'échéance affichée est dérivée de la politique, jamais stockée — même discipline que la péremption d'un export. Deux pièges rencontrés : un objet de composants exporté depuis un module client ne survit pas au passage par un composant serveur, React n'en recevant qu'un undefined ; et le minLength du navigateur masquait le contrôle serveur de la justification, que des espaces suffisent à contourner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
13 files changed
+1371
-6
No files matched your search
@@ -0,0 +1,202 @@
|
||||
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 : la purge ne doit pas l'emporter.
|
||||
await page.goto('/reglages/conservation');
|
||||
await expect(
|
||||
page.locator('li').filter({ hasText: fileName }).getByText('Échéance non atteinte'),
|
||||
).toBeVisible();
|
||||
|
||||
// Seule l'échéance déclenche l'effacement, et elle se compte en mois.
|
||||
await backdateDocument(fileName, 3);
|
||||
await page.reload();
|
||||
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);
|
||||
});
|
||||
@@ -33,3 +33,32 @@ export async function resetMfa(email: string): Promise<void> {
|
||||
});
|
||||
await db().mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Recule la date de dépôt d'une pièce.
|
||||
*
|
||||
* Aucune interface ne permet d'antidater, et c'est bien ainsi. Sans ce levier,
|
||||
* la moitié utile de la purge — celle qui efface — resterait invérifiable :
|
||||
* seule l'échéance atteinte la déclenche, et elle se compte en mois.
|
||||
*
|
||||
* La transaction pose `app.account_id` : la table est protégée par RLS, et une
|
||||
* mise à jour sans compte courant ne toucherait aucune ligne — en silence.
|
||||
*/
|
||||
export async function backdateDocument(
|
||||
name: string,
|
||||
months: number,
|
||||
): Promise<void> {
|
||||
const document = await db().$queryRaw<Array<{ id: string; accountId: string }>>`
|
||||
SELECT id, "accountId" FROM "Document" WHERE name = ${name} LIMIT 1
|
||||
`;
|
||||
const found = document[0];
|
||||
if (!found) throw new Error(`Pièce introuvable : ${name}`);
|
||||
|
||||
const uploadedAt = new Date();
|
||||
uploadedAt.setMonth(uploadedAt.getMonth() - months);
|
||||
|
||||
await db().$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.account_id', ${found.accountId}, true)`;
|
||||
await tx.$executeRaw`UPDATE "Document" SET "uploadedAt" = ${uploadedAt} WHERE id = ${found.id}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applicablePolicy,
|
||||
dueAt,
|
||||
isComputable,
|
||||
purgeVerdict,
|
||||
resolvePolicy,
|
||||
START_POINT_LABELS,
|
||||
START_POINTS,
|
||||
VERDICT_LABELS,
|
||||
type RetentionPolicyLike,
|
||||
} from '@/domain/retention/policy';
|
||||
|
||||
function policy(
|
||||
overrides: Partial<RetentionPolicyLike> = {},
|
||||
): RetentionPolicyLike {
|
||||
return {
|
||||
objectType: 'Document',
|
||||
durationMonths: 12,
|
||||
startPoint: 'creation',
|
||||
legalHold: false,
|
||||
effectiveFrom: new Date('2026-01-01T00:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('échéance', () => {
|
||||
it('compte de date à date', () => {
|
||||
expect(dueAt(new Date('2026-03-15T00:00:00Z'), 12)).toEqual(
|
||||
new Date('2027-03-15T00:00:00Z'),
|
||||
);
|
||||
expect(dueAt(new Date('2026-03-15T00:00:00Z'), 60)).toEqual(
|
||||
new Date('2031-03-15T00:00:00Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('ne déborde pas sur le mois suivant', () => {
|
||||
// 31 janvier + 1 mois n'est pas le 3 mars : sans bornage, une pièce
|
||||
// déposée en fin de mois se purgerait un jour trop tard, tous les mois.
|
||||
expect(dueAt(new Date('2026-01-31T00:00:00Z'), 1)).toEqual(
|
||||
new Date('2026-02-28T00:00:00Z'),
|
||||
);
|
||||
expect(dueAt(new Date('2026-08-31T00:00:00Z'), 1)).toEqual(
|
||||
new Date('2026-09-30T00:00:00Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('tient compte des années bissextiles', () => {
|
||||
expect(dueAt(new Date('2028-01-31T00:00:00Z'), 1)).toEqual(
|
||||
new Date('2028-02-29T00:00:00Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('conserve l’heure de l’ancrage', () => {
|
||||
expect(dueAt(new Date('2026-03-15T14:30:00Z'), 3)).toEqual(
|
||||
new Date('2026-06-15T14:30:00Z'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('politique applicable', () => {
|
||||
const older = policy({
|
||||
durationMonths: 12,
|
||||
effectiveFrom: new Date('2025-01-01T00:00:00Z'),
|
||||
});
|
||||
const newer = policy({
|
||||
durationMonths: 60,
|
||||
effectiveFrom: new Date('2026-06-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
it('retient la plus récente en vigueur', () => {
|
||||
expect(
|
||||
applicablePolicy([older, newer], 'Document', new Date('2026-07-01Z')),
|
||||
).toBe(newer);
|
||||
});
|
||||
|
||||
it('ignore celle qui n’est pas encore en vigueur', () => {
|
||||
// Effectif-daté : une pièce déposée en mars relève de la règle de mars.
|
||||
// Sans cela, un durcissement rétroactif purgerait ce que la règle du moment
|
||||
// autorisait à garder.
|
||||
expect(
|
||||
applicablePolicy([older, newer], 'Document', new Date('2026-03-01Z')),
|
||||
).toBe(older);
|
||||
});
|
||||
|
||||
it('ne rend rien quand aucune ne vise l’objet', () => {
|
||||
expect(
|
||||
applicablePolicy([older], 'Shift', new Date('2026-07-01Z')),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('résolution du plus précis au plus général', () => {
|
||||
const general = policy({ objectType: 'Document', durationMonths: 60 });
|
||||
const precise = policy({
|
||||
objectType: 'Document:SICK_NOTE',
|
||||
durationMonths: 6,
|
||||
});
|
||||
|
||||
it('préfère l’objet précis', () => {
|
||||
// Un arrêt de travail et un contrat n'ont aucune raison de se conserver
|
||||
// aussi longtemps.
|
||||
const found = resolvePolicy(
|
||||
[general, precise],
|
||||
['Document:SICK_NOTE', 'Document'],
|
||||
new Date('2026-07-01Z'),
|
||||
);
|
||||
expect(found?.durationMonths).toBe(6);
|
||||
});
|
||||
|
||||
it('retombe sur le général', () => {
|
||||
const found = resolvePolicy(
|
||||
[general, precise],
|
||||
['Document:IDENTITY', 'Document'],
|
||||
new Date('2026-07-01Z'),
|
||||
);
|
||||
expect(found?.durationMonths).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('décision de purge', () => {
|
||||
const anchor = new Date('2026-01-15T00:00:00Z');
|
||||
|
||||
it('refuse en l’absence de politique', () => {
|
||||
// Aucune durée par défaut : effacer faute de règle serait aussi fautif que
|
||||
// garder indéfiniment.
|
||||
expect(
|
||||
purgeVerdict({ policy: null, anchor, now: new Date('2099-01-01Z') }),
|
||||
).toBe('NO_POLICY');
|
||||
});
|
||||
|
||||
it('purge une fois l’échéance atteinte', () => {
|
||||
expect(
|
||||
purgeVerdict({
|
||||
policy: policy({ durationMonths: 12 }),
|
||||
anchor,
|
||||
now: new Date('2027-01-15T00:00:00Z'),
|
||||
}),
|
||||
).toBe('DUE');
|
||||
});
|
||||
|
||||
it('attend l’échéance à la journée près', () => {
|
||||
expect(
|
||||
purgeVerdict({
|
||||
policy: policy({ durationMonths: 12 }),
|
||||
anchor,
|
||||
now: new Date('2027-01-14T23:59:59Z'),
|
||||
}),
|
||||
).toBe('NOT_DUE');
|
||||
});
|
||||
|
||||
it('suspend malgré une échéance largement dépassée', () => {
|
||||
// Un contentieux impose de garder : la suspension prime sur la durée.
|
||||
expect(
|
||||
purgeVerdict({
|
||||
policy: policy({ legalHold: true }),
|
||||
anchor,
|
||||
now: new Date('2099-01-01Z'),
|
||||
}),
|
||||
).toBe('HELD');
|
||||
});
|
||||
|
||||
it('refuse un point de départ que le code ne sait pas situer', () => {
|
||||
// PlanFlow ne modèle pas de date de départ : purger sur une date inventée
|
||||
// serait pire que ne pas purger.
|
||||
expect(
|
||||
purgeVerdict({
|
||||
policy: policy({ startPoint: 'employee_departure' }),
|
||||
anchor,
|
||||
now: new Date('2099-01-01Z'),
|
||||
}),
|
||||
).toBe('NOT_COMPUTABLE');
|
||||
});
|
||||
|
||||
it('refuse quand l’ancrage manque', () => {
|
||||
expect(
|
||||
purgeVerdict({ policy: policy(), anchor: null, now: new Date() }),
|
||||
).toBe('NOT_COMPUTABLE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogue', () => {
|
||||
it('nomme chaque point de départ', () => {
|
||||
for (const point of START_POINTS) {
|
||||
expect(START_POINT_LABELS[point]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('distingue calculable et déclaré', () => {
|
||||
expect(isComputable('creation')).toBe(true);
|
||||
expect(isComputable('employee_departure')).toBe(false);
|
||||
});
|
||||
|
||||
it('motive chaque verdict', () => {
|
||||
// « Rien à purger » sans distinguer l'absence de politique, la suspension
|
||||
// et l'échéance non atteinte empêcherait de vérifier la conservation.
|
||||
for (const verdict of Object.keys(VERDICT_LABELS)) {
|
||||
expect(VERDICT_LABELS[verdict as keyof typeof VERDICT_LABELS]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user