Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials. Fix AI readiness after a joining human or human faction change so real lobby starts remain legal. Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
102 lines
6.9 KiB
TypeScript
102 lines
6.9 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { adminStore, pauseAdminBatch } from './admin-access-fixture';
|
|
import { adminLogin, requireAdmin, adminLogoutAll, AdminError } from '../db/admin-access';
|
|
import { projectAdminRoom, readAdminDirectory } from '../db/admin-directory';
|
|
|
|
const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
|
|
status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [],
|
|
players: [{ id: 'p1', name, faction: 'atreides', ready: false, hand: ['SECRET_HAND'], traitors: ['SECRET_TRAITOR'], prediction: 'SECRET_PREDICTION', spice: 17 }],
|
|
battle: null, response: null, decision: null, active: 'p1',
|
|
log: ['SECRET_HISTORY'], unknownFuturePrivateField: 'SECRET_NEW_FIELD',
|
|
});
|
|
const insert = (sqlite: DatabaseSync, code: string, value: unknown, updated = 100) => sqlite.prepare('INSERT INTO rooms (code,state,version,updated_at) VALUES (?,?,0,?)').run(code, typeof value === 'string' ? value : JSON.stringify(value), updated);
|
|
|
|
void test('recoverably removed rooms leave the default directory and remain explicitly searchable for restoration', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
insert(f.sqlite, 'ACTIVEAA', state('Active QA'));
|
|
insert(f.sqlite, 'REMOVEDA', state('Removed QA'));
|
|
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,100,100)').run('REMOVEDA');
|
|
const read = (query = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(query));
|
|
const active = await read(); assert.equal(active.total, 1); assert.equal(active.rooms[0].code, 'ACTIVEAA'); assert.equal(active.rooms[0].removed, false);
|
|
assert.equal((await read('q=Removed')).total, 0);
|
|
const removed = await read('removal=removed&q=Removed'); assert.equal(removed.total, 1); assert.equal(removed.rooms[0].removed, true);
|
|
assert.equal(JSON.stringify(removed).includes('SECRET'), false);
|
|
assert.equal((await read('removal=all')).total, 2);
|
|
await assert.rejects(read('removal=invalid'), (e: unknown) => e instanceof AdminError && e.status === 400);
|
|
f.sqlite.prepare('UPDATE room_removals SET removed=0,revision=2,removed_at=NULL WHERE room_code=?').run('REMOVEDA');
|
|
assert.equal((await read()).total, 2); assert.equal((await read('removal=removed')).total, 0);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
async function fixture() {
|
|
const store = adminStore();
|
|
const account = store.provision('viewer');
|
|
const login = await adminLogin(store.database, account.key);
|
|
const identity = await requireAdmin(store.database, login.token);
|
|
return { ...store, identity, login };
|
|
}
|
|
void test('admin directory allowlist reveals no private state, role power or seat credential', () => {
|
|
const input = state();
|
|
const output = projectAdminRoom({ code: 'ABCDEFGH', version: 3, updated_at: 100, state: JSON.stringify(input) });
|
|
assert.deepEqual(output.players, [{ id: 'p1', name: 'Directory QA', faction: 'atreides', control: 'human' }]);
|
|
assert.deepEqual(output.pending, { label: 'Lobby readiness', owners: ['p1'] });
|
|
assert.equal(JSON.stringify(output).includes('SECRET'), false);
|
|
assert.deepEqual(Object.keys(output.players[0]).sort(), ['control', 'faction', 'id', 'name']);
|
|
const decision = projectAdminRoom({ code: 'ABCDEFGH', version: 3, updated_at: 100, state: JSON.stringify({ ...input, status: 'playing', decision: { kind: 'capturedLeader', player: 'p1', leader: 'SECRET_CAPTURE', owner: 'SECRET_OWNER' } }) });
|
|
assert.deepEqual(decision.pending, { label: 'Special decision', owners: ['p1'] });
|
|
assert.equal(JSON.stringify(decision).includes('SECRET'), false);
|
|
});
|
|
void test('admin directory filters, stable pages, literal wildcard search and nested malformed saves', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
for (let n = 0; n < 28; n++) insert(f.sqlite, `QA${String(n).padStart(6, '0')}`, state(n === 0 ? 'Literal %_ QA' : 'Directory QA', n % 2 ? 'playing' : 'lobby', n % 2 === 1));
|
|
insert(f.sqlite, 'BROKEN01', '{bad');
|
|
insert(f.sqlite, 'BROKEN02', { ...state(), players: ['broken'] });
|
|
insert(f.sqlite, 'BROKEN03', { ...state(), players: 'broken' });
|
|
const before = f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all();
|
|
const read = (query = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(query));
|
|
const first = await read(), next = await read('page=2');
|
|
assert.equal(first.total, 31); assert.equal(first.rooms.length, 25); assert.equal(next.rooms.length, 6);
|
|
assert.equal(new Set([...first.rooms, ...next.rooms].map(r => r.code)).size, 31);
|
|
assert.equal((await read('q=Directory')).total, 27);
|
|
assert.equal((await read('q=%25_')).total, 1);
|
|
assert.equal((await read('status=playing&rules=advanced')).total, 14);
|
|
assert.equal((await read('status=lobby&rules=advanced')).total, 0);
|
|
assert.equal((await read('q=SECRET')).total, 0, 'Search cannot inspect hands, predictions or history');
|
|
assert.equal((await read('q=BROKEN')).rooms.every(r => r.status === 'unreadable'), true);
|
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
|
for (const query of ['page=0', 'page=1.5', 'page=-1', 'sort=state', 'status=bogus', 'q=a&q=b', 'x=1', 'q=' + 'a'.repeat(81)]) {
|
|
await assert.rejects(read(query), (e: unknown) => e instanceof AdminError && e.status === 400, query);
|
|
}
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
void test('admin directory rechecks authority inside its snapshot after concurrent revocation', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
insert(f.sqlite, 'ABCDEFGH', state());
|
|
const gate = pauseAdminBatch(f);
|
|
const pending = readAdminDirectory(f.database, f.identity, new URLSearchParams());
|
|
await gate.waiting;
|
|
await adminLogoutAll(f.database, f.identity);
|
|
gate.release();
|
|
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(); }
|
|
});
|