Terminplaner: Kunden-Schnittstelle – Nachbesserungen

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

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

No files matched your search

+21
View File
@@ -502,6 +502,27 @@ test('kontextPerFeature: active und disabled liefern ctx, auch ohne Einstellung
assert.equal(await kontextPerFeature(undefined), null)
})
test('unlesbares Einstellungs-JSON: der Kontext traegt die echte Version (einstellung null), Speichern mit ihr ueberschreibt den Schaden', async () => {
seedFeature()
seedProjekt()
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3 })
const ctx = await kontextPerFeature(FEATURE)
assert.equal(ctx.einstellung, null, 'nichts Lesbares')
assert.equal(ctx.version, 3, 'aber die gespeicherte Version, nicht 0')
assert.equal(await ladeEinstellung(FEATURE), null, 'ladeEinstellung bleibt: unlesbar = nicht gespeichert')
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'oeffentlich nicht buchbar')
// mit Version 0 (dem alten Wert des Kontexts) ging es nie; mit der echten gelingt es und die Funktion bekommt bisher = null
await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 0 }), (err) => err.status === 409)
let gesehen
const erg = await speichereEinstellung(feature(), async (bisher) => { gesehen = bisher; return einstellung() }, { version: ctx.version })
assert.equal(gesehen, null)
assert.equal(erg.version, 4)
const danach = await kontextPerFeature(FEATURE)
assert.equal(danach.version, 4)
assert.equal(danach.einstellung.betrieb.name, 'Salon Müller')
})
test('kontextPerFeature: Modus off => null ohne Anfrage; fehlendes Projekt => project null', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung(), { version: 0 })
+117 -3
View File
@@ -414,6 +414,26 @@ test('PATCH einstellungen: alte Version => 409 EINSTELLUNG_VERALTET, Stand des E
assert.equal((await aendere('Zukunft', 9)).status, 409)
})
test('unlesbares Einstellungs-JSON: GET zeigt keine Einstellung, aber die echte Version; Vorlage bleibt 409, PATCH mit dieser Version ueberschreibt', async () => {
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: P, einstellung: '{kaputt', version: 3 })
const stand = await lade()
assert.equal(stand.einstellung, null)
assert.equal(stand.version, 3)
assert.deepEqual(stand.feeds, {})
assert.deepEqual(stand.fehlt, [])
const vorlage = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'salon' } })
assert.equal(vorlage.status, 409)
assert.equal(aw.docs('termineConfig').get(FEATURE).einstellung, '{kaputt', 'nichts ueberschrieben')
const patch = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: stand.version, einstellung: eingabeEinstellung() } })
assert.equal(patch.status, 200, JSON.stringify(patch.body))
assert.equal(patch.body.version, 4)
const danach = await lade()
assert.equal(danach.einstellung.betrieb.name, 'Salon Müller')
assert.equal(danach.version, 4)
})
test('PATCH einstellungen: Version fehlt oder ist keine Ganzzahl => 400; Eingabe unbrauchbar => 400 mit Feldfehlern', async () => {
await speichere()
const stand = await lade()
@@ -617,7 +637,6 @@ test('GET zeiten: freie Zeiten ohne Vorlauf und Horizont (Betrieb), mit ohne=Buc
[`leistung=${S_SOFORT}&ressource=${R1}&ressource=${R2}&tag=${TAG}`, 'ressource'],
[`leistung=${S_SOFORT}`, 'tag'],
[`leistung=${S_SOFORT}&tag=2026-02-30`, 'tag'],
[`leistung=${S_SOFORT}&tag=${TAG}&ohne=a%20b`, 'ohne'],
]) {
const res = await zeiten(query)
assert.equal(res.status, 400, query)
@@ -625,6 +644,52 @@ test('GET zeiten: freie Zeiten ohne Vorlauf und Horizont (Betrieb), mit ohne=Buc
}
})
test('GET zeiten mit ohne (R42): rechnet mit der gebuchten Dauer und der Person der Buchung, Anfrage-Werte werden ignoriert', async () => {
const zeiten = async (query) => (await send('GET', `/api/termine/zeiten?projectId=p1&${query}`, { cookie })).body.zeiten
const mitRegeln = (leistungen, regeln = {}) => einstellung({ leistungen, regeln: { ...eingabeEinstellung().regeln, ...regeln } })
const leistung = (dauer, pufferNach) => [{ ...eingabeEinstellung().leistungen[0], dauer, pufferNach }]
// (a) Leistung nach der Buchung verlaengert (4 Stunden): die Buchung hat 45 + 5 Minuten und passt noch um 17:00
await speichere(mitRegeln(leistung(240, 5)))
seedBuchung('b_gebucht') // Montag, 10:00 bis 10:50 (R1)
const verlaengert = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}`)
assert.ok(!verlaengert.includes('17:00'), 'ohne ohne: die neue Dauer gilt')
const gebucht = await zeiten(`ohne=b_gebucht&tag=${TAG}&leistung=${S_ANFRAGE}&ressource=${R2}`)
assert.ok(gebucht.includes('17:00'), 'mit ohne: 17:00 + 50 Minuten endet vor 18:00')
assert.ok(gebucht.includes('10:00'), 'die eigene Zeit ist frei')
assert.equal(gebucht.at(-1), '17:00')
assert.ok(!gebucht.includes('17:15'), 'die gebuchte Dauer endet 18:05')
// (b) Leistung nach der Buchung verkuerzt (15 Minuten): ein Termin um 11:00 steht im Weg, 10:15 + 50 Minuten reicht hinein
await speichere(mitRegeln(leistung(15, 0)))
seedBuchung('b_dahinter', { ressourceId: R1, startsAt: `${TAG}T09:00:00.000+00:00`, endsAt: `${TAG}T09:45:00.000+00:00`, blockEndsAt: `${TAG}T09:50:00.000+00:00` })
const verkuerzt = await zeiten(`ohne=b_gebucht&tag=${TAG}&leistung=${S_SOFORT}&ressource=${R1}`)
assert.ok(verkuerzt.includes('10:00'), '10:00 bis 10:50 passt vor 11:00')
assert.ok(!verkuerzt.includes('10:15'), 'die gebuchte Dauer (50 Minuten) liefe in den Termin um 11:00')
// Person: waehlbar => die der Buchung, sonst jede freie ('egal'); die Angabe aus der Abfrage zaehlt nie
assert.ok(!(await zeiten(`ohne=b_gebucht&tag=${TAG}&ressource=${R2}`)).includes('10:15'), 'personWaehlbar: Annas Buchung bleibt bei Anna, Bens freie Zeit zaehlt nicht')
await speichere(mitRegeln(leistung(15, 0), { personWaehlbar: false }))
assert.ok((await zeiten(`ohne=b_gebucht&tag=${TAG}&ressource=${R1}`)).includes('10:15'), 'ohne Personenwahl genuegt eine freie Person: Ben um 10:15')
})
test('GET zeiten mit ohne (R42): unbekannte Buchung, andere Funktion, Sperre und unbrauchbare ID => 404', async () => {
await speichere()
seedBuchung('b_sperre', { quelle: 'sperre', gastName: '', gastEmail: '' })
seedBuchung('b_fremd', { featureId: 'f_anderer_termine' })
for (const ohne of ['b_gibtsnicht', 'b_sperre', 'b_fremd', 'a%20b', '..%2Fx', 'x'.repeat(200)]) {
const res = await send('GET', `/api/termine/zeiten?projectId=p1&tag=${TAG}&ohne=${ohne}`, { cookie })
assert.equal(res.status, 404, ohne)
assert.ok(res.body.error, ohne)
}
// mit ohne braucht es weder Leistung noch Person; der Tag bleibt Pflicht
seedBuchung('b_ok')
assert.equal((await send('GET', `/api/termine/zeiten?projectId=p1&tag=${TAG}&ohne=b_ok`, { cookie })).status, 200)
const ohneTag = await send('GET', '/api/termine/zeiten?projectId=p1&ohne=b_ok', { cookie })
assert.equal(ohneTag.status, 400)
assert.ok(ohneTag.body.fehler.tag)
})
// ---------------------------------------------------------------- Buchungen schreiben
const manuellBody = (over = {}) => ({ projectId: P, art: 'manuell', leistung: S_SOFORT, ressource: R1, tag: TAG, zeit: '11:00', name: 'Paula Telefon', telefon: '06301 555', ...over })
@@ -754,6 +819,55 @@ test('POST aktion: absagen braucht einen Grund (400), Betrieb sagt ab (abgesagt
assert.equal(falsch.body.code, 'nicht_moeglich')
})
// ---------------------------------------------------------------- Mandantentrennung
test('Mandantentrennung: Kunde 1 erreicht mit seiner projectId keine Buchung, Sperre oder Liste von Kunde 2 (echter zweiter Kunde mit aktiver Funktion)', async () => {
const feature2 = featureDoc({ $id: 'f_p3_termine', customerId: 'c2', projectId: 'p3', siteKey: 'fghijklmnopqrstu' })
aw.seed('portalFeatures', feature2)
await speichere(einstellung(), feature2)
const fremd = { featureId: 'f_p3_termine', customerId: 'c2', projectId: 'p3' }
seedBuchung('b_c2', { ...fremd, status: 'angefragt', gastName: 'Fremder Gast', gastEmail: 'fremd@example.com', gastNachricht: 'Streng geheim' })
seedBuchung('s_c2', { ...fremd, quelle: 'sperre', leistungId: '', leistungName: 'Fremde Sperre', gastName: '', gastEmail: '', startsAt: `${TAG}T12:00:00.000+00:00`, endsAt: `${TAG}T13:00:00.000+00:00`, blockEndsAt: `${TAG}T13:00:00.000+00:00` })
await speichere()
seedBuchung('b_eigen', { gastName: 'Eigener Gast', gastEmail: 'eigen@example.com' })
const vorher = JSON.stringify([...aw.docs('termineBookings').entries()])
// sanity: Kunde 2 sieht seine Daten
const c2 = customerCookie('c2')
const seine = await send('GET', `/api/termine/buchungen?projectId=p3&von=${TAG}&bis=${TAG}`, { cookie: c2 })
assert.deepEqual(seine.body.buchungen.map((b) => b.id), ['b_c2', 's_c2'])
const aktion = (id, body) => send('POST', `/api/termine/buchungen/${id}/aktion`, { cookie, body: { projectId: P, ...body } })
for (const body of [{ aktion: 'annehmen' }, { aktion: 'ablehnen' }, { aktion: 'absagen', grund: 'x' }, { aktion: 'verschieben', tag: '2026-10-19', zeit: '10:00' }, { aktion: 'anonymisieren' }, { aktion: 'nichterschienen' }]) {
const res = await aktion('b_c2', body)
assert.equal(res.status, 404, body.aktion)
assert.equal(res.body.code, 'unbekannt')
}
assert.equal((await aktion('s_c2', { aktion: 'absagen', grund: 'x' })).status, 404)
const weg = await send('DELETE', `/api/termine/buchungen/s_c2?projectId=${P}`, { cookie })
assert.equal(weg.status, 404)
assert.ok(aw.docs('termineBookings').has('s_c2'), 'die fremde Sperre steht noch')
assert.equal((await send('DELETE', `/api/termine/buchungen/b_c2?projectId=${P}`, { cookie })).status, 404)
for (const id of ['b_c2', 's_c2']) {
const zeiten = await send('GET', `/api/termine/zeiten?projectId=${P}&tag=${TAG}&ohne=${id}`, { cookie })
assert.equal(zeiten.status, 404, id)
}
const liste = await send('GET', `/api/termine/buchungen?projectId=${P}&von=${TAG}&bis=${TAG}`, { cookie })
assert.deepEqual(liste.body.buchungen.map((b) => b.id), ['b_eigen'])
const csv = (await rohExport()).text
assert.ok(csv.includes('Eigener Gast'))
for (const geheim of ['Fremder Gast', 'fremd@example.com', 'Streng geheim', buchungsNummer('b_c2'), 'Fremde Sperre']) assert.ok(!csv.includes(geheim), geheim)
assert.ok(!JSON.stringify(liste.body).includes('Fremder Gast'))
await warteAufVersand()
assert.equal(sink.sent.length, 0, 'keine Mail an den fremden Gast')
assert.equal(JSON.stringify([...aw.docs('termineBookings').entries()]), vorher, 'nichts veraendert')
assert.equal(logEintraege().length, 0)
})
// ---------------------------------------------------------------- Export
async function rohExport(projectId = P, c = cookie) {
@@ -776,7 +890,7 @@ test('GET export.csv: UTF-8 mit BOM, Strichpunkt, Kopfzeile, nach Beginn sortier
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
const zeilen = text.replace(/^/, '').split('\r\n')
const zeilen = text.replace(/^\uFEFF/, '').split('\r\n')
assert.equal(zeilen[0], 'Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht')
assert.equal(zeilen[1], `${buchungsNummer('b_eins')};Bestätigt;12.10.2026;10:00;Damenschnitt;Anna;"Max ""Maxe"" Mustermann; Jr.";max@example.com;0171 1234567;`)
assert.equal(zeilen[2], `${buchungsNummer('b_zwei')};Angefragt;14.10.2026;10:00;Damenschnitt;Anna;Zweiter;zwei@example.com;;"Zeile 1\nZeile 2"`, 'Zeilenumbruch in der Zelle gequotet')
@@ -798,7 +912,7 @@ test('GET export.csv: Zellen mit = + - @ am Anfang (auch nach Leerraum, Tab, CR)
blockEndsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:50:00.000+00:00`,
}))
const { text } = await rohExport()
const inhalt = text.replace(/^/, '')
const inhalt = text.replace(/^\uFEFF/, '')
assert.ok(inhalt.includes(`;"'=HYPERLINK(""http://x"")";`), 'Anfuehrungszeichen verdoppelt, Hochkomma davor')
assert.ok(inhalt.includes(";'+49 171 1234567;"))
assert.ok(inhalt.includes(";'-1+1;"))
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWriteLimiter } from '../server/services/writeLimiter.js'
// Schreib-Begrenzung je Schluessel im Speicher (News 30/h, Terminplaner 120/h): Fenster, getrennte Schluessel, Middleware.
const MINUTE = 60 * 1000
function uhr(start = 1_000_000) {
const stand = { jetzt: start }
return { stand, now: () => stand.jetzt }
}
test('writeLimiter: max Aufrufe sind erlaubt, der naechste ist darueber', () => {
const { now } = uhr()
const limiter = createWriteLimiter({ max: 3, now })
assert.deepEqual([1, 2, 3].map(() => limiter.istUeberschritten('k')), [false, false, false])
assert.equal(limiter.istUeberschritten('k'), true)
assert.equal(limiter.istUeberschritten('k'), true, 'weitere Aufrufe bleiben darueber')
})
test('writeLimiter: Schluessel sind getrennt', () => {
const { now } = uhr()
const limiter = createWriteLimiter({ max: 1, now })
assert.equal(limiter.istUeberschritten('a'), false)
assert.equal(limiter.istUeberschritten('a'), true)
assert.equal(limiter.istUeberschritten('b'), false, 'b hat sein eigenes Limit')
})
test('writeLimiter: das Fenster laeuft ab (Standard eine Stunde), ein Aufruf zaehlt genau bis 60 Minuten nach ihm', () => {
const { stand, now } = uhr()
const limiter = createWriteLimiter({ max: 2, now })
assert.equal(limiter.istUeberschritten('k'), false) // A bei 0
stand.jetzt += 60 * MINUTE - 1
assert.equal(limiter.istUeberschritten('k'), false) // B bei 59:59,999: A zaehlt noch (2 <= 2)
stand.jetzt += 1 // 60:00: A ist aus dem Fenster
assert.equal(limiter.istUeberschritten('k'), false) // C: B + C = 2
assert.equal(limiter.istUeberschritten('k'), true) // D: B + C + D = 3 > 2
})
test('writeLimiter: auch abgelehnte Aufrufe zaehlen im Fenster mit (wie bisher bei den News)', () => {
const { stand, now } = uhr()
const limiter = createWriteLimiter({ max: 1, now })
assert.equal(limiter.istUeberschritten('k'), false) // X bei 0
stand.jetzt += MINUTE
assert.equal(limiter.istUeberschritten('k'), true) // Y bei 1 min, abgelehnt
stand.jetzt += 59 * MINUTE // 60 min: X ist raus, Y (59 min alt) zaehlt noch
assert.equal(limiter.istUeberschritten('k'), true)
})
test('writeLimiter: eigenes Fenster, abgelaufene Schluessel werden vergessen, reset leert alles', () => {
const { stand, now } = uhr()
const limiter = createWriteLimiter({ max: 1, fensterMs: 10 * MINUTE, now })
assert.equal(limiter.istUeberschritten('a'), false)
assert.equal(limiter.istUeberschritten('a'), true)
stand.jetzt += 10 * MINUTE
assert.equal(limiter.istUeberschritten('a'), false, 'nach dem Fenster frei')
assert.equal(limiter.istUeberschritten('b'), false)
limiter.reset()
assert.equal(limiter.istUeberschritten('a'), false)
assert.equal(limiter.istUeberschritten('b'), false)
})
test('writeLimiter: middleware antwortet ab dem Ueberschreiten mit 429 und dem Text, sonst weiter', () => {
const { now } = uhr()
const limiter = createWriteLimiter({ max: 2, now })
const mw = limiter.middleware((req) => req.kunde, 'Zu viele Änderungen.')
const aufruf = (kunde) => {
const antwort = { status(code) { this.code = code; return this }, json(body) { this.body = body; return this } }
let weiter = 0
mw({ kunde }, antwort, () => { weiter += 1 })
return { weiter, code: antwort.code, body: antwort.body }
}
assert.deepEqual(aufruf('c1'), { weiter: 1, code: undefined, body: undefined })
assert.equal(aufruf('c1').weiter, 1)
assert.deepEqual(aufruf('c1'), { weiter: 0, code: 429, body: { error: 'Zu viele Änderungen.' } })
assert.equal(aufruf('c2').weiter, 1, 'anderer Kunde')
})
test('writeLimiter: ungueltige Angaben werfen', () => {
for (const max of [0, -1, 1.5, '3', undefined]) assert.throws(() => createWriteLimiter({ max }), TypeError, String(max))
assert.throws(() => createWriteLimiter({ max: 1, fensterMs: 0 }), TypeError)
assert.throws(() => createWriteLimiter({ max: 1, now: 5 }), TypeError)
})