Skip to content
5 changes: 5 additions & 0 deletions packages/wallet-service/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
addToWalletTable,
addToWalletTxHistoryTable,
addToTransactionTable,
seedFullnodeVersionData,
cleanDatabase,
makeGatewayEvent,
makeGatewayEventWithAuthorizer,
Expand Down Expand Up @@ -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);
});

Expand Down
91 changes: 91 additions & 0 deletions packages/wallet-service/tests/jestSetup.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,99 @@
/* 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';

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<string>([]);

type RequestArg = string | URL | http.RequestOptions;

const normalizeHost = (host: string | undefined): string => {
if (!host) return '<unknown>';
// `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 '<unknown>';
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);
};
Comment thread
luislhl marked this conversation as resolved.

const describeRequest = (protocol: 'http' | 'https', arg: RequestArg | undefined): string => {
if (!arg) return `${protocol}://<unknown>`;
if (typeof arg === 'string') return arg;
if (arg instanceof URL) return arg.toString();
const host = arg.host || arg.hostname || '<unknown>';
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;
2 changes: 2 additions & 0 deletions packages/wallet-service/tests/txProposalUtxoUnlock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
addToAddressTable,
addToUtxoTable,
addToWalletBalanceTable,
seedFullnodeVersionData,
ADDRESSES,
cleanDatabase,
makeGatewayEventWithAuthorizer,
Expand All @@ -45,6 +46,7 @@ const mysql = getDbConnection();

beforeEach(async () => {
await cleanDatabase(mysql);
await seedFullnodeVersionData(mysql);
});

afterAll(async () => {
Expand Down
43 changes: 42 additions & 1 deletion packages/wallet-service/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<FullNodeApiVersionResponse> = {},
): Promise<void> => {
await addToVersionDataTable(mysql, getUnixTimestamp(), { ...defaultTestVersionData(), ...overrides });
};

export const checkVersionDataTable = async (mysql: ServerlessMysql, versionData: FullNodeApiVersionResponse): Promise<boolean | Record<string, unknown>> => {
// first check the total number of rows in the table
let results: DbSelectResult = await mysql.query('SELECT * FROM `version_data`');
Expand Down
Loading