← back to Costa Rica
test/wa-webhook-cooldown.test.js
118 lines
'use strict';
// WhatsApp inbound webhook hardening (Cody cold audit, cycle 30):
// (A) COST GUARD: the keyword auto-reply is a Meta-BILLED send; a per-contact
// cooldown (atomic conditional UPDATE on whatsapp_contacts.last_auto_reply_at)
// caps it so a flood of inbound keywords can't drive unbounded billed sends.
// (B) MARKER RELEASE: if handleInbound (persistence) throws AFTER the idempotency
// marker is claimed, release the marker + 500 so Meta retries — leaving the
// marker + 200 would silently drop the inbound message forever.
// Signed webhook; pool.query mocked (records calls, routes rowCounts); wa.handleInbound
// + wa.sendButtons stubbed.
process.env.WHATSAPP_APP_SECRET = 'itest-wa-secret';
const { test, before, after, beforeEach } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const crypto = require('crypto');
const express = require('express');
const db = require('../lib/db');
const wa = require('../lib/whatsapp');
const webhooks = require('../routes/webhooks');
let calls = [];
let cooldownRowCount = 1; // 1 = won the cooldown slot (send); 0 = within window (skip)
let cooldownThrowRemaining = 0; // >0: the next N cooldown UPDATEs throw (simulate a DB blip)
let inboundImpl = null; // per-test wa.handleInbound
let buttonsSent = 0;
const origQuery = db.pool.query, origInbound = wa.handleInbound, origButtons = wa.sendButtons;
let server, base;
before(async () => {
db.pool.query = async (sql, args) => {
calls.push({ sql, args });
if (/INSERT INTO webhook_events/.test(sql)) return { rows: [], rowCount: 1 }; // firstTime: first-seen
if (/UPDATE whatsapp_contacts SET last_auto_reply_at/.test(sql)) {
if (cooldownThrowRemaining > 0) { cooldownThrowRemaining--; throw new Error('simulated cooldown UPDATE DB blip'); }
return { rows: [], rowCount: cooldownRowCount };
}
return { rows: [], rowCount: 1 };
};
wa.handleInbound = async (body) => (inboundImpl ? inboundImpl(body) : []);
wa.sendButtons = async () => { buttonsSent++; return { messages: [{ id: 'x' }] }; };
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 = origInbound; wa.sendButtons = origButtons; server && server.close(); });
beforeEach(() => { calls = []; buttonsSent = 0; cooldownRowCount = 1; cooldownThrowRemaining = 0; inboundImpl = null; });
const waSign = (raw) => 'sha256=' + crypto.createHmac('sha256', 'itest-wa-secret').update(raw).digest('hex');
function postWa(bodyObj) {
const body = JSON.stringify(bodyObj);
const opts = { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-hub-signature-256': waSign(body) } };
return new Promise((resolve, reject) => {
const r = http.request(base + '/webhooks/whatsapp', 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(body);
});
}
const INBOUND = { entry: [{ id: 'e1', changes: [{ value: { messages: [{ id: 'm1' }] } }] }] };
const oneKeyword = () => [{ contact: { id: 7, wa_id: '50688880000' }, text: 'hola' }];
test('A: an auto-reply fires only when the per-contact cooldown slot is WON (rowCount 1)', async () => {
inboundImpl = oneKeyword; cooldownRowCount = 1;
const r = await postWa(INBOUND);
assert.equal(r.status, 200);
assert.equal(buttonsSent, 1, 'winning the cooldown slot sends the auto-reply');
const upd = calls.find(c => /UPDATE whatsapp_contacts SET last_auto_reply_at/.test(c.sql));
assert.ok(upd, 'the cooldown is claimed via a conditional UPDATE');
assert.match(upd.sql, /interval '60 seconds'/, 'the UPDATE enforces the cooldown window atomically');
});
test('A: an auto-reply within the cooldown window (rowCount 0) is SKIPPED — no billed send', async () => {
inboundImpl = oneKeyword; cooldownRowCount = 0;
const r = await postWa(INBOUND);
assert.equal(r.status, 200);
assert.equal(buttonsSent, 0, 'within the cooldown window, no Meta-billed send fires');
});
test('A: a non-keyword message never even checks the cooldown / sends', async () => {
inboundImpl = () => [{ contact: { id: 7, wa_id: '50688880000' }, text: 'random chatter' }];
const r = await postWa(INBOUND);
assert.equal(r.status, 200);
assert.equal(buttonsSent, 0);
assert.equal(calls.some(c => /UPDATE whatsapp_contacts SET last_auto_reply_at/.test(c.sql)), false);
});
test('E: a cooldown-UPDATE DB error does NOT escape the loop — still 200, no 500 (Cody gate)', async () => {
// If this threw uncaught, it would 500 AFTER the marker is committed -> Meta's
// retry dedupes -> the auto-reply is silently lost. Must stay 200, fail-closed.
inboundImpl = oneKeyword; cooldownThrowRemaining = 1;
const r = await postWa(INBOUND);
assert.equal(r.status, 200, 'a cooldown-UPDATE blip must not 500 (which would dedupe-swallow the retry)');
assert.equal(buttonsSent, 0, 'fail closed: no billed send when the cooldown slot cannot be claimed');
});
test('F: a cooldown-UPDATE error on ONE event does not abort the rest of the batch', async () => {
// Two keyword events; the FIRST cooldown UPDATE throws. The catch+continue must let
// the SECOND event still process (per-event isolation preserved).
inboundImpl = () => [
{ contact: { id: 7, wa_id: '50688880000' }, text: 'hola' },
{ contact: { id: 8, wa_id: '50688881111' }, text: 'help' },
];
cooldownThrowRemaining = 1; // only the 1st cooldown UPDATE throws
const r = await postWa(INBOUND);
assert.equal(r.status, 200);
assert.equal(buttonsSent, 1, 'the 2nd event still gets its auto-reply despite the 1st failing');
});
test('B: handleInbound failure RELEASES the idempotency marker and returns 500 (Meta retries)', async () => {
inboundImpl = () => { throw new Error('transient DB error'); };
const r = await postWa(INBOUND);
assert.equal(r.status, 500, 'a persistence failure must 500 so Meta retries (not silently 200)');
const del = calls.find(c => /DELETE FROM webhook_events/.test(c.sql));
assert.ok(del, 'the idempotency marker must be released on failure');
assert.deepEqual(del.args, ['e1:m1'], 'the released marker matches the claimed evId');
assert.equal(buttonsSent, 0);
});