import { readFile, readdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { buildApp } from '../src/app.js'; import type { OidcConfig } from '../src/config.js'; import { makeConfig, makeTempDir, removeDir, startStubIdp, type StubIdp } from './helpers.js'; const MIB = 1024 * 1024; let idp: StubIdp; let cookie: string; let app: FastifyInstance; let baseUrl: string; let dir: string; function oidcOverride(ref: StubIdp): OidcConfig { return { issuer: ref.issuer, clientId: ref.clientId, clientSecret: ref.clientSecret, scopes: ['openid', 'profile', 'email'], }; } function sessionCookie(res: Response): string { return (res.headers.get('set-cookie') ?? '').split(';')[0] ?? ''; } /** Déroule la connexion OIDC via de vraies requêtes HTTP ; renvoie l'en-tête Cookie. */ async function loginViaHttp(): Promise { const start = await fetch(`${baseUrl}/auth/login`, { redirect: 'manual' }); const txn = sessionCookie(start); const authUrl = new URL(start.headers.get('location') ?? ''); idp.setNextIdToken({ nonce: authUrl.searchParams.get('nonce') ?? '' }); const state = authUrl.searchParams.get('state') ?? ''; const cb = await fetch(`${baseUrl}/auth/callback?code=x&state=${encodeURIComponent(state)}`, { redirect: 'manual', headers: { cookie: txn }, }); return sessionCookie(cb); } beforeAll(async () => { idp = await startStubIdp(); }); afterAll(async () => { await idp.close(); }); beforeEach(async () => { dir = await makeTempDir(); app = await buildApp( makeConfig({ targets: [ { name: 'flat', label: 'Plat', dir, allowDirectories: false }, { name: 'tree', label: 'Arbo', dir, allowDirectories: true }, ], maxFileSizeBytes: 8 * MIB, allowedMimeTypes: ['video/*', 'image/png'], oidc: oidcOverride(idp), }), { logger: false }, ); await app.listen({ port: 0, host: '127.0.0.1' }); const address = app.server.address(); if (address === null || typeof address === 'string') throw new Error('adresse inattendue'); baseUrl = `http://127.0.0.1:${String(address.port)}`; cookie = await loginViaHttp(); }); afterEach(async () => { await app.close(); await removeDir(dir); }); function initBody(over: Record = {}): Record { return { target: 'flat', name: 'clip.mp4', size: 3, type: 'video/mp4', ...over }; } interface InitResult { status: number; body: { id?: string; chunkSize?: number; chunkCount?: number; message?: string }; } async function init(over: Record = {}, withCookie = true): Promise { const res = await fetch(`${baseUrl}/upload/init`, { method: 'POST', headers: { 'content-type': 'application/json', ...(withCookie ? { cookie } : {}) }, body: JSON.stringify(initBody(over)), }); return { status: res.status, body: (await res.json().catch(() => ({}))) as InitResult['body'] }; } async function putChunk(id: string, index: number, data: Buffer): Promise { const res = await fetch(`${baseUrl}/upload/${id}/${String(index)}`, { method: 'PUT', headers: { 'content-type': 'application/octet-stream', cookie }, body: new Uint8Array(data), }); return res.status; } async function complete(id: string): Promise<{ status: number; body: unknown }> { const res = await fetch(`${baseUrl}/upload/${id}/complete`, { method: 'POST', headers: { cookie }, }); return { status: res.status, body: await res.json().catch(() => ({})) }; } describe('upload fragmenté', () => { it('init -> morceau -> complete : le fichier est finalisé au bon endroit', async () => { const data = randomBytes(200 * 1024); const started = await init({ size: data.length, chunkSize: MIB, path: 'a/b/clip.mp4', target: 'tree', }); expect(started.status).toBe(201); const { id, chunkCount } = started.body; expect(chunkCount).toBe(1); const ack = await fetch(`${baseUrl}/upload/${id!}/0`, { method: 'PUT', headers: { 'content-type': 'application/octet-stream', cookie }, body: new Uint8Array(data), }); expect(ack.status).toBe(200); expect(await ack.json()).toEqual({ received: 1, total: 1 }); const done = await complete(id!); expect(done.status).toBe(201); expect(done.body).toMatchObject({ storedName: 'a/b/clip.mp4', size: data.length }); expect(await readFile(join(dir, 'a', 'b', 'clip.mp4'))).toEqual(data); expect(await readdir(join(dir, 'a', 'b'))).toEqual(['clip.mp4']); }); it('init lit target et path dans la query (comme le client web)', async () => { const data = randomBytes(1000); const query = new URLSearchParams({ target: 'tree', path: 'dossier/clip.mp4' }); const res = await fetch(`${baseUrl}/upload/init?${query.toString()}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ name: 'clip.mp4', size: data.length, type: 'video/mp4' }), }); expect(res.status).toBe(201); const { id } = (await res.json()) as { id: string }; expect(await putChunk(id, 0, data)).toBe(200); const done = await complete(id); expect(done.body).toMatchObject({ target: 'tree', storedName: 'dossier/clip.mp4' }); expect(await readFile(join(dir, 'dossier', 'clip.mp4'))).toEqual(data); }); it('init refuse une cible inconnue passée dans la query (400)', async () => { const res = await fetch(`${baseUrl}/upload/init?target=inconnue`, { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ name: 'clip.mp4', size: 3, type: 'video/mp4' }), }); expect(res.status).toBe(400); }); it('PUT renvoie 404 (et non 500) si le fichier partiel a disparu', async () => { const { body } = await init({ size: MIB, chunkSize: MIB }); const [part] = await readdir(dir); await rm(join(dir, part!)); expect(await putChunk(body.id!, 0, Buffer.alloc(10))).toBe(404); }); it('init refuse une taille au-dessus de la limite (413)', async () => { expect((await init({ size: 9 * MIB })).status).toBe(413); }); it('init refuse un type non autorisé (400)', async () => { expect((await init({ type: 'application/zip' })).status).toBe(400); }); it('init refuse un chemin sur une cible sans allowDirectories (400)', async () => { expect((await init({ target: 'flat', path: 'x/clip.mp4' })).status).toBe(400); }); it('init exige une session (401)', async () => { expect((await init({}, false)).status).toBe(401); }); it('PUT refuse un index hors limites (400)', async () => { const { body } = await init({ size: MIB, chunkSize: MIB }); expect(await putChunk(body.id!, 5, Buffer.alloc(10))).toBe(400); }); it('complete refuse un upload incomplet (400) et ne finalise rien', async () => { const data = randomBytes(2 * MIB + 10); const { body } = await init({ size: data.length, chunkSize: MIB }); expect(body.chunkCount).toBe(3); expect(await putChunk(body.id!, 0, data.subarray(0, MIB))).toBe(200); expect((await complete(body.id!)).status).toBe(400); const files = await readdir(dir); expect(files).toHaveLength(1); expect(files[0]).toMatch(/\.mediadrop-part$/); }); it('404 sur une session inconnue', async () => { expect(await putChunk('inexistante', 0, Buffer.alloc(10))).toBe(404); expect((await complete('inexistante')).status).toBe(404); }); it('DELETE abandonne : le fichier partiel disparaît, la session aussi', async () => { const { body } = await init({ size: MIB, chunkSize: MIB }); expect(await putChunk(body.id!, 0, Buffer.alloc(1000))).toBe(200); const del = await fetch(`${baseUrl}/upload/${body.id!}`, { method: 'DELETE', headers: { cookie }, }); expect(del.status).toBe(204); expect(await readdir(dir)).toHaveLength(0); expect(await putChunk(body.id!, 0, Buffer.alloc(10))).toBe(404); }); });