94 lines
6.2 KiB
TypeScript
Executable File
94 lines
6.2 KiB
TypeScript
Executable File
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<string, unknown> = {}) {
|
|
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<string, unknown>, 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 }); }
|
|
});
|