77 lines
4.4 KiB
TypeScript
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 }); }
|
|
});
|