Files
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

149 lines
4.4 KiB
TypeScript
Raw Permalink 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';
/**
* Immutabilité du registre — critère d'acceptation de WP-06.
*
* La règle est **en base**, pas seulement dans l'application : une règle
* applicative finit par être contournée par un script de reprise, une console
* d'administration ou une migration pressée. Ces tests écrivent directement,
* sans passer par les Server Actions, pour le prouver.
*/
const enabled = (process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '').length > 0;
const describeIfDb = enabled ? describe : describe.skip;
const suffix = `ledger-${Date.now()}`;
const accountId = `${suffix}-account`;
const membershipId = `${suffix}-member`;
let counterId = '';
let operationId = '';
describeIfDb('registre des compteurs', () => {
beforeAll(async () => {
process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
const db = adminPrisma();
await db.account.create({
data: { id: accountId, name: `Compte ${suffix}` },
});
const role = await db.role.create({
data: { accountId, key: 'employee', name: 'Employé' },
});
await db.membership.create({
data: {
id: membershipId,
accountId,
roleId: role.id,
employeeNumber: 'L0001',
status: 'ACTIVE',
},
});
const counter = await db.counter.create({
data: {
accountId,
membershipId,
counterType: 'PAID_LEAVE',
acquisitionPeriodStart: new Date('2026-06-01'),
acquisitionPeriodEnd: new Date('2027-05-31'),
},
});
counterId = counter.id;
const operation = await db.ledgerOperation.create({
data: {
accountId,
counterId,
kind: 'ACCRUAL',
quantity: 25,
unit: 'DAY',
effectiveDate: new Date('2026-06-01'),
sourceType: 'SYSTEM',
},
});
operationId = operation.id;
});
afterAll(async () => {
if (!enabled) return;
const db = adminPrisma();
// Le trigger interdit DELETE sur les écritures : la suppression du compte
// ne peut donc pas cascader. On le désactive le temps du ménage.
await db.$executeRawUnsafe(
'ALTER TABLE "LedgerOperation" DISABLE TRIGGER ledger_operation_append_only',
);
await db.account.delete({ where: { id: accountId } });
await db.$executeRawUnsafe(
'ALTER TABLE "LedgerOperation" ENABLE TRIGGER ledger_operation_append_only',
);
});
it('refuse toute modification d’écriture', async () => {
await expect(
adminPrisma().ledgerOperation.update({
where: { id: operationId },
data: { quantity: 999 },
}),
).rejects.toThrow(/append-only/);
});
it('refuse toute suppression d’écriture', async () => {
await expect(
adminPrisma().ledgerOperation.delete({ where: { id: operationId } }),
).rejects.toThrow(/append-only/);
});
it('accepte une contre-passation, qui laisse les deux écritures', async () => {
// Une correction s'écrit ; elle ne se réécrit pas. Les deux lignes
// coexistent, et le solde redevient juste par addition.
const db = adminPrisma();
await db.ledgerOperation.create({
data: {
accountId,
counterId,
kind: 'REGULARISATION',
quantity: -25,
unit: 'DAY',
effectiveDate: new Date('2026-07-01'),
sourceType: 'MANUAL',
reason: 'Correction du solde d’ouverture',
reversesId: operationId,
},
});
const operations = await db.ledgerOperation.findMany({
where: { counterId },
});
expect(operations).toHaveLength(2);
const balance = operations.reduce(
(sum, operation) => sum + Number(operation.quantity.toString()),
0,
);
expect(balance).toBe(0);
});
it('n’accepte qu’une seule contre-passation par écriture', async () => {
// `reversesId` est unique : contre-passer deux fois la même écriture
// doublerait la correction, et le solde partirait dans l'autre sens.
await expect(
adminPrisma().ledgerOperation.create({
data: {
accountId,
counterId,
kind: 'REGULARISATION',
quantity: -25,
unit: 'DAY',
effectiveDate: new Date('2026-07-02'),
sourceType: 'MANUAL',
reversesId: operationId,
},
}),
).rejects.toThrow();
});
});