103 lines
5.0 KiB
JavaScript
103 lines
5.0 KiB
JavaScript
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();
|
|
});
|