import { INestApplication } from '@nestjs/common'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; interface WebhookSubscriptionBody { id: string; url: string; events: string[]; secret: string; active: boolean; } interface ReceivedRequest { body: string; headers: http.IncomingHttpHeaders; } describe('Webhooks (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const adminEmail = `webhooks-e2e-admin-${Date.now()}@example.com`; const staffEmail = `webhooks-e2e-staff-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let adminId: string; let staffId: string; let adminAccessToken: string; let staffAccessToken: string; let subscriptionId: string; let deliverySubscriptionId: string; let receiverServer: http.Server; let receiverUrl: string; let receivedRequests: ReceivedRequest[] = []; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const admin = await prisma.user.create({ data: { email: adminEmail, passwordHash, name: 'Webhooks E2E Admin', role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; const staff = await prisma.user.create({ data: { email: staffEmail, passwordHash, name: 'Webhooks E2E Staff', role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; adminAccessToken = (await login(app, adminEmail, password)).accessToken; staffAccessToken = (await login(app, staffEmail, password)).accessToken; receivedRequests = []; receiverServer = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { receivedRequests.push({ body: Buffer.concat(chunks).toString('utf-8'), headers: req.headers, }); res.writeHead(200); res.end('ok'); }); }); await new Promise((resolve) => receiverServer.listen(0, '127.0.0.1', resolve), ); const address = receiverServer.address(); if (address === null || typeof address === 'string') { throw new Error('Failed to determine receiver server address'); } receiverUrl = `http://127.0.0.1:${address.port}/hook`; }); afterAll(async () => { await new Promise((resolve, reject) => receiverServer.close((error) => (error ? reject(error) : resolve())), ); await prisma.webhookSubscription.deleteMany({ where: { id: { in: [subscriptionId, deliverySubscriptionId] } }, }); await prisma.task.deleteMany({ where: { createdByUserId: { in: [adminId, staffId] } }, }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [adminId, staffId] } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: [adminId, staffId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } }); await app.close(); }); it('rejects non-admin roles from listing webhooks', () => { return request(app.getHttpServer()) .get('/api/v1/webhooks') .set('Authorization', `Bearer ${staffAccessToken}`) .expect(403); }); it('rejects a webhook URL targeting a private or loopback address (SSRF guard)', async () => { await request(app.getHttpServer()) .post('/api/v1/webhooks') .set('Authorization', `Bearer ${adminAccessToken}`) .send({ url: receiverUrl, events: ['task.created'] }) .expect(400); await request(app.getHttpServer()) .post('/api/v1/webhooks') .set('Authorization', `Bearer ${adminAccessToken}`) .send({ url: 'http://169.254.169.254/latest/meta-data/', events: ['task.created'], }) .expect(400); }); it('lets an admin create a webhook subscription with a generated secret', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/webhooks') .set('Authorization', `Bearer ${adminAccessToken}`) .send({ url: 'https://example.com/hook', events: ['task.created'] }) .expect(201); const body = response.body as WebhookSubscriptionBody; subscriptionId = body.id; expect(body.secret).toHaveLength(64); expect(body.events).toEqual(['task.created']); }); it('excludes the secret from the list response', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/webhooks') .set('Authorization', `Bearer ${adminAccessToken}`) .expect(200); const body = response.body as Partial[]; const listed = body.find((entry) => entry.id === subscriptionId); expect(listed?.secret).toBeUndefined(); }); it('delivers an HMAC-signed POST to the subscribed URL when task.created fires', async () => { // Delivery is tested against a real local receiver, so the subscription // is inserted directly (the create() SSRF guard blocks loopback URLs by // design; the guard itself is covered separately above). const subscription = await prisma.webhookSubscription.create({ data: { url: receiverUrl, events: ['task.created'], secret: crypto.randomBytes(32).toString('hex'), createdByUserId: adminId, }, }); deliverySubscriptionId = subscription.id; const taskResponse = await request(app.getHttpServer()) .post('/api/v1/tasks') .set('Authorization', `Bearer ${adminAccessToken}`) .send({ title: `Webhook-Test ${Date.now()}` }) .expect(201); expect(receivedRequests).toHaveLength(1); const delivery = receivedRequests[0]; expect(delivery.headers['x-apicore-event']).toBe('task.created'); const expectedSignature = crypto .createHmac('sha256', subscription.secret) .update(delivery.body) .digest('hex'); expect(delivery.headers['x-apicore-signature']).toBe( `sha256=${expectedSignature}`, ); const payload = JSON.parse(delivery.body) as { event: string; data: { id: string }; }; expect(payload.event).toBe('task.created'); expect(payload.data.id).toBe((taskResponse.body as { id: string }).id); }); it('lets an admin delete a webhook subscription', () => { return request(app.getHttpServer()) .delete(`/api/v1/webhooks/${subscriptionId}`) .set('Authorization', `Bearer ${adminAccessToken}`) .expect(204); }); });