← back to Costa Rica

test/payments-body-timeout-failclosed.test.js

114 lines

'use strict';
// PRE-FLIGHT #7 fail-closed regression (Cody gate, cycle 6, TK-10346).
//
// The money-path bug: once fetchT bounds the BODY read, a header-fast /
// body-stalled provider makes res.json() throw PROVIDER_TIMEOUT. The adapters'
// refund()/payout() used `res.json().catch(() => ({}))`, which SWALLOWED that
// timeout — and because res.ok was already true (from the fast headers), they
// returned a FABRICATED success:
//   - payout(): {providerRef: undefined, status: 'processing', raw: {}} -> payouts.js
//     writes a stuck 'processing' payout row with a NULL provider_ref that can never
//     reconcile, and its catch/mark-failed logic never fires (no throw ever escaped).
//   - refund(): {status: 'refunded', raw: {}} -> a refund that never confirmed reads
//     as completed.
// These tests prove the fail-CLOSED fix: payout() THROWS, refund() -> 'failed'.
//
// Forces LIVE mode via env + require-cache reset (the adapters read creds at load
// time); global.fetch is faked, so there is zero network and zero real money.

const { test } = require('node:test');
const assert = require('node:assert');

const TILOPAY = require.resolve('../lib/payments/tilopay');

function loadLiveTilopay() {
  process.env.TILOPAY_API_USER = 'u';
  process.env.TILOPAY_API_PASSWORD = 'p';
  process.env.TILOPAY_API_KEY = 'k';
  delete require.cache[TILOPAY];
  return require(TILOPAY);
}
function unloadLiveTilopay() {
  delete process.env.TILOPAY_API_USER;
  delete process.env.TILOPAY_API_PASSWORD;
  delete process.env.TILOPAY_API_KEY;
  delete require.cache[TILOPAY];
}

const realFetch = global.fetch;

// Auth (/login) responds fast; the target endpoint's HEADERS arrive immediately but
// its BODY read hangs until the shared abort signal fires — reproducing the exact
// header-fast / body-stalled failure this PRE-FLIGHT exists to catch.
function fakeFetchStallBody() {
  return (url, opts) => {
    if (String(url).includes('/login')) {
      return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
    }
    return Promise.resolve({
      ok: true,
      status: 200,
      json: () => new Promise((_resolve, reject) => {
        opts.signal.addEventListener('abort', () => {
          const e = new Error('The operation was aborted'); e.name = 'AbortError'; reject(e);
        });
      }),
    });
  };
}

test('tilopay payout(): a body-read timeout THROWS PROVIDER_TIMEOUT (no stuck processing row)', async () => {
  process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
  const tilopay = loadLiveTilopay();
  global.fetch = fakeFetchStallBody();
  try {
    assert.equal(tilopay.liveMode, true, 'adapter is in LIVE mode for this test');
    await assert.rejects(
      () => tilopay.payout({ method: { sinpe_phone: '8888-0000' }, amount: 36000, currency: 'CRC', reference: 'CR-X' }),
      (err) => {
        assert.equal(err.code, 'PROVIDER_TIMEOUT', 'payout fails closed by throwing the timeout, not swallowing it');
        return true;
      },
    );
  } finally {
    global.fetch = realFetch;
    unloadLiveTilopay();
    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
  }
});

test('tilopay refund(): a body-read timeout returns status:failed (not a fabricated refunded)', async () => {
  process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
  const tilopay = loadLiveTilopay();
  global.fetch = fakeFetchStallBody();
  try {
    const r = await tilopay.refund('pay_1', 12000);
    assert.equal(r.status, 'failed', 'a stalled refund body must not read as refunded');
  } finally {
    global.fetch = realFetch;
    unloadLiveTilopay();
    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
  }
});

test('tilopay payout(): an empty/malformed but fully-received 200 body is still tolerated (not a timeout)', async () => {
  process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
  const tilopay = loadLiveTilopay();
  // Body arrives, but is not valid JSON -> res.json() throws a SyntaxError (NOT a
  // PROVIDER_TIMEOUT). This must be tolerated (raw:{}), not failed-closed — the
  // fail-closed branch is strictly for the timeout code.
  global.fetch = (url) => {
    if (String(url).includes('/login')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
    return Promise.resolve({ ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected end of JSON input'); } });
  };
  try {
    const r = await tilopay.payout({ method: { sinpe_phone: '8888-0000' }, amount: 36000, currency: 'CRC', reference: 'CR-Y' });
    assert.equal(r.status, 'processing', 'a fully-received 200 (empty body) stays processing — only a timeout fails closed');
    assert.deepEqual(r.raw, {}, 'tolerated malformed body -> raw {}');
  } finally {
    global.fetch = realFetch;
    unloadLiveTilopay();
    delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
  }
});