// @dune-suite integration import test from 'node:test'; import assert from 'node:assert/strict'; import { randomBytes, randomUUID, webcrypto } from 'node:crypto'; import { readFileSync, readdirSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import { DatabaseSync } from 'node:sqlite'; import ts from 'typescript'; import * as engine from '../game/engine'; import * as bots from '../game/bots'; import * as seatAiDelegation from '../lib/seat-ai-delegation'; import type * as Rooms from '../db/rooms'; const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000'; const secret = () => randomBytes(32).toString('hex'); async function request( path: string, body?: unknown, cookie?: string, origin?: string, ) { const response = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...(cookie ? { cookie } : {}), ...(origin ? { origin } : {}), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const raw = await response.text(); let data: unknown; try { data = JSON.parse(raw); } catch { data = { error: raw }; } return { status: response.status, data: data as engine.GameView & { view: engine.GameView; error?: string; replayed?: boolean; recoveryConfigured?: boolean; }, cookie: response.headers.get('set-cookie'), cache: response.headers.get('cache-control'), }; } async function create() { const r = await request('/api/rooms', { name: 'Seat recovery audit', faction: 'atreides', advanced: false, expansions: [], }); assert.equal(r.status, 201); assert.ok(r.cookie); return { ...r, cookie: r.cookie.split(';')[0], path: `/api/rooms/${r.data.code}`, }; } const sameGame = (a: engine.GameView, b: engine.GameView) => assert.deepEqual({ ...a, version: 0 }, { ...b, version: 0 }); const claim = (playerId: string, recoverySecret: string) => ({ type: 'recoverSeat', playerId, recoverySecret, operationId: randomUUID(), newSessionToken: secret(), }); void test('legacy cookie can configure recovery; recovery preserves private setup and invalidates the old seat cookie', async () => { const a = await create(); const b = await request(a.path, { type: 'join', name: 'Second seat', faction: 'harkonnen', }); assert.equal(b.status, 200); assert.ok(b.cookie); let view = b.data; for (const cookie of [a.cookie, b.cookie]) { const ready = await request( a.path, { version: view.version, action: { type: 'ready' } }, cookie, ); assert.equal(ready.status, 200); view = ready.data; } const start = await request( a.path, { version: view.version, action: { type: 'start' } }, a.cookie, ); assert.equal(start.status, 200); assert.equal(start.data.status, 'setup'); const recoverySecret = secret(); const saved = await request( a.path + '/control', { type: 'setRecoveryKey', version: start.data.version, recoverySecret }, a.cookie, ); assert.equal(saved.status, 200, JSON.stringify(saved.data)); assert.equal(saved.data.recoveryConfigured, true); sameGame(saved.data.view, start.data); const body = claim(a.data.me, recoverySecret); const restored = await request(a.path + '/control', body); assert.equal(restored.status, 200, JSON.stringify(restored.data)); assert.equal(restored.data.replayed, false); sameGame(restored.data.view, saved.data.view); assert.equal(restored.data.view.version, saved.data.view.version + 1); assert.equal(restored.cache, 'no-store'); assert.ok(restored.cookie?.includes('HttpOnly')); assert.ok(restored.cookie?.includes('SameSite=Strict')); assert.ok(restored.cookie?.includes(`Path=${a.path}`)); assert.equal((await request(a.path, undefined, a.cookie)).status, 409); assert.equal( ( await request( a.path, { version: restored.data.view.version, action: { type: 'ready' } }, a.cookie, ) ).status, 409, ); const other = await request(a.path, undefined, b.cookie); assert.equal(other.status, 200); assert.equal( other.data.players.find((p) => p.id === a.data.me)!.hand, undefined, ); assert.ok(!JSON.stringify(other.data).includes(recoverySecret)); assert.ok(!JSON.stringify(restored.data).includes(body.newSessionToken)); }); void test('lost recovery response replays once; altered payloads and older rotation receipts cannot rotate again', async () => { const a = await create(), recoverySecret = secret(); const saved = await request( a.path + '/control', { type: 'setRecoveryKey', version: a.data.version, recoverySecret }, a.cookie, ); assert.equal(saved.status, 200); const firstBody = claim(a.data.me, recoverySecret); const first = await request(a.path + '/control', firstBody); assert.equal(first.status, 200); const replay = await request(a.path + '/control', firstBody); assert.equal(replay.status, 200); assert.equal(replay.data.replayed, true); assert.deepEqual(replay.data.view, first.data.view); assert.equal(replay.cookie, first.cookie); const altered = await request(a.path + '/control', { ...firstBody, newSessionToken: secret(), }); assert.equal(altered.status, 409); assert.equal(altered.data.code, 'RECOVERY_RECEIPT_INVALID'); const second = await request( a.path + '/control', claim(a.data.me, recoverySecret), ); assert.equal(second.status, 200); const old = await request(a.path + '/control', firstBody); assert.equal(old.status, 409); assert.equal(old.data.code, 'RECOVERY_RECEIPT_INVALID'); assert.equal((await request(a.path, undefined, first.cookie!)).status, 409); const reused = await request(a.path + '/control', { ...claim(a.data.me, recoverySecret), newSessionToken: firstBody.newSessionToken, }); assert.equal(reused.status, 409); assert.equal(reused.data.code, 'INVALID_SESSION_TOKEN'); const final = await request(a.path, undefined, second.cookie!); assert.deepEqual(final.data, second.data.view); }); void test('stale key setup is atomic; key replacement invalidates old proof and even same-key prior receipts', async () => { const a = await create(), key = secret(), replacement = secret(); const saved = await request( a.path + '/control', { type: 'setRecoveryKey', version: a.data.version, recoverySecret: key }, a.cookie, ); const stale = await request( a.path + '/control', { type: 'setRecoveryKey', version: a.data.version, recoverySecret: replacement, }, a.cookie, ); assert.equal(stale.status, 409); assert.equal(stale.data.code, 'STALE_VERSION'); assert.equal( (await request(a.path + '/control', claim(a.data.me, replacement))).status, 409, ); const body = claim(a.data.me, key); const recovered = await request(a.path + '/control', body); assert.equal(recovered.status, 200); const resaved = await request( a.path + '/control', { type: 'setRecoveryKey', version: recovered.data.view.version, recoverySecret: key, }, recovered.cookie!, ); assert.equal(resaved.status, 200); assert.equal( (await request(a.path + '/control', body)).data.code, 'RECOVERY_RECEIPT_INVALID', ); const changed = await request( a.path + '/control', { type: 'setRecoveryKey', version: resaved.data.view.version, recoverySecret: replacement, }, recovered.cookie!, ); assert.equal(changed.status, 200); assert.equal( (await request(a.path + '/control', claim(a.data.me, key))).data.code, 'INVALID_RECOVERY_PROOF', ); assert.equal( (await request(a.path + '/control', claim(a.data.me, replacement))).status, 200, ); assert.equal(saved.data.view.version, a.data.version + 1); }); void test('recovery rejects other-player proofs, malformed credentials and cross-origin writes without disclosure', async () => { const a = await create(), key = secret(); const saved = await request( a.path + '/control', { type: 'setRecoveryKey', version: a.data.version, recoverySecret: key }, a.cookie, ); assert.equal(saved.status, 200); for (const body of [claim(randomUUID(), key), claim(a.data.me, secret())]) { const r = await request(a.path + '/control', body); assert.equal(r.status, 409); assert.equal(r.data.code, 'INVALID_RECOVERY_PROOF'); assert.deepEqual(Object.keys(r.data).sort(), ['code', 'error']); assert.equal(r.cookie, null); } assert.equal( ( await request(a.path + '/control', { ...claim(a.data.me, key), newSessionToken: 'weak', }) ).status, 400, ); assert.equal( ( await request( a.path + '/control', claim(a.data.me, key), undefined, 'https://unrelated.invalid', ) ).status, 403, ); assert.equal((await request(a.path + '/control', null)).status, 400); const after = await request(a.path, undefined, a.cookie); assert.deepEqual(after.data, saved.data.view); }); /** Run the actual room module against SQLite to retain an auth context across rotation. */ function unitStore() { const hooks: { beforeRead?: (sql: string) => Promise; beforeBatch?: () => Promise; } = {}; const sqlite = new DatabaseSync(':memory:'); for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) .filter((f) => f.endsWith('.sql')) .sort()) sqlite.exec( readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'), ); class Statement { values: (string | number | null)[] = []; constructor(readonly sql: string) {} bind(...values: (string | number | null)[]) { this.values = values; return this; } async first() { await hooks.beforeRead?.(this.sql); return sqlite.prepare(this.sql).get(...this.values) ?? null; } async run() { return { meta: { changes: Number(sqlite.prepare(this.sql).run(...this.values).changes), }, }; } } const database = { prepare: (sql: string) => new Statement(sql), batch: async (statements: Statement[]) => { await hooks.beforeBatch?.(); sqlite.exec('BEGIN'); try { const rows = []; for (const statement of statements) rows.push(await statement.run()); sqlite.exec('COMMIT'); return rows; } catch (error) { sqlite.exec('ROLLBACK'); throw error; } }, }; const source = readFileSync( new URL('../db/rooms.ts', import.meta.url), 'utf8', ); const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, }, }).outputText; const exports = {}; runInNewContext(compiled, { exports, crypto: webcrypto, TextEncoder, structuredClone, require: (name: string) => { if (name === 'cloudflare:workers') return { env: { DB: database } }; if (name === '@/game/engine') return engine; if (name === '@/game/bots') return bots; if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; throw new Error('Unexpected module ' + name); }, }); return { rooms: exports as typeof Rooms, sqlite, hooks }; } void test('already-authenticated old actions and reads are fenced even at the latest room version', async () => { const { rooms, sqlite } = unitStore(); try { const created = await rooms.createRoom( 'Fenced owner', 'atreides', false, [], ); const code = created.view.code; const oldAuth = await rooms.authenticate(code, created.token); const key = secret(); await rooms.setRecoveryKey(code, oldAuth, created.view.version, key); const body = claim(created.view.me, key); const recovered = await rooms.recoverSeat(code, body); // Deliberately supply the fresh version: version protection alone must not pass this request. await assert.rejects( rooms.act(code, oldAuth, recovered.view.version, { type: 'ready' }), /Another action/, ); await assert.rejects(rooms.readSeatView(code, oldAuth), /verified/); const newAuth = await rooms.authenticate(code, body.newSessionToken); const after = await rooms.readSeatView(code, newAuth); assert.deepEqual(after, recovered.view); const advanced = await rooms.act(code, newAuth, after.version, { type: 'ready', }); assert.equal(advanced.players[0].ready, true); const stored = sqlite.prepare('SELECT * FROM seat_recovery_keys').get()!; assert.notEqual(stored.recovery_hash, key); assert.equal(typeof stored.recovery_hash, 'string'); assert.equal(String(stored.recovery_hash).length, 64); assert.equal( sqlite.prepare('SELECT count(*) AS n FROM seats WHERE revoked = 0').get()! .n, 1, ); } finally { sqlite.close(); } }); void test('simultaneous identical recovery claims commit one rotation and can both recover the same receipt', async () => { const a = await create(), key = secret(); const saved = await request( a.path + '/control', { type: 'setRecoveryKey', version: a.data.version, recoverySecret: key }, a.cookie, ); assert.equal(saved.status, 200); const body = claim(a.data.me, key); const attempts = await Promise.all([ request(a.path + '/control', body), request(a.path + '/control', body), ]); assert.ok(attempts.some((r) => r.status === 200)); assert.ok(attempts.every((r) => [200, 409].includes(r.status))); const receipt = await request(a.path + '/control', body); assert.equal(receipt.status, 200); assert.equal(receipt.data.replayed, true); assert.equal(receipt.data.view.version, saved.data.view.version + 1); assert.equal((await request(a.path, undefined, a.cookie)).status, 409); }); void test('recovery retains the exact persisted sealed battle and does not reveal it to an opponent', async () => { const { rooms, sqlite } = unitStore(); try { const created = await rooms.createRoom( 'Battle recovery', 'atreides', false, [], ); const code = created.view.code, id = created.view.me; let g = await rooms.readRoom(code); engine.joinGame(g, engine.newPlayer('opponent', 'Opponent', 'emperor')); g.players.forEach((p) => { p.ready = true; }); g = engine.applyAction(g, id, { type: 'start' }); for (const p of g.players) { if (p.traitorChoices.length) g = engine.applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0], }); } g.phase = 6; g.storm = 18; g.active = id; g.order = [id, 'opponent']; for (const p of g.players) { p.forces = { 'arrakeen:10': 5 }; p.reserves = 15; p.spice = 10; } g = engine.applyAction(g, id, { type: 'chooseBattle', territory: 'arrakeen', target: 'opponent', }); for ( let step = 0; step < 20 && (g.response || g.battle?.preparation); step++ ) { if (g.response) g = engine.applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); else g = engine.applyAction(g, g.battle!.preparation!.owner, { type: 'declineBattlePower', }); } g = engine.applyAction(g, id, { type: 'battlePlan', dial: 2, leader: 'atreides-0', }); assert.ok(g.battle?.plans[id]); assert.equal(g.battle.revealed, false); const state = JSON.stringify(g); sqlite .prepare('UPDATE rooms SET state = ? WHERE code = ?') .run(state, code); const oldAuth = await rooms.authenticate(code, created.token), key = secret(); const saved = await rooms.setRecoveryKey(code, oldAuth, g.version, key); const recovered = await rooms.recoverSeat(code, claim(id, key)); sameGame(recovered.view, saved.view); assert.equal( sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(code)!.state, state, ); const opponent = engine.viewGame(await rooms.readRoom(code), 'opponent'); assert.equal(opponent.battle!.plans[id], undefined); assert.ok(recovered.view.battle!.plans[id]); assert.equal(recovered.view.battle!.revealed, false); } finally { sqlite.close(); } }); async function recoveryUnitFixture() { const store = unitStore(); const created = await store.rooms.createRoom( 'Interleaving recovery', 'atreides', false, [], ); const code = created.view.code, auth = await store.rooms.authenticate(code, created.token), key = secret(); await store.rooms.setRecoveryKey(code, auth, created.view.version, key); return { ...store, created, code, auth, key }; } function custodySnapshot(sqlite: DatabaseSync) { return JSON.stringify( ['rooms', 'seats', 'seat_recovery_keys', 'seat_recovery_receipts'].map( (table) => sqlite.prepare('SELECT * FROM ' + table + ' ORDER BY rowid').all(), ), ); } void test('SQLite recovery rolls back all custody changes when replacement credential insertion fails', async () => { const f = await recoveryUnitFixture(); try { const before = custodySnapshot(f.sqlite); f.sqlite.exec( "CREATE TRIGGER reject_replacement BEFORE INSERT ON seats BEGIN SELECT RAISE(ABORT, 'injected insertion failure'); END;", ); await assert.rejects( f.rooms.recoverSeat(f.code, claim(f.created.view.me, f.key)), /injected insertion failure/, ); assert.equal(custodySnapshot(f.sqlite), before); await f.rooms.readSeatView(f.code, f.auth); } finally { f.sqlite.close(); } }); void test('SQLite recovery rechecks a replaced proof between preflight and its atomic write', async () => { const f = await recoveryUnitFixture(); try { let afterReplacement = ''; f.hooks.beforeRead = async (sql) => { if (!sql.startsWith('SELECT state,version FROM rooms')) return; f.hooks.beforeRead = undefined; const current = await f.rooms.readRoom(f.code); await f.rooms.setRecoveryKey(f.code, f.auth, current.version, secret()); afterReplacement = custodySnapshot(f.sqlite); }; await assert.rejects( f.rooms.recoverSeat(f.code, claim(f.created.view.me, f.key)), /changed/, ); assert.ok(afterReplacement); assert.equal(custodySnapshot(f.sqlite), afterReplacement); await f.rooms.readSeatView(f.code, f.auth); } finally { f.sqlite.close(); } }); void test('SQLite recovery receipt cannot disclose state if another rotation wins after receipt preflight', async () => { const f = await recoveryUnitFixture(); try { const original = claim(f.created.view.me, f.key); await f.rooms.recoverSeat(f.code, original); const replacement = claim(f.created.view.me, f.key); let afterRotation = ''; f.hooks.beforeRead = async (sql) => { if (!sql.includes('JOIN seat_recovery_keys AS keys')) return; f.hooks.beforeRead = undefined; await f.rooms.recoverSeat(f.code, replacement); afterRotation = custodySnapshot(f.sqlite); }; await assert.rejects( f.rooms.recoverSeat(f.code, original), /no longer active/, ); assert.ok(afterRotation); assert.equal(custodySnapshot(f.sqlite), afterRotation); await assert.rejects( f.rooms.authenticate(f.code, original.newSessionToken), /verified/, ); await f.rooms.authenticate(f.code, replacement.newSessionToken); } finally { f.sqlite.close(); } }); void test('SQLite recovery same-key replacement invalidates a receipt already being verified', async () => { const f = await recoveryUnitFixture(); try { const body = claim(f.created.view.me, f.key); const recovered = await f.rooms.recoverSeat(f.code, body); const auth = await f.rooms.authenticate(f.code, body.newSessionToken); let afterReplacement = ''; f.hooks.beforeRead = async (sql) => { if (!sql.includes('JOIN seat_recovery_keys AS keys')) return; f.hooks.beforeRead = undefined; await f.rooms.setRecoveryKey(f.code, auth, recovered.view.version, f.key); afterReplacement = custodySnapshot(f.sqlite); }; await assert.rejects(f.rooms.recoverSeat(f.code, body), /no longer active/); assert.ok(afterReplacement); assert.equal(custodySnapshot(f.sqlite), afterReplacement); await f.rooms.readSeatView(f.code, auth); } finally { f.sqlite.close(); } }); void test('SQLite recovery stale version makes every conditional batch statement a no-op', async () => { const f = await recoveryUnitFixture(); try { let afterAction = ''; f.hooks.beforeBatch = async () => { f.hooks.beforeBatch = undefined; const current = await f.rooms.readRoom(f.code); await f.rooms.act(f.code, f.auth, current.version, { type: 'ready' }); afterAction = custodySnapshot(f.sqlite); }; await assert.rejects( f.rooms.recoverSeat(f.code, claim(f.created.view.me, f.key)), /changed/, ); assert.ok(afterAction); assert.equal(custodySnapshot(f.sqlite), afterAction); const view = await f.rooms.readSeatView(f.code, f.auth); assert.equal(view.players[0].ready, true); } finally { f.sqlite.close(); } }); function barrierGate() { let release!: () => void; const promise = new Promise((resolve) => { release = resolve; }); return { promise, release }; } void test('SQLite recovery distinct concurrent claims fence the loser and never resurrect a superseded receipt', async () => { const f = await recoveryUnitFixture(); const gates = [barrierGate(), barrierGate()], bothReady = barrierGate(), firstFinished = barrierGate(); try { const initialVersion = (await f.rooms.readRoom(f.code)).version; const bodies = [ claim(f.created.view.me, f.key), claim(f.created.view.me, f.key), ]; let arrivals = 0; f.hooks.beforeBatch = async () => { const index = arrivals++; assert.ok(index < 2); if (arrivals === 2) bothReady.release(); await gates[index].promise; }; const attempts = bodies.map((body) => f.rooms.recoverSeat(f.code, body).then( (value) => { firstFinished.release(); return { status: 'fulfilled' as const, value }; }, (reason: unknown) => ({ status: 'rejected' as const, reason }), ), ); // Both claims have read the same room version, but neither has begun its transaction. await bothReady.promise; gates[0].release(); await firstFinished.promise; gates[1].release(); const outcomes = await Promise.all(attempts); f.hooks.beforeBatch = undefined; assert.equal(outcomes.filter((r) => r.status === 'fulfilled').length, 1); assert.equal(outcomes.filter((r) => r.status === 'rejected').length, 1); const winner = outcomes.findIndex((r) => r.status === 'fulfilled'), loser = 1 - winner; assert.equal((await f.rooms.readRoom(f.code)).version, initialVersion + 1); assert.equal( f.sqlite .prepare('SELECT count(*) AS n FROM seat_recovery_receipts') .get()!.n, 1, ); await assert.rejects( f.rooms.authenticate(f.code, bodies[loser].newSessionToken), /verified/, ); const winningAuth = await f.rooms.authenticate( f.code, bodies[winner].newSessionToken, ); // The failed operation has no receipt; an explicit retry with valid owner proof may now perform a NEW rotation. const later = await f.rooms.recoverSeat(f.code, bodies[loser]); assert.equal(later.replayed, false); assert.equal(later.view.version, initialVersion + 2); const afterLater = custodySnapshot(f.sqlite); await assert.rejects( f.rooms.recoverSeat(f.code, bodies[winner]), /no longer active/, ); await assert.rejects( f.rooms.act(f.code, winningAuth, later.view.version, { type: 'ready' }), /Another action/, ); assert.equal(custodySnapshot(f.sqlite), afterLater); assert.equal( f.sqlite .prepare('SELECT count(*) AS n FROM seats WHERE revoked = 0') .get()!.n, 1, ); await f.rooms.authenticate(f.code, bodies[loser].newSessionToken); } finally { for (const gate of gates) gate.release(); f.sqlite.close(); } });