← 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');
});