Terminplaner: Kunden-Schnittstelle – Nachbesserungen

- GET /zeiten?ohne=<Buchung>: Leistung, Dauer und Person der Buchung wie beim
  Verschieben (verschiebeZeiten exportiert), unbekannte/fremde/Sperre 404 (R42)
- Schreib-Begrenzung als gemeinsamer Baustein (writeLimiter.js) fuer News und
  Terminplaner statt kopierter Funktion (R43)
- Unlesbares Einstellungs-JSON: Kontext traegt die echte Version, Speichern
  ueberschreibt den Schaden statt dauerhaft 409
- U+FEFF als Escape statt als rohes Zeichen
- Tests: Mandantentrennung mit echtem zweitem Kunden, R42, Limiter, kaputtes JSON

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent e3e198fb1a
commit bb45d35ec7
8 files changed
+319 -65

No files matched your search

+5 -22
View File
@@ -7,11 +7,10 @@ import { uploadSlot } from './chat.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { logIfKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
const router = Router()
const HOUR_MS = 60 * 60 * 1000
const MAX_WRITES_PER_HOUR = 30
const IMAGE_FILE_RE = /^([a-z0-9-]+)-(l|s)\.webp$/
function fail(res, err) {
@@ -23,26 +22,10 @@ function fail(res, err) {
}
// Schreib-Limit je Kunde und Stunde (im Speicher, wie beim Chat); zaehlt POST/PATCH/DELETE, nicht die Foto-Uploads
const recentWrites = new Map()
function writeRateLimited(customerId) {
const now = Date.now()
for (const [key, times] of recentWrites) {
const live = times.filter((t) => now - t < HOUR_MS)
if (live.length === 0) recentWrites.delete(key)
else if (live.length !== times.length) recentWrites.set(key, live)
}
const list = recentWrites.get(customerId) || []
list.push(now)
recentWrites.set(customerId, list)
return list.length > MAX_WRITES_PER_HOUR
}
function limitWrites(req, res, next) {
if (writeRateLimited(getSessionCustomerId(req))) {
return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' })
}
return next()
}
const limitWrites = createWriteLimiter({ max: 30 }).middleware(
(req) => getSessionCustomerId(req),
'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'
)
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
+35 -29
View File
@@ -5,8 +5,10 @@ import { getDocument } from '../services/appwriteAdmin.js'
import { analysePrivacyData } from '../services/analysePrivacy.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { verschiebeZeiten } from '../services/termine/ablauf.js'
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
import { imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
import { zustellFehler } from '../services/termine/outbox.js'
@@ -33,7 +35,6 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine
const router = Router()
const HOUR_MS = 60 * 60 * 1000
const MAX_WRITES_PER_HOUR = 120
const MAX_TAGE = 62
const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']
@@ -70,6 +71,7 @@ const MELDUNG = {
leistung: 'Bitte eine Leistung auswählen.',
ressource: 'Unbekannte Person oder unbekannter Platz.',
keineRessource: 'Diese Person oder diesen Platz gibt es nicht.',
keineBuchung: 'Diesen Termin gibt es nicht.',
ungueltig: 'Ungültige Eingabe.',
}
@@ -112,28 +114,12 @@ const route = (fn) => async (req, res, next) => {
// ---------------------------------------------------------------- Schreib-Limit
// Schreib-Limit je Kunde und Stunde (im Speicher, wie bei den News); zählt POST/PATCH/DELETE
const recentWrites = new Map()
function writeRateLimited(customerId) {
const now = uhr().getTime()
for (const [key, times] of recentWrites) {
const live = times.filter((t) => now - t < HOUR_MS)
if (live.length === 0) recentWrites.delete(key)
else if (live.length !== times.length) recentWrites.set(key, live)
}
const list = recentWrites.get(customerId) || []
list.push(now)
recentWrites.set(customerId, list)
return list.length > MAX_WRITES_PER_HOUR
}
const limitWrites = route((req, res, next) => {
if (writeRateLimited(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuViele })
return next()
})
// 120 Aenderungen je Kunde und Stunde (im Speicher); zaehlt POST/PATCH/DELETE; die Uhr folgt setzeUhrFuerTests
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR, now: () => uhr().getTime() })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetTermineRoutesForTests() {
recentWrites.clear()
limiter.reset()
uhr = () => new Date()
}
@@ -345,6 +331,12 @@ function buchungJson(b, zustellFehlerIds) {
const tagGueltig = (tag) => typeof tag === 'string' && zuUtc(tag, '12:00') !== null
/**
* Buchungen im Zeitraum `von` bis `bis` (Berliner Tage 'JJJJ-MM-TT', beide Tage eingeschlossen, beide Pflicht), nach Beginn
* sortiert, mit Sperren. Zeitraum: `bis` darf höchstens 62 Tage nach `von` liegen (bis ≤ von + 62 Tage, also bis zu 63
* Kalendertage) und nicht davor. `status` (optional) ist eine Liste bekannter Status, durch Komma getrennt; ohne Angabe
* kommen alle Status.
*/
router.get('/buchungen', requireSession, withTermine(), route(async (req, res) => {
const ctx = req.ctx
const { von: vonTag, bis: bisTag, status } = req.query
@@ -370,9 +362,27 @@ router.get('/buchungen', requireSession, withTermine(), route(async (req, res) =
res.json({ buchungen: liste.map((b) => buchungJson(b, mitMail)) })
}))
/**
* Freie Startzeiten eines Tages für den Betrieb (Termin eintragen, Verschieben): ohne Vorlauf und Horizont, nie in der
* Vergangenheit. `leistung` und `ressource` (Vorgabe 'egal') wählen, was gesucht wird.
* Mit `ohne=<Buchungs-ID>` (Verschieben) gelten Leistung, Dauer und Person der Buchung, genau wie beim Verschieben selbst
* (ablauf.js verschiebeZeiten, Ruling R42); `leistung` und `ressource` aus der Abfrage werden dann ignoriert. Unbekannte
* Buchung, Buchung einer anderen Funktion oder Sperre => 404.
*/
router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
const ctx = mitEinstellung(req)
const { leistung, ressource = 'egal', tag, ohne } = req.query
const now = uhr()
if (ohne !== undefined) {
if (typeof ohne !== 'string' || !ID_RE.test(ohne)) throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
const buchung = await hole(ohne)
if (!buchung || buchung.featureId !== ctx.feature.$id || buchung.quelle === 'sperre') throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
const { zeiten } = await verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln: true })
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
}
if (typeof leistung !== 'string' || !(ctx.einstellung.leistungen || []).some((l) => l.id === leistung && l.aktiv)) {
throw stop(400, MELDUNG.leistung, undefined, 'leistung')
}
@@ -380,22 +390,18 @@ router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
throw stop(400, MELDUNG.ressource, undefined, 'ressource')
}
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
if (ohne !== undefined && (typeof ohne !== 'string' || !ID_RE.test(ohne))) throw stop(400, MELDUNG.ungueltig, undefined, 'ohne')
const now = uhr()
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
// ohneRegeln: der Betrieb trägt auch kurzfristig und jenseits des Buchungszeitraums ein (nie in die Vergangenheit);
// ohne = die Buchung, die verschoben wird, belegt ihre neue Zeit nicht selbst
const zeiten = freieZeiten({
einstellung: ctx.einstellung,
leistungId: leistung,
ressourceId: ressource,
tag,
belegung: belegungAus(amTag, now, ohne ? { ohneBuchung: ohne } : {}),
belegung: belegungAus(amTag, now),
now,
ohneRegeln: true,
})
res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
}))
// ---------------------------------------------------------------- Buchungen schreiben
@@ -462,7 +468,7 @@ router.get('/export.csv', requireSession, withTermine(), route(async (req, res)
b.gastNachricht,
]
})]
const csv = `${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
const csv = `\uFEFF${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
res.set({
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="termine.csv"',
+3 -2
View File
@@ -713,9 +713,10 @@ const verschiebeRessource = (einstellung, buchung) => (einstellung.regeln?.perso
/**
* Freie Startzeiten eines Tages fuer das Verschieben einer Buchung (ohne die Buchung selbst); => { zeiten, amTag }.
* ohneRegeln: Verschieben durch den Betrieb (kein Vorlauf, kein Horizont).
* Leistung, Dauer und Person kommen aus der Buchung (mitGebuchterDauer, verschiebeRessource), genau wie in verschiebeKern.
* ohneRegeln: Verschieben durch den Betrieb (kein Vorlauf, kein Horizont). Auch die Zeitenabfrage des Portals nutzt sie.
*/
async function verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln = false } = {}) {
export async function verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln = false } = {}) {
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
const zeiten = freieZeiten({
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
+14 -9
View File
@@ -15,7 +15,7 @@ import { pruefeNow, pruefeText } from './util.js'
* feature portalFeatures-Dokument (featureKey 'termine')
* project websiteProjects-Dokument oder null (Projekt fehlt)
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert)
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert; bei unlesbarem JSON die Version des Dokuments)
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
*
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
@@ -51,8 +51,11 @@ async function holeOderNull(collectionId, id) {
}
}
/** { einstellung, version } aus einem termineConfig-Dokument; unlesbares JSON gilt als nicht gespeichert (mit Log). */
function liesEinstellung(doc) {
/**
* { einstellung, version } aus einem termineConfig-Dokument. Unlesbares JSON (mit Log) ergibt einstellung null, die Version
* des Dokuments bleibt: wer die kaputte Einstellung ueberschreiben will, speichert mit genau dieser Version.
*/
function liesDokument(doc) {
const version = Number.isInteger(doc.version) ? doc.version : 0
try {
const einstellung = JSON.parse(doc.einstellung)
@@ -60,7 +63,7 @@ function liesEinstellung(doc) {
return { einstellung, version }
} catch (err) {
console.error(`[termine-kontext] Einstellung ${doc.$id} ist unlesbar: ${err.message}`)
return null
return { einstellung: null, version }
}
}
@@ -72,15 +75,17 @@ function liesEinstellung(doc) {
export async function ladeEinstellung(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.termineConfig, featureId)
return doc ? liesEinstellung(doc) : null
const gelesen = doc ? liesDokument(doc) : null
return gelesen?.einstellung ? gelesen : null
}
async function baueKontext(feature) {
const [project, gespeichert] = await Promise.all([
const [project, doc] = await Promise.all([
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
ladeEinstellung(feature.$id),
holeOderNull(config.collections.termineConfig, feature.$id),
])
return { feature, project, einstellung: gespeichert?.einstellung ?? null, version: gespeichert?.version ?? 0 }
const gelesen = doc ? liesDokument(doc) : null
return { feature, project, einstellung: gelesen?.einstellung ?? null, version: gelesen?.version ?? 0 }
}
/**
@@ -160,7 +165,7 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
if (version !== null && version !== aktuell) throw veraltet()
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? (liesEinstellung(vorhanden)?.einstellung ?? null) : null) : einstellungOderFn
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? liesDokument(vorhanden).einstellung : null) : einstellungOderFn
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
const json = JSON.stringify(einstellung)
+41
View File
@@ -0,0 +1,41 @@
/**
* Schreib-Begrenzung je Schluessel (meist Kunde) im Speicher: hoechstens `max` Aufrufe je Fenster (Standard eine Stunde),
* gezaehlt ab dem Aufruf. Jeder Aufruf zaehlt, auch ein abgelehnter. Wie beim Chat und bei den News nur im Prozess, ein
* Neustart setzt die Zaehler zurueck; Schluessel ohne Treffer im Fenster werden bei der naechsten Abfrage vergessen.
* Genutzt von routes/news.js (30 je Stunde) und routes/termine.js (120 je Stunde).
*
* @param {{ max: number, fensterMs?: number, now?: () => number }} optionen now liefert Millisekunden (Tests: feste Uhr)
* @returns {{
* istUeberschritten: (key: string) => boolean,
* middleware: (keyOf: (req: object) => string, message: string) => (req: object, res: object, next: () => void) => void,
* reset: () => void,
* }} istUeberschritten zaehlt diesen Aufruf mit und sagt, ob damit mehr als `max` im Fenster liegen;
* middleware antwortet dann 429 { error: message }, sonst next(); reset leert alles (Tests)
*/
export function createWriteLimiter({ max, fensterMs = 60 * 60 * 1000, now = () => Date.now() } = {}) {
if (!Number.isInteger(max) || max < 1) throw new TypeError('createWriteLimiter: max muss eine ganze Zahl ab 1 sein')
if (!Number.isFinite(fensterMs) || fensterMs <= 0) throw new TypeError('createWriteLimiter: fensterMs muss groesser als 0 sein')
if (typeof now !== 'function') throw new TypeError('createWriteLimiter: now muss eine Funktion sein')
const treffer = new Map() // Schluessel -> Zeitpunkte (ms) im Fenster
function istUeberschritten(key) {
const jetzt = now()
for (const [schluessel, zeiten] of treffer) {
const frisch = zeiten.filter((t) => jetzt - t < fensterMs)
if (frisch.length === 0) treffer.delete(schluessel)
else if (frisch.length !== zeiten.length) treffer.set(schluessel, frisch)
}
const liste = treffer.get(key) || []
liste.push(jetzt)
treffer.set(key, liste)
return liste.length > max
}
const middleware = (keyOf, message) => (req, res, next) => {
if (istUeberschritten(keyOf(req))) return res.status(429).json({ error: message })
return next()
}
return { istUeberschritten, middleware, reset: () => treffer.clear() }
}
+21
View File
@@ -502,6 +502,27 @@ test('kontextPerFeature: active und disabled liefern ctx, auch ohne Einstellung
assert.equal(await kontextPerFeature(undefined), null)
})
test('unlesbares Einstellungs-JSON: der Kontext traegt die echte Version (einstellung null), Speichern mit ihr ueberschreibt den Schaden', async () => {
seedFeature()
seedProjekt()
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3 })
const ctx = await kontextPerFeature(FEATURE)
assert.equal(ctx.einstellung, null, 'nichts Lesbares')
assert.equal(ctx.version, 3, 'aber die gespeicherte Version, nicht 0')
assert.equal(await ladeEinstellung(FEATURE), null, 'ladeEinstellung bleibt: unlesbar = nicht gespeichert')
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'oeffentlich nicht buchbar')
// mit Version 0 (dem alten Wert des Kontexts) ging es nie; mit der echten gelingt es und die Funktion bekommt bisher = null
await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 0 }), (err) => err.status === 409)
let gesehen
const erg = await speichereEinstellung(feature(), async (bisher) => { gesehen = bisher; return einstellung() }, { version: ctx.version })
assert.equal(gesehen, null)
assert.equal(erg.version, 4)
const danach = await kontextPerFeature(FEATURE)
assert.equal(danach.version, 4)
assert.equal(danach.einstellung.betrieb.name, 'Salon Müller')
})
test('kontextPerFeature: Modus off => null ohne Anfrage; fehlendes Projekt => project null', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung(), { version: 0 })
+117 -3
View File
@@ -414,6 +414,26 @@ test('PATCH einstellungen: alte Version => 409 EINSTELLUNG_VERALTET, Stand des E
assert.equal((await aendere('Zukunft', 9)).status, 409)
})
test('unlesbares Einstellungs-JSON: GET zeigt keine Einstellung, aber die echte Version; Vorlage bleibt 409, PATCH mit dieser Version ueberschreibt', async () => {
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: P, einstellung: '{kaputt', version: 3 })
const stand = await lade()
assert.equal(stand.einstellung, null)
assert.equal(stand.version, 3)
assert.deepEqual(stand.feeds, {})
assert.deepEqual(stand.fehlt, [])
const vorlage = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'salon' } })
assert.equal(vorlage.status, 409)
assert.equal(aw.docs('termineConfig').get(FEATURE).einstellung, '{kaputt', 'nichts ueberschrieben')
const patch = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: stand.version, einstellung: eingabeEinstellung() } })
assert.equal(patch.status, 200, JSON.stringify(patch.body))
assert.equal(patch.body.version, 4)
const danach = await lade()
assert.equal(danach.einstellung.betrieb.name, 'Salon Müller')
assert.equal(danach.version, 4)
})
test('PATCH einstellungen: Version fehlt oder ist keine Ganzzahl => 400; Eingabe unbrauchbar => 400 mit Feldfehlern', async () => {
await speichere()
const stand = await lade()
@@ -617,7 +637,6 @@ test('GET zeiten: freie Zeiten ohne Vorlauf und Horizont (Betrieb), mit ohne=Buc
[`leistung=${S_SOFORT}&ressource=${R1}&ressource=${R2}&tag=${TAG}`, 'ressource'],
[`leistung=${S_SOFORT}`, 'tag'],
[`leistung=${S_SOFORT}&tag=2026-02-30`, 'tag'],
[`leistung=${S_SOFORT}&tag=${TAG}&ohne=a%20b`, 'ohne'],
]) {
const res = await zeiten(query)
assert.equal(res.status, 400, query)
@@ -625,6 +644,52 @@ test('GET zeiten: freie Zeiten ohne Vorlauf und Horizont (Betrieb), mit ohne=Buc
}
})
test('GET zeiten mit ohne (R42): rechnet mit der gebuchten Dauer und der Person der Buchung, Anfrage-Werte werden ignoriert', async () => {
const zeiten = async (query) => (await send('GET', `/api/termine/zeiten?projectId=p1&${query}`, { cookie })).body.zeiten
const mitRegeln = (leistungen, regeln = {}) => einstellung({ leistungen, regeln: { ...eingabeEinstellung().regeln, ...regeln } })
const leistung = (dauer, pufferNach) => [{ ...eingabeEinstellung().leistungen[0], dauer, pufferNach }]
// (a) Leistung nach der Buchung verlaengert (4 Stunden): die Buchung hat 45 + 5 Minuten und passt noch um 17:00
await speichere(mitRegeln(leistung(240, 5)))
seedBuchung('b_gebucht') // Montag, 10:00 bis 10:50 (R1)
const verlaengert = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}`)
assert.ok(!verlaengert.includes('17:00'), 'ohne ohne: die neue Dauer gilt')
const gebucht = await zeiten(`ohne=b_gebucht&tag=${TAG}&leistung=${S_ANFRAGE}&ressource=${R2}`)
assert.ok(gebucht.includes('17:00'), 'mit ohne: 17:00 + 50 Minuten endet vor 18:00')
assert.ok(gebucht.includes('10:00'), 'die eigene Zeit ist frei')
assert.equal(gebucht.at(-1), '17:00')
assert.ok(!gebucht.includes('17:15'), 'die gebuchte Dauer endet 18:05')
// (b) Leistung nach der Buchung verkuerzt (15 Minuten): ein Termin um 11:00 steht im Weg, 10:15 + 50 Minuten reicht hinein
await speichere(mitRegeln(leistung(15, 0)))
seedBuchung('b_dahinter', { ressourceId: R1, startsAt: `${TAG}T09:00:00.000+00:00`, endsAt: `${TAG}T09:45:00.000+00:00`, blockEndsAt: `${TAG}T09:50:00.000+00:00` })
const verkuerzt = await zeiten(`ohne=b_gebucht&tag=${TAG}&leistung=${S_SOFORT}&ressource=${R1}`)
assert.ok(verkuerzt.includes('10:00'), '10:00 bis 10:50 passt vor 11:00')
assert.ok(!verkuerzt.includes('10:15'), 'die gebuchte Dauer (50 Minuten) liefe in den Termin um 11:00')
// Person: waehlbar => die der Buchung, sonst jede freie ('egal'); die Angabe aus der Abfrage zaehlt nie
assert.ok(!(await zeiten(`ohne=b_gebucht&tag=${TAG}&ressource=${R2}`)).includes('10:15'), 'personWaehlbar: Annas Buchung bleibt bei Anna, Bens freie Zeit zaehlt nicht')
await speichere(mitRegeln(leistung(15, 0), { personWaehlbar: false }))
assert.ok((await zeiten(`ohne=b_gebucht&tag=${TAG}&ressource=${R1}`)).includes('10:15'), 'ohne Personenwahl genuegt eine freie Person: Ben um 10:15')
})
test('GET zeiten mit ohne (R42): unbekannte Buchung, andere Funktion, Sperre und unbrauchbare ID => 404', async () => {
await speichere()
seedBuchung('b_sperre', { quelle: 'sperre', gastName: '', gastEmail: '' })
seedBuchung('b_fremd', { featureId: 'f_anderer_termine' })
for (const ohne of ['b_gibtsnicht', 'b_sperre', 'b_fremd', 'a%20b', '..%2Fx', 'x'.repeat(200)]) {
const res = await send('GET', `/api/termine/zeiten?projectId=p1&tag=${TAG}&ohne=${ohne}`, { cookie })
assert.equal(res.status, 404, ohne)
assert.ok(res.body.error, ohne)
}
// mit ohne braucht es weder Leistung noch Person; der Tag bleibt Pflicht
seedBuchung('b_ok')
assert.equal((await send('GET', `/api/termine/zeiten?projectId=p1&tag=${TAG}&ohne=b_ok`, { cookie })).status, 200)
const ohneTag = await send('GET', '/api/termine/zeiten?projectId=p1&ohne=b_ok', { cookie })
assert.equal(ohneTag.status, 400)
assert.ok(ohneTag.body.fehler.tag)
})
// ---------------------------------------------------------------- Buchungen schreiben
const manuellBody = (over = {}) => ({ projectId: P, art: 'manuell', leistung: S_SOFORT, ressource: R1, tag: TAG, zeit: '11:00', name: 'Paula Telefon', telefon: '06301 555', ...over })
@@ -754,6 +819,55 @@ test('POST aktion: absagen braucht einen Grund (400), Betrieb sagt ab (abgesagt
assert.equal(falsch.body.code, 'nicht_moeglich')
})
// ---------------------------------------------------------------- Mandantentrennung
test('Mandantentrennung: Kunde 1 erreicht mit seiner projectId keine Buchung, Sperre oder Liste von Kunde 2 (echter zweiter Kunde mit aktiver Funktion)', async () => {
const feature2 = featureDoc({ $id: 'f_p3_termine', customerId: 'c2', projectId: 'p3', siteKey: 'fghijklmnopqrstu' })
aw.seed('portalFeatures', feature2)
await speichere(einstellung(), feature2)
const fremd = { featureId: 'f_p3_termine', customerId: 'c2', projectId: 'p3' }
seedBuchung('b_c2', { ...fremd, status: 'angefragt', gastName: 'Fremder Gast', gastEmail: 'fremd@example.com', gastNachricht: 'Streng geheim' })
seedBuchung('s_c2', { ...fremd, quelle: 'sperre', leistungId: '', leistungName: 'Fremde Sperre', gastName: '', gastEmail: '', startsAt: `${TAG}T12:00:00.000+00:00`, endsAt: `${TAG}T13:00:00.000+00:00`, blockEndsAt: `${TAG}T13:00:00.000+00:00` })
await speichere()
seedBuchung('b_eigen', { gastName: 'Eigener Gast', gastEmail: 'eigen@example.com' })
const vorher = JSON.stringify([...aw.docs('termineBookings').entries()])
// sanity: Kunde 2 sieht seine Daten
const c2 = customerCookie('c2')
const seine = await send('GET', `/api/termine/buchungen?projectId=p3&von=${TAG}&bis=${TAG}`, { cookie: c2 })
assert.deepEqual(seine.body.buchungen.map((b) => b.id), ['b_c2', 's_c2'])
const aktion = (id, body) => send('POST', `/api/termine/buchungen/${id}/aktion`, { cookie, body: { projectId: P, ...body } })
for (const body of [{ aktion: 'annehmen' }, { aktion: 'ablehnen' }, { aktion: 'absagen', grund: 'x' }, { aktion: 'verschieben', tag: '2026-10-19', zeit: '10:00' }, { aktion: 'anonymisieren' }, { aktion: 'nichterschienen' }]) {
const res = await aktion('b_c2', body)
assert.equal(res.status, 404, body.aktion)
assert.equal(res.body.code, 'unbekannt')
}
assert.equal((await aktion('s_c2', { aktion: 'absagen', grund: 'x' })).status, 404)
const weg = await send('DELETE', `/api/termine/buchungen/s_c2?projectId=${P}`, { cookie })
assert.equal(weg.status, 404)
assert.ok(aw.docs('termineBookings').has('s_c2'), 'die fremde Sperre steht noch')
assert.equal((await send('DELETE', `/api/termine/buchungen/b_c2?projectId=${P}`, { cookie })).status, 404)
for (const id of ['b_c2', 's_c2']) {
const zeiten = await send('GET', `/api/termine/zeiten?projectId=${P}&tag=${TAG}&ohne=${id}`, { cookie })
assert.equal(zeiten.status, 404, id)
}
const liste = await send('GET', `/api/termine/buchungen?projectId=${P}&von=${TAG}&bis=${TAG}`, { cookie })
assert.deepEqual(liste.body.buchungen.map((b) => b.id), ['b_eigen'])
const csv = (await rohExport()).text
assert.ok(csv.includes('Eigener Gast'))
for (const geheim of ['Fremder Gast', 'fremd@example.com', 'Streng geheim', buchungsNummer('b_c2'), 'Fremde Sperre']) assert.ok(!csv.includes(geheim), geheim)
assert.ok(!JSON.stringify(liste.body).includes('Fremder Gast'))
await warteAufVersand()
assert.equal(sink.sent.length, 0, 'keine Mail an den fremden Gast')
assert.equal(JSON.stringify([...aw.docs('termineBookings').entries()]), vorher, 'nichts veraendert')
assert.equal(logEintraege().length, 0)
})
// ---------------------------------------------------------------- Export
async function rohExport(projectId = P, c = cookie) {
@@ -776,7 +890,7 @@ test('GET export.csv: UTF-8 mit BOM, Strichpunkt, Kopfzeile, nach Beginn sortier
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
const zeilen = text.replace(/^/, '').split('\r\n')
const zeilen = text.replace(/^\uFEFF/, '').split('\r\n')
assert.equal(zeilen[0], 'Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht')
assert.equal(zeilen[1], `${buchungsNummer('b_eins')};Bestätigt;12.10.2026;10:00;Damenschnitt;Anna;"Max ""Maxe"" Mustermann; Jr.";max@example.com;0171 1234567;`)
assert.equal(zeilen[2], `${buchungsNummer('b_zwei')};Angefragt;14.10.2026;10:00;Damenschnitt;Anna;Zweiter;zwei@example.com;;"Zeile 1\nZeile 2"`, 'Zeilenumbruch in der Zelle gequotet')
@@ -798,7 +912,7 @@ test('GET export.csv: Zellen mit = + - @ am Anfang (auch nach Leerraum, Tab, CR)
blockEndsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:50:00.000+00:00`,
}))
const { text } = await rohExport()
const inhalt = text.replace(/^/, '')
const inhalt = text.replace(/^\uFEFF/, '')
assert.ok(inhalt.includes(`;"'=HYPERLINK(""http://x"")";`), 'Anfuehrungszeichen verdoppelt, Hochkomma davor')
assert.ok(inhalt.includes(";'+49 171 1234567;"))
assert.ok(inhalt.includes(";'-1+1;"))
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWriteLimiter } from '../server/services/writeLimiter.js'
// Schreib-Begrenzung je Schluessel im Speicher (News 30/h, Terminplaner 120/h): Fenster, getrennte Schluessel, Middleware.
const MINUTE = 60 * 1000
function uhr(start = 1_000_000) {
const stand = { jetzt: start }
return { stand, now: () => stand.jetzt }
}
test('writeLimiter: max Aufrufe sind erlaubt, der naechste ist darueber', () => {
const { now } = uhr()
const limiter = createWriteLimiter({ max: 3, now })
assert.deepEqual([1, 2, 3].map(() => limiter.istUeberschritten('k')), [false, false, false])
assert.equal(limiter.istUeberschritten('k'), true)
assert.equal(limiter.istUeberschritten('k'), true, 'weitere Aufrufe bleiben darueber')
})
test('writeLimiter: Schluessel sind getrennt', () => {
const { now } = uhr()
const limiter = createWriteLimiter({ max: 1, now })
assert.equal(limiter.istUeberschritten('a'), false)
assert.equal(limiter.istUeberschritten('a'), true)
assert.equal(limiter.istUeberschritten('b'), false, 'b hat sein eigenes Limit')
})
test('writeLimiter: das Fenster laeuft ab (Standard eine Stunde), ein Aufruf zaehlt genau bis 60 Minuten nach ihm', () => {
const { stand, now } = uhr()
const limiter = createWriteLimiter({ max: 2, now })
assert.equal(limiter.istUeberschritten('k'), false) // A bei 0
stand.jetzt += 60 * MINUTE - 1
assert.equal(limiter.istUeberschritten('k'), false) // B bei 59:59,999: A zaehlt noch (2 <= 2)
stand.jetzt += 1 // 60:00: A ist aus dem Fenster
assert.equal(limiter.istUeberschritten('k'), false) // C: B + C = 2
assert.equal(limiter.istUeberschritten('k'), true) // D: B + C + D = 3 > 2
})
test('writeLimiter: auch abgelehnte Aufrufe zaehlen im Fenster mit (wie bisher bei den News)', () => {
const { stand, now } = uhr()
const limiter = createWriteLimiter({ max: 1, now })
assert.equal(limiter.istUeberschritten('k'), false) // X bei 0
stand.jetzt += MINUTE
assert.equal(limiter.istUeberschritten('k'), true) // Y bei 1 min, abgelehnt
stand.jetzt += 59 * MINUTE // 60 min: X ist raus, Y (59 min alt) zaehlt noch
assert.equal(limiter.istUeberschritten('k'), true)
})
test('writeLimiter: eigenes Fenster, abgelaufene Schluessel werden vergessen, reset leert alles', () => {
const { stand, now } = uhr()
const limiter = createWriteLimiter({ max: 1, fensterMs: 10 * MINUTE, now })
assert.equal(limiter.istUeberschritten('a'), false)
assert.equal(limiter.istUeberschritten('a'), true)
stand.jetzt += 10 * MINUTE
assert.equal(limiter.istUeberschritten('a'), false, 'nach dem Fenster frei')
assert.equal(limiter.istUeberschritten('b'), false)
limiter.reset()
assert.equal(limiter.istUeberschritten('a'), false)
assert.equal(limiter.istUeberschritten('b'), false)
})
test('writeLimiter: middleware antwortet ab dem Ueberschreiten mit 429 und dem Text, sonst weiter', () => {
const { now } = uhr()
const limiter = createWriteLimiter({ max: 2, now })
const mw = limiter.middleware((req) => req.kunde, 'Zu viele Änderungen.')
const aufruf = (kunde) => {
const antwort = { status(code) { this.code = code; return this }, json(body) { this.body = body; return this } }
let weiter = 0
mw({ kunde }, antwort, () => { weiter += 1 })
return { weiter, code: antwort.code, body: antwort.body }
}
assert.deepEqual(aufruf('c1'), { weiter: 1, code: undefined, body: undefined })
assert.equal(aufruf('c1').weiter, 1)
assert.deepEqual(aufruf('c1'), { weiter: 0, code: 429, body: { error: 'Zu viele Änderungen.' } })
assert.equal(aufruf('c2').weiter, 1, 'anderer Kunde')
})
test('writeLimiter: ungueltige Angaben werfen', () => {
for (const max of [0, -1, 1.5, '3', undefined]) assert.throws(() => createWriteLimiter({ max }), TypeError, String(max))
assert.throws(() => createWriteLimiter({ max: 1, fensterMs: 0 }), TypeError)
assert.throws(() => createWriteLimiter({ max: 1, now: 5 }), TypeError)
})