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:
1 parent
e05a205a47
commit
07c20ae806
10 files changed
+287
-11
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/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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user