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
234 lines
7.9 KiB
TypeScript
234 lines
7.9 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';
|
||
|
||
/**
|
||
* 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();
|
||
});
|
||
});
|