Files
hope-events/backend/tests/webhooks.test.js
T
joshuaandClaude Opus 5.5 c0d01ce993 Outbound webhooks (1.48.0)
Site settings -> Webhooks: admins add https endpoints and choose event
types (registration created/paid/cancelled, payment received/refunded,
ticket checked in, account created, event published). Events are
noticed by a Prisma client extension so every write path is covered,
re-checked before sending, and delivered as signed JSON
(X-CrossEvents-Signature, HMAC-SHA256) with retries, a 30-day delivery
log, Send test and Resend. Public https addresses only; each endpoint
is added to the operator register; kids and volunteer tickets are never
sent. New `webhooks` job. Migration 20261028160000_webhooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:00:59 +02:00

116 lines
6.4 KiB
JavaScript

const mockDb = {
webhookEndpoint: { findMany: jest.fn(), update: jest.fn().mockResolvedValue({}) },
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
user: { findUnique: jest.fn() },
registration: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const crypto = require('crypto');
const webhooks = require('../src/utils/webhooks');
const { encrypt } = require('../src/utils/encryption');
describe('outbound webhooks', () => {
afterEach(() => { delete global.fetch; delete process.env.WEBHOOKS_ALLOW_PRIVATE; jest.clearAllMocks(); });
describe('where they may go', () => {
test('private, loopback and link-local addresses are refused', () => {
for (const ip of ['10.1.2.3', '127.0.0.1', '192.168.0.10', '172.16.5.5', '172.31.255.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '::1', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1']) {
expect([ip, webhooks.isPrivateAddress(ip)]).toEqual([ip, true]);
}
for (const ip of ['8.8.8.8', '172.32.0.1', '93.184.216.34', '2606:4700::1111']) {
expect([ip, webhooks.isPrivateAddress(ip)]).toEqual([ip, false]);
}
});
test('https only, no passwords in the address, no private hosts', () => {
expect(webhooks.cleanUrl(' https://hooks.example.com/x?y=1 ')).toBe('https://hooks.example.com/x?y=1');
expect(() => webhooks.cleanUrl('http://hooks.example.com/x')).toThrow('https://');
expect(() => webhooks.cleanUrl('https://user:pw@hooks.example.com/')).toThrow('password');
expect(() => webhooks.cleanUrl('https://localhost/x')).toThrow('private');
expect(() => webhooks.cleanUrl('https://10.0.0.5/x')).toThrow('private');
expect(() => webhooks.cleanUrl('not a url')).toThrow('https://');
});
test('local testing can allow http and private addresses', () => {
process.env.WEBHOOKS_ALLOW_PRIVATE = 'true';
expect(webhooks.cleanUrl('http://localhost:5199/hook')).toBe('http://localhost:5199/hook');
});
});
test('the signature can be checked by the receiver', () => {
const body = JSON.stringify({ id: 'evt_1', type: 'registration.paid' });
const header = webhooks.signatureHeader('whsec_abc', body, 1_800_000_000_000);
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
expect(t).toBe('1800000000');
expect(v1).toBe(crypto.createHmac('sha256', 'whsec_abc').update(`${t}.${body}`).digest('hex'));
});
test('retries after 1 min, 5 min, 30 min, 2 h and 12 h, then gives up', () => {
expect([1, 2, 3, 4, 5, 6].map(webhooks.retryDelay)).toEqual([60e3, 300e3, 1800e3, 7200e3, 43200e3, null]);
});
describe('sending', () => {
const delivery = (extra = {}) => ({
id: 'd1', endpointId: 'ep1', type: 'registration.paid', attempts: 0, payload: { id: 'evt_1', type: 'registration.paid', data: {} },
endpoint: { id: 'ep1', url: 'https://93.184.216.34/hook', isActive: true, secret: encrypt('whsec_s') }, ...extra,
});
beforeEach(() => {
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 1 });
mockDb.webhookDelivery.update.mockImplementation(async ({ data }) => ({ id: 'd1', ...data }));
});
test('a 2xx answer is delivered, signed, without following redirects', async () => {
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
global.fetch = jest.fn().mockResolvedValue({ status: 204 });
const out = await webhooks.send('d1');
expect(out.status).toBe('succeeded');
const [, init] = global.fetch.mock.calls[0];
expect(init.redirect).toBe('manual');
expect(init.headers['X-CrossEvents-Event']).toBe('registration.paid');
expect(init.headers['X-CrossEvents-Signature']).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/);
expect(mockDb.webhookEndpoint.update.mock.calls[0][0].data).toHaveProperty('lastSuccessAt');
});
test('a failure is retried later, and given up after the last try', async () => {
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
global.fetch = jest.fn().mockResolvedValue({ status: 500 });
expect((await webhooks.send('d1'))).toMatchObject({ status: 'retrying', attempts: 1, lastStatusCode: 500, lastError: 'Answered 500' });
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery({ attempts: 5 }));
global.fetch = jest.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
expect((await webhooks.send('d1'))).toMatchObject({ status: 'failed', attempts: 6, lastError: 'connect ECONNREFUSED' });
});
test('a redirect is not delivered', async () => {
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
global.fetch = jest.fn().mockResolvedValue({ status: 302 });
expect((await webhooks.send('d1')).lastError).toMatch(/redirects are not followed/);
});
test('a delivery someone else is already sending is left alone', async () => {
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 0 });
global.fetch = jest.fn();
expect(await webhooks.send('d1')).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
});
});
test('what happened is checked again, then one delivery per endpoint that wants it', async () => {
webhooks.forgetEndpoints();
mockDb.webhookEndpoint.findMany.mockResolvedValue([{ id: 'a', events: ['user.created'] }, { id: 'b', events: ['registration.paid'] }, { id: 'c', events: ['user.created', 'event.published'] }]);
mockDb.user.findUnique.mockResolvedValue({ id: 'u1', name: 'Thandi', email: 't@test.local', phoneNumber: null, role: 'user', createdAt: new Date() });
// A registration that was paid but changed back before the check: nothing is sent.
mockDb.registration.findUnique.mockResolvedValue({ id: 'r1', status: 'pending' });
mockDb.webhookDelivery.create.mockImplementation(async ({ data }) => ({ id: `d-${data.endpointId}`, ...data }));
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 0 });
webhooks.notice('user.created', 'u1');
webhooks.notice('registration.paid', 'r1');
webhooks.notice('not.a.type', 'x');
const made = await webhooks.flush();
expect(made.map(d => [d.endpointId, d.type, d.userId])).toEqual([['a', 'user.created', 'u1'], ['c', 'user.created', 'u1']]);
expect(made[0].eventId).toBe(made[1].eventId);
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
});
});