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:
1 parent
e3e198fb1a
commit
bb45d35ec7
8 files changed
+319
-65
No files matched your search
+5
-22
@@ -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
@@ -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"',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
@@ -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;"))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user