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>
217 lines
6.8 KiB
TypeScript
217 lines
6.8 KiB
TypeScript
import { createHmac } from 'node:crypto';
|
|
|
|
import Fastify from 'fastify';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import type { WebhookConfig } from '../src/config.js';
|
|
import { createNotifier, type NotifierLogger } from '../src/lib/notifications.js';
|
|
|
|
const silentLog: NotifierLogger = {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
};
|
|
|
|
interface ReceivedCall {
|
|
headers: Record<string, string | string[] | undefined>;
|
|
raw: string;
|
|
body: Record<string, unknown>;
|
|
}
|
|
|
|
interface Receiver {
|
|
url: string;
|
|
calls: ReceivedCall[];
|
|
setStatus: (status: number) => void;
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
async function startReceiver(): Promise<Receiver> {
|
|
const calls: ReceivedCall[] = [];
|
|
let status = 200;
|
|
const app = Fastify({ logger: false });
|
|
// Corps brut : on vérifie la signature sur les octets exacts reçus.
|
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
|
done(null, body);
|
|
});
|
|
app.post('/hook', (request, reply) => {
|
|
const raw = request.body as string;
|
|
calls.push({
|
|
headers: request.headers,
|
|
raw,
|
|
body: JSON.parse(raw) as Record<string, unknown>,
|
|
});
|
|
return reply.status(status).send({ ok: status < 400 });
|
|
});
|
|
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,
|
|
setStatus: (next: number) => {
|
|
status = next;
|
|
},
|
|
close: () => app.close(),
|
|
};
|
|
}
|
|
|
|
function webhook(url: string, over: Partial<WebhookConfig> = {}): WebhookConfig {
|
|
return { url, events: ['upload', 'error'], headers: {}, timeoutMs: 2000, ...over };
|
|
}
|
|
|
|
const UPLOAD_INPUT = {
|
|
target: 'videos',
|
|
storedName: 'vac/plage/clip.mp4',
|
|
originalName: 'clip.mp4',
|
|
size: 5 * 1024 * 1024,
|
|
mimeType: 'video/mp4',
|
|
user: 'user-123',
|
|
via: 'chunked' as const,
|
|
};
|
|
|
|
let receiver: Receiver;
|
|
|
|
afterEach(async () => {
|
|
await receiver.close();
|
|
});
|
|
|
|
describe('createNotifier', () => {
|
|
it('POSTe un corps structuré avec résumé lisible sur upload finalisé', async () => {
|
|
receiver = await startReceiver();
|
|
const notifier = createNotifier([webhook(receiver.url)], silentLog);
|
|
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
await notifier.close();
|
|
|
|
expect(receiver.calls).toHaveLength(1);
|
|
const call = receiver.calls[0]!;
|
|
expect(call.headers['content-type']).toMatch(/^application\/json/);
|
|
expect(call.headers['x-mediadrop-event']).toBe('upload');
|
|
expect(call.headers['user-agent']).toBe('mediaDrop-webhook');
|
|
expect(call.body).toMatchObject({
|
|
event: 'upload',
|
|
target: 'videos',
|
|
storedName: 'vac/plage/clip.mp4',
|
|
originalName: 'clip.mp4',
|
|
size: 5 * 1024 * 1024,
|
|
mimeType: 'video/mp4',
|
|
user: 'user-123',
|
|
via: 'chunked',
|
|
});
|
|
expect(typeof call.body.timestamp).toBe('string');
|
|
expect(call.body.text).toBe(call.body.content);
|
|
expect(call.body.text).toContain('clip.mp4');
|
|
expect(call.body.text).toContain('5.0 Mo');
|
|
});
|
|
|
|
it('envoie un corps `error` avec statut, méthode et chemin', async () => {
|
|
receiver = await startReceiver();
|
|
const notifier = createNotifier([webhook(receiver.url)], silentLog);
|
|
|
|
notifier.uploadFailed({
|
|
message: 'Type de média non autorisé : application/zip',
|
|
statusCode: 400,
|
|
method: 'POST',
|
|
path: '/upload',
|
|
target: 'videos',
|
|
});
|
|
await notifier.close();
|
|
|
|
expect(receiver.calls[0]!.body).toMatchObject({
|
|
event: 'error',
|
|
message: 'Type de média non autorisé : application/zip',
|
|
statusCode: 400,
|
|
method: 'POST',
|
|
path: '/upload',
|
|
target: 'videos',
|
|
});
|
|
expect(receiver.calls[0]!.headers['x-mediadrop-event']).toBe('error');
|
|
});
|
|
|
|
it('respecte le filtre `events` de chaque webhook', async () => {
|
|
receiver = await startReceiver();
|
|
const notifier = createNotifier([webhook(receiver.url, { events: ['error'] })], silentLog);
|
|
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
notifier.uploadFailed({ message: 'boom', statusCode: 500, method: 'PUT', path: '/upload/x/0' });
|
|
await notifier.close();
|
|
|
|
expect(receiver.calls).toHaveLength(1);
|
|
expect(receiver.calls[0]!.body.event).toBe('error');
|
|
});
|
|
|
|
it('signe le corps avec HMAC-SHA256 quand `secret` est défini', async () => {
|
|
receiver = await startReceiver();
|
|
const notifier = createNotifier([webhook(receiver.url, { secret: 'top-secret' })], silentLog);
|
|
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
await notifier.close();
|
|
|
|
const call = receiver.calls[0]!;
|
|
const expected = 'sha256=' + createHmac('sha256', 'top-secret').update(call.raw).digest('hex');
|
|
expect(call.headers['x-mediadrop-signature']).toBe(expected);
|
|
});
|
|
|
|
it('transmet les en-têtes personnalisés', async () => {
|
|
receiver = await startReceiver();
|
|
const notifier = createNotifier(
|
|
[webhook(receiver.url, { headers: { authorization: 'Bearer jeton', 'x-src': 'md' } })],
|
|
silentLog,
|
|
);
|
|
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
await notifier.close();
|
|
|
|
expect(receiver.calls[0]!.headers.authorization).toBe('Bearer jeton');
|
|
expect(receiver.calls[0]!.headers['x-src']).toBe('md');
|
|
});
|
|
|
|
it('réessaie une fois sur 5xx puis abandonne sans lever', async () => {
|
|
receiver = await startReceiver();
|
|
receiver.setStatus(503);
|
|
const notifier = createNotifier([webhook(receiver.url)], silentLog);
|
|
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
await expect(notifier.close()).resolves.toBeUndefined();
|
|
|
|
expect(receiver.calls).toHaveLength(2);
|
|
});
|
|
|
|
it('ne réessaie pas sur une erreur cliente 4xx', async () => {
|
|
receiver = await startReceiver();
|
|
receiver.setStatus(404);
|
|
const notifier = createNotifier([webhook(receiver.url)], silentLog);
|
|
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
await notifier.close();
|
|
|
|
expect(receiver.calls).toHaveLength(1);
|
|
});
|
|
|
|
it('livre à plusieurs webhooks en parallèle', async () => {
|
|
receiver = await startReceiver();
|
|
const notifier = createNotifier(
|
|
[webhook(receiver.url), webhook(receiver.url, { headers: { 'x-n': '2' } })],
|
|
silentLog,
|
|
);
|
|
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
await notifier.close();
|
|
|
|
expect(receiver.calls).toHaveLength(2);
|
|
});
|
|
|
|
it('sans webhook configuré : non-opération, close() résout', async () => {
|
|
receiver = await startReceiver();
|
|
const notifier = createNotifier([], silentLog);
|
|
|
|
expect(() => {
|
|
notifier.uploadCompleted(UPLOAD_INPUT);
|
|
notifier.uploadFailed({ message: 'x', statusCode: 500, method: 'POST', path: '/upload' });
|
|
}).not.toThrow();
|
|
await expect(notifier.close()).resolves.toBeUndefined();
|
|
expect(receiver.calls).toHaveLength(0);
|
|
});
|
|
});
|