import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { mailSink } from './support/mailSink.js' const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId', 'audienceConfirmed', 'audienceTextVersion'] const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel', 'audienceConfirmed', 'audienceTextVersion'] const aw = await startAppwriteMock({ schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions'), analyseConsents: CONSENT_KEYS }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { legalPaths } = await import('../server/services/analyseLegal.js') const { startTestApp, get, rawGet, post, sessionCookie } = await import('./support/testApp.js') const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') const { default: adminAnalyseRoutes } = await import('../server/routes/admin/analyse.js') const an = await import('../server/services/analyse.js') const sf = await import('../server/services/siteFeatures.js') const { verifyHeatToken } = await import('../analytics/src/heat.js') const { createAnalyticsClient } = await import('../server/services/analyticsClient.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/admin/analyse': adminAnalyseRoutes }) setMailTransportForTests(mailSink()) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) const puts = [] const stops = [] const statsCalls = [] let statsFail = false const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' }) an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), stopSite: async (siteKey) => stops.push(siteKey), getStats: async (siteKey, range) => { statsCalls.push({ siteKey, range }) if (statsFail) throw Object.assign(new Error('x'), { status: 502 }) return { pv: 5 } }, heatToken: (a) => realClient.heatToken(a), } an.analyseDeps.rebuild = async () => {} config.analyse.enabled = true config.analyse.heatSecret = 'h'.repeat(32) // Die Fassung 2026-10-02 liegt (je nach Uhr) noch in der Zukunft: Textform-Mindestdatum fuer die Tests auf frueh setzen config.analyse.avvVersion = '2000-01-01' seedFixtures(aw) aw.seed('subscriptions', wartungDoc) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'baeckerei.de', isPublic: true }) aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false }) aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Café', subdomain: 'cafe', isPublic: true }) aw.seed('websiteProjects', { $id: 'pd1', customerId: 'c1', projectName: 'Dev', subdomain: 'devsite', isPublic: true, previewDetected: 'dev:npm' }) aw.seed('websiteProjects', { $id: 'pd2', customerId: 'c1', projectName: 'Build', subdomain: 'buildsite', isPublic: true, previewDetected: 'build:npm' }) aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd', subdomain: 'fremd', isPublic: true }) const cookie = customerCookie('c1') const A = '/api/funktionen/analyse' const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) const adminPost = (id, body, jwt = 'jwt-admin') => post(`/api/admin/analyse/${id}`, body, { headers: { Authorization: `Bearer ${jwt}` } }) const todayBerlin = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date()) const tf = (extra = {}) => ({ textformDate: todayBerlin(), textformChannel: 'chat', audienceConfirmed: true, ...extra }) const consentsOf = (p) => [...aw.docs('analyseConsents').values()].filter((d) => d.projectId === p).sort((a, b) => a.$seq - b.$seq) const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') beforeEach(() => { puts.length = 0 stops.length = 0 statsCalls.length = 0 statsFail = false config.analyse.enabled = true }) test('Auth: ohne Anmeldung 401', async () => { assert.equal((await post(`${A}/activate`, { projectId: 'p1' })).status, 401) assert.equal((await rawGet(`${A}/stats?projectId=p1`)).status, 401) assert.equal((await post(`${A}/heatmap-link`, {})).status, 401) }) test('Eigentum: fremdes Projekt 403', async () => { assert.equal((await post(`${A}/activate`, { projectId: 'p5', consent: consent() }, { cookie })).status, 403) assert.equal((await post(`${A}/deactivate`, { projectId: 'p5' }, { cookie })).status, 403) assert.equal((await rawGet(`${A}/stats?projectId=p5`, { cookie })).status, 403) assert.equal((await post(`${A}/heatmap-link`, { projectId: 'p5', path: '/', vp: 'd' }, { cookie })).status, 403) }) test('Schalter aus: alle Analyse-Routen 409', async () => { config.analyse.enabled = false for (const r of [ post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie }), post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }), rawGet(`${A}/stats?projectId=p1`, { cookie }), post(`${A}/heatmap-link`, { projectId: 'p1', path: '/', vp: 'd' }, { cookie }), adminPost('p1', { on: true, ...tf() }), ]) { const res = await r assert.equal(res.status, 409) assert.equal(res.body.error, 'Website-Analyse ist noch nicht freigeschaltet') } assert.equal(await featureDoc('c1', 'p1'), null) }) test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', async () => { const body = await get('/api/funktionen', { cookie }) const p = (id) => body.projects.find((x) => x.projectId === id) assert.equal(p('p1').features.news.state, 'off') assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] }, activatedAt: null }) assert.equal(p('p2').analyse.eligible, false) assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich') assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) config.analyse.enabled = false const off = await get('/api/funktionen', { cookie }) for (const x of off.projects) assert.deepEqual(x.analyse, { comingSoon: true }) assert.equal(off.projects[0].features.news.state, 'off') }) test('Aktivieren und Deaktivieren (Kunde)', async () => { const bad = await post(`${A}/activate`, { projectId: 'p1', accentColor: '#112233', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }, { cookie }) assert.equal(bad.status, 400) const ok = await post(`${A}/activate`, { projectId: 'p1', accentColor: '#112233', consent: consent() }, { cookie }) assert.equal(ok.status, 200) assert.deepEqual(ok.body, { ok: true, state: 'active' }) assert.equal(puts[0].active, true) assert.equal((await featureDoc('c1', 'p1')).accentColor, '#112233') const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse assert.equal(view.state, 'active') assert.equal(view.accentColor, '#112233') assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/) const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }) assert.deepEqual(off.body, { ok: true, state: 'off' }) assert.equal(stops.length, 1) const notElig = await post(`${A}/activate`, { projectId: 'p2', consent: consent() }, { cookie }) assert.equal(notElig.status, 409) }) test('Stats: Weiterleitung, Zeitraum, 503 bei Ausfall', async () => { const p3 = await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie }) assert.equal(p3.status, 200) const key = (await featureDoc('c1', 'p3')).siteKey const ok = await rawGet(`${A}/stats?projectId=p3&range=7`, { cookie }) assert.equal(ok.status, 200) assert.deepEqual(ok.body, { pv: 5 }) assert.deepEqual(statsCalls[0], { siteKey: key, range: 7 }) assert.equal((await rawGet(`${A}/stats?projectId=p3&range=5`, { cookie })).status, 400) statsFail = true const down = await rawGet(`${A}/stats?projectId=p3`, { cookie }) assert.equal(down.status, 503) assert.equal(down.body.error, 'Auswertung gerade nicht erreichbar') statsFail = false // aus, Daten noch da: weiter erlaubt await post(`${A}/deactivate`, { projectId: 'p3' }, { cookie }) assert.equal((await rawGet(`${A}/stats?projectId=p3`, { cookie })).status, 200) // nie aktiviert: keine Auswertung aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Neu', subdomain: 'neu', isPublic: true }) assert.equal((await rawGet(`${A}/stats?projectId=p6`, { cookie })).status, 409) }) test('GET /api/funktionen: devMode nur bei aktiver Analyse eines Dev-Projekts', async () => { const view = async (id) => (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === id).analyse assert.equal((await view('pd1')).devMode, false, 'nicht aktiv: kein Hinweis') await post(`${A}/activate`, { projectId: 'pd1', consent: consent() }, { cookie }) await post(`${A}/activate`, { projectId: 'pd2', consent: consent() }, { cookie }) assert.equal((await view('pd1')).devMode, true) assert.equal((await view('pd2')).devMode, false, 'node_build wird gemessen') await post(`${A}/deactivate`, { projectId: 'pd1' }, { cookie }) await post(`${A}/deactivate`, { projectId: 'pd2' }, { cookie }) }) test('Heatmap-Link: Prüfung, URL, Token', async () => { await post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie }) const key = (await featureDoc('c1', 'p1')).siteKey const link = (b) => post(`${A}/heatmap-link`, { projectId: 'p1', ...b }, { cookie }) for (const path of ['start', '/a?x=1', '/a#b', '/' + 'a'.repeat(300), 5]) assert.equal((await link({ path, vp: 'd' })).status, 400, String(path)) assert.equal((await link({ path: '/', vp: 'x' })).status, 400) const ok = await link({ path: '/preise', vp: 'm' }) assert.equal(ok.status, 200) const url = new URL(ok.body.url) assert.equal(url.origin, 'https://baeckerei.de') assert.equal(url.pathname, '/preise') const v = verifyHeatToken(url.searchParams.get('__wk_heat'), config.analyse.heatSecret, Date.now()) assert.equal(v.siteKey, key) assert.equal(v.path, '/preise') assert.equal(v.vp, 'm') assert.equal(url.searchParams.get('__wk_embed'), null, 'ohne embed kein Einbettungsparameter') // embed: fuer den Geraeterahmen im Portal const em = await link({ path: '/preise', vp: 'd', embed: true }) assert.equal(em.status, 200) const eu = new URL(em.body.url) assert.equal(eu.searchParams.get('__wk_embed'), '1') assert.equal(verifyHeatToken(eu.searchParams.get('__wk_heat'), config.analyse.heatSecret, Date.now()).vp, 'd') assert.equal(new URL((await link({ path: '/', vp: 'd', embed: 'ja' })).body.url).searchParams.get('__wk_embed'), null, 'nur true zaehlt') // ohne Live-Domain: Subdomain await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie }) const sub = await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie }) assert.equal(new URL(sub.body.url).origin, 'https://cafe.project.webklar.com') // nicht aktiv: 409 await post(`${A}/deactivate`, { projectId: 'p3' }, { cookie }) assert.equal((await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie })).status, 409) }) test('Admin: 401, 400, 200', async () => { assert.equal((await post('/api/admin/analyse/p6', { on: true })).status, 401) assert.equal((await adminPost('p6', { on: true, ...tf() }, 'jwt-kunde')).status, 403) const noTf = await adminPost('p6', { on: true, audienceConfirmed: true, textformChannel: 'chat' }) assert.equal(noTf.status, 400) assert.equal((await adminPost('p6', { on: true, ...tf({ textformDate: undefined }) })).status, 400) assert.equal((await adminPost('p6', { on: true, ...tf({ textformChannel: undefined }) })).status, 400) assert.equal((await adminPost('p6', { on: true, ...tf({ textformChannel: 'telefon' }) })).status, 400) assert.equal((await adminPost('p6', { on: true, ...tf({ textformDate: '1999-01-01' }) })).status, 400, 'vor der Fassung') assert.equal((await adminPost('p6', { on: true, ...tf({ audienceConfirmed: undefined }) })).status, 400) const old = await adminPost('p6', { on: true, avvConfirmedVerbally: true, ...tf() }) assert.equal(old.status, 400) assert.equal(old.body.error, 'Zustimmung in Textform nötig') assert.equal(await featureDoc('c1', 'p6'), null, 'nichts angelegt') assert.equal((await adminPost('p6', { on: 'ja' })).status, 400) assert.equal((await adminPost('nix', { on: true, ...tf() })).status, 404) const on = await adminPost('p6', { on: true, ...tf() }) assert.equal(on.status, 200) assert.deepEqual(on.body, { ok: true, state: 'active' }) const d = await featureDoc('c1', 'p6') assert.equal(d.avvAcceptedBy, 'kenso@webklar.com') assert.equal(d.avvVersion, config.analyse.avvVersion) assert.equal(d.avvConsentDate, todayBerlin()) assert.equal(d.avvConsentChannel, 'chat') const h = consentsOf('p6') assert.equal(h.length, 1) assert.equal(h[0].via, 'woms') assert.equal(h[0].acceptedBy, 'kenso@webklar.com') assert.equal(h[0].audienceConfirmed, true) assert.equal(h[0].audienceTextVersion, config.analyse.avvVersion) assert.equal(d.audienceConfirmed, true) assert.equal(h[0].textformDate, todayBerlin()) assert.equal(h[0].textformChannel, 'chat') const off = await adminPost('p6', { on: false }) assert.deepEqual(off.body, { ok: true, state: 'off' }) }) test('Admin: Lesezugriff GET', async () => { const adminGet = (id, jwt = 'jwt-admin') => rawGet(`/api/admin/analyse/${id}`, { headers: { Authorization: `Bearer ${jwt}` } }) assert.equal((await rawGet('/api/admin/analyse/p1')).status, 401) assert.equal((await adminGet('p1', 'jwt-kunde')).status, 403) assert.equal((await adminGet('nix')).status, 404) await adminPost('p1', { on: false }) const off = await adminGet('p1') assert.equal(off.status, 200) assert.equal(off.body.enabled, true) assert.equal(off.body.eligible, true) assert.equal(off.body.reasonText, null) assert.equal(off.body.avvVersion, config.analyse.avvVersion) await adminPost('p1', { on: true, ...tf() }) const on = await adminGet('p1') assert.equal(on.body.state, 'active') assert.equal(on.body.avvConsentDate, todayBerlin()) assert.equal(on.body.avvConsentChannel, 'chat') assert.deepEqual(on.body.privacy, { ok: true, missing: [] }) assert.equal(on.body.avvAcceptedBy, 'kenso@webklar.com') assert.ok(on.body.activatedAt) assert.ok(!('siteKey' in on.body) && !JSON.stringify(on.body).includes((await featureDoc('c1', 'p1')).siteKey)) const priv = await adminGet('p2') assert.equal(priv.body.eligible, false) assert.equal(priv.body.state, null) assert.equal(priv.body.reasonText, 'Die Website-Analyse gibt es nur für öffentliche Websites.') }) test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async () => { await adminPost('p6', { on: true, ...tf() }) const cur = await featureDoc('c1', 'p6') await sf.disable(cur.$id, { by: 'Test' }) puts.length = 0 const r = await post(`${A}/deactivate`, { projectId: 'p6' }, { cookie }) assert.deepEqual(r.body, { ok: true, state: 'disabled' }) assert.equal((await featureDoc('c1', 'p6')).state, 'disabled') const staff = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p6', by: 'Andrej' }) assert.equal(staff.doc.state, 'off') }) test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst per stopSite', async () => { await adminPost('p6', { on: true, ...tf() }) const cur = await featureDoc('c1', 'p6') puts.length = 0 stops.length = 0 await sf.disable(cur.$id, { by: 'Test' }) assert.deepEqual(stops, [cur.siteKey]) assert.equal(puts.length, 0) // Ausfall des Sammeldienstes verhindert das Abschalten nicht const real = an.analyseDeps.client.stopSite an.analyseDeps.client.stopSite = async () => { throw new Error('weg') } const again = await sf.disable(cur.$id, { by: 'Test' }) an.analyseDeps.client.stopSite = real assert.equal(again.state, 'disabled') }) test('Härtung: ungültige Hosts, Pfadzeichen, Fehler bei einem Projekt', async () => { // analyseDomains: ungueltige Hosts werden uebersprungen assert.deepEqual(an.analyseDomains({ subdomain: 'cafe', liveDomain: 'https://Cafe.DE/x' }), ['cafe.project.webklar.com', 'cafe.de', 'www.cafe.de']) assert.deepEqual(an.analyseDomains({ subdomain: 'cafe', liveDomain: 'evil.de@x y' }), ['cafe.project.webklar.com']) assert.deepEqual(an.analyseDomains({ subdomain: 'ca fe', liveDomain: 'a_b.de' }), []) assert.deepEqual(an.analyseDomains({ subdomain: 'cafe', liveDomain: 'localhost' }), ['cafe.project.webklar.com']) // Heatmap-Pfad: Steuerzeichen, Backslash, Leerraum => 400; dekodierte Form bleibt erlaubt await post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie }) const link = (path) => post(`${A}/heatmap-link`, { projectId: 'p1', path, vp: 'd' }, { cookie }) for (const path of ['/a\\b', '/a b', '/a\nb', '/a\u0000b', '/a\tb', '/a\u007fb']) assert.equal((await link(path)).status, 400, JSON.stringify(path)) assert.equal((await link('/über-uns')).status, 200) // keine gueltige Domain => Aktivieren scheitert mit 409, vor dem Sammeldienst aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Kaputt', subdomain: 'ka putt', liveDomain: 'x_y', isPublic: true }) const before = puts.length const bad = await post(`${A}/activate`, { projectId: 'p7', consent: consent() }, { cookie }) assert.equal(bad.status, 409) assert.equal(bad.body.error, 'Keine gültige Domain für die Analyse') assert.equal(puts.length, before) }) test('Kunden-Aktivierung schreibt Historie mit Sitzungsnamen; fehlende Angaben: controller + 409', async () => { aw.seed('customers', { $id: 'c9', appwriteUserId: 'u9', email: 'neun', name: '', status: 'active', portalAccessEnabled: true }) aw.seed('websiteProjects', { $id: 'p9', customerId: 'c9', projectName: 'Leer', subdomain: 'leer', isPublic: true }) aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_w_c9', stripeSubscriptionId: 'sub_w_c9', customerId: 'c9' }) const c9 = sessionCookie({ customerId: 'c9', appwriteUserId: 'u9', name: 'Neun', email: 'neun' }) const view = (await get('/api/funktionen', { cookie: c9 })).projects.find((x) => x.projectId === 'p9').analyse assert.deepEqual(view.controller, { company: '', address: '', email: 'neun', ok: false, missing: ['firma', 'email'] }) const r = await post(`${A}/activate`, { projectId: 'p9', consent: consent() }, { cookie: c9 }) assert.equal(r.status, 409) assert.match(r.body.error, /fehlen in Deinen Kundendaten Firma oder Name/) assert.equal(consentsOf('p9').length, 0) const before = consentsOf('p3').length await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie }) const h = consentsOf('p3') assert.equal(h.length, before + 1) assert.equal(h.at(-1).acceptedBy, 'Kunde Eins ') assert.equal(h.at(-1).audienceConfirmed, true) assert.equal(h.at(-1).via, 'portal') const admin = (await rawGet('/api/admin/analyse/p9', { headers: { Authorization: 'Bearer jwt-admin' } })).body assert.deepEqual(admin.privacy, { ok: false, missing: ['firma', 'email'] }) }) test('Admin-Sitzung (Impersonation): handelnde Person ist der Admin, nicht der Kunde', async () => { aw.seed('websiteProjects', { $id: 'p20', customerId: 'c1', projectName: 'Imp', subdomain: 'imp', isPublic: true }) const c = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', name: 'Kunde Eins', email: 'k@example.invalid', adminName: 'Kenso', adminEmail: 'kenso@webklar.com' }) assert.equal((await post(`${A}/activate`, { projectId: 'p20', consent: consent() }, { cookie: c })).status, 200) const h = consentsOf('p20') assert.equal(h[0].acceptedBy, 'Kenso ') assert.equal((await featureDoc('c1', 'p20')).avvAcceptedBy, 'Kenso ') })