Analyse: Messdienst: Heatmap-Pfad wie gespeicherter Pfad, Tests für Kodierung und Stunde

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:26 +00:00
1 parent 96409c6306
commit a73e0be952
4 files changed
+35 -3

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
// GET /a/heat?token= (öffentlicher Listener): Klickpunkte für das Heatmap-Overlay.
// Token (R3): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, e = Ablauf in Unix-Sekunden.
import crypto from 'node:crypto';
import { hostOf } from './ingest.js';
import { hostOf, cleanPath } from './ingest.js';
const SITE_KEY = /^[a-z0-9]{16,40}$/;
const MAX_AHEAD = 30 * 60; // Sekunden
@@ -79,7 +79,7 @@ export function createHeat({ db, config = {}, now }) {
}
cors(res, req, domainsOf(site));
const since = clock() - CLICK_DAYS * 86400 * 1000;
const points = getClicks.all(t.siteKey, t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y }));
const points = getClicks.all(t.siteKey, cleanPath(t.path) || t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y }));
return end(200, { points });
};
}
+1 -1
View File
@@ -64,7 +64,7 @@ function looksLikeId(seg) {
return /\d{6,}/.test(d) || UUID.test(d) || d.includes('@');
}
function cleanPath(p) {
export function cleanPath(p) {
if (typeof p !== 'string') return null;
const s = p.split(/[?#]/)[0];
if (!s.startsWith('/') || s.length > 300 || /[\u0000-\u001f\u007f]/.test(s)) return null;
+8
View File
@@ -150,3 +150,11 @@ test('Heatmap: gleiche Stunde (ts-Gleichstand), Limit schneidet über die niedri
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();
});
+24
View File
@@ -425,3 +425,27 @@ test('T14: kennungsartige Pfadsegmente werden zu :id, alle Typen', async () => {
assert.equal(t.db.prepare('SELECT path FROM clicks').get().path, '/kunde/:id');
t.close();
});
test('T14: ungültige Prozent-Kodierung fällt auf den Rohwert zurück', async () => {
const t = await setup();
await t.post(pv({ path: '/a/%E0%A4%A/123456' }));
await t.post(pv({ path: '/a/%E0%A4%A/x' }));
await t.post(pv({ path: '/a/%E0%A4%A40/x' }));
assert.deepEqual(t.rows().map((r) => r.path), ['/a/%E0%A4%A/:id', '/a/%E0%A4%A/x', '/a/%E0%A4%A40/x']);
t.close();
});
test('T3: jede gespeicherte Zeile jedes Typs liegt auf der vollen Stunde (ungerade Uhrzeit)', async () => {
const t = await setup({ now: () => Date.UTC(2026, 9, 1, 10, 37, 12, 345) });
await t.post({ t: 'bn', site: KEY, a: 'shown' });
await t.post(pv());
await t.post({ t: 'cx', site: KEY, path: '/', vp: 'd' });
await t.post({ t: 'en', site: KEY, path: '/', vp: 'd', sec: 5, scroll: 10 });
await t.post({ t: 'ct', site: KEY, path: '/', kind: 'tel' });
await t.post({ t: 'ck', site: KEY, path: '/', vp: 'd', x: 0.5, y: 0.5 });
const r = t.rows();
assert.deepEqual([...new Set(r.map((x) => x.type))].sort(), ['bn', 'ct', 'cx', 'en', 'pv']);
for (const x of r) assert.equal(x.ts % 3600000, 0);
for (const x of t.db.prepare('SELECT ts FROM clicks').all()) assert.equal(x.ts % 3600000, 0);
t.close();
});