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
241 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|