[object Object]

← back to Costa Rica

costa-rica: route-level webhook forgery test (webhooks-route.test.js) — proves /webhooks/whatsapp + /webhooks/tilopay reject a forged/unsigned webhook with 401 BEFORE any DB write (no false payment confirmation / no DB mutation from forgery), and a correctly-signed WhatsApp webhook passes the gate to the dedup insert. Mounts the real router, recording-mock pool.query, stubbed handleInbound. 4 tests, suite 65/65 green — TK-10346

49025583feac08bc2bed6f1315a78345738789e3 · 2026-08-07 16:05:25 -0700 · Steve

Files touched

Diff

commit 49025583feac08bc2bed6f1315a78345738789e3
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 16:05:25 2026 -0700

    costa-rica: route-level webhook forgery test (webhooks-route.test.js) — proves /webhooks/whatsapp + /webhooks/tilopay reject a forged/unsigned webhook with 401 BEFORE any DB write (no false payment confirmation / no DB mutation from forgery), and a correctly-signed WhatsApp webhook passes the gate to the dedup insert. Mounts the real router, recording-mock pool.query, stubbed handleInbound. 4 tests, suite 65/65 green — TK-10346
---
 test/webhooks-route.test.js | 73 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 73 insertions(+)

diff --git a/test/webhooks-route.test.js b/test/webhooks-route.test.js
new file mode 100644
index 0000000..203b963
--- /dev/null
+++ b/test/webhooks-route.test.js
@@ -0,0 +1,73 @@
+'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. No real DB / network:
+// pool.query is a recording mock, wa.handleInbound is stubbed. 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.)
+process.env.WHATSAPP_APP_SECRET = 'itest-wa-secret';
+process.env.TILOPAY_WEBHOOK_SECRET = 'itest-tilo-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 server, base;
+before(async () => {
+  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());
+
+function post(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 waSign = (raw) => 'sha256=' + crypto.createHmac('sha256', 'itest-wa-secret').update(raw).digest('hex');
+
+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);
+});
+
+test('a correctly-signed WhatsApp webhook passes the gate (200) and reaches the DB 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');
+});
+
+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)');
+});

← fd3d8d2 costa-rica: column-list all remaining SELECT * (fleet PII-le  ·  back to Costa Rica  ·  costa-rica: Cody-gate fixes on webhook route test — add posi 024a556 →