Files
planflow/tests/integration/compliance.test.ts
T
Claude ef2868b980 Rendre le déploiement Docker correct, et la RLS réellement active
Trois problèmes, dont un grave, trouvés en corrigeant un échec de déploiement.

**Le compose connectait l'application en superutilisateur PostgreSQL.** Un
superutilisateur contourne toute politique de sécurité au niveau ligne, y
compris déclarée en FORCE : la seconde couche d'isolation était présente en
base et absente des faits. Le README l'interdisait déjà noir sur blanc ; le
chemin de déploiement que nous livrons faisait exactement l'inverse. Un script
d'initialisation crée désormais un rôle `planflow_app` NOSUPERUSER NOBYPASSRLS,
propriétaire de la base — il lui faut ce droit pour migrer, et les politiques
sont en FORCE précisément pour s'appliquer aussi au propriétaire. Mesuré : en
superutilisateur, deux lignes visibles sans compte courant ; avec le rôle
dédié, zéro.

Basculer la base de développement sur ce même rôle a révélé le défaut que le
superutilisateur masquait : `resolveSession` lisait `Membership`, table filtrée
par compte, sans périmètre. Avec la RLS active, plus personne ne pouvait se
connecter. La résolution passe maintenant par une porte étroite — une politique
qui n'ouvre que les lignes dont l'utilisateur est titulaire, sous `app.user_id`
— le temps de trouver le compte, puis repasse par le périmètre ordinaire. La
suite de tests traverse enfin la RLS au lieu de la contourner.

**Les pièces du dossier salarié n'avaient aucun volume.** Elles étaient écrites
dans la couche du conteneur et disparaissaient au premier redéploiement. Une
pièce d'identité perdue ne se reconstitue pas.

Le reste répond à la demande : Postgres préconfiguré — la base n'étant ni
publiée ni attachée au réseau du proxy, ce mot de passe protège d'un conteneur
voisin, pas d'Internet — réseau `nginx_default` déclaré externe avec
l'application seule dessus, et port publié peu courant.

ENCRYPTION_KEY reste la seule variable sans valeur par défaut, et n'en aura
pas : elle chiffre le NIR, l'IBAN et les arrêts de travail. Une clé livrée avec
l'image serait connue de quiconque lit ce dépôt.

Au démarrage, l'application contrôle ses propres privilèges : elle refuse de se
lancer si la base porte plus d'un compte, et se contente d'un avertissement
s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de
l'entreprise à ses données pour une fuite entre clients qui ne peut pas se
produire.

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

234 lines
7.9 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { adminPrisma } from './admin-db';
import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517';
import { zonedInstant } from '@/domain/planning/week';
/**
* Le moteur branché sur la base — PLAN.md §12.2.
*
* Ce que ces tests vérifient, et que les tests de règles ne peuvent pas
* vérifier : qu'une semaine ancienne reste évaluée avec les paramètres qui
* s'appliquaient **alors**. C'est l'exigence n° 1 de la matrice — reproduire à
* l'identique une paie antérieure après un changement de règle — et elle ne se
* démontre qu'avec deux versions coexistantes en base.
*/
const enabled = (process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '').length > 0;
const describeIfDb = enabled ? describe : describe.skip;
const TZ = 'Europe/Paris';
const suffix = `cmp-${Date.now()}`;
const accountId = `${suffix}-account`;
const locationId = `${suffix}-loc`;
const teamId = `${suffix}-team`;
const membershipId = `${suffix}-member`;
let agreementFor: typeof import('@/server/compliance/evaluate').agreementFor;
let evaluateSchedule: typeof import('@/server/compliance/evaluate').evaluateSchedule;
let withTenant: typeof import('@/server/tenant').withTenant;
/** 10 h de travail : conforme à 10 h, non conforme à 8 h. */
function tenHourShift(scheduleId: string, date: string) {
return {
accountId,
weeklyScheduleId: scheduleId,
membershipId,
localDate: new Date(`${date}T00:00:00Z`),
startAt: zonedInstant(date, '08:00', TZ),
endAt: zonedInstant(date, '18:00', TZ),
breakMinutes: 0,
};
}
describeIfDb('moteur de conformité en base', () => {
let marchSchedule = '';
let augustSchedule = '';
beforeAll(async () => {
process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
({ agreementFor, evaluateSchedule } = await import(
'@/server/compliance/evaluate'
));
({ withTenant } = await import('@/server/tenant'));
const db = adminPrisma();
await db.account.create({
data: { id: accountId, name: `Compte ${suffix}` },
});
await db.location.create({
data: {
id: locationId,
accountId,
name: 'Établissement de test',
timezone: TZ,
employerContributionRate: 0,
},
});
await db.team.create({
data: { id: teamId, accountId, locationId, name: 'Vente' },
});
const role = await db.role.create({
data: { accountId, key: 'employee', name: 'Employé' },
});
await db.membership.create({
data: {
id: membershipId,
accountId,
roleId: role.id,
employeeNumber: 'T0001',
status: 'ACTIVE',
},
});
await db.employeeProfile.create({
data: { membershipId, accountId, firstName: 'Test', lastName: 'Salarié' },
});
await db.userContract.create({
data: {
accountId,
membershipId,
locationId,
contractType: 'CDI',
startDate: new Date('2024-01-01'),
workTimeArrangement: 'HOURLY',
weeklyHours: 35,
},
});
await db.teamMember.create({ data: { accountId, teamId, membershipId } });
// Deux versions coexistantes. La v2 durcit la durée quotidienne et ne
// prend effet qu'au 1er juillet.
await db.collectiveAgreement.create({
data: {
accountId,
idcc: '1517',
name: 'Version initiale',
parameters: IDCC_1517_PARAMETERS,
version: 1,
effectiveFrom: new Date('2026-01-01'),
},
});
await db.collectiveAgreement.create({
data: {
accountId,
idcc: '1517',
name: 'Version durcie',
parameters: { ...IDCC_1517_PARAMETERS, maxDailyWorkMinutes: 8 * 60 },
version: 2,
effectiveFrom: new Date('2026-07-01'),
},
});
// Semaine 11 de 2026 (9 mars) et semaine 33 (10 août) : mêmes créneaux.
const march = await db.weeklySchedule.create({
data: { accountId, teamId, locationId, isoYear: 2026, isoWeek: 11 },
});
const august = await db.weeklySchedule.create({
data: { accountId, teamId, locationId, isoYear: 2026, isoWeek: 33 },
});
marchSchedule = march.id;
augustSchedule = august.id;
await db.shift.create({ data: tenHourShift(march.id, '2026-03-09') });
await db.shift.create({ data: tenHourShift(august.id, '2026-08-10') });
});
afterAll(async () => {
if (!enabled) return;
await adminPrisma().account.delete({ where: { id: accountId } });
});
it('choisit la version en vigueur à la date, pas la plus récente', async () => {
await withTenant(accountId, async (db) => {
const inMarch = await agreementFor(db, new Date('2026-03-09'));
const inAugust = await agreementFor(db, new Date('2026-08-10'));
expect(inMarch?.version).toBe(1);
expect(inAugust?.version).toBe(2);
});
});
it('n’applique pas rétroactivement une règle plus dure', async () => {
// Le cœur de l'exigence : 10 h de travail passent en mars sous la v1 et
// déclenchent en août sous la v2, avec exactement les mêmes créneaux.
await withTenant(accountId, async (db) => {
const march = await evaluateSchedule(db, marchSchedule);
const august = await evaluateSchedule(db, augustSchedule);
expect(march.map((entry) => entry.ruleCode)).not.toContain(
'MAX_DAILY_WORK',
);
expect(august.map((entry) => entry.ruleCode)).toContain('MAX_DAILY_WORK');
});
});
it('mémorise la version appliquée sur chaque constat', async () => {
await withTenant(accountId, async (db) => {
const stored = await db.complianceViolation.findFirst({
where: { weeklyScheduleId: augustSchedule, ruleCode: 'MAX_DAILY_WORK' },
});
const agreement = await agreementFor(db, new Date('2026-08-10'));
// Sans elle, un constat ancien devient inexplicable dès que les
// paramètres changent.
expect(stored?.agreementId).toBe(agreement?.id);
});
});
it('conserve l’acquittement d’une réévaluation à l’autre', async () => {
await withTenant(accountId, async (db) => {
const before = await db.complianceViolation.findFirst({
where: { weeklyScheduleId: augustSchedule, ruleCode: 'MAX_DAILY_WORK' },
});
if (!before) throw new Error('Constat attendu introuvable.');
await db.complianceViolation.update({
where: { id: before.id },
data: {
acknowledgedBy: membershipId,
acknowledgedAt: new Date(),
acknowledgementReason: 'Inventaire annuel',
},
});
// Un manager qui a justifié une alerte ne doit pas la revoir surgir
// parce qu'un collègue a déplacé un créneau ailleurs dans la semaine.
await evaluateSchedule(db, augustSchedule);
const after = await db.complianceViolation.findFirst({
where: { weeklyScheduleId: augustSchedule, ruleCode: 'MAX_DAILY_WORK' },
});
expect(after?.acknowledgementReason).toBe('Inventaire annuel');
expect(after?.id).not.toBe(before.id);
});
});
it('refuse de réécrire le contenu d’une version de convention', async () => {
// Le figeage est en base, pas seulement dans l'application : une paie
// antérieure doit rester reproductible même si quelqu'un écrit
// directement en SQL.
const db = adminPrisma();
const agreement = await db.collectiveAgreement.findFirst({
where: { accountId, version: 1 },
});
if (!agreement) throw new Error('Convention de test introuvable.');
await expect(
db.collectiveAgreement.update({
where: { id: agreement.id },
data: { parameters: { altered: true } },
}),
).rejects.toThrow(/figé/);
// L'approbation, elle, reste possible : c'est un acte postérieur légitime.
await expect(
db.collectiveAgreement.update({
where: { id: agreement.id },
data: { approvedBy: membershipId, approvedAt: new Date() },
}),
).resolves.toBeTruthy();
});
});