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