- init fragmenté : lit target/path dans la query (corps en repli) (#1) - erreurs client interrompu / disque plein / fichier partiel absent mappées en 400/507/404 sans webhook d'échec pour les interruptions (#2) - nettoyage des sessions expirées et des .part orphelins sécurisé, log des unhandledRejection (#2) - plafond de sessions porté à 256 pour l'usage multi-fenêtres (#3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
8 KiB
TypeScript
227 lines
8 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('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);
|
|
});
|
|
});
|