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 { config } from '../config.js'
|
||||||
import { getDocument } from '../services/appwriteAdmin.js'
|
import { getDocument } from '../services/appwriteAdmin.js'
|
||||||
import { logIfKundenansicht } from '../services/kundenansicht.js'
|
import { logIfKundenansicht } from '../services/kundenansicht.js'
|
||||||
|
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||||
|
|
||||||
const router = Router()
|
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$/
|
const IMAGE_FILE_RE = /^([a-z0-9-]+)-(l|s)\.webp$/
|
||||||
|
|
||||||
function fail(res, err) {
|
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
|
// Schreib-Limit je Kunde und Stunde (im Speicher, wie beim Chat); zaehlt POST/PATCH/DELETE, nicht die Foto-Uploads
|
||||||
const recentWrites = new Map()
|
const limitWrites = createWriteLimiter({ max: 30 }).middleware(
|
||||||
function writeRateLimited(customerId) {
|
(req) => getSessionCustomerId(req),
|
||||||
const now = Date.now()
|
'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
|
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
|
||||||
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
|
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 { analysePrivacyData } from '../services/analysePrivacy.js'
|
||||||
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
|
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
|
||||||
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.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 { 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 { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
|
||||||
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
|
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
|
||||||
import { zustellFehler } from '../services/termine/outbox.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 router = Router()
|
||||||
|
|
||||||
const HOUR_MS = 60 * 60 * 1000
|
|
||||||
const MAX_WRITES_PER_HOUR = 120
|
const MAX_WRITES_PER_HOUR = 120
|
||||||
const MAX_TAGE = 62
|
const MAX_TAGE = 62
|
||||||
const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']
|
const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']
|
||||||
@@ -70,6 +71,7 @@ const MELDUNG = {
|
|||||||
leistung: 'Bitte eine Leistung auswählen.',
|
leistung: 'Bitte eine Leistung auswählen.',
|
||||||
ressource: 'Unbekannte Person oder unbekannter Platz.',
|
ressource: 'Unbekannte Person oder unbekannter Platz.',
|
||||||
keineRessource: 'Diese Person oder diesen Platz gibt es nicht.',
|
keineRessource: 'Diese Person oder diesen Platz gibt es nicht.',
|
||||||
|
keineBuchung: 'Diesen Termin gibt es nicht.',
|
||||||
ungueltig: 'Ungültige Eingabe.',
|
ungueltig: 'Ungültige Eingabe.',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,28 +114,12 @@ const route = (fn) => async (req, res, next) => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- Schreib-Limit
|
// ---------------------------------------------------------------- Schreib-Limit
|
||||||
|
|
||||||
// Schreib-Limit je Kunde und Stunde (im Speicher, wie bei den News); zählt POST/PATCH/DELETE
|
// 120 Aenderungen je Kunde und Stunde (im Speicher); zaehlt POST/PATCH/DELETE; die Uhr folgt setzeUhrFuerTests
|
||||||
const recentWrites = new Map()
|
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR, now: () => uhr().getTime() })
|
||||||
function writeRateLimited(customerId) {
|
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
export function resetTermineRoutesForTests() {
|
export function resetTermineRoutesForTests() {
|
||||||
recentWrites.clear()
|
limiter.reset()
|
||||||
uhr = () => new Date()
|
uhr = () => new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +331,12 @@ function buchungJson(b, zustellFehlerIds) {
|
|||||||
|
|
||||||
const tagGueltig = (tag) => typeof tag === 'string' && zuUtc(tag, '12:00') !== null
|
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) => {
|
router.get('/buchungen', requireSession, withTermine(), route(async (req, res) => {
|
||||||
const ctx = req.ctx
|
const ctx = req.ctx
|
||||||
const { von: vonTag, bis: bisTag, status } = req.query
|
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)) })
|
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) => {
|
router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
|
||||||
const ctx = mitEinstellung(req)
|
const ctx = mitEinstellung(req)
|
||||||
const { leistung, ressource = 'egal', tag, ohne } = req.query
|
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)) {
|
if (typeof leistung !== 'string' || !(ctx.einstellung.leistungen || []).some((l) => l.id === leistung && l.aktiv)) {
|
||||||
throw stop(400, MELDUNG.leistung, undefined, 'leistung')
|
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')
|
throw stop(400, MELDUNG.ressource, undefined, 'ressource')
|
||||||
}
|
}
|
||||||
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
|
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'))
|
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({
|
const zeiten = freieZeiten({
|
||||||
einstellung: ctx.einstellung,
|
einstellung: ctx.einstellung,
|
||||||
leistungId: leistung,
|
leistungId: leistung,
|
||||||
ressourceId: ressource,
|
ressourceId: ressource,
|
||||||
tag,
|
tag,
|
||||||
belegung: belegungAus(amTag, now, ohne ? { ohneBuchung: ohne } : {}),
|
belegung: belegungAus(amTag, now),
|
||||||
now,
|
now,
|
||||||
ohneRegeln: true,
|
ohneRegeln: true,
|
||||||
})
|
})
|
||||||
res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
|
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Buchungen schreiben
|
// ---------------------------------------------------------------- Buchungen schreiben
|
||||||
@@ -462,7 +468,7 @@ router.get('/export.csv', requireSession, withTermine(), route(async (req, res)
|
|||||||
b.gastNachricht,
|
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({
|
res.set({
|
||||||
'Content-Type': 'text/csv; charset=utf-8',
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
'Content-Disposition': 'attachment; filename="termine.csv"',
|
'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 }.
|
* 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 amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
|
||||||
const zeiten = freieZeiten({
|
const zeiten = freieZeiten({
|
||||||
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
|
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { pruefeNow, pruefeText } from './util.js'
|
|||||||
* feature portalFeatures-Dokument (featureKey 'termine')
|
* feature portalFeatures-Dokument (featureKey 'termine')
|
||||||
* project websiteProjects-Dokument oder null (Projekt fehlt)
|
* project websiteProjects-Dokument oder null (Projekt fehlt)
|
||||||
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
|
* 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.
|
* 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
|
* - 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
|
const version = Number.isInteger(doc.version) ? doc.version : 0
|
||||||
try {
|
try {
|
||||||
const einstellung = JSON.parse(doc.einstellung)
|
const einstellung = JSON.parse(doc.einstellung)
|
||||||
@@ -60,7 +63,7 @@ function liesEinstellung(doc) {
|
|||||||
return { einstellung, version }
|
return { einstellung, version }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[termine-kontext] Einstellung ${doc.$id} ist unlesbar: ${err.message}`)
|
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) {
|
export async function ladeEinstellung(featureId) {
|
||||||
pruefeText(featureId, 'featureId')
|
pruefeText(featureId, 'featureId')
|
||||||
const doc = await holeOderNull(config.collections.termineConfig, 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) {
|
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,
|
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
|
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
|
||||||
if (version !== null && version !== aktuell) throw veraltet()
|
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')
|
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
|
||||||
const json = JSON.stringify(einstellung)
|
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)
|
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 () => {
|
test('kontextPerFeature: Modus off => null ohne Anfrage; fehlendes Projekt => project null', async () => {
|
||||||
seedFeature()
|
seedFeature()
|
||||||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
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)
|
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 () => {
|
test('PATCH einstellungen: Version fehlt oder ist keine Ganzzahl => 400; Eingabe unbrauchbar => 400 mit Feldfehlern', async () => {
|
||||||
await speichere()
|
await speichere()
|
||||||
const stand = await lade()
|
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}&ressource=${R1}&ressource=${R2}&tag=${TAG}`, 'ressource'],
|
||||||
[`leistung=${S_SOFORT}`, 'tag'],
|
[`leistung=${S_SOFORT}`, 'tag'],
|
||||||
[`leistung=${S_SOFORT}&tag=2026-02-30`, 'tag'],
|
[`leistung=${S_SOFORT}&tag=2026-02-30`, 'tag'],
|
||||||
[`leistung=${S_SOFORT}&tag=${TAG}&ohne=a%20b`, 'ohne'],
|
|
||||||
]) {
|
]) {
|
||||||
const res = await zeiten(query)
|
const res = await zeiten(query)
|
||||||
assert.equal(res.status, 400, 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
|
// ---------------------------------------------------------------- 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 })
|
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')
|
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
|
// ---------------------------------------------------------------- Export
|
||||||
|
|
||||||
async function rohExport(projectId = P, c = cookie) {
|
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('cache-control'), 'no-store')
|
||||||
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
|
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
|
||||||
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
|
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[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[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')
|
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`,
|
blockEndsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:50:00.000+00:00`,
|
||||||
}))
|
}))
|
||||||
const { text } = await rohExport()
|
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(`;"'=HYPERLINK(""http://x"")";`), 'Anfuehrungszeichen verdoppelt, Hochkomma davor')
|
||||||
assert.ok(inhalt.includes(";'+49 171 1234567;"))
|
assert.ok(inhalt.includes(";'+49 171 1234567;"))
|
||||||
assert.ok(inhalt.includes(";'-1+1;"))
|
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