monerod get_info

This commit is contained in:
2026-10-22 15:22:23 -04:00
parent 3f04c3afa9
commit d8780701e6
9 changed files with 315 additions and 10 deletions
+93
View File
@@ -0,0 +1,93 @@
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 }); }
});
+4
View File
@@ -15,6 +15,10 @@ test('private pilot Compose stack is loopback-bound, isolated, and mock-only', a
assert.match(compose, /pg-runtime:\/secrets\/runtime:ro/);
assert.doesNotMatch(compose, /POSTGRES_PASSWORD:\s|5432:5432/i);
assert.doesNotMatch(compose, /^\s+(?:monero|wallet-rpc|observer):/im);
assert.match(compose, /daemon-check:\n\s+image: wikideck:private-pilot/);
assert.match(compose, /profiles: \["monero-check"\]/);
assert.match(compose, /scripts\/monero-daemon-check\.ts/);
assert.match(compose, /target: \/run\/monero\/daemon\.json\n\s+read_only: true/);
const database = compose.split(' database:')[1].split(' migrate:')[0];
assert.doesNotMatch(database, /ports:/);
const app = compose.split(' wikideck:')[1].split('networks:\n financial:')[0];