import { test } from 'node:test'; import assert from 'node:assert/strict'; import { openDb } from '../src/db.js'; import { createPublicServer, createAdminServer } from '../src/server.js'; import { signHeatToken, verifyHeatToken } from '../src/heat.js'; const KEY = 'abcdef0123456789abcd'; const OTHER = 'zzzzzzzz0123456789zz'; const SECRET = 'geheim'; const NOW = Date.UTC(2026, 9, 10, 10, 0, 0); const exp = (s = 600) => Math.floor(NOW / 1000) + s; async function setup() { const db = openDb(':memory:'); const site = db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)'); site.run(KEY, 'p', JSON.stringify(['kunde.de', 'www.kunde.de'])); site.run(OTHER, 'q', JSON.stringify(['andere.de'])); const click = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)'); click.run(KEY, NOW - 1000, '/', 'd', 0.1, 0.2); click.run(KEY, NOW - 2000, '/', 'd', 0.3, 0.4); click.run(KEY, NOW - 1000, '/', 'm', 0.9, 0.9); click.run(KEY, NOW - 1000, '/x', 'd', 0.8, 0.8); click.run(OTHER, NOW - 1000, '/', 'd', 0.7, 0.7); click.run(KEY, NOW - 31 * 86400000, '/', 'd', 0.5, 0.5); const server = createPublicServer({ config: { heatSecret: SECRET }, db, now: () => NOW }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; const get = (token, headers = {}) => fetch(`${base}/a/heat?token=${encodeURIComponent(token)}`, { headers }); return { db, get, base, close: () => server.close() }; } const tok = (o = {}, secret = SECRET) => signHeatToken({ siteKey: KEY, path: '/', vp: 'd', exp: exp(), ...o }, secret); test('gültiges Token: neueste Punkte der Seite/Viewport, 30 Tage, no-store, CORS nur eigene Domain', async () => { const s = await setup(); const r = await s.get(tok(), { Origin: 'https://www.kunde.de' }); assert.equal(r.status, 200); assert.equal(r.headers.get('cache-control'), 'no-store'); assert.equal(r.headers.get('access-control-allow-origin'), 'https://www.kunde.de'); assert.deepEqual(await r.json(), { points: [{ x: 0.1, y: 0.2 }, { x: 0.3, y: 0.4 }] }); const fremd = await s.get(tok(), { Origin: 'https://evil.example' }); assert.equal(fremd.status, 200); assert.equal(fremd.headers.get('access-control-allow-origin'), null); const http = await s.get(tok(), { Origin: 'http://kunde.de' }); assert.equal(http.headers.get('access-control-allow-origin'), null); assert.equal(r.headers.get('access-control-allow-origin') === '*', false); s.close(); }); test('maximal 5000 neueste Punkte', async () => { const s = await setup(); s.db.exec('BEGIN'); const ins = s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)'); for (let i = 0; i < 5100; i++) ins.run(KEY, NOW - 10000 + i, '/many', 'd', 0.5, i < 100 ? 0.01 : 0.99); s.db.exec('COMMIT'); const j = await (await s.get(tok({ path: '/many' }))).json(); assert.equal(j.points.length, 5000); assert.ok(j.points.every((p) => p.y === 0.99)); // die 100 ältesten fehlen s.close(); }); test('403: abgelaufen, > 30 Min voraus, falsches Secret, manipuliert, Müll, fremde/unbekannte Website', async () => { const s = await setup(); const bad = [ tok({ exp: exp(-1) }), tok({ exp: exp(30 * 60 + 5) }), tok({}, 'anderes-secret'), 'abc', '', 'a.b', 'x'.repeat(10) + '.' + 'f'.repeat(64), tok({ siteKey: 'nichtvorhanden0123456' }), ]; // Manipulation: Payload tauschen, Signatur behalten const [part, sig] = tok().split('.'); for (const patch of [{ p: '/x' }, { v: 'm' }, { s: OTHER }]) { const o = { ...JSON.parse(Buffer.from(part, 'base64url')), ...patch }; bad.push(`${Buffer.from(JSON.stringify(o)).toString('base64url')}.${sig}`); } for (const t of bad) assert.equal((await s.get(t)).status, 403, t.slice(0, 30)); assert.equal((await fetch(`${s.base}/a/heat`)).status, 403); // Token der anderen Website liefert nur deren Punkte (nie die von KEY) const j = await (await s.get(tok({ siteKey: OTHER }))).json(); assert.deepEqual(j.points, [{ x: 0.7, y: 0.7 }]); s.close(); }); test('Token mit exp genau 30 Min voraus ist gültig; Helfer verify', () => { assert.ok(verifyHeatToken(tok({ exp: exp(1800) }), SECRET, NOW)); assert.equal(verifyHeatToken(tok({ exp: exp(1801) }), SECRET, NOW), null); assert.equal(verifyHeatToken(tok(), '', NOW), null); // leeres Secret sperrt assert.equal(verifyHeatToken(tok({ vp: 'x' }), SECRET, NOW), null); assert.equal(verifyHeatToken(tok({ path: 'kein-slash' }), SECRET, NOW), null); const t = tok(); assert.match(t, /^[A-Za-z0-9_-]+\.[0-9a-f]{64}$/); assert.deepEqual(JSON.parse(Buffer.from(t.split('.')[0], 'base64url')), { s: KEY, p: '/', v: 'd', e: exp() }); }); test('/a/heat nur auf dem öffentlichen Listener', async () => { const db = openDb(':memory:'); const server = createAdminServer({ config: { heatSecret: SECRET, adminToken: 't' }, db }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const r = await fetch(`http://127.0.0.1:${server.address().port}/a/heat?token=${tok()}`); assert.equal(r.status, 404); server.close(); }); test('403 bei ausgeschalteter Website (active = 0), mit CORS für die eigene Domain', async () => { const s = await setup(); s.db.prepare('UPDATE sites SET active = 0 WHERE siteKey = ?').run(KEY); const r = await s.get(tok(), { Origin: 'https://kunde.de' }); assert.equal(r.status, 403); assert.equal(r.headers.get('access-control-allow-origin'), 'https://kunde.de'); s.close(); }); test('403 mit CORS-Kopf, wenn die Website bekannt ist (abgelaufen, aus); sonst ohne', async () => { const s = await setup(); const origin = { Origin: 'https://www.kunde.de' }; const abgelaufen = await s.get(tok({ exp: exp(-1) }), origin); assert.equal(abgelaufen.status, 403); assert.equal(abgelaufen.headers.get('access-control-allow-origin'), 'https://www.kunde.de'); // fremder Origin, falsche Signatur, unbekannte Website: kein CORS-Kopf assert.equal((await s.get(tok({ exp: exp(-1) }), { Origin: 'https://evil.example' })).headers.get('access-control-allow-origin'), null); assert.equal((await s.get(tok({}, 'anderes-secret'), origin)).headers.get('access-control-allow-origin'), null); assert.equal((await s.get(tok({ siteKey: 'nichtvorhanden0123456' }), origin)).headers.get('access-control-allow-origin'), null); assert.equal((await s.get('abc', origin)).headers.get('access-control-allow-origin'), null); s.close(); }); test('OPTIONS-Preflight: CORS-Köpfe nur für erlaubte Origins bekannter Websites', async () => { const s = await setup(); const pre = (token, origin) => fetch(`${s.base}/a/heat?token=${encodeURIComponent(token)}`, { method: 'OPTIONS', headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' } }); const ok = await pre(tok(), 'https://kunde.de'); assert.equal(ok.status, 204); assert.equal(ok.headers.get('access-control-allow-origin'), 'https://kunde.de'); assert.match(ok.headers.get('access-control-allow-methods'), /GET/); assert.equal((await pre(tok({ exp: exp(-1) }), 'https://kunde.de')).headers.get('access-control-allow-origin'), 'https://kunde.de'); assert.equal((await pre(tok(), 'https://evil.example')).headers.get('access-control-allow-origin'), null); assert.equal((await pre('abc', 'https://kunde.de')).headers.get('access-control-allow-origin'), null); s.close(); });