From 035967f8eb79d13d10c39b4359e716be19eb5f6c Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:06:30 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20=C3=B6ffentliche=20API=20f?= =?UTF-8?q?=C3=BCr=20Kalender=20und=20Anfrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/publicUnterkunft.js | 325 +++++++++++++++++++++++++++++- test/unterkunftPublic.test.js | 202 +++++++++++++++++++ test/unterkunftWiring.test.js | 2 +- 3 files changed, 522 insertions(+), 7 deletions(-) create mode 100644 test/unterkunftPublic.test.js diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index 0fff1a4..a5ece1b 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -1,17 +1,330 @@ +import { randomInt } from 'node:crypto' import express, { Router } from 'express' +import { config } from '../config.js' +import { analyseDomains, analyseHosts } from '../services/analyse.js' +import { legeAnfrageAn } from '../services/unterkunft/ablauf.js' +import { belegteBereiche } from '../services/unterkunft/belegung.js' +import { istTag } from '../services/unterkunft/datum.js' +import { UkFehler } from '../services/unterkunft/fehler.js' +import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js' +import { ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' +import { formToken, ipHashVon, pruefeFormToken } from '../services/unterkunft/token.js' +import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js' +import { heute } from '../services/termine/zeit.js' /** * Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md). * Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js). - * Geruest (Task 5): Task 6 fuellt Konfiguration, Belegung und Anfrage, Task 7 die Buchungsseite. + * Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage. Task 7 ergaenzt die Buchungsseite. + * + * - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals + * (config.publicBaseUrl) oder gleicher Herkunft; sonst 403. + * - Grenzen: je IP 120 Abrufe je Minute, je Seite 600 je Minute; Anfragen je IP 10 je 10 Minuten und je Seite + * config.unterkunft.anfragenJeSiteStunde je Stunde. + * - Fehlerform: { fehler: code, felder? }. */ const router = Router() -router.use(express.json({ limit: '16kb' })) -// Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage +const BODY_MAX = 16 * 1024 +const STUNDE_MS = 60 * 60 * 1000 +const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789' +const STANDARD_AKZENT = '#8a6a3f' +const AKZENT_RE = /^#[0-9a-fA-F]{6}$/ + +let uhr = () => new Date() +export function setzeUhrFuerTests(neueUhr) { + uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() +} + +// ---------------------------------------------------------------- Antworten und Fehler + +const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(fehler), { status, fehlerCode: fehler, ...extra }) +const zuViele = (retryAfter) => fehlerMit(429, 'zu_viele_anfragen', { retryAfter }) +const nichtErlaubt = () => fehlerMit(403, 'nicht_erlaubt') +const nichtGefunden = () => fehlerMit(404, 'nicht_gefunden') +const ungueltig = (felder) => fehlerMit(400, 'ungueltig', felder ? { felder } : {}) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +function fehlerErgebnis(err) { + if (err instanceof UkFehler && err.status >= 400 && err.status < 500) { + const felder = Object.fromEntries(Object.entries(err.felder ?? {}).filter(([, v]) => typeof v === 'string' || typeof v === 'number')) + return { status: err.status, body: { fehler: err.code, ...(Object.keys(felder).length ? { felder } : {}) } } + } + if (typeof err?.fehlerCode === 'string' && err.status >= 400 && err.status < 500) { + const body = { fehler: err.fehlerCode } + if (err.felder) body.felder = err.felder + return { status: err.status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } + } + console.error(`[publicUnterkunft] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return { status: 503, body: { fehler: 'nicht_erreichbar' } } +} + +/** Schreibt { status, body, cache, headers }; GET/HEAD tragen immer `*`, POST nur mit geprueftem cors.erlaubt (Meldungen ohne Daten auch `*`). */ +function schreibe(req, res, ergebnis, cors = { erlaubt: null }) { + const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis + const lesen = req.method === 'GET' || req.method === 'HEAD' + if (lesen) res.set('Access-Control-Allow-Origin', '*') + else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' }) + else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*') + res.status(status) + res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) + if (status === 204) return res.end() + const inhalt = typeof body === 'string' ? body : JSON.stringify(body) + res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) + return res.end(inhalt) +} + +function notantwort(res, err) { + try { + console.error(`[publicUnterkunft] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + if (res.headersSent) return void res.end() + const inhalt = JSON.stringify({ fehler: 'nicht_erreichbar' }) + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(inhalt.length)) + return void res.end(inhalt) + } catch { + try { + res.destroy() + } catch { + // nichts mehr zu tun + } + } +} + +const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const minAntwortMs = () => (Number.isFinite(config.unterkunft?.minAntwortMs) && config.unterkunft.minAntwortMs >= 0 ? config.unterkunft.minAntwortMs : 300) + +/** + * Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe oder wirft; + * cors.erlaubt setzt fn, sobald die Herkunft geprueft ist. Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs. + */ +const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { + try { + const cors = { erlaubt: null } + let ergebnis + try { + ergebnis = await fn(req, uhr(), cors) + } catch (err) { + ergebnis = fehlerErgebnis(err) + } + if (mindestdauer) { + const rest = minAntwortMs() - (performance.now() - req.ukStart) + if (rest > 0) await warte(rest) + } + schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +// ---------------------------------------------------------------- Beginn: Zeit, Rumpf + +router.use((req, res, next) => { + req.ukStart = performance.now() + if (Number(req.get('content-length')) > BODY_MAX) { + res.set('Connection', 'close') + return schreibe(req, res, { status: 413, body: { fehler: 'zu_gross' } }) + } + return next() +}) +router.use(express.json({ limit: BODY_MAX })) + +// ---------------------------------------------------------------- Herkunft + +/** + * Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project), Host von config.publicBaseUrl + * oder gleiche Herkunft wie die Anfrage. Sonst null. + */ +function erlaubteHerkunft(req, project) { + const origin = req.get('origin') + if (typeof origin !== 'string' || origin === '') return null + let url + try { + url = new URL(origin) + } catch { + return null + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null + const erlaubt = new Set([String(req.get('host') || '').toLowerCase()]) + try { + erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase()) + } catch { + // Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft + } + if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain) + return erlaubt.has(url.host) ? origin : null +} + +function pruefeHerkunft(req, cors, project) { + const origin = erlaubteHerkunft(req, project) + if (!origin) throw nichtErlaubt() + cors.erlaubt = origin +} + +const vorabantwort = (origin) => ({ + status: 204, + body: '', + headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }, +}) + +// Preflight: ohne erreichbare Seite gibt es nichts zu pruefen und nichts zu verraten. +router.options( + '/:siteKey/:was', + route(async (req, now, cors) => { + if (req.params.was !== 'anfrage') throw nichtGefunden() + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) return vorabantwort('*') + pruefeHerkunft(req, cors, ctx.project) + return vorabantwort(cors.erlaubt) + }), +) + +// ---------------------------------------------------------------- Konfiguration + +const aktivFalse = () => ({ body: { aktiv: false }, cache: 'public, max-age=15' }) + +function datenschutzUrl(project) { + if (!project) return null + const hosts = analyseHosts(project) // [Vorschau-Subdomain, Live-Domain]: die Live-Domain, wenn es eine gibt + const host = hosts[hosts.length - 1] + return host ? `https://${host}/datenschutz` : null +} + +router.get( + '/:siteKey', + route(async (req, now) => { + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) return aktivFalse() + if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) + const e = ctx.einstellungen ?? {} + const unterkuenfte = await listeUnterkuenfte(ctx.featureId, { nurAktive: true }) + return { + body: { + aktiv: true, + anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) }, + akzentfarbe: AKZENT_RE.test(e.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT, + formToken: formToken(req.params.siteKey, now), + unterkuenfte: unterkuenfte.map((u) => ({ + id: u.$id, + name: u.name, + maxPersonen: u.maxPersonen, + mindestNaechte: u.mindestNaechte, + checkIn: u.checkIn, + checkOut: u.checkOut, + })), + }, + } + }), +) + +// ---------------------------------------------------------------- Belegung + +function monateDazu(tag, n) { + const [j, m, t] = tag.split('-').map(Number) + const d = new Date(Date.UTC(j, m - 1 + n, 1)) + const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate() + d.setUTCDate(Math.min(t, letzter)) + return d.toISOString().slice(0, 10) +} + +/** Fasst sich beruehrende oder ueberlappende Bereiche (nach von sortiert) zusammen; die Quelle wird nicht ausgegeben. */ +function verschmelze(bereiche) { + const aus = [] + for (const b of bereiche) { + const letzter = aus[aus.length - 1] + if (letzter && b.von <= letzter.bis) { + if (b.bis > letzter.bis) letzter.bis = b.bis + } else aus.push({ von: b.von, bis: b.bis }) + } + return aus +} + +router.get( + '/:siteKey/belegung', + route(async (req, now) => { + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) return aktivFalse() + if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) + const { unterkunft, von, bis } = req.query + if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis <= von) { + throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' }) + } + const tag = heute(now) + const bisMax = monateDazu(tag, config.unterkunft.horizontMonate) + if (bis > bisMax) throw ungueltig({ zeitraum: 'Der Zeitraum liegt zu weit in der Zukunft.' }) + const u = await ladeUnterkunft(unterkunft) + if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden() + const ab = von < tag ? tag : von + const belegt = ab < bis ? verschmelze(await belegteBereiche(u.$id, ab, bis, { now })) : [] + return { body: { belegt, heute: tag, bisMax } } + }), +) + +// ---------------------------------------------------------------- Anfrage + +function scheinNummer() { + let n = 'A-' + for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)] + return n +} + +const text = (wert) => (typeof wert === 'string' ? wert : '') + +router.post( + '/:siteKey/anfrage', + route( + async (req, now, cors) => { + const { siteKey } = req.params + const ctx = await unterkunftPerSiteKey(siteKey, { now }) + if (!ctx) return { body: { aktiv: false } } + pruefeHerkunft(req, cors, ctx.project) + if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600) + const b = req.body + if (!istObjekt(b)) throw ungueltig() + // Honigtopf gefuellt oder zu schnell abgeschickt: dieselbe Erfolgsantwort, gespeichert und gesendet wird nichts. + if (b.kontakt_url !== undefined && b.kontakt_url !== null && b.kontakt_url !== '') return { status: 201, body: { nummer: scheinNummer() } } + const pruefung = pruefeFormToken(siteKey, b.formToken, now) + if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } } + if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden') + if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600) + const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen + const anfrage = await legeAnfrageAn( + ctx, + { + unterkunftId: text(b.unterkunft), + von: b.von, + bis: b.bis, + personen, + gast: { name: text(b.name), firma: text(b.firma), email: text(b.email), telefon: text(b.telefon), nachricht: text(b.nachricht) }, + }, + { now, ipHash: ipHashVon(req.ip) }, + ) + return { status: 201, body: { nummer: anfrage.nummer } } + }, + { mindestdauer: true }, + ), +) + +// Rumpf nicht lesbar (kaputtes JSON): 400 in der Fehlerform der Schnittstelle +router.use((err, req, res, next) => { + if (err?.type === 'entity.parse.failed' || err?.type === 'entity.too.large') { + return schreibe(req, res, { status: err.type === 'entity.too.large' ? 413 : 400, body: { fehler: err.type === 'entity.too.large' ? 'zu_gross' : 'ungueltig' } }) + } + return next(err) +}) + // Task 7: GET|POST /:siteKey/buchung/:id, POST /:siteKey/buchung/:id/zahlung, GET /:siteKey/buchung/:id/rechnung.pdf -/** Solange nichts gefuellt ist: jede Seite ist (noch) nicht aktiv, ohne zu verraten, ob der Schluessel existiert. */ -router.get('/:siteKey', (_req, res) => res.json({ aktiv: false })) - export default router diff --git a/test/unterkunftPublic.test.js b/test/unterkunftPublic.test.js new file mode 100644 index 0000000..67df967 --- /dev/null +++ b/test/unterkunftPublic.test.js @@ -0,0 +1,202 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 6: oeffentliche API (Konfiguration, Belegung, Anfrage) +const NAMEN = ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.filter((n) => !['websiteProjects', 'customers'].includes(n)).map((n) => [n, schemaKeys(n)])), + datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] }, + defaultLimit: 25, +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) + +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const K = await import('../server/services/unterkunft/kontext.js') +const T = await import('../server/services/unterkunft/token.js') +const mails = await import('../server/services/unterkunft/mails.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { default: publicRoutes, setzeUhrFuerTests } = await import('../server/routes/publicUnterkunft.js') +const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes }) +after(async () => { + await app.close() + await aw.close() +}) + +const SITE = 'abcdefghijklmnop' +const FEATURE = 'f_uk' +const NOW = new Date('2026-11-01T12:00:00.000Z') +const BASE = `/api/public/unterkunft/v1/${SITE}` +const ORIGIN = 'https://donau-home.example' +const vorher = { ...config.unterkunft } + +beforeEach(async () => { + for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + Object.assign(config.unterkunft, vorher, { mode: 'on', minAntwortMs: 0, formMinSekunden: 3 }) + K.leereCache() + resetTermineLimitsForTests() + mails._aufrufe.length = 0 + setzeUhrFuerTests(() => NOW) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true }) + aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau', subdomain: 'donau', liveDomain: 'donau-home.example' }) + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', ort: 'Stetten', email: 'x@y.de', steuernummer: 'GEHEIM', iban: 'DE00', akzentfarbe: '#112233' }) + aw.seed('ukUnterkuenfte', { $id: 'u2', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Zweite', maxPersonen: 2, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 2, aktiv: true, icalExportToken: 'geheim' }) + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', maxPersonen: 4, mindestNaechte: 2, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true }) + aw.seed('ukUnterkuenfte', { $id: 'u3', featureId: FEATURE, name: 'Aus', maxPersonen: 4, mindestNaechte: 1, sort: 0, aktiv: false }) + aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: FEATURE, staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), personenInklusive: 2, steuerart: 'regel' }) +}) +afterEach(() => { + Object.assign(config.unterkunft, vorher) +}) + +const formToken = (alter = 60_000) => T.formToken(SITE, new Date(NOW.getTime() - alter)) +const anfrage = (extra = {}) => ({ unterkunft: 'u1', von: '2026-11-10', bis: '2026-11-12', personen: 2, name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo', formToken: formToken(), kontakt_url: '', ...extra }) +const post = (body, headers = { origin: ORIGIN }) => send('POST', `${BASE}/anfrage`, { body, headers }) + +test('Konfiguration: unbekannter oder inaktiver siteKey und Modus off -> {aktiv:false}', async () => { + assert.deepEqual((await send('GET', '/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz')).body, { aktiv: false }) + assert.deepEqual((await send('GET', '/api/public/unterkunft/v1/kurz')).body, { aktiv: false }) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'off', enabled: true }) + K.leereCache() + assert.deepEqual((await send('GET', BASE)).body, { aktiv: false }) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true }) + config.unterkunft.mode = 'off' + K.leereCache() + const r = await send('GET', BASE) + assert.deepEqual(r.body, { aktiv: false }) + assert.equal(r.headers.get('access-control-allow-origin'), '*') +}) + +test('Konfiguration: aktiv, nur aktive Unterkuenfte sortiert, keine internen Felder', async () => { + const r = await send('GET', BASE) + assert.equal(r.status, 200) + assert.equal(r.headers.get('access-control-allow-origin'), '*') + assert.equal(r.body.aktiv, true) + assert.deepEqual(r.body.anbieter, { name: 'Donau Home', ort: 'Stetten', datenschutzUrl: 'https://donau-home.example/datenschutz' }) + assert.equal(r.body.akzentfarbe, '#112233') + assert.equal(T.pruefeFormToken(SITE, r.body.formToken, new Date(NOW.getTime() + 10_000)), 'ok') + assert.deepEqual(r.body.unterkuenfte.map((u) => u.id), ['u1', 'u2']) + assert.deepEqual(Object.keys(r.body.unterkuenfte[0]).sort(), ['checkIn', 'checkOut', 'id', 'maxPersonen', 'mindestNaechte', 'name']) + const roh = JSON.stringify(r.body) + for (const geheim of ['c1', 'p1', 'GEHEIM', 'DE00', 'mimi@example.com', FEATURE, 'geheim']) assert.ok(!roh.includes(geheim), geheim) +}) + +test('Konfiguration: ohne Einstellungen und Domain -> Standardfarbe, datenschutzUrl null', async () => { + await deleteDocument(config.collections.ukEinstellungen, FEATURE) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau' }) + K.leereCache() + const r = await send('GET', BASE) + assert.equal(r.body.akzentfarbe, '#8a6a3f') + assert.equal(r.body.anbieter.datenschutzUrl, null) + assert.equal(r.body.anbieter.name, '') +}) + +test('Belegung: fasst angrenzende Bereiche zusammen, ohne Quelle, auf das Fenster zugeschnitten', async () => { + aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-05', bis: '2026-11-08', quelle: 'manuell' }) + aw.seed('ukSperren', { $id: 's2', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-08', bis: '2026-11-10', quelle: 'ical:abc', notiz: 'x' }) + aw.seed('ukAnfragen', { $id: 'a1', featureId: FEATURE, unterkunftId: 'u1', nummer: 'A-AAAA', von: '2026-11-20', bis: '2026-11-25', status: 'gebucht', personen: 2 }) + aw.seed('ukSperren', { $id: 's3', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-12', bis: '2026-11-14', quelle: 'manuell' }) + const r = await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-22`) + assert.equal(r.status, 200) + assert.deepEqual(r.body.belegt, [ + { von: '2026-11-05', bis: '2026-11-10' }, + { von: '2026-11-12', bis: '2026-11-14' }, + { von: '2026-11-20', bis: '2026-11-22' }, + ]) + assert.equal(r.body.heute, '2026-11-01') + assert.equal(r.body.bisMax, '2028-05-01') + assert.equal(r.headers.get('access-control-allow-origin'), '*') +}) + +test('Belegung: ueber 18 Monate hinaus, kaputte Daten, fremde oder inaktive Unterkunft', async () => { + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-02`)).status, 400) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-01`)).status, 200) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-05&bis=2026-11-05`)).status, 400) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=morgen&bis=2026-11-05`)).status, 400) + assert.equal((await send('GET', `${BASE}/belegung?von=2026-11-01&bis=2026-11-05`)).status, 400) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u3&von=2026-11-01&bis=2026-11-05`)).status, 404) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=nix&von=2026-11-01&bis=2026-11-05`)).status, 404) + assert.deepEqual((await send('GET', `/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-05`)).body, { aktiv: false }) +}) + +test('Anfrage: gueltig -> 201 {nummer} + Dokument neu, CORS-Origin gespiegelt', async () => { + const r = await post(anfrage()) + assert.equal(r.status, 201) + assert.match(r.body.nummer, /^A-[A-Z2-9]{4}$/) + assert.equal(r.headers.get('access-control-allow-origin'), ORIGIN) + const docs = [...aw.docs('ukAnfragen').values()] + assert.equal(docs.length, 1) + assert.equal(docs[0].status, 'neu') + assert.equal(docs[0].nummer, r.body.nummer) + assert.equal(docs[0].personen, 2) + assert.deepEqual(mails._aufrufe.map((m) => m.art || m.template), ['G16', 'K31']) +}) + +test('Anfrage: Honigtopf und zu schnelles Token -> 201 mit Schein-Nummer, nichts gespeichert', async () => { + const a = await post(anfrage({ kontakt_url: 'http://spam.example' })) + assert.equal(a.status, 201) + assert.match(a.body.nummer, /^A-[A-Z2-9]{4}$/) + const b = await post(anfrage({ formToken: formToken(500) })) + assert.equal(b.status, 201) + assert.match(b.body.nummer, /^A-[A-Z2-9]{4}$/) + assert.equal(aw.docs('ukAnfragen').size, 0) + assert.equal(mails._aufrufe.length, 0) +}) + +test('Anfrage: fehlende E-Mail, ungueltiges Token, Fachfehler', async () => { + const a = await post(anfrage({ email: '' })) + assert.equal(a.status, 400) + assert.equal(a.body.fehler, 'ungueltig') + assert.ok(a.body.felder.email) + const b = await post(anfrage({ formToken: 'quatsch' })) + assert.equal(b.status, 400) + assert.equal(b.body.fehler, 'neu_laden') + const c = await post(anfrage({ personen: 9 })) + assert.equal(c.status, 400) + assert.equal(c.body.fehler, 'zu_viele_personen') + assert.equal(c.body.felder.maxPersonen, 4) + const d = await post(anfrage({ personen: '2', von: '2026-11-10', bis: '2026-11-11' })) + assert.equal(d.body.fehler, 'mindestnaechte') + aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' }) + const e = await post(anfrage()) + assert.equal(e.status, 409) + assert.equal(e.body.fehler, 'zeitraum_belegt') + const f = await send('POST', `${BASE}/anfrage`, { body: '{kaputt', headers: { origin: ORIGIN, 'content-type': 'application/json' } }) + assert.equal(f.status, 400) + assert.equal(aw.docs('ukAnfragen').size, 0) +}) + +test('Anfrage: mehr als anfragenJeSiteStunde je Seite -> 429', async () => { + config.unterkunft.anfragenJeSiteStunde = 2 + assert.equal((await post(anfrage({ von: '2026-11-10', bis: '2026-11-12' }))).status, 201) + assert.equal((await post(anfrage({ von: '2026-12-10', bis: '2026-12-12' }))).status, 201) + const r = await post(anfrage({ von: '2027-01-10', bis: '2027-01-12' })) + assert.equal(r.status, 429) + assert.equal(r.body.fehler, 'zu_viele_anfragen') + assert.ok(r.headers.get('retry-after')) + assert.equal(aw.docs('ukAnfragen').size, 2) +}) + +test('CORS: Preflight erlaubt/fremd, POST von fremder oder fehlender Origin -> 403', async () => { + const ok = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: ORIGIN, 'access-control-request-method': 'POST' } }) + assert.equal(ok.status, 204) + assert.equal(ok.headers.get('access-control-allow-origin'), ORIGIN) + const sub = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: 'https://donau.project.webklar.com' } }) + assert.equal(sub.headers.get('access-control-allow-origin'), 'https://donau.project.webklar.com') + const fremd = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: 'https://boese.example' } }) + assert.equal(fremd.status, 403) + assert.equal(fremd.headers.get('access-control-allow-origin'), null) + const p = await post(anfrage(), { origin: 'https://boese.example' }) + assert.equal(p.status, 403) + assert.equal((await post(anfrage(), {})).status, 403) + assert.equal(aw.docs('ukAnfragen').size, 0) + // unbekannte Seite: Preflight verraet nichts + const unbekannt = await send('OPTIONS', '/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz/anfrage', { headers: { origin: 'https://boese.example' } }) + assert.equal(unbekannt.status, 204) + assert.equal(unbekannt.headers.get('access-control-allow-origin'), '*') +}) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 46b24ab..0b4b74a 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -150,7 +150,7 @@ test('API-Vertrag: docs/funktionen/unterkunft-api.md nennt alle Pfade', () => { }) // -------------------------------------------------------------------------------------------------- Öffentliche Schnittstelle -test('Öffentlich: GET /api/public/unterkunft/v1/ liefert {aktiv:false} (Gerüst)', async () => { +test('Öffentlich: GET /api/public/unterkunft/v1/ liefert für unbekannte Seiten {aktiv:false}', async () => { const r = await rawGet('/api/public/unterkunft/v1/aaaaaaaaaaaaaaaa') assert.equal(r.status, 200) assert.deepEqual(r.body, { aktiv: false })