- R55: Widerrufsbelehrung, Anschrift-Pflicht und Knopf "Vertrag widerrufen" gelten für jede Verbraucher-Buchung mit sichtbarer Leistung, auch Anfragen (zeigtWiderruf); der Nachweis hält die Belehrung auch bei Anfragen fest. Damit bleibt die Annahme einer Anfrage nie mehr still ohne G02. - "Gastdaten löschen" leert auch absageGrund und löscht noch nicht versandte Mails der Buchung (neu: verwerfeWartende in der Warteschlange). - R53: GET /:siteKey/config nennt formMinSekunden. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1091 lines
59 KiB
JavaScript
1091 lines
59 KiB
JavaScript
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
|
||
|
||
// Nichts Internes in irgendeiner Antwort: weder Felder noch Werte mit IDs des Portals oder Appwrite-Metadaten.
|
||
// Erlaubt sind die Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen); Buchungs-IDs sind UUIDs.
|
||
const VERBOTENE_FELDER = new Set([
|
||
'customerId', 'projectId', 'featureId', 'bookingId', '$id', '$collectionId', '$databaseId', '$createdAt', '$updatedAt', '$permissions',
|
||
'verifyHash', 'manageHash', 'feedToken',
|
||
])
|
||
const INTERNE_IDS = [KUNDE, PROJEKT, FEATURE]
|
||
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i
|
||
|
||
/** Wirft, wenn irgendwo im (JSON-)Wert ein verbotenes Feld, eine interne ID oder eine Buchungs-UUID steht. */
|
||
function pruefeOhneInternes(wert, pfad = '$', { uuid = true } = {}) {
|
||
if (typeof wert === 'string') {
|
||
for (const id of INTERNE_IDS) assert.ok(!wert.includes(id), `${pfad}: interne ID ${id} in der Antwort`)
|
||
if (uuid) assert.ok(!UUID_RE.test(wert), `${pfad}: UUID (Buchungs-ID?) in der Antwort`)
|
||
} else if (Array.isArray(wert)) {
|
||
wert.forEach((w, i) => pruefeOhneInternes(w, `${pfad}[${i}]`, { uuid }))
|
||
} else if (wert !== null && typeof wert === 'object') {
|
||
for (const [feld, w] of Object.entries(wert)) {
|
||
assert.ok(!VERBOTENE_FELDER.has(feld), `${pfad}.${feld}: internes Feld in der Antwort`)
|
||
pruefeOhneInternes(w, `${pfad}.${feld}`, { uuid })
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Anfrage an die oeffentliche Schnittstelle: Pfad ab /v1, Origin wie im Browser (Vorgabe: Seite des Betriebs), Client-Adresse je Test.
|
||
* JEDE Antwort (Erfolg wie Fehler) wird auf interne IDs geprueft; der Kalender-Text (UID = Buchungs-ID ist dort Pflicht) nur auf Portal-IDs.
|
||
*/
|
||
async function api(method, pfad, { body, origin = O_LIVE, ip: adresse = ip, headers = {}, cookie } = {}) {
|
||
const res = await send(method, BASIS + pfad, { body, cookie, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
|
||
pruefeOhneInternes(res.body, `${method} ${pfad}`, { uuid: typeof res.body !== 'string' })
|
||
return res
|
||
}
|
||
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', 'formMinSekunden', 'formToken', 'gastFelder', 'heute', 'hinweis', 'leistungen',
|
||
'personWaehlbar', 'preisHinweis', 'regeln', 'ressourcen', 'storno', 'texte', 'widerruf', 'zahlung',
|
||
])
|
||
assert.equal(c.aktiv, true)
|
||
assert.equal(c.formMinSekunden, config.termine.formMinSekunden, 'R53: das Widget wartet, bis das Formular-Token so alt ist')
|
||
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',
|
||
agbUrl: null,
|
||
})
|
||
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: formMinSekunden folgt der Einstellung zur Laufzeit (R53) und ist Zahl, auch 0', async () => {
|
||
await speichere()
|
||
for (const wert of [3, 0, 7.5]) {
|
||
config.termine.formMinSekunden = wert
|
||
const { body } = await api('GET', `/${SITE_KEY}/config`)
|
||
assert.equal(body.formMinSekunden, wert)
|
||
}
|
||
})
|
||
|
||
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: ohne Anschrift (Belehrung nicht zu erzeugen) oder Telefon ist die Funktion fuer Gaeste nicht aktiv (R41), nie eine Buchung ohne Belehrung', async () => {
|
||
for (const felder of [{ adresse: '' }, { telefon: '' }]) {
|
||
const e = einstellung()
|
||
Object.assign(e.betrieb, felder) // wie ein Entwurf aus einer Vorlage, gespeichert ohne strenge Pruefung
|
||
await speichere(e)
|
||
for (const [methode, pfad, body] of [
|
||
['GET', 'config'],
|
||
['GET', `tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`],
|
||
['GET', `zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`],
|
||
['POST', 'buchungen', buchungBody()],
|
||
]) {
|
||
const res = await api(methode, `/${SITE_KEY}/${pfad}`, body ? { body } : {})
|
||
assert.equal(res.status, 200, `${JSON.stringify(felder)} ${pfad}`)
|
||
assert.deepEqual(res.body, AKTIV_FALSE, `${JSON.stringify(felder)} ${pfad}`)
|
||
}
|
||
assert.equal(aw.docs('termineBookings').size, 0, 'nichts gebucht')
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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')
|
||
}
|
||
})
|
||
|
||
test('der Pruefer fuer interne IDs hat Zaehne: Felder, Werte, verschachtelt, UUIDs - die Buchungsnummer ist erlaubt', () => {
|
||
assert.doesNotThrow(() => pruefeOhneInternes({ ok: true, termin: { nummer: 'AB12CD34', leistung: 'Damenschnitt' }, ressourcen: [{ id: R1 }] }))
|
||
for (const schlecht of [
|
||
{ featureId: 'x' }, { a: { b: [{ customerId: 'x' }] } }, { $id: 'x' }, { $collectionId: 'termineBookings' }, { text: `Funktion ${FEATURE}` },
|
||
{ liste: [KUNDE] }, { id: '0b2a3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d' }, { projectId: PROJEKT },
|
||
]) {
|
||
assert.throws(() => pruefeOhneInternes(schlecht), assert.AssertionError, JSON.stringify(schlecht))
|
||
}
|
||
})
|
||
|
||
test('schreibt die Antwort selbst nicht (Serialisierung wirft): 503 { fehler } statt haengender Anfrage und unbehandelter Ablehnung', async () => {
|
||
await speichere()
|
||
const unbehandelt = []
|
||
const merke = (grund) => unbehandelt.push(grund)
|
||
process.on('unhandledRejection', merke)
|
||
const original = Buffer.byteLength
|
||
const ersatz = mock.method(Buffer, 'byteLength', function byteLength(text, ...rest) {
|
||
if (typeof text === 'string' && text.includes('"formToken"')) throw new Error('Nachbau kaputt: geheimer Rohtext')
|
||
return original.call(this, text, ...rest)
|
||
})
|
||
let res
|
||
try {
|
||
res = await Promise.race([
|
||
api('GET', `/${SITE_KEY}/config`),
|
||
new Promise((_, ab) => setTimeout(() => ab(new Error('Anfrage haengt: keine Antwort')), 3000).unref()),
|
||
])
|
||
await new Promise((resolve) => setImmediate(resolve)) // eine unbehandelte Ablehnung meldet sich erst danach
|
||
} finally {
|
||
ersatz.mock.restore()
|
||
process.off('unhandledRejection', merke)
|
||
}
|
||
assert.deepEqual([res.status, res.body], [503, NICHT_ERREICHBAR])
|
||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||
assert.deepEqual(unbehandelt, [])
|
||
assert.ok(!JSON.stringify(res.body).includes('geheimer'))
|
||
assert.ok(log.error.mock.calls.some((c) => String(c.arguments[0]).includes('Antwort nicht geschrieben')), 'steht im Log')
|
||
assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, 'danach geht es normal weiter')
|
||
})
|