import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { WebhooksService } from './webhooks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DbClient } from '../prisma/db-client'; import { openWebhookSecret } from './webhook-secret'; interface DeliveryRowInput { subscriptionId: string; event: string; body: string; } describe('WebhooksService', () => { let service: WebhooksService; let prisma: { webhookSubscription: { findMany: jest.Mock; findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; delete: jest.Mock; }; webhookDelivery: { createMany: jest.Mock; findMany: jest.Mock; }; $executeRaw: jest.Mock; }; let auditLog: { record: jest.Mock }; beforeEach(() => { process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret-0123456789'; prisma = { webhookSubscription: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, webhookDelivery: { createMany: jest .fn() .mockImplementation(({ data }: { data: DeliveryRowInput[] }) => Promise.resolve({ count: data.length }), ), findMany: jest.fn().mockResolvedValue([]), }, $executeRaw: jest.fn().mockResolvedValue(1), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; service = new WebhooksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, ); }); afterEach(() => jest.restoreAllMocks()); const db = () => prisma as unknown as DbClient; it('create generates a random secret and stores it', async () => { prisma.webhookSubscription.create.mockImplementation( ({ data }: { data: Record }) => ({ id: 'wh-1', ...data, }), ); const subscription = await service.create( { url: 'https://203.0.113.10/hook', events: ['colony.created'] }, 'user-1', ); expect(subscription.secret).toHaveLength(64); const [[createArg]] = prisma.webhookSubscription.create.mock.calls as [ [ { data: { url: string; events: string[]; createdByUserId: string; secret: string; }; }, ], ]; // Stored encrypted, shown in plain only in this one response. expect(createArg.data.secret).not.toContain(subscription.secret); expect(openWebhookSecret(createArg.data.secret)).toBe(subscription.secret); expect(createArg.data.url).toBe('https://203.0.113.10/hook'); expect(createArg.data.events).toEqual(['colony.created']); expect(createArg.data.createdByUserId).toBe('user-1'); }); it('delete throws NotFoundException when the subscription is missing', async () => { prisma.webhookSubscription.findUnique.mockResolvedValue(null); await expect(service.delete('missing', 'user-1')).rejects.toBeInstanceOf( NotFoundException, ); }); describe('enqueueDeliveries', () => { it('enqueueDeliveries writes one row per active subscription for the event and wakes the worker', async () => { prisma.webhookSubscription.findMany.mockResolvedValueOnce([ { id: 'wh-1' }, { id: 'wh-2' }, ]); const payload = { id: 'colony-1', code: 'V-1' }; await expect( service.enqueueDeliveries(db(), 'colony.created', payload), ).resolves.toBe(2); expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({ where: { active: true, events: { has: 'colony.created' } }, select: { id: true }, }); // The body is fixed here, so every retry sends the same bytes. const body = JSON.stringify({ event: 'colony.created', data: payload }); expect(prisma.webhookDelivery.createMany).toHaveBeenCalledWith({ data: [ { subscriptionId: 'wh-1', event: 'colony.created', body }, { subscriptionId: 'wh-2', event: 'colony.created', body }, ], }); expect(prisma.$executeRaw).toHaveBeenCalledTimes(1); const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [ [TemplateStringsArray, ...unknown[]], ]; expect(strings.join('$')).toBe("SELECT pg_notify($, '')"); expect(values).toEqual(['webhook_delivery']); }); it('writes nothing and wakes nobody without a matching subscription', async () => { await expect( service.enqueueDeliveries(db(), 'task.created', { id: 't1' }), ).resolves.toBe(0); expect(prisma.webhookDelivery.createMany).not.toHaveBeenCalled(); expect(prisma.$executeRaw).not.toHaveBeenCalled(); }); it('works on the client it is given, e.g. a handler transaction', async () => { const tx = { webhookSubscription: { findMany: jest.fn().mockResolvedValue([{ id: 'wh-1' }]), }, webhookDelivery: { createMany: jest.fn().mockResolvedValue({ count: 1 }), }, $executeRaw: jest.fn().mockResolvedValue(1), }; await service.enqueueDeliveries( tx as unknown as DbClient, 'stock.changed', { bottlingBatchIds: ['b1'], productIds: [] }, ); expect(tx.webhookDelivery.createMany).toHaveBeenCalledTimes(1); expect(tx.$executeRaw).toHaveBeenCalledTimes(1); expect(prisma.webhookDelivery.createMany).not.toHaveBeenCalled(); expect(prisma.$executeRaw).not.toHaveBeenCalled(); }); }); describe('dispatch (legacy path)', () => { it('enqueues on the root client after the commit and sends nothing itself', async () => { prisma.webhookSubscription.findMany.mockResolvedValueOnce([ { id: 'wh-1' }, ]); await expect( service.dispatch('colony.created', { id: 'colony-1' }), ).resolves.toBeUndefined(); expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({ where: { active: true, events: { has: 'colony.created' } }, select: { id: true }, }); expect(prisma.webhookDelivery.createMany).toHaveBeenCalledWith({ data: [ { subscriptionId: 'wh-1', event: 'colony.created', body: '{"event":"colony.created","data":{"id":"colony-1"}}', }, ], }); expect(prisma.$executeRaw).toHaveBeenCalledTimes(1); }); // dispatch() runs after the event's record is committed: a failure must // not fail that request (the client would retry and store it twice). describe('when enqueuing fails', () => { let errorSpy: jest.SpyInstance; beforeEach(() => { errorSpy = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); }); it('dispatch never throws when enqueuing fails (legacy path)', async () => { prisma.webhookSubscription.findMany.mockResolvedValueOnce([ { id: 'wh-1' }, ]); prisma.webhookDelivery.createMany.mockRejectedValueOnce( new Error('db down'), ); await expect( service.dispatch('colony.created', { id: 'colony-1' }), ).resolves.toBeUndefined(); expect(errorSpy).toHaveBeenCalledTimes(1); const [message] = errorSpy.mock.calls[0] as [string]; expect(message).toContain('colony.created colony-1'); expect(message).toContain('db down'); }); it('never throws when the subscriptions cannot be loaded and logs the record', async () => { prisma.webhookSubscription.findMany.mockRejectedValueOnce( new Error('db down'), ); await expect( service.dispatch('colony.created', { id: 'colony-1' }), ).resolves.toBeUndefined(); const [message] = errorSpy.mock.calls[0] as [string]; expect(message).toContain('colony.created colony-1'); expect(message).toContain('db down'); }); it('also logs a payload without its own id', async () => { prisma.webhookSubscription.findMany.mockRejectedValueOnce( new Error('db down'), ); await service.dispatch('stock.changed', { bottlingBatchIds: ['b-1'], productIds: [], }); const [message] = errorSpy.mock.calls[0] as [string]; expect(message).toContain('stock.changed'); expect(message).toContain('db down'); }); }); }); describe('findDeliveries', () => { it('lists the newest deliveries of a subscription without their body', async () => { prisma.webhookSubscription.findUnique.mockResolvedValueOnce({ id: 'wh-1', }); const rows = [{ id: 'd2' }, { id: 'd1' }]; prisma.webhookDelivery.findMany.mockResolvedValueOnce(rows); await expect(service.findDeliveries('wh-1', 20)).resolves.toBe(rows); expect(prisma.webhookDelivery.findMany).toHaveBeenCalledWith({ where: { subscriptionId: 'wh-1' }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: 20, select: { id: true, event: true, status: true, attempts: true, lastStatusCode: true, lastError: true, createdAt: true, deliveredAt: true, }, }); }); it('throws NotFoundException for an unknown subscription', async () => { prisma.webhookSubscription.findUnique.mockResolvedValueOnce(null); await expect( service.findDeliveries('missing', 50), ).rejects.toBeInstanceOf(NotFoundException); expect(prisma.webhookDelivery.findMany).not.toHaveBeenCalled(); }); }); describe('update', () => { const existing = { id: 'wh-1', url: 'https://203.0.113.10/hook', events: ['colony.created'], secret: 'a'.repeat(64), createdByUserId: 'user-1', }; it('applies the SSRF guard to a changed URL, so an edit is no way around it', async () => { prisma.webhookSubscription.findUnique.mockResolvedValue(existing); await expect( service.update('wh-1', { url: 'http://localhost/hook' }, 'user-1'), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.webhookSubscription.update).not.toHaveBeenCalled(); }); it('keeps the signing secret so a working receiver does not break', async () => { prisma.webhookSubscription.findUnique.mockResolvedValue(existing); prisma.webhookSubscription.update.mockResolvedValue({ ...existing, events: ['colony.created', 'harvest.created'], }); await service.update( 'wh-1', { events: ['colony.created', 'harvest.created'] }, 'user-1', ); const [[updateArgs]] = prisma.webhookSubscription.update.mock.calls as [ [{ data: Record }], ]; expect(updateArgs.data).not.toHaveProperty('secret'); }); it('keeps the secret out of the audit log', async () => { prisma.webhookSubscription.findUnique.mockResolvedValue(existing); prisma.webhookSubscription.update.mockResolvedValue(existing); await service.update('wh-1', { events: ['colony.created'] }, 'user-1'); const [[auditArgs]] = auditLog.record.mock.calls as [ [ { beforeData: Record; afterData: Record; }, ], ]; expect(auditArgs.beforeData).not.toHaveProperty('secret'); expect(auditArgs.afterData).not.toHaveProperty('secret'); }); it('throws NotFoundException for an unknown subscription', async () => { prisma.webhookSubscription.findUnique.mockResolvedValue(null); await expect( service.update('missing', { events: ['colony.created'] }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); }); }); });