Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite. analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged, analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg, Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen). Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte). Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
10 KiB
JavaScript
210 lines
10 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { loadConfig } from '../src/config.js';
|
|
import { openDb } from '../src/db.js';
|
|
import { createPublicServer, createAdminServer } from '../src/server.js';
|
|
|
|
const KEY = 'abcdef0123456789abcd';
|
|
|
|
async function setup(adminToken = 'geheim', make = createAdminServer) {
|
|
const db = openDb(':memory:');
|
|
const server = make({ config: { adminToken }, db });
|
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
const call = async (method, path, body, token = 'geheim', raw) => {
|
|
const headers = {};
|
|
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
|
if (body !== undefined || raw !== undefined) headers['Content-Type'] = 'application/json';
|
|
const res = await fetch(base + path, { method, headers, body: raw ?? (body === undefined ? undefined : JSON.stringify(body)) });
|
|
return { status: res.status, body: await res.json() };
|
|
};
|
|
return { db, call, close: () => server.close() };
|
|
}
|
|
const row = (db, k = KEY) => db.prepare('SELECT * FROM sites WHERE siteKey = ?').get(k);
|
|
const valid = { projectId: 'p1', domains: ['abc.project.webklar.com'], active: true };
|
|
|
|
test('loadConfig: Vorgaben und Umgebung', () => {
|
|
const c = loadConfig({});
|
|
assert.deepEqual(c, { port: 8090, adminPort: 8091, dataDir: '/srv/webklar-analytics', adminToken: '', heatSecret: '', geoFile: '', baseHost: 'project.webklar.com' });
|
|
const d = loadConfig({ ANALYTICS_PORT: '9000', ANALYTICS_ADMIN_TOKEN: 't', ANALYTICS_HEAT_SECRET: 'h' });
|
|
assert.equal(d.port, 9000);
|
|
assert.equal(d.adminToken, 't');
|
|
assert.equal(d.heatSecret, 'h');
|
|
});
|
|
|
|
test('openDb ist idempotent und legt Tabellen an', () => {
|
|
const db = openDb(':memory:');
|
|
const names = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
|
for (const n of ['sites', 'events_raw', 'clicks', 'daily']) assert.ok(names.includes(n), n);
|
|
});
|
|
|
|
test('GET /a/health ohne Token', async () => {
|
|
const t = await setup();
|
|
const r = await t.call('GET', '/a/health', undefined, null);
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(r.body, { ok: true });
|
|
t.close();
|
|
});
|
|
|
|
test('Admin: 401 ohne, mit falschem Token und bei leerem adminToken', async () => {
|
|
const t = await setup();
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid, null)).status, 401);
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid, 'falsch')).status, 401);
|
|
assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`, undefined, 'falsch')).status, 401);
|
|
assert.equal(row(t.db), undefined);
|
|
t.close();
|
|
const e = await setup('');
|
|
assert.equal((await e.call('PUT', `/a/admin/sites/${KEY}`, valid, '')).status, 401);
|
|
assert.equal((await e.call('PUT', `/a/admin/sites/${KEY}`, valid, 'x')).status, 401);
|
|
e.close();
|
|
});
|
|
|
|
test('PUT legt an und ändert', async () => {
|
|
const t = await setup();
|
|
const r = await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.ok, true);
|
|
let s = row(t.db);
|
|
assert.equal(s.projectId, 'p1');
|
|
assert.deepEqual(JSON.parse(s.domains), ['abc.project.webklar.com']);
|
|
assert.equal(s.active, 1);
|
|
assert.equal(s.offSince, null);
|
|
await t.call('PUT', `/a/admin/sites/${KEY}`, { projectId: 'p2', domains: ['a.de', 'b.de'], active: true });
|
|
s = row(t.db);
|
|
assert.equal(s.projectId, 'p2');
|
|
assert.deepEqual(JSON.parse(s.domains), ['a.de', 'b.de']);
|
|
assert.equal(t.db.prepare('SELECT COUNT(*) c FROM sites').get().c, 1);
|
|
t.close();
|
|
});
|
|
|
|
test('active=false setzt offSince einmalig, active=true löscht es', async () => {
|
|
const t = await setup();
|
|
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
|
await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: false });
|
|
const s1 = row(t.db);
|
|
assert.equal(s1.active, 0);
|
|
assert.ok(!Number.isNaN(Date.parse(s1.offSince)));
|
|
assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/);
|
|
t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY);
|
|
await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: false });
|
|
assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z');
|
|
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
|
assert.equal(row(t.db).offSince, null);
|
|
t.close();
|
|
});
|
|
|
|
test('PUT: ungültiger siteKey 400', async () => {
|
|
const t = await setup();
|
|
for (const k of ['kurz', 'ABCDEF0123456789ABCD', 'abcdef0123456789abc!', 'a'.repeat(41)]) {
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${k}`, valid)).status, 400, k);
|
|
}
|
|
assert.equal((await t.call('DELETE', '/a/admin/sites/kurz')).status, 400);
|
|
t.close();
|
|
});
|
|
|
|
test('PUT: Domains nur Hostnamen, max 5; projectId/active geprüft', async () => {
|
|
const t = await setup();
|
|
const bad = [
|
|
['a.de:8080'], ['a.de/pfad'], ['https://a.de'], ['A.de'], ['localhost'], [''], ['-a.de'], [1], 'a.de', [],
|
|
['a.de', 'b.de', 'c.de', 'd.de', 'e.de', 'f.de'],
|
|
];
|
|
for (const domains of bad) {
|
|
const r = await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains });
|
|
assert.equal(r.status, 400, JSON.stringify(domains));
|
|
assert.equal(typeof r.body.error, 'string');
|
|
}
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, projectId: '' })).status, 400);
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: 'ja' })).status, 400);
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, undefined, 'geheim', '{kaputt')).status, 400);
|
|
assert.equal(row(t.db), undefined);
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains: ['a.de', 'b.de', 'c.de', 'd.de', 'e.de'] })).status, 200);
|
|
t.close();
|
|
});
|
|
|
|
test('PUT: zu großer Body 413', async () => {
|
|
const t = await setup();
|
|
const r = await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, projectId: 'x'.repeat(70000) });
|
|
assert.equal(r.status, 413);
|
|
t.close();
|
|
});
|
|
|
|
test('DELETE entfernt Website, unbekannte Route 404', async () => {
|
|
const t = await setup();
|
|
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
|
assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`)).status, 200);
|
|
assert.equal(row(t.db), undefined);
|
|
assert.equal((await t.call('GET', '/a/nichts', undefined, null)).status, 404);
|
|
t.close();
|
|
});
|
|
|
|
test('Admin-Route auf dem öffentlichen Listener: 404 auch mit richtigem Token', async () => {
|
|
const t = await setup('geheim', createPublicServer);
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid)).status, 404);
|
|
assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`)).status, 404);
|
|
assert.equal(row(t.db), undefined);
|
|
assert.equal((await t.call('GET', '/a/health', undefined, null)).status, 200);
|
|
t.close();
|
|
});
|
|
|
|
test('Doppelte Domains: 400', async () => {
|
|
const t = await setup();
|
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains: ['a.de', 'a.de'] })).status, 400);
|
|
t.close();
|
|
});
|
|
|
|
test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => {
|
|
const f = join(mkdtempSync(join(tmpdir(), 'ana-')), 'a.db');
|
|
const d1 = openDb(f);
|
|
d1.prepare("INSERT INTO sites VALUES ('k','p','[]',1,NULL)").run();
|
|
assert.equal(d1.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
|
d1.close();
|
|
const d2 = openDb(f);
|
|
assert.equal(d2.prepare('SELECT COUNT(*) c FROM sites').get().c, 1);
|
|
assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
|
d2.close();
|
|
});
|
|
|
|
test('POST …/off: 401 ohne Token, 404 unbekannt, setzt active=0 und offSince einmalig', async () => {
|
|
const t = await setup();
|
|
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, null)).status, 401);
|
|
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, 'falsch')).status, 401);
|
|
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 404);
|
|
assert.equal((await t.call('POST', '/a/admin/sites/kurz/off')).status, 400);
|
|
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
|
assert.equal(row(t.db).active, 1);
|
|
const r = await t.call('POST', `/a/admin/sites/${KEY}/off`);
|
|
assert.equal(r.status, 200);
|
|
const s1 = row(t.db);
|
|
assert.equal(s1.active, 0);
|
|
assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/);
|
|
assert.deepEqual(JSON.parse(s1.domains), ['abc.project.webklar.com']);
|
|
t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY);
|
|
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 200);
|
|
assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z');
|
|
t.close();
|
|
});
|
|
|
|
test('POST …/off: ruft onSitesChanged, danach verwirft /a/e Ereignisse und w.js ist leer', async () => {
|
|
const db = openDb(':memory:');
|
|
const pub = createPublicServer({ config: {}, db });
|
|
const adm = createAdminServer({ config: { adminToken: 't' }, db, onSitesChanged: () => pub.invalidateSites() });
|
|
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
|
|
const pb = `http://127.0.0.1:${pub.address().port}`;
|
|
const ab = `http://127.0.0.1:${adm.address().port}`;
|
|
await fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active: true }) });
|
|
const ev = () => fetch(`${pb}/a/e`, { method: 'POST', headers: { 'Content-Type': 'text/plain', Origin: 'https://abc.project.webklar.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0) Chrome/120' },
|
|
body: JSON.stringify({ t: 'pv', site: KEY, path: '/', vp: 'd' }) });
|
|
await ev();
|
|
const before = db.prepare('SELECT COUNT(*) c FROM events_raw').get().c;
|
|
assert.equal(before, 1);
|
|
assert.equal((await fetch(`${ab}/a/admin/sites/${KEY}/off`, { method: 'POST', headers: { Authorization: 'Bearer t' } })).status, 200);
|
|
await ev();
|
|
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, before);
|
|
assert.equal((await (await fetch(`${pb}/a/w.js?s=${KEY}`)).text()).length < 200, true);
|
|
pub.close(); adm.close();
|
|
});
|