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