Files

142 lines
6.3 KiB
JavaScript
Raw Permalink Normal View History

2026-10-30 17:20:47 +01:00
// 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';
2026-10-30 17:20:47 +01:00
let server, base, week;
2026-10-30 17:20:47 +01:00
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;
2026-10-30 17:20:47 +01:00
week = (await api('/api/total')).data.week;
});
after(() => server?.stop());
2026-10-30 17:20:47 +01:00
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);
2026-10-30 17:20:47 +01:00
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');
2026-10-30 17:20:47 +01:00
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');
});
2026-10-30 17:20:47 +01:00
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);
});