Add administrator pause and joining-lock controls

Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices.

Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation.
This commit is contained in:
Quintine committed 2026-09-22 10:41:09 +10:00
1 parent f29d05620c
commit 8c922b446b
39 files changed
+3196 -104

No files matched your search

+9 -1
View File
@@ -11,13 +11,21 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.equal(created.status, 201);
const cookie = created.headers.get('set-cookie')?.split(';')[0];
assert.ok(cookie);
const room = (await created.json() as { code: string }).code;
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
for (const path of ['/api/admin/session', '/api/admin/rooms']) {
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`]) {
const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) });
assert.equal(result.status, 401, path);
assert.equal(result.headers.get('cache-control'), 'no-store');
assert.deepEqual(Object.keys(await result.json()), ['error']);
}
const write = await fetch(`${base}/api/admin/rooms/${room}/control`, {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedRevision: 0, paused: true, joinLocked: true, reason: 'Denied QA request' }),
signal: AbortSignal.timeout(15000),
});
assert.equal(write.status, 401);
assert.deepEqual(Object.keys(await write.json()), ['error']);
}
});
void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentials without setting sessions', async () => {
+15
View File
@@ -66,3 +66,18 @@ void test('admin directory rechecks authority inside its snapshot after concurre
await assert.rejects(pending, (e: unknown) => e instanceof AdminError && e.status === 401);
} finally { f.sqlite.close(); }
});
void test('directory projects and filters operational flags without private audit reasons', async () => {
const f = await fixture();
try {
insert(f.sqlite, 'ROOMAAAA', state()); insert(f.sqlite, 'ROOMBBBB', state());
f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES (?,1,1,2,999)').run('ROOMAAAA');
const read = (query: string) => readAdminDirectory(f.database, f.identity, new URLSearchParams(query));
const paused = await read('availability=paused');
assert.equal(paused.total, 1);
assert.deepEqual(paused.rooms[0].control, { paused: true, joinLocked: true, revision: 2, updatedAt: 999 });
assert.equal((await read('availability=running')).rooms[0].code, 'ROOMBBBB');
assert.equal((await read('availability=locked')).rooms[0].code, 'ROOMAAAA');
await assert.rejects(read('availability=unsupported'), (e: unknown) => e instanceof AdminError && e.status === 400);
} finally { f.sqlite.close(); }
});
+941
View File
@@ -0,0 +1,941 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID, webcrypto } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import { adminStore, pauseAdminBatch } from './admin-access-fixture';
import {
adminLogin,
requireAdmin,
adminLogoutAll,
AdminError,
type AdminRole,
} from '../db/admin-access';
import {
applyAdminRoomControl,
readAdminRoomControl,
} from '../db/admin-lifecycle';
import {
DEFAULT_ROOM_CONTROL,
validAdminRoomControlInput,
type AdminRoomControlInput,
} from '../lib/room-control';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import { baseDeck } from '../game/cards';
import * as seatAiDelegation from '../lib/seat-ai-delegation';
import type * as Rooms from '../db/rooms';
const secret = () => randomBytes(32).toString('hex');
const entry = () => ({ operationId: randomUUID(), sessionToken: secret() });
const change = (
expectedRevision = 0,
paused = true,
joinLocked = false,
): AdminRoomControlInput => ({
operationId: randomUUID(),
expectedRevision,
paused,
joinLocked,
reason: 'Dedicated QA room maintenance',
});
const status = (wanted: number) => (error: unknown) =>
error instanceof AdminError && error.status === wanted;
const fakeClock = (start = 10000) => {
let now = start;
const sleeps: number[] = [];
return {
now: () => now,
advance: (ms: number) => {
now += ms;
},
sleeps,
sleep: async (ms: number) => {
sleeps.push(ms);
now += ms;
},
};
};
async function fixture(role: AdminRole = 'operator', started = false) {
const store = adminStore();
assert.ok(store.sqlite instanceof DatabaseSync);
const account = store.provision(role);
const login = await adminLogin(store.database, account.key);
const identity = await requireAdmin(store.database, login.token);
const calls = { bots: 0, normalization: 0 };
const hooks: { beforeRun?: (sql: string) => Promise<void> } = {};
const database = {
batch: (statements: D1PreparedStatement[]) =>
store.database.batch(statements),
prepare(sql: string) {
const statement = store.database.prepare(sql);
const original = statement.run.bind(statement);
statement.run = async <T>() => {
await hooks.beforeRun?.(sql);
return original<T>();
};
return statement;
},
} as D1Database;
function loadRooms() {
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,
normalizeAutomaticGame: (
...args: Parameters<typeof engine.normalizeAutomaticGame>
) => {
calls.normalization++;
return engine.normalizeAutomaticGame(...args);
},
};
if (name === '@/game/bots')
return {
...bots,
runBots: (...args: Parameters<typeof bots.runBots>) => {
calls.bots++;
return bots.runBots(...args);
},
};
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
throw new Error('Unexpected dependency ' + name);
},
},
);
return exports as typeof Rooms;
}
const rooms = loadRooms();
const ownerEntry = entry();
const owner = await rooms.createRoom(
'Lifecycle QA owner',
'atreides',
false,
[],
ownerEntry,
);
const code = owner.view.code;
const auth = await rooms.authenticate(code, owner.token);
const guestEntry = entry();
const guest = await rooms.joinRoom(
code,
'Lifecycle QA guest',
'emperor',
guestEntry,
);
const guestAuth = await rooms.authenticate(code, guest.token!);
const clock = fakeClock();
if (started) {
for (const seat of [auth, guestAuth])
await rooms.act(
code,
seat,
(await rooms.readRoom(code)).version,
{ type: 'ready' },
clock,
);
await rooms.act(
code,
auth,
(await rooms.readRoom(code)).version,
{ type: 'start' },
clock,
);
}
const apply = (input = change(), now = clock.now()) =>
applyAdminRoomControl(database, identity, code, input, now);
const read = () =>
readAdminRoomControl(database, identity, code, clock.now());
const row = () =>
store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(code)!;
const version = () => Number(row().version);
return {
...store,
database,
account,
login,
identity,
calls,
runHooks: hooks,
rooms,
restart: loadRooms,
ownerEntry,
owner,
guestEntry,
guest,
auth,
guestAuth,
code,
clock,
apply,
read,
row,
version,
};
}
function pauseRun(
f: Awaited<ReturnType<typeof fixture>>,
match = (sql: string) => sql.startsWith('UPDATE rooms SET state'),
) {
let enter!: () => void, release!: () => void;
const waiting = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.runHooks.beforeRun = async (sql) => {
if (!match(sql)) return;
delete f.runHooks.beforeRun;
enter();
await gate;
};
return { waiting, release };
}
const count = (f: Awaited<ReturnType<typeof fixture>>, table: string) =>
Number(f.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()!.n);
void test('room controls default for existing/new rooms and stay outside saved Game and private views', async () => {
const f = await fixture();
try {
assert.deepEqual(await f.read(), DEFAULT_ROOM_CONTROL);
assert.equal(Object.hasOwn(f.owner.view, 'roomControl'), false);
assert.equal(
count(f, 'room_controls'),
0,
'Reads do not backfill or mutate existing saves',
);
assert.equal(
Object.hasOwn(await f.rooms.readRoom(f.code), 'roomControl'),
false,
);
const before = f.row();
const applied = await f.apply();
assert.deepEqual(applied, {
paused: true,
joinLocked: false,
revision: 1,
updatedAt: 10000,
replayed: false,
});
assert.equal(f.row().state, before.state);
assert.equal(f.row().updated_at, before.updated_at);
assert.equal(f.version(), Number(before.version) + 1);
const view = await f.restart().readSeatView(f.code, f.auth);
assert.deepEqual(view.roomControl, {
paused: true,
joinLocked: false,
revision: 1,
updatedAt: 10000,
});
const publicControl = JSON.stringify(view.roomControl);
for (const privateValue of [
f.identity.id,
f.identity.sessionHash,
'maintenance',
'actor',
'reason',
])
assert.equal(publicControl.includes(privateValue), false);
await f.apply(change(1, false, false));
const resumed = await f.restart().readSeatView(f.code, f.auth);
assert.deepEqual(resumed.roomControl, {
paused: false,
joinLocked: false,
revision: 2,
updatedAt: 10000,
});
assert.equal(
JSON.stringify(view).includes(f.guestEntry.sessionToken),
false,
);
} finally {
f.sqlite.close();
}
});
void test('room control receipts replay exactly without reverting later settings; mismatched reuse and stale new operations reject', async () => {
const f = await fixture();
try {
const input = change();
await f.apply(input);
const first = f.row();
assert.equal((await f.apply(input)).replayed, true);
assert.deepEqual(f.row(), first);
assert.equal(count(f, 'admin_room_audit'), 1);
for (const patch of [
{ paused: false },
{ joinLocked: true },
{ reason: 'Different reason' },
{ expectedRevision: 1 },
])
await assert.rejects(f.apply({ ...input, ...patch }), status(409));
await assert.rejects(f.apply(change(0)), status(409));
await f.apply(change(1, false, true), 20000);
const current = f.row();
assert.deepEqual(await f.apply(input), {
paused: false,
joinLocked: true,
revision: 2,
updatedAt: 20000,
replayed: true,
});
assert.deepEqual(f.row(), current);
assert.equal(count(f, 'admin_room_audit'), 2);
const audit = f.sqlite
.prepare('SELECT * FROM admin_room_audit ORDER BY created_at')
.all();
assert.equal(audit[0].actor_admin_id, f.identity.id);
assert.equal(audit[0].room_code, f.code);
assert.equal(audit[0].before_paused, 0);
assert.equal(audit[0].after_paused, 1);
assert.equal(audit[1].before_paused, 1);
assert.equal(audit[1].after_paused, 0);
assert.equal(
JSON.stringify(audit).includes(f.ownerEntry.sessionToken),
false,
);
assert.equal(JSON.stringify(audit).includes(f.identity.sessionHash), false);
const other = f.provision('operator');
const login = await adminLogin(f.database, other.key);
const identity = await requireAdmin(f.database, login.token);
await assert.rejects(
applyAdminRoomControl(f.database, identity, f.code, input),
status(409),
);
} finally {
f.sqlite.close();
}
});
void test('room mutations use live owner/operator authority; viewers may read and cannot spoof the identity role', async () => {
for (const role of ['owner', 'operator', 'viewer'] as const) {
const f = await fixture(role);
try {
assert.deepEqual(await f.read(), DEFAULT_ROOM_CONTROL);
const mutated = applyAdminRoomControl(
f.database,
{ ...f.identity, role: 'owner' },
f.code,
change(),
);
if (role === 'viewer') {
await assert.rejects(mutated, status(403));
assert.equal(count(f, 'admin_room_audit'), 0);
} else assert.equal((await mutated).paused, true);
await assert.rejects(
applyAdminRoomControl(
f.database,
{ ...f.identity, sessionHash: secret() },
f.code,
change(),
),
status(401),
);
} finally {
f.sqlite.close();
}
}
});
void test('room read/write authorization is fenced against logout, disable, role demotion and expiry at the transaction', async () => {
for (const mode of [
'logout',
'disable',
'demote',
'expire',
'read',
] as const) {
const f = await fixture();
try {
const before = f.row();
const gate = pauseAdminBatch(f);
const pending = mode === 'read' ? f.read() : f.apply();
await gate.waiting;
if (mode === 'disable')
f.sqlite
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
.run(f.identity.id);
else if (mode === 'demote')
f.sqlite
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
.run(f.identity.id);
else if (mode === 'expire')
f.sqlite
.prepare('UPDATE admin_sessions SET expires_at=1 WHERE token_hash=?')
.run(f.identity.sessionHash);
else await adminLogoutAll(f.database, f.identity);
gate.release();
await assert.rejects(pending, status(mode === 'demote' ? 403 : 401));
assert.deepEqual(f.row(), before);
assert.equal(count(f, 'admin_room_audit'), 0);
} finally {
f.sqlite.close();
}
}
});
void test('concurrent same revision operations have one winner and exact queued retry gets one receipt', async () => {
const f = await fixture();
try {
const same = change();
const gate = pauseAdminBatch(f);
const waiting = f.apply(same);
await gate.waiting;
await f.apply(same);
gate.release();
assert.equal((await waiting).replayed, true);
assert.equal(count(f, 'admin_room_audit'), 1);
const gate2 = pauseAdminBatch(f);
const stale = f.apply(change(1, false));
await gate2.waiting;
await f.apply(change(1, true, true));
gate2.release();
await assert.rejects(stale, status(409));
assert.equal(count(f, 'admin_room_audit'), 2);
} finally {
f.sqlite.close();
}
});
void test('failed control persistence rolls back receipt, game fence and audit; audit survives deliberate record removal', async () => {
const f = await fixture();
try {
const before = f.row();
f.sqlite.exec(
"CREATE TRIGGER fail_control BEFORE INSERT ON room_controls BEGIN SELECT RAISE(ABORT,'test storage failure'); END;",
);
await assert.rejects(f.apply(), /test storage failure/);
assert.deepEqual(f.row(), before);
assert.equal(count(f, 'admin_room_audit'), 0);
f.sqlite.exec('DROP TRIGGER fail_control');
await f.apply();
f.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(f.code);
assert.equal(count(f, 'room_controls'), 0);
assert.equal(count(f, 'admin_room_audit'), 1);
} finally {
f.sqlite.close();
}
});
void test('bounded mutation inputs reject malformed UUIDs, revisions, fields, reason controls and missing rooms', async () => {
const f = await fixture();
try {
for (const patch of [
{ operationId: randomUUID() + '\n' },
{ expectedRevision: -1 },
{ expectedRevision: 0.5 },
{ expectedRevision: Number.MAX_SAFE_INTEGER },
{ paused: 'true' },
{ reason: ' ' },
{ reason: 'a'.repeat(301) },
{ reason: 'line\nbreak' },
{ extra: true },
]) {
const input = { ...change(), ...patch };
assert.equal(validAdminRoomControlInput(input), false);
await assert.rejects(
f.apply(input as AdminRoomControlInput),
status(400),
);
}
await assert.rejects(
applyAdminRoomControl(f.database, f.identity, f.code + '\n', change()),
status(400),
);
await assert.rejects(
applyAdminRoomControl(f.database, f.identity, 'MISSING1', change()),
status(404),
);
await assert.rejects(
readAdminRoomControl(f.database, f.identity, 'MISSING1'),
status(404),
);
assert.equal(count(f, 'admin_room_audit'), 0);
} finally {
f.sqlite.close();
}
});
void test('pause and join locks block new seats but preserve completed entry retries and current-seat restoration', async () => {
const f = await fixture();
try {
await f.apply(change(0, false, true));
const before = f.row();
await assert.rejects(
f.rooms.joinRoom(f.code, 'New seat', 'guild', entry()),
/locked/i,
);
const retry = await f.rooms.joinRoom(
f.code,
'Lifecycle QA guest',
'emperor',
f.guestEntry,
);
assert.equal(retry.entryReceipt?.replayed, true);
assert.equal(retry.token, f.guest.token);
assert.equal(
(
await f.rooms.joinRoom(
f.code,
'New name',
'guild',
undefined,
f.guestAuth,
)
).alreadySeated,
true,
);
assert.deepEqual(f.row(), before);
await f.apply(change(1, true, false));
await assert.rejects(
f.rooms.joinRoom(f.code, 'New seat', 'guild', entry()),
/paused/i,
);
const replay = await f.rooms.createRoom(
'Lifecycle QA owner',
'atreides',
false,
[],
f.ownerEntry,
);
assert.equal(replay.view.code, f.code);
assert.equal(replay.view.roomControl?.paused, true);
assert.equal(count(f, 'seats'), 2);
} finally {
f.sqlite.close();
}
});
void test('pause/lock racing a prepared join rejects its room, seat and receipt write together', async () => {
for (const paused of [false, true]) {
const f = await fixture();
try {
const gate = pauseAdminBatch(f);
const joining = f.rooms.joinRoom(
f.code,
'Racing new seat',
'guild',
entry(),
);
await gate.waiting;
await f.apply(change(0, paused, !paused));
const state = f.row();
gate.release();
await assert.rejects(joining, /table changed/i);
assert.deepEqual(f.row(), state);
assert.equal(count(f, 'seats'), 2);
assert.equal(count(f, 'room_entry_receipts'), 2);
} finally {
f.sqlite.close();
}
}
});
void test('pause wins against in-flight human actions and later gameplay without modifying saved choices', async () => {
const f = await fixture();
try {
const gate = pauseRun(f);
const pending = f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'ready' },
f.clock,
);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
await assert.rejects(pending, /another action/i);
await assert.rejects(
f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' }, f.clock),
/paused/i,
);
await assert.rejects(
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'advanceBots' },
f.clock,
),
/paused/i,
);
assert.deepEqual(f.row(), before);
await f.apply(change(1, false));
const view = await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'ready' },
f.clock,
);
assert.equal(
view.players.find((p) => p.id === f.auth.playerId)!.ready,
true,
);
} finally {
f.sqlite.close();
}
});
void test('paused AI exits before computation or sleep, persists across restart, and resumes at a fresh deadline', async () => {
const f = await fixture('operator', true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
const before = f.row().state;
await f.apply();
assert.equal(f.row().state, before);
f.clock.advance(50000);
const snapshot = f.row();
await f.restart().continueRoomBots(f.code, 4, f.clock);
await f.restart().continueRoomAutomatic(f.code, f.clock);
assert.deepEqual(f.row(), snapshot);
assert.deepEqual(f.calls, { bots: 0, normalization: 0 });
assert.deepEqual(f.clock.sleeps, []);
await f.apply(change(1, false));
const resumed = await f.rooms.readRoom(f.code);
assert.equal(resumed.botNextActionAt, f.clock.now() + 1500);
assert.deepEqual(
{ ...resumed, version: 0, botNextActionAt: 0 },
{ ...JSON.parse(String(before)), version: 0, botNextActionAt: 0 },
);
await f.rooms.continueRoomBots(f.code, 1, f.clock);
assert.deepEqual(f.clock.sleeps, [1500]);
assert.equal(f.calls.bots, 1);
assert.equal(
(await f.rooms.readRoom(f.code)).players[0].traitors.length,
1,
);
} finally {
f.sqlite.close();
}
});
void test('worker asleep when paused rereads operational authority before computing or persisting', async () => {
const f = await fixture('operator', true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
let enter!: () => void, release!: () => void;
const sleeping = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const clock = {
now: f.clock.now,
sleep: async (ms: number) => {
enter();
await gate;
f.clock.advance(ms);
},
};
const running = f.rooms.continueRoomBots(f.code, 1, clock);
await sleeping;
await f.apply();
const before = f.row();
release();
await running;
assert.deepEqual(f.row(), before);
assert.equal(f.calls.bots, 0);
} finally {
f.sqlite.close();
}
});
void test('pause after AI computation but before SQL CAS discards the computed action', async () => {
const f = await fixture('operator', true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
f.clock.advance(1500);
const gate = pauseRun(f);
const running = f.rooms.continueRoomBots(f.code, 2, f.clock);
await gate.waiting;
assert.equal(f.calls.bots, 1);
await f.apply();
const before = f.row();
gate.release();
await running;
assert.deepEqual(f.row(), before);
assert.equal(f.calls.bots, 1);
} finally {
f.sqlite.close();
}
});
async function marketFixture() {
const f = await fixture();
const g = await f.rooms.readRoom(f.code);
g.players[0] = engine.newPlayer(f.auth.playerId, 'CHOAM', 'choam');
g.players.push(engine.newPlayer('guild', 'Guild', 'guild'));
g.status = 'playing';
g.phase = 3;
g.turn = 2;
g.order = g.players.map((p) => p.id);
g.active = f.auth.playerId;
for (const p of g.players) {
p.hand = [];
p.spice = 10;
}
g.auction = null;
g.response = null;
g.deck = baseDeck();
g.discard = [];
g.choamMarket = { owner: f.auth.playerId, resume: 'phase', blocked: [] };
g.decision = { kind: 'choamMarket', player: f.auth.playerId };
g.aid = { [f.auth.playerId]: { recipient: 'emperor', amount: 2 } };
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(JSON.stringify(g), f.code);
return f;
}
void test('automatic normalization stays frozen while paused and loses its commit race without double settlement', async () => {
const f = await marketFixture();
try {
const gate = pauseRun(f);
const running = f.rooms.continueRoomAutomatic(f.code, f.clock);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
await running;
assert.deepEqual(f.row(), before);
assert.equal(f.calls.normalization, 1);
await f.rooms.continueRoomAutomatic(f.code, f.clock);
assert.equal(f.calls.normalization, 1);
await f.apply(change(1, false));
await f.rooms.continueRoomAutomatic(f.code, f.clock);
const after = await f.rooms.readRoom(f.code);
assert.equal(after.phase, 4);
assert.equal(after.players[0].spice, 12);
assert.equal(after.decision, null);
await f.rooms.continueRoomAutomatic(f.code, f.clock);
assert.deepEqual(await f.rooms.readRoom(f.code), after);
} finally {
f.sqlite.close();
}
});
void test('paused seats can take back AI control and revoke consent but cannot grant, enable or activate AI', async () => {
const f = await fixture('operator', true);
try {
const grant = {
grantId: randomUUID(),
delegateId: f.guestAuth.playerId,
difficulty: 'Hard' as const,
};
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant,
f.clock,
);
await f.apply();
const before = f.row();
await assert.rejects(
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
),
/paused/i,
);
await assert.rejects(
f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
{ ...grant, grantId: randomUUID() },
f.clock,
),
/paused/i,
);
await assert.rejects(
f.rooms.useSeatAiDelegate(
f.code,
f.guestAuth,
f.version(),
{ ownerId: f.auth.playerId, grantId: grant.grantId },
f.clock,
),
/paused/i,
);
assert.deepEqual(f.row(), before);
const replay = await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant,
f.clock,
);
assert.equal(replay.replayed, true);
await f.rooms.revokeSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant.grantId,
f.clock,
);
await f.apply(change(1, false));
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
await f.apply(change(2));
const game = await f.rooms.readRoom(f.code);
const view = await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: null },
f.clock,
);
assert.equal(view.roomControl?.paused, true);
const after = await f.rooms.readRoom(f.code);
assert.equal(after.players[0].autopilot, undefined);
assert.deepEqual(
after.players[0].traitorChoices,
game.players[0].traitorChoices,
);
assert.deepEqual(after.players[0].hand, game.players[0].hand);
assert.equal(after.setupStage, game.setupStage);
} finally {
f.sqlite.close();
}
});
void test('paused delegated activation loses the final transaction race without consuming consent', async () => {
const f = await fixture('operator', true);
try {
const grant = {
grantId: randomUUID(),
delegateId: f.guestAuth.playerId,
difficulty: 'Hard' as const,
};
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant,
f.clock,
);
const gate = pauseAdminBatch(f);
const activation = f.rooms.useSeatAiDelegate(
f.code,
f.guestAuth,
f.version(),
{ ownerId: f.auth.playerId, grantId: grant.grantId },
f.clock,
);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
await assert.rejects(activation, /table.*changed/i);
assert.deepEqual(f.row(), before);
assert.equal(
f.sqlite.prepare('SELECT used_at FROM seat_ai_delegations').get()!
.used_at,
null,
);
} finally {
f.sqlite.close();
}
});
void test('paused room keeps recovery/key rotation and handover with exact retries and public pause metadata', async () => {
const f = await fixture('operator', true);
try {
await f.apply(change(0, true, true));
const privateState = f.row().state;
const recoverySecret = secret();
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
const recover = {
playerId: f.auth.playerId,
recoverySecret,
operationId: randomUUID(),
newSessionToken: secret(),
};
const recovered = await f.rooms.recoverSeat(f.code, recover);
const replay = await f.rooms.recoverSeat(f.code, recover);
assert.equal(replay.replayed, true);
for (const result of [recovered, replay])
assert.equal(result.view.roomControl?.paused, true);
await assert.rejects(f.rooms.readSeatView(f.code, f.auth), /verified/i);
const auth = await f.rooms.authenticate(f.code, recovered.token);
const offer = { offerId: randomUUID(), handoverSecret: secret() };
await f.rooms.createSeatHandover(f.code, auth, f.version(), offer, f.clock);
const claim = {
playerId: auth.playerId,
...offer,
operationId: randomUUID(),
newSessionToken: secret(),
};
const claimed = await f.rooms.claimSeatHandover(f.code, claim, f.clock);
const claimedAgain = await f.rooms.claimSeatHandover(
f.code,
claim,
f.clock,
);
assert.equal(claimedAgain.replayed, true);
for (const result of [claimed, claimedAgain])
assert.deepEqual(result.view.roomControl, {
paused: true,
joinLocked: true,
revision: 1,
updatedAt: 10000,
});
assert.equal(f.row().state, privateState);
await assert.rejects(f.rooms.readSeatView(f.code, auth), /verified/i);
} finally {
f.sqlite.close();
}
});
+77
View File
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { newAdminRoomRequest, readAdminRoomRequest, saveAdminRoomRequest, clearAdminRoomRequest, retainAdminRoomRequest } from '../lib/admin-room-control-client';
import { ClientRequestError } from '../lib/client-request';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
import { RoomControlNotice } from '../components/room-control-notice';
import { SeatAutopilot } from '../components/seat-autopilot';
function storage() {
const values = new Map<string, string>();
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
}
void test('room administration saves the exact request before retry and isolates accounts and rooms', () => {
const tab = storage();
const input = newAdminRoomRequest({ ...DEFAULT_ROOM_CONTROL, revision: 7 }, true, true, ' QA pause ');
saveAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA', input);
const restored = readAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA');
assert.deepEqual(restored, input);
assert.equal(restored?.expectedRevision, 7);
assert.equal(restored?.reason, 'QA pause');
assert.equal(readAdminRoomRequest(tab, 'owner-b', 'ROOMAAAA'), null);
assert.equal(readAdminRoomRequest(tab, 'owner-a', 'ROOMBBBB'), null);
const conflicting = newAdminRoomRequest({ ...DEFAULT_ROOM_CONTROL, revision: 8 }, false, true, 'Different intent');
assert.throws(() => saveAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA', conflicting), /Resolve the saved/);
assert.deepEqual(readAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA'), input);
saveAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA', restored!);
clearAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA');
assert.equal(readAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA'), null);
});
void test('storage failure and corrupt saved operations cannot silently become new requests', () => {
const tab = storage();
const input = newAdminRoomRequest(DEFAULT_ROOM_CONTROL, true, false, 'QA');
assert.throws(() => saveAdminRoomRequest({ ...tab, setItem() {} }, 'a', 'ROOMAAAA', input), /Nothing was sent/);
saveAdminRoomRequest(tab, 'a', 'ROOMAAAA', input);
const key = [...tab.values.keys()][0];
tab.values.set(key, '{broken');
assert.throws(() => readAdminRoomRequest(tab, 'a', 'ROOMAAAA'), /unreadable/);
assert.throws(() => saveAdminRoomRequest(tab, 'a', 'ROOMAAAA', input), /unreadable/);
assert.equal(tab.values.get(key), '{broken');
clearAdminRoomRequest(tab, 'a', 'ROOMAAAA');
assert.equal(tab.values.size, 0);
assert.throws(() => newAdminRoomRequest(DEFAULT_ROOM_CONTROL, true, false, ' '));
});
void test('expired or switched administrator access retains an earlier uncertain operation for its original account', () => {
for (const status of [401, 403, 502]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Denied now', 'http', status)), true);
assert.equal(retainAdminRoomRequest(new ClientRequestError('Stale settings', 'http', 409)), false);
assert.equal(retainAdminRoomRequest(new ClientRequestError('Bad request', 'http', 400)), false);
});
void test('public pause and joining-lock notices explain retained actions without disclosing admin metadata', () => {
const control = { ...DEFAULT_ROOM_CONTROL, paused: true, joinLocked: true, reason: 'PRIVATE_REASON', actor: 'PRIVATE_ACTOR' };
const html = renderToStaticMarkup(<RoomControlNotice control={control} />);
assert.match(html, /Player decisions and automatic play are paused/);
assert.match(html, /Existing players can reconnect and recover/);
assert.match(html, /aria-live="polite"/);
assert.doesNotMatch(html, /PRIVATE_/);
assert.equal(renderToStaticMarkup(<RoomControlNotice control={DEFAULT_ROOM_CONTROL} />), '');
});
void test('pause removes AI-start controls but preserves the current owner’s takeback button', () => {
const game = createGame('ROOMAAAA', newPlayer('a', 'Owner', 'atreides'));
game.status = 'setup';
const view: GameView = { ...viewGame(game, 'a'), roomControl: { ...DEFAULT_ROOM_CONTROL, paused: true } };
const render = () => renderToStaticMarkup(<SeatAutopilot game={view} act={() => {}} busy={false} />);
assert.doesNotMatch(render(), /Start autopilot/);
assert.match(render(), /enabled after an administrator resumes/);
view.players[0].autopilot = 'Medium';
const html = render();
assert.match(html, /AI decisions are paused/);
assert.match(html, /Take back control/);
assert.doesNotMatch(html, /disabled=""/);
});
+1 -1
View File
@@ -37,7 +37,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+1 -1
View File
@@ -34,7 +34,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+1 -1
View File
@@ -36,7 +36,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+1 -1
View File
@@ -34,7 +34,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+9 -7
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import type { DatabaseSync } from 'node:sqlite';
import { baseDeck } from '../game/cards';
import { createGame, newPlayer } from '../game/engine';
@@ -389,12 +389,14 @@ void test('0005 backfills an active 0004 claim for exact replay after upgrade',
(receipt.claim_fence as string).includes(body.newSessionToken),
false,
);
f.sqlite.exec(
readFileSync(
new URL('../drizzle/0006_thick_imperial_guard.sql', import.meta.url),
'utf8',
),
);
// Run current production code only after completing the schema upgrade.
// The receipt assertion above still verifies the 0005 backfill itself.
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((file) => file.endsWith('.sql') && file > '0005_salty_alice.sql')
.sort())
f.sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
const replay = await f.restart().claimSeatHandover(code, body, clock(3000));
assert.equal(replay.replayed, true);
assert.equal(replay.token, body.newSessionToken);