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; raw: string; body: Record; } interface Receiver { url: string; calls: ReceivedCall[]; setStatus: (status: number) => void; close: () => Promise; } async function startReceiver(): Promise { 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, }); 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 { 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); }); });