[object Object]

← back to Costa Rica

costa-rica: Cody-gate fixes on webhook route test — add positive Tilopay money-path test (correct HMAC -> 200 + reaches UPDATE payments, which also proves the secret was captured / HMAC branch fired, not sandbox-accept); add ONVO forged->401 parity + GET /whatsapp challenge route (200 echo / 403); assert the SPECIFIC sql (INSERT webhook_events / UPDATE payments) not just count; wrap mock mutation in before/after with originals restored. 7 tests, suite 68/68 green — TK-10346

024a5563132d485be0563266816f9760b09808e7 · 2026-08-07 16:09:02 -0700 · Steve

Files touched

Diff

commit 024a5563132d485be0563266816f9760b09808e7
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 16:09:02 2026 -0700

    costa-rica: Cody-gate fixes on webhook route test — add positive Tilopay money-path test (correct HMAC -> 200 + reaches UPDATE payments, which also proves the secret was captured / HMAC branch fired, not sandbox-accept); add ONVO forged->401 parity + GET /whatsapp challenge route (200 echo / 403); assert the SPECIFIC sql (INSERT webhook_events / UPDATE payments) not just count; wrap mock mutation in before/after with originals restored. 7 tests, suite 68/68 green — TK-10346
---
 test/webhooks-route.test.js | 67 +++++++++++++++++++++++++++++++++------------
 1 file changed, 49 insertions(+), 18 deletions(-)

diff --git a/test/webhooks-route.test.js b/test/webhooks-route.test.js
index 203b963..c4cb1b6 100644
--- a/test/webhooks-route.test.js
+++ b/test/webhooks-route.test.js
@@ -2,46 +2,53 @@
 // 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. No real DB / network:
-// pool.query is a recording mock, wa.handleInbound is stubbed. Run: node --test
+// 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 (read at module load).
-// (Without a secret these gates fall back to sandbox-accept, which we test elsewhere.)
+// 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');
-
-let sqls = [];
-db.pool.query = async (sql) => { sqls.push(sql); return { rows: [], rowCount: 1 }; };
-wa.handleInbound = async () => [];   // avoid the DB path inside the handler; we test the GATE
-
 const webhooks = require('../routes/webhooks');
 
+let sqls = [];
+const origQuery = db.pool.query;
+const origHandle = wa.handleInbound;
 let server, base;
 before(async () => {
+  db.pool.query = async (sql) => { sqls.push(sql); 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(() => server && server.close());
+after(() => { db.pool.query = origQuery; wa.handleInbound = origHandle; server && server.close(); });
 
-function post(path, rawBody, headers = {}) {
+function req(method, path, rawBody, headers = {}) {
   return new Promise((resolve, reject) => {
-    const req = http.request(base + path,
-      { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(rawBody), ...headers } },
-      res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, body: b })); });
-    req.on('error', reject); req.end(rawBody);
+    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 waSign = (raw) => 'sha256=' + crypto.createHmac('sha256', 'itest-wa-secret').update(raw).digest('hex');
+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 = [];
@@ -57,12 +64,12 @@ test('SECURITY: unsigned WhatsApp webhook (no header) → 401 and NO DB write',
   assert.equal(sqls.length, 0);
 });
 
-test('a correctly-signed WhatsApp webhook passes the gate (200) and reaches the DB dedup insert', async () => {
+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.length >= 1, 'a valid webhook should reach firstTime() and touch the DB');
+  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 () => {
@@ -71,3 +78,27 @@ test('SECURITY: forged Tilopay payment webhook (no signature) → 401 and NO DB
   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);
+});

← 4902558 costa-rica: route-level webhook forgery test (webhooks-route  ·  back to Costa Rica  ·  yoloforever: cycle 6 ledger — route-level webhook forgery te 5e6298b →