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 { // 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 }); } });