Files
planflow/tests/integration/pay-period.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

241 lines
8.1 KiB
TypeScript

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';
/**
* Périodes de paie — WP-07.
*
* Deux critères d'acceptation ne se démontrent qu'en base :
*
* - **Le verrouillage refuse toute mutation** dont la date tombe dans la
* période. Un contrôle applicatif qui laisserait passer une écriture rendrait
* faux un mois déjà transmis au cabinet.
* - **Grille, rapport d'heures et instantané donnent des chiffres identiques.**
* Trois calculs séparés divergent, et l'écart ne se voit qu'au bulletin.
*/
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 = `period-${Date.now()}`;
const accountId = `${suffix}-account`;
const locationId = `${suffix}-loc`;
const teamId = `${suffix}-team`;
const membershipId = `${suffix}-member`;
let withTenant: typeof import('@/server/tenant').withTenant;
let assertPeriodOpen: typeof import('@/server/payroll/periods').assertPeriodOpen;
let PeriodLockedError: typeof import('@/server/payroll/periods').PeriodLockedError;
let computeSnapshots: typeof import('@/server/payroll/periods').computeSnapshots;
let buildPayrollPeriod: typeof import('@/server/payroll/build').buildPayrollPeriod;
let periodId = '';
/** Août de l'an prochain : loin de tout ce que le seed pose. */
const YEAR = new Date().getUTCFullYear() + 3;
const MONTH = { year: YEAR, month: 8 };
describeIfDb('période de paie', () => {
beforeAll(async () => {
process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
({ withTenant } = await import('@/server/tenant'));
({ assertPeriodOpen, PeriodLockedError, computeSnapshots } = await import(
'@/server/payroll/periods'
));
({ buildPayrollPeriod } = await import('@/server/payroll/build'));
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: 'P0001',
silaeMatricule: '00001',
status: 'ACTIVE',
},
});
await db.employeeProfile.create({
data: { membershipId, accountId, firstName: 'Test', lastName: 'Période' },
});
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 } });
await db.collectiveAgreement.create({
data: {
accountId,
idcc: '1517',
name: 'Test',
parameters: IDCC_1517_PARAMETERS,
version: 1,
effectiveFrom: new Date('2020-01-01'),
},
});
// Deux créneaux : l'un avec réalisé saisi, l'autre sans — c'est le cas
// qui vérifie que le prévu fait foi à défaut.
const schedule = await db.weeklySchedule.create({
data: { accountId, teamId, locationId, isoYear: YEAR, isoWeek: 32 },
});
await db.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId,
localDate: new Date(`${YEAR}-08-03T00:00:00Z`),
startAt: zonedInstant(`${YEAR}-08-03`, '09:00', TZ),
endAt: zonedInstant(`${YEAR}-08-03`, '17:00', TZ),
breakMinutes: 0,
},
});
await db.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId,
localDate: new Date(`${YEAR}-08-04T00:00:00Z`),
startAt: zonedInstant(`${YEAR}-08-04`, '09:00', TZ),
endAt: zonedInstant(`${YEAR}-08-04`, '17:00', TZ),
breakMinutes: 0,
actualStartAt: zonedInstant(`${YEAR}-08-04`, '09:00', TZ),
actualEndAt: zonedInstant(`${YEAR}-08-04`, '19:00', TZ),
},
});
const period = await db.payPeriod.create({
data: {
accountId,
locationId,
label: `Août ${YEAR}`,
startDate: new Date(`${YEAR}-08-01T00:00:00Z`),
endDate: new Date(`${YEAR}-08-31T00:00:00Z`),
},
});
periodId = period.id;
});
afterAll(async () => {
if (!enabled) return;
await adminPrisma().account.delete({ where: { id: accountId } });
});
it('laisse passer une mutation sur une période ouverte', async () => {
await withTenant(accountId, async (db) => {
await expect(
assertPeriodOpen(db, locationId, [`${YEAR}-08-10`]),
).resolves.toBeUndefined();
});
});
it('retient le réalisé quand il est saisi, le prévu sinon', async () => {
await withTenant(accountId, async (db) => {
const payroll = await buildPayrollPeriod(db, MONTH, locationId);
const hours = payroll?.rows[0]?.elements.find(
(element) => element.key === 'WORKED_HOURS',
);
// 8 h prévues le 3, 10 h réalisées le 4 : 18 h au total, pas 16.
expect(hours?.value).toBe(18 * 60);
});
});
it('fige des instantanés identiques au rapport', async () => {
// Le critère croisé : l'instantané vient de la **même fonction** que le
// rapport et l'export. Trois calculs séparés divergeraient.
await withTenant(accountId, async (db) => {
const written = await computeSnapshots(db, periodId, MONTH, locationId);
expect(written).toBe(1);
const snapshot = await db.payPeriodSnapshot.findFirst({
where: { payPeriodId: periodId },
});
const payroll = await buildPayrollPeriod(db, MONTH, locationId);
const hours = payroll?.rows[0]?.elements.find(
(element) => element.key === 'WORKED_HOURS',
);
expect(snapshot?.plannedMinutes).toBe(hours?.value);
expect(snapshot?.workedDays).toBe(2);
});
});
it('refuse toute mutation une fois la période verrouillée', async () => {
await adminPrisma().payPeriod.update({
where: { id: periodId },
data: { status: 'LOCKED', lockedAt: new Date() },
});
await withTenant(accountId, async (db) => {
await expect(
assertPeriodOpen(db, locationId, [`${YEAR}-08-10`]),
).rejects.toBeInstanceOf(PeriodLockedError);
// Bornes comprises : le 1er et le 31 sont dans la période.
await expect(
assertPeriodOpen(db, locationId, [`${YEAR}-08-01`]),
).rejects.toBeInstanceOf(PeriodLockedError);
await expect(
assertPeriodOpen(db, locationId, [`${YEAR}-08-31`]),
).rejects.toBeInstanceOf(PeriodLockedError);
});
});
it('laisse passer une date hors de la période verrouillée', async () => {
await withTenant(accountId, async (db) => {
await expect(
assertPeriodOpen(db, locationId, [`${YEAR}-09-01`]),
).resolves.toBeUndefined();
});
});
it('recalcule intégralement les instantanés au nouveau verrouillage', async () => {
await withTenant(accountId, async (db) => {
const before = await db.payPeriodSnapshot.findFirst({
where: { payPeriodId: periodId },
});
const again = await computeSnapshots(db, periodId, MONTH, locationId);
const after = await db.payPeriodSnapshot.findFirst({
where: { payPeriodId: periodId },
});
expect(again).toBe(1);
// Nouvel enregistrement, même contenu : ils ne sont pas immuables au sens
// strict, c'est le couple (instantané, export) qui porte la preuve.
expect(after?.id).not.toBe(before?.id);
expect(after?.plannedMinutes).toBe(before?.plannedMinutes);
});
});
});