mediaDrop/tests/webhooks-integration.test.ts
Nicolas 8eb0470474
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 10s
Notifications par webhook (upload finalisé / échec)
Nouvelle clé `webhooks` : liste de points de terminaison POST notifiés à
chaque upload finalisé (`upload`, direct comme fragmenté) et à chaque
requête `/upload*` terminée en 4xx/5xx (`error`).

Chaque entrée : `url` (http/https), `events` (défaut : les deux),
`headers` (auth du récepteur), `secret` (signature HMAC-SHA256 dans
`X-MediaDrop-Signature`), `timeoutMs` (défaut 5 s, max 30 s).

Corps JSON structuré + champs `text`/`content` portant un résumé lisible,
pour fonctionner tel quel avec Slack / Discord / ntfy ou un récepteur
custom. Livraison en parallèle, sans bloquer la réponse d'upload ; une
nouvelle tentative sur erreur réseau / 429 / 5xx ; échecs journalisés en
`warn` ; l'arrêt du serveur attend les envois en cours. Sans webhook
configuré, le notifieur est une non-opération.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:29:44 +02:00

220 lines
6.7 KiB
TypeScript
Raw 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',
});
});
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);
});
});