More cards

This commit is contained in:
2026-10-19 18:52:49 -04:00
parent 716c9612cc
commit 0b9061372b
26 changed files with 549 additions and 201 deletions
+14 -5
View File
@@ -15,6 +15,7 @@ import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage,
type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
const binary = resolve(process.env.RANDOMX_BINARY ?? join(root, 'native', 'build',
`wikideck-randomx${process.platform === 'win32' ? '.exe' : ''}`));
@@ -74,7 +75,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
async function start(dataDir: string, peer?: string): Promise<RunningNode> {
t.signal.throwIfAborted();
const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
const child = spawn(process.execPath, ['--import', wikipediaMock, '--experimental-strip-types', 'server/index.ts'], {
cwd: root, windowsHide: true, shell: false,
env: { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production',
WIKIDECK_DATA_DIR: dataDir, RANDOMX_BINARY: binary,
@@ -116,7 +117,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
headers: Record<string, string> = {}): Promise<Response> {
const response = await fetch(`${base}${path}`, {
method: body === undefined ? 'GET' : 'POST', redirect: 'error',
signal: AbortSignal.any([t.signal, AbortSignal.timeout(15_000)]),
signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]),
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
@@ -183,7 +184,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.equal(health.headers.has('ratelimit'), false);
await (await request(first.base, '/api/wikipedia/0', 400)).text();
await (await request(first.base, '/api/wikipedia/not-a-page', 400)).text();
await (await request(first.base, '/api/wikipedia/9999999999', 404)).text();
await (await request(first.base, '/api/wikipedia/9999999999', 503)).text();
const initial = await snapshot(first.base);
assert.equal(initial.network, NETWORK);
assert.equal(initial.chainId, CHAIN_ID);
@@ -214,6 +215,12 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
const block1 = await mine(first.base, alice);
assert.equal(block1.height, 1);
assert.equal(block1.article.source, 'live');
assert.ok(block1.article.image);
assert.ok(['https://upload.wikimedia.org', 'https://thumb.wikimedia.org'].includes(new URL(block1.article.image).origin));
assert.equal(block1.statsSnapshot.pageviews.status, 'available');
assert.equal(block1.statsSnapshot.pageviews.total, 600);
assert.equal(block1.statsSnapshot.revisionId, block1.article.pageId + 1);
await submit(first.base, block1);
const unsigned = { network: NETWORK, chainId: CHAIN_ID, cardHash: block1.tokenId,
from: alice.owner, to: bob.owner, sequence: 0 };
@@ -280,7 +287,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.match(replay.error, /owner\/sequence/i);
assert.deepEqual(await json(node.base, `/api/nfts/${block1.tokenId}`), {
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
blockHash: block1.hash, confirmations: 3, article: block1.article, stateRoot: block3.stateRoot,
blockHash: block1.hash, confirmations: 3, article: block1.article,
statsSnapshot: block1.statsSnapshot, stateRoot: block3.stateRoot,
});
const inventory = await json<{ blocks: TestnetBlock[]; next: null }>(node.base, '/p2p/blocks?offset=0');
assert.deepEqual(inventory, { blocks: canonical.blocks, next: null });
@@ -308,7 +316,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.deepEqual(restored, { ...canonical, peers: 0 });
assert.deepEqual(await json(first.base, `/api/nfts/${block1.tokenId}`), {
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
blockHash: block1.hash, confirmations: 3, article: block1.article, stateRoot: block3.stateRoot,
blockHash: block1.hash, confirmations: 3, article: block1.article,
statsSnapshot: block1.statsSnapshot, stateRoot: block3.stateRoot,
});
assert.deepEqual(await json(first.base, '/api/blocks', 200, block3), { accepted: false, reorg: false });
await (await request(first.base, '/api/transfer', 400, transfer)).text();
+9
View File
@@ -186,6 +186,15 @@ test('six-hour TTL, deduplication and mutation isolation', async () => {
assert.equal(calls.length, 4);
});
test('fresh enrichment bypasses the display cache for mint-time snapshots', async () => {
const { service, calls } = fixture();
await service.get(736);
const pinned = await service.getFresh(736);
assert.equal(calls.length, 4); // Each lookup makes one metadata and one pageview request.
assert.equal(pinned.pageId, 736);
assert.equal(pinned.pageviews.status, 'available');
});
test('LRU bound and configured TTL are enforced', async () => {
let now = JANUARY.getTime();
const { service, calls } = fixture({ maxEntries: 2, ttlMs: 100, now: () => new Date(now) });
+40
View File
@@ -0,0 +1,40 @@
// Deterministic upstream responses for the isolated HTTP integration test only.
let nextPageId = 700_000;
const nativeFetch = globalThis.fetch.bind(globalThis);
const json = (value) => new Response(JSON.stringify(value), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
globalThis.fetch = async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.origin === 'https://en.wikipedia.org' && url.pathname === '/w/api.php') {
if (url.searchParams.get('generator') === 'random') {
const pageid = nextPageId++;
return json({ query: { pages: [{ pageid, ns: 0, title: `Test article ${pageid}`,
extract: `A deterministic mock Wikipedia extract for article ${pageid}, long enough to display on a card.`,
lastrevid: pageid + 1, thumbnail: { source: `https://upload.wikimedia.org/wikipedia/commons/${pageid}.jpg` },
categories: [{ title: 'Category:Science' }] }] } });
}
const pageid = Number(url.searchParams.get('pageids'));
if (pageid === 9_999_999_999) {
return json({ query: { pages: [{ pageid, ns: 0, title: 'Missing test page', missing: true }] } });
}
return json({ query: { pages: [{ pageid, ns: 0, title: `Test article ${pageid}`,
lastrevid: pageid + 1, length: 12_345, description: 'A mock article for integration testing.',
categories: [{ ns: 14, title: 'Category:Science' }], langlinks: [{ lang: 'fr', title: 'Article test' }] }] } });
}
if (url.origin === 'https://wikimedia.org' && url.pathname.includes('/metrics/pageviews/')) {
const parts = url.pathname.split('/');
const start = parts.at(-2);
const end = parts.at(-1);
const from = new Date(Date.UTC(Number(start.slice(0, 4)), Number(start.slice(4, 6)) - 1, 1));
const through = new Date(Date.UTC(Number(end.slice(0, 4)), Number(end.slice(4, 6)) - 1, 1));
const items = [];
for (const cursor = from; cursor <= through; cursor.setUTCMonth(cursor.getUTCMonth() + 1)) {
const timestamp = `${cursor.getUTCFullYear()}${String(cursor.getUTCMonth() + 1).padStart(2, '0')}0100`;
items.push({ timestamp, views: 100, project: 'en.wikipedia', access: 'all-access', agent: 'user', granularity: 'monthly' });
}
return json({ items });
}
return nativeFetch(input, init);
};
+35 -8
View File
@@ -13,6 +13,7 @@ import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME, MAX_TARGET, address, makeTemplate
mintId, sha256, stateRoot, transferId, transition, workFor } from '../server/testnet/consensus.ts';
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf,
type MiningTemplate, type NftState, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
import { deriveStats } from '../shared/enrichment.ts';
import type { PowEngine } from '../server/testnet/randomx.ts';
// Deliberately test-only: production has no fake PoW selector or fallback.
@@ -48,10 +49,23 @@ async function mine(template: MiningTemplate, miner: Identity = alice): Promise<
}
type Branch = { blocks: TestnetBlock[]; state: NftState };
function branch(): Branch { return { blocks: [], state: {} }; }
function articleFor(_ancestors: TestnetBlock[]) {
const pageId = 50_000;
return { pageId, title: `Test article ${pageId}`, extract: `A deterministic test article snapshot for Wikipedia page ${pageId}.`,
image: 'https://upload.wikimedia.org/wikipedia/commons/test-image.jpg',
url: `https://en.wikipedia.org/?curid=${pageId}`, revisionId: 1, source: 'live' as const, category: 'Culture' };
}
function statsSnapshot() {
const pageBytes = 2048, languageCount = 3;
return { capturedAt: '2026-10-19T00:00:00.000Z', revisionId: 1, pageBytes, languageCount,
languagesTruncated: false, pageviews: { status: 'unavailable' as const, months: [], latestMonth: '2026-09',
total: null, dailyAverage: null, trendPercent: null }, stats: deriveStats(pageBytes, languageCount, null) };
}
async function extend(chain: Branch, miner: Identity = alice, transactions: NftTransfer[] = [],
spacing = 30): Promise<TestnetBlock> {
const now = GENESIS_TIME + (chain.blocks.length + 1) * spacing;
const block = await mine(makeTemplate(chain.blocks, chain.state, miner.owner, miner.publicKey, transactions, now), miner);
const block = await mine(makeTemplate(chain.blocks, chain.state, miner.owner, miner.publicKey,
transactions, articleFor(chain.blocks), statsSnapshot(), now), miner);
chain.state = transition(chain.state, block, transactions);
chain.blocks.push(block);
return block;
@@ -87,7 +101,7 @@ test('testnet genesis is empty, manifest-bound, isolated and defensively copied'
await assert.rejects(readFile(join(dir, 'chain.json')), /ENOENT/);
assert.equal(JSON.parse(await readFile(file, 'utf8')).version, 1);
if (process.platform !== 'win32') assert.equal((await stat(file)).mode & 0o777, 0o600);
const candidate = await mine(ledger.template(alice.owner, alice.publicKey));
const candidate = await mine(ledger.template(alice.owner, alice.publicKey, articleFor([]), statsSnapshot()));
assert.deepEqual(await ledger.submit(candidate), { accepted: true, reorg: false });
candidate.article.title = 'caller mutation';
const copy = ledger.block(candidate.hash)!;
@@ -156,7 +170,8 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), alice.owner); // Unconfirmed is not ownership.
const restarted = await Ledger.open(dir, new FakePow());
assert.deepEqual(restarted.snapshot().pendingTransfers, [tx, tx2]);
const block = await mine(restarted.template(alice.owner, alice.publicKey));
const block = await mine(restarted.template(alice.owner, alice.publicKey,
articleFor(restarted.snapshot().blocks), statsSnapshot()));
assert.deepEqual(block.transactions, [tx, tx2]);
await restarted.submit(block);
assert.equal(testnetOwnerOf(restarted.snapshot(), minted.tokenId), carol.owner);
@@ -189,7 +204,8 @@ test('reorg rolls back NFT ownership and requeues only still-valid disconnected
assert.deepEqual(snapshot.pendingTransfers, [tx, tx2]); // Orphan issuance is discarded.
assert.ok(ledger.block(committed.hash));
assert.deepEqual((await Ledger.open(dir, new FakePow())).snapshot(), snapshot);
const next = await mine(ledger.template(bob.owner, bob.publicKey), bob);
const next = await mine(ledger.template(bob.owner, bob.publicKey,
articleFor(ledger.snapshot().blocks), statsSnapshot()), bob);
await ledger.submit(next);
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), carol.owner);
});
@@ -210,11 +226,17 @@ test('reorg removes mempool conflicts and does not requeue already committed tra
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), carol.owner);
});
test('invalid parent, manifest, proof, catalog, transaction/state commitments leave ledger unchanged', async t => {
test('invalid parent, manifest, proof, article/stat snapshots, and commitments leave ledger unchanged', async t => {
const { ledger } = await fixture(t);
const valid = await extend(branch());
assert.notEqual(mintId(valid), mintId({ ...valid,
statsSnapshot: { ...valid.statsSnapshot, capturedAt: '2026-10-19T00:00:01.000Z' } }));
for (const changed of [ { previousHash: 'aa'.repeat(32) }, { chainId: 'bb'.repeat(32) },
{ network: 'another-network' }, { article: { ...valid.article, extract: 'tampered catalog' } },
{ network: 'another-network' }, { article: { ...valid.article, extract: 'tampered snapshot' } },
{ article: { ...valid.article, image: 'https://evil.example/image.jpg' } },
{ article: { ...valid.article, image: undefined } },
{ statsSnapshot: { ...valid.statsSnapshot, capturedAt: '2026-10-19T00:00:01.000Z' } },
{ statsSnapshot: { ...valid.statsSnapshot, stats: { ...valid.statsSnapshot.stats, depth: 0 } } },
{ stateRoot: '00'.repeat(32) }, { transactionsRoot: '00'.repeat(32) },
{ tokenId: '00'.repeat(32) }, { powHash: '00'.repeat(32) }, { nonce: valid.nonce + 1 },
{ signature: bob.sign(blockClaimMessage(valid.hash)) } ]) {
@@ -223,7 +245,11 @@ test('invalid parent, manifest, proof, catalog, transaction/state commitments le
}
await assert.rejects(ledger.submitTransfer({ ...transfer('aa'.repeat(32)), chainId: 'bb'.repeat(32) }));
await ledger.submit(valid); // Queue recovers from failed validations.
assert.equal(ledger.size, 1);
const repeated = await mine(ledger.template(alice.owner, alice.publicKey, valid.article, valid.statsSnapshot));
assert.equal(repeated.article.pageId, valid.article.pageId);
assert.notEqual(repeated.tokenId, valid.tokenId);
await ledger.submit(repeated);
assert.equal(ledger.size, 2);
});
test('startup rechecks every proof, rejects tampering and mismatched manifests without resetting disk', async t => {
@@ -286,7 +312,8 @@ test('mempool is ordered, capped at 256, and templates include at most 64 withou
await Promise.all(transactions.map(tx => ledger.submitTransfer(tx)));
assert.deepEqual(ledger.snapshot().pendingTransfers, transactions);
await assert.rejects(ledger.submitTransfer(transfer(minted.tokenId, alice, bob, 256)), /256/);
const template = ledger.template(alice.owner, alice.publicKey);
const template = ledger.template(alice.owner, alice.publicKey,
articleFor(ledger.snapshot().blocks), statsSnapshot());
assert.deepEqual(template.transactions, transactions.slice(0, 64));
await assert.rejects(ledger.submitTransfer(transfer(mintId(template.header))), /256/);
const block = await mine(template);
+1 -1
View File
@@ -54,7 +54,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
key: createPublicKey({ key: spki, format: 'der', type: 'spki' }), dsaEncoding: 'ieee-p1363',
}, Buffer.from(signature, 'base64')), true);
assert.equal(backup.iterations, 600000);
assert.equal(backup.network, 'wikideck-randomx-testnet-2');
assert.equal(backup.network, 'wikideck-randomx-testnet-4');
assert.equal(Buffer.from(backup.salt, 'base64').length, 16);
assert.equal(Buffer.from(backup.iv, 'base64').length, 12);
assert.ok(Buffer.byteLength(JSON.stringify(backup)) < 32 * 1024);
+82
View File
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { WikipediaArticleSource } from '../server/wiki.ts';
function page(pageid: number, patch: Record<string, unknown> = {}) {
return { pageid, ns: 0, title: `Article ${pageid}`, extract: `An encyclopedia article with enough explanatory text for a useful collectible card. ${pageid}.`,
lastrevid: pageid + 100, thumbnail: { source: `https://upload.wikimedia.org/wikipedia/commons/${pageid}.jpg` },
categories: [{ title: 'Category:Science' }], ...patch };
}
function json(value: unknown, headers: Record<string, string> = {}) {
return new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json', ...headers } });
}
function mockFetch(handler: (url: URL, init: RequestInit) => Response | Promise<Response>) {
const calls: { url: URL; init: RequestInit }[] = [];
const fetcher: typeof fetch = async (input, init) => {
const url = new URL(String(input));
calls.push({ url, init: init! });
return handler(url, init!);
};
return { fetcher, calls };
}
test('random article lookup retries batches with no pictures and selects a pictured page', async () => {
let attempt = 0;
const { fetcher, calls } = mockFetch(() => {
attempt++;
return attempt === 1
? json({ query: { pages: [page(10, { thumbnail: undefined }), page(11, { thumbnail: undefined })] } })
: json({ query: { pages: [page(12)] } });
});
const article = await new WikipediaArticleSource(fetcher).random();
assert.equal(attempt, 2);
assert.equal(article.pageId, 12);
assert.equal(article.source, 'live');
assert.equal(article.image, 'https://upload.wikimedia.org/wikipedia/commons/12.jpg');
assert.equal(article.url, 'https://en.wikipedia.org/?curid=12');
assert.equal(calls.length, 2);
assert.equal(calls[0].url.origin, 'https://en.wikipedia.org');
assert.equal(calls[0].url.searchParams.get('generator'), 'random');
assert.equal(calls[0].url.searchParams.get('grnlimit'), '10');
assert.equal(calls[0].url.searchParams.get('piprop'), 'thumbnail');
assert.equal(calls[0].init.redirect, 'error');
});
test('only HTTPS Wikimedia pictures are eligible and random lookup has a bounded retry count', async () => {
const { fetcher, calls } = mockFetch(() => json({ query: { pages: [
page(20, { thumbnail: { source: 'https://evil.example/tracker.jpg' } }),
page(21, { thumbnail: { source: 'http://upload.wikimedia.org/image.jpg' } }),
] } }));
await assert.rejects(new WikipediaArticleSource(fetcher).random(), /three attempts/);
assert.equal(calls.length, 3);
});
test('random article fetch rejects unsafe response types, redirects, and oversized payloads without fallback', async () => {
const failures = [
() => new Response('{}', { headers: { 'Content-Type': 'text/html' } }),
() => new Response(null, { status: 302, headers: { Location: 'https://evil.example' } }),
() => new Response(' '.repeat(512 * 1024 + 1), { headers: { 'Content-Type': 'application/json' } }),
() => { throw new Error('offline'); },
];
for (const fail of failures) {
const { fetcher, calls } = mockFetch(() => fail());
await assert.rejects(new WikipediaArticleSource(fetcher).random());
assert.equal(calls.length, 3);
}
});
test('bad page records are skipped while later valid candidates are considered', async () => {
const { fetcher } = mockFetch(() => json({ query: { pages: [
page(30, { pageid: Number.MAX_SAFE_INTEGER + 1 }), page(31, { extract: 'short' }), page(32),
] } }));
const article = await new WikipediaArticleSource(fetcher).random();
assert.equal(article.pageId, 32);
});
test('a previously selected Wikipedia page may be selected again', async () => {
const { fetcher } = mockFetch(() => json({ query: { pages: [page(40)] } }));
const source = new WikipediaArticleSource(fetcher);
const first = await source.random();
const second = await source.random();
assert.equal(first.pageId, second.pageId);
});