diff --git a/packages/wallet-service/tests/api.test.ts b/packages/wallet-service/tests/api.test.ts index 0abd7a7f..7f70ae69 100644 --- a/packages/wallet-service/tests/api.test.ts +++ b/packages/wallet-service/tests/api.test.ts @@ -52,6 +52,7 @@ import { addToWalletTable, addToWalletTxHistoryTable, addToTransactionTable, + seedFullnodeVersionData, cleanDatabase, makeGatewayEvent, makeGatewayEventWithAuthorizer, @@ -1598,6 +1599,10 @@ test('GET /wallet/tx_outputs', async () => { test('POST /tx/proposal', async () => { expect.hasAssertions(); + // Seed version data so txProposalCreate's getFullnodeData() reads from + // the DB cache and does not make a real HTTP call to the fullnode. + await seedFullnodeVersionData(mysql); + await _testCORSHeaders(txProposalCreate, null, null); }); diff --git a/packages/wallet-service/tests/jestSetup.ts b/packages/wallet-service/tests/jestSetup.ts index a0aa3ecf..3b0c92e3 100644 --- a/packages/wallet-service/tests/jestSetup.ts +++ b/packages/wallet-service/tests/jestSetup.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-empty-function */ +import http from 'http'; +import https from 'https'; import { config } from 'dotenv'; import { stopGLLBackgroundTask } from '@hathor/wallet-lib'; @@ -6,3 +8,92 @@ Object.defineProperty(global, '_bitcore', { get() { return undefined; }, set() { stopGLLBackgroundTask(); config(); + +/** + * Block all real outbound HTTP/HTTPS requests from unit tests. + * + * Tests must mock their network dependencies. If a test accidentally reaches + * this point, it means a code path escaped mocking (e.g. a handler calls + * `fullnode.version()` without the `version_data` DB cache being seeded, or + * a direct `axios.get(...)` without a `jest.spyOn` / `jest.mock`). We throw + * a loud, explanatory error instead of silently hitting the public internet. + * + * Hosts listed in `ALLOWED_HOSTS` are permitted — the list is intentionally + * empty for the wallet-service unit suite. Integration tests use a separate + * jest config and should opt in explicitly if they need real connections. + */ +const ALLOWED_HOSTS = new Set([]); + +type RequestArg = string | URL | http.RequestOptions; + +const normalizeHost = (host: string | undefined): string => { + if (!host) return ''; + // `host` may include a port (e.g. `localhost:3000`) and IPv6 forms may be + // bracketed (e.g. `[::1]:3000`). Let URL parsing strip both consistently — + // ALLOWED_HOSTS is keyed by hostname only. + try { + return new URL(`http://${host}`).hostname; + } catch { + return host; + } +}; + +const extractHostname = (arg: RequestArg | undefined): string => { + if (!arg) return ''; + if (typeof arg === 'string') { + try { + return new URL(arg).hostname; + } catch { + return arg; + } + } + if (arg instanceof URL) return arg.hostname; + return arg.hostname || normalizeHost(arg.host); +}; + +const describeRequest = (protocol: 'http' | 'https', arg: RequestArg | undefined): string => { + if (!arg) return `${protocol}://`; + if (typeof arg === 'string') return arg; + if (arg instanceof URL) return arg.toString(); + const host = arg.host || arg.hostname || ''; + const path = arg.path || '/'; + return `${protocol}://${host}${path}`; +}; + +const blockRequest = (protocol: 'http' | 'https', originalRequest: typeof http.request) => ( + (...args: unknown[]) => { + const firstArg = args[0] as RequestArg | undefined; + const hostname = extractHostname(firstArg); + if (ALLOWED_HOSTS.has(hostname)) { + // @ts-ignore - passthrough + return originalRequest(...args); + } + throw new Error( + `[jestSetup] Blocked outbound ${protocol.toUpperCase()} request to ${describeRequest(protocol, firstArg)}. ` + + 'Tests must not make real network calls. Mock the HTTP client ' + + '(jest.spyOn / jest.mock) or seed the relevant DB cache (see ' + + 'tests/utils.ts#seedFullnodeVersionData).', + ); + } +); + +// Node's `http.get` / `https.get` keep an internal reference to the original +// `http.request`, so patching only `request` would leave `get` as a bypass. +// Delegate `get` through the patched `request` and call `.end()` ourselves to +// preserve the stock `get()` behavior for any allow-listed host. +const blockGet = (blockedRequest: typeof http.request) => ( + (...args: unknown[]) => { + // @ts-ignore - passthrough to wrapped request overloads + const req = blockedRequest(...args); + req.end(); + return req; + } +); + +const blockedHttpRequest = blockRequest('http', http.request) as typeof http.request; +const blockedHttpsRequest = blockRequest('https', https.request) as typeof https.request; + +http.request = blockedHttpRequest; +https.request = blockedHttpsRequest; +http.get = blockGet(blockedHttpRequest) as typeof http.get; +https.get = blockGet(blockedHttpsRequest) as typeof https.get; diff --git a/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts b/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts index dba0e8ed..3f21191a 100644 --- a/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts +++ b/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts @@ -24,6 +24,7 @@ import { addToAddressTable, addToUtxoTable, addToWalletBalanceTable, + seedFullnodeVersionData, ADDRESSES, cleanDatabase, makeGatewayEventWithAuthorizer, @@ -45,6 +46,7 @@ const mysql = getDbConnection(); beforeEach(async () => { await cleanDatabase(mysql); + await seedFullnodeVersionData(mysql); }); afterAll(async () => { diff --git a/packages/wallet-service/tests/utils.ts b/packages/wallet-service/tests/utils.ts index 63443fa5..57556e9b 100644 --- a/packages/wallet-service/tests/utils.ts +++ b/packages/wallet-service/tests/utils.ts @@ -11,7 +11,7 @@ import { FullNodeApiVersionResponse, } from '@src/types'; import { TxInput } from '@wallet-service/common/src/types'; -import { getWalletId } from '@src/utils'; +import { getUnixTimestamp, getWalletId } from '@src/utils'; import { addressUtils, walletUtils, Network, network, HathorWalletServiceWallet } from '@hathor/wallet-lib'; import { AddressTxHistoryTableEntry, @@ -864,6 +864,47 @@ export const addToVersionDataTable = async (mysql: ServerlessMysql, timestamp: n ); }; +/** + * Default version data used as the cache seed in tests. Any test that invokes + * a handler which calls `getFullnodeData` (e.g. txProposalCreate, GET /version) + * must seed this row — otherwise `fullnode.version()` would try to make a real + * HTTP request to the fullnode. Use `seedFullnodeVersionData` below. + * + * Returned as a function so `process.env.NETWORK` is read at call time rather + * than at module load — tests that mutate the env before calling + * `seedFullnodeVersionData` see their value reflected in the seed. + */ +export const defaultTestVersionData = (): FullNodeApiVersionResponse => ({ + version: '0.38.0', + network: process.env.NETWORK ?? 'mainnet', + min_weight: 14, + min_tx_weight: 14, + min_tx_weight_coefficient: 1.6, + min_tx_weight_k: 100, + token_deposit_percentage: 0.01, + reward_spend_min_blocks: 300, + max_number_inputs: 255, + max_number_outputs: 255, + decimal_places: 2, + nano_contracts_enabled: true, + genesis_block_hash: 'cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe', + genesis_tx1_hash: 'cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe', + genesis_tx2_hash: 'cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe', + native_token: { name: 'Hathor', symbol: 'HTR' }, +}); + +/** + * Seeds the `version_data` table with sensible defaults so that + * `getFullnodeData` reads from the DB cache instead of making a real + * HTTP request to the fullnode. Pass `overrides` to tweak specific fields. + */ +export const seedFullnodeVersionData = async ( + mysql: ServerlessMysql, + overrides: Partial = {}, +): Promise => { + await addToVersionDataTable(mysql, getUnixTimestamp(), { ...defaultTestVersionData(), ...overrides }); +}; + export const checkVersionDataTable = async (mysql: ServerlessMysql, versionData: FullNodeApiVersionResponse): Promise> => { // first check the total number of rows in the table let results: DbSelectResult = await mysql.query('SELECT * FROM `version_data`');