Files
balance2/bingo/tests/api.test.mjs
T
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

143 lines
6.3 KiB
JavaScript

// End-to-end API tests: starts the real server on a free port with a temp database.
// Run: npm test (Node.js >= 22.13, no dependencies)
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { startServer, ADMIN_KEY } from './helpers.mjs';
let server, base, week;
async function api(path, body, headers = {}) {
const res = await fetch(base + path, {
method: body ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json', ...headers },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, data: await res.json().catch(() => null), headers: res.headers };
}
const dev = (n) => `test0000-0000-0000-0000-${String(n).padStart(12, '0')}`;
const mark = (d, cell, on = true, w = week) => api('/api/mark', { d, week: w, cell, on });
before(async () => {
server = await startServer();
base = server.base;
week = (await api('/api/total')).data.week;
});
after(() => server?.stop());
test('total reports season settings', async () => {
const { status, data } = await api('/api/total');
assert.equal(status, 200);
assert.equal(data.goal, 500);
assert.equal(data.phase, 'active');
assert.match(data.week, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(new Date(data.week + 'T00:00:00Z').getUTCDay(), 1, 'week key is a Monday');
});
test('scoring: cells, row, column, diagonal, idempotency, unmark', async () => {
const d = dev(1);
const t0 = (await api('/api/total')).data.total;
const steps = async () => (await api('/api/total')).data.total - t0;
for (const c of [1, 2, 3]) await mark(d, c);
assert.equal(await steps(), 3);
await mark(d, 4); // row 1 complete
assert.equal(await steps(), 4 + 5);
await mark(d, 4); // same cell again: no change
assert.equal(await steps(), 9);
for (const c of [5, 9]) await mark(d, c);
await mark(d, 13); // column 01-13 complete
assert.equal(await steps(), 7 + 10);
for (const c of [6, 11]) await mark(d, c);
await mark(d, 16); // diagonal 01-16 complete
assert.equal(await steps(), 10 + 15);
await mark(d, 1, false); // breaks row, column and diagonal at once
assert.equal(await steps(), 9);
const me = (await api(`/api/me?d=${d}`)).data;
assert.deepEqual(me.cells, [2, 3, 4, 5, 6, 9, 11, 13, 16]);
});
test('full card is worth 66 steps', async () => {
const d = dev(2);
const t0 = (await api('/api/total')).data.total;
let last;
for (let c = 1; c <= 16; c++) last = await mark(d, c);
assert.equal(last.data.total - t0, 16 + 10 * 5);
});
test('rejects wrong week, bad input and oversized bodies', async () => {
assert.equal((await mark(dev(3), 1, true, '2020-01-06')).status, 409);
assert.equal((await mark(dev(3), 17)).status, 400);
assert.equal((await mark('short', 1)).status, 400);
assert.equal((await api('/api/mark', { d: dev(3), week, cell: 1, on: 'yes' })).status, 400);
const big = await fetch(base + '/api/mark', { method: 'POST', body: 'x'.repeat(5000) });
assert.equal(big.status, 413);
});
test('pulse answer is stored once per device and week', async () => {
const d = dev(4);
assert.equal((await api('/api/pulse', { d, week, answer: 'mid' })).status, 200);
assert.equal((await api('/api/pulse', { d, week, answer: 'low' })).status, 200);
assert.equal((await api(`/api/me?d=${d}`)).data.pulse, 'low');
assert.equal((await api('/api/pulse', { d, week, answer: 'meh' })).status, 400);
});
test('admin stats need a session or the key, and add up', async () => {
assert.equal((await api('/api/admin/stats')).status, 401);
assert.equal((await api('/api/admin/stats', null, { Authorization: 'Bearer wrong' })).status, 401);
const { status, data } = await api('/api/admin/stats', null, { Authorization: `Bearer ${ADMIN_KEY}` });
assert.equal(status, 200);
assert.equal(data.session, null, 'Bearer access has no session');
const w = data.weeks.find((x) => x.week === week);
assert.equal(w.steps, w.cells + 5 * (w.rows + w.cols + w.diags));
assert.equal(data.total, data.weeks.reduce((n, x) => n + x.steps, 0));
assert.equal(w.pulse.low, 1);
assert.equal(data.cells.length, 16);
});
test('admin login: key once, then HttpOnly session cookie; logout ends it', async () => {
const bad = await api('/api/admin/login', { key: 'wrong-key-wrong-key' });
assert.equal(bad.status, 401);
assert.equal(bad.headers.get('set-cookie'), null);
const ok = await api('/api/admin/login', { key: ADMIN_KEY });
assert.equal(ok.status, 200);
const setCookie = ok.headers.get('set-cookie');
assert.match(setCookie, /^bingo_admin=[A-Za-z0-9_-]{40,};/);
assert.match(setCookie, /HttpOnly/);
assert.match(setCookie, /SameSite=Strict/);
assert.match(setCookie, /Secure/);
assert.match(setCookie, /Max-Age=28800/, 'default 8 hours');
assert.doesNotMatch(setCookie, /Path=/, 'path defaults to /api/admin (or /<prefix>/api/admin behind nginx)');
assert.doesNotMatch(setCookie, new RegExp(ADMIN_KEY), 'cookie never contains the key');
const cookie = setCookie.split(';')[0];
const stats = await api('/api/admin/stats', null, { Cookie: cookie });
assert.equal(stats.status, 200);
const left = Date.parse(stats.data.session.expiresAt) - Date.now();
assert.ok(left > 7.9 * 3600e3 && left <= 8 * 3600e3, 'expires in ~8 h');
assert.equal((await api('/api/admin/stats', null, { Cookie: 'bingo_admin=forged-token-value' })).status, 401);
const out = await api('/api/admin/logout', {}, { Cookie: cookie });
assert.equal(out.status, 200);
assert.match(out.headers.get('set-cookie'), /bingo_admin=; Max-Age=0/);
assert.equal((await api('/api/admin/stats', null, { Cookie: cookie })).status, 401, 'old cookie is dead after logout');
});
test('static pages and security headers', async () => {
for (const p of ['/', '/admin', '/admin.html']) {
const res = await fetch(base + p);
assert.equal(res.status, 200, p);
assert.match(res.headers.get('content-security-policy'), /frame-ancestors 'self' https:\/\/teams\.microsoft\.com/);
assert.equal(res.headers.get('x-frame-options'), null, 'X-Frame-Options would break the Teams tab');
}
assert.equal((await fetch(base + '/fonts/manrope-latin-wght-normal.woff2')).headers.get('content-type'), 'font/woff2');
assert.equal((await fetch(base + '/../server/server.mjs')).status, 404);
assert.equal((await fetch(base + '/nope')).status, 404);
});