import { test } from 'node:test'; import assert from 'node:assert/strict'; import { openDb } from '../src/db.js'; import net from 'node:net'; import { createPublicServer, createAdminServer } from '../src/server.js'; import { classifyUa } from '../src/ua.js'; import { createSalt } from '../src/salt.js'; const KEY = 'abcdef0123456789abcd'; const HOST = 'abc.project.webklar.com'; const ORIGIN = `https://${HOST}`; const IP = '203.0.113.77'; const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148 Safari/604.1 GEHEIMERUA'; async function setup({ now = () => Date.UTC(2026, 9, 1, 10, 30, 45), geo = { lookup: () => 'DE' } } = {}) { const db = openDb(':memory:'); db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)') .run(KEY, 'p1', JSON.stringify([HOST, 'www.kunde.de'])); const server = createPublicServer({ config: {}, db, geo, now }); const invalidate = () => server.invalidateSites(); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; const post = (ev, { origin = ORIGIN, ua = UA, ip = IP, left = '1.2.3.4', referer, raw, type = 'text/plain' } = {}) => { const headers = { 'Content-Type': type, 'User-Agent': ua, 'X-Forwarded-For': `${left}, ${ip}` }; if (origin) headers.Origin = origin; if (referer) headers.Referer = referer; return fetch(base + '/a/e', { method: 'POST', headers, body: raw ?? JSON.stringify(ev) }); }; const rows = () => db.prepare('SELECT * FROM events_raw ORDER BY id').all(); return { db, post, rows, base, server, invalidate, close: () => server.close() }; } const pv = (o = {}) => ({ t: 'pv', site: KEY, path: '/', vp: 'd', ...o }); test('classifyUa: Bots, Geräte, leer', () => { assert.equal(classifyUa('').bot, true); assert.equal(classifyUa('Googlebot/2.1').bot, true); assert.equal(classifyUa('curl/8.0').bot, true); assert.equal(classifyUa('Mozilla/5.0 (Windows NT 10.0) Chrome/120').device, 'd'); assert.equal(classifyUa('Mozilla/5.0 (iPhone; CPU) Mobile/15E').device, 'm'); assert.equal(classifyUa('Mozilla/5.0 (Linux; Android 13; Pixel) Mobile Safari').device, 'm'); assert.equal(classifyUa('Mozilla/5.0 (iPad; CPU OS 17)').device, 't'); assert.equal(classifyUa('Mozilla/5.0 (Linux; Android 13; SM-T500) Safari').device, 't'); assert.equal(classifyUa('Mozilla/5.0 (X11; Linux) Firefox/120').bot, false); }); test('Salz: stabil am Tag, wechselt um Mitternacht Berlin', () => { let t = Date.UTC(2026, 9, 1, 10, 0, 0); let n = 0; const s = createSalt({ now: () => t, random: () => Buffer.alloc(32, ++n) }); const a = s.visitorId('s', '1.1.1.1', 'ua'); assert.match(a, /^[0-9a-f]{16}$/); t = Date.UTC(2026, 9, 1, 21, 59, 0); // 23:59 Berlin (MESZ) assert.equal(s.visitorId('s', '1.1.1.1', 'ua'), a); assert.notEqual(s.visitorId('s', '1.1.1.2', 'ua'), a); t = Date.UTC(2026, 9, 1, 22, 0, 1); // 00:00 Berlin assert.notEqual(s.visitorId('s', '1.1.1.1', 'ua'), a); assert.match(s.rateKey('s', '1.1.1.1'), /^[0-9a-f]{64}$/); assert.ok(!s.rateKey('s', '1.1.1.1').includes('1.1.1.1')); }); test('pv: gespeichert ohne visitor, mit Land und Gerät, Cookie-frei', async () => { const t = await setup(); const res = await t.post(pv({ path: '/preise?x=1#a', ref: 'https://Google.com/search?q=1' })); assert.equal(res.status, 204); assert.equal(res.headers.get('set-cookie'), null); const [r] = t.rows(); assert.equal(r.type, 'pv'); assert.equal(r.site, KEY); assert.equal(r.path, '/preise'); assert.equal(r.ref, 'google.com'); assert.equal(r.vp, 'd'); assert.equal(r.country, 'DE'); assert.equal(r.device, 'm'); assert.equal(r.visitor, null); assert.equal(res.headers.get('access-control-allow-origin'), ORIGIN); assert.equal(res.headers.get('access-control-allow-credentials'), null); t.close(); }); test('pv: ref eigene Domain wird verworfen, ungültige Felder', async () => { const t = await setup(); await t.post(pv({ ref: 'https://www.kunde.de/x' })); await t.post(pv({ ref: 'kein host!' })); assert.equal(t.rows()[0].ref, null); assert.equal(t.rows()[1].ref, null); await t.post(pv({ path: 'kein-slash' })); await t.post(pv({ path: '/' + 'a'.repeat(300) })); await t.post(pv({ path: '/a\u0001b' })); await t.post(pv({ vp: 'x' })); await t.post({ t: 'zz', site: KEY, path: '/', vp: 'd' }); await t.post({ site: KEY, path: '/', vp: 'd' }); await t.post(null, { raw: 'kein json' }); await t.post(null, { raw: '[1]' }); assert.equal(t.rows().length, 2); t.close(); }); test('Antwort immer 204; 413 bei großem Body; 405 bei falscher Methode', async () => { const t = await setup(); assert.equal((await t.post(null, { raw: 'kein json' })).status, 204); assert.equal((await t.post(pv({ pad: 'x'.repeat(5000) }))).status, 413); assert.equal(t.rows().length, 0); assert.equal((await fetch(t.base + '/a/e')).status, 405); assert.equal((await fetch(t.base + '/a/e', { method: 'PUT', body: '{}' })).status, 405); t.close(); }); test('application/json wird ebenfalls angenommen; unbekannte Felder werden nicht gespeichert', async () => { const t = await setup(); const res = await t.post(pv({ evil: 'GEHEIM', ip: '9.9.9.9' }), { type: 'application/json' }); assert.equal(res.status, 204); assert.equal(t.rows().length, 1); assert.ok(!JSON.stringify(t.db.prepare('SELECT * FROM events_raw').all()).includes('GEHEIM')); t.close(); }); test('Origin: fremd, fehlend, Referer-Fallback, inaktiv, unbekannte Site', async () => { const t = await setup(); assert.equal((await t.post(pv(), { origin: 'https://evil.example' })).headers.get('access-control-allow-origin'), null); await t.post(pv(), { origin: null }); assert.equal(t.rows().length, 0); await t.post(pv(), { origin: null, referer: `${ORIGIN}/seite` }); assert.equal(t.rows().length, 1); await t.post(pv(), { origin: 'https://www.kunde.de' }); assert.equal(t.rows().length, 2); await t.post(pv({ site: 'zzzzzzzzzzzzzzzzzzzz' })); assert.equal(t.rows().length, 2); t.db.prepare('UPDATE sites SET active = 0').run(); await t.post(pv()); assert.equal(t.rows().length, 2); t.close(); }); test('Bot und leerer User-Agent: nichts gespeichert', async () => { const t = await setup(); await t.post(pv(), { ua: 'Googlebot/2.1' }); await t.post(pv(), { ua: '' }); assert.equal(t.rows().length, 0); t.close(); }); test('bn: nur site, ts (Stunde), type, kind', async () => { const t = await setup(); await t.post({ t: 'bn', site: KEY, a: 'shown', path: '/x', vp: 'm', visitor: 'v' }); await t.post({ t: 'bn', site: KEY, a: 'accept' }); await t.post({ t: 'bn', site: KEY, a: 'foo' }); const rows = t.rows(); assert.equal(rows.length, 2); for (const r of rows) { assert.equal(r.type, 'bn'); for (const f of ['path', 'vp', 'country', 'device', 'visitor', 'ref', 'sec', 'scroll', 'host']) assert.equal(r[f], null, f); assert.equal(r.ts, Date.UTC(2026, 9, 1, 10, 0, 0)); } assert.deepEqual(rows.map((r) => r.kind), ['shown', 'accept']); t.close(); }); test('en: sec/scroll begrenzt, mit visitor', async () => { const t = await setup(); await t.post({ t: 'en', site: KEY, path: '/a?b', vp: 't', sec: 99999, scroll: 250 }); await t.post({ t: 'en', site: KEY, path: '/a', vp: 't', sec: 12.7, scroll: -5 }); await t.post({ t: 'en', site: KEY, path: '/a', vp: 't', sec: 'x', scroll: 1 }); const rows = t.rows(); assert.equal(rows.length, 2); assert.match(rows[0].visitor, /^[0-9a-f]{16}$/); assert.equal(rows[0].path, '/a'); assert.equal(rows[0].sec, 3600); assert.equal(rows[0].scroll, 100); assert.equal(rows[1].sec, 12); assert.equal(rows[1].scroll, 0); t.close(); }); test('ck: in clicks, geklemmt, ohne visitor', async () => { const t = await setup(); await t.post({ t: 'ck', site: KEY, path: '/a#h', vp: 'd', x: 0.123456, y: 1.7, sel: 'div>a'.repeat(100) }); await t.post({ t: 'ck', site: KEY, path: '/a', vp: 'd', x: 'n', y: 0.5 }); assert.equal(t.rows().length, 0); const c = t.db.prepare('SELECT * FROM clicks').all(); assert.equal(c.length, 1); assert.equal(c[0].path, '/a'); assert.equal(c[0].x, 0.123); assert.equal(c[0].y, 1); assert.equal(c[0].sel.length, 200); assert.deepEqual(Object.keys(c[0]).sort(), ['id', 'path', 'sel', 'site', 'ts', 'vp', 'x', 'y']); t.close(); }); test('ct: kind und host', async () => { const t = await setup(); await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'tel', host: 'ignored.de' }); await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'out', host: 'Instagram.COM' }); await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'out' }); await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'sms' }); const rows = t.rows(); assert.equal(rows.length, 2); assert.equal(rows[0].kind, 'tel'); assert.equal(rows[0].host, null); assert.equal(rows[1].host, 'instagram.com'); assert.match(rows[1].visitor, /^[0-9a-f]{16}$/); t.close(); }); test('Mengenlimit: 121. Ereignis verworfen, neue Minute frei', async () => { let now = Date.UTC(2026, 9, 1, 10, 30, 0); const t = await setup({ now: () => now }); for (let i = 0; i < 125; i++) await t.post(pv()); assert.equal(t.rows().length, 120); await t.post(pv(), { ip: '198.51.100.9' }); assert.equal(t.rows().length, 121); now += 61000; await t.post(pv()); assert.equal(t.rows().length, 122); t.close(); }); test('Land: nur 2 Großbuchstaben, sonst leer', async () => { const t = await setup({ geo: { lookup: () => 'xyz' } }); await t.post(pv()); assert.equal(t.rows()[0].country, ''); t.close(); const u = await setup({ geo: { lookup: () => 'at' } }); await u.post(pv()); assert.equal(u.rows()[0].country, 'AT'); u.close(); }); test('CORS-Preflight nur für erlaubte Origin', async () => { const t = await setup(); const ok = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: ORIGIN, 'Access-Control-Request-Method': 'POST' } }); assert.equal(ok.status, 204); assert.equal(ok.headers.get('access-control-allow-origin'), ORIGIN); assert.equal(ok.headers.get('access-control-allow-credentials'), null); const bad = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: 'https://evil.example' } }); assert.equal(bad.status, 204); assert.equal(bad.headers.get('access-control-allow-origin'), null); t.close(); }); test('Datenschutz: weder IP noch User-Agent irgendwo in der Datenbank', async () => { const t = await setup(); await t.post(pv({ ref: 'https://google.com' })); await t.post({ t: 'bn', site: KEY, a: 'accept' }); await t.post({ t: 'en', site: KEY, path: '/a', vp: 'd', sec: 5, scroll: 50 }); await t.post({ t: 'ck', site: KEY, path: '/a', vp: 'd', x: 0.5, y: 0.5, sel: 'a' }); await t.post({ t: 'ct', site: KEY, path: '/a', kind: 'mail' }); let dump = ''; for (const { name } of t.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()) { dump += JSON.stringify(t.db.prepare(`SELECT * FROM ${name}`).all()); } for (const needle of [IP, '1.2.3.4', 'GEHEIMERUA', 'Mozilla', 'iPhone']) assert.ok(!dump.includes(needle), needle); t.close(); }); test('t: nur eigene Feldlisten, kein Prototyp-Zugriff, keine Logzeile', async () => { const t = await setup(); const errs = []; const orig = console.error; console.error = (...a) => errs.push(a.join(' ')); try { for (const tt of ['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf']) { assert.equal((await t.post(null, { raw: JSON.stringify({ t: tt, site: KEY, path: '/', vp: 'd' }) })).status, 204); } assert.equal((await t.post(null, { raw: `{"t":"__proto__","site":"${KEY}","path":"/"}` })).status, 204); assert.equal((await t.post({ t: ['pv'], site: KEY, path: '/', vp: 'd' })).status, 204); assert.equal((await t.post({ t: { toString: 1 }, site: KEY, path: '/', vp: 'd' })).status, 204); } finally { console.error = orig; } assert.equal(t.rows().length, 0); assert.deepEqual(errs, []); t.close(); }); test('Origin/Referer: nur https ohne Port zählt', async () => { const t = await setup(); for (const origin of [`http://${HOST}`, `https://${HOST}:8080`, `https://evil-${HOST}`, `https://${HOST}.evil.com`, `ftp://${HOST}`]) { const res = await t.post(pv(), { origin }); assert.equal(res.headers.get('access-control-allow-origin'), null, origin); const pre = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: origin } }); assert.equal(pre.headers.get('access-control-allow-origin'), null, origin); } await t.post(pv(), { origin: null, referer: `http://${HOST}/x` }); await t.post(pv(), { origin: null, referer: `https://${HOST}:8080/x` }); assert.equal(t.rows().length, 0); await t.post(pv(), { origin: null, referer: `https://${HOST}/x` }); assert.equal(t.rows().length, 1); t.close(); }); function rawRequest(port, head, body = '') { return new Promise((resolve) => { const s = net.connect(port, '127.0.0.1'); let buf = ''; s.on('connect', () => s.write(head + '\r\n\r\n' + body)); s.on('data', (d) => { buf += d; }); s.on('close', () => resolve(buf)); s.on('error', () => resolve(buf)); setTimeout(() => { s.destroy(); }, 1500); }); } test('Body: früh 413 bei Content-Length > 4096, Abbruch bei gestreamtem Überlauf', async () => { const t = await setup(); const port = t.server.address().port; const head = `POST /a/e HTTP/1.1\r\nHost: x\r\nOrigin: ${ORIGIN}\r\nContent-Type: text/plain`; // Content-Length 1 MB angekündigt, aber nichts gesendet: Antwort kommt trotzdem sofort const early = await rawRequest(port, `${head}\r\nContent-Length: 1048576`); assert.match(early, /^HTTP\/1\.1 413/); // chunked ohne Content-Length: über 4 KB ⇒ 413 und Verbindung wird beendet const chunk = 'x'.repeat(3000); const body = `${chunk.length.toString(16)}\r\n${chunk}\r\n`.repeat(3); const streamed = await rawRequest(port, `${head}\r\nTransfer-Encoding: chunked`, body); assert.match(streamed, /^HTTP\/1\.1 413/); assert.equal(t.rows().length, 0); t.close(); }); test('Preflight: Hostliste gecacht (30 s), invalidateSites() und Admin-Änderung wirken sofort', async () => { let now = Date.UTC(2026, 9, 1, 10, 0, 0); const t = await setup({ now: () => now }); const pre = async () => (await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: ORIGIN } })).headers.get('access-control-allow-origin'); assert.equal(await pre(), ORIGIN); t.db.prepare('UPDATE sites SET active = 0').run(); assert.equal(await pre(), ORIGIN); // noch aus dem Cache now += 31000; assert.equal(await pre(), null); // Cache abgelaufen t.db.prepare('UPDATE sites SET active = 1').run(); assert.equal(await pre(), null); t.invalidate(); assert.equal(await pre(), ORIGIN); // Admin-Listener meldet Änderungen an den öffentlichen Listener const admin = createAdminServer({ config: { adminToken: 'geheim' }, db: t.db, onSitesChanged: () => t.invalidate() }); await new Promise((r) => admin.listen(0, '127.0.0.1', r)); const res = await fetch(`http://127.0.0.1:${admin.address().port}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer geheim', 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', domains: [HOST], active: false }), }); assert.equal(res.status, 200); assert.equal(await pre(), null); admin.close(); t.close(); }); test('X-Forwarded-For: rechtester Eintrag zählt für das Mengenlimit', async () => { const t = await setup(); for (let i = 0; i < 120; i++) await t.post(pv(), { left: `10.0.0.${i % 250}` }); // links variiert, rechts gleich assert.equal(t.rows().length, 120); await t.post(pv(), { left: '10.9.9.9' }); // gleicher Bucket ⇒ verworfen assert.equal(t.rows().length, 120); await t.post(pv(), { left: '1.2.3.4', ip: '198.51.100.9' }); // rechts anders ⇒ eigener Bucket assert.equal(t.rows().length, 121); t.close(); });