Add administrator sign-in and private-safe room directory

Connect personal operator-provisioned admin keys, persisted roles and eight-hour sessions, current/all-session revocation and a filtered room directory. Additive schema protects final owner, revokes disabled accounts and records safe audit history. No game state changes; remaining administration scope stays explicit.

Verified: typecheck, lint, 5196 offline tests, 52 HTTP tests, production build; authenticated local HTTP and desktop/mobile browser checks. Independent privacy/security review fixed malformed nested roster searches and read-time session revocation. Preserved 1,671 prior rooms and 3,538 seats. Container/deployed acceptance follows publication.
This commit is contained in:
Quintine committed 2026-09-22 09:36:11 +10:00
1 parent 36a03bda9c
commit c2d392e155
27 files changed
+3164 -13

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'node:url';
import { adminLogin, requireAdmin } from '../db/admin-access';
import { adminStore, sha256 } from './admin-access-fixture';
const checkout = fileURLToPath(new URL('../', import.meta.url));
const cli = fileURLToPath(
new URL('../tools/admin-access.mjs', import.meta.url),
);
const run = (...args: string[]) =>
spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' });
void test('operator CLI writes a unique private key outside the checkout and SQL provisions with safely escaped names', async () => {
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-cli-'));
const output = join(directory, 'owner');
const store = adminStore();
try {
assert.ok(store.sqlite instanceof DatabaseSync);
const name = "O'Brien'); DROP TABLE rooms; --";
const result = run('--name', name, '--role', 'owner', '--out', output);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readdirSync(output).sort(), [
'access-key.txt',
'provision.sql',
]);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const file of ['access-key.txt', 'provision.sql'])
assert.equal(statSync(join(output, file)).mode & 0o777, 0o600);
const key = readFileSync(join(output, 'access-key.txt'), 'utf8').trim();
const sql = readFileSync(join(output, 'provision.sql'), 'utf8');
assert.match(key, /^dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}$/);
assert.equal(sql.includes(key), false);
assert.equal(sql.includes(key.split('.').at(-1)!), false);
assert.equal(sql.includes(sha256(key)), true);
assert.equal((result.stdout + result.stderr).includes(key), false);
assert.equal((result.stdout + result.stderr).includes(sha256(key)), false);
store.sqlite.exec(sql);
assert.equal(
store.sqlite.prepare('SELECT name FROM admin_accounts').get()!.name,
name,
);
assert.equal(
store.sqlite.prepare('SELECT count(*) AS n FROM rooms').get()!.n,
0,
);
assert.equal(
store.sqlite
.prepare(
"SELECT count(*) AS n FROM admin_audit WHERE action = 'provision'",
)
.get()!.n,
1,
);
const login = await adminLogin(store.database, key, 1000);
await requireAdmin(store.database, login.token, ['owner'], 2000);
assert.throws(() => store.sqlite.exec(sql), /UNIQUE constraint/);
assert.equal(
store.sqlite
.prepare(
"SELECT count(*) AS n FROM admin_audit WHERE action = 'provision'",
)
.get()!.n,
1,
);
} finally {
store.sqlite.close();
rmSync(directory, { recursive: true, force: true });
}
});
void test('operator CLI revocation needs only a safe account ID, revokes sessions and refuses the final enabled owner', async () => {
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-revoke-cli-'));
const store = adminStore();
try {
const first = store.provision();
const second = store.provision();
const login = await adminLogin(store.database, first.key, 1000);
const output = join(directory, 'revoke');
const result = run('--revoke', first.id, '--out', output);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readdirSync(output), ['revoke.sql']);
const sql = readFileSync(join(output, 'revoke.sql'), 'utf8');
assert.equal(
(sql + result.stdout + result.stderr).includes(first.key),
false,
);
assert.equal(sql.includes('key_hash'), false);
store.sqlite.exec(sql);
await assert.rejects(
requireAdmin(store.database, login.token, undefined, 2000),
);
await assert.rejects(adminLogin(store.database, first.key, 2000));
assert.equal(
store.sqlite
.prepare(
"SELECT count(*) AS n FROM admin_audit WHERE action = 'revoke'",
)
.get()!.n,
1,
);
store.sqlite.exec(sql);
assert.equal(
store.sqlite
.prepare(
"SELECT count(*) AS n FROM admin_audit WHERE action = 'revoke'",
)
.get()!.n,
1,
);
const lastOutput = join(directory, 'last');
assert.equal(run('--revoke', second.id, '--out', lastOutput).status, 0);
assert.throws(
() =>
store.sqlite.exec(readFileSync(join(lastOutput, 'revoke.sql'), 'utf8')),
/final enabled administrator owner/,
);
assert.equal(
store.sqlite
.prepare('SELECT enabled FROM admin_accounts WHERE id = ?')
.get(second.id)!.enabled,
1,
);
} finally {
store.sqlite.close();
rmSync(directory, { recursive: true, force: true });
}
});
void test('operator CLI refuses existing paths, checkout paths and symlink aliases without overwriting files', () => {
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-paths-'));
const inside = join(checkout, '.admin-test-output');
try {
assert.equal(existsSync(inside), false);
const direct = run('--name', 'Owner', '--role', 'owner', '--out', inside);
assert.equal(direct.status, 1);
assert.equal(existsSync(inside), false);
const alias = join(directory, 'checkout-alias');
symlinkSync(checkout, alias, 'dir');
const viaAlias = run(
'--name',
'Owner',
'--role',
'owner',
'--out',
join(alias, '.admin-test-output'),
);
assert.equal(viaAlias.status, 1);
assert.equal(existsSync(inside), false);
const existing = join(directory, 'existing');
mkdirSync(existing, { mode: 0o700 });
writeFileSync(join(existing, 'access-key.txt'), 'existing private value', {
mode: 0o600,
});
const reuse = run('--name', 'Owner', '--role', 'owner', '--out', existing);
assert.equal(reuse.status, 1);
assert.equal(
readFileSync(join(existing, 'access-key.txt'), 'utf8'),
'existing private value',
);
assert.equal(
(reuse.stdout + reuse.stderr).includes('existing private value'),
false,
);
assert.deepEqual(readdirSync(existing), ['access-key.txt']);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
void test('operator CLI rejects malformed names, roles, IDs and incomplete commands before generating any credential', () => {
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-input-'));
try {
const output = join(directory, 'new');
for (const args of [
[],
['--name', 'Owner', '--out', output],
['--name', 'Owner', '--role', 'superuser', '--out', output],
['--name', ' ', '--role', 'owner', '--out', output],
['--name', 'Owner\nInjected', '--role', 'owner', '--out', output],
['--name', 'x'.repeat(81), '--role', 'owner', '--out', output],
[
'--name',
'Owner',
'--role',
'owner',
'--role',
'viewer',
'--out',
output,
],
['--name', 'Owner', '--role', 'owner', '--out', 'relative-output'],
['--revoke', "x' OR 1=1;--", '--out', output],
[
'--revoke',
'10000000-0000-4000-8000-000000000001',
'--name',
'Owner',
'--out',
output,
],
]) {
assert.equal(run(...args).status, 1);
assert.equal(existsSync(output), false);
}
const help = run('--help');
assert.equal(help.status, 0);
assert.match(help.stdout, /No database is contacted/);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+93
View File
@@ -0,0 +1,93 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import type { AdminRole } from '../db/admin-access';
export function adminStore(path = ':memory:', migrate = true) {
const sqlite = new DatabaseSync(path);
sqlite.exec('PRAGMA foreign_keys = ON');
if (migrate) {
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((file) => file.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
}
const hooks: { beforeBatch?: () => Promise<void> } = {};
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async all() {
return {
results: sqlite.prepare(this.sql).all(...this.values),
success: true,
};
}
async run() {
const statement = sqlite.prepare(this.sql);
if (statement.columns().length) {
return {
results: statement.all(...this.values),
meta: { changes: 0 },
success: true,
};
}
const result = statement.run(...this.values);
return {
results: [],
meta: { changes: Number(result.changes) },
success: true,
};
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
await hooks.beforeBatch?.();
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
} as unknown as D1Database;
function provision(role: AdminRole = 'owner', name = 'Admin') {
const id = randomUUID();
const key = `dune-admin.${id}.${randomBytes(32).toString('hex')}`;
sqlite
.prepare(`INSERT INTO admin_accounts
(id,name,role,key_hash,created_at,updated_at) VALUES (?,?,?,?,?,?)`)
.run(id, name, role, sha256(key), 1000, 1000);
return { id, key, name, role };
}
return { sqlite, database, hooks, provision };
}
export const sha256 = (value: string) =>
createHash('sha256').update(value).digest('hex');
export function pauseAdminBatch(store: ReturnType<typeof adminStore>) {
let entered!: () => void;
let release!: () => void;
const waiting = new Promise<void>((resolve) => (entered = resolve));
const gate = new Promise<void>((resolve) => (release = resolve));
store.hooks.beforeBatch = async () => {
delete store.hooks.beforeBatch;
entered();
await gate;
};
return { waiting, release };
}
+567
View File
@@ -0,0 +1,567 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import {
ADMIN_SESSION_DURATION_MS,
AdminError,
adminLogin,
adminLogout,
adminLogoutAll,
requireAdmin,
} from '../db/admin-access';
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
const denied =
(status = 401) =>
(error: unknown) =>
error instanceof AdminError && error.status === status;
function snapshot(store: ReturnType<typeof adminStore>) {
return JSON.stringify(
['admin_accounts', 'admin_sessions', 'admin_audit'].map((table) =>
store.sqlite.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(),
),
);
}
void test('admin migration is additive and preserves preexisting room and seat bytes', () => {
const sqlite = new DatabaseSync(':memory:');
try {
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((file) => file.endsWith('.sql') && file < '0008')
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
sqlite
.prepare(
'INSERT INTO rooms (code,state,version,updated_at) VALUES (?,?,?,?)',
)
.run('PRIOR001', '{"private":"sealed","version":17}', 17, 9000);
sqlite
.prepare(
'INSERT INTO seats (token_hash,revoked,room_code,player_id) VALUES (?,?,?,?)',
)
.run('c1'.repeat(32), 0, 'PRIOR001', 'saved-player');
const before = JSON.stringify([
sqlite.prepare('SELECT * FROM rooms').all(),
sqlite.prepare('SELECT * FROM seats').all(),
]);
sqlite.exec(
readFileSync(
new URL('../drizzle/0008_admin_access.sql', import.meta.url),
'utf8',
),
);
assert.equal(
JSON.stringify([
sqlite.prepare('SELECT * FROM rooms').all(),
sqlite.prepare('SELECT * FROM seats').all(),
]),
before,
);
assert.equal(
sqlite
.prepare(
"SELECT count(*) AS n FROM sqlite_master WHERE name = 'rooms_updated_at' AND type = 'index'",
)
.get()!.n,
1,
);
assert.equal(
sqlite.prepare('SELECT count(*) AS n FROM admin_accounts').get()!.n,
0,
);
} finally {
sqlite.close();
}
});
void test('malformed credentials fail before querying D1, including a trailing line break', async () => {
const database = {
prepare: () => {
throw new Error('Unexpected database query');
},
} as unknown as D1Database;
const key = `dune-admin.10000000-0000-4000-8000-000000000001.${'a'.repeat(64)}`;
for (const malformed of [`${key}\n`, `${key}\r\n`, ` ${key}`, {}, null])
await assert.rejects(adminLogin(database, malformed, 1000), denied());
for (const malformed of ['a'.repeat(64) + '\n', 'A'.repeat(64), undefined])
await assert.rejects(
requireAdmin(database, malformed, undefined, 1000),
denied(),
);
});
void test('admin login persists only hashes, preserves audit privacy and survives a database restart', async () => {
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-restart-'));
const path = join(directory, 'admin.sqlite');
let store = adminStore(path);
try {
assert.ok(store.sqlite instanceof DatabaseSync);
const owner = store.provision();
const login = await adminLogin(store.database, owner.key, 1000);
assert.deepEqual(login.admin, {
id: owner.id,
name: owner.name,
role: owner.role,
});
assert.equal(login.expiresAt, 1000 + ADMIN_SESSION_DURATION_MS);
assert.match(login.token, /^[0-9a-f]{64}$/);
const second = await adminLogin(store.database, owner.key, 2000);
assert.notEqual(second.token, login.token);
const saved = snapshot(store);
assert.equal(saved.includes(owner.key), false);
assert.equal(saved.includes(login.token), false);
assert.equal(saved.includes(second.token), false);
assert.ok(saved.includes(sha256(owner.key)));
assert.ok(saved.includes(sha256(login.token)));
const audit = JSON.stringify(
store.sqlite.prepare('SELECT * FROM admin_audit').all(),
);
assert.equal(audit.includes(sha256(owner.key)), false);
assert.equal(audit.includes(sha256(login.token)), false);
assert.equal(audit.includes('sessionId'), true);
store.sqlite.close();
store = adminStore(path, false);
assert.deepEqual(
await requireAdmin(store.database, login.token, ['owner'], 3000),
{
...login.admin,
sessionHash: sha256(login.token),
},
);
} finally {
store.sqlite.close();
rmSync(directory, { recursive: true, force: true });
}
});
void test('absolute expiry cannot be extended by authenticated reads', async () => {
const store = adminStore();
try {
const owner = store.provision();
const login = await adminLogin(store.database, owner.key, 5000);
await requireAdmin(
store.database,
login.token,
undefined,
login.expiresAt - 1,
);
const before = snapshot(store);
await assert.rejects(
requireAdmin(store.database, login.token, undefined, login.expiresAt),
denied(),
);
await assert.rejects(
requireAdmin(store.database, login.token, undefined, login.expiresAt + 1),
denied(),
);
assert.equal(snapshot(store), before);
} finally {
store.sqlite.close();
}
});
void test('roles are checked against the current account and ordinary saved-seat cookies never grant admin access', async () => {
const store = adminStore();
try {
const owner = store.provision();
store.provision('owner', 'Other owner');
const login = await adminLogin(store.database, owner.key, 1000);
store.sqlite
.prepare("UPDATE admin_accounts SET role = 'viewer' WHERE id = ?")
.run(owner.id);
assert.equal(
(await requireAdmin(store.database, login.token, ['viewer'], 2000)).role,
'viewer',
);
await assert.rejects(
requireAdmin(store.database, login.token, ['owner', 'operator'], 2000),
denied(403),
);
await assert.rejects(
requireAdmin(store.database, login.token, [], 2000),
denied(403),
);
const seatToken = 'ea'.repeat(32);
store.sqlite
.prepare('INSERT INTO rooms (code,state,updated_at) VALUES (?,?,?)')
.run('ADMINQA1', '{"saved":"private"}', 1000);
store.sqlite
.prepare(
'INSERT INTO seats (token_hash,room_code,player_id) VALUES (?,?,?)',
)
.run(sha256(seatToken), 'ADMINQA1', owner.id);
const before = snapshot(store);
await assert.rejects(
requireAdmin(store.database, seatToken, undefined, 2000),
denied(),
);
await assert.rejects(adminLogin(store.database, seatToken, 2000), denied());
assert.equal(snapshot(store), before);
assert.equal(
store.sqlite.prepare('SELECT state FROM rooms').get()!.state,
'{"saved":"private"}',
);
} finally {
store.sqlite.close();
}
});
void test('malformed and incorrect credentials are rejected without stored mutation or secret-bearing errors', async () => {
const store = adminStore();
try {
const owner = store.provision();
const before = snapshot(store);
for (const key of [
null,
undefined,
123,
{},
[],
'',
'shared-password',
owner.key.toUpperCase(),
` ${owner.key}`,
`${owner.key}\n`,
owner.key.slice(0, -1),
`dune-admin.${owner.id}.${'0'.repeat(64)}`,
]) {
await assert.rejects(
adminLogin(store.database, key, 1000),
(error: unknown) => {
assert.ok(denied()(error));
assert.equal(String(error).includes(owner.key), false);
return true;
},
);
}
for (const token of [
undefined,
'',
owner.key,
'a'.repeat(63),
'A'.repeat(64),
'a'.repeat(65),
'a'.repeat(64) + '\n',
])
await assert.rejects(
requireAdmin(store.database, token, undefined, 1000),
denied(),
);
assert.equal(snapshot(store), before);
} finally {
store.sqlite.close();
}
});
void test('single-session logout is persisted and idempotently audited without revoking the access key', async () => {
const store = adminStore();
try {
const owner = store.provision();
const first = await adminLogin(store.database, owner.key, 1000);
const second = await adminLogin(store.database, owner.key, 1000);
await adminLogout(store.database, first.token, 2000);
await assert.rejects(
requireAdmin(store.database, first.token, undefined, 2000),
denied(),
);
await requireAdmin(store.database, second.token, undefined, 2000);
const beforeRetry = snapshot(store);
await adminLogout(store.database, first.token, 3000);
await adminLogout(store.database, 'malformed', 3000);
assert.equal(snapshot(store), beforeRetry);
await adminLogin(store.database, owner.key, 3000);
assert.equal(
store.sqlite
.prepare(
"SELECT count(*) AS n FROM admin_audit WHERE action = 'logout'",
)
.get()!.n,
1,
);
} finally {
store.sqlite.close();
}
});
void test('logout-all revokes only the authenticated account and its access key can open a fresh session', async () => {
const store = adminStore();
try {
const owner = store.provision();
const other = store.provision('viewer');
const first = await adminLogin(store.database, owner.key, 1000);
const second = await adminLogin(store.database, owner.key, 1000);
const observer = await adminLogin(store.database, other.key, 1000);
const identity = await requireAdmin(
store.database,
first.token,
undefined,
1000,
);
const beforeForged = snapshot(store);
await assert.rejects(
adminLogoutAll(store.database, { ...identity, id: other.id }, 2000),
denied(),
);
assert.equal(snapshot(store), beforeForged);
await adminLogoutAll(store.database, identity, 2000);
for (const token of [first.token, second.token])
await assert.rejects(
requireAdmin(store.database, token, undefined, 2000),
denied(),
);
await requireAdmin(store.database, observer.token, undefined, 2000);
const beforeRetry = snapshot(store);
await assert.rejects(
adminLogoutAll(store.database, identity, 3000),
denied(),
);
assert.equal(snapshot(store), beforeRetry);
const fresh = await adminLogin(store.database, owner.key, 3000);
await requireAdmin(store.database, fresh.token, undefined, 3000);
assert.equal(
store.sqlite
.prepare(
"SELECT count(*) AS n FROM admin_audit WHERE action = 'logout_all'",
)
.get()!.n,
1,
);
} finally {
store.sqlite.close();
}
});
void test('logout-all rechecks expiry inside its mutation batch', async () => {
const store = adminStore();
try {
const owner = store.provision();
const login = await adminLogin(store.database, owner.key, 1000);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
1000,
);
const before = snapshot(store);
await assert.rejects(
adminLogoutAll(store.database, identity, login.expiresAt),
denied(),
);
assert.equal(snapshot(store), before);
} finally {
store.sqlite.close();
}
});
void test('a concurrent logout-all fences an already-authorized login before session creation', async () => {
const store = adminStore();
try {
const owner = store.provision();
const current = await adminLogin(store.database, owner.key, 1000);
const identity = await requireAdmin(
store.database,
current.token,
undefined,
1000,
);
const gate = pauseAdminBatch(store);
const pending = adminLogin(store.database, owner.key, 2000);
await gate.waiting;
await adminLogoutAll(store.database, identity, 2000);
const afterRevoke = snapshot(store);
gate.release();
await assert.rejects(pending, denied());
assert.equal(snapshot(store), afterRevoke);
assert.equal(
store.sqlite.prepare('SELECT count(*) AS n FROM admin_sessions').get()!.n,
1,
);
} finally {
store.sqlite.close();
}
});
void test('concurrent account disable and reenable cannot resurrect an in-flight login or existing sessions', async () => {
const store = adminStore();
try {
const owner = store.provision();
store.provision('owner');
const current = await adminLogin(store.database, owner.key, 1000);
const gate = pauseAdminBatch(store);
const pending = adminLogin(store.database, owner.key, 2000);
await gate.waiting;
store.sqlite
.prepare('UPDATE admin_accounts SET enabled = 0 WHERE id = ?')
.run(owner.id);
await assert.rejects(
requireAdmin(store.database, current.token, undefined, 2000),
denied(),
);
await assert.rejects(adminLogin(store.database, owner.key, 2000), denied());
store.sqlite
.prepare('UPDATE admin_accounts SET enabled = 1 WHERE id = ?')
.run(owner.id);
const afterDisable = snapshot(store);
gate.release();
await assert.rejects(pending, denied());
await assert.rejects(
requireAdmin(store.database, current.token, undefined, 2000),
denied(),
);
assert.equal(snapshot(store), afterDisable);
assert.equal(
store.sqlite
.prepare(
"SELECT count(*) AS n FROM admin_audit WHERE action = 'revoke'",
)
.get()!.n,
1,
);
} finally {
store.sqlite.close();
}
});
void test('a concurrent browser logout denies stale logout-all without revoking other browser sessions', async () => {
const store = adminStore();
try {
const owner = store.provision();
const current = await adminLogin(store.database, owner.key, 1000);
const other = await adminLogin(store.database, owner.key, 1000);
const identity = await requireAdmin(
store.database,
current.token,
undefined,
1000,
);
const gate = pauseAdminBatch(store);
const pending = adminLogoutAll(store.database, identity, 2000);
await gate.waiting;
await adminLogout(store.database, current.token, 2000);
const afterLogout = snapshot(store);
gate.release();
await assert.rejects(pending, denied());
assert.equal(snapshot(store), afterLogout);
await requireAdmin(store.database, other.token, undefined, 2000);
} finally {
store.sqlite.close();
}
});
void test('concurrent logout-all calls commit a single generation advance and audit event', async () => {
const store = adminStore();
try {
const owner = store.provision();
const login = await adminLogin(store.database, owner.key, 1000);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
1000,
);
const gate = pauseAdminBatch(store);
const pending = adminLogoutAll(store.database, identity, 2000);
await gate.waiting;
await adminLogoutAll(store.database, identity, 2000);
const afterWinner = snapshot(store);
gate.release();
await assert.rejects(pending, denied());
assert.equal(snapshot(store), afterWinner);
assert.equal(
store.sqlite
.prepare('SELECT session_generation FROM admin_accounts WHERE id = ?')
.get(owner.id)!.session_generation,
1,
);
} finally {
store.sqlite.close();
}
});
void test('audit failures roll back both session creation and logout-all generation changes', async () => {
const store = adminStore();
try {
const owner = store.provision();
store.sqlite.exec(
"CREATE TRIGGER fail_login_audit BEFORE INSERT ON admin_audit WHEN NEW.action = 'login' BEGIN SELECT RAISE(ABORT, 'audit failure'); END",
);
const beforeLogin = snapshot(store);
await assert.rejects(
adminLogin(store.database, owner.key, 1000),
/audit failure/,
);
assert.equal(snapshot(store), beforeLogin);
store.sqlite.exec('DROP TRIGGER fail_login_audit');
const login = await adminLogin(store.database, owner.key, 1000);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
1000,
);
store.sqlite.exec(
"CREATE TRIGGER fail_logout_audit BEFORE INSERT ON admin_audit WHEN NEW.action = 'logout_all' BEGIN SELECT RAISE(ABORT, 'audit failure'); END",
);
const beforeLogout = snapshot(store);
await assert.rejects(
adminLogoutAll(store.database, identity, 2000),
/audit failure/,
);
assert.equal(snapshot(store), beforeLogout);
await requireAdmin(store.database, login.token, undefined, 2000);
} finally {
store.sqlite.close();
}
});
void test('operator revocation protects the final enabled owner and persists account, session and audit changes atomically', async () => {
const store = adminStore();
try {
const first = store.provision();
const second = store.provision('owner', 'Second owner');
const login = await adminLogin(store.database, first.key, 1000);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
1000,
);
store.sqlite
.prepare('UPDATE admin_accounts SET enabled = 0 WHERE id = ?')
.run(first.id);
await assert.rejects(
requireAdmin(store.database, login.token, undefined, 2000),
denied(),
);
await assert.rejects(
adminLogoutAll(store.database, identity, 2000),
denied(),
);
const beforeDenied = snapshot(store);
for (const sql of [
'UPDATE admin_accounts SET enabled = 0 WHERE id = ?',
"UPDATE admin_accounts SET role = 'operator' WHERE id = ?",
'DELETE FROM admin_accounts WHERE id = ?',
])
assert.throws(
() => store.sqlite.prepare(sql).run(second.id),
/final enabled administrator owner/,
);
assert.equal(snapshot(store), beforeDenied);
assert.equal(
store.sqlite
.prepare(
'SELECT count(*) AS n FROM admin_sessions WHERE admin_id = ? AND revoked_at IS NULL',
)
.get(first.id)!.n,
0,
);
} finally {
store.sqlite.close();
}
});
+37
View File
@@ -0,0 +1,37 @@
// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
void test('admin HTTP denies anonymous, forged admin, room-host and spoofed identity-header access', async () => {
const created = await fetch(base + '/api/rooms', {
method: 'POST', headers: { 'Content-Type': 'application/json', origin: base },
body: JSON.stringify({ name: 'Admin boundary QA', faction: 'atreides', advanced: false, expansions: [] }),
});
assert.equal(created.status, 201);
const cookie = created.headers.get('set-cookie')?.split(';')[0];
assert.ok(cookie);
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']) {
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');
assert.deepEqual(Object.keys(await result.json()), ['error']);
}
}
});
void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentials without setting sessions', async () => {
for (const [origin, body, expected] of [
['', '{}', 403], ['https://foreign.invalid', '{}', 403],
[base, ' '.repeat(2049), 413], [base, JSON.stringify({ action: 'login', key: 'invalid' }), 401],
] as const) {
const result = await fetch(base + '/api/admin/session', {
method: 'POST', headers: { 'Content-Type': 'application/json', ...(origin ? { origin } : {}) }, body,
signal: AbortSignal.timeout(15000),
});
assert.equal(result.status, expected);
assert.equal(result.headers.get('set-cookie'), null);
if (expected !== 403) assert.equal(result.headers.get('cache-control'), 'no-store');
await result.text();
}
});
+68
View File
@@ -0,0 +1,68 @@
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);
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(); }
});
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { adminBody, adminCookie, adminFailure, adminResponse, adminToken } from '../lib/admin-http';
import { AdminError } from '../db/admin-access';
const endpoint = 'http://localhost:3000/api/admin/session';
const request = (body: string, headers: Record<string, string> = {}) => new Request(endpoint, {
method: 'POST', body, headers: { origin: 'http://localhost:3000', 'content-type': 'application/json', ...headers },
});
void test('administrator mutations require exact configured origin and bounded JSON', async () => {
assert.deepEqual(await adminBody(request('{"action":"logout"}')), { action: 'logout' });
for (const headers of [{ origin: '' }, { origin: 'https://foreign.invalid' }, { 'sec-fetch-site': 'cross-site' }] as Record<string, string>[]) {
await assert.rejects(adminBody(request('{}', headers)), (e: unknown) => e instanceof AdminError && e.status === 403);
}
await assert.rejects(adminBody(request('{}', { 'content-type': 'text/plain' })), (e: unknown) => e instanceof AdminError && e.status === 415);
await assert.rejects(adminBody(request(' '.repeat(2049))), (e: unknown) => e instanceof AdminError && e.status === 413);
await assert.rejects(adminBody(request('[]')), (e: unknown) => e instanceof AdminError && e.status === 400);
await assert.rejects(adminBody(request('bad')), SyntaxError);
assert.deepEqual(await adminBody(request('{}', { origin: 'https://dune.procrastination.games' }), 'https://dune.procrastination.games'), {});
await assert.rejects(adminBody(request('{}', { 'x-forwarded-host': 'foreign.invalid', origin: 'https://foreign.invalid' })), AdminError);
});
void test('administrator cookies isolate room credentials and secure the configured HTTPS origin', () => {
const token = 'a'.repeat(64);
assert.equal(adminToken(new Request(endpoint, { headers: { cookie: `dune_ABCDEFGH=room-secret; dune_admin_session=${token}` } })), token);
assert.equal(adminToken(new Request(endpoint, { headers: { cookie: 'dune_ABCDEFGH=room-secret' } })), undefined);
const value = adminCookie(new Request(endpoint), token, 'https://dune.procrastination.games');
for (const part of ['Path=/api/admin', 'HttpOnly', 'SameSite=Strict', 'Max-Age=28800', '; Secure']) assert.ok(value.includes(part));
assert.ok(adminCookie(new Request(endpoint), '', 'https://dune.procrastination.games').includes('Max-Age=0'));
assert.ok(!adminCookie(new Request(endpoint), token).includes('; Secure'));
});
void test('administrator responses are not cacheable and failures redact internal details', async () => {
assert.equal(adminResponse({}).headers.get('cache-control'), 'no-store');
assert.equal(adminResponse({}).headers.get('vary'), 'Cookie');
const response = adminFailure(new Error('SECRET_DATABASE_OR_CREDENTIAL'));
assert.equal(response.status, 503);
assert.ok(!(await response.text()).includes('SECRET_DATABASE_OR_CREDENTIAL'));
assert.equal(adminFailure(new AdminError('Not permitted.', 403)).status, 403);
});