Files
wikideck/tests/api.test.ts
T
2026-10-19 12:16:49 -04:00

77 lines
4.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { generateKeyPairSync, sign } from 'node:crypto';
import { address, powHash } from '../server/chain.ts';
import { claimMessage, transferMessage, type Challenge, type ChainState } from '../shared/protocol.ts';
test('HTTP mine, signed transfer, replay rejection and persisted restart', { timeout: 60000 }, async () => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-test-'));
let child: ChildProcess | undefined;
async function start(): Promise<string> {
// Exercise the same native TypeScript runtime and bind address as Docker.
child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
env: { ...process.env, HOST: '0.0.0.0', PORT: '0', NODE_ENV: 'production', WIKIDECK_DATA_DIR: dir, WIKIDECK_OFFLINE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Server startup timeout')), 15000);
let output = '';
child!.stdout!.on('data', chunk => {
output += chunk.toString();
const match = output.match(/http:\/\/0\.0\.0\.0:\d+/);
if (match) { clearTimeout(timer); resolve(match[0].replace('0.0.0.0', '127.0.0.1')); }
});
child!.stderr!.on('data', chunk => { output += chunk.toString(); });
child!.once('exit', () => { clearTimeout(timer); reject(new Error(output)); });
child!.once('error', reject);
});
}
async function stop() {
if (child && child.exitCode === null) { const closed = once(child, 'exit'); child.kill(); await closed; }
}
try {
let base = await start();
const health = await fetch(`${base}/healthz`);
assert.equal(health.status, 200);
assert.deepEqual(await health.json(), { status: 'ok' });
assert.equal(health.headers.get('cache-control'), 'no-store');
assert.equal(health.headers.has('ratelimit'), false);
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
const owner = address(publicKey);
const signed = (message: string) => sign('sha256', Buffer.from(message), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64');
const post = (path: string, body: unknown, origin?: string) => fetch(`${base}/api/${path}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...(origin ? { Origin: origin } : {}) }, body: JSON.stringify(body),
});
const { challenge } = await (await fetch(`${base}/api/state`)).json() as { challenge: Challenge };
assert.equal(challenge.article.source, 'offline');
assert.equal((await post('mine', {})).status, 400);
assert.equal((await post('mine', {}, 'https://evil.example')).status, 403);
let nonce = 0;
let hash = await powHash(challenge, owner, nonce);
while (!hash.startsWith('0'.repeat(challenge.difficulty))) hash = await powHash(challenge, owner, ++nonce);
const proof = { seed: challenge.seed, nonce, hash, owner, publicKey, signature: signed(claimMessage(challenge, owner, nonce, hash)) };
const accepted = await post('mine', proof);
assert.equal(accepted.status, 201, await accepted.text());
assert.equal((await post('mine', proof)).status, 409);
const body = { cardHash: hash, from: owner, to: 'b'.repeat(64), sequence: 0 };
const transfer = { ...body, publicKey, signature: signed(transferMessage(body)) };
assert.equal((await post('transfer', { ...transfer, to: 'c'.repeat(64) })).status, 403);
assert.equal((await post('transfer', transfer)).status, 201);
assert.equal((await post('transfer', transfer)).status, 403);
assert.equal((await fetch(`${base}/api/missing`)).status, 404);
await stop();
base = await start();
const restored = await (await fetch(`${base}/api/chain`)).json() as ChainState;
assert.equal(restored.blocks.length, 1);
assert.equal(restored.blocks[0].hash, hash);
assert.equal(restored.transfers.length, 1);
assert.equal(restored.transfers[0].to, 'b'.repeat(64));
} finally { await stop(); await rm(dir, { recursive: true, force: true }); }
});