← back to Costa Rica
test/webhooks-route.test.js
214 lines
'use strict';
// Route-level integration test for the webhook signature gates. The unit tests
// cover wa.verifySignature / provider.verifyWebhook in isolation; THIS proves the
// actual routes/webhooks.js endpoints reject a FORGED webhook with 401 BEFORE any
// DB write, and that a correctly-signed one passes the gate to the right DB write.
// No real DB / network: pool.query is a recording mock, wa.handleInbound stubbed.
// Load order is verified clean — neither lib/db nor lib/whatsapp requires payments,
// so tilopay.js first loads via the routes/webhooks require below, AFTER the secrets
// are set — so verifyWebhook runs the real HMAC branch (a forged→401 pass confirms
// it: the no-secret sandbox branch would return ok:!LIVE=true and 200 instead).
// Run: node --test
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const crypto = require('crypto');
const express = require('express');
// Signing secrets MUST be set before requiring the modules (captured at module load).
process.env.WHATSAPP_APP_SECRET = 'itest-wa-secret';
process.env.TILOPAY_WEBHOOK_SECRET = 'itest-tilo-secret';
process.env.ONVO_WEBHOOK_SECRET = 'itest-onvo-secret';
const db = require('../lib/db');
const wa = require('../lib/whatsapp');
const webhooks = require('../routes/webhooks');
let sqls = [];
// Per-test control: firstTime()'s INSERT ... ON CONFLICT DO NOTHING returns rowCount:1
// (first-seen) or 0 (duplicate). Default 1; a test can flip the NEXT webhook_events
// insert to 0 to simulate a replay. Everything else returns rowCount:1 as before.
let nextWebhookInsertRowCount = null;
let throwOnSql = null; // a test can set a regex to make matching queries throw (simulate a DB error mid-processing)
const origQuery = db.pool.query;
const origHandle = wa.handleInbound;
let server, base;
before(async () => {
db.pool.query = async (sql) => {
sqls.push(sql);
if (throwOnSql && throwOnSql.test(sql)) throw new Error('simulated DB error');
if (/INSERT INTO webhook_events/.test(sql) && nextWebhookInsertRowCount !== null) {
const rc = nextWebhookInsertRowCount; nextWebhookInsertRowCount = null;
return { rows: [], rowCount: rc };
}
return { rows: [], rowCount: 1 };
};
wa.handleInbound = async () => []; // avoid the DB path inside the handler; we test the GATE
const app = express();
app.use('/webhooks', webhooks);
await new Promise(r => { server = app.listen(0, r); });
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => { db.pool.query = origQuery; wa.handleInbound = origHandle; server && server.close(); });
function req(method, path, rawBody, headers = {}) {
return new Promise((resolve, reject) => {
const opts = { method, headers: { ...headers } };
if (rawBody != null) { opts.headers['content-type'] = 'application/json'; opts.headers['content-length'] = Buffer.byteLength(rawBody); }
const r = http.request(base + path, opts, res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, body: b })); });
r.on('error', reject); r.end(rawBody ?? undefined);
});
}
const post = (p, body, h) => req('POST', p, body, h);
const waSign = (raw) => 'sha256=' + crypto.createHmac('sha256', 'itest-wa-secret').update(raw).digest('hex');
const tiloSign = (raw) => crypto.createHmac('sha256', 'itest-tilo-secret').update(raw).digest('hex'); // raw hex, no prefix
test('SECURITY: forged WhatsApp webhook (bad signature) → 401 and NO DB write', async () => {
sqls = [];
const r = await post('/webhooks/whatsapp', JSON.stringify({ entry: [{ id: '1' }] }), { 'x-hub-signature-256': 'sha256=deadbeef' });
assert.equal(r.status, 401);
assert.equal(sqls.length, 0, 'a forged webhook must be rejected before any DB write');
});
test('SECURITY: unsigned WhatsApp webhook (no header) → 401 and NO DB write', async () => {
sqls = [];
const r = await post('/webhooks/whatsapp', JSON.stringify({ entry: [{ id: '1' }] }));
assert.equal(r.status, 401);
assert.equal(sqls.length, 0);
});
// PER-EVENT ISOLATION (Cody cycle-13 finding, fixed cycle 22): a payload with
// multiple inbound messages must not let a failing auto-reply for message N starve
// the auto-reply for message N+1. Stub handleInbound to return 2 greeting events;
// the first sendButtons throws; assert the second is STILL attempted, and the route
// still 200s to Meta regardless (no retry storm).
test('WhatsApp webhook: a failing auto-reply for one inbound event does NOT skip the next event\'s auto-reply (200 either way)', async () => {
const sendCalls = [];
const origSendButtons = wa.sendButtons;
wa.handleInbound = async () => [
{ contact: { wa_id: '50611111111' }, text: 'hola' },
{ contact: { wa_id: '50622222222' }, text: 'hi there' },
];
wa.sendButtons = async (waId, ...rest) => {
sendCalls.push(waId);
if (waId === '50611111111') throw new Error('simulated send failure for the first event');
return { ok: true };
};
try {
const body = JSON.stringify({ entry: [{ id: 'multi', changes: [{ value: { messages: [{ id: 'm-multi' }] } }] }] });
const r = await post('/webhooks/whatsapp', body, { 'x-hub-signature-256': waSign(body) });
assert.equal(r.status, 200, 'the webhook still 200s to Meta even though one auto-reply failed');
assert.deepEqual(sendCalls, ['50611111111', '50622222222'],
'BOTH events were attempted — the first\'s failure did not skip the second');
} finally {
wa.handleInbound = async () => []; // restore the suite's default stub
wa.sendButtons = origSendButtons;
}
});
test('a correctly-signed WhatsApp webhook passes the gate (200) and does the dedup INSERT', async () => {
sqls = [];
const body = JSON.stringify({ entry: [{ id: 'abc', changes: [{ value: { messages: [{ id: 'm1' }] } }] }] });
const r = await post('/webhooks/whatsapp', body, { 'x-hub-signature-256': waSign(body) });
assert.equal(r.status, 200);
assert.ok(sqls.some(s => /INSERT INTO webhook_events/.test(s)), 'valid webhook should hit the firstTime() dedup INSERT');
});
test('SECURITY: forged Tilopay payment webhook (no signature) → 401 and NO DB write', async () => {
sqls = [];
const r = await post('/webhooks/tilopay', JSON.stringify({ paymentId: 'x', status: 'succeeded' }));
assert.equal(r.status, 401);
assert.equal(sqls.length, 0, 'a forged payment webhook must not touch the DB (no false payment confirmation)');
});
test('SECURITY: forged ONVO payment webhook (no signature) → 401 and NO DB write', async () => {
sqls = [];
const r = await post('/webhooks/onvo', JSON.stringify({ id: 'x', status: 'succeeded' }));
assert.equal(r.status, 401);
assert.equal(sqls.length, 0);
});
test('a correctly-signed Tilopay webhook passes the gate (200) and reaches the payments UPDATE (the money path)', async () => {
sqls = [];
const body = JSON.stringify({ paymentId: 'ref-123', status: 'processing' }); // unknown ref → getCharge sandbox = processing (no confirmBooking)
const r = await post('/webhooks/tilopay', body, { 'x-tilopay-signature': tiloSign(body) });
assert.equal(r.status, 200);
assert.ok(sqls.some(s => /INSERT INTO webhook_events/.test(s)), 'valid payment webhook should dedup-insert');
assert.ok(sqls.some(s => /UPDATE payments/.test(s)), 'valid payment webhook should reach the payments UPDATE');
});
test('GET /webhooks/whatsapp challenge: correct verify_token echoes the challenge; wrong → 403', async () => {
const okr = await req('GET', '/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=cr-verify-sandbox&hub.challenge=987654');
assert.equal(okr.status, 200);
assert.equal(okr.body, '987654');
const bad = await req('GET', '/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=WRONG&hub.challenge=987654');
assert.equal(bad.status, 403);
});
const onvoSign = (raw) => crypto.createHmac('sha256', 'itest-onvo-secret').update(raw).digest('hex');
// P3 — payment webhook replay: the SAME event id twice. The 1st passes the dedup
// INSERT (rowCount 1); the 2nd's INSERT ... ON CONFLICT DO NOTHING returns rowCount 0
// → the handler returns 'dup' and NEVER reaches the payments UPDATE / confirmBooking.
test('P3: a replayed Tilopay webhook (same event id) → "dup" on the 2nd, NO 2nd payments UPDATE', async () => {
const body = JSON.stringify({ paymentId: 'replay-ref-1', status: 'processing' });
const sig = tiloSign(body);
sqls = [];
const first = await post('/webhooks/tilopay', body, { 'x-tilopay-signature': sig });
assert.equal(first.status, 200);
assert.equal(sqls.filter(s => /UPDATE payments/.test(s)).length, 1, 'first delivery reaches the payments UPDATE once');
sqls = [];
nextWebhookInsertRowCount = 0; // simulate the ON CONFLICT DO NOTHING no-op (already seen)
const second = await post('/webhooks/tilopay', body, { 'x-tilopay-signature': sig });
assert.equal(second.status, 200);
assert.equal(second.body, 'dup', 'the replay is recognized as a duplicate');
assert.equal(sqls.filter(s => /UPDATE payments/.test(s)).length, 0, 'a replay must NOT re-run the payments UPDATE / confirmBooking');
});
// RELIABILITY (cycle 12): the idempotency marker is claimed BEFORE processing.
// If processing then fails (a transient DB error), the old code left the marker in
// place -> the provider's retry (on our 500) hit the dedupe gate, got 'dup', and
// the confirmation was lost forever. The fix RELEASES the marker on failure so the
// retry re-processes. Prove: a processing failure -> 500 AND a DELETE webhook_events.
test('RELIABILITY: a processing failure after the dedupe insert releases the marker (retry not swallowed as dup)', async () => {
sqls = [];
throwOnSql = /UPDATE payments/; // DB blows up mid-processing, after the event was claimed
const body = JSON.stringify({ paymentId: 'fail-ref-1', status: 'succeeded' });
const r = await post('/webhooks/tilopay', body, { 'x-tilopay-signature': tiloSign(body) });
throwOnSql = null;
assert.equal(r.status, 500, 'a processing failure returns 500 (retryable), not a false 200');
assert.ok(sqls.some(s => /INSERT INTO webhook_events/.test(s)), 'the event was claimed (dedupe insert ran)');
assert.ok(sqls.some(s => /DELETE FROM webhook_events/.test(s)), 'the marker is RELEASED on failure so the provider retry re-processes (not deduped away)');
});
test('RELIABILITY: a SUCCESSFUL delivery keeps the marker (no spurious release; true replays still dedupe)', async () => {
sqls = [];
const body = JSON.stringify({ paymentId: 'ok-ref-1', status: 'processing' });
const r = await post('/webhooks/tilopay', body, { 'x-tilopay-signature': tiloSign(body) });
assert.equal(r.status, 200);
assert.equal(sqls.some(s => /DELETE FROM webhook_events/.test(s)), false, 'a successful delivery must NOT release the marker');
});
// P6 — R3: a signed-but-malformed (unparseable JSON) payment body. verifyWebhook
// returns { ok:true, event:null }; the route must 400 'bad body' and do NO DB write
// (no phantom firstTime insert, no payments UPDATE, no confirmBooking).
test('P6: signed-but-malformed Tilopay body → 400 and NO DB write (no phantom confirm)', async () => {
sqls = [];
const rawBad = '{ not: valid json ]]]'; // signed, but not parseable JSON
const r = await post('/webhooks/tilopay', rawBad, { 'x-tilopay-signature': tiloSign(rawBad) });
assert.equal(r.status, 400, 'a signed-but-unparseable body must be a clean 400');
assert.equal(sqls.length, 0, 'no webhook_events insert, no payments UPDATE, no confirmBooking on a malformed body');
});
// P7 — cross-signature: an ONVO-signed body delivered to /tilopay. Tilopay's HMAC
// uses a DIFFERENT secret, so the signature check fails → 401, no DB write.
test('P7: an ONVO-signed body POSTed to /webhooks/tilopay → 401 and NO DB write', async () => {
sqls = [];
const body = JSON.stringify({ paymentId: 'x-provider', status: 'succeeded' });
const r = await post('/webhooks/tilopay', body, { 'x-tilopay-signature': onvoSign(body) });
assert.equal(r.status, 401, 'a signature from another provider must not validate on /tilopay');
assert.equal(sqls.length, 0, 'a bad-provider signature must be rejected before any DB write');
});