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