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);
+ });
+});