Files
Dune/tests/admin-access-cli.test.ts
Quintine c2d392e155 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.
2026-09-22 09:36:11 +10:00

228 lines
7.6 KiB
TypeScript

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 });
}
});