Merge pull request #20 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Déposer et consulter les pièces du dossier salarié
This commit is contained in:
16 files changed
+1275
No files matched your search
@@ -21,3 +21,6 @@ blob-report/
|
||||
*.log
|
||||
.tmp/
|
||||
.pgdata/
|
||||
|
||||
# Pièces du dossier salarié, chiffrées au repos
|
||||
storage/
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-sm text-ink-3">
|
||||
Aucune pièce. Un dossier complet porte au minimum une pièce d’identité
|
||||
et un relevé d’identité bancaire.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{documents.map((document) => (
|
||||
<li
|
||||
key={document.id}
|
||||
className="flex flex-wrap items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<a
|
||||
href={document.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-ink-1 underline"
|
||||
>
|
||||
{document.name}
|
||||
</a>
|
||||
<Badge tone="neutral">{CATEGORY_LABELS[document.category]}</Badge>
|
||||
{document.isSensitive ? (
|
||||
// Le dire à l'écran : celui qui ouvre la pièce doit savoir que
|
||||
// sa consultation laisse une trace nominative.
|
||||
<Badge tone="warn">Santé · lecture journalisée</Badge>
|
||||
) : null}
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
{formatBytes(document.sizeBytes)}
|
||||
</span>
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
déposée le {dateFormat.format(document.uploadedAt)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{document.retentionUntil ? (
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
conservée jusqu’au {dateFormat.format(document.retentionUntil)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-micro text-ink-3">
|
||||
échéance de conservation non fixée
|
||||
</span>
|
||||
)}
|
||||
{canManage ? (
|
||||
<form action={remove}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="documentId"
|
||||
value={document.id}
|
||||
/>
|
||||
<Button type="submit">Retirer</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<form
|
||||
action={upload}
|
||||
className="flex flex-wrap items-end gap-3 border-t border-line-1 pt-4"
|
||||
>
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Catégorie
|
||||
</span>
|
||||
<select
|
||||
name="category"
|
||||
defaultValue="IDENTITY"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{DOCUMENT_CATEGORIES.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Fichier
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
required
|
||||
accept={ACCEPTED_MIME_TYPES.join(',')}
|
||||
className="text-sm text-ink-2 file:mr-2 file:rounded-2 file:border file:border-line-2 file:bg-surface-2 file:px-2 file:py-1 file:text-sm"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">
|
||||
PDF ou image, {formatBytes(MAX_DOCUMENT_BYTES)} au plus. Chiffré au
|
||||
repos.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={uploading}>
|
||||
Déposer
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{uploadState.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{uploadState.error}
|
||||
</p>
|
||||
) : null}
|
||||
{uploadState.message ? (
|
||||
<p className="text-xs text-ok-soft-ink">{uploadState.message}</p>
|
||||
) : null}
|
||||
{deleteState.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{deleteState.error}
|
||||
</p>
|
||||
) : null}
|
||||
{deleteState.message ? (
|
||||
<p className="text-xs text-ok-soft-ink">{deleteState.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<DocumentCategory, string> = {
|
||||
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<DocumentCategory>(['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;
|
||||
}
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<DocumentState> {
|
||||
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<DocumentState> {
|
||||
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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<DocumentList> {
|
||||
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'),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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/<uuid>` — 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<StoredFile> {
|
||||
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<ReadResult> {
|
||||
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<void> {
|
||||
await rm(pathFor(fileKey), { force: true });
|
||||
}
|
||||
@@ -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<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user