mediaDrop/tests/webhooks-integration.test.ts
Nicolas bdae13ba95 feat(webhooks): nom du fichier dans la notification d'échec
- 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>
2026-10-14 08:00:07 +02:00

250 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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