// Offline only. No test contacts Wikipedia or starts an engine. import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { setImmediate } from 'node:timers/promises'; import { LIMITS, USER_AGENT, parseCatalogue, apiURL, allowedURL, fetchBatch, entriesFromResponse, fetchEntries, excerpt, luaString, serializeBundle, parseBundle, main } from '../scripts/wiki-species.mjs'; const catalogue = parseCatalogue(await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8')); const cached = parseBundle(await readFile(new URL('../mods/hearth/wiki_articles.lua', import.meta.url), 'utf8')); const additions = [ ['crab_apple', 'Malus sylvestris'], ['wild_cherry', 'Prunus avium'], ['field_maple', 'Acer campestre'], ['honeysuckle', 'Lonicera periclymenum'], ['ivy', 'Hedera helix'], ['gorse', 'Ulex europaeus'], ['broom', 'Cytisus scoparius'], ['dandelion', 'Taraxacum officinale'], ['daisy', 'Bellis perennis'], ['red_clover', 'Trifolium pratense'], ['birdsfoot_trefoil', 'Lotus corniculatus'], ['ribwort_plantain', 'Plantago lanceolata'], ['selfheal', 'Prunella vulgaris'], ['knapweed', 'Centaurea nigra'], ['oxeye_daisy', 'Leucanthemum vulgare'], ['field_scabious', 'Knautia arvensis'], ['teasel', 'Dipsacus fullonum'], ['cow_parsley', 'Anthriscus sylvestris'], ['garlic_mustard', 'Alliaria petiolata'], ['holly_fern', 'Polystichum setiferum'], ].map(([id, latin]) => ({ id, latin })); const retrieved = '2026-11-03T08:00:00.000Z'; const record = catalogue[0]; const intro = 'Quercus robur is a species of flowering plant. This fixture is original test text, not a cached Wikipedia quotation.'; let mockedRequests = 0; function page(title, changes = {}) { return { pageid: 201415, ns: 0, title, extract: intro, canonicalurl: 'https://en.wikipedia.org/wiki/' + encodeURIComponent(title.replaceAll(' ', '_')), lastrevid: 12345, revisions: [{ revid: 12345, timestamp: '2026-11-01T10:00:00Z' }], contentmodel: 'wikitext', pagelanguage: 'en', ...changes }; } function payload(pages = [page(record.latin)], changes = {}) { return { batchcomplete: true, query: { pages, ...changes } }; } function jsonResponse(data, headers = {}) { return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json; charset=utf-8', ...headers } }); } function mock(fn) { return async (...args) => { mockedRequests++; return fn(...args); }; } test('bounded non-executing Lua parser finds the actual 100 Latin names and multi-underscore IDs', async () => { assert.equal(LIMITS.species, 100); assert.equal(catalogue.length, 100); assert.equal(new Set(catalogue.map(r => r.id)).size, 100); assert.deepEqual(catalogue.find(r => r.id === 'wood_blewit'), { id: 'wood_blewit', latin: 'Collybia nuda' }); assert.deepEqual(catalogue.find(r => r.id === 'sweet_vernal_grass'), { id: 'sweet_vernal_grass', latin: 'Anthoxanthum odoratum' }); assert.deepEqual(catalogue.filter(r => additions.some(a => a.id === r.id)), additions); const source = await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8'); for (const bad of [source.replace('Quercus robur', 'https://127.0.0.1/'), source.replace('Quercus robur', 'Quercus robur|User:Private'), source.replace('Quercus robur', 'Quercus robur&action=edit'), source.replace('add("oak"', 'add("constructor"'), source.replace('add("birch"', 'add("oak"'), source.replace('"Betula pendula"', '"Quercus robur"'), source.replace('add("oak",', 'add(variable,'), source + '\nadd("extra","Extra","Abies alba",', source.replace(/^\s*add\("holly_fern"[^\r\n]*/m, ''), source.repeat(10), 'os.execute("not executed")']) assert.throws(() => parseCatalogue(bad)); assert.equal(parseCatalogue(source + '\nos.execute("never evaluated")').length, 100); }); test('URL construction rejects URL/query/title injection before any request', async () => { for (const name of ['https://evil.example/', '//127.0.0.1/', '../Quercus robur', 'Quercus robur|Betula pendula', 'Quercus robur&action=edit', 'Quercus robur#x', 'Quercus robur\n', 'Quercus%20robur', 'User:Somebody', 'Quercus robur?x=1']) { assert.throws(() => apiURL([name])); await assert.rejects(fetchBatch([name], { fetchImpl: () => { throw new Error('must not fetch'); } }), /Invalid Latin/); } assert.throws(() => apiURL([])); assert.throws(() => apiURL(Array(11).fill('Quercus robur'))); const u = apiURL(['Quercus robur', 'Collybia nuda']); assert.equal(u.origin, 'https://en.wikipedia.org'); assert.equal(u.pathname, '/w/api.php'); assert.equal(u.searchParams.get('titles'), 'Quercus robur|Collybia nuda'); assert.equal(u.searchParams.get('explaintext'), '1'); assert.equal(u.searchParams.get('exintro'), '1'); assert.equal(u.searchParams.get('rvprop'), 'ids|timestamp'); }); test('strict HTTPS allowlist excludes credentials, ports, fragments and lookalike hosts', () => { for (const u of ['http://en.wikipedia.org/wiki/X', 'https://en.wikipedia.org.evil.example/', 'https://en.wikipedia.org@evil.example/', 'https://user:pass@en.wikipedia.org/', 'https://en.wikipedia.org:8443/', 'https://en.wikipedia.org/#x', 'https://127.0.0.1/', 'https://en.m.wikipedia.org/', 'file:///etc/passwd', 'https://evil.example/?next=https://en.wikipedia.org']) assert.throws(() => allowedURL(u)); assert.equal(allowedURL('https://en.wikipedia.org/wiki/Quercus_robur').hostname, 'en.wikipedia.org'); }); test('built-in fetch contract uses manual redirects, omitted credentials and bounded signals', async () => { const result = await fetchBatch([record.latin], { fetchImpl: mock((url, options) => { assert.equal(new URL(url).searchParams.get('titles'), record.latin); assert.equal(options.redirect, 'manual'); assert.equal(options.credentials, 'omit'); assert.equal(options.referrerPolicy, 'no-referrer'); assert.equal(options.headers['User-Agent'], USER_AGENT); assert.match(USER_AGENT, /LastHearth.*offline/i); assert.equal(Object.keys(options.headers).length, 2); assert.ok(options.signal instanceof AbortSignal); return jsonResponse(payload()); }) }); assert.equal(result.query.pages[0].pageid, 201415); assert.equal(LIMITS.requestMs, 15000); assert.equal(LIMITS.totalMs, 90000); }); test('redirect SSRF and query changes are rejected without a second request', async () => { for (const location of ['https://evil.example/', 'http://en.wikipedia.org/w/api.php', '//127.0.0.1/admin', 'https://en.wikipedia.org@127.0.0.1/', '/wiki/Quercus_robur', '/w/api.php?action=edit', 'https://u:p@en.wikipedia.org/w/api.php']) { let calls = 0; await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => { calls++; return new Response(null, { status: 302, headers: { location } }); }) })); assert.equal(calls, 1); } }); test('same allowed endpoint redirects are bounded to two, sharing one timeout', async () => { let calls = 0; const signals = new Set(); await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock((url, options) => { calls++; signals.add(options.signal); return new Response(null, { status: 307, headers: { location: url } }); }) }), /Too many/); assert.equal(calls, 3); assert.equal(signals.size, 1); }); test('unexpected automatic redirect response URL is rejected', async () => { await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => { const r = jsonResponse(payload()); Object.defineProperty(r, 'url', { value: 'https://evil.example/' }); return r; }) }), /Only credential-free/); }); test('HTTP errors, API warnings, malformed JSON and HTML are failures, not notes', async () => { for (const r of [new Response('rate limit', { status: 429 }), new Response('error', { status: 503 }), new Response('error', { headers: { 'content-type': 'text/html' } }), new Response('{bad', { headers: { 'content-type': 'application/json' } }), new Response(new Uint8Array([0xff]), { headers: { 'content-type': 'application/json' } })]) { await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => r) })); } for (const bad of [{ error: { code: 'maxlag' } }, { ...payload(), warnings: { extracts: 'failure' } }, { ...payload(), batchcomplete: false }, { query: { pages: [] } }]) { assert.throws(() => entriesFromResponse([record], bad, retrieved)); } }); test('advertised and streamed response limits both enforce 2 MiB', async () => { await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => jsonResponse(payload(), { 'content-length': String(LIMITS.responseBytes + 1) })) }), /byte limit/); let cancelled = false; const stream = new ReadableStream({ start(c) { c.enqueue(new Uint8Array(LIMITS.responseBytes + 1)); }, cancel() { cancelled = true; } }); await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => new Response(stream, { headers: { 'content-type': 'application/json' } })) }), /byte limit/); assert.equal(cancelled, true); }); test('aborting a stalled fetch or stalled response body is bounded and cancels body', async () => { const a = new AbortController(); const request = fetchBatch([record.latin], { signal: a.signal, fetchImpl: mock(() => new Promise(() => {})) }); a.abort(new Error('bounded request')); await assert.rejects(request, /bounded request/); const b = new AbortController(); let cancelled = false; const body = new ReadableStream({ pull() { b.abort(new Error('bounded body')); }, cancel() { cancelled = true; } }, { highWaterMark: 0 }); await assert.rejects(fetchBatch([record.latin], { signal: b.signal, fetchImpl: mock(() => new Response(body, { headers: { 'content-type': 'application/json' } })) }), /bounded body/); assert.equal(cancelled, true); }); test('source title must match exact Latin or a returned redirect chain; no guessed aliases', () => { const r = { id: 'wood_blewit', latin: 'Collybia nuda' }; const data = payload([page('Lepista nuda')], { redirects: [{ from: 'Collybia nuda', to: 'Lepista nuda' }] }); const [e] = entriesFromResponse([r], data, retrieved); assert.equal(e.latin, 'Collybia nuda'); assert.equal(e.title, 'Lepista nuda'); assert.equal(e.url, 'https://en.wikipedia.org/wiki/Lepista_nuda'); assert.equal(e.revision_url, 'https://en.wikipedia.org/w/index.php?oldid=12345'); assert.match(e.history_url, /title=Lepista_nuda&action=history$/); assert.equal(e.attribution, 'Wikipedia contributors, "Lepista nuda"'); assert.equal(e.license, 'CC BY-SA 4.0'); assert.throws(() => entriesFromResponse([r], payload([page('Lepista nuda')]), retrieved), /Unverified/); for (const redirects of [[{ from: r.latin, to: 'Lepista nuda', tofragment: 'Species' }], [{ from: r.latin, to: 'Lepista nuda' }, { from: 'Lepista nuda', to: r.latin }], [{ from: r.latin, to: 'Lepista nuda' }, { from: r.latin, to: 'Clitocybe nuda' }]]) { assert.throws(() => entriesFromResponse([r], payload([page('Lepista nuda')], { redirects }), retrieved)); } }); test('disambiguation, non-mainspace, language, title and canonical URL mismatch fail closed', () => { for (const changes of [{ pageprops: { disambiguation: '' } }, { ns: 1 }, { invalid: true }, { pagelanguage: 'fr' }, { contentmodel: 'json' }, { title: 'Quercus' }, { canonicalurl: 'https://evil.example/wiki/Quercus_robur' }, { canonicalurl: 'https://en.wikipedia.org/wiki/Betula_pendula' }, { canonicalurl: 'https://en.wikipedia.org/wiki/Quercus_robur?x=1' }, { canonicalurl: 'https://en.wikipedia.org/wiki/Quercus_robur#section' }]) { assert.throws(() => entriesFromResponse([record], payload([page(record.latin, changes)]), retrieved)); } }); test('page IDs and revisions must be positive finite safe integers with matching current revision', () => { for (const n of [0, -1, 1.5, NaN, Infinity, '12345', null, Number.MAX_SAFE_INTEGER + 1]) { assert.throws(() => entriesFromResponse([record], payload([page(record.latin, { pageid: n })]), retrieved)); assert.throws(() => entriesFromResponse([record], payload([page(record.latin, { revisions: [{ revid: n, timestamp: '2026-11-01T10:00:00Z' }] })]), retrieved)); } for (const changes of [{ lastrevid: 54321 }, { revisions: [] }, { revisions: [{ revid: 12345, timestamp: 'not a date' }] }, { revisions: [{ revid: 12345, timestamp: '2026-02-31T10:00:00Z' }] }, { revisions: [{ revid: 12345, timestamp: '2027-01-01T10:00:00Z' }] }]) { assert.throws(() => entriesFromResponse([record], payload([page(record.latin, changes)]), retrieved)); } }); test('only an explicit missing species page becomes original_notes, never a fake excerpt/revision', () => { const r = { id: 'old_mans_beard', latin: 'Usnea subfloridana' }; const missing = { ns: 0, title: r.latin, missing: true, canonicalurl: 'https://en.wikipedia.org/wiki/Usnea_subfloridana', contentmodel: 'wikitext', pagelanguage: 'en' }; const [e] = entriesFromResponse([r], payload([missing]), retrieved); assert.equal(e.kind, 'original_notes'); assert.equal(e.excerpt, false); assert.equal(e.revision, undefined); assert.equal(e.pageid, undefined); assert.equal(e.revision_url, undefined); assert.match(e.text, /not a Wikipedia excerpt/); assert.equal(e.license, 'CC0 1.0'); assert.throws(() => entriesFromResponse([r], payload([]), retrieved)); assert.throws(() => entriesFromResponse([r], payload([{ ...missing, missing: 'true' }]), retrieved)); assert.deepEqual(parseBundle(serializeBundle([e])), [e]); }); test('plaintext excerpt cap preserves exact Unicode prefix on a boundary and labels shortening', () => { const full = 'A botanical observation.\n' + 'A longer sentence about a species. '.repeat(500); const e = excerpt(full); assert.ok(Array.from(e.text).length <= 8000); assert.equal(e.truncated, true); assert.ok(full.startsWith(e.text)); assert.match(e.text, /[.!?]$/); const unicode = 'Species ' + String.fromCodePoint(0x1f333).repeat(1000) + ' ecology. '.repeat(1000); const u = excerpt(unicode); assert.ok(Array.from(u.text).length <= 8000 && !u.text.includes('\ufffd')); assert.equal(excerpt(intro).text, intro); assert.equal(excerpt(intro).truncated, false); for (const bad of ['', '' + intro, intro + '\0', intro + ' [[article]]']) assert.throws(() => excerpt(bad)); }); test('untrusted Wikipedia text is exclusively UTF-8 byte literals, never executable Lua', () => { const attack = 'Species text " }; os.execute("bad"); --\n]=] [[ return function() end \\123 ' + String.fromCodePoint(0xe9, 0x1f333); const entry = entriesFromResponse([record], payload(), retrieved)[0]; // Exercise the serializer directly even for text rejected by plaintext policy. entry.text = attack; const source = serializeBundle([entry]); assert.match(luaString(attack), /^"(?:\\\d{3})*"$/); assert.ok(!source.includes('os.execute') && !source.includes('return function')); assert.equal(parseBundle(source)[0].text, attack); assert.equal(serializeBundle(parseBundle(source)), source); assert.throws(() => parseBundle(source.replace('return {', 'os.execute("bad")\nreturn {'))); assert.throws(() => parseBundle(source.replace('\\083', '\\999'))); assert.throws(() => serializeBundle([{ ...entry, revision: Infinity }])); assert.throws(() => serializeBundle([{ ...entry, revision: '12345' }])); }); test('100 titles use exactly ten mocked batches at concurrency <=2 and stable catalogue order', async () => { let active = 0, peak = 0, calls = 0; const seen = []; const entries = await fetchEntries(catalogue, { now: () => new Date(retrieved), fetchImpl: mock(async url => { calls++; active++; peak = Math.max(peak, active); const names = new URL(url).searchParams.get('titles').split('|'); seen.push(...names); assert.ok(names.length <= 10); await setImmediate(); active--; return jsonResponse(payload(names.map(name => page(name)))); }) }); assert.equal(calls, 10); assert.equal(peak, 2); assert.deepEqual(seen, catalogue.map(r => r.latin)); assert.deepEqual(entries.map(r => r.id), catalogue.map(r => r.id)); assert.equal(entries.length, 100); assert.deepEqual(parseBundle(serializeBundle(entries)), entries); for (const records of [[], [...catalogue, { id: 'extra', latin: 'Abies alba' }]]) { await assert.rejects(fetchEntries(records, { fetchImpl: () => { throw new Error('must not fetch'); } }), /scope/); assert.throws(() => serializeBundle(records), /scope/); } const extra = { ...entries[0], id: 'extra', latin: 'Abies alba' }; const oversized = serializeBundle(entries).replace(/}\n$/, serializeBundle([extra]).split('return {\n')[1]); assert.throws(() => parseBundle(oversized), /scope/); }); test('batch failure aborts other workers and never launches the remaining catalogue', async () => { let calls = 0, otherSignal; await assert.rejects(fetchEntries(catalogue, { fetchImpl: mock((url, options) => { calls++; if (calls === 1) return new Response('error', { status: 503 }); otherSignal = options.signal; return new Promise(() => {}); }) }), /HTTP 503/); assert.equal(calls, 2); assert.equal(otherSignal.aborted, true); }); test('shipped bundle retains 78 old intros and two notes plus 20 real new intros for all 100 species', () => { assert.equal(cached.length, 100); assert.deepEqual(cached.map(({ id, latin }) => ({ id, latin })), catalogue); assert.equal(cached.filter(e => e.kind === 'wikipedia_intro').length, 98); const old = cached.filter(e => !additions.some(a => a.id === e.id)); assert.equal(old.length, 80); assert.equal(old.filter(e => e.kind === 'wikipedia_intro').length, 78); for (const addition of additions) { const e = cached.find(e => e.id === addition.id); assert.equal(e.latin, addition.latin); assert.equal(e.kind, 'wikipedia_intro'); assert.equal(e.excerpt, true); assert.ok(e.text.length > 30); } assert.deepEqual(cached.filter(e => e.kind === 'original_notes').map(e => e.latin), ['Usnea subfloridana', 'Cladonia chlorophaea']); for (const e of cached) { assert.ok(Array.from(e.text).length <= 8000); assert.ok(!/<\/?[A-Za-z][^>]*>/.test(e.text)); if (e.kind === 'wikipedia_intro') { assert.equal(e.license, 'CC BY-SA 4.0'); assert.ok(Number.isSafeInteger(e.pageid) && e.pageid > 0); assert.ok(Number.isSafeInteger(e.revision) && e.revision > 0); assert.ok(e.history_url.endsWith('&action=history')); assert.ok(e.revision_url.endsWith('?oldid=' + e.revision)); assert.match(e.attribution, /^Wikipedia contributors/); assert.equal(e.license_url, 'https://creativecommons.org/licenses/by-sa/4.0/'); assert.ok(Date.parse(e.revision_timestamp) <= Date.parse(e.retrieved)); } else { assert.equal(e.kind, 'original_notes'); assert.equal(e.license, 'CC0 1.0'); assert.equal(e.excerpt, false); for (const field of ['pageid', 'revision', 'revision_url', 'revision_timestamp']) assert.equal(e[field], undefined); } } assert.equal(cached.find(e => e.id === 'meadow_fescue').title, 'Lolium pratense'); assert.equal(cached.find(e => e.id === 'wood_blewit').title, 'Collybia nuda'); }); test('import, help, invalid CLI arguments and offline --check perform zero network requests', async () => { const original = globalThis.fetch; let network = 0; globalThis.fetch = () => { network++; throw new Error('Offline test attempted network'); }; try { await import('../scripts/wiki-species.mjs?offline-import-test'); await main(['--help']); for (const args of [[], ['--refresh', '--url', 'https://evil.example/'], ['https://evil.example/'], ['--output=../other.lua']]) { await assert.rejects(main(args), /Usage/); } await main(['--check']); assert.equal(network, 0); } finally { globalThis.fetch = original; } }); test('CLI refresh HTTP failure preserves the existing cache byte for byte', async () => { const url = new URL('../mods/hearth/wiki_articles.lua', import.meta.url); const before = await readFile(url), original = globalThis.fetch; let calls = 0; globalThis.fetch = mock(() => { calls++; return new Response('unavailable', { status: 503 }); }); try { await assert.rejects(main(['--refresh']), /HTTP 503/); } finally { globalThis.fetch = original; } assert.ok(calls > 0 && calls <= 2); assert.deepEqual(await readFile(url), before); }); test.after(() => console.log(`WIKI_SPECIES_OFFLINE_COUNTS: 21 tests; real network requests: 0; mocked requests: ${mockedRequests}.`));