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'] const aw = await startAppwriteMock({ schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions') }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, get, rawGet, post } = 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 statsCalls = [] let statsFail = false const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' }) an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), 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) 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 featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') beforeEach(() => { puts.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, avvConfirmedVerbally: true }), ]) { 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 }) 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') const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }) assert.deepEqual(off.body, { ok: true, state: 'off' }) assert.equal(puts[1].active, false) 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') // 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, avvConfirmedVerbally: true }, 'jwt-kunde')).status, 403) const noAvv = await adminPost('p6', { on: true }) assert.equal(noAvv.status, 400) assert.equal(noAvv.body.error, 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat') assert.equal((await adminPost('p6', { on: 'ja' })).status, 400) assert.equal((await adminPost('nix', { on: true, avvConfirmedVerbally: true })).status, 404) const on = await adminPost('p6', { on: true, avvConfirmedVerbally: true }) 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) 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, avvConfirmedVerbally: true }) const on = await adminGet('p1') assert.equal(on.body.state, 'active') 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, avvConfirmedVerbally: true }) 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 active:false', async () => { await adminPost('p6', { on: true, avvConfirmedVerbally: true }) const cur = await featureDoc('c1', 'p6') puts.length = 0 await sf.disable(cur.$id, { by: 'Test' }) assert.equal(puts.length, 1) assert.deepEqual(puts[0], { siteKey: cur.siteKey, projectId: 'p6', domains: ['neu.project.webklar.com'], active: false }) // Ausfall des Sammeldienstes verhindert das Abschalten nicht const real = an.analyseDeps.client.putSite an.analyseDeps.client.putSite = async () => { throw new Error('weg') } const again = await sf.disable(cur.$id, { by: 'Test' }) an.analyseDeps.client.putSite = 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) })