733 lines
24 KiB
TypeScript
733 lines
24 KiB
TypeScript
// @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<void>;
|
|
beforeBatch?: () => Promise<void>;
|
|
} = {};
|
|
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' }),
|
|
/Your seat could not be verified\./,
|
|
);
|
|
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<void>((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' }),
|
|
/Your seat could not be verified\./,
|
|
);
|
|
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();
|
|
}
|
|
});
|