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