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') } })