POST /api/termine/einstellungen/vorlage nimmt jetzt eine Branche (BRANCHEN), optionale
Betriebsfelder (nichtleere Werte ueberschreiben das Konto) und schreibt den Fragebogen mit
der Einstellung in einem Vorgang. Der Gesundheitsbereich ist ausgeschlossen (400
gesundheitsbereich, nichts wird angelegt). Eine bestehende Einstellung ersetzt nur
ersetzen:true mit passender version im Zustand off (neue IDs und Kalender-Token, Fragebogen
neu, Einbauwuensche bleiben). Die Route arbeitet im Entwurf (off, active, ordered/setup nur
in der Kundenansicht, disabled nie).
PUT /api/termine/fragebogen speichert nur den Fragebogen (Version der Einstellung bleibt),
ist in der Kundenansicht gesperrt (Waechter und Router), zaehlt gegen das Schreibbudget und
antwortet mit { fragebogen, offen }.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
323 lines
18 KiB
JavaScript
323 lines
18 KiB
JavaScript
import { test, after } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { mkdtempSync } from 'node:fs'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { startFakeStripe } from './support/fakeStripe.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
|
|
// Waechter der Kundenansicht: Ablauf nach 2 h, Verwechslungsschutz (X-Kundenansicht), Schreibsperre ausser News
|
|
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'kundenansicht-guard-'))
|
|
const aw = await startAppwriteMock({
|
|
schema: {
|
|
customerViewLog: schemaKeys('customerViewLog'),
|
|
portalFeatures: schemaKeys('portalFeatures'),
|
|
newsPosts: schemaKeys('newsPosts'),
|
|
},
|
|
})
|
|
const st = await startFakeStripe()
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
const { config } = await import('../server/config.js')
|
|
const { startTestApp } = await import('./support/testApp.js')
|
|
const { seedFixtures, customerCookie, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js')
|
|
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
|
|
const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js')
|
|
|
|
// Kunden-Router mit denselben Mount-Pfaden wie server/index.js
|
|
const termineRouter = (await import('../server/routes/termine.js')).default
|
|
const MOUNTS = [
|
|
['/api/auth', (await import('../server/routes/auth.js')).default],
|
|
['/api/projects', (await import('../server/routes/projects.js')).default],
|
|
['/api/features', (await import('../server/routes/features.js')).default],
|
|
['/api/news', (await import('../server/routes/news.js')).default],
|
|
['/api', (await import('../server/routes/customerData.js')).default],
|
|
['/api/chat', (await import('../server/routes/chat.js')).default],
|
|
['/api/abo/takeover-credits', (await import('../server/routes/takeoverCredits.js')).default],
|
|
['/api/abo', (await import('../server/routes/abo.js')).default],
|
|
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
|
|
['/api/termine', termineRouter],
|
|
]
|
|
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
|
|
const oeffentlich = (await import('../server/routes/publicTermine.js')).default
|
|
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS), '/api/public/termine/v1': oeffentlich })
|
|
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
|
|
|
seedFixtures(aw)
|
|
aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true })
|
|
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
|
|
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
|
|
|
|
const VIEW = 'a'.repeat(32)
|
|
const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
|
|
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
|
|
|
|
async function call(method, url, { cookie, headers = {}, body } = {}) {
|
|
const res = await fetch(app.url + url, {
|
|
method,
|
|
redirect: 'manual',
|
|
headers: { ...(cookie ? { cookie } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
})
|
|
const text = await res.text()
|
|
let parsed = text
|
|
try {
|
|
parsed = text ? JSON.parse(text) : null
|
|
} catch {
|
|
/* Text-Antwort */
|
|
}
|
|
return { status: res.status, body: parsed, headers: res.headers }
|
|
}
|
|
|
|
/** Alle nicht-lesenden Routen der Router (router.stack), :param durch 'x' ersetzt. */
|
|
function writeRoutes(mounts) {
|
|
const routes = []
|
|
for (const [prefix, router] of mounts) {
|
|
for (const layer of router.stack) {
|
|
if (!layer.route) continue
|
|
for (const method of Object.keys(layer.route.methods)) {
|
|
if (['get', 'head', 'options', '_all'].includes(method)) continue
|
|
const sub = String(layer.route.path).replace(/:[A-Za-z]+/g, 'x')
|
|
routes.push({ method: method.toUpperCase(), path: `${prefix === '/api' ? '/api' : prefix}${sub === '/' ? '' : sub}` || '/' })
|
|
}
|
|
}
|
|
}
|
|
return routes
|
|
}
|
|
const allowed = (method, p) => KUNDENANSICHT_ALLOWED.some(([m, re]) => m === method && re.test(p))
|
|
const logEntries = () => [...aw.docs('customerViewLog').values()]
|
|
// geloeschtes Cookie: Ablauf 1970 (ein signiertes behaelt dabei einen leeren signierten Wert "s:.<sig>")
|
|
const clears = (res, name) => res.headers.getSetCookie().some((c) => c.startsWith(`${name}=`) && /Expires=Thu, 01 Jan 1970/.test(c))
|
|
|
|
test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', async () => {
|
|
const routes = writeRoutes(MOUNTS)
|
|
assert.ok(routes.length >= 25, `nur ${routes.length} Schreib-Routen gefunden`)
|
|
let checked = 0
|
|
for (const { method, path: p } of routes) {
|
|
if (allowed(method, p)) continue
|
|
const res = await call(method, p, kv())
|
|
assert.equal(res.status, 403, `${method} ${p}`)
|
|
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
|
|
checked += 1
|
|
}
|
|
assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`)
|
|
})
|
|
|
|
// Checkout je Funktion (POST /api/funktionen/:key/checkout): die Schleife oben findet die Route ueber :key => x und sperrt sie,
|
|
// ohne dass sie auf der Erlaubnisliste steht (die Route prueft zusaetzlich selbst req.session.viewAs)
|
|
test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /api/funktionen/x/checkout, Alias news auch)', async () => {
|
|
const routes = writeRoutes(MOUNTS)
|
|
assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/x/checkout'), 'Route :key/checkout gefunden')
|
|
for (const key of ['x', 'news', 'termine']) {
|
|
const p = `/api/funktionen/${key}/checkout`
|
|
assert.ok(!allowed('POST', p), p)
|
|
const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', fortsetzen: true } })
|
|
assert.equal(res.status, 403, p)
|
|
assert.equal(res.body.code, 'view_blocked', p)
|
|
}
|
|
})
|
|
|
|
test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => {
|
|
const ok = [
|
|
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
|
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
|
|
['POST', '/api/auth/logout'], ['POST', '/api/auth/login'],
|
|
['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
|
|
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
|
|
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
|
|
]
|
|
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
|
|
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard')
|
|
for (const [m, p] of [
|
|
['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'],
|
|
// Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen
|
|
['POST', '/api/termineX'], ['POST', '/api/termineX/buchungen'], ['POST', '/api/termine'], ['POST', '/api/termine/'],
|
|
// frueher durch die Wildcard POST /api/termine/.+ erlaubt, jetzt nur noch die ausgeschriebenen Pfade
|
|
['POST', '/api/termine/zukunft'], ['POST', '/api/termine/fragebogen'], ['PUT', '/api/termine/fragebogen'], ['POST', '/api/termine/export.csv'],
|
|
['POST', '/api/termine/buchungen/abc'], ['POST', '/api/termine/buchungen/abc/aktion/x'], ['POST', '/api/termine/einstellungen/vorlage/x'],
|
|
['POST', '/api/termine/ressourcen/abc/feed/x'], ['POST', '/api/funktionen/termine/checkout'],
|
|
['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'],
|
|
['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'],
|
|
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'],
|
|
]) {
|
|
assert.ok(!allowed(m, p), `${m} ${p}`)
|
|
}
|
|
})
|
|
|
|
// Spec 2026-10-03, Abschnitt 3: unbekannte Schreib-Pfade unter /api/termine sind in der Kundenansicht gesperrt, bevor der Router
|
|
// sie sieht (auch Pfade ohne Route und Gross-/Kleinschreibung); normale Kunden beruehrt der Waechter nicht.
|
|
// PUT /fragebogen hat seit C5 eine echte Route: in der Kundenansicht sperrt sie der Waechter (403 view_blocked, der Router kommt nie
|
|
// dran), ein normaler Kunde mit eigenem Projekt bekommt die Antwort der Route (hier 409 "noch nicht verfuegbar": der Pilot-Schalter
|
|
// steht im Test auf off; weder 403 noch 404). Der 403 am Router selbst (ohne Waechter) steht in test/termineFragebogenRoutes.test.js.
|
|
test('unbekanntes POST/PUT unter /api/termine in der Kundenansicht -> 403 view_blocked, normale Kunden nicht', async () => {
|
|
const faelle = [
|
|
['POST', '/api/termine/zukunft'],
|
|
['PUT', '/api/termine/fragebogen', { projectId: 'p1', fragebogen: {} }],
|
|
['POST', '/api/termine/buchungen/abc/aktion/x'],
|
|
['POST', '/API/Termine/Zukunft'],
|
|
]
|
|
for (const [method, p, body = {}] of faelle) {
|
|
const gesperrt = await call(method, p, { ...kv(), body })
|
|
assert.equal(gesperrt.status, 403, `${method} ${p}`)
|
|
assert.equal(gesperrt.body.code, 'view_blocked', `${method} ${p}`)
|
|
const kunde = await call(method, p, { cookie: customerCookie('c1'), body })
|
|
assert.notEqual(kunde.status, 403, `${method} ${p}: normaler Kunde`)
|
|
if (p.endsWith('/fragebogen')) {
|
|
assert.equal(kunde.status, 409, `${method} ${p}: die Route antwortet selbst`)
|
|
assert.equal(kunde.body.code, 'nicht_verfuegbar', `${method} ${p}`)
|
|
} else {
|
|
assert.equal(kunde.status, 404, `${method} ${p}: der Router kennt den Pfad nicht`)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Router-Abgleich: die Erlaubnisliste und die echten Schreib-Routen des Terminplaners decken sich genau; eine neue Route faellt
|
|
// im Test "jede Schreib-Route ausserhalb der Erlaubnisliste -> 403" auf (sie muss gesperrt sein), ein toter Eintrag hier
|
|
test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf der Erlaubnisliste', () => {
|
|
const erlaubt = writeRoutes([['/api/termine', termineRouter]])
|
|
.filter(({ method, path: p }) => allowed(method, p))
|
|
.map(({ method, path: p }) => `${method} ${p}`)
|
|
.sort()
|
|
assert.deepEqual(erlaubt, [
|
|
'DELETE /api/termine/buchungen/x',
|
|
'PATCH /api/termine/einstellungen',
|
|
'POST /api/termine/buchungen',
|
|
'POST /api/termine/buchungen/x/aktion',
|
|
'POST /api/termine/einstellungen/vorlage',
|
|
'POST /api/termine/ressourcen/x/feed',
|
|
])
|
|
// und jeder Termine-Eintrag der Liste trifft genau eine echte Route (kein toter, kein doppelter Eintrag)
|
|
const routen = writeRoutes([['/api/termine', termineRouter]])
|
|
const termineEintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('termine'))
|
|
assert.equal(termineEintraege.length, 6)
|
|
for (const [m, re] of termineEintraege) {
|
|
assert.equal(routen.filter((r) => r.method === m && re.test(r.path)).length, 1, `${m} ${re}`)
|
|
}
|
|
})
|
|
|
|
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
|
|
const res = await call('POST', '/api/chat/read', kv())
|
|
assert.equal(res.status, 403)
|
|
})
|
|
|
|
test('News anlegen in der Kundenansicht erlaubt', async () => {
|
|
const res = await call('POST', '/api/news', { ...kv(), body: note })
|
|
assert.equal(res.status, 201)
|
|
})
|
|
|
|
test('fremde Kennung -> 409 view_replaced mit Kundennamen, auch beim Lesen', async () => {
|
|
const res = await call('GET', '/api/projects', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } })
|
|
assert.equal(res.status, 409)
|
|
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Eins'])
|
|
})
|
|
|
|
test('Schreiben ohne Kennung -> 409, Lesen ohne Kennung -> erlaubt', async () => {
|
|
const write = await call('POST', '/api/news', { cookie: kundenansichtCookie(), body: note })
|
|
assert.equal(write.status, 409)
|
|
assert.equal(write.body.code, 'view_replaced')
|
|
const read = await call('GET', '/api/news?projectId=p1', { cookie: kundenansichtCookie() })
|
|
assert.equal(read.status, 200)
|
|
})
|
|
|
|
test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abgelaufen genau einmal', async (t) => {
|
|
const at = Date.now()
|
|
t.mock.method(Date, 'now', () => at)
|
|
const viewId = 'e'.repeat(32)
|
|
const opts = kv({ viewId, viewExpiresAt: at })
|
|
const before = logEntries().filter((d) => d.action === 'abgelaufen').length
|
|
const first = await call('GET', '/api/projects', opts)
|
|
const second = await call('POST', '/api/news', { ...opts, body: note })
|
|
for (const res of [first, second]) {
|
|
assert.equal(res.status, 401)
|
|
assert.equal(res.body.code, 'view_expired')
|
|
assert.equal(res.body.error, 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.')
|
|
assert.ok(clears(res, config.cookieName), 'Portal-Cookie geloescht')
|
|
assert.ok(clears(res, 'preview_session'), 'Preview-Cookie geloescht')
|
|
}
|
|
const expired = logEntries().filter((d) => d.action === 'abgelaufen')
|
|
assert.equal(expired.length, before + 1)
|
|
assert.deepEqual([expired.at(-1).customerId, expired.at(-1).staffName], ['c1', 'Andrej'])
|
|
})
|
|
|
|
test('alte Ansicht ohne viewId (frueheres Admin-Panel): Lesen, Schreiben, Logout 401 view_expired, Cookies weg, kein Protokoll', async () => {
|
|
const cookie = viewAsCookie('c1')
|
|
const before = logEntries().length
|
|
for (const [method, p, body] of [['GET', '/api/news?projectId=p1'], ['POST', '/api/news', note], ['POST', '/api/auth/logout']]) {
|
|
const res = await call(method, p, { cookie, body })
|
|
assert.equal(res.status, 401, `${method} ${p}`)
|
|
assert.equal(res.body.code, 'view_expired', `${method} ${p}`)
|
|
assert.ok(clears(res, config.cookieName), `${method} ${p}: Portal-Cookie geloescht`)
|
|
}
|
|
assert.equal(logEntries().length, before)
|
|
})
|
|
|
|
test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => {
|
|
const res = await call('POST', '/api/news', { cookie: customerCookie('c1'), body: note })
|
|
assert.equal(res.status, 201)
|
|
})
|
|
|
|
test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines neuen Links', async () => {
|
|
const cookie = kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 })
|
|
assert.equal((await call('GET', '/api/auth/me', { cookie })).status, 200)
|
|
const token = createStaffPreviewToken({
|
|
purpose: 'kundenansicht',
|
|
customerId: 'c1',
|
|
staff: { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' },
|
|
})
|
|
const res = await call('GET', `/api/auth/kundenansicht?token=${token}`, { cookie })
|
|
assert.equal(res.status, 302)
|
|
assert.equal(res.headers.get('location'), '/dashboard.html')
|
|
})
|
|
|
|
// Abschluss-Review: Express routet ohne Ruecksicht auf Gross-/Kleinschreibung, der Waechter muss das auch
|
|
test('Grossschreibung umgeht den Waechter nicht: /API/... wie /api/...', async () => {
|
|
const routes = writeRoutes(MOUNTS).filter(({ method, path: p }) => !allowed(method, p))
|
|
for (const { method, path: p } of routes) {
|
|
const upper = p.replace(/^\/api\//, '/API/')
|
|
const res = await call(method, upper, kv())
|
|
assert.equal(res.status, 403, `${method} ${upper}`)
|
|
}
|
|
const legacy = await call('POST', '/API/news', { cookie: viewAsCookie('c1'), body: note })
|
|
assert.equal(legacy.status, 401)
|
|
const expired = await call('GET', '/Api/Projects', kv({ viewExpiresAt: Date.now() - 1000 }))
|
|
assert.equal(expired.status, 401)
|
|
})
|
|
|
|
test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder Daten', async () => {
|
|
const header = { 'x-kundenansicht': VIEW }
|
|
const res = await call('GET', '/api/projects', { cookie: customerCookie('c2'), headers: header })
|
|
assert.equal(res.status, 409)
|
|
assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Zwei'])
|
|
// alte Ansicht ohne Kennung: beendet, ebenfalls keine fremden Daten
|
|
assert.equal((await call('GET', '/api/projects', { cookie: viewAsCookie('c2'), headers: header })).status, 401)
|
|
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
|
|
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
|
|
})
|
|
|
|
// Terminplaner (Aufgabe 16): Gaeste buchen ohne Sitzung; ein Kundenansicht-Cookie im selben Browser (Mitarbeiter testet die
|
|
// Website des Kunden) darf ihre POSTs weder sperren noch beenden
|
|
test('/api/public/ ist ausgenommen: Kundenansicht-Cookie sperrt oeffentliche POSTs nicht und beendet keine Ansicht', async (t) => {
|
|
const vorher = config.termine.minAntwortMs
|
|
config.termine.minAntwortMs = 0 // sonst wartet jede Buchungsantwort 300 ms
|
|
t.after(() => (config.termine.minAntwortMs = vorher))
|
|
const url = '/api/public/termine/v1/abcdefghijklmnop/buchungen'
|
|
const faelle = [
|
|
['Kundenansicht mit Kennung', kv()],
|
|
['Kundenansicht ohne Kennung (sonst 409)', { cookie: kundenansichtCookie() }],
|
|
['fremde Kennung (sonst 409)', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } }],
|
|
['abgelaufene Ansicht (sonst 401)', kv({ viewExpiresAt: Date.now() - 1000 })],
|
|
['alte Ansicht ohne viewId (sonst 401)', { cookie: viewAsCookie('c1') }],
|
|
]
|
|
for (const [was, opts] of faelle) {
|
|
for (const pfad of [url, url.replace('/api/public/', '/API/Public/')]) {
|
|
const res = await call('POST', pfad, { ...opts, body: {} })
|
|
assert.equal(res.status, 200, `${was} ${pfad}`)
|
|
assert.deepEqual(res.body, { aktiv: false }, `${was} ${pfad}`) // Modus off: die Schnittstelle antwortet selbst
|
|
assert.equal(clears(res, config.cookieName), false, `${was}: das Portal-Cookie bleibt`)
|
|
}
|
|
}
|
|
// ausserhalb von /api/public/ bleibt die Sperre
|
|
assert.equal((await call('POST', '/api/chat/read', kv())).status, 403)
|
|
})
|