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:
1 parent
36a03bda9c
commit
c2d392e155
27 files changed
+3164
-13
No files matched your search
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user