Diagnose stalled administrator decisions from the operations page

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.
This commit is contained in:
Quintine committed 2026-10-07 17:51:08 +11:00
1 parent e05a205a47
commit 07c20ae806
10 files changed
+287 -11

No files matched your search

+1 -1
View File
@@ -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/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) {
for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/operations?stalled=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) {
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');
+61 -1
View File
@@ -2,7 +2,7 @@ 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 { readAdminIntegrity, readAdminOperations } from '../db/admin-operations';
import { ADMIN_STALL_LIMIT, ADMIN_STALL_MS, readAdminIntegrity, readAdminOperations, readAdminStalledDecisions } from '../db/admin-operations';
import { adminStore } from './admin-access-fixture';
const now = 10_000;
@@ -32,6 +32,7 @@ void test('owner operations sample counts saved states and backup provenance wit
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);
@@ -57,6 +58,65 @@ void test('stale owner identity, demotion and revoked session cannot sample oper
} 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 {