Add administrative room close and reopen controls
Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped. Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
This commit is contained in:
1 parent
238de3860d
commit
98b1b3261b
49 files changed
+4255
-86
No files matched your search
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
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', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`]) {
|
||||
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');
|
||||
@@ -42,6 +42,14 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
assert.equal(removal.status, 401);
|
||||
assert.equal(removal.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await removal.json()), ['error']);
|
||||
const closure = await fetch(`${base}/api/admin/rooms/${room}/closure`, {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, closed: true, reason: 'Denied closure QA' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(closure.status, 401);
|
||||
assert.equal(closure.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await closure.json()), ['error']);
|
||||
const create = await fetch(base + '/api/admin/rooms', {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { adminClosureConfirmation, adminClosureResponse, clearAdminClosureRequest, newAdminClosureRequest, readAdminClosureRequest, saveAdminClosureRequest } from '../lib/admin-closure-client';
|
||||
import { retainAdminRoomRequest } from '../lib/admin-room-control-client';
|
||||
import { ClientRequestError } from '../lib/client-request';
|
||||
import type { AdminClosureView } from '../lib/admin-closure';
|
||||
|
||||
const view = (): AdminClosureView => ({ code: 'ABCD2345', version: 7, closed: false, removed: false, revision: 2, closedAt: null, updatedAt: null, paused: false, joinLocked: true });
|
||||
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('closure retry preserves exact intent/version/revision/reason across refresh and isolates account and room', () => {
|
||||
const tab = storage(), room = view(), input = newAdminClosureRequest(room, true, ' QA closure ');
|
||||
saveAdminClosureRequest(tab, 'a', room.code, input);
|
||||
assert.deepEqual(readAdminClosureRequest(tab, 'a', room.code), input);
|
||||
assert.equal(input.expectedVersion, 7); assert.equal(input.expectedRevision, 2); assert.equal(input.reason, 'QA closure');
|
||||
assert.equal(readAdminClosureRequest(tab, 'b', room.code), null);
|
||||
assert.equal(readAdminClosureRequest(tab, 'a', 'DIFF2345'), null);
|
||||
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { reason: 'Different' }, { closed: false }])
|
||||
assert.throws(() => saveAdminClosureRequest(tab, 'a', room.code, { ...input, ...change }), /Resolve the saved/);
|
||||
saveAdminClosureRequest(tab, 'a', room.code, input);
|
||||
clearAdminClosureRequest(tab, 'a', room.code);
|
||||
assert.equal(readAdminClosureRequest(tab, 'a', room.code), null);
|
||||
const reopen = newAdminClosureRequest({ ...room, closed: true, version: 8, revision: 3 }, false, 'Restore');
|
||||
saveAdminClosureRequest(tab, 'a', room.code, reopen);
|
||||
assert.deepEqual(readAdminClosureRequest(tab, 'a', room.code), reopen);
|
||||
});
|
||||
|
||||
void test('corrupt or unwritable closure retry storage fails closed and never replaces the saved record', () => {
|
||||
const tab = storage(), room = view(), input = newAdminClosureRequest(room, true, 'QA');
|
||||
assert.throws(() => saveAdminClosureRequest({ ...tab, setItem() {} }, 'a', room.code, input), /Nothing was sent/);
|
||||
saveAdminClosureRequest(tab, 'a', room.code, input);
|
||||
const key = [...tab.values.keys()][0];
|
||||
for (const raw of ['{broken', JSON.stringify({ ...input, sessionToken: 'secret' }), JSON.stringify({ ...input, expectedRevision: -1 }), ' '.repeat(2049)]) {
|
||||
tab.values.set(key, raw);
|
||||
assert.throws(() => readAdminClosureRequest(tab, 'a', room.code), /unreadable/);
|
||||
assert.throws(() => saveAdminClosureRequest(tab, 'a', room.code, input), /unreadable/);
|
||||
assert.equal(tab.values.get(key), raw);
|
||||
}
|
||||
assert.throws(() => clearAdminClosureRequest({ ...tab, removeItem() {} }, 'a', room.code), /could not be cleared/);
|
||||
clearAdminClosureRequest(tab, 'a', room.code);
|
||||
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'bad\u007freason'])
|
||||
assert.throws(() => newAdminClosureRequest(room, true, reason));
|
||||
assert.throws(() => newAdminClosureRequest(room, false, 'No change'));
|
||||
assert.throws(() => newAdminClosureRequest({ ...room, removed: true }, true, 'Removed'), /Restore/);
|
||||
assert.throws(() => newAdminClosureRequest({ ...room, revision: Number.MAX_SAFE_INTEGER }, true, 'Overflow'));
|
||||
});
|
||||
|
||||
void test('confirmation binds exact operation and both applied versions while replay accepts later current availability', () => {
|
||||
const room = view(), input = newAdminClosureRequest(room, true, 'QA');
|
||||
const current = { ...room, version: 12, revision: 4, closed: false, removed: false, closedAt: 100, updatedAt: 200, paused: true };
|
||||
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: current };
|
||||
assert.deepEqual(adminClosureConfirmation(receipt, room.code, input), receipt);
|
||||
const fresh = { ...receipt, replayed: false, room: { ...room, version: 8, revision: 3, closed: true, closedAt: 100, updatedAt: 100 } };
|
||||
assert.deepEqual(adminClosureConfirmation(fresh, room.code, input), fresh);
|
||||
for (const invalid of [{ ...receipt, operationId: crypto.randomUUID() }, { ...receipt, appliedVersion: 9 }, { ...receipt, appliedRevision: 4 },
|
||||
{ ...receipt, room: { ...current, version: 7 } }, { ...receipt, room: { ...current, revision: 2 } },
|
||||
{ ...receipt, room: { ...current, code: 'DIFF2345' } }, { ...receipt, sessionToken: 'secret' }, { ...receipt, replayed: undefined }])
|
||||
assert.throws(() => adminClosureConfirmation(invalid, room.code, input));
|
||||
});
|
||||
|
||||
void test('room availability projection excludes private data and rejects malformed metadata', () => {
|
||||
const room = view();
|
||||
assert.deepEqual(adminClosureResponse(room, room.code), room);
|
||||
assert.doesNotThrow(() => adminClosureResponse({ ...room, closed: true, closedAt: 100, updatedAt: 100 }, room.code));
|
||||
for (const invalid of [null, [], { ...room, state: {} }, { ...room, reason: 'Private audit' }, { ...room, seats: [] },
|
||||
{ ...room, version: 1.5 }, { ...room, revision: -1 }, { ...room, code: 'bad code' }, { ...room, closed: 'true' },
|
||||
{ ...room, closedAt: -1 }, { ...room, closedAt: Number.MAX_SAFE_INTEGER }, { ...room, updatedAt: '100' },
|
||||
{ ...room, paused: undefined }, { ...room, joinLocked: null }])
|
||||
assert.throws(() => adminClosureResponse(invalid, room.code));
|
||||
});
|
||||
|
||||
void test('uncertain closure and changed-access responses keep the exact saved request', () => {
|
||||
const tab = storage(), room = view(), input = newAdminClosureRequest(room, true, 'QA');
|
||||
saveAdminClosureRequest(tab, 'a', room.code, input);
|
||||
for (const error of [new ClientRequestError('Offline', 'network'), new ClientRequestError('Timeout', 'timeout'),
|
||||
new ClientRequestError('Unreadable', 'invalid-response', 200), ...[401, 403, 408, 500, 502, 503].map(status => new ClientRequestError('Unavailable', 'http', status)), new Error('Wrong confirmation')]) {
|
||||
if (!retainAdminRoomRequest(error)) clearAdminClosureRequest(tab, 'a', room.code);
|
||||
assert.deepEqual(readAdminClosureRequest(tab, 'a', room.code), input);
|
||||
}
|
||||
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
|
||||
});
|
||||
@@ -0,0 +1,851 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
|
||||
import {
|
||||
AdminError,
|
||||
adminLogin,
|
||||
requireAdmin,
|
||||
type AdminRole,
|
||||
} from '../db/admin-access';
|
||||
import { applyAdminClosure, readAdminClosure } from '../db/admin-closure';
|
||||
import {
|
||||
validAdminClosureInput,
|
||||
type AdminClosureInput,
|
||||
} from '../lib/admin-closure';
|
||||
import {
|
||||
applyAdminRoomControl,
|
||||
readAdminRoomControl,
|
||||
} from '../db/admin-lifecycle';
|
||||
import {
|
||||
configureAdminLobby,
|
||||
readAdminLobby,
|
||||
} from '../db/admin-lobby-configuration';
|
||||
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
|
||||
import { createGame, newPlayer } from '../game/engine';
|
||||
|
||||
const now = 10000;
|
||||
const secret = () => randomBytes(32).toString('hex');
|
||||
const status = (value: number) => (error: unknown) =>
|
||||
error instanceof AdminError && error.status === value;
|
||||
type Store = ReturnType<typeof adminStore>;
|
||||
const rows = (store: Store, table: string) =>
|
||||
store.sqlite.prepare(`SELECT * FROM ${table}`).all();
|
||||
const secureTables = [
|
||||
'seats',
|
||||
'seat_recovery_keys',
|
||||
'seat_recovery_receipts',
|
||||
'seat_handover_offers',
|
||||
'seat_handover_claim_receipts',
|
||||
'seat_ai_delegations',
|
||||
'room_entry_receipts',
|
||||
'room_messages',
|
||||
];
|
||||
const secureRows = (store: Store) =>
|
||||
secureTables.map((table) => rows(store, table));
|
||||
const change = (
|
||||
expectedVersion = 0,
|
||||
expectedRevision = 0,
|
||||
closed = true,
|
||||
): AdminClosureInput => ({
|
||||
operationId: randomUUID(),
|
||||
expectedVersion,
|
||||
expectedRevision,
|
||||
closed,
|
||||
reason: 'Dedicated QA room closure',
|
||||
});
|
||||
function seed(store: Store, code = 'CLOSURES') {
|
||||
const host = newPlayer(randomUUID(), 'Private player name', 'atreides');
|
||||
const game = createGame(code, host, false);
|
||||
const token = secret(),
|
||||
tokenHash = sha256(token),
|
||||
revokedHash = sha256(secret());
|
||||
store.sqlite
|
||||
.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run(code, JSON.stringify(game), 0, 1000);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
|
||||
)
|
||||
.run(tokenHash, code, host.id, 0);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
|
||||
)
|
||||
.run(revokedHash, code, host.id, 1);
|
||||
return { host, game, token, tokenHash, revokedHash, code };
|
||||
}
|
||||
function seedDependents(store: Store, room: ReturnType<typeof seed>) {
|
||||
const { code, host, tokenHash } = room;
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
.run(sha256(secret()), sha256(secret()), tokenHash, code, host.id);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_recovery_keys(room_code,player_id,recovery_hash,current_operation_hash) VALUES(?,?,?,?)',
|
||||
)
|
||||
.run(code, host.id, sha256(secret()), sha256(secret()));
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_recovery_receipts(room_code,player_id,operation_hash,session_hash,recovery_hash) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
.run(code, host.id, sha256(secret()), tokenHash, sha256(secret()));
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at) VALUES(?,?,?,?,?,?)',
|
||||
)
|
||||
.run(code, host.id, sha256(secret()), sha256(secret()), tokenHash, now + 1);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_handover_claim_receipts(room_code,player_id,operation_hash,offer_hash,secret_hash,session_hash,claim_fence,claimed_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
code,
|
||||
host.id,
|
||||
sha256(secret()),
|
||||
sha256(secret()),
|
||||
sha256(secret()),
|
||||
tokenHash,
|
||||
sha256(secret()),
|
||||
now,
|
||||
);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_ai_delegations(room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
code,
|
||||
host.id,
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
'Hard',
|
||||
tokenHash,
|
||||
sha256(secret()),
|
||||
now + 1,
|
||||
now,
|
||||
);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_messages(room_code,id,sender_id,sender_session_hash,sender_name,sender_faction,recipient_id,recipient_name,body,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
code,
|
||||
randomUUID(),
|
||||
host.id,
|
||||
tokenHash,
|
||||
host.name,
|
||||
host.faction,
|
||||
randomUUID(),
|
||||
'Private recipient',
|
||||
'Private saved discussion',
|
||||
now,
|
||||
);
|
||||
}
|
||||
async function fixture(role: AdminRole = 'operator') {
|
||||
const store = adminStore();
|
||||
const account = store.provision(role, 'Private administrator');
|
||||
const login = await adminLogin(store.database, account.key, now);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
now,
|
||||
);
|
||||
const room = seed(store);
|
||||
const row = () =>
|
||||
store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(room.code)!;
|
||||
const version = () => Number(row().version);
|
||||
const read = () => readAdminClosure(store.database, identity, room.code, now);
|
||||
const apply = (input = change(version()), at = now) =>
|
||||
applyAdminClosure(store.database, identity, room.code, input, at);
|
||||
return {
|
||||
...store,
|
||||
...room,
|
||||
account,
|
||||
login,
|
||||
identity,
|
||||
row,
|
||||
version,
|
||||
read,
|
||||
apply,
|
||||
};
|
||||
}
|
||||
function gateBatch(store: Store, number = 1) {
|
||||
let enter!: () => void, release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
store.hooks.beforeBatch = async () => {
|
||||
if (--number) return;
|
||||
delete store.hooks.beforeBatch;
|
||||
enter();
|
||||
await gate;
|
||||
};
|
||||
return { waiting, release };
|
||||
}
|
||||
|
||||
void test('closure migration is additive and preserves all existing rooms, credentials and audit records', () => {
|
||||
const store = adminStore(':memory:', false);
|
||||
try {
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((file) => file.endsWith('.sql') && file < '0013')
|
||||
.sort())
|
||||
store.sqlite.exec(
|
||||
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
||||
);
|
||||
const room = seed(store);
|
||||
seedDependents(store, room);
|
||||
store.provision();
|
||||
const tables = [
|
||||
'rooms',
|
||||
...secureTables,
|
||||
'room_controls',
|
||||
'admin_accounts',
|
||||
'admin_sessions',
|
||||
'admin_audit',
|
||||
'admin_room_audit',
|
||||
'admin_room_creations',
|
||||
'admin_lobby_operations',
|
||||
];
|
||||
const before = tables.map((table) => rows(store, table));
|
||||
store.sqlite.exec(
|
||||
readFileSync(
|
||||
new URL('../drizzle/0013_admin_room_closure.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
tables.map((table) => rows(store, table)),
|
||||
before,
|
||||
);
|
||||
assert.deepEqual(rows(store, 'room_closures'), []);
|
||||
assert.deepEqual(rows(store, 'admin_room_closures'), []);
|
||||
assert.throws(
|
||||
() =>
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_closures(room_code,closed,revision,updated_at) VALUES(?,1,0,?)',
|
||||
)
|
||||
.run(room.code, now),
|
||||
/CHECK/,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('closure inputs require exact bounded fields and both safe version counters', () => {
|
||||
const valid = change();
|
||||
assert.equal(validAdminClosureInput(valid), true);
|
||||
for (const invalid of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ ...valid, extra: true },
|
||||
{ ...valid, closed: 1 },
|
||||
...['', ' ', 'x'.repeat(301), 'line\n', 'a\u007f'].map((reason) => ({
|
||||
...valid,
|
||||
reason,
|
||||
})),
|
||||
...['', valid.operationId + '\n', valid.operationId.toUpperCase()].map(
|
||||
(operationId) => ({ ...valid, operationId }),
|
||||
),
|
||||
...[-1, 0.5, Infinity, NaN, Number.MAX_SAFE_INTEGER, '0'].flatMap(
|
||||
(value) => [
|
||||
{ ...valid, expectedVersion: value },
|
||||
{ ...valid, expectedRevision: value },
|
||||
],
|
||||
),
|
||||
Object.assign(Object.create({ closed: true }), {
|
||||
operationId: valid.operationId,
|
||||
expectedVersion: 0,
|
||||
expectedRevision: 0,
|
||||
reason: 'Reason',
|
||||
other: true,
|
||||
}),
|
||||
])
|
||||
assert.equal(validAdminClosureInput(invalid), false);
|
||||
});
|
||||
|
||||
void test('live roles can read only operational defaults without backfilling or disclosing room and seat contents', async () => {
|
||||
for (const role of ['owner', 'operator', 'viewer'] as const) {
|
||||
const f = await fixture(role);
|
||||
try {
|
||||
const before = f.row();
|
||||
assert.deepEqual(await f.read(), {
|
||||
code: f.code,
|
||||
version: 0,
|
||||
closed: false,
|
||||
removed: false,
|
||||
revision: 0,
|
||||
closedAt: null,
|
||||
updatedAt: null,
|
||||
paused: false,
|
||||
joinLocked: false,
|
||||
});
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'room_closures'), []);
|
||||
if (role === 'viewer') await assert.rejects(f.apply(), status(403));
|
||||
else assert.equal((await f.apply()).room.closed, true);
|
||||
await assert.rejects(
|
||||
readAdminClosure(f.database, f.identity, f.code + '\n', now),
|
||||
status(400),
|
||||
);
|
||||
await assert.rejects(
|
||||
readAdminClosure(f.database, f.identity, 'ABSENTAA', now),
|
||||
status(404),
|
||||
);
|
||||
await assert.rejects(
|
||||
readAdminClosure(
|
||||
f.database,
|
||||
{ ...f.identity, sessionHash: f.tokenHash },
|
||||
f.code,
|
||||
now,
|
||||
),
|
||||
status(401),
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('close and reopen preserve saved JSON, every dependent record, revoked access and unrelated rooms', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
seedDependents(f, f);
|
||||
const other = seed(f, 'KEEPSAFE');
|
||||
const old = f.row(),
|
||||
secure = secureRows(f);
|
||||
const untouched = f.sqlite
|
||||
.prepare('SELECT * FROM rooms WHERE code=?')
|
||||
.get(other.code);
|
||||
const result = await f.apply();
|
||||
assert.equal(result.replayed, false);
|
||||
assert.equal(result.appliedVersion, 1);
|
||||
assert.equal(result.appliedRevision, 1);
|
||||
assert.deepEqual(result.room, {
|
||||
code: f.code,
|
||||
version: 1,
|
||||
closed: true,
|
||||
removed: false,
|
||||
revision: 1,
|
||||
closedAt: now,
|
||||
updatedAt: now,
|
||||
paused: false,
|
||||
joinLocked: false,
|
||||
});
|
||||
assert.equal(f.row().state, old.state);
|
||||
const reopend = await f.apply(change(1, 1, false), now + 5000);
|
||||
assert.deepEqual(reopend.room, {
|
||||
...result.room,
|
||||
version: 2,
|
||||
closed: false,
|
||||
revision: 2,
|
||||
closedAt: null,
|
||||
updatedAt: now + 5000,
|
||||
});
|
||||
assert.equal(f.row().state, old.state);
|
||||
assert.deepEqual(secureRows(f), secure);
|
||||
assert.deepEqual(
|
||||
f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.code),
|
||||
untouched,
|
||||
);
|
||||
assert.equal(rows(f, 'room_closures').length, 1);
|
||||
const audit = rows(f, 'admin_room_closures');
|
||||
assert.equal(audit.length, 2);
|
||||
for (const row of audit) {
|
||||
const metadata = JSON.stringify([
|
||||
JSON.parse(String(row.before_metadata)),
|
||||
JSON.parse(String(row.after_metadata)),
|
||||
]);
|
||||
for (const privateValue of [
|
||||
f.host.id,
|
||||
f.host.name,
|
||||
f.token,
|
||||
f.tokenHash,
|
||||
'Private saved discussion',
|
||||
'spice',
|
||||
'deck',
|
||||
f.account.name,
|
||||
])
|
||||
assert.equal(metadata.includes(privateValue), false);
|
||||
assert.equal(row.actor_admin_id, f.identity.id);
|
||||
assert.equal(row.reason, change().reason);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('closure and reopening tolerate unreadable saves without parsing diagnostics or repairing JSON', async () => {
|
||||
for (const state of [
|
||||
'{invalid private save',
|
||||
'null',
|
||||
'[]',
|
||||
'{"status":"playing","players":[null,7,"private"]}',
|
||||
]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(state, f.code);
|
||||
await f.apply();
|
||||
assert.equal((await f.read()).closed, true);
|
||||
await f.apply(change(1, 1, false));
|
||||
assert.equal(f.row().state, state);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('reopening refreshes existing AI pacing only when unpaused and preserves pause and joining flags', async () => {
|
||||
for (const paused of [false, true])
|
||||
for (const controller of ['bot', 'autopilot', 'human'] as const) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const game = structuredClone(f.game);
|
||||
game.status = 'playing';
|
||||
if (controller !== 'human') game.players[0][controller] = 'Hard';
|
||||
game.botsPending = controller !== 'human';
|
||||
game.botNextActionAt = 5;
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(JSON.stringify(game), f.code);
|
||||
const controls = {
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 0,
|
||||
paused,
|
||||
joinLocked: true,
|
||||
reason: 'QA controls',
|
||||
};
|
||||
await applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
controls,
|
||||
now,
|
||||
);
|
||||
const state = String(f.row().state),
|
||||
beforeControls = rows(f, 'room_controls');
|
||||
await f.apply(change(f.version()));
|
||||
assert.equal(f.row().state, state);
|
||||
const reopend = await f.apply(
|
||||
change(f.version(), 1, false),
|
||||
now + 3000,
|
||||
);
|
||||
assert.equal(reopend.room.paused, paused);
|
||||
assert.equal(reopend.room.joinLocked, true);
|
||||
const expected = JSON.parse(state);
|
||||
if (!paused && controller !== 'human') {
|
||||
expected.botsPending = true;
|
||||
expected.botNextActionAt = now + 4500;
|
||||
} else assert.equal(f.row().state, state);
|
||||
assert.deepEqual(JSON.parse(String(f.row().state)), expected);
|
||||
assert.deepEqual(rows(f, 'room_controls'), beforeControls);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('exact receipts bind actor, room and normalized intent and report current state after later transitions', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = { ...change(), reason: ' Exact QA reason ' };
|
||||
const first = await f.apply(input);
|
||||
const reopen = await f.apply(change(1, 1, false), now + 1);
|
||||
const replay = await f.apply(
|
||||
{ ...input, reason: 'Exact QA reason' },
|
||||
now + 2,
|
||||
);
|
||||
assert.deepEqual(replay, { ...first, replayed: true, room: reopen.room });
|
||||
const before = f.row();
|
||||
for (const mismatch of [
|
||||
{ ...input, expectedVersion: 1 },
|
||||
{ ...input, expectedRevision: 1 },
|
||||
{ ...input, closed: false },
|
||||
{ ...input, reason: 'Other reason' },
|
||||
])
|
||||
await assert.rejects(f.apply(mismatch), status(409));
|
||||
const account = f.provision('operator');
|
||||
const login = await adminLogin(f.database, account.key, now);
|
||||
const identity = await requireAdmin(
|
||||
f.database,
|
||||
login.token,
|
||||
undefined,
|
||||
now,
|
||||
);
|
||||
await assert.rejects(
|
||||
applyAdminClosure(f.database, identity, f.code, input, now),
|
||||
status(409),
|
||||
);
|
||||
seed(f, 'KEEPSAFE');
|
||||
await assert.rejects(
|
||||
applyAdminClosure(f.database, f.identity, 'KEEPSAFE', input, now),
|
||||
status(409),
|
||||
);
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.equal(rows(f, 'admin_room_closures').length, 2);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('new no-op and stale requests reject without writes; both room version and closure revision fence races', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.row();
|
||||
for (const input of [change(0, 0, false), change(1), change(0, 1)])
|
||||
await assert.rejects(f.apply(input), status(409));
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'admin_room_closures'), []);
|
||||
const pending = change();
|
||||
const gate = pauseAdminBatch(f);
|
||||
const result = f.apply(pending).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET version=version+1 WHERE code=?')
|
||||
.run(f.code);
|
||||
gate.release();
|
||||
assert.ok(status(409)(await result));
|
||||
await f.apply(change(1));
|
||||
await assert.rejects(f.apply(change(2, 1, true)), status(409));
|
||||
await assert.rejects(f.apply(change(2, 0, false)), status(409));
|
||||
assert.equal(rows(f, 'admin_room_closures').length, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('live session, account, role, expiry and generation authority fences reads, writes and exact replay', async () => {
|
||||
for (const operation of ['read', 'write', 'replay'] as const)
|
||||
for (const revoke of [
|
||||
'disabled',
|
||||
'role',
|
||||
'session',
|
||||
'generation',
|
||||
'expiry',
|
||||
] as const) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = change();
|
||||
if (operation === 'replay') await f.apply(input);
|
||||
const before = f.row(),
|
||||
audit = rows(f, 'admin_room_closures');
|
||||
const gate = pauseAdminBatch(f);
|
||||
const pending = (operation === 'read' ? f.read() : f.apply(input)).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
if (revoke === 'disabled')
|
||||
f.sqlite
|
||||
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
|
||||
.run(f.identity.id);
|
||||
if (revoke === 'role')
|
||||
f.sqlite
|
||||
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
|
||||
.run(f.identity.id);
|
||||
if (revoke === 'session')
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?',
|
||||
)
|
||||
.run(now, f.identity.sessionHash);
|
||||
if (revoke === 'generation')
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE admin_accounts SET session_generation=session_generation+1 WHERE id=?',
|
||||
)
|
||||
.run(f.identity.id);
|
||||
if (revoke === 'expiry')
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE admin_sessions SET expires_at=? WHERE token_hash=?',
|
||||
)
|
||||
.run(now, f.identity.sessionHash);
|
||||
gate.release();
|
||||
const result = await pending;
|
||||
if (revoke === 'role' && operation === 'read')
|
||||
assert.equal(result, null);
|
||||
else assert.ok(status(revoke === 'role' ? 403 : 401)(result));
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'admin_room_closures'), audit);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('queued identical requests commit once while competing operations cannot reuse the old version', async () => {
|
||||
for (const same of [true, false]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = change(),
|
||||
gate = pauseAdminBatch(f);
|
||||
const pending = f.apply(input).then(
|
||||
(value) => value,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
const first = await f.apply(same ? input : change());
|
||||
gate.release();
|
||||
const second = await pending;
|
||||
if (same) assert.deepEqual(second, { ...first, replayed: true });
|
||||
else assert.ok(status(409)(second));
|
||||
assert.equal(f.version(), 1);
|
||||
assert.equal(rows(f, 'admin_room_closures').length, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('audit, game fence and closure metadata roll back together at every failed persistence stage', async () => {
|
||||
for (const table of ['admin_room_closures', 'rooms', 'room_closures']) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.row(),
|
||||
secure = secureRows(f);
|
||||
f.sqlite.exec(
|
||||
`CREATE TRIGGER qa_failure BEFORE ${table === 'rooms' ? 'UPDATE' : 'INSERT'} ON ${table} BEGIN SELECT RAISE(ABORT,'QA closure failure'); END;`,
|
||||
);
|
||||
await assert.rejects(f.apply(), /QA closure failure/);
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(secureRows(f), secure);
|
||||
assert.deepEqual(rows(f, 'admin_room_closures'), []);
|
||||
assert.deepEqual(rows(f, 'room_closures'), []);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('closed games and exact receipts survive database reopen without restoring historical credentials', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-closure-'));
|
||||
let store = adminStore(join(dir, 'rooms.sqlite'));
|
||||
try {
|
||||
const account = store.provision('operator'),
|
||||
login = await adminLogin(store.database, account.key, now);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
now,
|
||||
),
|
||||
room = seed(store);
|
||||
seedDependents(store, room);
|
||||
const secure = secureRows(store),
|
||||
state = rows(store, 'rooms')[0].state;
|
||||
const input = change();
|
||||
const closed = await applyAdminClosure(
|
||||
store.database,
|
||||
identity,
|
||||
room.code,
|
||||
input,
|
||||
now,
|
||||
);
|
||||
store.sqlite.close();
|
||||
store = adminStore(join(dir, 'rooms.sqlite'), false);
|
||||
assert.deepEqual(
|
||||
await applyAdminClosure(store.database, identity, room.code, input, now),
|
||||
{ ...closed, replayed: true },
|
||||
);
|
||||
await applyAdminClosure(
|
||||
store.database,
|
||||
identity,
|
||||
room.code,
|
||||
change(1, 1, false),
|
||||
now + 10000,
|
||||
);
|
||||
assert.equal(rows(store, 'rooms')[0].state, state);
|
||||
assert.deepEqual(secureRows(store), secure);
|
||||
const audit = rows(store, 'admin_room_closures');
|
||||
store.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(room.code);
|
||||
assert.deepEqual(rows(store, 'admin_room_closures'), audit);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
void test('closed rooms keep public admin reads and old receipts while new lobby and pause mutations reject', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const lobbyInput = {
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: 0,
|
||||
action: { type: 'rules' as const, advanced: true },
|
||||
reason: 'QA rules',
|
||||
};
|
||||
const lobby = await configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
lobbyInput,
|
||||
now,
|
||||
);
|
||||
const controls = {
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 0,
|
||||
paused: false,
|
||||
joinLocked: true,
|
||||
reason: 'QA controls',
|
||||
};
|
||||
await applyAdminRoomControl(f.database, f.identity, f.code, controls, now);
|
||||
await f.apply(change(f.version()));
|
||||
const before = f.row();
|
||||
assert.equal(
|
||||
(await readAdminLobby(f.database, f.identity, f.code, now)).editable,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(await readAdminRoomControl(f.database, f.identity, f.code, now))
|
||||
.joinLocked,
|
||||
true,
|
||||
);
|
||||
const receipt = await configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
lobbyInput,
|
||||
now,
|
||||
);
|
||||
assert.equal(receipt.replayed, true);
|
||||
assert.equal(receipt.appliedVersion, lobby.appliedVersion);
|
||||
assert.equal(receipt.lobby.editable, false);
|
||||
assert.equal(
|
||||
(
|
||||
await applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
controls,
|
||||
now,
|
||||
)
|
||||
).replayed,
|
||||
true,
|
||||
);
|
||||
await assert.rejects(
|
||||
configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
...lobbyInput,
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: f.version(),
|
||||
},
|
||||
now,
|
||||
),
|
||||
status(409),
|
||||
);
|
||||
await assert.rejects(
|
||||
applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
...controls,
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 1,
|
||||
paused: true,
|
||||
},
|
||||
now,
|
||||
),
|
||||
status(409),
|
||||
);
|
||||
assert.deepEqual(f.row(), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('prepared admin lobby and pause writes lose their transaction race to closure', async () => {
|
||||
for (const operation of ['lobby', 'pause'] as const) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const gate = gateBatch(f, operation === 'lobby' ? 2 : 1);
|
||||
const pending = (
|
||||
operation === 'lobby'
|
||||
? configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: 0,
|
||||
action: { type: 'rules', advanced: true },
|
||||
reason: 'QA rules',
|
||||
},
|
||||
now,
|
||||
)
|
||||
: applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 0,
|
||||
paused: true,
|
||||
joinLocked: true,
|
||||
reason: 'QA pause',
|
||||
},
|
||||
now,
|
||||
)
|
||||
).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
await f.apply();
|
||||
const before = f.row();
|
||||
gate.release();
|
||||
assert.ok(status(409)(await pending));
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'admin_lobby_operations'), []);
|
||||
assert.deepEqual(rows(f, 'admin_room_audit'), []);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
void test('removal retains closure and restoring a closed room preserves AI deadlines until a separate reopen', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const game = JSON.parse(String(f.row().state));
|
||||
game.status = 'playing'; game.players[0].autopilot = 'Hard'; game.botNextActionAt = 222; game.botsPending = true;
|
||||
f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), f.code);
|
||||
const closing = change();
|
||||
await f.apply(closing);
|
||||
const state = f.row().state, secure = secureRows(f);
|
||||
const removal = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA removal while closed' };
|
||||
await applyAdminRemoval(f.database, f.identity, f.code, removal, now);
|
||||
assert.equal((await readAdminRemoval(f.database, f.identity, f.code, now)).closed, true);
|
||||
const current = await readAdminClosure(f.database, f.identity, f.code, now);
|
||||
assert.equal(current.removed, true); assert.equal(current.closed, true);
|
||||
await assert.rejects(f.apply(change(f.version(), 1, false)), status(409));
|
||||
assert.equal((await f.apply(closing)).replayed, true);
|
||||
assert.equal(f.row().state, state);
|
||||
await applyAdminRemoval(f.database, f.identity, f.code, { ...removal, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 1, removed: false }, now + 50000);
|
||||
assert.equal(f.row().state, state);
|
||||
assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).closed, true);
|
||||
await applyAdminClosure(f.database, f.identity, f.code, change(f.version(), 1, false), now + 50000);
|
||||
assert.equal(JSON.parse(String(f.row().state)).botNextActionAt, now + 51500);
|
||||
assert.deepEqual(secureRows(f), secure);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -99,3 +99,18 @@ void test('directory projects and filters operational flags without private audi
|
||||
await assert.rejects(read('availability=unsupported'), (e: unknown) => e instanceof AdminError && e.status === 400);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('closed rooms remain in the directory but are excluded from running rooms and expose only the closure flag', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
insert(f.sqlite, 'OPENROOM', state()); insert(f.sqlite, 'CLOSEDQA', state());
|
||||
f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run('CLOSEDQA');
|
||||
const read = (q: string) => readAdminDirectory(f.database, f.identity, new URLSearchParams(q));
|
||||
assert.equal((await read('')).total, 2);
|
||||
const closed = await read('availability=closed');
|
||||
assert.equal(closed.total, 1); assert.equal(closed.rooms[0].code, 'CLOSEDQA');
|
||||
assert.deepEqual(closed.rooms[0].control, { paused: false, joinLocked: false, revision: 0, updatedAt: null, closed: true });
|
||||
assert.equal(JSON.stringify(closed).includes('SECRET'), false);
|
||||
assert.deepEqual((await read('availability=running')).rooms.map(room => room.code), ['OPENROOM']);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -276,3 +276,10 @@ void test('a failed gateway response keeps its status and requires reconciliatio
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
void test('a typed closure rejection retains possibly completed request proof without treating it as removal', async () => {
|
||||
const { isRoomClosed, isRoomRemoved, requestMayHaveCompleted } = await import('../lib/client-request');
|
||||
const error = new ClientRequestError('Room closed', 'http', 409, 'ROOM_CLOSED');
|
||||
assert.equal(isRoomClosed(error), true); assert.equal(isRoomRemoved(error), false);
|
||||
assert.equal(requestMayHaveCompleted(error), true);
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
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 } from './admin-access-fixture';
|
||||
import { adminLogin, requireAdmin } from '../db/admin-access';
|
||||
import { applyAdminClosure, readAdminClosure } from '../db/admin-closure';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import * as engine from '../game/engine';
|
||||
import * as bots from '../game/bots';
|
||||
import * as delegation from '../lib/seat-ai-delegation';
|
||||
import { requestOrigin } from '../lib/request-origin';
|
||||
import * as talkContract from '../lib/table-talk';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import type * as Talk from '../db/table-talk';
|
||||
|
||||
const secret = () => randomBytes(32).toString('hex');
|
||||
const entry = () => ({ operationId: randomUUID(), sessionToken: secret() });
|
||||
const closed = (error: unknown) =>
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ROOM_CLOSED' &&
|
||||
'status' in error &&
|
||||
error.status === 409;
|
||||
const draft = (recipientId: string | null = null) => ({
|
||||
id: randomUUID(),
|
||||
recipientId,
|
||||
text: 'Retained discussion for the same saved seat.',
|
||||
});
|
||||
|
||||
type Values = (string | number | null)[];
|
||||
async function fixture(started = false) {
|
||||
const store = adminStore();
|
||||
assert.ok(store.sqlite instanceof DatabaseSync);
|
||||
const account = store.provision('operator');
|
||||
const login = await adminLogin(store.database, account.key);
|
||||
const identity = await requireAdmin(store.database, login.token);
|
||||
const calls = { bots: 0, normalization: 0, views: 0 };
|
||||
const hooks: {
|
||||
beforeFirst?: (sql: string, values: Values) => void;
|
||||
beforeRun?: (sql: string, values: Values) => void;
|
||||
} = {};
|
||||
const database = {
|
||||
batch: (statements: D1PreparedStatement[]) =>
|
||||
store.database.batch(statements),
|
||||
prepare(sql: string) {
|
||||
const statement = store.database.prepare(sql);
|
||||
const first = statement.first.bind(statement);
|
||||
const run = statement.run.bind(statement);
|
||||
const bind = statement.bind.bind(statement);
|
||||
let values: Values = [];
|
||||
statement.bind = (...input: Values) => {
|
||||
values = input;
|
||||
return bind(...input);
|
||||
};
|
||||
statement.first = async <T>() => {
|
||||
hooks.beforeFirst?.(sql, values);
|
||||
return first<T>();
|
||||
};
|
||||
statement.run = async <T>() => {
|
||||
hooks.beforeRun?.(sql, values);
|
||||
return run<T>();
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
} as D1Database;
|
||||
function load<T>(file: string) {
|
||||
const exports = {};
|
||||
runInNewContext(
|
||||
ts.transpileModule(readFileSync(new URL(file, 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,
|
||||
viewGame: (...args: Parameters<typeof engine.viewGame>) => {
|
||||
calls.views++;
|
||||
return engine.viewGame(...args);
|
||||
},
|
||||
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 delegation;
|
||||
if (name === '../lib/table-talk') return talkContract;
|
||||
throw new Error('Unexpected dependency ' + name);
|
||||
},
|
||||
},
|
||||
);
|
||||
return exports as T;
|
||||
}
|
||||
const rooms = load<typeof Rooms>('../db/rooms.ts');
|
||||
const talk = load<typeof Talk>('../db/table-talk.ts');
|
||||
const ownerEntry = entry(),
|
||||
guestEntry = entry();
|
||||
const owner = await rooms.createRoom(
|
||||
'Closure owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
ownerEntry,
|
||||
);
|
||||
const code = owner.view.code;
|
||||
const guest = await rooms.joinRoom(
|
||||
code,
|
||||
'Closure guest',
|
||||
'emperor',
|
||||
guestEntry,
|
||||
);
|
||||
const auth = await rooms.authenticate(code, owner.token);
|
||||
const guestAuth = await rooms.authenticate(code, guest.token!);
|
||||
let now = 10000;
|
||||
const clock = {
|
||||
now: () => now,
|
||||
advance: (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
sleep: async (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
};
|
||||
const version = () =>
|
||||
Number(
|
||||
store.sqlite
|
||||
.prepare('SELECT version FROM rooms WHERE code = ?')
|
||||
.get(code)!.version,
|
||||
);
|
||||
if (started) {
|
||||
for (const seat of [auth, guestAuth])
|
||||
await rooms.act(code, seat, version(), { type: 'ready' }, clock);
|
||||
await rooms.act(code, auth, version(), { type: 'start' }, clock);
|
||||
}
|
||||
const transition = async (closed: boolean, roomCode = code) => {
|
||||
const view = await readAdminClosure(database, identity, roomCode, now);
|
||||
return applyAdminClosure(
|
||||
database,
|
||||
identity,
|
||||
roomCode,
|
||||
{
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: view.version,
|
||||
expectedRevision: view.revision,
|
||||
closed,
|
||||
reason: 'Dedicated recoverable closure runtime QA',
|
||||
},
|
||||
now,
|
||||
);
|
||||
};
|
||||
// Deliberately retain the old game version to prove the SQL closure predicate itself,
|
||||
// independently of the additional version bump made by the real admin transaction.
|
||||
const flagClosed = (roomCode = code) =>
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,10000,10000)',
|
||||
)
|
||||
.run(roomCode);
|
||||
const snapshot = () =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
'rooms',
|
||||
'seats',
|
||||
'room_entry_receipts',
|
||||
'seat_recovery_keys',
|
||||
'seat_recovery_receipts',
|
||||
'seat_handover_offers',
|
||||
'seat_handover_claim_receipts',
|
||||
'seat_ai_delegations',
|
||||
'room_messages',
|
||||
'room_controls',
|
||||
'room_closures',
|
||||
].map((table) => [
|
||||
table,
|
||||
store.sqlite
|
||||
.prepare('SELECT * FROM ' + table + ' ORDER BY rowid')
|
||||
.all(),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...store,
|
||||
rooms,
|
||||
talk,
|
||||
hooks,
|
||||
calls,
|
||||
code,
|
||||
owner,
|
||||
ownerEntry,
|
||||
guest,
|
||||
guestEntry,
|
||||
auth,
|
||||
guestAuth,
|
||||
clock,
|
||||
version,
|
||||
transition,
|
||||
flagClosed,
|
||||
snapshot,
|
||||
restart: () => load<typeof Rooms>('../db/rooms.ts'),
|
||||
};
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
const grant = (f: Fixture) => ({
|
||||
grantId: randomUUID(),
|
||||
delegateId: f.guestAuth.playerId,
|
||||
difficulty: 'Hard' as const,
|
||||
});
|
||||
const offer = () => ({ offerId: randomUUID(), handoverSecret: secret() });
|
||||
const recovery = (f: Fixture, recoverySecret: string) => ({
|
||||
playerId: f.auth.playerId,
|
||||
recoverySecret,
|
||||
operationId: randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
});
|
||||
|
||||
for (const kind of [
|
||||
'join',
|
||||
'action',
|
||||
'takeback',
|
||||
'grant',
|
||||
'revoke grant',
|
||||
'activate grant',
|
||||
'recovery key',
|
||||
'recovery',
|
||||
'create handover',
|
||||
'revoke handover',
|
||||
'claim handover',
|
||||
'discussion send',
|
||||
] as const) {
|
||||
void test(`${kind} commit fence rejects closure without changing state, credentials or receipts`, async () => {
|
||||
const f = await fixture(kind !== 'join' && kind !== 'action');
|
||||
try {
|
||||
const consent = grant(f),
|
||||
handover = offer(),
|
||||
recoverySecret = secret();
|
||||
if (kind === 'revoke grant' || kind === 'activate grant')
|
||||
await f.rooms.setSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent,
|
||||
f.clock,
|
||||
);
|
||||
if (kind === 'recovery')
|
||||
await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
recoverySecret,
|
||||
);
|
||||
if (kind === 'revoke handover' || kind === 'claim handover')
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
);
|
||||
if (kind === 'takeback')
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: 'Hard' },
|
||||
f.clock,
|
||||
);
|
||||
let expected: ReturnType<typeof f.snapshot> | undefined;
|
||||
f.hooks.beforeRun = (sql) => {
|
||||
if (
|
||||
!sql.startsWith(
|
||||
kind === 'discussion send'
|
||||
? 'INSERT INTO room_messages'
|
||||
: 'UPDATE rooms',
|
||||
)
|
||||
)
|
||||
return;
|
||||
delete f.hooks.beforeRun;
|
||||
f.flagClosed();
|
||||
expected = f.snapshot();
|
||||
};
|
||||
const operations = {
|
||||
join: () => f.rooms.joinRoom(f.code, 'Racing guest', 'guild', entry()),
|
||||
action: () =>
|
||||
f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' }, f.clock),
|
||||
takeback: () =>
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: null },
|
||||
f.clock,
|
||||
),
|
||||
grant: () =>
|
||||
f.rooms.setSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent,
|
||||
f.clock,
|
||||
),
|
||||
'revoke grant': () =>
|
||||
f.rooms.revokeSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent.grantId,
|
||||
f.clock,
|
||||
),
|
||||
'activate grant': () =>
|
||||
f.rooms.useSeatAiDelegate(
|
||||
f.code,
|
||||
f.guestAuth,
|
||||
f.version(),
|
||||
{ ownerId: f.auth.playerId, grantId: consent.grantId },
|
||||
f.clock,
|
||||
),
|
||||
'recovery key': () =>
|
||||
f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret),
|
||||
recovery: () =>
|
||||
f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
|
||||
'create handover': () =>
|
||||
f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
),
|
||||
'revoke handover': () =>
|
||||
f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover.offerId,
|
||||
f.clock,
|
||||
),
|
||||
'claim handover': () =>
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
{
|
||||
playerId: f.auth.playerId,
|
||||
...handover,
|
||||
operationId: randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
},
|
||||
f.clock,
|
||||
),
|
||||
'discussion send': () =>
|
||||
f.talk.sendTableTalk(f.code, f.auth, draft(), 1000),
|
||||
};
|
||||
await assert.rejects(operations[kind](), closed);
|
||||
assert.ok(expected);
|
||||
assert.deepEqual(f.snapshot(), expected);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('sleeping AI rechecks closure before computing and reopened AI retains normal pacing', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: 'Hard' },
|
||||
f.clock,
|
||||
);
|
||||
const clock = {
|
||||
now: f.clock.now,
|
||||
sleep: async (ms: number) => {
|
||||
await f.transition(true);
|
||||
f.clock.advance(ms);
|
||||
},
|
||||
};
|
||||
await f.rooms.continueRoomBots(f.code, 1, clock);
|
||||
assert.equal(f.calls.bots, 0);
|
||||
const before = f.snapshot();
|
||||
await f.rooms.continueRoomAutomatic(f.code, f.clock);
|
||||
await f.restart().continueRoomBots(f.code, 4, f.clock);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
f.clock.advance(50000);
|
||||
await f.transition(false);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).botNextActionAt,
|
||||
f.clock.now() + 1500,
|
||||
);
|
||||
await f.rooms.continueRoomBots(f.code, 1, f.clock);
|
||||
assert.equal(f.calls.bots, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of ['AI', 'automatic normalization'] as const) {
|
||||
void test(`${kind} discards computed work when closure wins its final SQL fence`, async () => {
|
||||
const f = await fixture(kind === 'AI');
|
||||
try {
|
||||
if (kind === 'AI') {
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: 'Hard' },
|
||||
f.clock,
|
||||
);
|
||||
f.clock.advance(1500);
|
||||
} else {
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
game.players[0] = engine.newPlayer(f.auth.playerId, 'CHOAM', 'choam');
|
||||
game.players.push(engine.newPlayer('guild', 'Guild', 'guild'));
|
||||
Object.assign(game, {
|
||||
status: 'playing',
|
||||
phase: 3,
|
||||
turn: 2,
|
||||
order: game.players.map((p) => p.id),
|
||||
active: f.auth.playerId,
|
||||
auction: null,
|
||||
response: null,
|
||||
deck: baseDeck(),
|
||||
discard: [],
|
||||
choamMarket: { owner: f.auth.playerId, resume: 'phase', blocked: [] },
|
||||
decision: { kind: 'choamMarket', player: f.auth.playerId },
|
||||
aid: { [f.auth.playerId]: { recipient: 'emperor', amount: 2 } },
|
||||
});
|
||||
for (const player of game.players) {
|
||||
player.hand = [];
|
||||
player.spice = 10;
|
||||
}
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), f.code);
|
||||
}
|
||||
let expected: ReturnType<typeof f.snapshot> | undefined;
|
||||
f.hooks.beforeRun = (sql) => {
|
||||
if (!sql.startsWith('UPDATE rooms')) return;
|
||||
delete f.hooks.beforeRun;
|
||||
f.flagClosed();
|
||||
expected = f.snapshot();
|
||||
};
|
||||
if (kind === 'AI') await f.rooms.continueRoomBots(f.code, 4, f.clock);
|
||||
else await f.rooms.continueRoomAutomatic(f.code, f.clock);
|
||||
assert.ok(expected);
|
||||
assert.deepEqual(f.snapshot(), expected);
|
||||
assert.equal(kind === 'AI' ? f.calls.bots : f.calls.normalization, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('closed rooms retain private views, history and committed entry/offer/message receipts while all new mutations stop', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const consent = grant(f), handover = offer(), recoverySecret = secret();
|
||||
await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock);
|
||||
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
|
||||
await f.rooms.createSeatHandover(f.code, f.auth, f.version(), handover, f.clock);
|
||||
const message = draft(f.guestAuth.playerId);
|
||||
await f.talk.sendTableTalk(f.code, f.auth, message, 1000);
|
||||
const original = await f.rooms.readSeatView(f.code, f.auth);
|
||||
assert.equal(original.roomControl, undefined);
|
||||
await f.transition(true);
|
||||
const before = f.snapshot();
|
||||
assert.deepEqual(await f.rooms.authenticate(f.code, f.owner.token), f.auth);
|
||||
const view = await f.rooms.readSeatView(f.code, f.auth);
|
||||
assert.deepEqual(view.roomControl, { paused: false, joinLocked: false, closed: true, revision: 0, updatedAt: null });
|
||||
const { roomControl: _control, ...rest } = view;
|
||||
assert.deepEqual({ ...rest, version: original.version }, original);
|
||||
assert.equal((await f.talk.readTableTalk(f.code, f.auth, f.guestAuth.playerId)).messages[0].text, message.text);
|
||||
assert.equal((await f.talk.sendTableTalk(f.code, f.auth, message, 1000)).replayed, true);
|
||||
assert.equal((await f.rooms.createRoom('Closure owner', 'atreides', false, [], f.ownerEntry)).entryReceipt?.replayed, true);
|
||||
assert.equal((await f.rooms.joinRoom(f.code, 'Closure guest', 'emperor', f.guestEntry)).entryReceipt?.replayed, true);
|
||||
assert.equal((await f.rooms.joinRoom(f.code, 'Any name', 'guild', undefined, f.auth)).view.me, f.auth.playerId);
|
||||
assert.equal((await f.rooms.createSeatHandover(f.code, f.auth, f.version(), handover, f.clock)).replayed, true);
|
||||
assert.equal((await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock)).replayed, true);
|
||||
for (const action of [
|
||||
() => f.rooms.joinRoom(f.code, 'New guest', 'guild', entry()),
|
||||
() => f.rooms.act(f.code, f.auth, f.version(), { type: 'advanceBots' }, f.clock),
|
||||
() => f.rooms.act(f.code, f.auth, f.version(), { type: 'setAutopilot', difficulty: null }, f.clock),
|
||||
() => f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), grant(f), f.clock),
|
||||
() => f.rooms.revokeSeatAiDelegate(f.code, f.auth, f.version(), consent.grantId, f.clock),
|
||||
() => f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), { ownerId: f.auth.playerId, grantId: consent.grantId }, f.clock),
|
||||
() => f.rooms.setRecoveryKey(f.code, f.auth, f.version(), secret()),
|
||||
() => f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
|
||||
() => f.rooms.createSeatHandover(f.code, f.auth, f.version(), offer(), f.clock),
|
||||
() => f.rooms.revokeSeatHandover(f.code, f.auth, f.version(), handover.offerId, f.clock),
|
||||
() => f.rooms.claimSeatHandover(f.code, { playerId: f.auth.playerId, ...handover, operationId: randomUUID(), newSessionToken: secret() }, f.clock),
|
||||
() => f.talk.sendTableTalk(f.code, f.auth, draft(), 3000),
|
||||
]) await assert.rejects(action(), closed);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
await f.transition(false);
|
||||
assert.deepEqual({ ...await f.rooms.readSeatView(f.code, f.auth), version: original.version }, original);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('completed recovery and handover confirm while closed without rotating again or reviving old credentials', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const recoverySecret = secret();
|
||||
await f.rooms.setRecoveryKey(f.code, f.guestAuth, f.version(), recoverySecret);
|
||||
const recovering = { ...recovery(f, recoverySecret), playerId: f.guestAuth.playerId };
|
||||
const recovered = await f.rooms.recoverSeat(f.code, recovering);
|
||||
const handover = offer();
|
||||
await f.rooms.createSeatHandover(f.code, f.auth, f.version(), handover, f.clock);
|
||||
const claiming = { playerId: f.auth.playerId, ...handover, operationId: randomUUID(), newSessionToken: secret() };
|
||||
const claimed = await f.rooms.claimSeatHandover(f.code, claiming, f.clock);
|
||||
await f.transition(true);
|
||||
const before = f.snapshot();
|
||||
const recoveredAgain = await f.restart().recoverSeat(f.code, recovering);
|
||||
const claimedAgain = await f.restart().claimSeatHandover(f.code, claiming, f.clock);
|
||||
assert.equal(recoveredAgain.replayed, true); assert.equal(recoveredAgain.token, recovered.token);
|
||||
assert.equal(claimedAgain.replayed, true); assert.equal(claimedAgain.token, claimed.token);
|
||||
assert.equal(recoveredAgain.view.roomControl?.closed, true); assert.equal(claimedAgain.view.roomControl?.closed, true);
|
||||
for (const token of [f.owner.token, f.guest.token!]) await assert.rejects(f.rooms.authenticate(f.code, token), /could not be verified/);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
await f.transition(false);
|
||||
for (const token of [f.owner.token, f.guest.token!]) await assert.rejects(f.rooms.authenticate(f.code, token), /could not be verified/);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('an already used AI grant remains a read-only receipt while closure blocks taking control back', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const consent = grant(f);
|
||||
await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock);
|
||||
const activate = { ownerId: f.auth.playerId, grantId: consent.grantId };
|
||||
await f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), activate, f.clock);
|
||||
await f.transition(true);
|
||||
const before = f.snapshot();
|
||||
assert.equal((await f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), activate, f.clock)).replayed, true);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auth, f.version(), { type: 'setAutopilot', difficulty: null }, f.clock), closed);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('closed HTTP reads and exact delegation receipts do not schedule automatic or AI workers', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const consent = grant(f), activation = { ownerId: f.auth.playerId, grantId: '' };
|
||||
await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock);
|
||||
activation.grantId = consent.grantId;
|
||||
await f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), activation, f.clock);
|
||||
await f.transition(true);
|
||||
const before = f.snapshot();
|
||||
let scheduled = 0;
|
||||
function route(control: boolean) {
|
||||
const exports: Record<string, (req: Request) => Promise<Response>> = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL(control ? '../app/api/rooms/[code]/control/route.ts' : '../app/api/rooms/[code]/route.ts', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText, { exports, URL, Response, JSON, require: (name: string) => {
|
||||
if (name === '@/db/rooms') return f.rooms;
|
||||
if (name === '@/db/room-continuation') return { resumeRoom: async () => { assert.fail('Closed room scheduled a worker.'); } };
|
||||
if (name === '@/game/engine') return engine;
|
||||
if (name === '@/lib/request-origin') return { requestOrigin };
|
||||
if (name === '@/lib/seat-ai-delegation') return delegation;
|
||||
if (name === 'cloudflare:workers') return { env: {}, waitUntil: () => { scheduled++; } };
|
||||
throw new Error('Unexpected route dependency ' + name);
|
||||
} });
|
||||
return exports;
|
||||
}
|
||||
const path = `http://localhost/api/rooms/${f.code}`;
|
||||
const headers = { cookie: `dune_${f.code}=${f.guest.token}`, origin: 'http://localhost', 'content-type': 'application/json' };
|
||||
const read = await route(false).GET(new Request(path, { headers }));
|
||||
assert.equal(read.status, 200);
|
||||
assert.equal((await read.json() as { roomControl: { closed: boolean } }).roomControl.closed, true);
|
||||
const replay = await route(true).POST(new Request(path + '/control', { method: 'POST', headers, body: JSON.stringify({ type: 'useSeatAiDelegate', version: f.version(), ...activation }) }));
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal((await replay.json() as { replayed: boolean }).replayed, true);
|
||||
const blocked = await route(false).POST(new Request(path, { method: 'POST', headers, body: JSON.stringify({ version: f.version(), action: { type: 'advanceBots' } }) }));
|
||||
assert.equal(blocked.status, 409);
|
||||
assert.equal(scheduled, 0); assert.deepEqual(f.snapshot(), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SeatRecoveryClaim } from '../components/seat-recovery';
|
||||
|
||||
void test('saved-seat recovery renders without a browser window or ambient closed variable', () => {
|
||||
assert.equal('closed' in globalThis, false);
|
||||
const html = renderToStaticMarkup(<SeatRecoveryClaim onRestored={() => {}} />);
|
||||
assert.match(html, /Recover a saved seat/);
|
||||
assert.match(html, /Paste your private recovery kit/);
|
||||
});
|
||||
Reference in new issue
Block a user