Files
Webklar-Kundenbereich/analytics/test/heat.test.js
T

161 lines
8.2 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();
});
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();
});
test('Heatmap: gleiche Stunde (ts-Gleichstand), Limit schneidet über die niedrigste id ab', async () => {
const s = await setup();
const ins = s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)');
const ts = NOW - 3600000;
ins.run(KEY, ts, '/g', 'd', 0.99, 0.99); // niedrigste id
s.db.exec('BEGIN');
for (let i = 0; i < 5000; i++) ins.run(KEY, ts, '/g', 'd', 0.5, 0.5);
s.db.exec('COMMIT');
const j = await (await s.get(tok({ path: '/g' }))).json();
assert.equal(j.points.length, 5000);
assert.ok(!j.points.some((p) => p.x === 0.99));
s.close();
});
test('Heatmap: Token-Pfad mit Kennung findet die als :id gespeicherten Klicks', async () => {
const s = await setup();
s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)').run(KEY, NOW - 1000, '/kunde/:id', 'd', 0.4, 0.6);
const j = await (await s.get(tok({ path: '/kunde/12345678' }))).json();
assert.deepEqual(j, { points: [{ x: 0.4, y: 0.6 }] });
s.close();
});