v2
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
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 }); }
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
||||
import { address, powHash, validSignature, validTransfer, verifyBlock, verifyState, mineSchema, transferSchema } from '../server/chain.ts';
|
||||
import { GENESIS, DIFFICULTY, proofMessage, claimMessage, transferMessage, ownerOf, rarity, stats,
|
||||
type Block, type Challenge, type ChainState } from '../shared/protocol.ts';
|
||||
import { STARTER_ARTICLES } from '../shared/catalog.ts';
|
||||
|
||||
function identity() {
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicKey = keys.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
|
||||
return { publicKey, owner: address(publicKey), sign: (message: string) =>
|
||||
sign('sha256', Buffer.from(message), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
|
||||
}
|
||||
const challenge: Challenge = { height: 1, previousHash: GENESIS, seed: 'ab'.repeat(32),
|
||||
article: STARTER_ARTICLES[0], difficulty: DIFFICULTY, createdAt: 1792411200000 };
|
||||
const alice = identity();
|
||||
const bob = identity();
|
||||
let block: Block;
|
||||
|
||||
test('real Argon2id proof is deterministic, owner-bound and independently verified', async () => {
|
||||
let nonce = 0;
|
||||
let hash = await powHash(challenge, alice.owner, nonce);
|
||||
while (!hash.startsWith('0'.repeat(DIFFICULTY))) hash = await powHash(challenge, alice.owner, ++nonce);
|
||||
block = { ...challenge, nonce, hash, owner: alice.owner, publicKey: alice.publicKey,
|
||||
signature: alice.sign(claimMessage(challenge, alice.owner, nonce, hash)) };
|
||||
assert.equal(await powHash(challenge, alice.owner, nonce), hash);
|
||||
assert.equal(await verifyBlock(block, GENESIS, 1), true);
|
||||
assert.equal(await verifyState({ blocks: [block], transfers: [] }), true);
|
||||
assert.equal(await verifyBlock({ ...block, nonce: nonce + 1 }, GENESIS, 1), false);
|
||||
assert.equal(await verifyBlock({ ...block, owner: bob.owner }, GENESIS, 1), false);
|
||||
assert.equal(await verifyBlock({ ...block, article: { ...block.article, pageId: 1 } }, GENESIS, 1), false);
|
||||
assert.equal(await verifyBlock({ ...block, article: { ...block.article, extract: 'Altered' } }, GENESIS, 1), false);
|
||||
assert.equal(await verifyBlock({ ...block, previousHash: 'ff'.repeat(32) }, GENESIS, 1), false);
|
||||
assert.equal(await verifyBlock({ ...block, difficulty: 0 }, GENESIS, 1), false);
|
||||
assert.equal(await verifyBlock({ ...block, nonce: -1 }, GENESIS, 1), false);
|
||||
});
|
||||
|
||||
test('canonical proof commits all article content and mining fields', () => {
|
||||
const original = proofMessage(challenge, alice.owner, 1);
|
||||
for (const field of ['title', 'extract', 'url', 'image', 'category', 'source'] as const) {
|
||||
const changed = { ...challenge, article: { ...challenge.article, [field]: 'changed' } } as Challenge;
|
||||
assert.notEqual(proofMessage(changed, alice.owner, 1), original);
|
||||
}
|
||||
assert.notEqual(proofMessage({ ...challenge, createdAt: 1 }, alice.owner, 1), original);
|
||||
assert.notEqual(proofMessage(challenge, bob.owner, 1), original);
|
||||
});
|
||||
|
||||
test('P-256 signatures reject tampering, incorrect owner and malformed keys', () => {
|
||||
const sig = alice.sign('original');
|
||||
assert.equal(validSignature(alice.publicKey, alice.owner, 'original', sig), true);
|
||||
assert.equal(validSignature(alice.publicKey, alice.owner, 'tampered', sig), false);
|
||||
assert.equal(validSignature(alice.publicKey, bob.owner, 'original', sig), false);
|
||||
assert.equal(validSignature('bad key', alice.owner, 'original', sig), false);
|
||||
});
|
||||
|
||||
test('transfers verify ownership, signatures, sequence and prevent replay', async () => {
|
||||
const state: ChainState = { blocks: [block], transfers: [] };
|
||||
const body = { cardHash: block.hash, from: alice.owner, to: bob.owner, sequence: 0 };
|
||||
const transfer = { ...body, publicKey: alice.publicKey, signature: alice.sign(transferMessage(body)) };
|
||||
assert.equal(validTransfer(state, transfer), true);
|
||||
assert.equal(validTransfer(state, { ...transfer, to: alice.owner }), false);
|
||||
assert.equal(validTransfer(state, { ...transfer, sequence: 2 }), false);
|
||||
assert.equal(validTransfer(state, { ...transfer, publicKey: bob.publicKey }), false);
|
||||
const next = { ...state, transfers: [transfer] };
|
||||
assert.equal(ownerOf(next, block.hash), bob.owner);
|
||||
assert.equal(validTransfer(next, transfer), false);
|
||||
assert.equal(await verifyState(next), true);
|
||||
assert.equal(await verifyState({ ...next, transfers: [transfer, transfer] }), false);
|
||||
});
|
||||
|
||||
test('untrusted request schemas reject malformed and oversized inputs', () => {
|
||||
assert.equal(mineSchema.safeParse({}).success, false);
|
||||
assert.equal(mineSchema.safeParse({ ...block, nonce: -1 }).success, false);
|
||||
assert.equal(transferSchema.safeParse({ cardHash: 'x'.repeat(10000) }).success, false);
|
||||
assert.equal(transferSchema.safeParse({ from: 'a'.repeat(64), to: 'b'.repeat(64), sequence: Infinity }).success, false);
|
||||
});
|
||||
|
||||
test('rarity and game stats are stable and bounded', () => {
|
||||
assert.equal(rarity('0'.repeat(64)), 'Legendary');
|
||||
assert.equal(rarity(`${'0'.repeat(60)}0008`), 'Epic');
|
||||
for (let n = 0; n < 100; n++) {
|
||||
const hash = n.toString(16).padStart(64, '0');
|
||||
for (const stat of Object.values(stats(hash))) assert.ok(stat >= 30 && stat <= 99);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { DEFAULT_THEME, isTheme, resolveTheme, THEMES, THEME_STORAGE_KEY } from '../src/theme.ts';
|
||||
|
||||
const bootstrap = readFileSync(new URL('../public/theme-init.js', import.meta.url), 'utf8');
|
||||
function boot(saved: unknown, blocked = false) {
|
||||
const root = { dataset: {} as Record<string, string>, style: {} as Record<string, string> };
|
||||
let meta = '';
|
||||
runInNewContext(bootstrap, {
|
||||
localStorage: { getItem(key: string) {
|
||||
assert.equal(key, THEME_STORAGE_KEY);
|
||||
if (blocked) throw new Error('Storage access denied');
|
||||
return saved;
|
||||
} },
|
||||
document: { documentElement: root, querySelector: () => ({ setAttribute: (_key: string, value: string) => { meta = value; } }) },
|
||||
});
|
||||
return { theme: root.dataset.theme, scheme: root.style.colorScheme, meta };
|
||||
}
|
||||
|
||||
test('Mocha is the default, including missing, malicious, or inaccessible preferences', () => {
|
||||
assert.equal(DEFAULT_THEME, 'mocha');
|
||||
for (const value of [null, undefined, '', 'unknown', 'MOCHA', '__proto__', 'constructor', '<script>alert(1)</script>', {}, 1]) {
|
||||
assert.equal(isTheme(value), false);
|
||||
assert.equal(resolveTheme(value), 'mocha');
|
||||
assert.deepEqual(boot(value), { theme: 'mocha', scheme: 'dark', meta: '#1e1e2e' });
|
||||
}
|
||||
assert.deepEqual(boot('latte', true), { theme: 'mocha', scheme: 'dark', meta: '#1e1e2e' });
|
||||
});
|
||||
|
||||
test('every saved Catppuccin flavor boots with matching browser chrome and color scheme', () => {
|
||||
for (const theme of THEMES) {
|
||||
assert.equal(isTheme(theme.id), true);
|
||||
assert.equal(resolveTheme(theme.id), theme.id);
|
||||
assert.deepEqual(boot(theme.id), { theme: theme.id, scheme: theme.id === 'latte' ? 'light' : 'dark', meta: theme.base });
|
||||
}
|
||||
});
|
||||
|
||||
test('every flavor defines all 26 official palette roles with the correct base color', () => {
|
||||
const css = readFileSync(new URL('../src/themes.css', import.meta.url), 'utf8');
|
||||
const roles = ['rosewater', 'flamingo', 'pink', 'mauve', 'red', 'maroon', 'peach', 'yellow', 'green', 'teal',
|
||||
'sky', 'sapphire', 'blue', 'lavender', 'text', 'subtext1', 'subtext0', 'overlay2', 'overlay1', 'overlay0',
|
||||
'surface2', 'surface1', 'surface0', 'base', 'mantle', 'crust'];
|
||||
for (const theme of THEMES) {
|
||||
const block = css.match(new RegExp(`:root\\[data-theme='${theme.id}'\\]\\s*\\{([^}]+)\\}`))?.[1];
|
||||
assert.ok(block);
|
||||
for (const role of roles) assert.match(block, new RegExp(`--ctp-${role}: #[a-f0-9]{6};`));
|
||||
assert.ok(block.includes(`--ctp-base: ${theme.base};`));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user