POST /api/funktionen/:key/checkout ersetzt /news/checkout (der News-Pfad bleibt als key = news erhalten). Stufen ohne Seiteneffekt: 1 Kundenansicht (403), 2 Schluessel gegen das Register (404), 3 Zustimmung (400 zustimmung_fehlt, 409 fassung_veraltet mit der Serverfassung; News weiter mit consent: true), 4 Projekt (400/403; bei fortsetzen muss das Dokument ordered sein), 5 Schalter (409 nicht_verfuegbar), 5b Unterlagen (503 unterlagen_fehlen), 6 Bestellpruefung (409 bestellung_offen mit offen), 7 Wartungs-Abo (409 kein_abo | gekuendigt | nicht_aktiv, Lesefehler 500). fail() gibt err.body weiter. featureOrder.js: pruefeZustimmung, istFortsetzen, aboMeldung (einzige Quelle der Abo-Hinweistexte), ladeEntwurf, pruefeAbo, orderDeps. featureRegistry.js: featureSperre und die Standard-Haken verfuegbar, bestellpruefung (mitAbo: false, ohne abo) und unterlagenBereit des Terminplaners. Der Rest der Route (Sperre, Zustand, Sitzung, Nachweis) bleibt bis Task B7 nur fuer News; andere Funktionen enden mit 503. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
316 lines
17 KiB
JavaScript
316 lines
17 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
|
|
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'],
|
|
['POST', '/api/termine/buchungen/abc/aktion/x'],
|
|
['POST', '/API/Termine/Zukunft'],
|
|
]
|
|
for (const [method, p] 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`)
|
|
// PUT /fragebogen bekommt in Block C eine echte Route (dann antwortet sie selbst); die anderen Pfade gibt es nie
|
|
if (!p.endsWith('/fragebogen')) 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)
|
|
})
|