Add an owner-only, read-only sample of at most ten live rooms whose public pending interaction — decision, response, Truthtrance or phase opening — has been idle longest, with the room code, idle duration, interaction kind, turn and phase. GET /api/admin/operations?stalled=1 repeats live owner authority, answers no-store, rejects any other query combination and repairs nothing; paused, closed, removed and archived rooms are excluded and no private game content is selected. The aggregate sample adds stalledRooms and oldestStalledChange for the same predicate. Owner-only unit coverage asserts the counters, the oldest stalled write, the bounded oldest-first order, every exclusion boundary, idle arithmetic, the absence of private content and the owner recheck after demotion. The anonymous HTTP boundary denies the new read, and the full HTTP integration suite passes 55/55 against a local build of this revision. The panel was verified in the real browser against six hours of genuine idle rooms, which caught and fixed a missing threshold field in the response. No game, seat, saved state, deployment, public start or played-save conversion changes.
156 lines
11 KiB
TypeScript
156 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { randomUUID } from 'node:crypto';
|
|
import test from 'node:test';
|
|
import { adminLogin, requireAdmin, AdminError } from '../db/admin-access';
|
|
import { ADMIN_STALL_LIMIT, ADMIN_STALL_MS, readAdminIntegrity, readAdminOperations, readAdminStalledDecisions } from '../db/admin-operations';
|
|
import { adminStore } from './admin-access-fixture';
|
|
|
|
const now = 10_000;
|
|
|
|
void test('owner operations sample counts saved states and backup provenance without leaking contents or changing rooms', async () => {
|
|
const store = adminStore();
|
|
try {
|
|
const owner = store.provision('owner');
|
|
const login = await adminLogin(store.database, owner.key, now);
|
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
|
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABC', '{"private":"sealed card"}', 12, 9000);
|
|
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABD', '{broken private state', 3, 9200);
|
|
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run('private-session', 'OPERAABC', 'one');
|
|
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,1)').run('revoked-session', 'OPERAABC', 'two');
|
|
store.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
|
store.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
|
store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)').run('OPERAABC', now);
|
|
store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
|
const backupId = randomUUID();
|
|
store.sqlite.prepare(`INSERT INTO admin_room_backups
|
|
(operation_id,actor_admin_id,room_code,request_hash,room_version,created_at,size_bytes,digest,payload,reason)
|
|
VALUES(?,?,?,'request',12,?,14,'digest','secret payload','private reason')`).run(backupId, owner.id, 'OPERAABC', now);
|
|
store.sqlite.prepare('INSERT INTO admin_room_backup_downloads(download_id,operation_id,actor_admin_id,room_code,created_at) VALUES(?,?,?,?,?)').run(randomUUID(), backupId, owner.id, 'OPERAABC', now);
|
|
const before = store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all();
|
|
const result = await readAdminOperations(store.database, identity, now);
|
|
assert.deepEqual(result, {
|
|
observedAt: now, rooms: 2, removedRooms: 1, archivedRooms: 1,
|
|
pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1,
|
|
lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1,
|
|
stalledRooms: 0, oldestStalledChange: null,
|
|
});
|
|
assert.equal(JSON.stringify(result).includes('secret'), false);
|
|
assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before);
|
|
assert.deepEqual(await readAdminIntegrity(store.database, identity, now),
|
|
{ observedAt: now, sqlite: 'passed', foreignKeys: 'passed' });
|
|
assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before);
|
|
} finally { store.sqlite.close(); }
|
|
});
|
|
|
|
void test('stale owner identity, demotion and revoked session cannot sample operational counts', async () => {
|
|
const store = adminStore();
|
|
try {
|
|
const owner = store.provision('owner');
|
|
store.provision('owner', 'Other owner');
|
|
const login = await adminLogin(store.database, owner.key, now);
|
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
|
const denied = (error: unknown) => error instanceof AdminError && error.status === 403;
|
|
store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id);
|
|
await assert.rejects(readAdminOperations(store.database, identity, now), denied);
|
|
store.sqlite.prepare("UPDATE admin_accounts SET role='owner' WHERE id=?").run(owner.id);
|
|
store.sqlite.prepare('UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?').run(now, identity.sessionHash);
|
|
await assert.rejects(readAdminOperations(store.database, identity, now), denied);
|
|
} finally { store.sqlite.close(); }
|
|
});
|
|
|
|
void test('stalled-decision diagnosis samples only live public interactions within its bound', async () => {
|
|
const store = adminStore();
|
|
try {
|
|
const owner = store.provision('owner');
|
|
const login = await adminLogin(store.database, owner.key, now);
|
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
|
const hours = (value: number) => now - value * 3_600_000;
|
|
const add = (code: string, state: unknown, updatedAt: number) =>
|
|
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
|
.run(code, typeof state === 'string' ? state : JSON.stringify(state), 4, updatedAt);
|
|
// Twelve genuine stalled rooms with distinct idle ages plus three other
|
|
// public interaction kinds, oldest first.
|
|
for (let index = 0; index < 12; index++)
|
|
add(`STALL${String(index).padStart(3, '0')}`,
|
|
{ status: 'playing', turn: 3, phase: 5, decision: { kind: 'battlePlan', player: 'a' },
|
|
private: { secret: 'sealed card' } }, hours(7 + index));
|
|
add('EXCLTRUT', { status: 'playing', truthtrance: { stage: 'priority' } }, hours(30));
|
|
add('EXCLPHAS', { status: 'playing', phaseOpening: { passed: [] } }, hours(31));
|
|
add('EXCLRESP', { status: 'playing', response: { kind: 'guildIncome' } }, hours(32));
|
|
// Each exclusion boundary keeps its room out of the diagnosis.
|
|
add('EXCLPASD', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(40));
|
|
store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)').run('EXCLPASD', now);
|
|
add('EXCLREMV', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(41));
|
|
store.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)').run('EXCLREMV', now, now);
|
|
add('EXCLCLOS', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(42));
|
|
store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)').run('EXCLCLOS', now, now);
|
|
add('EXCLARCH', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(43));
|
|
store.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,?,?)').run('EXCLARCH', now, now);
|
|
add('EXCLLOBB', { status: 'lobby', decision: { kind: 'battlePlan' } }, hours(44));
|
|
add('EXCLFRES', { status: 'playing', decision: { kind: 'battlePlan' } }, hours(5));
|
|
add('EXCLIDLE', { status: 'playing', turn: 1, phase: 1 }, hours(45));
|
|
add('EXCLBRKN', '{broken private state', hours(46));
|
|
const before = store.sqlite.prepare('SELECT code,state,version,updated_at FROM rooms ORDER BY code').all();
|
|
const counters = await readAdminOperations(store.database, identity, now);
|
|
assert.equal(counters.stalledRooms, 15);
|
|
assert.equal(counters.oldestStalledChange, hours(32));
|
|
const sample = await readAdminStalledDecisions(store.database, identity, now);
|
|
assert.equal(sample.length, ADMIN_STALL_LIMIT);
|
|
assert.deepEqual(sample.map(row => row.code), ['EXCLRESP', 'EXCLPHAS', 'EXCLTRUT',
|
|
'STALL011', 'STALL010', 'STALL009', 'STALL008', 'STALL007', 'STALL006', 'STALL005']);
|
|
assert.equal(sample[0].kind, 'guildIncome');
|
|
assert.equal(sample[1].kind, 'phaseOpening');
|
|
assert.equal(sample[2].kind, 'truthtrance');
|
|
assert.equal(sample[0].turn, null);
|
|
assert.equal(sample[0].phase, null);
|
|
assert.equal(sample[3].kind, 'battlePlan');
|
|
assert.equal(sample[3].turn, 3);
|
|
assert.equal(sample[3].phase, 5);
|
|
assert.equal(sample[3].idleMs, 18 * 3_600_000);
|
|
assert.ok(sample.every(row => row.idleMs >= ADMIN_STALL_MS));
|
|
assert.equal(JSON.stringify(sample).includes('secret'), false);
|
|
assert.deepEqual(store.sqlite.prepare('SELECT code,state,version,updated_at FROM rooms ORDER BY code').all(), before);
|
|
store.provision('owner', 'Remaining owner');
|
|
store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id);
|
|
await assert.rejects(readAdminStalledDecisions(store.database, identity, now),
|
|
(error: unknown) => error instanceof AdminError && error.status === 403);
|
|
} finally { store.sqlite.close(); }
|
|
});
|
|
|
|
void test('manual integrity check detects orphaned foreign keys without exposing their rows', async () => {
|
|
const store = adminStore();
|
|
try {
|
|
const owner = store.provision('owner');
|
|
const login = await adminLogin(store.database, owner.key, now);
|
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
|
store.sqlite.exec('PRAGMA foreign_keys=OFF');
|
|
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)')
|
|
.run('private-orphan-token', 'GHOSTABC', 'private-player');
|
|
store.sqlite.exec('PRAGMA foreign_keys=ON');
|
|
const before = store.sqlite.prepare('SELECT * FROM seats').all();
|
|
const result = await readAdminIntegrity(store.database, identity, now);
|
|
assert.deepEqual(result, { observedAt: now, sqlite: 'passed', foreignKeys: 'failed' });
|
|
assert.equal(JSON.stringify(result).includes('private'), false);
|
|
assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats').all(), before);
|
|
store.provision('owner', 'Backup owner');
|
|
store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id);
|
|
await assert.rejects(readAdminIntegrity(store.database, identity, now),
|
|
(error: unknown) => error instanceof AdminError && error.status === 403);
|
|
} finally { store.sqlite.close(); }
|
|
});
|
|
|
|
void test('manual integrity check revalidates owner authority before returning results', async () => {
|
|
const store = adminStore();
|
|
try {
|
|
const owner = store.provision('owner');
|
|
store.provision('owner', 'Remaining owner');
|
|
const login = await adminLogin(store.database, owner.key, now);
|
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
|
store.hooks.beforeBatch = async () => {
|
|
store.sqlite.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?").run(owner.id);
|
|
};
|
|
await assert.rejects(readAdminIntegrity(store.database, identity, now),
|
|
(error: unknown) => error instanceof AdminError && error.status === 403);
|
|
} finally { store.sqlite.close(); }
|
|
});
|