Files
planflow/tests/unit/documents.test.ts
T
Claude 29369d4a4e Déposer et consulter les pièces du dossier salarié
Rien ne pouvait être téléversé jusqu'ici : ni pièce d'identité, ni relevé
d'identité bancaire, ni arrêt de travail. Le dossier RH n'existait qu'en
champs de formulaire.

Les fichiers vivent sur disque, chiffrés avec la clé qui protège déjà le NIR et
l'IBAN. Le plan n'exige le chiffrement que des pièces jointes de santé ; les
chiffrer toutes supprime une branche dont l'oubli serait silencieux et ne coûte
rien de plus. Leur emplacement est tiré au sort, jamais dérivé du nom déposé :
la traversée de chemin devient impossible par construction plutôt que par
filtrage, et un filtre s'oublie.

Le caractère sensible se déduit de la catégorie et n'est jamais saisi : laisser
déclarer qu'un arrêt de travail n'est pas une donnée de santé reviendrait à
laisser désactiver la journalisation de sa lecture. Cette lecture est inscrite
au journal avant d'être servie, et l'écran l'annonce — celui qui ouvre la pièce
doit savoir que sa consultation laisse une trace nominative.

Les liens sont signés et durent deux minutes, comme l'exige le plan. La
signature ne remplace pas le contrôle d'accès : la route revérifie session,
capacité et périmètre. Elle s'y ajoute pour qu'un lien recopié dans un message
cesse de fonctionner de lui-même, sans attendre qu'une session expire. La
signature est éprouvée avant l'échéance, sans quoi répondre « expiré » à un lien
fabriqué indiquerait qu'il aurait pu marcher.

L'empreinte du clair est conservée et revérifiée à chaque lecture : servir un
contenu qui ne correspond plus reviendrait à présenter comme authentique une
pièce altérée. Retirer une pièce efface le contenu mais garde la ligne : le
dossier doit conserver trace qu'elle a existé et qui l'a retirée.

Aucune durée de conservation n'est appliquée par défaut — le plan l'interdit
explicitement (§12.5). L'échéance reste nulle et l'écran le dit, plutôt que
d'inventer « cinq ans partout ».

Deux pièges d'outillage rencontrés et documentés dans les tests : le cookie de
session étant marqué Secure, le client HTTP de Playwright ne l'émet pas sur
http et faisait passer les refus pour de bonnes raisons sans rien prouver ; et
la visionneuse PDF intégrée de Chromium ne restitue pas le corps d'une
navigation, ce qui masquait la vérification d'intégrité du contenu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-09 07:31:22 +00:00

120 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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);
});
});