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>
162 lines
8.3 KiB
JavaScript
162 lines
8.3 KiB
JavaScript
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import http from 'node:http'
|
|
import { readFileSync } from 'node:fs'
|
|
import express from 'express'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
|
|
|
// Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlermiddleware mit: hier haengt er an einer nackten
|
|
// express()-App ohne globalen Parser und ohne eigene Fehlerbehandlung. Was beim Gast ankommt, kommt nur vom Router.
|
|
const aw = await startAppwriteMock({
|
|
schema: {
|
|
portalFeatures: schemaKeys('portalFeatures'),
|
|
termineConfig: schemaKeys('termineConfig'),
|
|
termineBookings: schemaKeys('termineBookings'),
|
|
termineClaims: schemaKeys('termineClaims'),
|
|
termineOutbox: schemaKeys('termineOutbox'),
|
|
},
|
|
datetimes: TERMINE_DATETIMES,
|
|
defaultLimit: 25,
|
|
})
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
|
|
|
const { config } = await import('../server/config.js')
|
|
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
|
const { resetKontextForTests } = await import('../server/services/termine/kontext.js')
|
|
const { default: router, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js')
|
|
|
|
const app = express()
|
|
app.use('/api/public/termine/v1', router)
|
|
const server = http.createServer(app)
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
const PORT = server.address().port
|
|
after(() => Promise.all([new Promise((resolve) => server.close(resolve)), aw.close()]))
|
|
|
|
const SITE_KEY = 'abcdefghijklmnop'
|
|
const PFAD = `/api/public/termine/v1/${SITE_KEY}/buchungen`
|
|
const MODUS_VORHER = config.termine.mode
|
|
const MIN_ANTWORT_VORHER = config.termine.minAntwortMs
|
|
const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } }
|
|
const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } }
|
|
|
|
beforeEach(() => {
|
|
resetTermineLimitsForTests()
|
|
resetKontextForTests()
|
|
resetPublicTermineForTests()
|
|
config.termine.mode = 'pilot'
|
|
config.termine.minAntwortMs = 0
|
|
mock.method(console, 'error', () => {})
|
|
})
|
|
afterEach(() => mock.restoreAll())
|
|
after(() => {
|
|
config.termine.mode = MODUS_VORHER
|
|
config.termine.minAntwortMs = MIN_ANTWORT_VORHER
|
|
})
|
|
|
|
/** Roher Aufruf mit Chunks (ohne content-length) oder festem Body; => { status, text, headers }. */
|
|
function roh({ pfad = PFAD, method = 'POST', headers = {}, chunks = [] } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request({ host: '127.0.0.1', port: PORT, path: pfad, method, headers }, (res) => {
|
|
let text = ''
|
|
res.setEncoding('utf8')
|
|
res.on('data', (d) => (text += d))
|
|
res.on('end', () => resolve({ status: res.statusCode, text, headers: res.headers }))
|
|
})
|
|
req.on('error', reject)
|
|
for (const chunk of chunks) req.write(chunk)
|
|
req.end()
|
|
})
|
|
}
|
|
|
|
test('20 KB ohne content-length (chunked): 413 vom Router-Parser, ohne Rohtext', async () => {
|
|
const json = `{"name":"${'x'.repeat(20 * 1024)}"}`
|
|
const teile = json.match(/[\s\S]{1,2000}/g)
|
|
const res = await roh({ headers: { 'content-type': 'application/json', 'transfer-encoding': 'chunked' }, chunks: teile })
|
|
assert.equal(res.status, 413)
|
|
assert.deepEqual(JSON.parse(res.text), ZU_GROSS)
|
|
assert.match(res.headers['content-type'], /^application\/json/)
|
|
assert.equal(res.headers['cache-control'], 'no-store')
|
|
assert.ok(!res.text.includes('xxxx'))
|
|
})
|
|
|
|
test('content-length ueber 16 384: 413 vorab, auch fuer einen Body, der gar nicht mehr geschickt wird', async () => {
|
|
const res = await roh({ headers: { 'content-type': 'application/json', 'content-length': '16385' }, chunks: [] }).catch((err) => err)
|
|
// der Server antwortet, bevor der Rumpf kommt; der Client darf dabei zurueckgesetzt werden
|
|
if (res instanceof Error) assert.match(res.code, /ECONNRESET|EPIPE|ECONNABORTED/)
|
|
else {
|
|
assert.equal(res.status, 413)
|
|
assert.deepEqual(JSON.parse(res.text), ZU_GROSS)
|
|
}
|
|
const gross = await fetch(`http://127.0.0.1:${PORT}${PFAD}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ a: 'y'.repeat(16400) }) })
|
|
assert.equal(gross.status, 413)
|
|
assert.deepEqual(await gross.json(), ZU_GROSS)
|
|
})
|
|
|
|
test('genau 16 384 Bytes sind noch erlaubt', async () => {
|
|
const kopf = '{"kontakt_url":"'
|
|
const ende = '"}'
|
|
const fuell = 'z'.repeat(16384 - kopf.length - ende.length)
|
|
const body = kopf + fuell + ende
|
|
assert.equal(Buffer.byteLength(body), 16384)
|
|
const res = await fetch(`http://127.0.0.1:${PORT}${PFAD}`, { method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://salon.de' }, body })
|
|
// nicht 413: der Router liest den Rumpf; der siteKey ist hier unbekannt (nichts gespeichert), also { aktiv: false }
|
|
assert.equal(res.status, 200)
|
|
assert.deepEqual(await res.json(), { aktiv: false })
|
|
})
|
|
|
|
test('kaputtes JSON: 400 mit fester Meldung, nie der Text des Parsers', async () => {
|
|
for (const body of ['{"name": ', '{"a":}', 'nicht json', '{"a":1}}', '[1,2', "{'a':1}"]) {
|
|
const res = await roh({ headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(body)) }, chunks: [body] })
|
|
assert.equal(res.status, 400, body)
|
|
assert.deepEqual(JSON.parse(res.text), UNGUELTIG, body)
|
|
assert.doesNotMatch(res.text, /Unexpected|JSON|position|token|SyntaxError|at /i, body)
|
|
}
|
|
// dasselbe auf den Token-Seiten
|
|
const token = await roh({ pfad: '/api/public/termine/v1/termin/vorschau', headers: { 'content-type': 'application/json' }, chunks: ['{"token":'] })
|
|
assert.equal(token.status, 400)
|
|
assert.deepEqual(JSON.parse(token.text), UNGUELTIG)
|
|
})
|
|
|
|
test('unbekannte Kodierung, falscher Zeichensatz und abgebrochene Anfragen: 4xx als { fehler }, kein Stacktrace', async () => {
|
|
const gzip = await roh({ headers: { 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': '2' }, chunks: ['{}'] })
|
|
assert.equal(gzip.status, 400)
|
|
assert.deepEqual(JSON.parse(gzip.text), UNGUELTIG)
|
|
const zeichensatz = await roh({ headers: { 'content-type': 'application/json; charset=iso-8859-1', 'content-length': '2' }, chunks: ['{}'] })
|
|
assert.equal(zeichensatz.status, 400)
|
|
assert.deepEqual(JSON.parse(zeichensatz.text), UNGUELTIG)
|
|
})
|
|
|
|
test('interner Fehler: 503 { fehler } ohne err.message, auch ohne Fehlerbehandlung der App', async () => {
|
|
const RAW = 'Nachbau kaputt: geheimer Rohtext'
|
|
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: RAW })
|
|
const res = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v1/${SITE_KEY}/config`)
|
|
assert.equal(res.status, 503)
|
|
const text = await res.text()
|
|
assert.deepEqual(JSON.parse(text), { fehler: { _: 'Gerade nicht erreichbar' } })
|
|
assert.ok(!text.includes('Nachbau') && !text.includes('geheimer'))
|
|
})
|
|
|
|
test('Router und App: der Router beantwortet alles unter seinem Pfad selbst (404 als { fehler }), die App darunter sieht nichts', async () => {
|
|
const res = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v1/irgendwas/unbekannt`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"a":1}' })
|
|
assert.equal(res.status, 404)
|
|
assert.deepEqual(await res.json(), { fehler: { _: 'Nicht gefunden' } })
|
|
// ausserhalb des Pfades antwortet Express selbst
|
|
const aussen = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v2/x`)
|
|
assert.equal(aussen.status, 404)
|
|
})
|
|
|
|
test('Reihenfolge in server/index.js: kundenansichtGuard, dann der Router der oeffentlichen Schnittstelle, dann der globale express.json', () => {
|
|
const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
|
|
const guard = quelle.indexOf('app.use(kundenansichtGuard)')
|
|
const oeffentlich = quelle.indexOf("app.use('/api/public/termine/v1'")
|
|
const global = quelle.indexOf('app.use(express.json(')
|
|
assert.ok(guard >= 0 && oeffentlich >= 0 && global >= 0, 'alle drei Aufrufe gefunden')
|
|
assert.equal(quelle.indexOf("app.use('/api/public/termine/v1'", oeffentlich + 1), -1, 'nur einmal eingehaengt')
|
|
assert.ok(guard < oeffentlich, 'erst der Waechter der Kundenansicht')
|
|
assert.ok(oeffentlich < global, 'vor dem globalen Parser (2 MB)')
|
|
assert.match(quelle, /import publicTermineRoutes from '\.\/routes\/publicTermine\.js'/)
|
|
})
|