← back to Costa Rica
test/payments-timeout.test.js
275 lines
'use strict';
// Timeout wrapper for LIVE provider HTTP (lib/payments/http.js). Proves a hung
// provider connection REJECTS (fail-closed) instead of hanging forever, and
// that a normal response passes through with the timer cleared. Zero network,
// zero live creds: global.fetch is faked per test (node runs this file in its
// own process, so the fake does not leak into other test files).
// GO-LIVE PRE-FLIGHT #4/#5, TK-10346.
const { test } = require('node:test');
const assert = require('node:assert');
const { fetchT, timeoutMs } = require('../lib/payments/http');
const realFetch = global.fetch;
function restore() { global.fetch = realFetch; }
test('fetchT: a hung fetch that honours abort -> rejects with a timeout error (does not hang)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '30';
// Simulate a hung TCP: never resolves on its own, only settles when aborted.
global.fetch = (url, opts) => new Promise((_resolve, reject) => {
opts.signal.addEventListener('abort', () => {
const e = new Error('The operation was aborted'); e.name = 'AbortError'; reject(e);
});
});
try {
const started = Date.now();
await assert.rejects(
() => fetchT('https://provider.test/hang'),
(err) => {
assert.equal(err.code, 'PROVIDER_TIMEOUT');
assert.match(err.message, /timeout after 30ms/);
assert.match(err.message, /provider\.test\/hang/);
return true;
},
);
// Sanity: it resolved via the timeout, not by waiting on the (never-ending) fetch.
assert.ok(Date.now() - started < 2000, 'rejected promptly, not hung');
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: a fast response passes its metadata through (ok/status/headers readable off the wrapper)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
// A Response-like with sync metadata + an async body. The wrapper delegates
// everything but json()/text() straight to this object.
const sentinel = { ok: true, status: 201, marker: 'passthrough', json: async () => ({ id: 'ch_1' }) };
let sawSignal = false;
global.fetch = (url, opts) => { sawSignal = !!(opts && opts.signal); return Promise.resolve(sentinel); };
try {
const res = await fetchT('https://provider.test/ok', { method: 'POST' });
assert.equal(res.ok, true, 'res.ok reads through the wrapper');
assert.equal(res.status, 201, 'res.status reads through the wrapper');
assert.equal(res.marker, 'passthrough', 'arbitrary props read through the wrapper');
assert.deepEqual(await res.json(), { id: 'ch_1' }, 'body read returns the parsed JSON');
assert.ok(sawSignal, 'passes an abort signal down to fetch');
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: res.clone() returns a WRAPPED clone whose body read is bounded too (Cody cycle-6 latent)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
// A naked clone() would return the RAW Response, whose json() is unbounded. Prove
// the clone reads correctly through the wrapper, and the original stays readable
// (clone tees the stream). Real undici Response so clone() actually tees.
global.fetch = async () => new Response(JSON.stringify({ id: 'ch_clone' }),
{ status: 200, headers: { 'content-type': 'application/json' } });
try {
const res = await fetchT('https://provider.test/clone');
const clone = res.clone();
assert.notStrictEqual(clone, res, 'clone is a distinct object');
assert.deepEqual(await clone.json(), { id: 'ch_clone' }, 'clone body reads through the wrapper');
assert.deepEqual(await res.json(), { id: 'ch_clone' }, 'original still independently readable after clone');
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: SEQUENTIAL clone-after-original-read is safe — the clone does NOT re-stall on the network even though the shared deadline was already cleared (Cody gate, cycle 8)', async () => {
// The wrap()'d clone and original SHARE one timer/`done()`. Draining the original
// clears that timer. The safety of a clone read AFTER that point rests entirely on
// an unverified claim in the code comment: "once either read completes, a second
// read draws from already-buffered bytes." Cody required this be PROVEN, not just
// asserted — WHATWG stream tee() proactively buffers the not-yet-read branch as
// bytes are pulled by whichever branch IS being read, so the clone should resolve
// near-instantly off already-buffered bytes, never touching the (now unguarded)
// network wait. If tee did NOT buffer this way, the clone read would re-incur the
// full per-chunk delay with no timer to bound it — an unbounded hang, silently.
process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000'; // generous; must not fire during the drain
const CHUNK_DELAY_MS = 30;
const CHUNKS = ['{"a":1,', '"b":2,', '"c":3}'];
global.fetch = async () => {
let i = 0;
const stream = new ReadableStream({
pull(controller) {
return new Promise((resolve) => {
setTimeout(() => {
if (i < CHUNKS.length) controller.enqueue(new TextEncoder().encode(CHUNKS[i++]));
else controller.close();
resolve();
}, CHUNK_DELAY_MS);
});
},
});
return new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } });
};
try {
const res = await fetchT('https://provider.test/tee-sequential');
const clone = res.clone(); // clone BEFORE any read (clone-after-consumed throws)
const orig = await res.json(); // drains all 3 chunks x 30ms delay ≈ 90ms — clears the shared timer
assert.deepEqual(orig, { a: 1, b: 2, c: 3 });
const t1 = Date.now();
const cl = await clone.json(); // proves: does this re-stall (~90ms) or resolve off buffered bytes?
const cloneMs = Date.now() - t1;
assert.deepEqual(cl, { a: 1, b: 2, c: 3 }, 'clone parses the same body after the original was fully drained');
assert.ok(cloneMs < CHUNK_DELAY_MS * CHUNKS.length, `clone resolved in ${cloneMs}ms off tee-buffered bytes, not by re-waiting on the network (would be >=${CHUNK_DELAY_MS * CHUNKS.length}ms)`);
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: a body-stalled CLONE also rejects with PROVIDER_TIMEOUT (clone carries the same bound, not raw)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
// Response-like whose clone() yields a fresh Response-like whose json() hangs
// until the shared signal aborts. If clone() returned the RAW body (the pre-fix
// behavior) this would hang forever instead of rejecting.
const makeStallBody = (opts) => ({
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); });
}),
clone() { return makeStallBody(opts); },
});
global.fetch = (url, opts) => Promise.resolve(makeStallBody(opts));
try {
const res = await fetchT('https://provider.test/clonestall'); // headers fast
const clone = res.clone();
const started = Date.now();
await assert.rejects(
() => clone.json(),
(err) => {
assert.equal(err.code, 'PROVIDER_TIMEOUT', 'a stalled clone body is labelled PROVIDER_TIMEOUT');
assert.match(err.message, /body read timeout after 40ms/);
return true;
},
);
assert.ok(Date.now() - started < 2000, 'clone body aborted promptly, not hung');
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: a stalled arrayBuffer() is bounded too (the whole body-method class, not just json/text)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
global.fetch = (url, opts) => Promise.resolve({
ok: true, status: 200,
arrayBuffer: () => new Promise((_resolve, reject) => {
opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
}),
});
try {
const res = await fetchT('https://provider.test/binstall');
await assert.rejects(
() => res.arrayBuffer(),
(err) => { assert.equal(err.code, 'PROVIDER_TIMEOUT', 'arrayBuffer stall is bounded like json'); return true; },
);
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: a header-fast / BODY-stalled response -> json() rejects with PROVIDER_TIMEOUT (PRE-FLIGHT #7)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
// Headers arrive immediately; the body read hangs until the shared signal aborts.
global.fetch = (url, opts) => 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);
});
}),
});
try {
const res = await fetchT('https://provider.test/slowbody'); // resolves fast (headers only)
const started = Date.now();
await assert.rejects(
() => res.json(),
(err) => {
assert.equal(err.code, 'PROVIDER_TIMEOUT', 'body-read abort is labelled PROVIDER_TIMEOUT');
assert.match(err.message, /body read timeout after 40ms/);
assert.match(err.message, /provider\.test\/slowbody/);
return true;
},
);
assert.ok(Date.now() - started < 2000, 'body read aborted promptly, not hung');
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: a fast body read clears the deadline (a later slow op is NOT aborted by a stale timer)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '50';
let capturedSignal = null;
global.fetch = (url, opts) => { capturedSignal = opts.signal; return Promise.resolve({ ok: true, status: 200, json: async () => ({ done: true }) }); };
try {
const res = await fetchT('https://provider.test/fastbody');
assert.deepEqual(await res.json(), { done: true }, 'body parsed');
// The timer must have been cleared when json() resolved; wait past the old deadline
// and confirm the signal never fired (no stale abort strands a later operation).
await new Promise(r => setTimeout(r, 90));
assert.equal(capturedSignal.aborted, false, 'deadline cleared on body-read success — signal never aborted');
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: reads metadata + body through the Proxy off a REAL Response (brand-checked getters)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
// A real undici Response exposes ok/status via private-class-field getters that
// THROW if accessed with the wrong receiver. This guards the Proxy's
// Reflect.get(target, prop, target): a "helpful" refactor to `receiver` (the
// Proxy) would make every getter throw. The other tests mock fetch with plain
// objects and would NOT catch that; this one would.
global.fetch = async () => new Response(JSON.stringify({ id: 'ch_real' }),
{ status: 200, headers: { 'content-type': 'application/json' } });
try {
const res = await fetchT('https://provider.test/real');
assert.equal(res.ok, true, 'res.ok reads off a real Response through the Proxy');
assert.equal(res.status, 200, 'res.status reads off a real Response through the Proxy');
assert.deepEqual(await res.json(), { id: 'ch_real' }, 'body parsed off a real Response through the Proxy');
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('fetchT: a non-abort network error is re-thrown as-is (not masked as a timeout)', async () => {
process.env.PROVIDER_HTTP_TIMEOUT_MS = '5000';
global.fetch = () => Promise.reject(new Error('ECONNREFUSED'));
try {
await assert.rejects(
() => fetchT('https://provider.test/down'),
(err) => { assert.match(err.message, /ECONNREFUSED/); assert.notEqual(err.code, 'PROVIDER_TIMEOUT'); return true; },
);
} finally {
restore();
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
}
});
test('timeoutMs: bad/zero/absent values fall back to the 15s default; sane values pass; clamped to ceiling', () => {
const cases = [
[undefined, 15000], ['', 15000], ['0', 15000], ['-5', 15000], ['abc', 15000],
['3000', 3000], ['999999', 120000],
];
for (const [val, expect] of cases) {
if (val === undefined) delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
else process.env.PROVIDER_HTTP_TIMEOUT_MS = val;
assert.equal(timeoutMs(), expect, `timeoutMs for ${JSON.stringify(val)}`);
}
delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
});