import test from 'node:test'; import assert from 'node:assert/strict'; import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { loadMoneroDaemonProbeConfig, probeMoneroDaemon, type MoneroDaemonProbeTransport } from '../server/market/monero-daemon-probe.ts'; const config = { network: 'mainnet' as const, url: 'http://10.1.2.3:18081/json_rpc', allowPrivateHttp: true }; function info(overrides: Record = {}) { return { status: 'OK', nettype: 'mainnet', mainnet: true, stagenet: false, testnet: false, synchronized: true, busy_syncing: false, offline: false, untrusted: false, height: 3_000_000, height_without_bootstrap: 3_000_000, target_height: 3_000_000, version: '0.18.4.2-release', bootstrap_daemon_address: '', ...overrides }; } function transportFor(result: Record, options: { status?: number; type?: string; redirected?: boolean } = {}) { let request: { method?: string; redirect?: RequestRedirect; headers?: HeadersInit; body?: string } | undefined; const transport: MoneroDaemonProbeTransport = async (url, init) => { assert.equal(url, config.url); request = { method: init.method, redirect: init.redirect, headers: init.headers, body: init.body as string }; const body = JSON.parse(init.body as string) as { id: string }; return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result }), { status: options.status ?? 200, headers: { 'content-type': options.type ?? 'application/json' }, ...((options.redirected === undefined) ? {} : { statusText: options.redirected ? 'Moved' : 'OK' }), }); }; return { transport, getRequest: () => request }; } test('daemon probe calls only read-only get_info and accepts a synchronized local Mainnet chain', async () => { const fixture = transportFor(info()); const result = await probeMoneroDaemon(config, fixture.transport, Date.UTC(2026, 9, 22)); assert.deepEqual(result, { network: 'mainnet', height: 3_000_000, version: '0.18.4.2-release', synchronized: true, bootstrapProxyUsed: false, checkedAt: '2026-10-22T00:00:00.000Z' }); const request = fixture.getRequest()!; assert.equal(request.method, 'POST'); assert.equal(request.redirect, 'error'); assert.equal(JSON.parse(request.body!).method, 'get_info'); assert.equal(new Headers(request.headers).has('authorization'), false); }); test('daemon probe rejects network mismatch, syncing, offline, bootstrap and untrusted replies', async () => { for (const override of [ { nettype: 'stagenet', mainnet: false, stagenet: true }, { synchronized: false }, { busy_syncing: true }, { offline: true }, { untrusted: true }, { height_without_bootstrap: 2_999_999 }, { bootstrap_daemon_address: 'node.example:18081' }, { testnet: true }, ]) { const fixture = transportFor(info(override)); await assert.rejects(probeMoneroDaemon(config, fixture.transport), /network or synchronization/); } }); test('daemon probe rejects malformed, oversized, redirected and non-JSON responses', async () => { const badJson: MoneroDaemonProbeTransport = async (_url, init) => { const body = JSON.parse(init.body as string) as { id: string }; return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { ...info(), height: 'bad' } }), { headers: { 'content-type': 'application/json' } }); }; await assert.rejects(probeMoneroDaemon(config, badJson), /Invalid Monero daemon RPC response/); const tooLarge: MoneroDaemonProbeTransport = async () => new Response('x'.repeat(70 * 1024), { headers: { 'content-type': 'application/json' } }); await assert.rejects(probeMoneroDaemon(config, tooLarge), /Invalid Monero daemon RPC response/); const redirected = transportFor(info(), { status: 302 }); await assert.rejects(probeMoneroDaemon(config, redirected.transport), /response rejected/); const wrongType = transportFor(info(), { type: 'text/html' }); await assert.rejects(probeMoneroDaemon(config, wrongType.transport), /response rejected/); const mismatchedId: MoneroDaemonProbeTransport = async () => new Response(JSON.stringify({ jsonrpc: '2.0', id: '00000000-0000-4000-8000-000000000000', result: info(), }), { headers: { 'content-type': 'application/json' } }); await assert.rejects(probeMoneroDaemon(config, mismatchedId), /response ID mismatch/); }); test('daemon probe requires explicit private literal endpoint and HTTP boundary acknowledgement', async () => { await assert.rejects(probeMoneroDaemon({ ...config, allowPrivateHttp: false }, async () => new Response()), /explicit private-network acknowledgement/); await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://daemon.example:18081/json_rpc' }, async () => new Response()), /private literal IP/); await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://8.8.8.8:18081/json_rpc' }, async () => new Response()), /private literal IP/); await assert.rejects(probeMoneroDaemon({ ...config, url: 'https://user:password@10.1.2.3:18081/json_rpc' }, async () => new Response()), /bare \/json_rpc/); await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://10.1.2.3:18081/' }, async () => new Response()), /bare \/json_rpc/); }); test('daemon probe config loader requires a protected file and rejects unsafe configuration', async () => { await assert.rejects(loadMoneroDaemonProbeConfig({}), /config file is required/); const directory = await mkdtemp(join(tmpdir(), 'wikideck-monero-daemon-')); const path = join(directory, 'daemon.json'); try { await writeFile(path, JSON.stringify(config), { mode: 0o600 }); if (process.platform !== 'win32') await chmod(path, 0o600); const loaded = await loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path }); assert.deepEqual(loaded, config); await writeFile(path, JSON.stringify({ ...config, url: 'http://daemon.example:18081/json_rpc' }), { mode: 0o600 }); await assert.rejects(loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path }), /Invalid or inaccessible/); } finally { await rm(directory, { recursive: true, force: true }); } });