Add reversible administrator room archiving

Organize closed rooms with archive filters, live-authorized atomic receipts and exact saved retries. Preserve saved games, timestamps, credentials, discussion and AI deadlines. Unarchive leaves play closed; reopen requires unarchive, and removal/restoration preserves both flags.

Verified types, lint, 5379 offline tests, 52 HTTP tests and build. Twenty dedicated administrator HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a single archive after a lost response and refresh, readable recovery text, mobile unarchive and separate reopening, and saved-seat restoration. All 2,049 baseline rooms, 4,348 seats and related records preserved. Authenticated production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
This commit is contained in:
Quintine committed 2026-09-22 12:55:44 +10:00
1 parent 98b1b3261b
commit e918e2e0c0
35 files changed
+3624 -38

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { adminArchiveConfirmation, adminArchiveResponse, clearAdminArchiveRequest, newAdminArchiveRequest, readAdminArchiveRequest, saveAdminArchiveRequest } from '../lib/admin-archive-client';
import { retainAdminRoomRequest } from '../lib/admin-room-control-client';
import { ClientRequestError } from '../lib/client-request';
import type { AdminArchiveView } from '../lib/admin-archive';
const view = (): AdminArchiveView => ({ code: 'ABCD2345', version: 7, archived: false, closed: true, removed: false, revision: 2, archivedAt: 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('archive retry preserves exact intent/version/revision/reason across refresh and isolates account and room', () => {
const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, ' QA archive ');
saveAdminArchiveRequest(tab, 'a', room.code, input);
assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), input);
assert.equal(input.expectedVersion, 7); assert.equal(input.expectedRevision, 2); assert.equal(input.reason, 'QA archive');
assert.equal(readAdminArchiveRequest(tab, 'b', room.code), null);
assert.equal(readAdminArchiveRequest(tab, 'a', 'DIFF2345'), null);
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { reason: 'Different' }, { archived: false }])
assert.throws(() => saveAdminArchiveRequest(tab, 'a', room.code, { ...input, ...change }), /Resolve the saved/);
saveAdminArchiveRequest(tab, 'a', room.code, input);
clearAdminArchiveRequest(tab, 'a', room.code);
assert.equal(readAdminArchiveRequest(tab, 'a', room.code), null);
const reopen = newAdminArchiveRequest({ ...room, archived: true, version: 8, revision: 3 }, false, 'Restore');
saveAdminArchiveRequest(tab, 'a', room.code, reopen);
assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), reopen);
});
void test('corrupt or unwritable archive retry storage fails closed and never replaces the saved record', () => {
const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, 'QA');
assert.throws(() => saveAdminArchiveRequest({ ...tab, setItem() {} }, 'a', room.code, input), /Nothing was sent/);
saveAdminArchiveRequest(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(() => readAdminArchiveRequest(tab, 'a', room.code), /unreadable/);
assert.throws(() => saveAdminArchiveRequest(tab, 'a', room.code, input), /unreadable/);
assert.equal(tab.values.get(key), raw);
}
assert.throws(() => clearAdminArchiveRequest({ ...tab, removeItem() {} }, 'a', room.code), /could not be cleared/);
clearAdminArchiveRequest(tab, 'a', room.code);
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'bad\u007freason'])
assert.throws(() => newAdminArchiveRequest(room, true, reason));
assert.throws(() => newAdminArchiveRequest(room, false, 'No change'));
assert.throws(() => newAdminArchiveRequest({ ...room, removed: true }, true, 'Removed'), /Restore/);
assert.throws(() => newAdminArchiveRequest({ ...room, closed: false }, true, 'Open'), /Close/);
assert.throws(() => newAdminArchiveRequest({ ...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 = newAdminArchiveRequest(room, true, 'QA');
const current = { ...room, version: 12, revision: 4, archived: false, closed: true, removed: false, archivedAt: 100, updatedAt: 200, paused: true };
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: current };
assert.deepEqual(adminArchiveConfirmation(receipt, room.code, input), receipt);
const fresh = { ...receipt, replayed: false, room: { ...room, version: 8, revision: 3, archived: true, archivedAt: 100, updatedAt: 100 } };
assert.deepEqual(adminArchiveConfirmation(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(() => adminArchiveConfirmation(invalid, room.code, input));
});
void test('room availability projection excludes private data and rejects malformed metadata', () => {
const room = view();
assert.deepEqual(adminArchiveResponse(room, room.code), room);
assert.doesNotThrow(() => adminArchiveResponse({ ...room, archived: true, archivedAt: 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, archived: 'true' },
{ ...room, archivedAt: -1 }, { ...room, archivedAt: Number.MAX_SAFE_INTEGER }, { ...room, updatedAt: '100' },
{ ...room, closed: undefined }, { ...room, removed: 1 }, { ...room, paused: undefined }, { ...room, joinLocked: null }])
assert.throws(() => adminArchiveResponse(invalid, room.code));
});
void test('uncertain archive and changed-access responses keep the exact saved request', () => {
const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, 'QA');
saveAdminArchiveRequest(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)) clearAdminArchiveRequest(tab, 'a', room.code);
assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), input);
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
+737
View File
@@ -0,0 +1,737 @@
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 { applyAdminArchive, readAdminArchive } from '../db/admin-archive';
import {
validAdminArchiveInput,
type AdminArchiveInput,
} from '../lib/admin-archive';
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
import { applyAdminClosure, readAdminClosure } from '../db/admin-closure';
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 = [
'room_closures',
'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,
archived = true,
): AdminArchiveInput => ({
operationId: randomUUID(),
expectedVersion,
expectedRevision,
archived,
reason: 'Dedicated QA room archive',
});
function seed(store: Store, code = 'ARCHIVES') {
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);
store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run(code);
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 = () => readAdminArchive(store.database, identity, room.code, now);
const apply = (input = change(version()), at = now) =>
applyAdminArchive(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('archive 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 < '0014')
.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/0014_admin_room_archive.sql', import.meta.url),
'utf8',
),
);
assert.deepEqual(
tables.map((table) => rows(store, table)),
before,
);
assert.deepEqual(rows(store, 'room_archives'), []);
assert.deepEqual(rows(store, 'admin_room_archives'), []);
assert.throws(
() =>
store.sqlite
.prepare(
'INSERT INTO room_archives(room_code,archived,revision,updated_at) VALUES(?,1,0,?)',
)
.run(room.code, now),
/CHECK/,
);
} finally {
store.sqlite.close();
}
});
void test('archive inputs require exact bounded fields and both safe version counters', () => {
const valid = change();
assert.equal(validAdminArchiveInput(valid), true);
for (const invalid of [
null,
[],
{},
{ ...valid, extra: true },
{ ...valid, archived: 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({ archived: true }), {
operationId: valid.operationId,
expectedVersion: 0,
expectedRevision: 0,
reason: 'Reason',
other: true,
}),
])
assert.equal(validAdminArchiveInput(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,
archived: false,
closed: true,
removed: false,
revision: 0,
archivedAt: null,
updatedAt: null,
paused: false,
joinLocked: false,
});
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'room_archives'), []);
if (role === 'viewer') await assert.rejects(f.apply(), status(403));
else assert.equal((await f.apply()).room.archived, true);
await assert.rejects(
readAdminArchive(f.database, f.identity, f.code + '\n', now),
status(400),
);
await assert.rejects(
readAdminArchive(f.database, f.identity, 'ABSENTAA', now),
status(404),
);
await assert.rejects(
readAdminArchive(
f.database,
{ ...f.identity, sessionHash: f.tokenHash },
f.code,
now,
),
status(401),
);
} finally {
f.sqlite.close();
}
}
});
void test('archive and unarchive 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,
archived: true,
closed: true,
removed: false,
revision: 1,
archivedAt: now,
updatedAt: now,
paused: false,
joinLocked: false,
});
assert.equal(f.row().state, old.state);
assert.equal(f.row().updated_at, old.updated_at);
const reopend = await f.apply(change(1, 1, false), now + 5000);
assert.deepEqual(reopend.room, {
...result.room,
version: 2,
archived: false,
revision: 2,
archivedAt: null,
updatedAt: now + 5000,
});
assert.equal(f.row().state, old.state);
assert.equal(f.row().updated_at, old.updated_at);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(
f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.code),
untouched,
);
assert.equal(rows(f, 'room_archives').length, 1);
const audit = rows(f, 'admin_room_archives');
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('archiving and unarchiving 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()).archived, true);
await f.apply(change(1, 1, false));
assert.equal(f.row().state, state);
} 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, archived: 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(
applyAdminArchive(f.database, identity, f.code, input, now),
status(409),
);
seed(f, 'KEEPSAFE');
await assert.rejects(
applyAdminArchive(f.database, f.identity, 'KEEPSAFE', input, now),
status(409),
);
assert.deepEqual(f.row(), before);
assert.equal(rows(f, 'admin_room_archives').length, 2);
} finally {
f.sqlite.close();
}
});
void test('new no-op and stale requests reject without writes; both room version and archive 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_archives'), []);
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_archives').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_archives');
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_archives'), 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_archives').length, 1);
} finally {
f.sqlite.close();
}
}
});
void test('audit, game fence and archive metadata roll back together at every failed persistence stage', async () => {
for (const table of ['admin_room_archives', 'rooms', 'room_archives']) {
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 archive failure'); END;`,
);
await assert.rejects(f.apply(), /QA archive failure/);
assert.deepEqual(f.row(), before);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(rows(f, 'admin_room_archives'), []);
assert.deepEqual(rows(f, 'room_archives'), []);
} finally {
f.sqlite.close();
}
}
});
void test('archived games and exact receipts survive database reopen without restoring historical credentials', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-archive-'));
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 archived = await applyAdminArchive(
store.database,
identity,
room.code,
input,
now,
);
store.sqlite.close();
store = adminStore(join(dir, 'rooms.sqlite'), false);
assert.deepEqual(
await applyAdminArchive(store.database, identity, room.code, input, now),
{ ...archived, replayed: true },
);
await applyAdminArchive(
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_archives');
store.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(room.code);
assert.deepEqual(rows(store, 'admin_room_archives'), audit);
} finally {
store.sqlite.close();
rmSync(dir, { recursive: true, force: true });
}
});
void test('archive requires closure; unarchive leaves closure and reopening requires unarchive first', async () => {
const f = await fixture();
try {
const native = await readAdminClosure(f.database, f.identity, f.code, now);
const reopen = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: native.revision, closed: false, reason: 'QA reopening' };
const oldState = f.row().state;
await applyAdminClosure(f.database, f.identity, f.code, reopen, now);
const open = f.row();
await assert.rejects(f.apply(change(f.version())), /Close this room/);
assert.deepEqual(f.row(), open);
const close = { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 2, closed: true };
await applyAdminClosure(f.database, f.identity, f.code, close, now);
const input = change(f.version());
await f.apply(input);
const archived = f.row();
assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).archived, true);
await assert.rejects(applyAdminClosure(f.database, f.identity, f.code, { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 3 }, now), /Unarchive/);
assert.deepEqual(f.row(), archived);
const oldClosureReceipt = await applyAdminClosure(f.database, f.identity, f.code, close, now);
assert.equal(oldClosureReceipt.replayed, true);
assert.equal(oldClosureReceipt.room.archived, true);
const unarchive = change(f.version(), 1, false);
await f.apply(unarchive);
assert.equal((await f.read()).closed, true);
assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).archived, undefined);
await applyAdminClosure(f.database, f.identity, f.code, { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 3 }, now);
assert.equal((await f.read()).closed, false);
assert.equal(f.row().state, oldState);
const after = f.row();
const receipt = await f.apply(input);
assert.equal(receipt.replayed, true); assert.equal(receipt.room.closed, false); assert.equal(receipt.room.archived, false);
assert.deepEqual(f.row(), after);
} finally { f.sqlite.close(); }
});
void test('removal and restoration preserve archived closure and every saved AI deadline and credential', 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);
f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES (?,?,1,1,100)').run(f.code, Number(paused));
seedDependents(f, f);
const original = f.row(), secure = secureRows(f), controls = rows(f, 'room_controls');
const input = change();
await f.apply(input);
const remove = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA removal' };
await applyAdminRemoval(f.database, f.identity, f.code, remove, now);
assert.equal((await readAdminRemoval(f.database, f.identity, f.code, now)).archived, true);
const removed = f.row();
const receipt = await f.apply(input); assert.equal(receipt.replayed, true); assert.equal(receipt.room.removed, true);
await assert.rejects(f.apply(change(f.version(), 1, false)), /Restore/);
assert.deepEqual(f.row(), removed);
await applyAdminRemoval(f.database, f.identity, f.code, { ...remove, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 1, removed: false }, now + 10000);
assert.equal((await f.read()).archived, true); assert.equal((await f.read()).closed, true);
const beforeUnarchive = f.row();
await f.apply(change(f.version(), 1, false), now + 20000);
assert.equal(f.row().state, original.state);
assert.equal(f.row().updated_at, beforeUnarchive.updated_at);
assert.deepEqual(secureRows(f), secure); assert.deepEqual(rows(f, 'room_controls'), controls);
assert.equal((await f.read()).closed, true);
} finally { f.sqlite.close(); }
}
});
void test('reopening and archiving cannot both win a transaction race, in either order', async () => {
for (const archiveFirst of [false, true]) {
const f = await fixture();
try {
const before = f.row().state;
const archive = () => f.apply(change());
const reopen = () => applyAdminClosure(f.database, f.identity, f.code, { operationId: randomUUID(), expectedVersion: 0, expectedRevision: 1, closed: false, reason: 'QA reopen race' }, now);
const gate = gateBatch(f);
const pending = (archiveFirst ? reopen() : archive()).then(() => null, (error: unknown) => error);
await gate.waiting;
await (archiveFirst ? archive() : reopen()); gate.release();
assert.ok(status(409)(await pending));
const current = await f.read(); assert.equal(current.archived, archiveFirst); assert.equal(current.closed, archiveFirst);
assert.equal(f.row().state, before); assert.equal(f.version(), 1);
assert.equal(rows(f, 'admin_room_archives').length + rows(f, 'admin_room_closures').length, 1);
} finally { f.sqlite.close(); }
}
});
void test('removal fences a queued archive or unarchive without erasing a prior exact receipt', async () => {
for (const unarchive of [false, true]) {
const f = await fixture();
try {
const original = change();
if (unarchive) await f.apply(original);
const gate = gateBatch(f);
const pending = f.apply(change(f.version(), unarchive ? 1 : 0, !unarchive)).then(() => null, (error: unknown) => error);
await gate.waiting;
await applyAdminRemoval(f.database, f.identity, f.code, { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA remove race' }, now);
const removed = f.row(); gate.release();
assert.ok(status(409)(await pending)); assert.deepEqual(f.row(), removed);
assert.equal((await f.read()).archived, unarchive);
if (unarchive) assert.equal((await f.apply(original)).replayed, true);
} finally { f.sqlite.close(); }
}
});
+8 -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/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`]) {
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`, `/api/admin/rooms/${room}/archive`]) {
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');
@@ -50,6 +50,13 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.equal(closure.status, 401);
assert.equal(closure.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await closure.json()), ['error']);
const archive = await fetch(`${base}/api/admin/rooms/${room}/archive`, {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, archived: true, reason: 'Denied archive QA' }),
});
assert.equal(archive.status, 401);
assert.equal(archive.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await archive.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' }),
+10
View File
@@ -82,3 +82,13 @@ void test('uncertain closure and changed-access responses keep the exact saved r
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
void test('closure projections and exact receipts retain only the explicit archived flag', () => {
const room = view(), input = newAdminClosureRequest(room, true, 'QA');
assert.deepEqual(adminClosureResponse({ ...room, archived: true }, room.code), { ...room, archived: true });
for (const archived of [false, undefined, null, 1, 'true'])
assert.throws(() => adminClosureResponse({ ...room, archived }, room.code));
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: { ...room, version: 12, revision: 4, archived: true } };
assert.deepEqual(adminClosureConfirmation(receipt, room.code, input), receipt);
assert.throws(() => newAdminClosureRequest({ ...room, closed: true, archived: true }, false, 'Reopen'), /Unarchive/);
});
+32
View File
@@ -114,3 +114,35 @@ void test('closed rooms remain in the directory but are excluded from running ro
assert.deepEqual((await read('availability=running')).rooms.map(room => room.code), ['OPENROOM']);
} finally { f.sqlite.close(); }
});
void test('archive filters compose with removal, availability, private-safe search and stable pagination', async () => {
const f = await fixture();
try {
insert(f.sqlite, 'OPENROOM', state('Open QA'));
for (let n = 0; n < 28; n++) {
const code = `AR${String(n).padStart(6, '0')}`;
insert(f.sqlite, code, state(n === 0 ? 'Archived %_ QA' : 'Archived QA'));
f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run(code);
f.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES (?,1,1,100,100)').run(code);
if (n === 27) f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,100,100)').run(code);
}
const read = (q = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(q));
assert.deepEqual((await read()).rooms.map(room => room.code), ['OPENROOM']);
assert.equal((await read('q=Archived')).total, 0);
const first = await read('archive=archived'), second = await read('archive=archived&page=2');
assert.equal(first.total, 27); assert.equal(first.rooms.length, 25); assert.equal(second.rooms.length, 2);
assert.equal(new Set([...first.rooms, ...second.rooms].map(room => room.code)).size, 27);
assert.ok(first.rooms.every(room => room.archived && room.control.closed && !room.removed));
assert.equal(JSON.stringify(first).includes('SECRET'), false);
assert.equal((await read('archive=all')).total, 28);
assert.equal((await read('removal=all')).total, 1);
assert.equal((await read('removal=removed')).total, 0);
assert.equal((await read('archive=all&removal=all')).total, 29);
assert.equal((await read('archive=archived&removal=removed')).total, 1);
assert.equal((await read('archive=archived&availability=closed')).total, 27);
assert.equal((await read('archive=all&availability=running')).total, 1);
assert.equal((await read('archive=archived&q=%25_')).total, 1);
assert.equal((await read('archive=all&q=SECRET')).total, 0);
for (const q of ['archive=unknown', 'archive=all&archive=archived']) await assert.rejects(read(q), (e: unknown) => e instanceof AdminError && e.status === 400);
} finally { f.sqlite.close(); }
});
+9
View File
@@ -81,3 +81,12 @@ void test('uncertain removal and changed-access responses keep the exact saved r
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
void test('removal projections and exact receipts retain only the explicit archived flag', () => {
const room = view(), input = newAdminRemovalRequest(room, true, 'QA');
assert.deepEqual(adminRemovalResponse({ ...room, archived: true }, room.code), { ...room, archived: true });
for (const archived of [false, undefined, null, 1, 'true'])
assert.throws(() => adminRemovalResponse({ ...room, archived }, room.code));
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: { ...room, version: 12, revision: 4, archived: true } };
assert.deepEqual(adminRemovalConfirmation(receipt, room.code, input), receipt);
});