mediaDrop/tests/upload-chunked.test.ts
Nicolas f637ad4eeb feat(upload): reprise des morceaux en échec via « Réessayer »
Ajoute GET /upload/:id (morceaux reçus). Le client conserve la session
après échec et ne renvoie que les morceaux manquants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:23:53 +02:00

261 lines
9.2 KiB
TypeScript

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<string> {
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<string, unknown> = {}): Record<string, unknown> {
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<string, unknown> = {}, withCookie = true): Promise<InitResult> {
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<number> {
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('GET /upload/:id liste les morceaux reçus et permet de reprendre', async () => {
const data = randomBytes(MIB + 10);
const { body } = await init({
size: data.length,
chunkSize: MIB,
target: 'tree',
path: 'a/clip.mp4',
});
expect(body.chunkCount).toBe(2);
expect(await putChunk(body.id!, 1, data.subarray(MIB))).toBe(200);
const state = await fetch(`${baseUrl}/upload/${body.id!}`, { headers: { cookie } });
expect(state.status).toBe(200);
expect(await state.json()).toEqual({
id: body.id,
chunkSize: MIB,
chunkCount: 2,
received: [1],
});
expect(await putChunk(body.id!, 0, data.subarray(0, MIB))).toBe(200);
const done = await complete(body.id!);
expect(done.status).toBe(201);
expect(await readFile(join(dir, 'a', 'clip.mp4'))).toEqual(data);
}, 20_000);
it('GET /upload/:id : 404 si inconnue, 401 sans session', async () => {
const unknown = await fetch(`${baseUrl}/upload/inexistante`, { headers: { cookie } });
expect(unknown.status).toBe(404);
const { body } = await init({ size: MIB, chunkSize: MIB });
const anonymous = await fetch(`${baseUrl}/upload/${body.id!}`);
expect(anonymous.status).toBe(401);
});
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);
});
});