diff --git a/server/config.js b/server/config.js index 86ea37c..439a308 100644 --- a/server/config.js +++ b/server/config.js @@ -388,6 +388,9 @@ export const config = { mailsProStunde: 200, // Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot) formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3), + // Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob + // eine Adresse oder Buchung bekannt ist (Zeitunterschied). Tests setzen 0. + minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300), }, invoiceNinja: { diff --git a/server/index.js b/server/index.js index c0a8ff9..2cd3390 100644 --- a/server/index.js +++ b/server/index.js @@ -35,6 +35,7 @@ import adminEmployeesRoutes from './routes/admin/employees.js' import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' +import publicTermineRoutes from './routes/publicTermine.js' import adminAnalyseRoutes from './routes/admin/analyse.js' import { analyseLegalRouter } from './services/analyseLegal.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' @@ -54,6 +55,9 @@ app.use(sessionMiddleware()) // Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen // Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften app.use(kundenansichtGuard) +// Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und +// haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde +app.use('/api/public/termine/v1', publicTermineRoutes) // Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit, // muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung // zuerst, damit niemand ohne Session grosse Bodies parsen laesst. diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index dddbe6c..7f3e1fb 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -20,8 +20,9 @@ export const KUNDENANSICHT_ALLOWED = [ ['POST', /^\/api\/auth\/login\/?$/], ] -// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen) -const PASS_PREFIXES = ['/api/team/', '/api/admin/'] +// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen); oeffentliche Schnittstellen +// (/api/public/: Pinnwand, Terminplaner fuer Gaeste) kennen keine Sitzung und ignorieren ein Kundenansicht-Cookie im Browser +const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/public/'] const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht'] const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'] diff --git a/server/routes/publicTermine.js b/server/routes/publicTermine.js new file mode 100644 index 0000000..3d19f32 --- /dev/null +++ b/server/routes/publicTermine.js @@ -0,0 +1,542 @@ +import { createHash, timingSafeEqual } from 'node:crypto' +import express, { Router } from 'express' +import { config } from '../config.js' +import { analyseDomains } from '../services/analyse.js' +import { + MELDUNG, + bestaetige, + fehlerMit, + fehlerSammler, + halte, + istObjekt, + pruefeEingabe, + pruefeSlot, + sageAbGast, + verschiebeGast, + vorschau, + widerrufeGast, + widerrufeOhneLink, + zeitenFuerToken, +} from '../services/termine/ablauf.js' +import { imZeitraum } from '../services/termine/buchungen.js' +import { brauchtZustimmungVorzeitig, emailAdresse, oeffentlicheEinstellung } from '../services/termine/einstellungen.js' +import { icsKalender } from '../services/termine/ics.js' +import { kontextPerSiteKey } from '../services/termine/kontext.js' +import { + darfAnfrage, + darfAnfrageSite, + darfBuchungsversuch, + darfG01, + darfHaltung, + darfWiderrufsversuch, +} from '../services/termine/limits.js' +import { + GESUNDHEIT_HINWEIS, + ZUSTIMMUNG_ERINNERUNG, + ZUSTIMMUNG_STORNO, + ZUSTIMMUNG_VORZEITIG, + formularHinweis, + widerrufsbelehrung, +} from '../services/termine/rechtstexte.js' +import { belegungAus, freieTage, freieZeiten } from '../services/termine/slots.js' +import { termineAus } from '../services/termine/schalter.js' +import { buchungsNummer, formToken, pruefeFormToken } from '../services/termine/token.js' +import { datumLang, monatInfo, zuUtc } from '../services/termine/zeit.js' + +/** + * Oeffentliche Schnittstelle des Terminplaners: /api/public/termine/v1 (Spezifikation 2026-10-02-terminplaner-design.md, + * Abschnitt 8). Ohne Session, ohne Captcha; der Schutz besteht aus Herkunftspruefung (CORS), Honeypot und Formular-Token, + * Grenzen im Speicher (services/termine/limits.js) und einer Mindestantwortzeit. + * + * - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem + * globalen express.json (2 MB): so liest nie der grosse Parser den Rumpf einer oeffentlichen Anfrage. Alles unterhalb des + * Pfades beantwortet dieser Router selbst. Antworten sind immer JSON { ... } oder { fehler: { : Text }, code? }; + * Rohtexte von Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar"). + * - Antworten enthalten nie customerId, projectId, featureId, $id, Ticket- oder Stripe-IDs: nur Leistungs-/Ressourcen-IDs + * der Einstellung und die Buchungsnummer. Die Geschaeftsregeln stehen in services/termine/*; hier wird nur gelesen, + * geprueft, begrenzt und uebersetzt. + * - Unbekannter, inaktiver, abgeschalteter siteKey oder Modus off: immer 200 { aktiv: false } (GET und POST unter + * /:siteKey). Die Token-Seiten (/termin/*) bleiben fuer abgeschaltete Funktionen nutzbar (Absagen, Widerruf), bei Modus + * off antworten sie wie bei einem unbekannten Link, ohne Abfrage. + * - CORS: GET fuer alle (*); POST und Preflight nur von Seiten des Betriebs (Hosts des Projekts, analyseDomains), des + * Portals (Host von config.publicBaseUrl) oder gleicher Herkunft, die Token-Seiten nur von den beiden letzten. Eine + * fremde oder fehlende Origin ist 403. Fuer einen unbekannten siteKey laesst sich nichts pruefen: dort { aktiv: false }. + * Meldungen ohne Daten (429, 503, aktiv:false) tragen `*`, damit das Widget sie lesen kann. + * - Buchung (POST /:siteKey/buchungen), in dieser Reihenfolge: Grenze je IP (jeder Versuch zaehlt) => Funktion pruefen => + * Herkunft => Honeypot kontakt_url und Formular-Token (zu schnell oder Honeypot: dieselbe Erfolgsantwort, nichts + * gespeichert; zu alt oder fremd: 400 "Bitte laden Sie die Seite neu.") => Eingabe und Zeit pruefen (Fehler des Gastes + * zuerst, damit keine Grenze etwas verraet) => Grenze je siteKey (429) => Grenze G01 je Adresse (still: dieselbe 201-Antwort, + * aber keine Buchung und keine Mail) => halten. "Zu viele offene Termine" und "gehalten" antworten gleich (kein Orakel + * fuer Dritte). + * - Mindestantwortzeit config.termine.minAntwortMs fuer die Buchung und beide Widerrufe (auch bei Fehlern), gemessen ab + * Beginn der Anfrage: verwischt, ob eine Adresse oder Buchung bekannt ist. + * - tage und zeiten: 10 Sekunden Speicher-Cache je (siteKey, Parameter); config und alle anderen Antworten ohne ETag. + */ + +const router = Router() + +const BODY_MAX = 16 * 1024 +const TAG_MS = 24 * 60 * 60 * 1000 +const CACHE_MS = 10 * 1000 +const CACHE_MAX = 10000 +const FEED_DATEI_RE = /^([A-Za-z0-9_-]{43})\.ics$/ +const FEED_VOR_MS = 7 * TAG_MS +const FEED_NACH_MS = 90 * TAG_MS + +const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } } +const TEXT_NICHT_GEFUNDEN = 'Nicht gefunden' +const NICHT_GEFUNDEN = { fehler: { _: TEXT_NICHT_GEFUNDEN } } +const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } } +const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } } +const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' +const MELDUNG_NEU_LADEN = 'Bitte laden Sie die Seite neu.' + +// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). +let uhr = () => new Date() +export function setzeUhrFuerTests(neueUhr) { + uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() +} + +/** Speicher-Cache von tage und zeiten: Schluessel -> { bis (ms), body }; hoechstens CACHE_MAX Eintraege. */ +const cache = new Map() +export function resetPublicTermineForTests() { + cache.clear() + uhr = () => new Date() +} + +// ---------------------------------------------------------------- Antworten und Fehler + +/** Fehler mit Statuscode fuer den Gast (4xx); `fehlerErgebnis` macht daraus die Antwort. */ +const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS }) +const nichtGefunden = () => fehlerMit(404, { _: TEXT_NICHT_GEFUNDEN }) +const nichtErlaubt = () => fehlerMit(403, { _: 'Nicht erlaubt' }) +const unbekannterLink = () => fehlerMit(404, { _: MELDUNG.unbekannt }, { code: 'unbekannt' }) + +const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {}) + +/** Antwort fuer einen Fehler: Fehler des Gastes (4xx mit `fehler` aus den Diensten) wie sie sind, alles andere 503 ohne Rohtext. */ +function fehlerErgebnis(err) { + const status = err?.status + const fehler = nurTexte(err?.fehler) + if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) { + const body = { fehler } + if (typeof err.code === 'string') body.code = err.code + if (istObjekt(err.betrieb)) body.betrieb = nurTexte(err.betrieb) + return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } + } + console.error(`[publicTermine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return { status: 503, body: NICHT_ERREICHBAR } +} + +/** Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null }. */ +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', '*') // Meldungen ohne Daten + res.status(status) + res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) + if (status === 204) return res.end() // Preflight: nur Kopfzeilen + const inhalt = typeof body === 'string' ? body : JSON.stringify(body) + res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) + return res.end(inhalt) +} + +/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Funktionen (GET kurz im Browser-Cache). */ +const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' }) + +const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * 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 config.termine.minAntwortMs (ab Beginn der Anfrage, auch beim Lesen des Rumpfes). + */ +const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { + const cors = { erlaubt: null } + let ergebnis + try { + ergebnis = await fn(req, uhr(), cors) + } catch (err) { + ergebnis = fehlerErgebnis(err) + } + if (mindestdauer) { + const rest = config.termine.minAntwortMs - (performance.now() - req.termineStart) + if (rest > 0) await warte(rest) + } + schreibe(req, res, ergebnis, cors) +} + +// ---------------------------------------------------------------- Herkunft + +/** + * Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project) (Seiten des Betriebs), Host von + * config.publicBaseUrl (Gaesteseiten des Portals) oder gleiche Herkunft wie die Anfrage (Host-Header). Ohne project (die + * Token-Seiten /termin/*) gelten nur die beiden letzten. Sonst null (fehlende, fremde, unlesbare Origin oder nicht in + * kanonischer Schreibweise). + */ +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 +} + +/** Prueft die Herkunft eines POST und merkt sie in cors; sonst 403. */ +function pruefeHerkunft(req, cors, project) { + const origin = erlaubteHerkunft(req, project) + if (!origin) throw nichtErlaubt() + cors.erlaubt = origin +} + +// ---------------------------------------------------------------- Beginn: Zeit, Rumpf + +router.use((req, res, next) => { + req.termineStart = performance.now() + // Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen). + if (Number(req.get('content-length')) > BODY_MAX) { + res.set('Connection', 'close') + return schreibe(req, res, { status: 413, body: ZU_GROSS }) + } + return next() +}) +router.use(express.json({ limit: BODY_MAX })) + +/** Antwort auf einen Preflight (204): erlaubte Methoden, Content-Type, 24 Stunden gueltig. */ +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', + }, +}) + +// ---------------------------------------------------------------- Token-Seiten (/termin/*) + +const rumpf = (req) => (istObjekt(req.body) ? req.body : {}) + +/** + * Aufruf mit Token: Herkunft nur Portal und gleiche Herkunft, Grenze je IP (widerruf: zusaetzlich Widerrufe je IP), bei + * Modus off wie ein unbekannter Link ohne Abfrage. fn(body, now) liefert den Body der 200-Antwort. + */ +const terminRoute = (fn, { widerruf = false } = {}) => + route( + async (req, now, cors) => { + pruefeHerkunft(req, cors, null) + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + if (widerruf && !darfWiderrufsversuch(req.ip, now)) throw zuViele(3600) + if (termineAus()) throw unbekannterLink() + return { body: await fn(rumpf(req), now) } + }, + { mindestdauer: widerruf }, + ) + +router.post('/termin/vorschau', terminRoute((b, now) => vorschau(b.token, now))) +router.post('/termin/bestaetigen', terminRoute(async (b, now) => ({ ok: true, ...(await bestaetige(b.token, now)) }))) +router.post('/termin/absagen', terminRoute(async (b, now) => ({ ok: true, ...(await sageAbGast(b.token, now)) }))) +router.post( + '/termin/verschieben', + terminRoute(async (b, now) => ({ ok: true, ...(await verschiebeGast(b.token, { tag: b.tag, zeit: b.zeit }, now)) })), +) +router.post( + '/termin/widerruf', + terminRoute(async (b, now) => ({ ok: true, ...(await widerrufeGast(b.token, { name: b.name }, now)) }), { widerruf: true }), +) +// zeitenFuerToken unterscheidet Monat und Tag daran, welcher Schluessel gesetzt ist: fehlt der gefragte, ist er leer (=> 400) +router.post( + '/termin/tage', + terminRoute((b, now) => zeitenFuerToken(b.token, { monat: typeof b.monat === 'string' ? b.monat : '' }, now)), +) +router.post( + '/termin/zeiten', + terminRoute((b, now) => zeitenFuerToken(b.token, { tag: typeof b.tag === 'string' ? b.tag : '' }, now)), +) +router.options( + '/termin/:aktion', + route(async (req, now, cors) => { + pruefeHerkunft(req, cors, null) + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + return vorabantwort(cors.erlaubt) + }), +) + +// ---------------------------------------------------------------- Kalender-Abo + +const sha256 = (text) => createHash('sha256').update(text).digest() + +/** Die Ressource, deren feedToken zum gegebenen passt (Vergleich in konstanter Zeit ueber alle Ressourcen); sonst null. */ +function ressourceZuFeedToken(einstellung, token) { + const gegeben = sha256(token) + let treffer = null + for (const r of einstellung.ressourcen || []) { + if (typeof r?.feedToken === 'string' && timingSafeEqual(sha256(r.feedToken), gegeben)) treffer = r + } + return treffer +} + +router.get( + '/kalender/:siteKey/:datei', + route(async (req, now) => { + const { siteKey, datei } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const format = FEED_DATEI_RE.exec(datei) + const ctx = format ? await kontextPerSiteKey(siteKey, now) : null + const ressource = ctx ? ressourceZuFeedToken(ctx.einstellung, format[1]) : null + if (!ressource) throw nichtGefunden() // ohne Hinweis, was nicht stimmt + if (!darfAnfrageSite(siteKey, now)) throw zuViele(60) + + const e = ctx.einstellung + const nowMs = now.getTime() + const liste = await imZeitraum(ctx.feature.$id, new Date(nowMs - FEED_VOR_MS), new Date(nowMs + FEED_NACH_MS)) + const termine = liste + .filter((b) => b.ressourceId === ressource.id && b.status === 'bestaetigt') + .sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)) + .map((b) => ({ + uid: `${b.$id}@termine.webklar.com`, + sequenz: Number.isInteger(b.icsSequenz) && b.icsSequenz >= 0 ? b.icsSequenz : 0, + start: b.startsAt, + ende: b.endsAt, + titel: b.quelle === 'sperre' ? b.leistungName || 'Gesperrt' : b.gastName ? `${b.leistungName}: ${b.gastName}` : b.leistungName, + ort: b.quelle === 'sperre' ? '' : e.betrieb.adresse, + beschreibung: b.quelle === 'sperre' ? '' : `Buchungsnummer: ${buchungsNummer(b.$id)}`, + })) + return { + body: icsKalender(termine, { name: `${e.betrieb.name} – ${ressource.name}`, jetzt: now }), + typ: 'text/calendar; charset=utf-8', + cache: 'private, max-age=300', // enthaelt Gastnamen: nur im Cache des Abonnenten, nicht in gemeinsamen Caches + headers: { 'X-Robots-Tag': 'noindex', 'Referrer-Policy': 'no-referrer' }, + } + }), +) + +// ---------------------------------------------------------------- Konfiguration, Tage, Zeiten + +/** Lesende Abrufe: Grenze je IP, Funktion suchen ({ aktiv: false } wenn es sie nicht gibt), Grenze je siteKey. */ +const lesenRoute = (fn) => + route(async (req, now) => { + const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await kontextPerSiteKey(siteKey, now) + if (!ctx) return aktivFalse(req) + if (!darfAnfrageSite(siteKey, now)) throw zuViele(60) + return fn(ctx, req, now) + }) + +router.get( + '/:siteKey/config', + lesenRoute((ctx, req, now) => { + const e = ctx.einstellung + const oeffentlich = oeffentlicheEinstellung(e, now) + const texte = { + storno: ZUSTIMMUNG_STORNO, + erinnerung: ZUSTIMMUNG_ERINNERUNG(e.erinnerung.stundenVorher), + vorzeitig: ZUSTIMMUNG_VORZEITIG(e.betrieb.name), + gesundheit: GESUNDHEIT_HINWEIS, + } + // Das Widget zeigt die Belehrung vor "Zahlungspflichtig buchen" (R14); fehlt eine Angabe, wirft sie => 503 statt einer Buchung ohne Belehrung + if (oeffentlich.widerruf) texte.widerrufsbelehrung = widerrufsbelehrung(e.betrieb) + return { + body: { aktiv: true, ...oeffentlich, hinweis: formularHinweis(e), texte, formToken: formToken(req.params.siteKey, now) }, + cache: 'public, max-age=60', // das Formular-Token ist bei jedem Abruf neu: kein ETag + } + }), +) + +/** Leistung und Ressource aus der Abfrage (wie das Formular sie annimmt); Fehler gehen an fehl. => { leistung, ressource } oder null */ +function leistungUndRessource(e, query, fehl) { + const leistung = typeof query.leistung === 'string' ? (e.leistungen || []).find((l) => l.id === query.leistung && l.aktiv) : undefined + const aktiv = new Set((e.ressourcen || []).filter((r) => r.aktiv).map((r) => r.id)) + const infrage = leistung ? (leistung.ressourcen || []).filter((id) => aktiv.has(id)) : [] + if (!leistung || infrage.length === 0) { + fehl('leistung', MELDUNG.leistung) + return null + } + const ressource = query.ressource === undefined ? 'egal' : query.ressource + if (ressource !== 'egal' && !(typeof ressource === 'string' && infrage.includes(ressource))) { + fehl('ressource', MELDUNG.ressource) + return null + } + return { leistung, ressource } +} + +/** Antwort aus dem 10-Sekunden-Speicher oder frisch berechnet. */ +async function ausCache(schluessel, nowMs, berechne) { + const gemerkt = cache.get(schluessel) + if (gemerkt && gemerkt.bis > nowMs) return gemerkt.body + const body = await berechne() + if (cache.size >= CACHE_MAX) { + for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key) + if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value) + } + cache.set(schluessel, { bis: nowMs + CACHE_MS, body }) + return body +} + +router.get( + '/:siteKey/tage', + lesenRoute(async (ctx, req, now) => { + const e = ctx.einstellung + const { fehler, fehl } = fehlerSammler() + const wahl = leistungUndRessource(e, req.query, fehl) + const monat = typeof req.query.monat === 'string' ? req.query.monat : '' + const info = monatInfo(monat) + const von = info ? zuUtc(`${monat}-01`, '00:00') : null + const bis = info ? zuUtc(`${info.nachher}-01`, '00:00') : null + if (!von || !bis) fehl('monat', MELDUNG.monat) + if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) + + const schluessel = `tage|${req.params.siteKey}|${wahl.leistung.id}|${wahl.ressource}|${monat}` + const body = await ausCache(schluessel, now.getTime(), async () => { + const imMonat = await imZeitraum(ctx.feature.$id, von, bis) + const tage = freieTage({ einstellung: e, leistungId: wahl.leistung.id, ressourceId: wahl.ressource, monat, belegung: belegungAus(imMonat, now), now }) + return { tage, monat: info } + }) + return { body, cache: 'public, max-age=15' } + }), +) + +router.get( + '/:siteKey/zeiten', + lesenRoute(async (ctx, req, now) => { + const e = ctx.einstellung + const { fehler, fehl } = fehlerSammler() + const wahl = leistungUndRessource(e, req.query, fehl) + const tag = typeof req.query.tag === 'string' ? req.query.tag : '' + const mittag = zuUtc(tag, '12:00') + if (!mittag) fehl('tag', MELDUNG.tag) + if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) + + const schluessel = `zeiten|${req.params.siteKey}|${wahl.leistung.id}|${wahl.ressource}|${tag}` + const body = await ausCache(schluessel, now.getTime(), async () => { + const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00')) + const zeiten = freieZeiten({ einstellung: e, leistungId: wahl.leistung.id, ressourceId: wahl.ressource, tag, belegung: belegungAus(amTag, now), now }) + return { + zeiten: zeiten.map((z) => z.uhrzeit), + datumLang: datumLang(mittag), + // Zustimmung zum vorzeitigen Beginn: serverseitig fuer diesen Tag, das Widget rechnet nie selbst + vorzeitigNoetig: zeiten.some((z) => brauchtZustimmungVorzeitig(e, wahl.leistung, z.start, now)), + } + }) + return { body, cache: 'no-store' } + }), +) + +// ---------------------------------------------------------------- Buchen und Widerruf (POST /:siteKey/...) + +/** Maskierte Adresse fuer die Antwort: 'm***@gmail.com' (erstes Zeichen, Rest des Namens verdeckt); ohne Adresse '***'. */ +function maskiere(adresse) { + const at = typeof adresse === 'string' ? adresse.lastIndexOf('@') : -1 + return at < 1 ? '***' : `${[...adresse][0]}***${adresse.slice(at)}` +} + +/** Die Erfolgsantwort der Buchung (auch bei Honeypot, zu schnell, zu viele offene Termine und Mailgrenze). */ +const erfolg = (adresse) => ({ status: 201, body: { ok: true, email: maskiere(adresse), minuten: config.termine.holdMinutes } }) + +/** Nur die Felder des Formulars (kein Durchreichen fremder Schluessel an die Dienste). */ +const formularFelder = (b) => ({ + leistung: b.leistung, + ressource: b.ressource, + tag: b.tag, + zeit: b.zeit, + name: b.name, + email: b.email, + telefon: b.telefon, + nachricht: b.nachricht, + zusatz: b.zusatz, + erinnerung: b.erinnerung, + zustimmungen: b.zustimmungen, +}) + +router.post( + '/:siteKey/buchungen', + route( + async (req, now, cors) => { + const { siteKey } = req.params + if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600) + const ctx = await kontextPerSiteKey(siteKey, now) + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + const b = req.body + if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG.formular }) + + // Honeypot (jeder Wert ausser leer) und zu schnell abgeschickt: dieselbe Antwort wie sonst, nichts wird gespeichert + const honig = b.kontakt_url + const falle = honig !== undefined && honig !== null && !(typeof honig === 'string' && honig.trim() === '') + if (falle) return erfolg(emailAdresse(b.email)) + const token = pruefeFormToken(siteKey, b.formToken, now) + if (token === 'zu_schnell') return erfolg(emailAdresse(b.email)) + if (token !== 'ok') throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) + + // Fehler des Gastes (Eingabe, nicht mehr freie Zeit) vor allen Grenzen, die von der Adresse abhaengen + const eingabe = formularFelder(b) + const gepruft = pruefeEingabe(ctx.einstellung, eingabe, now) + await pruefeSlot(ctx, gepruft, now) + if (!darfHaltung(siteKey, now)) throw zuViele(3600) + if (!darfG01(ctx.feature.$id, gepruft.email, now)) return erfolg(gepruft.email) // still: keine Buchung, keine Mail + + const ergebnis = await halte(ctx, eingabe, now) + return erfolg(ergebnis.email) + }, + { mindestdauer: true }, + ), +) + +router.post( + '/:siteKey/widerruf', + route( + async (req, now, cors) => { + if (!darfWiderrufsversuch(req.ip, now)) throw zuViele(3600) + const ctx = await kontextPerSiteKey(req.params.siteKey, now) + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + const b = req.body + const erklaerung = istObjekt(b) ? { name: b.name, email: b.email, nummer: b.nummer } : b + const { eingang } = await widerrufeOhneLink(ctx, erklaerung, now) + return { body: { ok: true, eingang } } + }, + { mindestdauer: true }, + ), +) + +// Preflight unter /:siteKey/*: erlaubt fuer Herkunft des Betriebs, des Portals und gleiche Herkunft; ohne Funktion nichts zu pruefen +router.options( + '/:siteKey/:was', + route(async (req, now, cors) => { + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await kontextPerSiteKey(req.params.siteKey, now) + if (!ctx) return vorabantwort('*') + pruefeHerkunft(req, cors, ctx.project) + return vorabantwort(cors.erlaubt) + }), +) + +// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers + +router.use((req, res) => schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN })) + +// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers +router.use((err, req, res, _next) => { + if (err?.type === 'entity.too.large' || err?.status === 413) return schreibe(req, res, { status: 413, body: ZU_GROSS }) + if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibe(req, res, { status: 400, body: UNGUELTIG }) + return schreibe(req, res, fehlerErgebnis(err)) +}) + +export default router diff --git a/server/services/termine/limits.js b/server/services/termine/limits.js index d370fcb..0f1627f 100644 --- a/server/services/termine/limits.js +++ b/server/services/termine/limits.js @@ -1,3 +1,4 @@ +import { isIPv4, isIPv6 } from 'node:net' import { pruefeNow, pruefeText } from './util.js' /** @@ -8,9 +9,12 @@ import { pruefeNow, pruefeText } from './util.js' * Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht). * * Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt). - * Mails des Widerrufs (Aufgabe 13): darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende - * Buchung, ein Treffer bekommt seine Mails immer. Die Grenzen je IP, siteKey und G01 ergaenzt - * Aufgabe 16 in diesem Modul. + * Mails des Widerrufs: darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende + * Buchung, ein Treffer bekommt seine Mails immer. + * Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe + * je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01 + * (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist + * ein ganzes /64, die Adresse darin wechselt beliebig. */ const MAX_SCHLUESSEL = 10000 @@ -23,6 +27,20 @@ const G08_JE_FUNKTION = 20 /** K21 "nicht zugeordnet": je Funktion und Stunde. */ const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 30 +const MINUTE_MS = 60 * 1000 + +/** Lesende Abrufe und Aufrufe mit Token: je IP und Minute, je siteKey und Minute. */ +const ANFRAGEN_JE_IP = 120 +const ANFRAGEN_JE_SITE = 600 +/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */ +const BUCHUNGEN_JE_IP = 10 +const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS +const HALTUNGEN_JE_SITE = 60 +/** Widerrufserklaerungen: je IP und Stunde. */ +const WIDERRUFE_JE_IP = 10 +/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */ +const G01_JE_ADRESSE = 5 + /** schluessel -> { start (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. */ const zaehler = new Map() @@ -80,6 +98,74 @@ export function darfK21NichtZugeordnet(featureId, now) { return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now) } +// ---------------------------------------------------------------- IP, siteKey, G01 + +const acht = (gruppen) => gruppen.length === 8 && gruppen.every((g) => /^[0-9a-f]{1,4}$/.test(g)) + +/** Die acht Gruppen einer IPv6-Adresse (ohne Zonenkennung, Kurzform "::" und eingebettete IPv4 aufgeloest) oder null. */ +function ipv6Gruppen(adresse) { + let s = adresse.split('%')[0] + const v4 = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(s) + if (v4) { + const [a, b, c, d] = v4.slice(1).map(Number) + s = `${s.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}` + } + const teile = s.split('::') + if (teile.length > 2) return null + const links = teile[0] === '' ? [] : teile[0].split(':') + const rechts = teile.length === 2 && teile[1] !== '' ? teile[1].split(':') : [] + const gruppen = teile.length === 2 ? [...links, ...Array(Math.max(0, 8 - links.length - rechts.length)).fill('0'), ...rechts] : links + return acht(gruppen) ? gruppen : null +} + +/** + * Zaehlschluessel einer Client-Adresse (req.ip): IPv4 unveraendert, IPv4 in IPv6 (::ffff:a.b.c.d) als IPv4, IPv6 je /64 + * (die ersten vier Gruppen, vierstellig); fehlt die Adresse oder ist sie unlesbar: 'unbekannt' (alle teilen sich einen Zaehler). + */ +export function ipSchluessel(ip) { + const s = typeof ip === 'string' ? ip.trim().toLowerCase() : '' + if (isIPv4(s)) return s + if (!isIPv6(s)) return 'unbekannt' + const gruppen = ipv6Gruppen(s) + if (!gruppen) return 'unbekannt' + const werte = gruppen.map((g) => parseInt(g, 16)) + // ::ffff:a.b.c.d (Gruppen 0-4 null, Gruppe 5 = ffff) ist ein IPv4-Client hinter einem Dual-Stack-Socket + if (werte.slice(0, 5).every((w) => w === 0) && werte[5] === 0xffff) { + return `${werte[6] >> 8}.${werte[6] & 255}.${werte[7] >> 8}.${werte[7] & 255}` + } + return `v6:${werte.slice(0, 4).map((w) => w.toString(16).padStart(4, '0')).join(':')}` +} + +/** Darf diese IP (IPv6 je /64) jetzt einen lesenden Abruf oder Token-Aufruf machen? Hoechstens 120 je Minute. */ +export function darfAnfrage(ip, now) { + return zaehle(`anfrage-ip:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now) +} + +/** Darf diese Funktion (siteKey) jetzt einen lesenden Abruf beantworten? Hoechstens 600 je Minute fuer alle zusammen. */ +export function darfAnfrageSite(siteKey, now) { + return zaehle(`anfrage-site:${siteKey}`, ANFRAGEN_JE_SITE, MINUTE_MS, now) +} + +/** Darf diese IP (IPv6 je /64) jetzt ein Buchungsformular abschicken? Hoechstens 10 je 10 Minuten (jeder Versuch zaehlt). */ +export function darfBuchungsversuch(ip, now) { + return zaehle(`buchung-ip:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now) +} + +/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */ +export function darfHaltung(siteKey, now) { + return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now) +} + +/** Darf diese IP (IPv6 je /64) jetzt eine Widerrufserklaerung abschicken? Hoechstens 10 je Stunde. */ +export function darfWiderrufsversuch(ip, now) { + return zaehle(`widerruf-ip:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now) +} + +/** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */ +export function darfG01(featureId, adresse, now) { + return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now) +} + /** Leert alle Zaehler (Tests). */ export function resetTermineLimitsForTests() { zaehler.clear() diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 30bb54f..74f8fee 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -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) +}) diff --git a/test/publicTermine.test.js b/test/publicTermine.test.js new file mode 100644 index 0000000..2733d74 --- /dev/null +++ b/test/publicTermine.test.js @@ -0,0 +1,1001 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Oeffentliche Schnittstelle des Terminplaners (/api/public/termine/v1): Router im Nachbau von Appwrite, Mails in der Senke, +// feste Uhr (setzeUhrFuerTests), je Test eine eigene Client-Adresse (X-Forwarded-For) und eine Origin wie im Browser. +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 { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { kundenansichtCookie } = await import('./support/fixtures.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const { buchungsNummer, formToken, pruefeFormToken, verwaltungsToken } = await import('../server/services/termine/token.js') +const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') +const { resetTermineLimitsForTests, darfHaltung } = await import('../server/services/termine/limits.js') +const { ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, ZUSTIMMUNG_VORZEITIG, GESUNDHEIT_HINWEIS, formularHinweis, widerrufsbelehrung } = await import( + '../server/services/termine/rechtstexte.js' +) +const { halte } = await import('../server/services/termine/ablauf.js') +const { sperre } = await import('../server/services/termine/ablaufBetrieb.js') +const { default: router, setzeUhrFuerTests, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/termine/v1': router }) +after(() => Promise.all([app.close(), aw.close()])) +after(() => setMailTransportForTests(null)) + +const BASIS = '/api/public/termine/v1' +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin +const TAG_NAH = '2026-10-12' // 7 Tage spaeter: vorzeitiger Beginn zustimmungspflichtig +const TAG_FERN = '2026-10-26' // 21 Tage spaeter, Montag +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_termine' +const SITE_KEY = 'abcdefghijklmnop' +const R1 = 'r_aaaaaaa1' +const R2 = 'r_aaaaaaa2' +const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 Minuten, 45 Euro, sofort +const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage +const S_GRATIS = 's_bbbbbbb3' // Kennenlernen, 30 Minuten, ohne Preis, sofort +const O_LIVE = 'https://salon.de' +const O_PORTAL = 'https://project.webklar.com' +const ABSENDER_VORHER = config.smtp.from +const MODUS_VORHER = config.termine.mode +const BASIS_VORHER = config.publicBaseUrl +const FORM_VORHER = config.termine.formMinSekunden +const MIN_ANTWORT_VORHER = config.termine.minAntwortMs + +const plus = (date, ms) => new Date(date.getTime() + ms) + +const feature = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: true, siteKey: SITE_KEY }) + +const eingabeEinstellung = (over = {}) => ({ + betrieb: { + name: 'Salon Müller', + email: 'info@salon.de', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + websiteUrl: 'https://salon.de', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', + }, + anrede: 'sie', + bundesland: 'RP', + ressourcen: [ + { id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }, + { id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } }, + ], + leistungen: [ + { id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] }, + { id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] }, + { id: S_GRATIS, name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', ressourcen: [R1, R2] }, + ], + regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true }, + gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] }, + erinnerung: { anbieten: true, stundenVorher: 24 }, + storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' }, + recht: { gaesteSind: 'verbraucher', freizeit: false }, + zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] }, + ...over, +}) +const einstellung = (over) => { + const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT }) + assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) + return erg.einstellung +} +const ctxMit = (e = einstellung()) => ({ feature: feature(), project: { $id: PROJEKT, subdomain: 'salon', liveDomain: 'salon.de' }, einstellung: e, version: 1 }) + +/** Formularinhalt eines Gastes mit frischem Formular-Token (Alter 0 s, mit formMinSekunden 0 gueltig). */ +const buchungBody = (over = {}) => ({ + leistung: S_SOFORT, + ressource: 'egal', + tag: TAG_FERN, + zeit: '10:00', + name: 'Max Mustermann', + email: 'max@example.com', + telefon: '0171 1234567', + nachricht: '', + zusatz: {}, + erinnerung: false, + zustimmungen: { storno: true, vorzeitig: false }, + kontakt_url: '', + formToken: formToken(SITE_KEY, JETZT), + ...over, +}) + +let sink +let log +let clientIp = 0 +let ip + +/** Anfrage an die oeffentliche Schnittstelle: Pfad ab /v1, Origin wie im Browser (Vorgabe: Seite des Betriebs), Client-Adresse je Test. */ +function api(method, pfad, { body, origin = O_LIVE, ip: adresse = ip, headers = {}, cookie } = {}) { + return send(method, BASIS + pfad, { body, cookie, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } }) +} +const buchen = (over, opts) => api('POST', `/${SITE_KEY}/buchungen`, { body: buchungBody(over), ...opts }) +const tokenAnruf = (aktion, body, opts = {}) => api('POST', `/termin/${aktion}`, { body, origin: O_PORTAL, ...opts }) + +/** Die Einstellung wird gespeichert, damit die Schnittstelle (kontextPerSiteKey) sie findet. */ +async function speichere(e = einstellung()) { + resetKontextForTests() + const doc = aw.docs('termineConfig').get(FEATURE) + await speichereEinstellung(feature(), e, { version: doc ? doc.version : 0, now: JETZT }) + return ctxMit(e) +} + +beforeEach(async () => { + for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } + resetKontextForTests() + resetOutboxForTests() + resetTermineLimitsForTests() + resetPublicTermineForTests() + setzeUhrFuerTests(() => JETZT) + config.termine.mode = 'pilot' + config.termine.formMinSekunden = 0 + config.termine.minAntwortMs = 0 + config.smtp.from = 'noreply@webklar.com' + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } + aw.seed('portalFeatures', feature()) + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon', liveDomain: 'salon.de' }) + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) + clientIp += 1 + ip = `203.0.113.${clientIp}` +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + resetPublicTermineForTests() + config.termine.mode = MODUS_VORHER + config.termine.formMinSekunden = FORM_VORHER + config.termine.minAntwortMs = MIN_ANTWORT_VORHER + config.smtp.from = ABSENDER_VORHER + config.publicBaseUrl = BASIS_VORHER +}) + +const buchungen = () => [...aw.docs('termineBookings').values()] +const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId) +const mailsAn = (adresse) => sink.sent.filter((m) => m.to.some((t) => t.address === adresse)) +const AKTIV_FALSE = { aktiv: false } +const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } } + +/** Bestaetigungstoken (#v=) bzw. Verwaltungstoken (#m=) aus dem Text einer Mail. */ +const tokenAusMail = (mail, art) => new RegExp(`termin\\.html#${art}=([A-Za-z0-9_-]{43})`).exec(mail.text)[1] + +/** Buchung per HTTP halten und bestaetigen; => { id, verwaltung (Verwaltungstoken aus der Mail G02) }. */ +async function gebucht(over = {}) { + const halten = await buchen(over) + assert.equal(halten.status, 201, JSON.stringify(halten.body)) + await warteAufVersand() + const vorher = sink.sent.length + const g01 = mailsAn(over.email ?? 'max@example.com').at(-1) + const bestaetigt = await tokenAnruf('bestaetigen', { token: tokenAusMail(g01, 'v') }) + assert.equal(bestaetigt.status, 200, JSON.stringify(bestaetigt.body)) + await warteAufVersand() + const g02 = sink.sent.slice(vorher).find((m) => m.to.some((t) => t.address === (over.email ?? 'max@example.com'))) + const verwaltung = tokenAusMail(g02, 'm') + const buchung = buchungen().find((b) => verwaltungsToken(b.$id) === verwaltung) + assert.ok(buchung) + return { id: buchung.$id, verwaltung } +} + +// ---------------------------------------------------------------- Konfiguration + +test('config: Betriebsdaten, Leistungen, Regeln, Texte und Formular-Token; keine internen IDs; kurzer Cache ohne ETag', async () => { + const ctx = await speichere() + const res = await api('GET', `/${SITE_KEY}/config`, { origin: null }) + assert.equal(res.status, 200) + assert.equal(res.headers.get('cache-control'), 'public, max-age=60') + assert.equal(res.headers.get('etag'), null, 'das Formular-Token ist bei jedem Abruf neu: kein ETag') + assert.equal(res.headers.get('access-control-allow-origin'), '*') + assert.match(res.headers.get('content-type'), /^application\/json/) + const c = res.body + + assert.deepEqual(Object.keys(c).sort(), [ + 'aktiv', 'anrede', 'betrieb', 'erinnerung', 'farbe', 'formToken', 'gastFelder', 'heute', 'hinweis', 'leistungen', 'personWaehlbar', + 'preisHinweis', 'regeln', 'ressourcen', 'storno', 'texte', 'widerruf', 'zahlung', + ]) + assert.equal(c.aktiv, true) + assert.equal(c.heute, '2026-10-05') + assert.deepEqual(c.betrieb, { + name: 'Salon Müller', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', + }) + assert.deepEqual(c.ressourcen, [{ id: R1, name: 'Anna', typ: 'person' }, { id: R2, name: 'Ben', typ: 'person' }]) + assert.deepEqual(c.regeln, { horizontTage: 90, absageBisStd: 24 }) + assert.equal(c.personWaehlbar, true) + assert.deepEqual(c.zahlung, { modus: 'vorort', text: 'Bezahlung vor Ort: bar, EC-Karte' }) + assert.equal(c.preisHinweis, 'inkl. USt') + assert.deepEqual(c.erinnerung, { anbieten: true, stundenVorher: 24 }) + assert.equal(c.storno.text, 'Kostenlose Absage bis 24 Stunden vor dem Termin.') + assert.equal(c.widerruf, true) + assert.equal(c.hinweis, formularHinweis(ctx.einstellung)) + assert.deepEqual(c.texte, { + storno: ZUSTIMMUNG_STORNO, + erinnerung: ZUSTIMMUNG_ERINNERUNG(24), + vorzeitig: ZUSTIMMUNG_VORZEITIG('Salon Müller'), + gesundheit: GESUNDHEIT_HINWEIS, + widerrufsbelehrung: widerrufsbelehrung(ctx.einstellung.betrieb), + }) + assert.equal(pruefeFormToken(SITE_KEY, c.formToken, plus(JETZT, 10000)), 'ok') + assert.equal(pruefeFormToken('bcdefghijklmnopq', c.formToken, plus(JETZT, 10000)), 'ungueltig', 'das Token gilt nur fuer diesen siteKey') + + // nichts Internes: keine IDs des Portals, keine Feed-Token, keine Betriebs-E-Mail (ausser im Wortlaut der Widerrufsbelehrung, + // die die Adresse fuer den Widerruf nennen muss), keine internen Regeln + assert.match(c.texte.widerrufsbelehrung, /info@salon\.de/) + const text = JSON.stringify({ ...c, texte: { ...c.texte, widerrufsbelehrung: '' } }) + for (const verboten of [KUNDE, PROJEKT, FEATURE, 'customerId', 'projectId', 'featureId', '$id', 'feedToken', 'info@salon.de', 'maxOffeneJeEmail']) { + assert.ok(!text.includes(verboten), `${verboten} darf nicht in der Konfiguration stehen`) + } + for (const r of ctx.einstellung.ressourcen) assert.ok(!JSON.stringify(c).includes(r.feedToken)) +}) + +test('config: Knopf je Fall (Zahlungspflichtig buchen, Termin anfragen, Termin buchen)', async () => { + await speichere() + const { body } = await api('GET', `/${SITE_KEY}/config`) + const knopf = Object.fromEntries(body.leistungen.map((l) => [l.id, l.knopf])) + assert.deepEqual(knopf, { [S_SOFORT]: 'Zahlungspflichtig buchen', [S_ANFRAGE]: 'Termin anfragen', [S_GRATIS]: 'Termin buchen' }) + const damen = body.leistungen.find((l) => l.id === S_SOFORT) + assert.deepEqual(damen, { + id: S_SOFORT, name: 'Damenschnitt', beschreibung: '', dauer: 45, preis: '45,00 €', preisArt: 'fest', buchungsart: 'sofort', knopf: 'Zahlungspflichtig buchen', + verbraucher: true, ressourcen: [R1, R2], + }) +}) + +test('config: ohne Widerrufsrecht (nur Unternehmer) kein widerruf und keine Belehrung', async () => { + await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } })) + const { body } = await api('GET', `/${SITE_KEY}/config`) + assert.equal(body.widerruf, false) + assert.equal(Object.hasOwn(body.texte, 'widerrufsbelehrung'), false) + assert.deepEqual(Object.keys(body.texte).sort(), ['erinnerung', 'gesundheit', 'storno', 'vorzeitig']) +}) + +test('config: eine nicht zu erzeugende Belehrung (Anschrift fehlt) ist 503 statt einer Buchung ohne Belehrung', async () => { + const e = einstellung() + e.betrieb.adresse = '' + await speichere(e) + const res = await api('GET', `/${SITE_KEY}/config`) + assert.equal(res.status, 503) + assert.deepEqual(res.body, NICHT_ERREICHBAR) +}) + +// ---------------------------------------------------------------- Unbekannt, aus, abgeschaltet + +test('unbekannter, ungueltiger, inaktiver und abgeschalteter siteKey sowie Modus off: immer { aktiv: false } (GET und POST), kalender 404', async () => { + await speichere() + const GET_PFADE = ['config', `tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`, `zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`] + const POST_PFADE = ['buchungen', 'widerruf'] + const pruefe = async (siteKey, was) => { + for (const pfad of GET_PFADE) { + const res = await api('GET', `/${siteKey}/${pfad}`) + assert.equal(res.status, 200, `${was} GET ${pfad}`) + assert.deepEqual(res.body, AKTIV_FALSE, `${was} GET ${pfad}`) + } + for (const pfad of POST_PFADE) { + const res = await api('POST', `/${siteKey}/${pfad}`, { body: buchungBody({ name: 'Max', nummer: 'ABCD1234' }) }) + assert.equal(res.status, 200, `${was} POST ${pfad}`) + assert.deepEqual(res.body, AKTIV_FALSE, `${was} POST ${pfad}`) + } + const feed = await api('GET', `/kalender/${siteKey}/${'a'.repeat(43)}.ics`) + assert.equal(feed.status, 404, `${was} Kalender`) + } + await pruefe('zzzzzzzzzzzzzzzz', 'unbekannt') + await pruefe('x', 'ungueltiges Format') + await pruefe(SITE_KEY.toUpperCase(), 'Grossschreibung') + + for (const state of ['inactive', 'disabled', 'pending']) { + aw.seed('portalFeatures', { ...feature(state) }) + resetKontextForTests() + await pruefe(SITE_KEY, `Zustand ${state}`) + } + aw.seed('portalFeatures', feature()) + resetKontextForTests() + assert.equal((await api('GET', `/${SITE_KEY}/config`)).body.aktiv, true, 'zurueck auf active') + + config.termine.mode = 'off' + await pruefe(SITE_KEY, 'Modus off') +}) + +test('abgeschaltete Funktion (disabled): die Token-Seiten bleiben nutzbar (Absagen, Widerruf)', async () => { + await speichere() + const { verwaltung, id } = await gebucht() + aw.seed('portalFeatures', feature('disabled')) + resetKontextForTests() + assert.deepEqual((await api('GET', `/${SITE_KEY}/config`)).body, AKTIV_FALSE) + const vorschau = await tokenAnruf('vorschau', { token: verwaltung }) + assert.equal(vorschau.status, 200) + assert.equal(vorschau.body.art, 'verwalten') + const absage = await tokenAnruf('absagen', { token: verwaltung }) + assert.deepEqual(absage.body, { ok: true, status: 'abgesagt' }) + assert.equal(aw.docs('termineBookings').get(id).status, 'abgesagt') + config.termine.mode = 'off' + assert.equal((await tokenAnruf('vorschau', { token: verwaltung })).status, 404, 'Modus off: auch die Token-Seiten antworten nichts') +}) + +test('Modus off: keine Abfrage an Appwrite - auch nicht fuer die Token-Seiten und den Kalender', async () => { + await speichere() + config.termine.mode = 'off' + aw.failNext({ method: 'GET', status: 500, message: 'Nachbau: darf nicht abgefragt werden' }) // jede Abfrage wuerde scheitern + const [config1, vorschau, absagen, tage, feed] = [ + await api('GET', `/${SITE_KEY}/config`), + await tokenAnruf('vorschau', { token: 'x'.repeat(43) }), + await tokenAnruf('absagen', { token: 'x'.repeat(43) }), + await tokenAnruf('tage', { token: 'x'.repeat(43), monat: '2026-10' }), + await api('GET', `/kalender/${SITE_KEY}/${'a'.repeat(43)}.ics`), + ] + assert.deepEqual([config1.status, config1.body], [200, AKTIV_FALSE]) + for (const antwort of [vorschau, absagen, tage]) { + assert.deepEqual([antwort.status, antwort.body.code], [404, 'unbekannt']) + } + assert.equal(feed.status, 404) + // die Abfrage, die scheitern sollte, ist noch scharf: erst jetzt wird sie verbraucht + config.termine.mode = 'pilot' + resetKontextForTests() + assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 503) + assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200) +}) + +// ---------------------------------------------------------------- CORS + +test('CORS: GET fuer alle Herkunft; POST nur von Seiten des Betriebs, des Portals oder gleicher Herkunft, sonst 403', async () => { + await speichere() + const get = await api('GET', `/${SITE_KEY}/config`, { origin: 'https://fremd.example' }) + assert.equal(get.status, 200) + assert.equal(get.headers.get('access-control-allow-origin'), '*') + + const gleicheHerkunft = new URL(app.url).origin + const erlaubt = [O_LIVE, 'https://www.salon.de', 'https://salon.project.webklar.com', O_PORTAL, gleicheHerkunft] + for (const origin of erlaubt) { + const res = await buchen({ kontakt_url: 'x' }, { origin }) + assert.equal(res.status, 201, origin) + assert.equal(res.headers.get('access-control-allow-origin'), origin, origin) + assert.match(res.headers.get('vary'), /Origin/i) + } + let fremdeIp = 0 + for (const origin of ['https://fremd.example', 'https://salon.de.evil.example', 'https://evilsalon.de', 'null', 'kaputt', 'ftp://salon.de', null]) { + for (const [pfad, body] of [['buchungen', buchungBody()], ['widerruf', { name: 'Max', email: 'max@example.com', nummer: '' }]]) { + fremdeIp += 1 + const res = await api('POST', `/${SITE_KEY}/${pfad}`, { body, origin, ip: `198.51.100.${fremdeIp}` }) + assert.equal(res.status, 403, `${origin} ${pfad}`) + assert.deepEqual(res.body, { fehler: { _: 'Nicht erlaubt' } }) + assert.equal(res.headers.get('access-control-allow-origin'), null, 'kein Zugriff fuer fremde Seiten') + } + } + assert.equal(buchungen().length, 0) + assert.equal(sink.sent.length, 0) +}) + +test('CORS: die Token-Seiten (/termin/*) nehmen nur das Portal und die gleiche Herkunft an, nicht die Seite des Betriebs', async () => { + await speichere() + for (const aktion of ['vorschau', 'bestaetigen', 'absagen', 'verschieben', 'widerruf', 'tage', 'zeiten']) { + const betrieb = await tokenAnruf(aktion, { token: 'x' }, { origin: O_LIVE }) + assert.equal(betrieb.status, 403, aktion) + assert.deepEqual(betrieb.body, { fehler: { _: 'Nicht erlaubt' } }) + const portal = await tokenAnruf(aktion, { token: 'x' }) + assert.notEqual(portal.status, 403, aktion) + assert.equal(portal.headers.get('access-control-allow-origin'), O_PORTAL) + const gleich = await tokenAnruf(aktion, { token: 'x' }, { origin: new URL(app.url).origin }) + assert.notEqual(gleich.status, 403, aktion) + } +}) + +test('Preflight: 204 mit Methoden, Content-Type und 24 Stunden fuer erlaubte Herkunft, sonst 403', async () => { + await speichere() + const preflight = (pfad, origin) => + api('OPTIONS', pfad, { origin, headers: { 'access-control-request-method': 'POST', 'access-control-request-headers': 'content-type' } }) + for (const pfad of ['/' + SITE_KEY + '/buchungen', '/' + SITE_KEY + '/widerruf']) { + const ok = await preflight(pfad, O_LIVE) + assert.equal(ok.status, 204, pfad) + assert.equal(ok.headers.get('access-control-allow-origin'), O_LIVE) + assert.equal(ok.headers.get('access-control-allow-methods'), 'GET, POST, OPTIONS') + assert.equal(ok.headers.get('access-control-allow-headers'), 'Content-Type') + assert.equal(ok.headers.get('access-control-max-age'), '86400') + assert.match(ok.headers.get('vary'), /Origin/i) + const fremd = await preflight(pfad, 'https://fremd.example') + assert.equal(fremd.status, 403) + assert.deepEqual(fremd.body, { fehler: { _: 'Nicht erlaubt' } }) + } + assert.equal((await preflight('/termin/vorschau', O_PORTAL)).status, 204) + assert.equal((await preflight('/termin/vorschau', O_LIVE)).status, 403) + assert.equal((await preflight('/termin/bestaetigen', new URL(app.url).origin)).status, 204) + // ohne Funktion gibt es nichts zu pruefen: die Antwort { aktiv: false } soll das Widget trotzdem lesen koennen + const unbekannt = await preflight('/zzzzzzzzzzzzzzzz/buchungen', 'https://fremd.example') + assert.equal(unbekannt.status, 204) + assert.equal(unbekannt.headers.get('access-control-allow-origin'), '*') +}) + +// ---------------------------------------------------------------- Buchen: Schutz + +test('Honeypot und zu schnell: dieselbe Erfolgsantwort wie eine echte Buchung, nichts gespeichert, keine Mail', async () => { + await speichere() + const erwartet = { ok: true, email: 'm***@example.com', minuten: 30 } + + const honig = await buchen({ kontakt_url: 'https://spam.example' }) + assert.equal(honig.status, 201) + assert.deepEqual(honig.body, erwartet) + const honigListe = await buchen({ kontakt_url: ['x'] }) + assert.deepEqual([honigListe.status, honigListe.body], [201, erwartet]) + + config.termine.formMinSekunden = 3 + const schnell = await buchen({ formToken: formToken(SITE_KEY, JETZT) }) // Alter 0 s < 3 s + assert.equal(schnell.status, 201) + assert.deepEqual(schnell.body, erwartet) + await warteAufVersand() + assert.equal(buchungen().length, 0) + assert.equal(aw.docs('termineClaims').size, 0) + assert.equal(sink.sent.length, 0) + assert.equal(aw.docs('termineOutbox').size, 0) + + // drei Sekunden spaeter ist es eine echte Buchung mit derselben Antwort + setzeUhrFuerTests(() => plus(JETZT, 4000)) + const echt = await buchen({ formToken: formToken(SITE_KEY, JETZT) }) + assert.equal(echt.status, 201) + assert.deepEqual(echt.body, erwartet) + await warteAufVersand() + assert.equal(buchungen().length, 1) + assert.equal(sink.sent.length, 1) +}) + +test('Honeypot ohne gueltige Adresse: Erfolgsantwort mit neutraler Adresse', async () => { + await speichere() + const res = await buchen({ kontakt_url: 'x', email: 'kein-postfach' }) + assert.deepEqual([res.status, res.body], [201, { ok: true, email: '***', minuten: 30 }]) +}) + +test('Formular-Token zu alt, fehlend oder fremd: 400 "Bitte laden Sie die Seite neu." ohne Buchung', async () => { + await speichere() + const melde = { fehler: { _: 'Bitte laden Sie die Seite neu.' }, code: 'neu_laden' } + for (const token of [formToken(SITE_KEY, plus(JETZT, -3 * 3600 * 1000)), formToken('bcdefghijklmnopq', JETZT), 'unsinn', '', undefined, 42]) { + const res = await buchen({ formToken: token }) + assert.equal(res.status, 400, String(token)) + assert.deepEqual(res.body, melde, String(token)) + } + assert.equal((await buchen({ formToken: formToken(SITE_KEY, plus(JETZT, -3600 * 1000)) })).status, 201, 'eine Stunde alt gilt') + assert.equal(buchungen().length, 1) +}) + +test('Eingabefehler: 400 mit Feldfehlern, Zeit nicht mehr frei: 409 mit Feld zeit; nichts ausser dem Fehlertext in der Antwort', async () => { + await speichere() + const leer = await buchen({ name: '', email: 'kaputt', zeit: '' }) + assert.equal(leer.status, 400) + assert.deepEqual(Object.keys(leer.body), ['fehler']) + assert.deepEqual(Object.keys(leer.body.fehler).sort(), ['email', 'name', 'zeit']) + assert.ok(Object.values(leer.body.fehler).every((t) => typeof t === 'string')) + + const array = await api('POST', `/${SITE_KEY}/buchungen`, { body: [] }) + assert.equal(array.status, 400) + assert.deepEqual(Object.keys(array.body.fehler), ['_']) + + assert.equal((await buchen({ zeit: '10:00', ressource: R1 })).status, 201) + const doppelt = await buchen({ zeit: '10:00', ressource: R1, email: 'anna@example.com' }) + assert.equal(doppelt.status, 409) + assert.deepEqual(Object.keys(doppelt.body.fehler), ['zeit']) + assert.equal(doppelt.body.code, 'SLOT_VERGEBEN') + assert.equal(buchungen().length, 1) +}) + +test('Buchung: 201 mit maskierter Adresse und Haltezeit; Buchung gehalten, Mail G01 geht an die Adresse', async () => { + await speichere() + const res = await buchen({ email: ' Max@GMail.com ' }) + assert.equal(res.status, 201) + assert.deepEqual(res.body, { ok: true, email: 'm***@gmail.com', minuten: 30 }) + assert.equal(res.headers.get('cache-control'), 'no-store') + await warteAufVersand() + const [b] = buchungen() + assert.equal(b.status, 'gehalten') + assert.equal(b.gastEmail, 'max@gmail.com') + assert.equal(mailsAn('max@gmail.com').length, 1) + assert.ok(!JSON.stringify(res.body).includes(b.$id)) +}) + +test('zu viele offene Termine: dieselbe 201-Antwort, keine neue Buchung, statt der Bestaetigung geht G01 "zu viele"', async () => { + await speichere() + for (const zeit of ['10:00', '11:00', '12:00']) assert.equal((await buchen({ zeit })).status, 201) + await warteAufVersand() + const vorher = sink.sent.length + const vierte = await buchen({ zeit: '13:00' }) + assert.deepEqual([vierte.status, vierte.body], [201, { ok: true, email: 'm***@example.com', minuten: 30 }]) + await warteAufVersand() + assert.equal(buchungen().length, 3) + assert.equal(sink.sent.length, vorher + 1) + assert.doesNotMatch(sink.sent.at(-1).text, /termin\.html#v=/, 'keine Bestaetigung') +}) + +// ---------------------------------------------------------------- Buchen: Grenzen + +test('11. Buchungsversuch je IP in 10 Minuten: 429; andere IPs bleiben frei', async () => { + await speichere() + for (let i = 0; i < 10; i += 1) assert.equal((await buchen({ kontakt_url: 'x' })).status, 201, `Versuch ${i + 1}`) + const elfte = await buchen({ zeit: '10:00' }) + assert.equal(elfte.status, 429) + assert.deepEqual(Object.keys(elfte.body), ['fehler']) + assert.deepEqual(Object.keys(elfte.body.fehler), ['_']) + assert.ok(Number(elfte.headers.get('retry-after')) > 0) + assert.equal(buchungen().length, 0, 'auch ein gueltiges Formular wird nicht mehr angenommen') + + assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '198.51.100.9' })).status, 201, 'andere IP') + setzeUhrFuerTests(() => plus(JETZT, 10 * 60000)) + assert.equal((await buchen({ kontakt_url: 'x' })).status, 201, 'nach 10 Minuten wieder') +}) + +test('IPv6: alle Adressen eines /64 teilen sich die 10 Buchungsversuche, ein anderes /64 nicht', async () => { + await speichere() + for (let i = 0; i < 5; i += 1) assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `2001:db8:1:2::${i + 1}` })).status, 201) + for (let i = 0; i < 5; i += 1) assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `2001:db8:1:2:ffff::${i + 1}` })).status, 201) + assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '2001:0db8:0001:0002:0000:0000:0000:0099' })).status, 429) + assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '2001:db8:1:3::1' })).status, 201) + assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '::ffff:203.0.113.200' })).status, 201, 'IPv4 in IPv6 zaehlt als IPv4') +}) + +test('die IP kommt vom Proxy: ein selbst gesetzter erster Eintrag in X-Forwarded-For zaehlt nicht', async () => { + await speichere() + for (let i = 0; i < 10; i += 1) { + assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `192.0.2.${i + 1}, ${ip}` })).status, 201) + } + assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `192.0.2.99, ${ip}` })).status, 429, 'gezaehlt wird der letzte Eintrag (trust proxy 1)') +}) + +test('je siteKey 60 gehaltene Termine pro Stunde: danach 429, ohne dass eine Buchung entsteht; Honeypot-Versuche zaehlen nicht mit', async () => { + await speichere() + for (let i = 0; i < 60; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true) + const res = await buchen() + assert.equal(res.status, 429) + assert.deepEqual(Object.keys(res.body.fehler), ['_']) + assert.equal(buchungen().length, 0) + + resetTermineLimitsForTests() + for (let i = 0; i < 59; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true) + for (let i = 0; i < 3; i += 1) assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `198.51.100.${i + 1}` })).status, 201) + assert.equal((await buchen({ zeit: '10:00' })).status, 201, 'die 60. Haltung geht noch') + assert.equal((await buchen({ zeit: '11:00', email: 'anna@example.com' })).status, 429, 'die 61. nicht') + assert.equal(buchungen().length, 1) +}) + +test('je Adresse und Funktion 5 Bestaetigungsmails pro Tag: die sechste Anfrage bekommt dieselbe Antwort, aber keine Mail', async () => { + await speichere() + const zeiten = ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00'] + const antworten = [] + for (const zeit of zeiten) antworten.push(await buchen({ zeit, email: 'Viel@Example.com' })) + await warteAufVersand() + assert.ok(antworten.every((a) => a.status === 201), JSON.stringify(antworten.map((a) => a.status))) + assert.ok(antworten.every((a) => JSON.stringify(a.body) === JSON.stringify(antworten[0].body)), 'alle Antworten gleich') + assert.deepEqual(antworten[0].body, { ok: true, email: 'v***@example.com', minuten: 30 }) + assert.equal(buchungen().length, 3, 'nur drei offene Termine') + assert.equal(mailsAn('viel@example.com').length, 5, '3 Bestaetigungen + 2 "zu viele", die sechste Anfrage bekommt nichts') + + // andere Adressen zaehlen getrennt; Fehler der Zeit kommen vor der Mailgrenze (kein Orakel, ob die Adresse an der Grenze ist) + const andere = await buchen({ zeit: '16:00', email: 'anna@example.com' }) + assert.equal(andere.status, 201) + const ungueltig = await buchen({ zeit: '17:30', email: 'viel@example.com' }) // 17:30 liegt nicht im Raster der freien Zeiten + assert.equal(ungueltig.status, 409, 'Fehler der Zeit kommen vor der Mailgrenze') +}) + +test('Lesen: 120 Abrufe je IP und Minute (IPv6 je /64), 600 je siteKey; danach 429 mit Retry-After', async () => { + await speichere() + for (let i = 0; i < 120; i += 1) assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, `Abruf ${i + 1}`) + const zuviel = await api('GET', `/${SITE_KEY}/config`) + assert.equal(zuviel.status, 429) + assert.deepEqual(Object.keys(zuviel.body.fehler), ['_']) + assert.ok(Number(zuviel.headers.get('retry-after')) > 0) + assert.equal((await api('GET', `/${SITE_KEY}/config`, { ip: '198.51.100.1' })).status, 200, 'andere IP') + assert.equal((await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`)).status, 429, 'tage zaehlt mit') + setzeUhrFuerTests(() => plus(JETZT, 60000)) + assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, 'nach einer Minute wieder') + + resetTermineLimitsForTests() + for (let i = 0; i < 5; i += 1) { + for (let k = 0; k < 120; k += 1) assert.equal((await api('GET', `/${SITE_KEY}/config`, { ip: `198.51.100.${i + 10}` })).status, 200) + } + assert.equal((await api('GET', `/${SITE_KEY}/config`, { ip: '198.51.100.99' })).status, 429, 'der siteKey ist ausgeschoepft') + assert.deepEqual((await api('GET', '/zzzzzzzzzzzzzzzz/config', { ip: '198.51.100.99' })).body, AKTIV_FALSE, 'unbekannte Schluessel haben kein eigenes Kontingent') +}) + +test('Widerruf: 10 Erklaerungen je IP und Stunde (beide Wege), danach 429', async () => { + await speichere() + for (let i = 0; i < 5; i += 1) { + assert.equal((await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: 'ABCD1234' } })).status, 200) + assert.equal((await tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' })).status, 404) + } + const elfte = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: 'ABCD1234' } }) + assert.equal(elfte.status, 429) + assert.equal((await tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' })).status, 429) + assert.equal((await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: '' }, ip: '198.51.100.7' })).status, 200) +}) + +test('Mindestantwortzeit: Buchung und beide Widerrufe antworten nie schneller als minAntwortMs, auch mit Fehler', async () => { + await speichere() + const { id } = await gebucht() + config.termine.minAntwortMs = 200 + const dauer = async (fn) => { + const start = performance.now() + const res = await fn() + return { res, ms: performance.now() - start } + } + const nummer = buchungsNummer(id) + const treffer = await dauer(() => api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer } })) + const ohne = await dauer(() => api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: 'AAAAAAAA' } })) + const honig = await dauer(() => buchen({ kontakt_url: 'x' })) + const fehler = await dauer(() => buchen({ name: '' })) + const token = await dauer(() => tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' })) + for (const [was, d] of Object.entries({ treffer, ohne, honig, fehler, token })) assert.ok(d.ms >= 190, `${was}: ${Math.round(d.ms)} ms`) + assert.deepEqual(treffer.res.body, ohne.res.body) + assert.equal(honig.res.status, 201) + assert.equal(fehler.res.status, 400) + // lesende Abrufe sind nicht verzoegert + const lesen = await dauer(() => api('GET', `/${SITE_KEY}/config`)) + assert.ok(lesen.ms < 150, `config: ${Math.round(lesen.ms)} ms`) +}) + +// ---------------------------------------------------------------- Tage und Zeiten + +test('tage: Tage mit freien Zeiten und Monatsdaten, 15 Sekunden Cache; Eingaben werden geprueft', async () => { + await speichere() + const res = await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`, { origin: null }) + assert.equal(res.status, 200) + assert.equal(res.headers.get('cache-control'), 'public, max-age=15') + assert.equal(res.headers.get('access-control-allow-origin'), '*') + assert.deepEqual(res.body.tage, ['2026-10-05', '2026-10-12', '2026-10-19', '2026-10-26'], 'montags 09-18 Uhr; heute ab 12:00') + assert.deepEqual(res.body.monat, { label: 'Oktober 2026', ersterWochentag: 4, anzahlTage: 31, vorher: '2026-09', nachher: '2026-11' }) + + const einzeln = await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=${R1}&monat=2026-11`) + assert.deepEqual(einzeln.body.tage, ['2026-11-02', '2026-11-09', '2026-11-16', '2026-11-23', '2026-11-30']) + + const fehler = [ + [`leistung=${S_SOFORT}&ressource=egal&monat=2026-13`, 'monat'], + [`leistung=${S_SOFORT}&ressource=egal&monat=`, 'monat'], + [`leistung=${S_SOFORT}&ressource=egal`, 'monat'], + [`leistung=${S_SOFORT}&ressource=egal&monat=0050-05`, 'monat'], + ['leistung=s_unbekannt&ressource=egal&monat=2026-10', 'leistung'], + ['ressource=egal&monat=2026-10', 'leistung'], + [`leistung=${S_SOFORT}&leistung=${S_GRATIS}&ressource=egal&monat=2026-10`, 'leistung'], + [`leistung=${S_SOFORT}&ressource=r_unbekannt&monat=2026-10`, 'ressource'], + ] + for (const [abfrage, feld] of fehler) { + const f = await api('GET', `/${SITE_KEY}/tage?${abfrage}`) + assert.equal(f.status, 400, abfrage) + assert.deepEqual(Object.keys(f.body), ['fehler'], abfrage) + assert.deepEqual(Object.keys(f.body.fehler), [feld], abfrage) + } +}) + +test('zeiten: Uhrzeiten, Datum lang und vorzeitigNoetig fuer genau diesen Tag; ohne Angabe der Person', async () => { + await speichere() + const nah = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_NAH}`) + assert.equal(nah.status, 200) + assert.equal(nah.headers.get('cache-control'), 'no-store') + assert.equal(nah.headers.get('access-control-allow-origin'), '*') + assert.deepEqual(Object.keys(nah.body).sort(), ['datumLang', 'vorzeitigNoetig', 'zeiten']) + assert.equal(nah.body.datumLang, 'Montag, 12.10.2026') + assert.equal(nah.body.vorzeitigNoetig, true, 'Beginn innerhalb der Widerrufsfrist einer bezahlten Verbraucherbuchung') + assert.equal(nah.body.zeiten[0], '09:00') + assert.equal(nah.body.zeiten.at(-1), '17:00', 'Block 45 + 5 Minuten muss vor 18:00 enden') + assert.ok(nah.body.zeiten.every((z) => /^\d\d:\d\d$/.test(z))) + + const fern = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`) + assert.equal(fern.body.vorzeitigNoetig, false) + const gratis = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_GRATIS}&ressource=egal&tag=${TAG_NAH}`) + assert.equal(gratis.body.vorzeitigNoetig, false, 'ohne Preis keine Zustimmung') + const anfrage = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_ANFRAGE}&ressource=egal&tag=${TAG_FERN}`) + assert.equal(anfrage.body.vorzeitigNoetig, false) + + const frei = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_SOFORT}&ressource=egal&tag=2026-10-13`) + assert.deepEqual(frei.body, { zeiten: [], datumLang: 'Dienstag, 13.10.2026', vorzeitigNoetig: false }, 'Dienstag: geschlossen') + + for (const abfrage of [`leistung=${S_SOFORT}&ressource=egal&tag=2026-02-30`, `leistung=${S_SOFORT}&ressource=egal`, `leistung=x&ressource=egal&tag=${TAG_FERN}`]) { + const f = await api('GET', `/${SITE_KEY}/zeiten?${abfrage}`) + assert.equal(f.status, 400, abfrage) + } +}) + +test('tage und zeiten: 10 Sekunden Speicher-Cache je siteKey und Parameter', async () => { + const ctx = await speichere() + const zeitenUrl = `/${SITE_KEY}/zeiten?leistung=${S_GRATIS}&ressource=${R1}&tag=${TAG_FERN}` + const tageUrl = `/${SITE_KEY}/tage?leistung=${S_GRATIS}&ressource=${R1}&monat=2026-10` + const vorher = await api('GET', zeitenUrl) + assert.ok(vorher.body.zeiten.includes('10:00')) + const tageVorher = await api('GET', tageUrl) + // die Zeit wird (am Cache vorbei) vergeben; zwei Termine an einem Tag: der Tag bleibt frei, die Zeit nicht + await halte(ctx, { ...buchungBody(), leistung: S_GRATIS, ressource: R1, zeit: '10:00', zustimmungen: { storno: true, vorzeitig: false } }, JETZT) + const gemerkt = await api('GET', zeitenUrl) + assert.deepEqual(gemerkt.body, vorher.body, 'innerhalb von 10 Sekunden die gemerkte Antwort') + const andereRessource = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_GRATIS}&ressource=${R2}&tag=${TAG_FERN}`) + assert.ok(andereRessource.body.zeiten.includes('10:00'), 'andere Parameter, anderer Eintrag') + + setzeUhrFuerTests(() => plus(JETZT, 9999)) + assert.deepEqual((await api('GET', zeitenUrl)).body, vorher.body, 'noch keine 10 Sekunden') + setzeUhrFuerTests(() => plus(JETZT, 10000)) + const frisch = await api('GET', zeitenUrl) + assert.ok(!frisch.body.zeiten.includes('10:00'), 'nach 10 Sekunden neu berechnet') + assert.deepEqual(frisch.body.zeiten, vorher.body.zeiten.filter((z) => !['09:45', '10:00', '10:15'].includes(z)), 'nur die vergebene Zeit und ihre Ueberlappungen fehlen') + assert.deepEqual((await api('GET', tageUrl)).body, tageVorher.body, 'der Tag bleibt frei (andere Zeiten sind noch da)') +}) + +// ---------------------------------------------------------------- Ablauf ueber HTTP + +test('Ablauf: halten, Vorschau, bestaetigen, Verwaltungslink, freie Zeiten, verschieben, absagen', async () => { + await speichere() + const halten = await buchen({ zeit: '10:00', ressource: R1 }) + assert.equal(halten.status, 201) + await warteAufVersand() + const vToken = tokenAusMail(mailsAn('max@example.com')[0], 'v') + + const vorschau = await tokenAnruf('vorschau', { token: vToken }) + assert.equal(vorschau.status, 200) + assert.equal(vorschau.body.art, 'bestaetigen') + assert.equal(vorschau.body.status, 'gehalten') + assert.equal(vorschau.body.knopf, 'Zahlungspflichtig bestätigen') + assert.equal(vorschau.body.termin.leistung, 'Damenschnitt') + assert.equal(vorschau.body.termin.zeit, '10:00') + assert.equal(vorschau.body.aktionen.bestaetigen, true) + assert.equal(vorschau.body.gast, null) + assert.equal(vorschau.headers.get('cache-control'), 'no-store') + assert.equal(vorschau.headers.get('access-control-allow-origin'), O_PORTAL) + const [buchung] = buchungen() + assert.ok(!JSON.stringify(vorschau.body).includes(buchung.$id) && !JSON.stringify(vorschau.body).includes(FEATURE)) + + const bestaetigt = await tokenAnruf('bestaetigen', { token: vToken }) + assert.deepEqual([bestaetigt.status, bestaetigt.body], [200, { ok: true, status: 'bestaetigt' }]) + const nochmal = await tokenAnruf('bestaetigen', { token: vToken }) + assert.deepEqual([nochmal.status, nochmal.body], [200, { ok: true, status: 'bestaetigt', schon: true }]) + await warteAufVersand() + const g02 = mailsAn('max@example.com').find((m) => /#m=/.test(m.text)) + const mToken = tokenAusMail(g02, 'm') + + const verwalten = await tokenAnruf('vorschau', { token: mToken }) + assert.equal(verwalten.body.art, 'verwalten') + assert.deepEqual(verwalten.body.gast, { name: 'Max Mustermann', email: 'max@example.com' }) + assert.equal(verwalten.body.aktionen.absagen, true) + assert.equal(verwalten.body.aktionen.verschieben, true) + + const tage = await tokenAnruf('tage', { token: mToken, monat: '2026-10' }) + assert.equal(tage.status, 200) + assert.ok(tage.body.tage.includes(TAG_FERN)) + assert.equal(tage.body.monat.label, 'Oktober 2026') + const zeiten = await tokenAnruf('zeiten', { token: mToken, tag: TAG_FERN }) + assert.ok(zeiten.body.zeiten.includes('10:00') && zeiten.body.zeiten.includes('10:30')) + assert.deepEqual(Object.keys(zeiten.body), ['zeiten']) + + const verschoben = await tokenAnruf('verschieben', { token: mToken, tag: TAG_FERN, zeit: '10:30' }) + assert.equal(verschoben.status, 200) + assert.deepEqual(verschoben.body, { ok: true, status: 'bestaetigt', termin: { datum: 'Montag, 26.10.2026', zeit: '10:30' } }) + assert.equal(new Date(aw.docs('termineBookings').get(buchung.$id).startsAt).toISOString(), '2026-10-26T09:30:00.000Z') + + const abgesagt = await tokenAnruf('absagen', { token: mToken }) + assert.deepEqual([abgesagt.status, abgesagt.body], [200, { ok: true, status: 'abgesagt' }]) + assert.equal(aw.docs('termineBookings').get(buchung.$id).status, 'abgesagt') + assert.equal(claims(buchung.$id).length, 0) + assert.equal((await tokenAnruf('absagen', { token: mToken })).body.schon, true) +}) + +test('Token-Fehler: unbekannter Link 404, abgelaufene Reservierung 410 mit Betrieb, Frist vorbei 409; immer { fehler, code }', async () => { + await speichere() + const unbekannt = await tokenAnruf('vorschau', { token: 'x'.repeat(43) }) + assert.deepEqual([unbekannt.status, unbekannt.body], [404, { fehler: { _: 'Dieser Link ist ungültig oder abgelaufen.' }, code: 'unbekannt' }]) + for (const body of [{}, { token: 5 }, { token: '' }, { token: 'zu kurz' }, [], { token: { $ne: null } }]) { + const res = await tokenAnruf('vorschau', body) + assert.equal(res.status, 404, JSON.stringify(body)) + } + + await buchen({ zeit: '10:00' }) + await warteAufVersand() + const vToken = tokenAusMail(mailsAn('max@example.com')[0], 'v') + setzeUhrFuerTests(() => plus(JETZT, 31 * 60000)) + const abgelaufen = await tokenAnruf('bestaetigen', { token: vToken }) + assert.equal(abgelaufen.status, 410) + assert.equal(abgelaufen.body.code, 'abgelaufen') + assert.deepEqual(abgelaufen.body.fehler, { _: 'Diese Reservierung ist abgelaufen.' }) + assert.deepEqual(abgelaufen.body.betrieb, { name: 'Salon Müller', telefon: '06301 1234', websiteUrl: 'https://salon.de/' }) + + setzeUhrFuerTests(() => JETZT) + const { verwaltung } = await gebucht({ zeit: '11:00', email: 'anna@example.com' }) + setzeUhrFuerTests(() => new Date('2026-10-26T08:00:00.000Z')) // 2 Stunden vor dem Termin + const frist = await tokenAnruf('absagen', { token: verwaltung }) + assert.equal(frist.status, 409) + assert.equal(frist.body.code, 'frist_vorbei') + assert.match(frist.body.fehler._, /telefonisch/) +}) + +test('Widerruf mit und ohne Treffer: dieselbe Antwort; mit Treffer ist der Termin widerrufen', async () => { + await speichere() + const { id } = await gebucht({ zeit: '10:00' }) + const nummer = buchungsNummer(id) + const erwartet = { ok: true, eingang: { datum: '05.10.2026', uhrzeit: '10:00' } } + + const ohne = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max Mustermann', email: 'max@example.com', nummer: 'ZZZZZZZZ' } }) + assert.deepEqual([ohne.status, ohne.body], [200, erwartet]) + assert.equal(aw.docs('termineBookings').get(id).status, 'bestaetigt') + const fremd = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max Mustermann', email: 'anna@example.com', nummer } }) + assert.deepEqual([fremd.status, fremd.body], [200, erwartet], 'richtige Nummer, fremde Adresse') + assert.equal(aw.docs('termineBookings').get(id).status, 'bestaetigt') + + const treffer = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max Mustermann', email: ' MAX@example.com ', nummer: nummer.toLowerCase() } }) + assert.deepEqual([treffer.status, treffer.body], [200, erwartet]) + const b = aw.docs('termineBookings').get(id) + assert.equal(b.status, 'abgesagt') + assert.ok(b.widerrufenAt) + assert.equal(claims(id).length, 0) + assert.equal(treffer.headers.get('cache-control'), 'no-store') + + const fehler = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: '', email: 'kaputt' } }) + assert.equal(fehler.status, 400) + assert.deepEqual(Object.keys(fehler.body.fehler).sort(), ['email', 'name']) +}) + +test('Widerruf mit Verwaltungslink (/termin/widerruf): nur mit Namen, Eingang in Berliner Zeit', async () => { + await speichere() + const { id, verwaltung } = await gebucht({ zeit: '10:00' }) + const ohneName = await tokenAnruf('widerruf', { token: verwaltung, name: '' }) + assert.equal(ohneName.status, 400) + assert.deepEqual(Object.keys(ohneName.body.fehler), ['name']) + const ok = await tokenAnruf('widerruf', { token: verwaltung, name: 'Max Mustermann' }) + assert.deepEqual([ok.status, ok.body], [200, { ok: true, eingang: { datum: '05.10.2026', uhrzeit: '10:00' } }]) + assert.equal(aw.docs('termineBookings').get(id).status, 'abgesagt') + const unbekannt = await tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' }) + assert.equal(unbekannt.status, 404) +}) + +// ---------------------------------------------------------------- Kalender-Abo + +test('Kalender-Abo: falsches Token 404 ohne Hinweis; richtiges liefert bestaetigte Termine und Sperren der Ressource im Zeitraum', async () => { + const ctx = await speichere() + const [r1, r2] = ctx.einstellung.ressourcen + const bestaetigt = await gebucht({ zeit: '10:00', ressource: R1 }) + await buchen({ zeit: '14:00', ressource: R1, email: 'gehalten@example.com' }) // nur gehalten + const andere = await gebucht({ zeit: '10:00', ressource: R2, email: 'ben@example.com' }) + await sperre(ctx, { ressource: R1, tag: '2026-10-12', von: '13:00', bis: '15:00', titel: 'Fortbildung' }, JETZT) + await sperre(ctx, { ressource: R1, tag: '2027-01-11', von: '13:00', bis: '15:00', titel: 'Zu spaet' }, JETZT) // 98 Tage spaeter + + const feed = await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`, { origin: null }) + assert.equal(feed.status, 200) + assert.equal(feed.headers.get('content-type'), 'text/calendar; charset=utf-8') + assert.match(feed.headers.get('cache-control'), /max-age=300/) + assert.equal(feed.headers.get('etag'), null) + const ics = feed.body.replace(/\r\n /g, '') + assert.match(ics, /^BEGIN:VCALENDAR\r\n/) + assert.match(ics, /METHOD:PUBLISH/) + assert.match(ics, /X-WR-CALNAME:Salon Müller – Anna/) + assert.equal((ics.match(/BEGIN:VEVENT/g) ?? []).length, 2) + assert.ok(ics.includes(`UID:${bestaetigt.id}@termine.webklar.com`)) + assert.ok(ics.includes('DTSTART:20261026T090000Z') && ics.includes('DTEND:20261026T094500Z')) + assert.ok(ics.includes('SUMMARY:Damenschnitt: Max Mustermann')) + assert.ok(ics.includes('SUMMARY:Fortbildung')) + assert.ok(!ics.includes('Zu spaet'), 'Sperre nach +90 Tagen') + assert.ok(!ics.includes(andere.id), 'Termin einer anderen Ressource') + assert.ok(!ics.includes('gehalten@example.com')) + const gehaltene = buchungen().find((b) => b.gastEmail === 'gehalten@example.com') + assert.equal(gehaltene.status, 'gehalten') + assert.ok(!ics.includes(gehaltene.$id), 'der gehaltene Termin steht nicht im Kalender') + assert.doesNotMatch(ics, /ATTENDEE/) + + const fremd = await api('GET', `/kalender/${SITE_KEY}/${r2.feedToken}.ics`, { origin: null }) + assert.ok(fremd.body.includes(andere.id) && !fremd.body.includes(bestaetigt.id), 'jede Ressource hat ihren eigenen Link') + + const falsch = await api('GET', `/kalender/${SITE_KEY}/${'A'.repeat(43)}.ics`) + assert.equal(falsch.status, 404) + assert.deepEqual(falsch.body, { fehler: { _: 'Nicht gefunden' } }) + for (const pfad of [`/kalender/${SITE_KEY}/${r1.feedToken.slice(1)}.ics`, `/kalender/${SITE_KEY}/${r1.feedToken}x.ics`, `/kalender/${SITE_KEY}/${r1.feedToken}`, `/kalender/${SITE_KEY}/.ics`, `/kalender/${SITE_KEY}/${r1.feedToken}.ICS`]) { + const res = await api('GET', pfad) + assert.equal(res.status, 404, pfad) + assert.deepEqual(res.body, falsch.body, pfad) + } + const unbekanntKey = await api('GET', `/kalender/zzzzzzzzzzzzzzzz/${r1.feedToken}.ics`) + assert.deepEqual([unbekanntKey.status, unbekanntKey.body], [404, falsch.body], 'kein Hinweis, ob der siteKey existiert') +}) + +test('Kalender-Abo: ein neu erzeugter Token macht den alten ungueltig; abgesagte und alte Termine fehlen', async () => { + const ctx = await speichere() + const r1 = ctx.einstellung.ressourcen[0] + const a = await gebucht({ zeit: '10:00', ressource: R1 }) + const b = await gebucht({ zeit: '11:00', ressource: R1, email: 'anna@example.com' }) + assert.equal((await tokenAnruf('absagen', { token: b.verwaltung })).status, 200) + // der Abo-Zeitraum beginnt 7 Tage vor dem Abruf: der Termin vom 26.10. (09:00-09:45 UTC) ist am 2.11. um 08:00 UTC noch drin + setzeUhrFuerTests(() => new Date('2026-11-02T08:00:00.000Z')) + const noch = await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`) + assert.equal(noch.status, 200) + assert.ok(noch.body.includes(a.id)) + assert.ok(!noch.body.includes(b.id), 'abgesagt') + setzeUhrFuerTests(() => new Date('2026-11-02T10:00:00.000Z')) + const zuAlt = await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`) + assert.equal(zuAlt.status, 200) + assert.ok(!zuAlt.body.includes(a.id), 'nach den 7 Tagen nicht mehr') + assert.equal((zuAlt.body.match(/BEGIN:VEVENT/g) ?? []).length, 0) + const neu = einstellung() + neu.ressourcen[0].feedToken = 'B'.repeat(43) + await speichere(neu) + assert.equal((await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`)).status, 404) + assert.equal((await api('GET', `/kalender/${SITE_KEY}/${'B'.repeat(43)}.ics`)).status, 200) +}) + +// ---------------------------------------------------------------- Fehler, Unbekanntes, Kundenansicht + +test('Appwrite nicht erreichbar: 503 { fehler } ohne Rohtext, bei GET, POST und Token-Aufrufen', async () => { + await speichere() + const RAW = 'Nachbau kaputt: geheimer Rohtext' + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: RAW }) + const config1 = await api('GET', `/${SITE_KEY}/config`) + assert.equal(config1.status, 503) + assert.deepEqual(config1.body, NICHT_ERREICHBAR) + assert.equal(config1.headers.get('cache-control'), 'no-store') + + resetKontextForTests() + aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: RAW }) + const post = await buchen() + assert.equal(post.status, 503) + assert.deepEqual(post.body, NICHT_ERREICHBAR) + assert.equal(buchungen().length, 0) + + aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: RAW }) + const token = await tokenAnruf('vorschau', { token: 'x'.repeat(43) }) + assert.equal(token.status, 503) + assert.deepEqual(token.body, NICHT_ERREICHBAR) + + aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: RAW }) + const tage = await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`) + assert.deepEqual([tage.status, tage.body], [503, NICHT_ERREICHBAR]) + assert.equal((await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`)).status, 200, 'der Fehler wurde nicht gemerkt') + for (const antwort of [config1, post, token, tage]) assert.ok(!JSON.stringify(antwort.body).includes('Nachbau')) + assert.ok(log.error.mock.calls.length >= 4, 'die Stoerungen stehen im Log') + assert.ok(log.error.mock.calls.every((c) => !JSON.stringify(c.arguments).includes('max@example.com')), 'keine Gastdaten im Log') +}) + +test('unbekannte Pfade und Methoden: 404 { fehler }, nie Express-Standardseiten', async () => { + await speichere() + for (const [methode, pfad] of [ + ['GET', '/'], ['GET', `/${SITE_KEY}`], ['GET', `/${SITE_KEY}/unbekannt`], ['POST', `/${SITE_KEY}/config`], ['GET', `/${SITE_KEY}/buchungen`], + ['POST', '/termin/unbekannt'], ['GET', '/termin/vorschau'], ['PUT', `/${SITE_KEY}/buchungen`], ['DELETE', `/${SITE_KEY}/widerruf`], + ]) { + const res = await api(methode, pfad) + assert.equal(res.status, 404, `${methode} ${pfad}`) + assert.deepEqual(res.body, { fehler: { _: 'Nicht gefunden' } }, `${methode} ${pfad}`) + } +}) + +test('Kundenansicht-Cookie (Mitarbeiter im Portal eines Kunden) stoert oeffentliche POSTs nicht', async () => { + await speichere() + const cookie = kundenansichtCookie() + const mit = await buchen({ kontakt_url: 'x' }, { cookie, headers: { 'x-kundenansicht': 'a'.repeat(32) } }) + assert.equal(mit.status, 201) + const ohneKennung = await buchen({ kontakt_url: 'x' }, { cookie }) + assert.equal(ohneKennung.status, 201, 'auch ohne Kennung der Ansicht (sonst 409)') + const fremdeKennung = await buchen({ kontakt_url: 'x' }, { cookie, headers: { 'x-kundenansicht': 'b'.repeat(32) } }) + assert.equal(fremdeKennung.status, 201) + const abgelaufen = await buchen({ kontakt_url: 'x' }, { cookie: kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 }) }) + assert.equal(abgelaufen.status, 201, 'eine abgelaufene Ansicht wird hier nicht beendet (kein 401)') + const token = await tokenAnruf('vorschau', { token: 'x'.repeat(43) }, { cookie }) + assert.equal(token.status, 404) + assert.equal(buchungen().length, 0) +}) + +test('Antworten tragen kein Set-Cookie und fuer Browser nosniff', async () => { + await speichere() + for (const res of [await api('GET', `/${SITE_KEY}/config`), await buchen({ kontakt_url: 'x' }), await tokenAnruf('vorschau', { token: 'x'.repeat(43) })]) { + assert.equal(res.headers.get('set-cookie'), null) + assert.equal(res.headers.get('x-content-type-options'), 'nosniff') + } +}) diff --git a/test/publicTermineRohling.test.js b/test/publicTermineRohling.test.js new file mode 100644 index 0000000..41697c5 --- /dev/null +++ b/test/publicTermineRohling.test.js @@ -0,0 +1,161 @@ +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'/) +}) diff --git a/test/termineLimits.test.js b/test/termineLimits.test.js index 069c498..0516da7 100644 --- a/test/termineLimits.test.js +++ b/test/termineLimits.test.js @@ -1,11 +1,25 @@ import { test, beforeEach } from 'node:test' import assert from 'node:assert/strict' -import { zaehle, zaehleAlle, darfG08, darfK21NichtZugeordnet, resetTermineLimitsForTests } from '../server/services/termine/limits.js' +import { + zaehle, + zaehleAlle, + darfG08, + darfK21NichtZugeordnet, + darfAnfrage, + darfAnfrageSite, + darfBuchungsversuch, + darfHaltung, + darfWiderrufsversuch, + darfG01, + ipSchluessel, + resetTermineLimitsForTests, +} from '../server/services/termine/limits.js' // Grenzen im Speicher: festes Fenster ab dem ersten Ereignis, alle-oder-keine bei mehreren Regeln, Obergrenze der Schluessel. const T0 = new Date('2026-10-05T08:00:00.000Z') const STUNDE = 60 * 60 * 1000 const TAG = 24 * STUNDE +const MINUTE = 60 * 1000 const plus = (ms) => new Date(T0.getTime() + ms) beforeEach(() => resetTermineLimitsForTests()) @@ -81,3 +95,75 @@ test('resetTermineLimitsForTests leert alles', () => { resetTermineLimitsForTests() assert.equal(zaehle('a', 1, TAG, T0), true) }) + +// ---------------------------------------------------------------- Aufgabe 16: IP, siteKey, G01 + +test('ipSchluessel: IPv4 bleibt, IPv4-in-IPv6 wird IPv4, IPv6 zaehlt je /64 (auch in anderer Schreibweise)', () => { + assert.equal(ipSchluessel('203.0.113.7'), '203.0.113.7') + assert.equal(ipSchluessel('::ffff:203.0.113.7'), '203.0.113.7') + assert.equal(ipSchluessel('::FFFF:203.0.113.7'), '203.0.113.7') + const a = ipSchluessel('2001:db8:1:2::1') + assert.equal(ipSchluessel('2001:0db8:0001:0002:ffff:ffff:ffff:ffff'), a, 'dieselbe /64, ausgeschrieben') + assert.equal(ipSchluessel('2001:DB8:1:2:abcd::9'), a, 'dieselbe /64, Grossbuchstaben und Kurzform') + assert.equal(ipSchluessel('2001:db8:1:2:0:0:0:0'), a) + assert.notEqual(ipSchluessel('2001:db8:1:3::1'), a, 'andere /64') + assert.notEqual(ipSchluessel('2001:db8:2:2::1'), a) + assert.equal(ipSchluessel('fe80::1%eth0'), ipSchluessel('fe80::2'), 'Zonenkennung faellt weg') + assert.equal(ipSchluessel('::1'), ipSchluessel('0:0:0:0:ffff:ffff:ffff:ffff'), 'Loopback ist die /64 ::') + assert.notEqual(ipSchluessel('203.0.113.7'), ipSchluessel('203.0.113.8')) + assert.notEqual(ipSchluessel('203.0.113.7'), a, 'IPv4 und IPv6 stossen nie zusammen') +}) + +test('ipSchluessel: fehlende oder unlesbare Adresse ergibt einen festen Ersatzschluessel (nie einen Fehler)', () => { + for (const wert of [undefined, null, '', ' ', 'kaputt', '1.2.3', '999.1.1.1', 42, {}]) { + assert.equal(ipSchluessel(wert), 'unbekannt', JSON.stringify(wert)) + } +}) + +test('darfAnfrage: 120 je IP und Minute (IPv6 je /64); darfAnfrageSite: 600 je siteKey und Minute', () => { + assert.equal(Array.from({ length: 125 }, () => darfAnfrage('203.0.113.7', T0)).filter(Boolean).length, 120) + assert.equal(darfAnfrage('203.0.113.8', T0), true, 'andere IP') + assert.equal(darfAnfrage('203.0.113.7', plus(MINUTE)), true, 'nach einer Minute wieder') + + resetTermineLimitsForTests() + const erlaubt = Array.from({ length: 125 }, (_, i) => darfAnfrage(`2001:db8:1:2::${i + 1}`, T0)) + assert.equal(erlaubt.filter(Boolean).length, 120, 'alle Adressen der /64 teilen sich 120') + assert.equal(darfAnfrage('2001:db8:1:3::1', T0), true) + + resetTermineLimitsForTests() + assert.equal(Array.from({ length: 605 }, () => darfAnfrageSite('abcdefghijklmnop', T0)).filter(Boolean).length, 600) + assert.equal(darfAnfrageSite('bcdefghijklmnopq', T0), true) + assert.equal(darfAnfrageSite('abcdefghijklmnop', plus(MINUTE)), true) +}) + +test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 60 je siteKey und Stunde', () => { + assert.equal(Array.from({ length: 12 }, () => darfBuchungsversuch('203.0.113.7', T0)).filter(Boolean).length, 10) + assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE - 1)), false) + assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE)), true) + assert.equal(darfBuchungsversuch('2001:db8::1', T0) && darfBuchungsversuch('2001:db8:0:0:1::1', T0), true, 'getrennt von der IPv4') + + resetTermineLimitsForTests() + for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9::${i + 1}`, T0), true) + for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9:1::${i + 1}`, T0), true) + assert.equal(darfBuchungsversuch('2001:db8:9:9:ffff::1', T0), false, 'elf Versuche aus einer /64') + + resetTermineLimitsForTests() + assert.equal(Array.from({ length: 65 }, () => darfHaltung('abcdefghijklmnop', T0)).filter(Boolean).length, 60) + assert.equal(darfHaltung('bcdefghijklmnopq', T0), true) + assert.equal(darfHaltung('abcdefghijklmnop', plus(STUNDE)), true) +}) + +test('darfWiderrufsversuch: 10 je IP und Stunde', () => { + assert.equal(Array.from({ length: 12 }, () => darfWiderrufsversuch('203.0.113.7', T0)).filter(Boolean).length, 10) + assert.equal(darfWiderrufsversuch('203.0.113.8', T0), true) + assert.equal(darfWiderrufsversuch('203.0.113.7', plus(STUNDE)), true) +}) + +test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren sich nicht', () => { + assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, true, true, false, false]) + assert.equal(darfG01('f2', 'max@example.com', T0), true, 'andere Funktion') + assert.equal(darfG01('f1', 'anna@example.com', T0), true, 'andere Adresse') + assert.equal(darfG01('f1', 'max@example.com', plus(TAG - 1)), false) + assert.equal(darfG01('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder') + assert.equal(darfG08('f1', 'max@example.com', T0), true, 'G08 zaehlt getrennt von G01') +}) diff --git a/test/termineSchalter.test.js b/test/termineSchalter.test.js index 21ffddd..25d5ca0 100644 --- a/test/termineSchalter.test.js +++ b/test/termineSchalter.test.js @@ -7,6 +7,7 @@ const TERMINE_ENV = [ 'TERMINE_ENABLED', 'TERMINE_PILOT_EMAILS', 'TERMINE_FORM_MIN_SECONDS', + 'TERMINE_MIN_ANTWORT_MS', 'APPWRITE_COLLECTION_TERMINE_CONFIG', 'APPWRITE_COLLECTION_TERMINE_BOOKINGS', 'APPWRITE_COLLECTION_TERMINE_CLAIMS', @@ -47,6 +48,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { zelleMin: 5, mailsProStunde: 200, formMinSekunden: 3, + minAntwortMs: 300, }) assert.equal(fresh.collections.termineConfig, 'termineConfig') assert.equal(fresh.collections.termineBookings, 'termineBookings') @@ -59,6 +61,7 @@ test('Konfiguration: Umgebungsvariablen werden gelesen und bereinigt', async () TERMINE_ENABLED: ' Pilot ', TERMINE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ', TERMINE_FORM_MIN_SECONDS: '0', + TERMINE_MIN_ANTWORT_MS: '0', APPWRITE_COLLECTION_TERMINE_CONFIG: 'tConfig', APPWRITE_COLLECTION_TERMINE_BOOKINGS: 'tBookings', APPWRITE_COLLECTION_TERMINE_CLAIMS: 'tClaims', @@ -67,6 +70,7 @@ test('Konfiguration: Umgebungsvariablen werden gelesen und bereinigt', async () assert.equal(fresh.termine.mode, 'pilot') assert.deepEqual(fresh.termine.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com']) assert.equal(fresh.termine.formMinSekunden, 0) + assert.equal(fresh.termine.minAntwortMs, 0) assert.deepEqual( [fresh.collections.termineConfig, fresh.collections.termineBookings, fresh.collections.termineClaims, fresh.collections.termineOutbox], ['tConfig', 'tBookings', 'tClaims', 'tOutbox'], @@ -83,6 +87,11 @@ test('Konfiguration: leerer oder unbekannter Schalter ist off, ungueltige Sekund assert.equal(fresh.termine.formMinSekunden, 3, `TERMINE_FORM_MIN_SECONDS=${JSON.stringify(value)}`) } assert.equal((await freshConfig({ TERMINE_FORM_MIN_SECONDS: '7' })).termine.formMinSekunden, 7) + for (const value of ['', 'abc', '-1', 'NaN']) { + const fresh = await freshConfig({ TERMINE_MIN_ANTWORT_MS: value }) + assert.equal(fresh.termine.minAntwortMs, 300, `TERMINE_MIN_ANTWORT_MS=${JSON.stringify(value)}`) + } + assert.equal((await freshConfig({ TERMINE_MIN_ANTWORT_MS: '750' })).termine.minAntwortMs, 750) }) test('termineModus: leer und unbekannt sind off, pilot und on bleiben', () => {