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';
|
2026-10-30 20:53:04 +01:00
|
|
|
import { startServer, ADMIN_KEY } from './helpers.mjs';
|
2026-10-30 17:20:47 +01:00
|
|
|
|
2026-10-30 20:53:04 +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 () => {
|
2026-10-30 20:53:04 +01:00
|
|
|
server = await startServer();
|
|
|
|
|
base = server.base;
|
2026-10-30 17:20:47 +01:00
|
|
|
week = (await api('/api/total')).data.week;
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-30 20:53:04 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-30 20:53:04 +01:00
|
|
|
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);
|
2026-10-30 20:53:04 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-30 20:53:04 +01:00
|
|
|
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);
|
|
|
|
|
});
|