Files
balance2/bingo/tests/admin-session.test.mjs
Claude 9b421c9b47 Admin: log in once, keep a session cookie instead of the key in the URL
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
2026-10-30 20:00:21 +00:00

45 lines
1.8 KiB
JavaScript

// Session expiry and the "admin disabled" case, each on its own server instance.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer, ADMIN_KEY } from './helpers.mjs';
const post = (base, path, body, headers = {}) => fetch(base + path, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
});
test('session expires after ADMIN_SESSION_HOURS', async () => {
const srv = await startServer({ ADMIN_SESSION_HOURS: String(1.5 / 3600) }); // 1.5 seconds
try {
const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY });
assert.equal(res.status, 200);
const cookie = res.headers.get('set-cookie').split(';')[0];
assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 200);
await new Promise((r) => setTimeout(r, 1800));
assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 401);
} finally {
srv.stop();
}
});
test('without ADMIN_KEY the admin API is disabled', async () => {
const srv = await startServer({ ADMIN_KEY: '' });
try {
const res = await post(srv.base, '/api/admin/login', { key: 'anything-anything-anything' });
assert.equal(res.status, 403);
assert.equal((await res.json()).error, 'admin_disabled');
assert.equal((await (await fetch(srv.base + '/api/admin/stats')).json()).error, 'admin_disabled');
} finally {
srv.stop();
}
});
test('COOKIE_SECURE=0 drops the Secure flag (plain-http testing only)', async () => {
const srv = await startServer({ COOKIE_SECURE: '0' });
try {
const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY });
assert.doesNotMatch(res.headers.get('set-cookie'), /Secure/);
} finally {
srv.stop();
}
});