- nouveau champ `file` (et résumé lisible) dans l'événement `error` - le nom est associé à la requête (bindUploadFile) pour les uploads directs et fragmentés, jamais celui du .part Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
250 lines
7.8 KiB
TypeScript
250 lines
7.8 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
||
|
||
import Fastify, { type FastifyInstance } from 'fastify';
|
||
import FormData from 'form-data';
|
||
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;
|
||
|
||
interface HookCall {
|
||
event: string;
|
||
body: Record<string, unknown>;
|
||
}
|
||
|
||
interface Receiver {
|
||
url: string;
|
||
calls: HookCall[];
|
||
close: () => Promise<void>;
|
||
}
|
||
|
||
async function startReceiver(): Promise<Receiver> {
|
||
const calls: HookCall[] = [];
|
||
const app = Fastify({ logger: false });
|
||
app.post('/hook', (request, reply) => {
|
||
const body = request.body as Record<string, unknown>;
|
||
calls.push({ event: String(body.event), body });
|
||
return reply.status(204).send();
|
||
});
|
||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||
const address = app.server.address();
|
||
if (address === null || typeof address === 'string') {
|
||
await app.close();
|
||
throw new Error('adresse du récepteur inattendue');
|
||
}
|
||
return {
|
||
url: `http://127.0.0.1:${String(address.port)}/hook`,
|
||
calls,
|
||
close: () => app.close(),
|
||
};
|
||
}
|
||
|
||
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] ?? '';
|
||
}
|
||
|
||
let idp: StubIdp;
|
||
let receiver: Receiver;
|
||
let app: FastifyInstance;
|
||
let baseUrl: string;
|
||
let dir: string;
|
||
let cookie: string;
|
||
|
||
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 () => {
|
||
receiver = await startReceiver();
|
||
dir = await makeTempDir();
|
||
app = await buildApp(
|
||
makeConfig({
|
||
targets: [{ name: 'videos', label: 'Vidéos', dir, allowDirectories: true }],
|
||
maxFileSizeBytes: 8 * MIB,
|
||
allowedMimeTypes: ['video/*', 'image/png'],
|
||
oidc: oidcOverride(idp),
|
||
webhooks: [{ url: receiver.url, events: ['upload', 'error'], headers: {}, timeoutMs: 2000 }],
|
||
}),
|
||
{ 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(); // vide les livraisons webhook en attente (onClose -> notifier.close)
|
||
await receiver.close();
|
||
await removeDir(dir);
|
||
});
|
||
|
||
describe('webhooks — intégration', () => {
|
||
it('notifie `upload` après un dépôt direct', async () => {
|
||
const form = new FormData();
|
||
form.append('file', randomBytes(2048), { filename: 'clip.mp4', contentType: 'video/mp4' });
|
||
const res = await fetch(`${baseUrl}/upload?target=videos`, {
|
||
method: 'POST',
|
||
headers: { ...form.getHeaders(), cookie },
|
||
body: form.getBuffer(),
|
||
});
|
||
expect(res.status).toBe(201);
|
||
|
||
await app.close();
|
||
const uploads = receiver.calls.filter((c) => c.event === 'upload');
|
||
expect(uploads).toHaveLength(1);
|
||
expect(uploads[0]!.body).toMatchObject({
|
||
event: 'upload',
|
||
target: 'videos',
|
||
originalName: 'clip.mp4',
|
||
mimeType: 'video/mp4',
|
||
via: 'direct',
|
||
user: 'user-123',
|
||
});
|
||
});
|
||
|
||
it('notifie `error` quand un dépôt échoue (type refusé)', async () => {
|
||
const form = new FormData();
|
||
form.append('file', Buffer.from('PK'), {
|
||
filename: 'x.zip',
|
||
contentType: 'application/zip',
|
||
});
|
||
const res = await fetch(`${baseUrl}/upload?target=videos`, {
|
||
method: 'POST',
|
||
headers: { ...form.getHeaders(), cookie },
|
||
body: form.getBuffer(),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
|
||
await app.close();
|
||
const errors = receiver.calls.filter((c) => c.event === 'error');
|
||
expect(errors).toHaveLength(1);
|
||
expect(errors[0]!.body).toMatchObject({
|
||
event: 'error',
|
||
statusCode: 400,
|
||
method: 'POST',
|
||
path: '/upload',
|
||
target: 'videos',
|
||
file: 'x.zip',
|
||
});
|
||
});
|
||
|
||
it('`error` d’un upload fragmenté : nom du fichier déposé, jamais celui du .part', async () => {
|
||
const init = await fetch(`${baseUrl}/upload/init`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', cookie },
|
||
body: JSON.stringify({
|
||
target: 'videos',
|
||
path: 'a/clip.mp4',
|
||
name: 'clip.mp4',
|
||
size: MIB,
|
||
type: 'video/mp4',
|
||
chunkSize: MIB,
|
||
}),
|
||
});
|
||
const { id } = (await init.json()) as { id: string };
|
||
const res = await fetch(`${baseUrl}/upload/${id}/9`, {
|
||
method: 'PUT',
|
||
headers: { 'content-type': 'application/octet-stream', cookie },
|
||
body: new Uint8Array(10),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
|
||
await app.close();
|
||
const errors = receiver.calls.filter((c) => c.event === 'error');
|
||
expect(errors).toHaveLength(1);
|
||
expect(errors[0]!.body).toMatchObject({ statusCode: 400, file: 'a/clip.mp4' });
|
||
expect(JSON.stringify(errors[0]!.body)).not.toContain('.mediadrop-part');
|
||
expect(String(errors[0]!.body.text)).toContain('a/clip.mp4');
|
||
});
|
||
|
||
it('notifie `upload` (via: chunked) après finalisation fragmentée', async () => {
|
||
const data = randomBytes(200 * 1024);
|
||
const init = await fetch(`${baseUrl}/upload/init`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', cookie },
|
||
body: JSON.stringify({
|
||
target: 'videos',
|
||
path: 'a/clip.mp4',
|
||
name: 'clip.mp4',
|
||
size: data.length,
|
||
type: 'video/mp4',
|
||
chunkSize: MIB,
|
||
}),
|
||
});
|
||
const { id } = (await init.json()) as { id: string };
|
||
await fetch(`${baseUrl}/upload/${id}/0`, {
|
||
method: 'PUT',
|
||
headers: { 'content-type': 'application/octet-stream', cookie },
|
||
body: new Uint8Array(data),
|
||
});
|
||
const done = await fetch(`${baseUrl}/upload/${id}/complete`, {
|
||
method: 'POST',
|
||
headers: { cookie },
|
||
});
|
||
expect(done.status).toBe(201);
|
||
|
||
await app.close();
|
||
const uploads = receiver.calls.filter((c) => c.event === 'upload');
|
||
expect(uploads).toHaveLength(1);
|
||
expect(uploads[0]!.body).toMatchObject({
|
||
event: 'upload',
|
||
target: 'videos',
|
||
storedName: 'a/clip.mp4',
|
||
originalName: 'clip.mp4',
|
||
via: 'chunked',
|
||
});
|
||
});
|
||
|
||
it('ne notifie rien si aucun webhook n’est configuré', async () => {
|
||
const quiet = await buildApp(
|
||
makeConfig({
|
||
targets: [{ name: 'videos', label: 'Vidéos', dir, allowDirectories: true }],
|
||
allowedMimeTypes: ['video/*'],
|
||
oidc: oidcOverride(idp),
|
||
webhooks: [],
|
||
}),
|
||
{ logger: false },
|
||
);
|
||
await quiet.listen({ port: 0, host: '127.0.0.1' });
|
||
const quietAddress = quiet.server.address();
|
||
if (quietAddress === null || typeof quietAddress === 'string') throw new Error('adresse');
|
||
const quietUrl = `http://127.0.0.1:${String(quietAddress.port)}`;
|
||
|
||
const before = receiver.calls.length;
|
||
const res = await fetch(`${quietUrl}/upload?target=videos`, { method: 'POST', body: 'x' });
|
||
expect(res.status).toBe(401);
|
||
await quiet.close();
|
||
expect(receiver.calls.length).toBe(before);
|
||
});
|
||
});
|