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; } interface Receiver { url: string; calls: HookCall[]; close: () => Promise; } async function startReceiver(): Promise { const calls: HookCall[] = []; const app = Fastify({ logger: false }); app.post('/hook', (request, reply) => { const body = request.body as Record; 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 { 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); }); });