Create an explicitly owned new host seat with supported rules/modules and ordered AI seats through one live-authorized transaction. Save exact account-bound retries across refresh, record durable audit metadata, and never restore a revoked or transferred host credential. Connect invitation joining and ordinary voluntary host handover. Preserve normal readiness and Advanced preview gates. Verified types, lint, 5240 offline tests, 52 HTTP tests and build. Eight authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one room after a lost response and refresh/retry, working host controls and saved continuation. Mobile layout passed; requested desktop viewport override did not take effect. Existing games preserved. Authenticated deployed administrator acceptance remains pending explicit QA access.
770 lines
25 KiB
TypeScript
770 lines
25 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { randomBytes, randomUUID, webcrypto } from 'node:crypto';
|
|
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
|
|
import {
|
|
adminLogin,
|
|
requireAdmin,
|
|
adminLogoutAll,
|
|
AdminError,
|
|
type AdminRole,
|
|
} from '../db/admin-access';
|
|
import { createAdminRoom } from '../db/admin-room-creation';
|
|
import {
|
|
validAdminRoomCreationInput,
|
|
type AdminRoomCreationInput,
|
|
} from '../lib/admin-room-creation';
|
|
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 secret = () => randomBytes(32).toString('hex');
|
|
const input = (
|
|
patch: Partial<AdminRoomCreationInput> = {},
|
|
): AdminRoomCreationInput => ({
|
|
operationId: randomUUID(),
|
|
sessionToken: secret(),
|
|
name: 'Creation QA host',
|
|
faction: 'atreides',
|
|
advanced: false,
|
|
techTokens: false,
|
|
strongholdCards: false,
|
|
bots: [{ faction: 'harkonnen', difficulty: 'Hard' }],
|
|
reason: 'Dedicated QA room creation',
|
|
...patch,
|
|
});
|
|
const status = (wanted: number) => (error: unknown) =>
|
|
error instanceof AdminError && error.status === wanted;
|
|
const rows = (sqlite: DatabaseSync, table: string) =>
|
|
sqlite.prepare(`SELECT * FROM ${table}`).all();
|
|
const saved = (sqlite: DatabaseSync, code: string) =>
|
|
JSON.parse(
|
|
String(
|
|
sqlite.prepare('SELECT state FROM rooms WHERE code=?').get(code)!.state,
|
|
),
|
|
) as engine.Game;
|
|
|
|
function loadRooms(database: D1Database) {
|
|
const exports = {};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports,
|
|
crypto: webcrypto,
|
|
TextEncoder,
|
|
structuredClone,
|
|
JSON,
|
|
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 dependency ' + name);
|
|
},
|
|
},
|
|
);
|
|
return exports as typeof Rooms;
|
|
}
|
|
async function fixture(role: AdminRole = 'operator') {
|
|
const store = adminStore();
|
|
const account = store.provision(role);
|
|
const login = await adminLogin(store.database, account.key);
|
|
const identity = await requireAdmin(store.database, login.token);
|
|
const rooms = loadRooms(store.database);
|
|
const create = (request = input(), now = 10000) =>
|
|
createAdminRoom(store.database, identity, request, now);
|
|
return { ...store, identity, account, login, rooms, create };
|
|
}
|
|
|
|
void test('admin creation migration adds only its durable receipt table and indexes', () => {
|
|
const store = adminStore(':memory:', false);
|
|
try {
|
|
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
|
.filter((file) => file.endsWith('.sql') && file < '0010')
|
|
.sort())
|
|
store.sqlite.exec(
|
|
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
|
);
|
|
store.sqlite
|
|
.prepare(
|
|
'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)',
|
|
)
|
|
.run('PRESERVE', '{"private":"saved game"}', 17, 123);
|
|
store.sqlite
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
|
)
|
|
.run(sha256(secret()), 'PRESERVE', randomUUID());
|
|
const account = store.provision('owner');
|
|
const before = ['rooms', 'seats', 'admin_accounts', 'admin_audit'].map(
|
|
(table) => rows(store.sqlite, table),
|
|
);
|
|
store.sqlite.exec(
|
|
readFileSync(
|
|
new URL('../drizzle/0010_admin_room_creation.sql', import.meta.url),
|
|
'utf8',
|
|
),
|
|
);
|
|
assert.deepEqual(
|
|
['rooms', 'seats', 'admin_accounts', 'admin_audit'].map((table) =>
|
|
rows(store.sqlite, table),
|
|
),
|
|
before,
|
|
);
|
|
assert.deepEqual(rows(store.sqlite, 'admin_room_creations'), []);
|
|
assert.throws(
|
|
() =>
|
|
store.sqlite
|
|
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
|
|
.run(account.id),
|
|
/last|final|owner/i,
|
|
);
|
|
} finally {
|
|
store.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('admin creates zero through five AI seats through ordinary lobby actions with one unready human host', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const factions = [
|
|
'harkonnen',
|
|
'emperor',
|
|
'fremen',
|
|
'guild',
|
|
'beneGesserit',
|
|
] as const;
|
|
for (let n = 0; n <= 5; n++) {
|
|
const request = input({
|
|
bots: factions.slice(0, n).map((faction, index) => ({
|
|
faction,
|
|
difficulty: (['Easy', 'Medium', 'Hard', 'Brutal'] as const)[
|
|
index % 4
|
|
],
|
|
})),
|
|
});
|
|
const result = await f.create(request);
|
|
assert.deepEqual(Object.keys(result).sort(), [
|
|
'code',
|
|
'hostAccess',
|
|
'hostId',
|
|
'operationId',
|
|
'replayed',
|
|
]);
|
|
assert.equal(result.operationId, request.operationId);
|
|
assert.equal(result.replayed, false);
|
|
assert.equal(result.hostAccess, true);
|
|
assert.match(result.code, /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/);
|
|
const game = saved(f.sqlite, result.code);
|
|
assert.equal(game.status, 'lobby');
|
|
assert.equal(game.version, 0);
|
|
assert.equal(game.host, result.hostId);
|
|
assert.equal(game.players.length, n + 1);
|
|
assert.equal(game.players[0].ready, false);
|
|
assert.equal(game.players[0].bot, undefined);
|
|
assert.deepEqual(
|
|
game.players.slice(1).map((player) => ({
|
|
faction: player.faction,
|
|
difficulty: player.bot,
|
|
})),
|
|
request.bots,
|
|
);
|
|
assert.ok(game.players.slice(1).every((player) => player.ready));
|
|
assert.deepEqual(game.deck, []);
|
|
assert.equal(game.botsPending ?? false, false);
|
|
assert.equal(game.botNextActionAt, undefined);
|
|
assert.deepEqual(game.expansions, []);
|
|
assert.equal(game.advancedPreview, undefined);
|
|
const seat = f.sqlite
|
|
.prepare('SELECT * FROM seats WHERE room_code=?')
|
|
.all(result.code);
|
|
assert.equal(seat.length, 1);
|
|
assert.equal(seat[0].token_hash, sha256(request.sessionToken));
|
|
assert.equal(seat[0].player_id, result.hostId);
|
|
const auth = await f.rooms.authenticate(
|
|
result.code,
|
|
request.sessionToken,
|
|
);
|
|
const view = await f.rooms.readSeatView(result.code, auth);
|
|
assert.equal(view.me, result.hostId);
|
|
assert.equal(view.host, result.hostId);
|
|
assert.equal(JSON.stringify(view).includes(request.reason), false);
|
|
}
|
|
assert.equal(rows(f.sqlite, 'rooms').length, 6);
|
|
assert.equal(rows(f.sqlite, 'seats').length, 6);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('Basic and explicit Advanced preview genuinely start, retain modules, offer human choices and legally continue AI after JSON restore', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
for (const advanced of [false, true]) {
|
|
const request = input({
|
|
advanced,
|
|
techTokens: true,
|
|
strongholdCards: advanced,
|
|
bots: [
|
|
{ faction: 'harkonnen', difficulty: 'Easy' },
|
|
{ faction: 'guild', difficulty: 'Medium' },
|
|
],
|
|
});
|
|
const created = await f.create(request);
|
|
const auth = await f.rooms.authenticate(
|
|
created.code,
|
|
request.sessionToken,
|
|
);
|
|
let now = 10000;
|
|
const clock = {
|
|
now: () => now,
|
|
sleep: async (ms: number) => {
|
|
now += ms;
|
|
},
|
|
};
|
|
let view = await f.rooms.readSeatView(created.code, auth);
|
|
assert.equal(view.status, 'lobby');
|
|
assert.ok(view.techTokens);
|
|
assert.equal(!!view.strongholdCards, advanced);
|
|
await assert.rejects(
|
|
f.rooms.act(
|
|
created.code,
|
|
auth,
|
|
view.version,
|
|
{ type: 'start', ...(advanced ? { advancedPreview: true } : {}) },
|
|
clock,
|
|
),
|
|
/ready/i,
|
|
);
|
|
view = await f.rooms.act(
|
|
created.code,
|
|
auth,
|
|
view.version,
|
|
{ type: 'ready' },
|
|
clock,
|
|
);
|
|
if (advanced)
|
|
await assert.rejects(
|
|
f.rooms.act(
|
|
created.code,
|
|
auth,
|
|
view.version,
|
|
{ type: 'start' },
|
|
clock,
|
|
),
|
|
/Advanced.*unfinished|preview/i,
|
|
);
|
|
view = await f.rooms.act(
|
|
created.code,
|
|
auth,
|
|
view.version,
|
|
{ type: 'start', ...(advanced ? { advancedPreview: true } : {}) },
|
|
clock,
|
|
);
|
|
assert.equal(view.status, 'setup');
|
|
assert.equal(view.advancedPreview, advanced);
|
|
const ownerChoices = view.players.find(
|
|
(player) => player.id === view.me,
|
|
)!.traitorChoices;
|
|
assert.ok(ownerChoices?.length);
|
|
await loadRooms(f.database).continueRoomBots(created.code, 4, clock);
|
|
const restored = await loadRooms(f.database).readSeatView(
|
|
created.code,
|
|
auth,
|
|
);
|
|
assert.deepEqual(
|
|
restored.players.find((player) => player.id === view.me)!
|
|
.traitorChoices,
|
|
ownerChoices,
|
|
);
|
|
assert.ok(restored.version > view.version);
|
|
for (const player of restored.players.filter(
|
|
(player) => player.id !== view.me,
|
|
)) {
|
|
assert.equal('hand' in player, false);
|
|
assert.equal('traitors' in player, false);
|
|
assert.equal('traitorChoices' in player, false);
|
|
}
|
|
}
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('Tech Tokens remain selectable in a small lobby but ordinary start still requires three players', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const request = input({ techTokens: true });
|
|
const created = await f.create(request);
|
|
const auth = await f.rooms.authenticate(created.code, request.sessionToken);
|
|
const view = await f.rooms.act(created.code, auth, 0, { type: 'ready' });
|
|
const before = rows(f.sqlite, 'rooms');
|
|
await assert.rejects(
|
|
f.rooms.act(created.code, auth, view.version, { type: 'start' }),
|
|
/three players/i,
|
|
);
|
|
assert.deepEqual(rows(f.sqlite, 'rooms'), before);
|
|
const added = await f.rooms.act(created.code, auth, view.version, {
|
|
type: 'addBot',
|
|
faction: 'guild',
|
|
difficulty: 'Hard',
|
|
});
|
|
const ready = await f.rooms.act(created.code, auth, added.version, {
|
|
type: 'ready',
|
|
});
|
|
assert.equal(
|
|
(await f.rooms.act(created.code, auth, ready.version, { type: 'start' }))
|
|
.status,
|
|
'setup',
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('strict creation input rejects malformed proof, missing or unknown fields, duplicate/expansion factions and unsupported profiles before persistence', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const missing = input();
|
|
delete (missing as Partial<AdminRoomCreationInput>).reason;
|
|
const invalid: unknown[] = [
|
|
null,
|
|
[],
|
|
missing,
|
|
...[
|
|
{ operationId: randomUUID() + '\n' },
|
|
{ sessionToken: secret() + '\n' },
|
|
{ sessionToken: 'short' },
|
|
{ name: ' ' },
|
|
{ name: 'x'.repeat(33) },
|
|
{ name: 'line\nbreak' },
|
|
{ reason: ' ' },
|
|
{ reason: 'x'.repeat(301) },
|
|
{ reason: 'line\nbreak' },
|
|
{ advanced: 'true' },
|
|
{ strongholdCards: true },
|
|
{ faction: 'ixians' },
|
|
{ expansion: ['ix'] },
|
|
{ start: true },
|
|
{ hostId: randomUUID() },
|
|
{ bots: Array(6).fill({ faction: 'guild', difficulty: 'Hard' }) },
|
|
{ bots: [{ faction: 'atreides', difficulty: 'Hard' }] },
|
|
{
|
|
bots: [
|
|
{ faction: 'guild', difficulty: 'Hard' },
|
|
{ faction: 'guild', difficulty: 'Easy' },
|
|
],
|
|
},
|
|
{ bots: [{ faction: 'ixians', difficulty: 'Hard' }] },
|
|
{ bots: [{ faction: 'guild', difficulty: 'Impossible' }] },
|
|
{ bots: [{ faction: 'guild', difficulty: 'Hard', ready: true }] },
|
|
].map((patch) => ({ ...input(), ...patch })),
|
|
];
|
|
for (const request of invalid) {
|
|
assert.equal(validAdminRoomCreationInput(request), false);
|
|
await assert.rejects(
|
|
f.create(request as AdminRoomCreationInput),
|
|
status(400),
|
|
);
|
|
}
|
|
assert.equal(rows(f.sqlite, 'rooms').length, 0);
|
|
assert.equal(rows(f.sqlite, 'seats').length, 0);
|
|
assert.equal(rows(f.sqlite, 'admin_room_creations').length, 0);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('live owner/operator roles authorize creation and viewer, unknown session and forged role cannot create or replay', async () => {
|
|
for (const role of ['owner', 'operator', 'viewer'] as const) {
|
|
const f = await fixture(role);
|
|
try {
|
|
const request = input();
|
|
const attempt = createAdminRoom(
|
|
f.database,
|
|
{ ...f.identity, role: 'owner' },
|
|
request,
|
|
);
|
|
if (role === 'viewer') {
|
|
await assert.rejects(attempt, status(403));
|
|
assert.equal(rows(f.sqlite, 'rooms').length, 0);
|
|
} else {
|
|
await attempt;
|
|
if (role === 'operator') {
|
|
f.sqlite
|
|
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
|
|
.run(f.identity.id);
|
|
await assert.rejects(f.create(request), status(403));
|
|
}
|
|
}
|
|
await assert.rejects(
|
|
createAdminRoom(
|
|
f.database,
|
|
{ ...f.identity, sessionHash: secret() },
|
|
input(),
|
|
),
|
|
status(401),
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('creation and receipt reads recheck disable, logout, expiry and demotion inside the transaction', async () => {
|
|
for (const replay of [false, true])
|
|
for (const mode of ['disable', 'logout', 'expiry', 'demote'] as const) {
|
|
const f = await fixture();
|
|
try {
|
|
const request = input();
|
|
if (replay) await f.create(request);
|
|
const before = ['rooms', 'seats', 'admin_room_creations'].map((table) =>
|
|
rows(f.sqlite, table),
|
|
);
|
|
const gate = pauseAdminBatch(f);
|
|
const pending = f.create(request);
|
|
await gate.waiting;
|
|
if (mode === 'disable')
|
|
f.sqlite
|
|
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
|
|
.run(f.identity.id);
|
|
else if (mode === 'logout')
|
|
await adminLogoutAll(f.database, f.identity);
|
|
else if (mode === 'expiry')
|
|
f.sqlite
|
|
.prepare(
|
|
'UPDATE admin_sessions SET expires_at=1 WHERE token_hash=?',
|
|
)
|
|
.run(f.identity.sessionHash);
|
|
else
|
|
f.sqlite
|
|
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
|
|
.run(f.identity.id);
|
|
gate.release();
|
|
await assert.rejects(pending, status(mode === 'demote' ? 403 : 401));
|
|
assert.deepEqual(
|
|
['rooms', 'seats', 'admin_room_creations'].map((table) =>
|
|
rows(f.sqlite, table),
|
|
),
|
|
before,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('same-operation concurrency and normalized exact retries commit one room, seat and public audit receipt', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const request = input({
|
|
name: ' Creation QA host ',
|
|
reason: ' Dedicated QA room creation ',
|
|
bots: [
|
|
{ faction: 'guild', difficulty: 'Hard' },
|
|
{ faction: 'harkonnen', difficulty: 'Easy' },
|
|
],
|
|
});
|
|
const gate = pauseAdminBatch(f);
|
|
const pending = f.create(request);
|
|
await gate.waiting;
|
|
const winner = await f.create(request);
|
|
gate.release();
|
|
const retry = await pending;
|
|
assert.deepEqual(retry, { ...winner, replayed: true });
|
|
assert.deepEqual(
|
|
await f.create({
|
|
...request,
|
|
name: request.name.trim(),
|
|
reason: request.reason.trim(),
|
|
}),
|
|
retry,
|
|
);
|
|
const before = ['rooms', 'seats', 'admin_room_creations'].map((table) =>
|
|
rows(f.sqlite, table),
|
|
);
|
|
for (const patch of [
|
|
{ name: 'Changed host' },
|
|
{ sessionToken: secret() },
|
|
{ advanced: true },
|
|
{ techTokens: true },
|
|
{ bots: [...request.bots].reverse() },
|
|
{ reason: 'Changed reason' },
|
|
])
|
|
await assert.rejects(f.create({ ...request, ...patch }), status(409));
|
|
const other = f.provision('operator');
|
|
const login = await adminLogin(f.database, other.key);
|
|
const identity = await requireAdmin(f.database, login.token);
|
|
await assert.rejects(
|
|
createAdminRoom(f.database, identity, request),
|
|
status(409),
|
|
);
|
|
assert.deepEqual(
|
|
['rooms', 'seats', 'admin_room_creations'].map((table) =>
|
|
rows(f.sqlite, table),
|
|
),
|
|
before,
|
|
);
|
|
const audit = rows(f.sqlite, 'admin_room_creations')[0];
|
|
assert.equal(audit.actor_admin_id, f.identity.id);
|
|
assert.equal(audit.room_code, winner.code);
|
|
assert.equal(audit.host_id, winner.hostId);
|
|
assert.equal(audit.created_at, 10000);
|
|
assert.equal(audit.session_hash, sha256(request.sessionToken));
|
|
const config = JSON.parse(String(audit.configuration));
|
|
assert.deepEqual(Object.keys(config).sort(), [
|
|
'advanced',
|
|
'bots',
|
|
'faction',
|
|
'name',
|
|
'strongholdCards',
|
|
'techTokens',
|
|
]);
|
|
assert.deepEqual(config.bots, request.bots);
|
|
for (const value of [
|
|
request.sessionToken,
|
|
f.identity.sessionHash,
|
|
'traitorChoices',
|
|
'deck',
|
|
])
|
|
assert.equal(JSON.stringify(audit).includes(value), false);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('session hashes cannot be reused from active/revoked seats or durable creation receipts after room removal', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const original = input();
|
|
const created = await f.create(original);
|
|
for (const revoked of [0, 1]) {
|
|
f.sqlite
|
|
.prepare('UPDATE seats SET revoked=? WHERE token_hash=?')
|
|
.run(revoked, sha256(original.sessionToken));
|
|
await assert.rejects(
|
|
f.create(input({ sessionToken: original.sessionToken })),
|
|
status(409),
|
|
);
|
|
}
|
|
f.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(created.code);
|
|
assert.deepEqual(await f.create(original), {
|
|
...created,
|
|
replayed: true,
|
|
hostAccess: false,
|
|
});
|
|
await assert.rejects(
|
|
f.create(input({ sessionToken: original.sessionToken })),
|
|
status(409),
|
|
);
|
|
const ordinaryToken = secret();
|
|
const ordinary = await f.rooms.createRoom(
|
|
'Ordinary QA owner',
|
|
'atreides',
|
|
false,
|
|
[],
|
|
{ operationId: randomUUID(), sessionToken: ordinaryToken },
|
|
);
|
|
await assert.rejects(
|
|
f.create(input({ sessionToken: ordinaryToken })),
|
|
status(409),
|
|
);
|
|
f.sqlite
|
|
.prepare('UPDATE seats SET revoked=1 WHERE room_code=?')
|
|
.run(ordinary.view.code);
|
|
await assert.rejects(
|
|
f.create(input({ sessionToken: ordinaryToken })),
|
|
status(409),
|
|
);
|
|
assert.equal(rows(f.sqlite, 'rooms').length, 1);
|
|
assert.equal(rows(f.sqlite, 'admin_room_creations').length, 1);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('seat/audit storage failures roll back all new room records and preserve other saved games', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
await f.create();
|
|
for (const table of ['seats', 'admin_room_creations']) {
|
|
const before = ['rooms', 'seats', 'admin_room_creations'].map((name) =>
|
|
rows(f.sqlite, name),
|
|
);
|
|
f.sqlite.exec(
|
|
`CREATE TRIGGER reject_creation BEFORE INSERT ON ${table} BEGIN SELECT RAISE(ABORT,'QA creation failure'); END;`,
|
|
);
|
|
await assert.rejects(f.create(), /QA creation failure/);
|
|
assert.deepEqual(
|
|
['rooms', 'seats', 'admin_room_creations'].map((name) =>
|
|
rows(f.sqlite, name),
|
|
),
|
|
before,
|
|
);
|
|
f.sqlite.exec('DROP TRIGGER reject_creation');
|
|
}
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('room invitation collisions retry boundedly without replacing existing games or retaining partial seats', async (t) => {
|
|
const f = await fixture();
|
|
try {
|
|
f.sqlite
|
|
.prepare(
|
|
'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)',
|
|
)
|
|
.run('AAAAAAAA', '{"private":"preserve"}', 7, 42);
|
|
const before = rows(f.sqlite, 'rooms')[0];
|
|
let calls = 0;
|
|
const random = t.mock.method(globalThis.crypto, 'getRandomValues', (<
|
|
T extends ArrayBufferView | null,
|
|
>(
|
|
array: T,
|
|
): T => {
|
|
(array as Uint8Array).fill(calls++ === 0 ? 0 : 1);
|
|
return array;
|
|
}) as typeof crypto.getRandomValues);
|
|
const first = await f.create(input({ bots: [] }));
|
|
assert.equal(first.code, 'BBBBBBBB');
|
|
assert.equal(calls, 2);
|
|
assert.deepEqual(rows(f.sqlite, 'rooms')[0], before);
|
|
random.mock.restore();
|
|
const collision = t.mock.method(globalThis.crypto, 'getRandomValues', (<
|
|
T extends ArrayBufferView | null,
|
|
>(
|
|
array: T,
|
|
): T => {
|
|
(array as Uint8Array).fill(0);
|
|
return array;
|
|
}) as typeof crypto.getRandomValues);
|
|
const request = input({ bots: [] });
|
|
await assert.rejects(f.create(request), status(409));
|
|
assert.equal(collision.mock.callCount(), 5);
|
|
assert.equal(rows(f.sqlite, 'rooms').length, 2);
|
|
assert.equal(rows(f.sqlite, 'seats').length, 1);
|
|
assert.equal(rows(f.sqlite, 'admin_room_creations').length, 1);
|
|
collision.mock.restore();
|
|
assert.equal((await f.create(request)).replayed, false);
|
|
} finally {
|
|
t.mock.restoreAll();
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('durable creation receipt and hash-only host seat survive database close/reopen with exact latest-state replay', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-create-'));
|
|
let store = adminStore(join(dir, 'rooms.sqlite'));
|
|
try {
|
|
const account = store.provision('operator');
|
|
const login = await adminLogin(store.database, account.key);
|
|
const identity = await requireAdmin(store.database, login.token);
|
|
const request = input();
|
|
const created = await createAdminRoom(store.database, identity, request);
|
|
const rooms = loadRooms(store.database);
|
|
const auth = await rooms.authenticate(created.code, request.sessionToken);
|
|
const ready = await rooms.act(created.code, auth, 0, { type: 'ready' });
|
|
const before = rows(store.sqlite, 'rooms');
|
|
store.sqlite.close();
|
|
store = adminStore(join(dir, 'rooms.sqlite'), false);
|
|
assert.deepEqual(await createAdminRoom(store.database, identity, request), {
|
|
...created,
|
|
replayed: true,
|
|
});
|
|
assert.deepEqual(rows(store.sqlite, 'rooms'), before);
|
|
const restored = await loadRooms(store.database).readSeatView(
|
|
created.code,
|
|
auth,
|
|
);
|
|
assert.deepEqual(restored, ready);
|
|
assert.equal(rows(store.sqlite, 'admin_room_creations').length, 1);
|
|
} finally {
|
|
store.sqlite.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
void test('exact creation retries never regrant a revoked, recovered, handed-over or removed host seat', async () => {
|
|
for (const lost of [
|
|
'revoked',
|
|
'recovered',
|
|
'handover',
|
|
'missing-player',
|
|
'malformed-game',
|
|
] as const) {
|
|
const f = await fixture();
|
|
try {
|
|
const request = input();
|
|
const created = await f.create(request);
|
|
const auth = await f.rooms.authenticate(
|
|
created.code,
|
|
request.sessionToken,
|
|
);
|
|
if (lost === 'revoked')
|
|
f.sqlite
|
|
.prepare('UPDATE seats SET revoked=1 WHERE token_hash=?')
|
|
.run(auth.tokenHash);
|
|
else if (lost === 'recovered') {
|
|
const recoverySecret = secret();
|
|
await f.rooms.setRecoveryKey(created.code, auth, 0, recoverySecret);
|
|
await f.rooms.recoverSeat(created.code, {
|
|
playerId: auth.playerId,
|
|
recoverySecret,
|
|
operationId: randomUUID(),
|
|
newSessionToken: secret(),
|
|
});
|
|
} else if (lost === 'handover') {
|
|
const offer = { offerId: randomUUID(), handoverSecret: secret() };
|
|
await f.rooms.createSeatHandover(created.code, auth, 0, offer);
|
|
await f.rooms.claimSeatHandover(created.code, {
|
|
playerId: auth.playerId,
|
|
...offer,
|
|
operationId: randomUUID(),
|
|
newSessionToken: secret(),
|
|
});
|
|
} else if (lost === 'missing-player')
|
|
f.sqlite
|
|
.prepare(
|
|
"UPDATE rooms SET state=json_set(state,'$.players',json('[]')) WHERE code=?",
|
|
)
|
|
.run(created.code);
|
|
else
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
|
.run('{invalid', created.code);
|
|
const before = ['rooms', 'seats', 'admin_room_creations'].map((table) =>
|
|
rows(f.sqlite, table),
|
|
);
|
|
assert.deepEqual(await f.create(request), {
|
|
...created,
|
|
replayed: true,
|
|
hostAccess: false,
|
|
});
|
|
assert.deepEqual(
|
|
['rooms', 'seats', 'admin_room_creations'].map((table) =>
|
|
rows(f.sqlite, table),
|
|
),
|
|
before,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|