Files
Webklar-Kundenbereich/test/analyseRoutes.test.js
T
2026-10-01 10:22:19 +00:00

240 lines
13 KiB
JavaScript

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: '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', 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('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('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'])
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 => 409
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Kaputt', subdomain: 'ka putt', liveDomain: 'x_y', isPublic: true })
await post(`${A}/activate`, { projectId: 'p7', consent: consent() }, { cookie })
const bad = await post(`${A}/heatmap-link`, { projectId: 'p7', path: '/', vp: 'd' }, { cookie })
assert.equal(bad.status, 409)
assert.equal(bad.body.error, 'Keine gültige Domain')
})