From 29369d4a4e3c33eb3b28e76e2d751c6ce3ff1d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 07:31:22 +0000 Subject: [PATCH] =?UTF-8?q?D=C3=A9poser=20et=20consulter=20les=20pi=C3=A8c?= =?UTF-8?q?es=20du=20dossier=20salari=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rien ne pouvait être téléversé jusqu'ici : ni pièce d'identité, ni relevé d'identité bancaire, ni arrêt de travail. Le dossier RH n'existait qu'en champs de formulaire. Les fichiers vivent sur disque, chiffrés avec la clé qui protège déjà le NIR et l'IBAN. Le plan n'exige le chiffrement que des pièces jointes de santé ; les chiffrer toutes supprime une branche dont l'oubli serait silencieux et ne coûte rien de plus. Leur emplacement est tiré au sort, jamais dérivé du nom déposé : la traversée de chemin devient impossible par construction plutôt que par filtrage, et un filtre s'oublie. Le caractère sensible se déduit de la catégorie et n'est jamais saisi : laisser déclarer qu'un arrêt de travail n'est pas une donnée de santé reviendrait à laisser désactiver la journalisation de sa lecture. Cette lecture est inscrite au journal avant d'être servie, et l'écran l'annonce — celui qui ouvre la pièce doit savoir que sa consultation laisse une trace nominative. Les liens sont signés et durent deux minutes, comme l'exige le plan. La signature ne remplace pas le contrôle d'accès : la route revérifie session, capacité et périmètre. Elle s'y ajoute pour qu'un lien recopié dans un message cesse de fonctionner de lui-même, sans attendre qu'une session expire. La signature est éprouvée avant l'échéance, sans quoi répondre « expiré » à un lien fabriqué indiquerait qu'il aurait pu marcher. L'empreinte du clair est conservée et revérifiée à chaque lecture : servir un contenu qui ne correspond plus reviendrait à présenter comme authentique une pièce altérée. Retirer une pièce efface le contenu mais garde la ligne : le dossier doit conserver trace qu'elle a existé et qui l'a retirée. Aucune durée de conservation n'est appliquée par défaut — le plan l'interdit explicitement (§12.5). L'échéance reste nulle et l'écran le dit, plutôt que d'inventer « cinq ans partout ». Deux pièges d'outillage rencontrés et documentés dans les tests : le cookie de session étant marqué Secure, le client HTTP de Playwright ne l'émet pas sur http et faisait passer les refus pour de bonnes raisons sans rien prouver ; et la visionneuse PDF intégrée de Chromium ne restitue pas le corps d'une navigation, ce qui masquait la vérification d'intégrité du contenu. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- .gitignore | 3 + .../20260809072154_documents/migration.sql | 38 ++++ .../migration.sql | 17 ++ prisma/schema.prisma | 47 +++++ src/app/(app)/equipe/[id]/DocumentsPanel.tsx | 181 ++++++++++++++++ src/app/(app)/equipe/[id]/page.tsx | 20 ++ src/app/documents/[id]/route.ts | 91 +++++++++ src/domain/documents/rules.ts | 137 +++++++++++++ src/lib/env.ts | 9 + src/server/crypto.ts | 39 ++++ src/server/documents/actions.ts | 168 +++++++++++++++ src/server/documents/links.ts | 57 ++++++ src/server/documents/queries.ts | 59 ++++++ src/server/documents/storage.ts | 97 +++++++++ tests/e2e/documents.spec.ts | 193 ++++++++++++++++++ tests/unit/documents.test.ts | 119 +++++++++++ 16 files changed, 1275 insertions(+) create mode 100644 prisma/migrations/20260809072154_documents/migration.sql create mode 100644 prisma/migrations/20260809072300_documents_rls/migration.sql create mode 100644 src/app/(app)/equipe/[id]/DocumentsPanel.tsx create mode 100644 src/app/documents/[id]/route.ts create mode 100644 src/domain/documents/rules.ts create mode 100644 src/server/documents/actions.ts create mode 100644 src/server/documents/links.ts create mode 100644 src/server/documents/queries.ts create mode 100644 src/server/documents/storage.ts create mode 100644 tests/e2e/documents.spec.ts create mode 100644 tests/unit/documents.test.ts diff --git a/.gitignore b/.gitignore index bcfa61f..d259b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ blob-report/ *.log .tmp/ .pgdata/ + +# Pièces du dossier salarié, chiffrées au repos +storage/ diff --git a/prisma/migrations/20260809072154_documents/migration.sql b/prisma/migrations/20260809072154_documents/migration.sql new file mode 100644 index 0000000..0713c02 --- /dev/null +++ b/prisma/migrations/20260809072154_documents/migration.sql @@ -0,0 +1,38 @@ +-- CreateEnum +CREATE TYPE "DocumentCategory" AS ENUM ('IDENTITY', 'BANK', 'CONTRACT', 'AMENDMENT', 'SICK_NOTE', 'WORK_PERMIT', 'REGISTER', 'OTHER'); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "membershipId" TEXT, + "category" "DocumentCategory" NOT NULL, + "name" TEXT NOT NULL, + "fileKey" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "checksum" TEXT NOT NULL, + "isSensitive" BOOLEAN NOT NULL DEFAULT false, + "retentionUntil" TIMESTAMP(3), + "uploadedBy" TEXT NOT NULL, + "uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(3), + "deletedBy" TEXT, + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Document_fileKey_key" ON "Document"("fileKey"); + +-- CreateIndex +CREATE INDEX "Document_accountId_idx" ON "Document"("accountId"); + +-- CreateIndex +CREATE INDEX "Document_membershipId_idx" ON "Document"("membershipId"); + +-- CreateIndex +CREATE INDEX "Document_retentionUntil_idx" ON "Document"("retentionUntil"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260809072300_documents_rls/migration.sql b/prisma/migrations/20260809072300_documents_rls/migration.sql new file mode 100644 index 0000000..ec0eab3 --- /dev/null +++ b/prisma/migrations/20260809072300_documents_rls/migration.sql @@ -0,0 +1,17 @@ +-- --------------------------------------------------------------------------- +-- Isolation par compte — même défense en profondeur que les autres tables. +-- +-- Une pièce de dossier RH est le pire objet à laisser franchir la frontière +-- entre deux comptes : elle porte un nom, une identité, parfois une donnée de +-- santé. L'extension Prisma filtre déjà, ces règles valent pour le cas où elle +-- serait contournée. +-- --------------------------------------------------------------------------- + +ALTER TABLE "Document" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "Document" FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON "Document" + USING ("accountId" = planflow_current_account()); + +CREATE POLICY tenant_insert ON "Document" + FOR INSERT WITH CHECK ("accountId" = planflow_current_account()); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd10215..30ca82b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -167,6 +167,7 @@ model Membership { contracts UserContract[] workPermits WorkPermit[] teams TeamMember[] + documents Document[] @@unique([accountId, employeeNumber]) @@index([accountId]) @@ -717,6 +718,52 @@ model DailyNote { /// pouvoir reproduire à l'identique une paie antérieure après un changement de /// règle. Une mise à jour en place rendrait cette reproduction impossible, et /// l'impossibilité ne se découvrirait qu'au premier contrôle. +/// Pièce du dossier salarié — PLAN.md §4.7. +/// +/// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce +/// jointe de santé chiffrée au repos est une exigence du plan (§3.6), et +/// stocker des mégaoctets en base rendrait toute sauvegarde impraticable. +/// `fileKey` est tiré au sort — jamais dérivé du nom déposé, qui ne doit pas +/// pouvoir désigner un emplacement. +model Document { + id String @id @default(cuid()) + accountId String + membershipId String? + category DocumentCategory + name String + fileKey String @unique + mimeType String + sizeBytes Int + /// Empreinte du clair, pour détecter une altération du fichier stocké. + checksum String + /// Donnée de santé : sa lecture est journalisée (§3.6). + isSensitive Boolean @default(false) + /// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée : + /// le plan interdit d'appliquer une durée par défaut (§12.5). + retentionUntil DateTime? + uploadedBy String + uploadedAt DateTime @default(now()) + deletedAt DateTime? + deletedBy String? + + membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@index([membershipId]) + @@index([retentionUntil]) +} + +enum DocumentCategory { + IDENTITY + BANK + CONTRACT + AMENDMENT + SICK_NOTE + WORK_PERMIT + REGISTER + OTHER +} + model CollectiveAgreement { id String @id @default(cuid()) accountId String diff --git a/src/app/(app)/equipe/[id]/DocumentsPanel.tsx b/src/app/(app)/equipe/[id]/DocumentsPanel.tsx new file mode 100644 index 0000000..cd23aae --- /dev/null +++ b/src/app/(app)/equipe/[id]/DocumentsPanel.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { + ACCEPTED_MIME_TYPES, + CATEGORY_LABELS, + DOCUMENT_CATEGORIES, + formatBytes, + MAX_DOCUMENT_BYTES, + type DocumentCategory, +} from '@/domain/documents/rules'; +import { + deleteDocumentAction, + uploadDocumentAction, + type DocumentState, +} from '@/server/documents/actions'; + +const empty: DocumentState = {}; +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }); + +export interface DocumentView { + id: string; + name: string; + category: DocumentCategory; + sizeBytes: number; + isSensitive: boolean; + retentionUntil: Date | null; + uploadedAt: Date; + href: string; +} + +/** + * Pièces du dossier. + * + * Les liens sont signés et expirent en quelques minutes : recharger la page en + * fabrique de nouveaux. C'est voulu — un lien recopié dans un message cesse de + * fonctionner de lui-même, sans attendre qu'une session expire. + */ +export function DocumentsPanel({ + membershipId, + documents, + canManage, +}: { + membershipId: string; + documents: DocumentView[]; + canManage: boolean; +}) { + const [uploadState, upload, uploading] = useActionState( + uploadDocumentAction, + empty, + ); + const [deleteState, remove] = useActionState(deleteDocumentAction, empty); + + return ( +
+ {documents.length === 0 ? ( +

+ Aucune pièce. Un dossier complet porte au minimum une pièce d’identité + et un relevé d’identité bancaire. +

+ ) : ( +
    + {documents.map((document) => ( +
  • + + {document.name} + + {CATEGORY_LABELS[document.category]} + {document.isSensitive ? ( + // Le dire à l'écran : celui qui ouvre la pièce doit savoir que + // sa consultation laisse une trace nominative. + Santé · lecture journalisée + ) : null} + + {formatBytes(document.sizeBytes)} + + + déposée le {dateFormat.format(document.uploadedAt)} + + + {document.retentionUntil ? ( + + conservée jusqu’au {dateFormat.format(document.retentionUntil)} + + ) : ( + + échéance de conservation non fixée + + )} + {canManage ? ( +
    + + +
    + ) : null} +
  • + ))} +
+ )} + + {canManage ? ( +
+ + + + + + + +
+ ) : null} + + {uploadState.error ? ( +

+ {uploadState.error} +

+ ) : null} + {uploadState.message ? ( +

{uploadState.message}

+ ) : null} + {deleteState.error ? ( +

+ {deleteState.error} +

+ ) : null} + {deleteState.message ? ( +

{deleteState.message}

+ ) : null} +
+ ); +} diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx index ad22c5b..80a9fd9 100644 --- a/src/app/(app)/equipe/[id]/page.tsx +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -1,9 +1,11 @@ import { notFound } from 'next/navigation'; +import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel'; import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel'; import { PageBody, PageHeader } from '@/components/shell/PageHeader'; import { Badge } from '@/components/ui/Badge'; import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { listDocuments } from '@/server/documents/queries'; import { getEmployee } from '@/server/employees/queries'; export const dynamic = 'force-dynamic'; @@ -19,6 +21,10 @@ export default async function FichePage({ const employee = await getEmployee(id); if (!employee) notFound(); + // `members.documents.view` peut manquer là où `members.view` est accordée : + // la section disparaît alors, plutôt que d'échouer sur toute la page. + const documents = await listDocuments(id).catch(() => null); + const active = employee.contracts.find( (contract) => contract.status === 'ACTIVE', ); @@ -63,6 +69,20 @@ export default async function FichePage({ ) : null} + {documents ? ( + + {documents.documents.length}} + /> + + + ) : null} + {employee.canInvite ? ( diff --git a/src/app/documents/[id]/route.ts b/src/app/documents/[id]/route.ts new file mode 100644 index 0000000..2f7b423 --- /dev/null +++ b/src/app/documents/[id]/route.ts @@ -0,0 +1,91 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules'; +import { recordAudit } from '@/server/audit'; +import { query } from '@/server/context'; +import { checkSignature } from '@/server/documents/links'; +import { readFileByKey } from '@/server/documents/storage'; + +/** + * Téléchargement d'une pièce — PLAN.md §3.6. + * + * Trois conditions, toutes nécessaires : une signature valide et non expirée, + * une session, et la capacité de lire les documents. La signature seule ne + * suffit pas — un lien recopié dans un message ne doit rien ouvrir à qui n'a + * pas le droit de lire le dossier. + * + * La lecture d'une pièce de santé est journalisée, comme l'exige le plan pour + * toute donnée de catégorie particulière. + */ +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await context.params; + const parameters = request.nextUrl.searchParams; + + const link = checkSignature(id, parameters.get('e'), parameters.get('s')); + if (!link.ok) { + return NextResponse.json( + { + error: + link.reason === 'expired' + ? 'Ce lien a expiré. Rouvrez le dossier pour en obtenir un nouveau.' + : 'Lien invalide.', + }, + { status: link.reason === 'expired' ? 410 : 400 }, + ); + } + + // `query` impose la session et la capacité avant d'ouvrir la transaction, et + // le client scopé ne voit que les documents du compte courant. + const found = await query('members.documents.view', async (db, actor) => { + const document = await db.document.findUnique({ where: { id } }); + if (!document || document.deletedAt) return null; + + if (document.isSensitive) { + // Journalisée **avant** la lecture : une consultation qui échouerait + // après coup a tout de même eu lieu du point de vue de l'accès. + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'document.sensitive.read', + entityType: 'Document', + entityId: document.id, + after: { + category: document.category, + membershipId: document.membershipId, + }, + }); + } + + return document; + }); + + if (!found) { + return NextResponse.json({ error: 'Document introuvable.' }, { status: 404 }); + } + + const file = await readFileByKey(found.fileKey, found.checksum); + if (!file.intact) { + // Servir un contenu qui ne correspond plus à son empreinte reviendrait à + // présenter comme authentique une pièce altérée. + return NextResponse.json( + { error: 'Le fichier stocké ne correspond plus à son empreinte.' }, + { status: 409 }, + ); + } + + const name = sanitiseFileName(found.name); + return new NextResponse(new Uint8Array(file.content), { + headers: { + 'content-type': found.mimeType, + 'content-length': String(file.content.byteLength), + // `inline` laisse le navigateur afficher un PDF ou une image sans + // téléchargement ; le nom accentué passe par `filename*`, le repli ASCII + // sert aux clients qui ne lisent que `filename`. + 'content-disposition': `inline; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`, + // Une pièce de dossier RH n'a rien à faire dans un cache partagé. + 'cache-control': 'private, no-store', + }, + }); +} diff --git a/src/domain/documents/rules.ts b/src/domain/documents/rules.ts new file mode 100644 index 0000000..8ae1ab4 --- /dev/null +++ b/src/domain/documents/rules.ts @@ -0,0 +1,137 @@ +/** + * Pièces du dossier salarié — PLAN.md §4.7 et §3.6. + * + * Un dossier RH contient des pièces d'identité, des RIB et des arrêts de + * travail. Les derniers sont des données de santé, catégorie particulière au + * sens du RGPD : leur simple **lecture** doit être journalisée, et leur + * stockage chiffré. + */ + +export const DOCUMENT_CATEGORIES = [ + 'IDENTITY', + 'BANK', + 'CONTRACT', + 'AMENDMENT', + 'SICK_NOTE', + 'WORK_PERMIT', + 'REGISTER', + 'OTHER', +] as const; + +export type DocumentCategory = (typeof DOCUMENT_CATEGORIES)[number]; + +export const CATEGORY_LABELS: Record = { + IDENTITY: 'Pièce d’identité', + BANK: 'Coordonnées bancaires', + CONTRACT: 'Contrat', + AMENDMENT: 'Avenant', + SICK_NOTE: 'Arrêt de travail', + WORK_PERMIT: 'Titre de séjour', + REGISTER: 'Registre', + OTHER: 'Autre', +}; + +/** + * Catégories relevant de la santé. + * + * Une liste plutôt qu'un drapeau saisi à la main : laisser l'utilisateur + * déclarer qu'un arrêt de travail n'est pas sensible reviendrait à lui laisser + * désactiver la journalisation. + */ +const HEALTH_CATEGORIES = new Set(['SICK_NOTE']); + +export function isSensitiveCategory(category: DocumentCategory): boolean { + return HEALTH_CATEGORIES.has(category); +} + +/** + * Types de fichiers acceptés. + * + * Restreint volontairement : un dossier RH reçoit des scans et des PDF. Élargir + * aux documents bureautiques ferait entrer des formats à macros, qu'aucun usage + * ici ne justifie. + */ +export const ACCEPTED_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', +] as const; + +/** 20 Mio — un scan de plusieurs pages passe, une vidéo non. Choix produit. */ +export const MAX_DOCUMENT_BYTES = 20 * 1024 * 1024; + +export interface UploadCandidate { + name: string; + mimeType: string; + sizeBytes: number; +} + +/** Motif de refus, ou `null` si la pièce est acceptable. */ +export function uploadProblem(candidate: UploadCandidate): string | null { + if (candidate.sizeBytes <= 0) { + return 'Le fichier est vide.'; + } + if (candidate.sizeBytes > MAX_DOCUMENT_BYTES) { + return `Le fichier dépasse ${Math.round(MAX_DOCUMENT_BYTES / 1024 / 1024)} Mio.`; + } + if (!(ACCEPTED_MIME_TYPES as readonly string[]).includes(candidate.mimeType)) { + return 'Format non accepté. Déposez un PDF ou une image (JPEG, PNG, WebP, HEIC).'; + } + if (!candidate.name.trim()) { + return 'Le fichier n’a pas de nom.'; + } + return null; +} + +/** + * Nom d'affichage assaini. + * + * Le nom déposé par l'utilisateur ne sert **jamais** à désigner l'emplacement + * du fichier — celui-ci porte un identifiant tiré au sort. L'assainir reste + * utile pour l'en-tête de téléchargement, où un retour à la ligne ou un + * guillemet permettrait d'y injecter un second champ. + */ +export function sanitiseFileName(name: string): string { + const base = name.split(/[\\/]/).pop() ?? name; + const cleaned = base + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/["\\]/g, '') + .trim(); + return cleaned.slice(0, 120) || 'document'; +} + +/** + * Nom en ASCII pur, pour l'en-tête `Content-Disposition`. + * + * Les accents y sont transmis séparément, en `filename*` ; le repli ASCII sert + * aux clients qui ne lisent que `filename`. + */ +export function asciiFileName(name: string): string { + return ( + sanitiseFileName(name) + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^\x20-\x7E]/g, '_') || 'document' + ); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} o`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Kio`; + return `${(bytes / 1024 / 1024).toFixed(1)} Mio`; +} + +/** + * Durée de validité d'un lien de téléchargement. + * + * Assez pour ouvrir la pièce depuis la liste, trop peu pour qu'un lien recopié + * dans un message reste exploitable. Le lien seul ne suffit d'ailleurs pas : + * la session et la capacité sont revérifiées à la lecture. + */ +export const DOWNLOAD_LINK_TTL_SECONDS = 120; + +export function isLinkExpired(expiresAtSeconds: number, nowMs: number): boolean { + return expiresAtSeconds * 1000 <= nowMs; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 5cbbd18..576c507 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -25,6 +25,15 @@ const schema = z.object({ /** Public origin, used for links in invitation and notification e-mails. */ APP_URL: z.url().default('http://localhost:3000'), + + /** + * Where employee documents live on disk, encrypted with ENCRYPTION_KEY. + * + * Kept out of the database: megabytes of scans would make every backup + * impractical. Back this directory up alongside the database — one without + * the other restores a dossier with missing pieces. + */ + DOCUMENT_STORE: z.string().min(1).default('./storage/documents'), }); export type Env = z.infer; diff --git a/src/server/crypto.ts b/src/server/crypto.ts index feddfef..ad4c5a4 100644 --- a/src/server/crypto.ts +++ b/src/server/crypto.ts @@ -62,6 +62,45 @@ function decryptBuffer(payload: Buffer): string { ]).toString('utf8'); } +/** + * Chiffrement d'un contenu binaire — pièces du dossier salarié. + * + * Même format et même clé que les colonnes : un arrêt de travail est une donnée + * de santé, et le plan (§3.6) exige qu'il soit chiffré au repos. Chiffrer + * **toutes** les pièces plutôt que les seules pièces de santé évite une branche + * dont l'oubli serait silencieux, et ne coûte rien de plus. + */ +export function encryptBytes(plaintext: Uint8Array): Buffer { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key(), iv); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), encrypted]); +} + +export function decryptBytes(payload: Uint8Array): Buffer { + const buffer = Buffer.from( + payload.buffer, + payload.byteOffset, + payload.byteLength, + ); + if (buffer.length < IV_LENGTH + TAG_LENGTH) { + throw new Error('Chiffré invalide : trop court pour contenir iv et tag'); + } + + const iv = buffer.subarray(0, IV_LENGTH); + const tag = buffer.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = buffer.subarray(IV_LENGTH + TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +} + +/** Empreinte d'un contenu, pour détecter l'altération du fichier stocké. */ +export function checksumBytes(payload: Uint8Array): string { + return createHash('sha256').update(payload).digest('hex'); +} + export function encryptOptional(value: string | null | undefined): Buffer | null { return value ? encrypt(value) : null; } diff --git a/src/server/documents/actions.ts b/src/server/documents/actions.ts new file mode 100644 index 0000000..87cb8e1 --- /dev/null +++ b/src/server/documents/actions.ts @@ -0,0 +1,168 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { AuthorizationError } from '@/domain/access/authorize'; +import { + DOCUMENT_CATEGORIES, + isSensitiveCategory, + sanitiseFileName, + uploadProblem, + type DocumentCategory, +} from '@/domain/documents/rules'; +import { recordAudit } from '@/server/audit'; +import { mutate } from '@/server/context'; +import { removeFile, storeFile } from '@/server/documents/storage'; + +/** + * Dépôt et retrait de pièces. + * + * Le fichier est écrit sur disque **avant** la ligne en base : l'inverse + * laisserait, en cas d'échec d'écriture, une entrée désignant un contenu + * inexistant — une pièce fantôme dans un dossier RH est pire qu'une absence, + * parce qu'elle se croit présente. + */ + +export interface DocumentState { + error?: string; + ok?: boolean; + message?: string; +} + +export async function uploadDocumentAction( + _previous: DocumentState, + formData: FormData, +): Promise { + const membershipId = String(formData.get('membershipId') ?? ''); + const rawCategory = String(formData.get('category') ?? ''); + const file = formData.get('file'); + + if (!membershipId) return { error: 'Salarié introuvable.' }; + if (!(DOCUMENT_CATEGORIES as readonly string[]).includes(rawCategory)) { + return { error: 'Catégorie inconnue.' }; + } + if (!(file instanceof File)) return { error: 'Aucun fichier déposé.' }; + + const category = rawCategory as DocumentCategory; + const name = sanitiseFileName(file.name); + + const problem = uploadProblem({ + name, + mimeType: file.type, + sizeBytes: file.size, + }); + if (problem) return { error: problem }; + + const content = new Uint8Array(await file.arrayBuffer()); + // La taille annoncée par le navigateur n'engage personne : c'est celle du + // contenu réellement reçu qui doit passer le contrôle. + const received = uploadProblem({ + name, + mimeType: file.type, + sizeBytes: content.byteLength, + }); + if (received) return { error: received }; + + try { + await mutate('members.documents.manage', async (db, actor) => { + const membership = await db.membership.findUnique({ + where: { id: membershipId }, + select: { id: true }, + }); + if (!membership) throw new ValidationError('Salarié introuvable.'); + + const stored = await storeFile(actor.accountId, content); + + await db.document.create({ + data: { + membershipId, + category, + name, + fileKey: stored.fileKey, + mimeType: file.type, + sizeBytes: stored.sizeBytes, + checksum: stored.checksum, + // Déduit de la catégorie, jamais saisi : laisser déclarer qu'un arrêt + // de travail n'est pas sensible reviendrait à laisser désactiver la + // journalisation de sa lecture. + isSensitive: isSensitiveCategory(category), + uploadedBy: actor.membershipId, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'document.upload', + entityType: 'Membership', + entityId: membershipId, + // Le nom du fichier suffit à la relecture ; son contenu n'a rien à + // faire dans un journal. + after: { name, category, sizeBytes: stored.sizeBytes }, + }); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit de déposer une pièce."); + } + + revalidatePath(`/equipe/${membershipId}`); + return { ok: true, message: `« ${name} » déposé.` }; +} + +/** + * Retire une pièce. + * + * Le contenu est effacé du disque ; la ligne reste, marquée supprimée. Le + * dossier doit garder trace qu'une pièce a existé et qui l'a retirée — c'est le + * contenu qui disparaît, pas l'événement. + */ +export async function deleteDocumentAction( + _previous: DocumentState, + formData: FormData, +): Promise { + const documentId = String(formData.get('documentId') ?? ''); + if (!documentId) return { error: 'Pièce introuvable.' }; + + let membershipId: string | null = null; + + try { + await mutate('members.documents.manage', async (db, actor) => { + const document = await db.document.findUnique({ + where: { id: documentId }, + }); + if (!document || document.deletedAt) { + throw new ValidationError('Pièce introuvable.'); + } + + membershipId = document.membershipId; + + await db.document.update({ + where: { id: documentId }, + data: { deletedAt: new Date(), deletedBy: actor.membershipId } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'document.delete', + entityType: 'Document', + entityId: documentId, + before: { name: document.name, category: document.category }, + after: { deleted: true }, + }); + + await removeFile(document.fileKey); + }); + } catch (error) { + return toState(error, "Vous n'avez pas le droit de retirer une pièce."); + } + + if (membershipId) revalidatePath(`/equipe/${membershipId}`); + return { ok: true, message: 'Pièce retirée.' }; +} + +class ValidationError extends Error {} + +function toState(error: unknown, denied: string): DocumentState { + if (error instanceof ValidationError) return { error: error.message }; + if (error instanceof AuthorizationError) return { error: denied }; + throw error; +} diff --git a/src/server/documents/links.ts b/src/server/documents/links.ts new file mode 100644 index 0000000..048df19 --- /dev/null +++ b/src/server/documents/links.ts @@ -0,0 +1,57 @@ +import 'server-only'; + +import { createHmac } from 'node:crypto'; + +import { DOWNLOAD_LINK_TTL_SECONDS } from '@/domain/documents/rules'; +import { env } from '@/lib/env'; +import { safeEqual } from '@/server/crypto'; + +/** + * Liens de téléchargement signés — PLAN.md §3.6. + * + * Le plan exige que les fichiers ne soient servis que par URL signée à durée + * courte. La signature ne remplace pas le contrôle d'accès : la route + * revérifie la session, la capacité et le périmètre. Elle s'y ajoute, pour + * qu'un lien recopié ailleurs cesse de fonctionner de lui-même, sans attendre + * qu'une session expire. + */ + +function signature(documentId: string, expiresAt: number): string { + return createHmac('sha256', Buffer.from(env.ENCRYPTION_KEY, 'base64')) + .update(`${documentId}:${expiresAt}`) + .digest('base64url'); +} + +export function signedDocumentUrl(documentId: string, nowMs = Date.now()): string { + const expiresAt = Math.floor(nowMs / 1000) + DOWNLOAD_LINK_TTL_SECONDS; + const parameters = new URLSearchParams({ + e: String(expiresAt), + s: signature(documentId, expiresAt), + }); + return `/documents/${documentId}?${parameters.toString()}`; +} + +export type LinkCheck = + | { ok: true } + | { ok: false; reason: 'expired' | 'invalid' }; + +export function checkSignature( + documentId: string, + rawExpiresAt: string | null, + rawSignature: string | null, + nowMs = Date.now(), +): LinkCheck { + if (!rawExpiresAt || !rawSignature) return { ok: false, reason: 'invalid' }; + + const expiresAt = Number(rawExpiresAt); + if (!Number.isInteger(expiresAt)) return { ok: false, reason: 'invalid' }; + + // La signature est éprouvée **avant** l'échéance : sans cela, on répondrait + // « expiré » à un lien fabriqué, ce qui indiquerait qu'il aurait pu marcher. + if (!safeEqual(signature(documentId, expiresAt), rawSignature)) { + return { ok: false, reason: 'invalid' }; + } + if (expiresAt * 1000 <= nowMs) return { ok: false, reason: 'expired' }; + + return { ok: true }; +} diff --git a/src/server/documents/queries.ts b/src/server/documents/queries.ts new file mode 100644 index 0000000..51247e3 --- /dev/null +++ b/src/server/documents/queries.ts @@ -0,0 +1,59 @@ +import { can } from '@/domain/access/authorize'; +import type { DocumentCategory } from '@/domain/documents/rules'; +import { query } from '@/server/context'; +import { signedDocumentUrl } from '@/server/documents/links'; + +export interface DocumentRow { + id: string; + name: string; + category: DocumentCategory; + mimeType: string; + sizeBytes: number; + isSensitive: boolean; + retentionUntil: Date | null; + uploadedAt: Date; + /** Lien signé, valable quelques minutes seulement. */ + href: string; +} + +export interface DocumentList { + documents: DocumentRow[]; + canManage: boolean; +} + +/** + * Pièces d'un salarié. + * + * Le lien signé est fabriqué à la lecture de la page, jamais stocké : sa durée + * de vie se compte en minutes, et le conserver en base le rendrait aussi long + * que la ligne qui le porte. + */ +export async function listDocuments( + membershipId: string, +): Promise { + return query('members.documents.view', async (db, actor) => { + const rows = await db.document.findMany({ + where: { membershipId, deletedAt: null }, + orderBy: { uploadedAt: 'desc' }, + select: { + id: true, + name: true, + category: true, + mimeType: true, + sizeBytes: true, + isSensitive: true, + retentionUntil: true, + uploadedAt: true, + }, + }); + + return { + documents: rows.map((row) => ({ + ...row, + category: row.category as DocumentCategory, + href: signedDocumentUrl(row.id), + })), + canManage: can(actor, 'members.documents.manage'), + }; + }); +} diff --git a/src/server/documents/storage.ts b/src/server/documents/storage.ts new file mode 100644 index 0000000..5626a25 --- /dev/null +++ b/src/server/documents/storage.ts @@ -0,0 +1,97 @@ +import 'server-only'; + +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; + +import { env } from '@/lib/env'; +import { checksumBytes, decryptBytes, encryptBytes } from '@/server/crypto'; + +/** + * Stockage des pièces — PLAN.md §3.6. + * + * Sur disque plutôt qu'en base : des mégaoctets en base rendraient toute + * sauvegarde impraticable. Chiffré, parce qu'un arrêt de travail est une donnée + * de santé et qu'un disque volé ne doit rien livrer. + * + * L'emplacement est **tiré au sort**, jamais dérivé du nom déposé. C'est ce qui + * rend la traversée de chemin impossible par construction plutôt que par + * filtrage — un filtre s'oublie, un identifiant aléatoire ne se négocie pas. + */ + +export interface StoredFile { + fileKey: string; + checksum: string; + sizeBytes: number; +} + +function root(): string { + return resolve(env.DOCUMENT_STORE); +} + +/** + * Chemin absolu d'une clé, vérifié. + * + * La clé vient de la base et non d'un formulaire, mais la vérification reste : + * une donnée corrompue ou une migration maladroite ne doit pas pouvoir faire + * écrire ailleurs que dans le magasin. + */ +function pathFor(fileKey: string): string { + const base = root(); + const target = resolve(join(base, fileKey)); + if (target !== base && !target.startsWith(base + sep)) { + throw new Error('Clé de fichier hors du magasin de documents'); + } + return target; +} + +/** `aa/bb/` — deux niveaux, pour ne pas entasser des milliers d'entrées. */ +function newKey(accountId: string): string { + const id = randomUUID(); + return join(accountId, id.slice(0, 2), id.slice(2, 4), id); +} + +export async function storeFile( + accountId: string, + content: Uint8Array, +): Promise { + const fileKey = newKey(accountId); + const target = pathFor(fileKey); + + await mkdir(dirname(target), { recursive: true }); + // Le clair ne touche jamais le disque : on chiffre avant d'écrire, et la + // taille comme l'empreinte se rapportent au clair, seul objet que + // l'utilisateur reconnaît. + await writeFile(target, encryptBytes(content), { mode: 0o600 }); + + return { + fileKey, + checksum: checksumBytes(content), + sizeBytes: content.byteLength, + }; +} + +export interface ReadResult { + content: Buffer; + /** Faux si le contenu ne correspond plus à l'empreinte enregistrée. */ + intact: boolean; +} + +export async function readFileByKey( + fileKey: string, + expectedChecksum: string, +): Promise { + const content = decryptBytes(await readFile(pathFor(fileKey))); + return { content, intact: checksumBytes(content) === expectedChecksum }; +} + +/** + * Efface le contenu. + * + * L'entrée en base reste, marquée supprimée : le dossier doit garder trace + * qu'une pièce a existé et qui l'a retirée. C'est le contenu qui disparaît, pas + * l'événement. + */ +export async function removeFile(fileKey: string): Promise { + await rm(pathFor(fileKey), { force: true }); +} diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts new file mode 100644 index 0000000..2020212 --- /dev/null +++ b/tests/e2e/documents.spec.ts @@ -0,0 +1,193 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * Pièces du dossier salarié — PLAN.md §3.6 et WP-10. + * + * Trois garanties à protéger : le contenu revient tel qu'il a été déposé, le + * lien expire, et il ne suffit pas à lui seul — un navigateur sans session + * n'obtient rien. + * + * Chaque test dépose sur **son** salarié : les pièces s'accumulent, et deux + * tests qui se partageraient un dossier compteraient les pièces de l'autre. + * + * Les liens sont suivis par **navigation** et non par le client HTTP de + * Playwright : le cookie de session est marqué `Secure`, et ce client ne + * l'émet pas sur http, ce qui ferait passer les refus pour de bonnes raisons + * sans rien prouver. + */ + +/** Un PDF minimal mais authentique : le type est contrôlé au dépôt. */ +const PDF = Buffer.from( + '%PDF-1.4\n1 0 obj<>endobj\ntrailer<>\n%%EOF\n', + 'ascii', +); + +async function createEmployee(page: Page, tag: string) { + const suffix = `${Date.now()}-${tag}`; + const lastName = `Dossier${suffix}`; + + await page.goto('/equipe'); + const form = page.locator('form').filter({ hasText: 'Ajouter' }); + await form.getByLabel('Prénom').fill('Inès'); + await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Matricule').fill(`DOC${suffix}`); + await form.getByRole('button', { name: 'Ajouter' }).click(); + await expect(page.getByText('Salarié ajouté.')).toBeVisible(); + + await page.getByRole('link', { name: new RegExp(lastName) }).click(); + await expect(page.getByRole('heading', { name: new RegExp(lastName) })).toBeVisible(); + return { lastName, url: page.url() }; +} + +/** + * Récupère une pièce depuis la page elle-même. + * + * `page.goto` sur un PDF passe par la visionneuse intégrée de Chromium, qui ne + * restitue pas le corps ; une requête émise depuis la page contourne cela et + * emporte les mêmes cookies que la navigation. + */ +async function fetchDocument(page: Page, href: string) { + return page.evaluate(async (target) => { + const response = await fetch(target); + const bytes = new Uint8Array(await response.arrayBuffer()); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return { + status: response.status, + contentType: response.headers.get('content-type'), + base64: btoa(binary), + }; + }, href); +} + +function uploadForm(page: Page) { + return page.locator('form').filter({ hasText: 'Déposer' }); +} + +async function upload(page: Page, category: string, name: string) { + await uploadForm(page).getByLabel('Catégorie').selectOption(category); + await uploadForm(page) + .getByLabel('Fichier') + .setInputFiles({ name, mimeType: 'application/pdf', buffer: PDF }); + await uploadForm(page).getByRole('button', { name: 'Déposer' }).click(); + await expect(page.getByText(`« ${name} » déposé.`)).toBeVisible(); +} + +test('une pièce déposée se relit à l’identique', async ({ page }) => { + await createEmployee(page, 'depot'); + + await expect(page.getByText(/Aucune pièce/)).toBeVisible(); + await upload(page, 'IDENTITY', 'carte-identite.pdf'); + + const link = page.getByRole('link', { name: 'carte-identite.pdf' }); + await expect(link).toBeVisible(); + + const href = (await link.getAttribute('href'))!; + const response = await fetchDocument(page, href); + expect(response.status).toBe(200); + expect(response.contentType).toBe('application/pdf'); + // Le contenu traverse un chiffrement au repos : il doit revenir tel quel. + expect(Buffer.from(response.base64, 'base64').equals(PDF)).toBe(true); +}); + +test('un arrêt de travail est signalé comme donnée de santé', async ({ + page, +}) => { + await createEmployee(page, 'sante'); + await upload(page, 'SICK_NOTE', 'arret.pdf'); + + // Celui qui ouvre la pièce doit savoir que sa consultation laisse une trace + // nominative — c'est la contrepartie de la journalisation, pas un détail. + await expect(page.getByText('Santé · lecture journalisée')).toBeVisible(); + + const href = (await page + .getByRole('link', { name: 'arret.pdf' }) + .getAttribute('href'))!; + expect((await fetchDocument(page, href)).status).toBe(200); +}); + +test('un lien de téléchargement ne vaut rien sans session', async ({ + page, + browser, +}) => { + await createEmployee(page, 'anonyme'); + await upload(page, 'CONTRACT', 'contrat.pdf'); + + const href = (await page + .getByRole('link', { name: 'contrat.pdf' }) + .getAttribute('href'))!; + + // La signature seule ne suffit pas : un lien recopié dans un message ne doit + // rien ouvrir à qui n'a pas le droit de lire le dossier. + const guest = await browser.newContext({ storageState: undefined }); + const guestPage = await guest.newPage(); + await guestPage.goto(new URL(href, page.url()).toString()); + + // Renvoyé vers la connexion, et sans une once du contenu. + await expect(guestPage).toHaveURL(/connexion/); + expect(await guestPage.content()).not.toContain('%PDF'); + await guest.close(); +}); + +test('un lien altéré ou expiré est refusé', async ({ page }) => { + await createEmployee(page, 'signature'); + await upload(page, 'BANK', 'rib.pdf'); + + const href = (await page + .getByRole('link', { name: 'rib.pdf' }) + .getAttribute('href'))!; + const url = new URL(href, page.url()); + + // Signature falsifiée. + const forged = new URL(url); + forged.searchParams.set('s', 'signature-inventee'); + expect((await fetchDocument(page, forged.toString())).status).toBe(400); + + // Échéance repoussée à la main : la signature ne couvre plus la valeur. + const extended = new URL(url); + extended.searchParams.set('e', String(Math.floor(Date.now() / 1000) + 86_400)); + expect((await fetchDocument(page, extended.toString())).status).toBe(400); + + // Échéance passée : refusée aussi, mais après contrôle de la signature — + // répondre « expiré » à un lien fabriqué indiquerait qu'il aurait pu marcher. + const stale = new URL(url); + stale.searchParams.set('e', '1'); + expect((await fetchDocument(page, stale.toString())).status).toBe(400); +}); + +test('un format non accepté est refusé', async ({ page }) => { + await createEmployee(page, 'format'); + + await uploadForm(page) + .getByLabel('Fichier') + .setInputFiles({ + name: 'macro.xls', + mimeType: 'application/vnd.ms-excel', + buffer: Buffer.from('sans importance'), + }); + await uploadForm(page).getByRole('button', { name: 'Déposer' }).click(); + + await expect(page.getByText(/Format non accepté/)).toBeVisible(); + await expect(page.getByText(/Aucune pièce/)).toBeVisible(); +}); + +test('une pièce retirée n’est plus servie', async ({ page }) => { + await createEmployee(page, 'retrait'); + await upload(page, 'OTHER', 'note.pdf'); + + const href = (await page + .getByRole('link', { name: 'note.pdf' }) + .getAttribute('href'))!; + + await page + .locator('li') + .filter({ hasText: 'note.pdf' }) + .getByRole('button', { name: 'Retirer' }) + .click(); + await expect(page.getByText('Pièce retirée.')).toBeVisible(); + + await expect(page.getByText(/Aucune pièce/)).toBeVisible(); + + // Le lien signé reste valide dans sa fenêtre, mais le document ne l'est plus. + expect((await fetchDocument(page, href)).status).toBe(404); +}); diff --git a/tests/unit/documents.test.ts b/tests/unit/documents.test.ts new file mode 100644 index 0000000..57c5b74 --- /dev/null +++ b/tests/unit/documents.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + asciiFileName, + CATEGORY_LABELS, + DOCUMENT_CATEGORIES, + formatBytes, + isLinkExpired, + isSensitiveCategory, + MAX_DOCUMENT_BYTES, + sanitiseFileName, + uploadProblem, +} from '@/domain/documents/rules'; + +describe('catégories', () => { + it('portent toutes un libellé', () => { + // Une catégorie sans libellé s'afficherait en majuscules anglaises dans un + // dossier RH français. + for (const category of DOCUMENT_CATEGORIES) { + expect(CATEGORY_LABELS[category]).toBeTruthy(); + } + }); + + it('classent l’arrêt de travail en donnée de santé', () => { + expect(isSensitiveCategory('SICK_NOTE')).toBe(true); + }); + + it('n’étendent pas la sensibilité à tout le dossier', () => { + // Marquer tout comme sensible noierait les lectures qui comptent vraiment + // sous un journal que personne ne relit. + expect(isSensitiveCategory('IDENTITY')).toBe(false); + expect(isSensitiveCategory('CONTRACT')).toBe(false); + }); +}); + +describe('contrôle du dépôt', () => { + const valid = { + name: 'attestation.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024, + }; + + it('accepte un PDF ordinaire', () => { + expect(uploadProblem(valid)).toBeNull(); + }); + + it('refuse un fichier vide', () => { + expect(uploadProblem({ ...valid, sizeBytes: 0 })).toMatch(/vide/); + }); + + it('refuse au-delà de la taille maximale', () => { + expect( + uploadProblem({ ...valid, sizeBytes: MAX_DOCUMENT_BYTES + 1 }), + ).toMatch(/20 Mio/); + expect(uploadProblem({ ...valid, sizeBytes: MAX_DOCUMENT_BYTES })).toBeNull(); + }); + + it('refuse un format hors de la liste', () => { + // Les formats bureautiques à macros n'ont aucun usage ici. + expect( + uploadProblem({ ...valid, mimeType: 'application/vnd.ms-excel' }), + ).toMatch(/Format non accepté/); + expect(uploadProblem({ ...valid, mimeType: 'text/html' })).toMatch( + /Format non accepté/, + ); + }); + + it('accepte les images de scan', () => { + for (const mimeType of ['image/jpeg', 'image/png', 'image/webp', 'image/heic']) { + expect(uploadProblem({ ...valid, mimeType })).toBeNull(); + } + }); +}); + +describe('nom de fichier', () => { + it('ne garde que le dernier segment', () => { + // Le nom ne désigne jamais l'emplacement — celui-ci est tiré au sort — mais + // le nettoyer évite qu'un chemin s'affiche tel quel dans le dossier. + expect(sanitiseFileName('../../etc/passwd')).toBe('passwd'); + expect(sanitiseFileName('C:\\Users\\moi\\carte.pdf')).toBe('carte.pdf'); + }); + + it('retire ce qui casserait l’en-tête de téléchargement', () => { + // Un guillemet ou un retour à la ligne permettrait d'injecter un second + // champ dans `Content-Disposition`. + expect(sanitiseFileName('rib".pdf')).toBe('rib.pdf'); + expect(sanitiseFileName('note\r\ninjectée.pdf')).toBe('noteinjectée.pdf'); + }); + + it('borne la longueur', () => { + expect(sanitiseFileName(`${'a'.repeat(300)}.pdf`).length).toBe(120); + }); + + it('ne rend jamais une chaîne vide', () => { + expect(sanitiseFileName(' ')).toBe('document'); + expect(sanitiseFileName('/')).toBe('document'); + }); + + it('produit un repli ASCII pour l’en-tête', () => { + expect(asciiFileName('attestation été.pdf')).toBe('attestation ete.pdf'); + expect(asciiFileName('相片.png')).toBe('__.png'); + }); +}); + +describe('taille lisible', () => { + it('choisit l’unité', () => { + expect(formatBytes(512)).toBe('512 o'); + expect(formatBytes(2048)).toBe('2 Kio'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 Mio'); + }); +}); + +describe('expiration d’un lien', () => { + it('expire à la seconde annoncée', () => { + const now = 1_700_000_000_000; + expect(isLinkExpired(now / 1000, now)).toBe(true); + expect(isLinkExpired(now / 1000 + 1, now)).toBe(false); + }); +});