Files
Webklar-Kundenbereich/test/kundenansichtGuard.test.js
T
ClaudeandClaude Opus 5.5 e87140e5ba Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:12:23 +00:00

409 lines
24 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 zeitenRouter = (await import('../server/routes/zeiten.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],
['/api/shop', (await import('../server/routes/shop.js')).default],
['/api/zeiten', zeitenRouter],
]
// 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)
}
})
// Gratisweg "Einschalten" der Oeffnungszeiten (POST /api/funktionen/zeiten/einschalten): eine Schreib-Route, die nicht auf der Erlaubnisliste
// steht; Mitarbeiter schalten nicht fuer den Kunden ein. Der Waechter sperrt sie vor dem Router (die Route prueft zusaetzlich selbst viewAs).
test('Einschalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefunden, nicht erlaubt, 403 view_blocked)', async () => {
const routes = writeRoutes(MOUNTS)
assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/zeiten/einschalten'), 'Route gefunden')
const p = '/api/funktionen/zeiten/einschalten'
assert.ok(!allowed('POST', p), p)
assert.ok(!allowed('POST', `${p}/`), `${p}/`)
const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', zustimmung: { version: '2026-10-04', haken: ['leistung'] } } })
assert.equal(res.status, 403)
assert.equal(res.body.code, 'view_blocked')
})
// "Fuer 30 EUR behalten" (POST /api/funktionen/zeiten/behalten): ebenfalls eine Bestellung im Namen des Kunden, nicht auf der Erlaubnisliste.
test('Behalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefunden, nicht erlaubt, 403 view_blocked)', async () => {
const routes = writeRoutes(MOUNTS)
assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/zeiten/behalten'), 'Route gefunden')
const p = '/api/funktionen/zeiten/behalten'
assert.ok(!allowed('POST', p), p)
assert.ok(!allowed('POST', `${p}/`), `${p}/`)
const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', zustimmung: { version: '2026-10-04', haken: ['leistung', 'beginn'] } } })
assert.equal(res.status, 403)
assert.equal(res.body.code, 'view_blocked')
})
test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Öffnungszeiten, 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/'],
['POST', '/api/shop/produkte'], ['POST', '/api/shop/produkte/'], ['POST', '/api/shop/produkte/sortierung'], ['PUT', '/api/shop/produkte/abc'],
['DELETE', '/api/shop/produkte/abc'], ['PUT', '/api/shop/einstellungen'], ['PUT', '/api/shop/rechtstexte'], ['POST', '/api/shop/bild'],
['PUT', '/api/zeiten/abc'], ['PUT', '/api/zeiten/abc/'],
]
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 7 + 1, 'genau sechs Termine-, sieben Webshop- und ein Zeiten-Eintrag 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'],
// Webshop: Anfrage stellen und zurueckziehen sind in der Kundenansicht gesperrt (nicht auf der Liste)
['POST', '/api/shop/anfrage'], ['POST', '/api/shop/anfrage/zurueckziehen'],
// Webshop: Oeffnen und Bestaetigen der Rechtstexte macht der Haendler selbst; Stripe und Erstattungen sind nie dabei
['POST', '/api/shop/oeffnen'], ['POST', '/api/shop/rechtstexte/bestaetigen'], ['POST', '/api/shop/stripe/verbinden'], ['POST', '/api/shop/bestellungen/abc/erstatten'],
// und nur die ausgeschriebenen Pfade und Methoden der Verwaltung
['POST', '/api/shop/rechtstexte'], ['POST', '/api/shop/einstellungen'], ['PATCH', '/api/shop/produkte/abc'], ['POST', '/api/shop/produkte/abc'],
['PUT', '/api/shop/produkte'], ['DELETE', '/api/shop/produkte'], ['PUT', '/api/shop/bild'], ['POST', '/api/shop/bild/x'],
['POST', '/api/shopX/produkte'], ['PUT', '/api/shop/produkte/abc/def'], ['DELETE', '/api/shop/einstellungen'],
// Oeffnungszeiten: nur PUT auf das Projekt, sonst nichts
['PUT', '/api/zeiten'], ['PUT', '/api/zeiten/'], ['PUT', '/api/zeiten/abc/vorschau'], ['PUT', '/api/zeiten/abc/def/ghi'], ['PUT', '/api/zeitenX/abc'],
['POST', '/api/zeiten/abc'], ['PATCH', '/api/zeiten/abc'], ['DELETE', '/api/zeiten/abc'], ['POST', '/api/zeiten'],
]) {
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 Webshop: genau die sieben Verwaltungs-Schreib-Routen stehen auf der Liste, jeder Eintrag trifft genau eine echte Route
test('Router-Abgleich: genau sieben Schreib-Routen des Webshops stehen auf der Erlaubnisliste', () => {
const shopRouter = MOUNTS.find(([prefix]) => prefix === '/api/shop')[1]
const routen = writeRoutes([['/api/shop', shopRouter]])
assert.deepEqual(
routen.filter(({ method, path: p }) => allowed(method, p)).map(({ method, path: p }) => `${method} ${p}`).sort(),
['DELETE /api/shop/produkte/x', 'POST /api/shop/bild', 'POST /api/shop/produkte', 'POST /api/shop/produkte/sortierung',
'PUT /api/shop/einstellungen', 'PUT /api/shop/produkte/x', 'PUT /api/shop/rechtstexte']
)
const shopEintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('shop'))
assert.equal(shopEintraege.length, 7)
for (const [m, re] of shopEintraege) assert.equal(routen.filter((r) => r.method === m && re.test(r.path)).length, 1, `${m} ${re}`)
// gesperrt bleiben insbesondere Anfrage, Oeffnen und Bestaetigen
for (const p of ['/api/shop/anfrage', '/api/shop/anfrage/zurueckziehen', '/api/shop/oeffnen', '/api/shop/rechtstexte/bestaetigen']) {
assert.ok(routen.some((r) => r.method === 'POST' && r.path === p), `Route ${p} existiert`)
assert.ok(!allowed('POST', p), p)
}
})
// 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}`)
}
})
// Oeffnungszeiten: die Erlaubnisliste und die echten Schreib-Routen decken sich genau (eine Route, ein Eintrag); jedes andere
// Schreiben unter /api/zeiten (auch ohne Route, auch Gross-/Kleinschreibung) sperrt der Waechter, normale Kunden beruehrt er nicht.
test('Router-Abgleich: genau eine Schreib-Route der Öffnungszeiten (PUT /:projectId) steht auf der Erlaubnisliste', async () => {
const routen = writeRoutes([['/api/zeiten', zeitenRouter]])
assert.deepEqual(routen.map(({ method, path: p }) => `${method} ${p}`), ['PUT /api/zeiten/x'], 'der Router schreibt nur per PUT /:projectId')
const eintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('zeiten'))
assert.equal(eintraege.length, 1)
assert.ok(allowed('PUT', '/api/zeiten/x'))
for (const [method, p] of [['POST', '/api/zeiten/p1'], ['PATCH', '/api/zeiten/p1'], ['DELETE', '/api/zeiten/p1'], ['PUT', '/api/zeiten/p1/vorschau'], ['PUT', '/api/zeiten'], ['POST', '/API/Zeiten/p1']]) {
const res = await call(method, p, { ...kv(), body: {} })
assert.equal(res.status, 403, `${method} ${p}`)
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
}
// ohne Kennung im Header gilt auch der erlaubte Pfad nicht (wie bei allen Schreib-Routen)
const ohneKennung = await call('PUT', '/api/zeiten/p1', { cookie: kundenansichtCookie(), body: {} })
assert.equal(ohneKennung.status, 409)
assert.equal(ohneKennung.body.code, 'view_replaced')
// der erlaubte Pfad erreicht den Router (hier: das Projekt gehoert c1, die Funktion gibt es nicht => 403 der Route, nicht des Waechters)
const durchgelassen = await call('PUT', '/api/zeiten/p1', { ...kv(), body: { version: 0, daten: {} } })
assert.equal(durchgelassen.status, 403)
assert.notEqual(durchgelassen.body.code, 'view_blocked')
assert.match(durchgelassen.body.error, /nicht eingeschaltet/)
})
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)
})