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 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', (await import('../server/routes/termine.js')).default], ] // 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:.") 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`) }) 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 + 3, 'genau drei Termine-Eintraege dazu') 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/'], ['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}`) } }) 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) })