The admin key used to stay in the URL fragment and was sent with every
request. Now the key is exchanged once for a server-side session:
- POST /api/admin/login {key} sets bingo_admin=<random token>
(HttpOnly, Secure, SameSite=Strict, no Path so it is scoped to the
admin API directory, also behind a subpath proxy)
- POST /api/admin/logout ends the session
- sessions live in memory as sha256(token) -> expiry, absolute lifetime
ADMIN_SESSION_HOURS (default 8); restart logs everyone out
- /admin#k=KEY still works as a login link; the page strips the fragment
from the URL and history before logging in, then shows plain /admin
- wrong key answers 401 after 500 ms; Bearer header still works for scripts
- COOKIE_SECURE=0 for plain-http testing only
Tests: 11 passing (login, cookie flags, logout, expiry, admin disabled).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhHiznVwtQ55ZSguTBWrxn
34 lines
1.3 KiB
JavaScript
34 lines
1.3 KiB
JavaScript
// Starts the real server on a free port with a throwaway database.
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createServer } from 'node:net';
|
|
|
|
export const ADMIN_KEY = 'test-admin-key-1234567890';
|
|
|
|
const freePort = () => new Promise((resolve) => {
|
|
const s = createServer().listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
|
});
|
|
|
|
export async function startServer(env = {}) {
|
|
const dataDir = mkdtempSync(join(tmpdir(), 'bingo-test-'));
|
|
const port = await freePort();
|
|
const base = `http://127.0.0.1:${port}`;
|
|
const proc = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', 'server/server.mjs'], {
|
|
env: {
|
|
...process.env, PORT: String(port), DATA_DIR: dataDir, ADMIN_KEY,
|
|
SEASON_START: '2020-01-06', SEASON_END: '2099-12-31', GOAL: '500', BACKUP_KEEP: '0', ...env,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
});
|
|
for (let i = 0; i < 50; i++) {
|
|
try { if ((await fetch(base + '/healthz')).ok) break; } catch { /* not up yet */ }
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
return {
|
|
base,
|
|
stop() { proc.kill(); rmSync(dataDir, { recursive: true, force: true }); },
|
|
};
|
|
}
|