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