diff --git a/server/routes/news.js b/server/routes/news.js index 151c859..36aacd0 100644 --- a/server/routes/news.js +++ b/server/routes/news.js @@ -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) diff --git a/server/routes/termine.js b/server/routes/termine.js index 7313edd..cd8dde8 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -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=` (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"', diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index 0626239..ed88eaa 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -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), diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js index a4839d6..06d9256 100644 --- a/server/services/termine/kontext.js +++ b/server/services/termine/kontext.js @@ -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) diff --git a/server/services/writeLimiter.js b/server/services/writeLimiter.js new file mode 100644 index 0000000..0d9352a --- /dev/null +++ b/server/services/writeLimiter.js @@ -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() } +} diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js index ad1b2b0..d3136b5 100644 --- a/test/termineKontext.test.js +++ b/test/termineKontext.test.js @@ -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 }) diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js index 893b8f6..4927948 100644 --- a/test/termineRoutes.test.js +++ b/test/termineRoutes.test.js @@ -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;")) diff --git a/test/writeLimiter.test.js b/test/writeLimiter.test.js new file mode 100644 index 0000000..707adb1 --- /dev/null +++ b/test/writeLimiter.test.js @@ -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) +})