Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz

Router /api/public/termine/v1 (config, tage, zeiten, buchungen, widerruf, Token-Seiten /termin/*, Kalender-Abo) mit
eigenem 16-KB-Parser und Fehlermiddleware, CORS nach Herkunft, Honeypot und Formular-Token, Grenzen je IP (IPv6 je /64),
siteKey und Adresse in limits.js, Mindestantwortzeit (TERMINE_MIN_ANTWORT_MS) und 10-Sekunden-Cache fuer tage/zeiten.
Vor dem globalen express.json eingehaengt; /api/public/ ist vom Kundenansicht-Waechter ausgenommen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 43ac05aa06
commit 2b0c05b8e9
10 files changed
+1928 -7

No files matched your search

+29 -1
View File
@@ -37,7 +37,9 @@ const MOUNTS = [
['/api/abo', (await import('../server/routes/abo.js')).default],
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
]
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS) })
// 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)
@@ -210,3 +212,29 @@ test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder
// 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)
})