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.
568 lines
18 KiB
TypeScript
568 lines
18 KiB
TypeScript
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();
|
|
}
|
|
});
|