Öffnungszeiten: öffentliche Route
GET /api/public/zeiten/v1/:siteKey mit CORS *, OPTIONS 204, 405, weichem ETag ohne stand, Cache-Control max-age=60, Expose-Headers Date, Grenze 120 je Minute und IP (zeiten/limits.js), 503 ohne Details und 404 für unbekannte Pfade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4c1be91c7
commit
d1f9176c99
4 files changed
+446
No files matched your search
@@ -38,6 +38,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
|||||||
import newsRoutes from './routes/news.js'
|
import newsRoutes from './routes/news.js'
|
||||||
import publicNewsRoutes from './routes/publicNews.js'
|
import publicNewsRoutes from './routes/publicNews.js'
|
||||||
import publicTermineRoutes from './routes/publicTermine.js'
|
import publicTermineRoutes from './routes/publicTermine.js'
|
||||||
|
import publicZeitenRoutes from './routes/publicZeiten.js'
|
||||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||||
import { analyseLegalRouter } from './services/analyseLegal.js'
|
import { analyseLegalRouter } from './services/analyseLegal.js'
|
||||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||||
@@ -73,6 +74,7 @@ app.use('/api/auth', authRoutes)
|
|||||||
app.use('/api/projects', projectsRoutes)
|
app.use('/api/projects', projectsRoutes)
|
||||||
app.use('/api/features', featuresRoutes)
|
app.use('/api/features', featuresRoutes)
|
||||||
app.use('/api/public/news', publicNewsRoutes)
|
app.use('/api/public/news', publicNewsRoutes)
|
||||||
|
app.use('/api/public/zeiten/v1', publicZeitenRoutes)
|
||||||
app.use('/api/news', newsRoutes)
|
app.use('/api/news', newsRoutes)
|
||||||
app.use('/api', customerDataRoutes)
|
app.use('/api', customerDataRoutes)
|
||||||
app.use('/api/chat', chatRoutes)
|
app.use('/api/chat', chatRoutes)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { darfZeitenAnfrage } from '../services/zeiten/limits.js'
|
||||||
|
import { oeffentlicheZeitenAntwort } from '../services/zeiten/oeffentlich.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffentliche Lese-API der Oeffnungszeiten fuer das Skript auf den Kundenwebsites: /api/public/zeiten/v1/:siteKey
|
||||||
|
* (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4.1). Ohne Session, eigenes CORS (`*`, nur GET und HEAD),
|
||||||
|
* Antworten enthalten nie Kunden-, Projekt-, Ticket- oder Stripe-IDs (das stellt services/zeiten/oeffentlich.js sicher).
|
||||||
|
*
|
||||||
|
* - Reihenfolge: CORS-Kopfzeilen (auch bei jedem Fehler, damit das Skript sie lesen kann) => OPTIONS 204 => 405 fuer alles ausser
|
||||||
|
* GET/HEAD => Grenze 120 Abrufe je Minute und IP (services/zeiten/limits.js) => Antwort.
|
||||||
|
* - Der Speicher (30 s, LRU 500), die Pruefung des siteKey-Formats und alle { aktiv: false }-Faelle (Schalter aus, unbekannter
|
||||||
|
* Schluessel, geloeschter Kunde, Zustand nicht setup/active) liegen im Dienst; hier wird nur gesendet.
|
||||||
|
* - Das weiche ETag hasht den Body OHNE `stand`: es bleibt gleich, solange sich die Daten nicht aendern, auch wenn die Antwort
|
||||||
|
* neu gebaut wird. `Access-Control-Expose-Headers: Date` laesst das Skript die Serverzeit lesen (Uhrabweichung).
|
||||||
|
* - Ein Fehler des Dienstes (Appwrite) wird zu 503 mit fester Meldung, nie mit dem Text des Fehlers.
|
||||||
|
*/
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const TEXT_ZU_VIELE = 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.'
|
||||||
|
const TEXT_NICHT_ERREICHBAR = 'Vorübergehend nicht erreichbar.'
|
||||||
|
|
||||||
|
// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter).
|
||||||
|
let uhr = () => new Date()
|
||||||
|
export function setzeUhrFuerTests(neueUhr) {
|
||||||
|
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
|
||||||
|
}
|
||||||
|
export function resetPublicZeitenForTests() {
|
||||||
|
uhr = () => new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
res.set({
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, If-None-Match',
|
||||||
|
'Access-Control-Expose-Headers': 'Date',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
})
|
||||||
|
if (req.method === 'OPTIONS') return res.sendStatus(204)
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).json({ error: 'Methode nicht erlaubt' })
|
||||||
|
// die App steht hinter Traefik, 'trust proxy' ist gesetzt -> req.ip ist die Client-IP
|
||||||
|
if (!darfZeitenAnfrage(req.ip, uhr())) {
|
||||||
|
res.set({ 'Retry-After': '60', 'Cache-Control': 'no-store' })
|
||||||
|
return res.status(429).json({ error: TEXT_ZU_VIELE })
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Sendet den Body mit weichem ETag (sha1, base64url) ueber den Body ohne `stand`; 304 bei If-None-Match (auch `*`). */
|
||||||
|
function sendeMitEtag(req, res, body) {
|
||||||
|
const etag = `W/"${createHash('sha1').update(JSON.stringify({ ...body, stand: undefined })).digest('base64url')}"`
|
||||||
|
res.set({ ETag: etag, 'Cache-Control': 'public, max-age=60', 'X-Content-Type-Options': 'nosniff' })
|
||||||
|
const gesendet = String(req.get('If-None-Match') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().replace(/^W\//, ''))
|
||||||
|
if (gesendet.includes(etag.slice(2)) || gesendet.includes('*')) return res.status(304).end()
|
||||||
|
return res.type('application/json').send(JSON.stringify(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
function antworteNichtErreichbar(res, err) {
|
||||||
|
console.error(`[publicZeiten] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||||
|
if (res.headersSent) return res.end()
|
||||||
|
res.set('Cache-Control', 'no-store')
|
||||||
|
return res.status(503).json({ error: TEXT_NICHT_ERREICHBAR })
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:siteKey', async (req, res) => {
|
||||||
|
try {
|
||||||
|
return sendeMitEtag(req, res, await oeffentlicheZeitenAntwort(req.params.siteKey, uhr()))
|
||||||
|
} catch (err) {
|
||||||
|
return antworteNichtErreichbar(res, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.use((req, res) => res.status(404).json({ error: 'Nicht gefunden' }))
|
||||||
|
|
||||||
|
router.use((err, req, res, _next) => antworteNichtErreichbar(res, err))
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ipSchluessel, zaehle } from '../termine/limits.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grenze der oeffentlichen Zeiten-Schnittstelle (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4.1):
|
||||||
|
* hoechstens 120 Abrufe je Minute und Client-IP. Der Zaehler liegt im Speicher dieses Prozesses (services/termine/limits.js:
|
||||||
|
* IPv6 je /64, 'ip:'-Schluessel verdraengen keine langlebigen Zaehler) und traegt einen eigenen Schluessel, damit Zeiten und
|
||||||
|
* Terminplaner nicht aus demselben Topf zaehlen. Tests leeren ihn mit resetTermineLimitsForTests.
|
||||||
|
*/
|
||||||
|
const ANFRAGEN_JE_IP = 120
|
||||||
|
const MINUTE_MS = 60 * 1000
|
||||||
|
|
||||||
|
/** Darf diese IP (IPv6 je /64) jetzt die Zeiten abrufen? Hoechstens 120 je Minute; true = erlaubt (gezaehlt), false = Grenze erreicht. */
|
||||||
|
export function darfZeitenAnfrage(ip, now) {
|
||||||
|
return zaehle(`ip:zeiten:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now)
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { schemaKeys } from './support/schema.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||||
|
|
||||||
|
// Oeffentliche Schnittstelle der Oeffnungszeiten (/api/public/zeiten/v1, Spezifikation 2026-10-04-oeffnungszeiten-live-design.md,
|
||||||
|
// Abschnitt 4.1): Router im Nachbau von Appwrite, feste Uhr (setzeUhrFuerTests), je Test eine eigene Client-Adresse (X-Forwarded-For).
|
||||||
|
const aw = await startAppwriteMock({
|
||||||
|
schema: {
|
||||||
|
portalFeatures: schemaKeys('portalFeatures'),
|
||||||
|
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
|
||||||
|
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
|
||||||
|
},
|
||||||
|
datetimes: ZEITEN_DATETIMES,
|
||||||
|
defaultLimit: 25,
|
||||||
|
})
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||||
|
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const { startTestApp, send } = await import('./support/testApp.js')
|
||||||
|
const { kundenansichtCookie } = await import('./support/fixtures.js')
|
||||||
|
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||||
|
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
|
||||||
|
const { speichereZeiten } = await import('../server/services/zeiten/speicher.js')
|
||||||
|
const { darfZeitenAnfrage } = await import('../server/services/zeiten/limits.js')
|
||||||
|
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
||||||
|
const { default: router, setzeUhrFuerTests, resetPublicZeitenForTests } = await import('../server/routes/publicZeiten.js')
|
||||||
|
|
||||||
|
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/zeiten/v1': router })
|
||||||
|
after(() => Promise.all([app.close(), aw.close()]))
|
||||||
|
|
||||||
|
const BASIS = '/api/public/zeiten/v1'
|
||||||
|
const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00
|
||||||
|
const SITE_KEY = 'abcdabcdabcdabcd'
|
||||||
|
const KUNDE = 'c_kunde01'
|
||||||
|
const PROJEKT = 'p_projekt01'
|
||||||
|
const FEATURE_ID = 'f_p_projekt01_zeiten'
|
||||||
|
const P_STANDARD = 'p_aaaaaaa1'
|
||||||
|
const MODUS_VORHER = config.zeiten.mode
|
||||||
|
|
||||||
|
const FEATURE = { $id: FEATURE_ID, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY, featureKey: 'zeiten', state: 'active', enabled: true }
|
||||||
|
|
||||||
|
const tage = (woche = {}) => {
|
||||||
|
const t = {}
|
||||||
|
for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] }
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
const eingabe = (extra = {}) => ({
|
||||||
|
land: 'RP',
|
||||||
|
plaene: [
|
||||||
|
{ id: P_STANDARD, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'] }) },
|
||||||
|
],
|
||||||
|
feiertage: {},
|
||||||
|
ausnahmen: [],
|
||||||
|
hinweise: [{ id: 'h_dauer001', art: 'info', text: 'Wir sind für Euch da.', knopf: null }],
|
||||||
|
kontakt: { an: true, telefon: '0171 1234567', adresse: 'Hauptstraße 1, 67722 Winnweiler', whatsapp: null },
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Funktion, Kunde und Projekt in den Nachbau legen; mit zeiten = false bleibt es beim leeren Dokument. */
|
||||||
|
async function legeAn({ feature = {}, kunde = { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein' }, zeiten = true, extra = {} } = {}) {
|
||||||
|
aw.seed('portalFeatures', { ...FEATURE, ...feature })
|
||||||
|
if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde })
|
||||||
|
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Sonnenschein-Website' })
|
||||||
|
if (zeiten) await speichereZeiten({ feature: FEATURE, eingabe: eingabe(extra), version: 0, by: 'kunde', now: JETZT })
|
||||||
|
}
|
||||||
|
|
||||||
|
let clientIp = 0
|
||||||
|
let ip
|
||||||
|
/** Anfrage an die Schnittstelle: Pfad ab /v1, eigene Client-Adresse je Test. */
|
||||||
|
const api = (method, pfad, { ip: adresse = ip, headers = {}, ...opts } = {}) =>
|
||||||
|
send(method, BASIS + pfad, { ...opts, headers: { 'x-forwarded-for': adresse, ...headers } })
|
||||||
|
const lies = (pfad = `/${SITE_KEY}`, opts) => api('GET', pfad, opts)
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) {
|
||||||
|
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||||
|
}
|
||||||
|
config.zeiten.mode = 'on'
|
||||||
|
resetZeitenCacheForTests()
|
||||||
|
resetTermineLimitsForTests()
|
||||||
|
resetPublicZeitenForTests()
|
||||||
|
setzeUhrFuerTests(() => JETZT)
|
||||||
|
mock.method(console, 'error', () => {})
|
||||||
|
mock.method(console, 'warn', () => {})
|
||||||
|
clientIp += 1
|
||||||
|
ip = `198.51.100.${clientIp}`
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
config.zeiten.mode = MODUS_VORHER
|
||||||
|
mock.restoreAll()
|
||||||
|
resetPublicZeitenForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
const AKTIV_FALSE = { aktiv: false }
|
||||||
|
const NICHT_ERREICHBAR = { error: 'Vorübergehend nicht erreichbar.' }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- CORS, Methoden
|
||||||
|
|
||||||
|
test('CORS * und OPTIONS 204', async () => {
|
||||||
|
const vorab = await api('OPTIONS', `/${SITE_KEY}`, { headers: { origin: 'https://baeckerei.example', 'access-control-request-method': 'GET' } })
|
||||||
|
assert.equal(vorab.status, 204)
|
||||||
|
assert.equal(vorab.headers.get('access-control-allow-origin'), '*')
|
||||||
|
assert.equal(vorab.headers.get('access-control-allow-methods'), 'GET, HEAD, OPTIONS')
|
||||||
|
assert.match(vorab.headers.get('access-control-allow-headers'), /If-None-Match/)
|
||||||
|
assert.equal(vorab.headers.get('access-control-max-age'), '86400')
|
||||||
|
assert.equal(vorab.body, null)
|
||||||
|
|
||||||
|
await legeAn()
|
||||||
|
const antwort = await lies(undefined, { headers: { origin: 'https://baeckerei.example' } })
|
||||||
|
assert.equal(antwort.status, 200)
|
||||||
|
assert.equal(antwort.headers.get('access-control-allow-origin'), '*')
|
||||||
|
assert.equal(antwort.headers.get('set-cookie'), null)
|
||||||
|
assert.equal(antwort.headers.get('x-content-type-options'), 'nosniff')
|
||||||
|
assert.match(antwort.headers.get('content-type'), /^application\/json/)
|
||||||
|
|
||||||
|
// der Preflight zaehlt nicht gegen die Grenze und ist auch auf unbekannten Pfaden ein 204
|
||||||
|
const anderswo = await api('OPTIONS', '/irgendwas/anderes')
|
||||||
|
assert.equal(anderswo.status, 204)
|
||||||
|
assert.equal(anderswo.headers.get('access-control-allow-origin'), '*')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nur GET/HEAD, sonst 405', async () => {
|
||||||
|
await legeAn()
|
||||||
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||||
|
const res = await api(method, `/${SITE_KEY}`, { body: { a: 1 } })
|
||||||
|
assert.equal(res.status, 405, method)
|
||||||
|
assert.deepEqual(res.body, { error: 'Methode nicht erlaubt' }, method)
|
||||||
|
assert.equal(res.headers.get('access-control-allow-origin'), '*', `${method}: die Meldung bleibt fuer das Skript lesbar`)
|
||||||
|
}
|
||||||
|
const head = await api('HEAD', `/${SITE_KEY}`)
|
||||||
|
assert.equal(head.status, 200)
|
||||||
|
assert.equal(head.body, null, 'HEAD ohne Rumpf')
|
||||||
|
assert.match(head.headers.get('etag'), /^W\/"[A-Za-z0-9_-]{27}"$/)
|
||||||
|
assert.equal(head.headers.get('cache-control'), 'public, max-age=60')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Kopfzeilen, ETag
|
||||||
|
|
||||||
|
test('Cache-Control public, max-age=60 und Expose-Headers Date', async () => {
|
||||||
|
await legeAn()
|
||||||
|
const res = await lies()
|
||||||
|
assert.equal(res.status, 200)
|
||||||
|
assert.equal(res.headers.get('cache-control'), 'public, max-age=60')
|
||||||
|
assert.equal(res.headers.get('access-control-expose-headers'), 'Date')
|
||||||
|
assert.equal(res.body.aktiv, true)
|
||||||
|
assert.equal(res.body.stand, JETZT.toISOString())
|
||||||
|
assert.equal(res.body.plaene.length, 1)
|
||||||
|
assert.equal(res.body.firma.name, 'Bäckerei Sonnenschein')
|
||||||
|
|
||||||
|
// auch die kurze Antwort { aktiv: false } und die Fehlerantworten tragen die Date-Freigabe, damit das Skript die Serverzeit lesen kann
|
||||||
|
const leer = await lies('/zzzzzzzzzzzzzzzz')
|
||||||
|
assert.equal(leer.headers.get('access-control-expose-headers'), 'Date')
|
||||||
|
assert.equal(leer.headers.get('cache-control'), 'public, max-age=60')
|
||||||
|
const fremd = await api('POST', `/${SITE_KEY}`)
|
||||||
|
assert.equal(fremd.headers.get('access-control-expose-headers'), 'Date')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Antwort ohne interne IDs, Kundendaten und Zugangswerte', async () => {
|
||||||
|
await legeAn({ kunde: { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein', email: 'sonne@example.com', phone: '0699' } })
|
||||||
|
const res = await lies()
|
||||||
|
const text = JSON.stringify(res.body)
|
||||||
|
for (const intern of [FEATURE_ID, KUNDE, PROJEKT, SITE_KEY, P_STANDARD, 'h_dauer001', 'Frau Sonne', 'sonne@example.com', 'Sonnenschein-Website']) {
|
||||||
|
assert.ok(!text.includes(intern), `Wert ${intern}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ETag bleibt gleich, obwohl stand sich ändert ⇒ 304', async () => {
|
||||||
|
await legeAn()
|
||||||
|
const erste = await lies()
|
||||||
|
assert.equal(erste.status, 200)
|
||||||
|
const etag = erste.headers.get('etag')
|
||||||
|
assert.match(etag, /^W\/"[A-Za-z0-9_-]{27}"$/, 'weiches ETag, sha1 base64url')
|
||||||
|
|
||||||
|
// der Speicher laeuft nach 30 s ab und die Antwort wird neu gebaut: gleiche Daten, neuer Zeitstempel (noch dieselbe Minute:
|
||||||
|
// der erste Eintrag von wechsel beginnt mit der aktuellen Minute und gehoert zum Body)
|
||||||
|
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 30 * 1000))
|
||||||
|
const neu = await lies()
|
||||||
|
assert.equal(neu.status, 200)
|
||||||
|
assert.notEqual(neu.body.stand, erste.body.stand, 'stand ist neu')
|
||||||
|
assert.equal(neu.headers.get('etag'), etag, 'das ETag haengt nicht an stand')
|
||||||
|
|
||||||
|
const bedingt = await lies(undefined, { headers: { 'if-none-match': etag } })
|
||||||
|
assert.equal(bedingt.status, 304)
|
||||||
|
assert.equal(bedingt.body, null)
|
||||||
|
assert.equal(bedingt.headers.get('etag'), etag)
|
||||||
|
assert.equal(bedingt.headers.get('cache-control'), 'public, max-age=60')
|
||||||
|
assert.equal(bedingt.headers.get('access-control-allow-origin'), '*')
|
||||||
|
|
||||||
|
for (const wert of [etag.slice(2), `"abc", ${etag}`, '*']) {
|
||||||
|
const res = await lies(undefined, { headers: { 'if-none-match': wert } })
|
||||||
|
assert.equal(res.status, 304, wert)
|
||||||
|
}
|
||||||
|
const veraltet = await lies(undefined, { headers: { 'if-none-match': 'W/"nicht-mehr-aktuell"' } })
|
||||||
|
assert.equal(veraltet.status, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ETag ändert sich mit dem Inhalt (Hinweis geändert) und ist für { aktiv: false } stabil', async () => {
|
||||||
|
await legeAn()
|
||||||
|
const vorher = (await lies()).headers.get('etag')
|
||||||
|
await speichereZeiten({ feature: FEATURE, eingabe: eingabe({ hinweise: [{ id: 'h_dauer001', art: 'info', text: 'Neuer Text', knopf: null }] }), version: 1, by: 'kunde', now: JETZT })
|
||||||
|
const nachher = await lies()
|
||||||
|
assert.equal(nachher.status, 200)
|
||||||
|
assert.equal(nachher.body.hinweise[0].text, 'Neuer Text')
|
||||||
|
assert.notEqual(nachher.headers.get('etag'), vorher)
|
||||||
|
|
||||||
|
const leer1 = await lies('/zzzzzzzzzzzzzzzz')
|
||||||
|
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 5 * 60 * 1000))
|
||||||
|
const leer2 = await lies('/zzzzzzzzzzzzzzzz')
|
||||||
|
assert.deepEqual(leer1.body, AKTIV_FALSE)
|
||||||
|
assert.equal(leer1.headers.get('etag'), leer2.headers.get('etag'))
|
||||||
|
assert.equal((await lies('/zzzzzzzzzzzzzzzz', { headers: { 'if-none-match': leer1.headers.get('etag') } })).status, 304)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Grenze
|
||||||
|
|
||||||
|
test('121. Anfrage in einer Minute ⇒ 429 mit Retry-After', async () => {
|
||||||
|
await legeAn()
|
||||||
|
for (let i = 1; i <= 120; i += 1) {
|
||||||
|
const res = await lies()
|
||||||
|
assert.equal(res.status, 200, `Anfrage ${i}`)
|
||||||
|
}
|
||||||
|
const zuViele = await lies()
|
||||||
|
assert.equal(zuViele.status, 429)
|
||||||
|
assert.equal(zuViele.headers.get('retry-after'), '60')
|
||||||
|
assert.equal(zuViele.headers.get('access-control-allow-origin'), '*')
|
||||||
|
assert.equal(zuViele.headers.get('cache-control'), 'no-store')
|
||||||
|
assert.deepEqual(zuViele.body, { error: 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.' })
|
||||||
|
|
||||||
|
// der Preflight zaehlt nicht, eine andere Adresse hat ihre eigene Grenze, nach einer Minute geht es wieder
|
||||||
|
assert.equal((await api('OPTIONS', `/${SITE_KEY}`)).status, 204)
|
||||||
|
assert.equal((await lies(undefined, { ip: '198.51.100.250' })).status, 200)
|
||||||
|
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 60 * 1000))
|
||||||
|
assert.equal((await lies()).status, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('darfZeitenAnfrage: 120 je Minute und IP, IPv6 je /64, getrennt von den Zählern des Terminplaners', async () => {
|
||||||
|
const jetzt = new Date('2026-10-05T10:00:00.000Z')
|
||||||
|
for (let i = 0; i < 120; i += 1) assert.equal(darfZeitenAnfrage('203.0.113.9', jetzt), true, `Anfrage ${i + 1}`)
|
||||||
|
assert.equal(darfZeitenAnfrage('203.0.113.9', jetzt), false)
|
||||||
|
assert.equal(darfZeitenAnfrage('203.0.113.9', new Date(jetzt.getTime() + 59999)), false, 'das Fenster dauert 60 s')
|
||||||
|
assert.equal(darfZeitenAnfrage('203.0.113.9', new Date(jetzt.getTime() + 60000)), true)
|
||||||
|
assert.equal(darfZeitenAnfrage('203.0.113.10', jetzt), true, 'andere IP, anderer Zähler')
|
||||||
|
|
||||||
|
for (let i = 0; i < 120; i += 1) assert.equal(darfZeitenAnfrage('2001:db8:1:2:aaaa::1', jetzt), true)
|
||||||
|
assert.equal(darfZeitenAnfrage('2001:db8:1:2:bbbb::7', jetzt), false, 'dasselbe /64 teilt sich den Zähler')
|
||||||
|
assert.equal(darfZeitenAnfrage('2001:db8:1:3::1', jetzt), true)
|
||||||
|
|
||||||
|
const { darfAnfrage } = await import('../server/services/termine/limits.js')
|
||||||
|
assert.equal(darfAnfrage('203.0.113.9', jetzt), true, 'der Terminplaner zählt für dieselbe IP für sich')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Fehler
|
||||||
|
|
||||||
|
test('Appwrite-Fehler ⇒ 503 ohne Details', async () => {
|
||||||
|
await legeAn()
|
||||||
|
const RAW = 'Nachbau kaputt: geheimer Rohtext'
|
||||||
|
aw.failNext({ collection: 'portalFeatures', status: 500, message: RAW })
|
||||||
|
const res = await api('GET', `/${SITE_KEY}`)
|
||||||
|
assert.equal(res.status, 503)
|
||||||
|
assert.deepEqual(res.body, NICHT_ERREICHBAR)
|
||||||
|
assert.equal(res.headers.get('access-control-allow-origin'), '*')
|
||||||
|
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||||
|
assert.ok(!JSON.stringify(res.body).includes('Nachbau'))
|
||||||
|
assert.ok(console.error.mock.calls.length > 0, 'der Fehler steht im Log, nicht in der Antwort')
|
||||||
|
|
||||||
|
// nichts wurde gemerkt: danach geht es wieder
|
||||||
|
assert.equal((await lies()).status, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- aktiv:false
|
||||||
|
|
||||||
|
test('aktiv:false bei Schalter aus, unbekanntem Schlüssel, Zustand off/ordered/disabled', async () => {
|
||||||
|
await legeAn()
|
||||||
|
|
||||||
|
config.zeiten.mode = 'off'
|
||||||
|
const aus = await lies()
|
||||||
|
assert.equal(aus.status, 200)
|
||||||
|
assert.deepEqual(aus.body, AKTIV_FALSE)
|
||||||
|
assert.equal(aus.headers.get('access-control-allow-origin'), '*')
|
||||||
|
assert.equal(aus.headers.get('cache-control'), 'public, max-age=60')
|
||||||
|
config.zeiten.mode = 'on'
|
||||||
|
assert.equal((await lies()).body.aktiv, true)
|
||||||
|
|
||||||
|
assert.deepEqual((await lies('/zzzzzzzzzzzzzzzz')).body, AKTIV_FALSE, 'unbekannter Schluessel')
|
||||||
|
for (const kaputt of ['/ABCDABCDABCDABCD', '/abc', '/abcdabcdabcdabc1', `/${'a'.repeat(200)}`]) {
|
||||||
|
const res = await lies(kaputt)
|
||||||
|
assert.equal(res.status, 200, kaputt)
|
||||||
|
assert.deepEqual(res.body, AKTIV_FALSE, kaputt)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const state of ['off', 'ordered', 'disabled']) {
|
||||||
|
await legeAn({ feature: { state, enabled: false }, zeiten: false })
|
||||||
|
resetZeitenCacheForTests()
|
||||||
|
const res = await lies()
|
||||||
|
assert.equal(res.status, 200, state)
|
||||||
|
assert.deepEqual(res.body, AKTIV_FALSE, state)
|
||||||
|
}
|
||||||
|
for (const state of ['setup', 'active']) {
|
||||||
|
await legeAn({ feature: { state }, zeiten: false })
|
||||||
|
resetZeitenCacheForTests()
|
||||||
|
assert.equal((await lies()).body.aktiv, true, state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kunde gelöscht ⇒ aktiv:false', async () => {
|
||||||
|
await legeAn({ kunde: null })
|
||||||
|
assert.deepEqual((await lies()).body, AKTIV_FALSE)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Rest
|
||||||
|
|
||||||
|
test('unbekannter Pfad ⇒ 404 mit CORS', async () => {
|
||||||
|
for (const pfad of ['', '/', `/${SITE_KEY}/mehr`, '/a/b/c']) {
|
||||||
|
const res = await api('GET', pfad)
|
||||||
|
assert.equal(res.status, 404, pfad)
|
||||||
|
assert.deepEqual(res.body, { error: 'Nicht gefunden' }, pfad)
|
||||||
|
assert.equal(res.headers.get('access-control-allow-origin'), '*', pfad)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kundenansicht-Cookie (Mitarbeiter im Portal eines Kunden) stört den öffentlichen Abruf nicht', async () => {
|
||||||
|
await legeAn()
|
||||||
|
const cookie = kundenansichtCookie()
|
||||||
|
const res = await lies(undefined, { cookie, headers: { 'x-kundenansicht': 'b'.repeat(32) } })
|
||||||
|
assert.equal(res.status, 200)
|
||||||
|
assert.equal(res.body.aktiv, true)
|
||||||
|
const abgelaufen = await lies(undefined, { cookie: kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 }) })
|
||||||
|
assert.equal(abgelaufen.status, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Reihenfolge in server/index.js: direkt nach dem Mount der News, hinter kundenansichtGuard, Termine unberührt', () => {
|
||||||
|
const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
|
||||||
|
const guard = quelle.indexOf('app.use(kundenansichtGuard)')
|
||||||
|
const news = quelle.indexOf("app.use('/api/public/news', publicNewsRoutes)")
|
||||||
|
const zeiten = quelle.indexOf("app.use('/api/public/zeiten/v1', publicZeitenRoutes)")
|
||||||
|
const termine = quelle.indexOf("app.use('/api/public/termine/v1', publicTermineRoutes)")
|
||||||
|
assert.ok(guard >= 0 && news >= 0 && zeiten >= 0 && termine >= 0, 'alle Mounts gefunden')
|
||||||
|
assert.equal(quelle.indexOf("app.use('/api/public/zeiten/v1'", zeiten + 1), -1, 'nur einmal eingehaengt')
|
||||||
|
assert.ok(guard < zeiten, 'erst der Waechter der Kundenansicht')
|
||||||
|
assert.equal(quelle.slice(news, zeiten).split('\n').length, 2, 'unmittelbar die naechste Zeile')
|
||||||
|
assert.ok(termine < quelle.indexOf('app.use(express.json('), 'Termine bleibt vor dem globalen Parser')
|
||||||
|
assert.match(quelle, /import publicZeitenRoutes from '\.\/routes\/publicZeiten\.js'/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user