mediaDrop/tests/storage.test.ts
Nicolas 1b429049c2
Some checks failed
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Failing after 9s
Upload fragmenté et reprenable pour les gros fichiers
Serveur :
- POST /upload/init -> session { id, chunkSize (16 Mo), chunkCount }
- PUT /upload/:id/:index -> écrit le morceau à son offset (octet-stream brut,
  bufferisé, parser dédié) ; renvoie { received, total }
- POST /upload/:id/complete -> vérifie tous les morceaux + la taille, renomme
  le .mediadrop-part vers le chemin final ; 201 + métadonnées
- DELETE /upload/:id -> abandon, supprime le fichier partiel
- src/lib/upload-sessions.ts : registre en mémoire, purge après 30 min
  d'inactivité, 100 sessions max
- src/lib/targets.ts : resolveTarget / resolvePlacement extraits et partagés
- writeChunkAt / createEmptyFile / finalizeFile / removeFile dans storage.ts
- limites, allowDirectories, jokers MIME et confinement dans la cible
  s'appliquent aussi au chemin fragmenté

Page :
- bascule automatique en fragmenté au-delà de 32 Mo : 3 morceaux en parallèle,
  3 réessais par morceau, progression et annulation agrégées
- makeReporter partagé (débit + ETA) ; job.cancel unifié (simple / fragmenté /
  en file d'attente)

Tests : +tests/upload-chunked.test.ts (9), +writeChunkAt (storage) ; 73 tests.
Vérifié en live : 40 Mio -> 3 morceaux // -> octets exacts, DELETE nettoie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 18:25:27 +02:00

143 lines
4.7 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 { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createEmptyFile,
finalizeFile,
removeFile,
saveFile,
writeChunkAt,
} from '../src/lib/storage.js';
import { PayloadTooLargeError } from '../src/errors.js';
import { makeTempDir, removeDir, TINY_PNG } from './helpers.js';
let uploadDir: string;
beforeEach(async () => {
uploadDir = await makeTempDir();
});
afterEach(async () => {
await removeDir(uploadDir);
});
function params(maxFileSizeBytes = 1024 * 1024): { targetDir: string; maxFileSizeBytes: number } {
return { targetDir: uploadDir, maxFileSizeBytes };
}
describe('saveFile', () => {
it('écrit le fichier et renvoie ses métadonnées', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: 'photo.png',
mimeType: 'image/png',
...params(),
});
expect(stored.size).toBe(TINY_PNG.length);
expect(stored.storedName).toMatch(/^photo-\d+-[0-9a-f]{8}\.png$/);
expect(await readFile(stored.path)).toEqual(TINY_PNG);
});
it('produit un nom sans séparateur de chemin même pour un nom hostile', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: '../../etc/passwd',
mimeType: 'image/png',
...params(),
});
expect(stored.storedName).not.toMatch(/[/\\]/);
expect(stored.storedName).not.toContain('..');
expect(stored.storedName).toMatch(/^passwd-\d+-[0-9a-f]{8}\.png$/);
});
it('assainit les noms avec espaces et accents', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: 'mon joli cliché.PNG',
mimeType: 'image/png',
...params(),
});
expect(stored.storedName).toMatch(/^[A-Za-z0-9_-]+-\d+-[0-9a-f]{8}\.png$/);
});
it('rejette au-delà de la taille maximale sans laisser de fichier partiel', async () => {
await expect(
saveFile({
source: Readable.from(Buffer.alloc(5000, 1)),
originalName: 'big.png',
mimeType: 'image/png',
...params(1000),
}),
).rejects.toBeInstanceOf(PayloadTooLargeError);
expect(await readdir(uploadDir)).toHaveLength(0);
});
});
describe('saveFile — mode dossier (placement)', () => {
it('recrée les sous-dossiers et conserve le nom d’origine', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: 'ignoré.png',
mimeType: 'image/png',
...params(),
placement: { segments: ['vacances', 'plage'], fileName: 'DSC 001.jpg' },
});
expect(stored.storedName).toBe('vacances/plage/DSC 001.jpg');
expect(stored.path).toBe(path.join(uploadDir, 'vacances', 'plage', 'DSC 001.jpg'));
expect(await readFile(stored.path)).toEqual(TINY_PNG);
});
it('écrase un fichier déjà présent au même chemin', async () => {
const common = { originalName: 'x', mimeType: 'image/png', ...params() };
const placement = { segments: ['dir'], fileName: 'a.bin' };
await saveFile({ ...common, source: Readable.from(Buffer.from('aaaa')), placement });
const second = await saveFile({
...common,
source: Readable.from(Buffer.from('bb')),
placement,
});
expect(second.size).toBe(2);
expect(await readFile(second.path)).toEqual(Buffer.from('bb'));
expect(await readdir(path.join(uploadDir, 'dir'))).toEqual(['a.bin']);
});
});
describe('writeChunkAt — réassemblage de morceaux', () => {
it('écrit des morceaux à des offsets disjoints puis finalise, octets exacts', async () => {
const chunkSize = 64 * 1024;
const data = randomBytes(chunkSize * 2 + 777);
const partPath = path.join(uploadDir, 'sub', 'video.bin.part');
await createEmptyFile(partPath);
// Dans le désordre pour vérifier l'indépendance des écritures positionnées.
for (const index of [2, 0, 1]) {
const slice = data.subarray(index * chunkSize, (index + 1) * chunkSize);
const written = await writeChunkAt({
data: slice,
filePath: partPath,
offset: index * chunkSize,
});
expect(written).toBe(slice.length);
}
const finalPath = path.join(uploadDir, 'sub', 'video.bin');
await finalizeFile(partPath, finalPath);
expect(await readFile(finalPath)).toEqual(data);
expect(await readdir(path.join(uploadDir, 'sub'))).toEqual(['video.bin']);
});
it('removeFile ne lève pas si le fichier est absent', async () => {
await expect(removeFile(path.join(uploadDir, 'absent'))).resolves.toBeUndefined();
});
});