mediaDrop/tests/notifications.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

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