From 7ce657182c690f19954fbc5972451e357e705bf5 Mon Sep 17 00:00:00 2001 From: fernando-mendoza <55415857+fernando-mendoza@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:19:57 -0600 Subject: [PATCH] test(sdk): make the x402Serve smoke test hermetic (#126) The guard-integration cases reached preflight(), which fetched the default facilitator (channels.openzeppelin.com) three times per run. Stub that fetch with a 503 and assert on x402Serve's error path, so the result no longer depends on a third-party service. Add a jest setupFilesAfterEnv guard that refuses non-loopback sockets and fails the test that opened one, even when the code under test swallows the error. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/jest.config.js | 2 ++ packages/sdk/jest.no-network.js | 44 ++++++++++++++++++++++++ packages/sdk/src/x402serve-smoke.test.ts | 32 ++++++++++++----- 3 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 packages/sdk/jest.no-network.js diff --git a/packages/sdk/jest.config.js b/packages/sdk/jest.config.js index 4dffe23..a4e359d 100644 --- a/packages/sdk/jest.config.js +++ b/packages/sdk/jest.config.js @@ -4,4 +4,6 @@ module.exports = { testEnvironment: 'node', roots: ['/src'], testMatch: ['**/*.test.ts'], + // #126: no test may open a real outbound connection. + setupFilesAfterEnv: ['/jest.no-network.js'], }; diff --git a/packages/sdk/jest.no-network.js b/packages/sdk/jest.no-network.js new file mode 100644 index 0000000..f52a0ac --- /dev/null +++ b/packages/sdk/jest.no-network.js @@ -0,0 +1,44 @@ +// Issue #126: fail any jest test that opens a real outbound connection. +// +// Loopback stays allowed so a test can run a local stub server. Everything +// else is refused at the socket and recorded, and the test that caused it +// fails in afterEach. The record matters: code under test often catches the +// connection error (x402Serve turns it into a 503), so refusing alone would +// pass silently. +const net = require('net'); + +const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '::ffff:127.0.0.1']); +const violations = []; + +function target(args) { + const [a, b] = args; + if (Array.isArray(a)) return target(a); // internal normalized form + if (a && typeof a === 'object') { + if (a.path) return null; // unix socket / named pipe: not network + return { host: a.host ?? 'localhost', port: a.port }; + } + if (typeof a === 'string' && Number.isNaN(Number(a))) return null; // ipc path + return { host: typeof b === 'string' ? b : 'localhost', port: a }; +} + +if (!net.Socket.prototype.__niriumNoNetwork) { + const connect = net.Socket.prototype.connect; + net.Socket.prototype.connect = function (...args) { + const t = target(args); + if (t && !LOOPBACK.has(String(t.host).replace(/^\[|\]$/g, ''))) { + const err = new Error(`outbound network blocked in tests: ${t.host}:${t.port} (see jest.no-network.js)`); + violations.push(err.message); + process.nextTick(() => this.destroy(err)); + return this; + } + return connect.apply(this, args); + }; + net.Socket.prototype.__niriumNoNetwork = true; +} + +afterEach(() => { + if (violations.length) { + const found = violations.splice(0); + throw new Error(`test opened ${found.length} outbound connection(s):\n ${found.join('\n ')}`); + } +}); diff --git a/packages/sdk/src/x402serve-smoke.test.ts b/packages/sdk/src/x402serve-smoke.test.ts index bfcd966..31b8069 100644 --- a/packages/sdk/src/x402serve-smoke.test.ts +++ b/packages/sdk/src/x402serve-smoke.test.ts @@ -102,6 +102,23 @@ describe('x402Serve guard integration (config.guard)', () => { // x402Serve ever reaches preflight()/build() — so none of this needs the // @x402/express/@x402/core mocks above. The allowed-path/store behavior // itself is covered directly in x402-guard.test.ts. + // + // #126: calls that get past the guard reach preflight(), which fetches the + // facilitator's /supported. Stub that fetch so these tests never touch the + // network, and answer 503 so the path after the guard is always the same + // (x402Serve's own 503), instead of depending on a third-party service. + let facilitatorFetch: jest.SpyInstance; + beforeEach(() => { + facilitatorFetch = jest.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(null, { status: 503 })); + }); + afterEach(() => facilitatorFetch.mockRestore()); + + function expectStubbedPreflight(res: any) { + expect(facilitatorFetch).toHaveBeenCalledWith('https://channels.openzeppelin.com/x402/testnet/supported', expect.anything()); + expect(res.statusCode).toBe(503); + expect(res.body.error).toBe('x402 payments unavailable'); + expect(res.body.detail).toBe('facilitator returned 503'); + } function fakeStore(overrides: Partial<{ claimOnce: (key: string, ttlMs: number) => Promise; @@ -138,9 +155,8 @@ describe('x402Serve guard integration (config.guard)', () => { const first = mockReqRes({ 'payment-signature': 'proof-1' }); await handler(first.req, first.res, () => {}); - // First call proceeds past the guard (into preflight, which will fail - // in this test env with no real facilitator — that's fine, it's not - // what this test is checking). + // First call proceeds past the guard, into the stubbed preflight. + expectStubbedPreflight(first.res); const second = mockReqRes({ 'payment-signature': 'proof-1' }); await handler(second.req, second.res, () => {}); @@ -178,10 +194,10 @@ describe('x402Serve guard integration (config.guard)', () => { await handler(first.req, first.res, () => {}); const second = mockReqRes({ 'payment-signature': 'proof-4' }); // same "proof" reused await handler(second.req, second.res, () => {}); - // Neither call was rejected by a guard that doesn't exist - whatever - // status they got came from downstream (preflight failing in this test - // env), never 409/429, and never our guard's own error bodies. - expect(second.res.body?.error).not.toBe('payment_replayed'); - expect(second.res.statusCode).not.toBe(409); + // Neither call was rejected by a guard that doesn't exist: both reached + // the stubbed preflight and got its 503, never 409/429. + expectStubbedPreflight(first.res); + expectStubbedPreflight(second.res); + expect(facilitatorFetch).toHaveBeenCalledTimes(2); }); });