From 98d1e6613da0233c2233f1ac764e1a67c33f17f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:54:06 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20F1:=20=C3=B6ffentliche=20Schnittstelle?= =?UTF-8?q?=20zum=20Lesen=20(Konfiguration=20mit=20Artikeln,=20Artikelbild?= =?UTF-8?q?er,=20Grenzen,=20Formular-Token)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/index.js | 3 + server/routes/publicShop.js | 374 +++++++++++++++++++++++++++++ server/services/shop/token.js | 64 +++++ test/publicShop.test.js | 427 ++++++++++++++++++++++++++++++++++ 4 files changed, 868 insertions(+) create mode 100644 server/routes/publicShop.js create mode 100644 server/services/shop/token.js create mode 100644 test/publicShop.test.js diff --git a/server/index.js b/server/index.js index 7c83540..68c527c 100644 --- a/server/index.js +++ b/server/index.js @@ -38,6 +38,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' import publicTermineRoutes from './routes/publicTermine.js' +import publicShopRoutes from './routes/publicShop.js' import adminAnalyseRoutes from './routes/admin/analyse.js' import { legalRouter } from './services/legalDocs.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' @@ -60,6 +61,8 @@ app.use(kundenansichtGuard) // Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und // haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde app.use('/api/public/termine/v1', publicTermineRoutes) +// Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser +app.use('/api/public/shop/v1', publicShopRoutes) // Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit, // muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung // zuerst, damit niemand ohne Session grosse Bodies parsen laesst. diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js new file mode 100644 index 0000000..4f05fbe --- /dev/null +++ b/server/routes/publicShop.js @@ -0,0 +1,374 @@ +import express, { Router } from 'express' +import { config } from '../config.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { bildMeta, bildPfad } from '../services/shop/bilder.js' +import { reserviert } from '../services/shop/bestellungen.js' +import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js' +import { euro } from '../services/shop/geld.js' +import { listeProdukte, parseProdukt, produktOeffentlich } from '../services/shop/produkte.js' +import { GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, versandUndZahlung, zahlungsartenText } from '../services/shop/rechtstexte.js' +import { shopFuerKunde, shopModus } from '../services/shop/schalter.js' +import { formToken } from '../services/shop/token.js' +import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js' + +/** + * Oeffentliche Schnittstelle des Webshops: /api/public/shop/v1 (Plan 2026-10-05-webshop.md, Block F; Muster + * routes/publicTermine.js). Ohne Session, ohne Captcha. Hier steht das Lesen (F1: Konfiguration samt Artikeln, Artikelbilder); + * Bestellung, Status und Widerruf (F2/F3) kommen als POST/GET-Routen in dieselbe Datei und nutzen route(), schreibe() und die + * Fehlerhelfer. + * + * - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem + * globalen express.json (2 MB). Antworten sind immer JSON { ... } oder { fehler: { : Text }, code? }; Rohtexte von + * Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar"). + * - Antworten enthalten nie customerId, projectId, featureId, $id, Stripe-IDs oder E-Mail-Adressen ausser der oeffentlichen + * Anbieter-Adresse des Haendlers. + * - Unbekannter siteKey, Modus off, Pilotmodus fuer einen Kunden ausserhalb der Pilotliste oder eine Funktion, die nicht active + * oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Eine abgeschaltete Funktion (disabled) antwortet mit + * { aktiv: true, offen: false, nurWiderruf: true, ... }, damit Kaeufer den Widerruf weiter erklaeren koennen. + * - CORS: GET fuer alle (*). Meldungen ohne Daten (429, 503, aktiv:false) tragen `*`. + * - Grenzen: je IP 120 je Minute (darfAnfrage), je Shop 600 je Minute (Schluessel 'shop:'); die Artikelbilder haben + * eigene, groessere Zaehler, damit eine Seite mit vielen Bildern die Konfiguration nicht aussperrt. + */ + +const router = Router() + +const BODY_MAX = 16 * 1024 +const MINUTE_MS = 60 * 1000 +const SHOP_JE_MINUTE = 600 +const BILD_JE_IP = 600 +const BILD_JE_SHOP = 6000 +const BILD_RE = /^([a-z0-9-]{1,64})-(s|l)\.webp$/ +const MIN_ANTWORT_STANDARD_MS = 300 +const LAENDER_NAMEN = { DE: 'Deutschland', AT: 'Österreich' } +const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit'] + +const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } } +const NICHT_GEFUNDEN = { fehler: { _: 'Nicht gefunden' } } +const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } } +const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } } +const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' + +// 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() +} + +/** Besitzer-Adressen fuer den Pilotmodus je ctx (ein ctx aus shopPerSiteKey lebt 30 Sekunden): ctx -> Promise. */ +const besitzer = new WeakMap() +export function resetPublicShopForTests() { + uhr = () => new Date() +} + +// ---------------------------------------------------------------- Antworten und Fehler + +/** Fehler mit Statuscode fuer den Besucher (4xx); `fehlerErgebnis` macht daraus die Antwort. */ +const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra }) +const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS }) +const nichtGefunden = () => fehlerMit(404, NICHT_GEFUNDEN.fehler) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {}) + +/** Antwort fuer einen Fehler: Fehler des Besuchers (4xx mit `fehler`) wie sie sind, alles andere 503 ohne Rohtext. */ +function fehlerErgebnis(err) { + const status = err?.status + const fehler = nurTexte(err?.fehler) + if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) { + const body = { fehler } + if (typeof err.code === 'string') body.code = err.code + return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } + } + console.error(`[publicShop] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return { status: 503, body: NICHT_ERREICHBAR } +} + +/** + * Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null } + * (fuer POST, F2/F3). GET und HEAD tragen immer `*`; Meldungen ohne Daten (429, 503, aktiv:false) auch bei POST. + */ +function schreibe(req, res, ergebnis, cors = { erlaubt: null }) { + const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis + const lesen = req.method === 'GET' || req.method === 'HEAD' + if (lesen) res.set('Access-Control-Allow-Origin', '*') + else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' }) + else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*') + res.status(status) + res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) + if (status === 204) return res.end() + const inhalt = typeof body === 'string' ? body : JSON.stringify(body) + res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) + return res.end(inhalt) +} + +/** Sendet eine Datei (Ergebnis { datei: absoluter Pfad }); fehlt sie, antwortet 404 wie sonst. */ +function sendeDatei(req, res, ergebnis) { + return new Promise((resolve) => { + res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' }) + res.sendFile( + ergebnis.datei, + { cacheControl: false, dotfiles: 'deny', headers: { 'Cache-Control': ergebnis.cache, 'X-Content-Type-Options': 'nosniff' } }, + (err) => { + if (err && !res.headersSent) { + for (const kopf of ['Cache-Control', 'Content-Type', 'Content-Length', 'Cross-Origin-Resource-Policy']) res.removeHeader(kopf) + schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN }) + } else if (err) res.destroy() + resolve() + }, + ) + }) +} + +/** Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert: schlichtes 503, sofern noch nichts gesendet wurde. */ +function notantwort(res, err) { + try { + console.error(`[publicShop] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + } catch { + // das Log darf die Notantwort nicht verhindern + } + try { + if (res.headersSent) return void res.end() + const inhalt = JSON.stringify(NICHT_ERREICHBAR) + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII + return void res.end(inhalt) + } catch { + try { + res.destroy() + } catch { + // nichts mehr zu tun + } + } +} + +function schreibeSicher(req, res, ergebnis, cors) { + try { + schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Shops (GET kurz im Browser-Cache). */ +const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' }) + +const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const minAntwortMs = () => (Number.isFinite(config.shop?.minAntwortMs) && config.shop.minAntwortMs >= 0 ? config.shop.minAntwortMs : MIN_ANTWORT_STANDARD_MS) + +/** + * Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe + * (oder { datei, cache } fuer sendeDatei) oder wirft; cors.erlaubt setzt fn, sobald die Herkunft geprueft ist (F2/F3). + * Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs (ab Beginn der Anfrage). + */ +const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { + try { + const cors = { erlaubt: null } + let ergebnis + try { + ergebnis = await fn(req, uhr(), cors) + } catch (err) { + ergebnis = fehlerErgebnis(err) + } + if (mindestdauer) { + const rest = minAntwortMs() - (performance.now() - req.shopStart) + if (rest > 0) await warte(rest) + } + if (ergebnis.datei) await sendeDatei(req, res, ergebnis) + else schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +// ---------------------------------------------------------------- Beginn: Zeit, Rumpf + +router.use((req, res, next) => { + req.shopStart = performance.now() + // Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen). + if (Number(req.get('content-length')) > BODY_MAX) { + res.set('Connection', 'close') + return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) + } + return next() +}) +router.use(express.json({ limit: BODY_MAX })) + +// ---------------------------------------------------------------- Zugang + +/** E-Mail-Adresse des Shop-Besitzers (leer, wenn der Kunde fehlt); je ctx einmal geladen. */ +function besitzerEmail(ctx) { + let gemerkt = besitzer.get(ctx) + if (!gemerkt) { + gemerkt = getDocument(config.collections.customers, ctx.shop.customerId).then( + (kunde) => String(kunde?.email ?? ''), + (err) => { + if (err?.status === 404) return '' + besitzer.delete(ctx) // Fehler nicht merken + throw err + }, + ) + besitzer.set(ctx, gemerkt) + } + return gemerkt +} + +/** + * Der Kontext des Shops, wenn er ueber die Schnittstelle erreichbar ist; sonst null. Modus off => null ohne jede Abfrage; + * im Pilotmodus muss der Besitzer auf der Pilotliste stehen (SHOP_PILOT_EMAILS). + */ +async function erreichbarerShop(siteKey, now) { + const modus = shopModus() + if (modus === 'off') return null + const ctx = await shopPerSiteKey(siteKey, { now }) + if (!ctx) return null + if (modus === 'pilot' && !shopFuerKunde(await besitzerEmail(ctx))) return null + return ctx +} + +// ---------------------------------------------------------------- Konfiguration + +const text = (wert) => (typeof wert === 'string' ? wert.trim() : '') +const basisUrl = () => String(config.publicBaseUrl ?? '').replace(/\/+$/, '') + +function anbieterOeffentlich(a) { + return { name: a.name, strasse: a.strasse, plz: a.plz, ort: a.ort, land: a.land, email: a.email, telefon: a.telefon } +} + +/** Kurzer Versandtext, z. B. 'Versand nach Deutschland 4,90 €, ab 50,00 € versandkostenfrei'. */ +function versandText(e) { + if (!e.versand.aktiv) return e.abholung.aktiv ? 'Nur Abholung' : '' + const laender = (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => { + const name = LAENDER_NAMEN[code] ?? code + const kosten = e.versand.kostenCent?.[code] + return Number.isInteger(kosten) ? `${name} ${euro(kosten)}` : name + }) + let t = laender.length > 0 ? `Versand nach ${laender.join(', ')}` : 'Versand' + if (Number.isInteger(e.versand.freiAbCent)) t += `, ab ${euro(e.versand.freiAbCent)} versandkostenfrei` + return t +} + +function steuerHinweisKurz(e) { + return e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : 'Alle Preise inkl. MwSt.' +} + +/** Der Teil `shop` der Antwort (Anzeige- und Kasseninformationen des Haendlers). */ +function shopOeffentlich(e) { + const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : [] + return { + name: e.shopname, + akzent: e.akzent, + anbieter: anbieterOeffentlich(e.anbieter), + lieferzeit: e.lieferzeit, + lieferlaender: e.versand.aktiv ? (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => ({ code, name: LAENDER_NAMEN[code] ?? code })) : [], + abholung: { aktiv: e.abholung.aktiv === true, adresse: e.abholung.adresse, hinweis: e.abholung.hinweis }, + zahlungsarten: zahlungsartenText(e), + zahlungsartenListe: ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a)).map((a) => zahlungsartenText({ zahlungsarten: [a] })), + versandText: versandText(e), + mindestbestellText: Number.isInteger(e.mindestbestellCent) ? `Mindestbestellwert ${euro(e.mindestbestellCent)}` : '', + kleinunternehmer: e.kleinunternehmer === true, + steuerHinweisKurz: steuerHinweisKurz(e), + } +} + +/** Die bestaetigten Rechtstexte (Fassung, die Kaeufer sehen) oder null, solange keine Fassung bestaetigt ist. */ +function rechtstexteOeffentlich(ctx) { + const t = oeffentlicheRechtstexte(ctx) + if (!t) return null + return { + agb: t.agb, + widerrufsbelehrung: t.widerrufsbelehrung, + widerrufsformular: t.widerrufsformular, + versandUndZahlung: versandUndZahlung(ctx.shop.einstellungen), + impressumUrl: t.impressumUrl, + datenschutzUrl: t.datenschutzUrl, + } +} + +router.get( + '/:siteKey', + route(async (req, now) => { + const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await erreichbarerShop(siteKey, now) + if (!ctx) return aktivFalse(req) + if (!zaehle(`shop:${siteKey}`, SHOP_JE_MINUTE, MINUTE_MS, now)) throw zuViele(60) + + const e = ctx.shop.einstellungen + const widerrufUrl = `${basisUrl()}/shop/${siteKey}/widerruf` + const token = formToken(siteKey, now) + + // Abgeschaltet: nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar + if (ctx.doc.state === 'disabled') { + return { + body: { + aktiv: true, + offen: false, + nurWiderruf: true, + shop: { name: e.shopname, akzent: e.akzent, anbieter: anbieterOeffentlich(e.anbieter) }, + widerrufUrl, + formToken: token, + }, + } + } + + const dokumente = await listeProdukte(ctx.shop.id) + const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: dokumente }), reserviert(ctx.shop.id, now)]) + const produkte = [] + for (const doc of dokumente) { + if (doc.aktiv === false) continue + const p = produktOeffentlich(doc, { einstellungen: e, reserviert: belegt, siteKey }) + if (p) produkte.push(p) + } + const kategorien = [...new Set(produkte.map((p) => text(p.kategorie)).filter(Boolean))] + return { + body: { + aktiv: true, + offen, + shop: shopOeffentlich(e), + kategorien, + produkte, + rechtstexte: rechtstexteOeffentlich(ctx), + gewaehrleistung: { bild: `${basisUrl()}${GEWAEHRLEISTUNG_BILD}`, text: gewaehrleistungsHinweis() }, + widerrufUrl, + formToken: token, + }, + } + }), +) + +// ---------------------------------------------------------------- Artikelbilder + +router.get( + '/:siteKey/bild/:datei', + route(async (req, now) => { + const { siteKey, datei } = req.params + const ip = ipSchluessel(req.ip) + if (!zaehle(`ip:shop-bild:${ip}`, BILD_JE_IP, MINUTE_MS, now)) throw zuViele(60) + const format = BILD_RE.exec(datei) + const ctx = format ? await erreichbarerShop(siteKey, now) : null + if (!ctx || ctx.doc.state !== 'active') throw nichtGefunden() // ohne Hinweis, was nicht stimmt + if (!zaehle(`shop-bild:${siteKey}`, BILD_JE_SHOP, MINUTE_MS, now)) throw zuViele(60) + + // Das Bild muss zu einem aktiven Artikel dieses Shops gehoeren + const [, bildId, groesse] = format + const gehoert = (await listeProdukte(ctx.shop.id)).some((doc) => doc.aktiv !== false && (parseProdukt(doc)?.bilder ?? []).includes(bildId)) + const pfad = gehoert && (await bildMeta(siteKey, bildId)) ? bildPfad(siteKey, bildId, groesse) : null + if (!pfad) throw nichtGefunden() + return { datei: pfad, cache: 'public, max-age=86400' } + }), +) + +// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers + +router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN })) + +// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers +router.use((err, req, res, _next) => { + if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS }) + if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG }) + return schreibeSicher(req, res, fehlerErgebnis(err)) +}) + +export default router diff --git a/server/services/shop/token.js b/server/services/shop/token.js new file mode 100644 index 0000000..307d2f7 --- /dev/null +++ b/server/services/shop/token.js @@ -0,0 +1,64 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' + +/** + * Formular-Token des Webshops (Plan 2026-10-05-webshop.md, Block F): "." je siteKey, wie das + * Formular-Token des Terminplaners, aber mit eigenem Schluessel (Beschriftung 'shop-form', abgeleitet aus config.sessionSecret): + * ein Terminplaner-Token gilt im Shop nicht und umgekehrt. Das Token beweist nur Alter und Herkunft (kein Geheimnis fuer den + * Besucher). Zu schnell abgeschickt (config.shop.formMinSekunden, Standard 3) = Bot, aelter als 2 Stunden = Seite neu laden. + */ + +const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000 +const FORM_TOKEN_MAX_LAENGE = 100 +const MAC_RE = /^[A-Za-z0-9_-]{43}$/ +const ZEIT_RE = /^[0-9a-z]{1,13}$/ +const MIN_STANDARD_SEKUNDEN = 3 + +/** Mindestalter in Sekunden; wird bei jedem Aufruf gelesen (Tests aendern den Wert). */ +export const formMinSekunden = () => { + const wert = config.shop?.formMinSekunden + return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN +} + +function schluessel() { + const geheimnis = config.sessionSecret + if (typeof geheimnis !== 'string' || geheimnis.length < 32) { + throw new Error('Shop-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).') + } + return createHmac('sha256', geheimnis).update('shop-form').digest() +} + +const zeitMs = (now, was) => { + const ms = now instanceof Date ? now.getTime() : Number(now) + if (!Number.isFinite(ms) || ms < 0) throw new TypeError(`${was}: now ist kein gueltiger Zeitpunkt`) + return ms +} + +function formMac(siteKey, zeit) { + return createHmac('sha256', schluessel()).update(`${siteKey}|${zeit}`).digest('base64url') +} + +/** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */ +export function formToken(siteKey, now = new Date()) { + if (typeof siteKey !== 'string' || siteKey === '') throw new TypeError('formToken: siteKey fehlt') + const zeit = zeitMs(now, 'formToken').toString(36) + return `${zeit}.${formMac(siteKey, zeit)}` +} + +/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */ +export function pruefeFormToken(siteKey, token, now = new Date()) { + if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig' + if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig' + const teile = token.split('.') + if (teile.length !== 2) return 'ungueltig' + const [zeit, mac] = teile + if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig' + const erwartet = Buffer.from(formMac(siteKey, zeit)) + const gegeben = Buffer.from(mac) + if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig' + + const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36) + if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen' + if (alter < formMinSekunden() * 1000) return 'zu_schnell' + return 'ok' +} diff --git a/test/publicShop.test.js b/test/publicShop.test.js new file mode 100644 index 0000000..f95e9d6 --- /dev/null +++ b/test/publicShop.test.js @@ -0,0 +1,427 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import http from 'node:http' +import path from 'node:path' +import express from 'express' +import sharp from 'sharp' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Oeffentliche Schnittstelle des Webshops, lesender Teil (F1): Konfiguration samt Artikeln und Artikelbilder. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + }, + datetimes: SHOP_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 { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const K = await import('../server/services/shop/kontext.js') +const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash, GEWAEHRLEISTUNG_BILD } = await import('../server/services/shop/rechtstexte.js') +const { formToken, pruefeFormToken } = await import('../server/services/shop/token.js') +const { darfAnfrage, resetTermineLimitsForTests, zaehle } = await import('../server/services/termine/limits.js') +const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/shop/v1': router }) +const uploadDir = mkdtempSync(path.join(tmpdir(), 'shop-public-')) +after(() => Promise.all([app.close(), aw.close()]).then(() => rmSync(uploadDir, { recursive: true, force: true }))) + +const BASIS = '/api/public/shop/v1' +const JETZT = new Date('2026-10-05T12:00:00.000Z') +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_shop' +const SITE_KEY = 'abcdefghijklmnop' +const BILD_ID = 'b1b1b1b1-aaaa-bbbb-cccc-111122223333' +const FREMD_BILD = 'f0f0f0f0-aaaa-bbbb-cccc-111122223333' +const VORHER = { + mode: config.shop.mode, + pilot: [...config.shop.pilotEmails], + uploadDir: config.shop.uploadDir, + basis: config.publicBaseUrl, + formMin: config.shop.formMinSekunden, +} + +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Brot & Mehr', + akzent: '#aa3300', + anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123', ustId: 'DE123456789' }, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 }, + abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, + zahlungsarten: ['card', 'sepa_debit'], + mindestbestellCent: 1000, + kleinunternehmer: false, +} +const TEXTE = { + agb: 'AGB-Text', + widerrufsbelehrung: 'Widerruf-Text', + widerrufsformular: 'Formular-Text', + impressumUrl: 'https://brot.example/impressum', + datenschutzUrl: 'https://brot.example/datenschutz', +} + +const featureDoc = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over }) +const shopDoc = (over = {}) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: SITE_KEY, + anfrageStatus: 'angenommen', + stripeAccountId: 'acct_geheim123', + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(EINSTELLUNGEN), + rechtstexte: JSON.stringify({ + entwurf: { ...TEXTE, agb: 'ENTWURF-AGB' }, + bestaetigt: { texte: TEXTE, hash: rechtstexteHash(TEXTE, EINSTELLUNGEN), at: '2026-10-05T10:00:00.000Z', von: 'kunde@example.invalid' }, + }), + geoeffnet: true, + version: 3, + ...over, +}) +let zaehler = 0 +const artikel = (id, over = {}, produktOver = {}) => { + const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 450, ...produktOver }, { einstellungen: EINSTELLUNGEN }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: ++zaehler, name: r.produkt.name, produkt: JSON.stringify({ ...r.produkt, ...(produktOver.bilder ? { bilder: produktOver.bilder } : {}) }), ...over } +} + +async function aufbau({ feature = {}, shop = {}, produkte, kunde = { email: 'inhaber@brot.example' } } = {}) { + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'customers']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + } + K.leereCache() + resetTermineLimitsForTests() + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen' }) + if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde }) + aw.seed('portalFeatures', featureDoc(feature)) + if (shop !== null) aw.seed('shopConfig', shopDoc(shop)) + const liste = produkte ?? [ + artikel('a1', {}, { kategorie: 'Brot', bilder: [BILD_ID] }), + artikel('a2', {}, { name: 'Zweites Brot', kategorie: 'Brot', bestand: 3 }), + artikel('a3', {}, { name: 'Kuchen', kategorie: 'Süßes' }), + artikel('a4', { aktiv: false }, { name: 'Verstecktes', kategorie: 'Geheim' }), + artikel('a5', {}, { name: 'Ohne Kategorie' }), + ] + for (const p of liste) aw.seed('shopProducts', p) +} + +const kopfzeile = (res, name) => res.headers.get(name) +let ip +let n = 0 +const api = (pfad, { ip: adresse = ip, headers = {} } = {}) => send('GET', BASIS + pfad, { headers: { 'x-forwarded-for': adresse, ...headers } }) + +// Nichts Internes in irgendeiner Antwort +const VERBOTEN = ['customerId', 'projectId', 'featureId', 'acct_', 'stripeAccountId', 'stripeStatus', '$id', '$collectionId', '$createdAt', 'inhaber@brot.example', KUNDE, PROJEKT, FEATURE, 'DE123456789', 'ENTWURF-AGB'] +const ohneInternes = (body) => { + const s = JSON.stringify(body) + for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`) +} + +beforeEach(async () => { + zaehler = 0 + ip = `10.1.${Math.floor(++n / 250)}.${(n % 250) + 1}` + config.shop.mode = 'on' + config.shop.pilotEmails = [] + config.shop.uploadDir = uploadDir + config.publicBaseUrl = 'https://portal.example' + setzeUhrFuerTests(() => JETZT) + await aufbau() +}) +afterEach(() => { + resetPublicShopForTests() + config.shop.mode = VORHER.mode + config.shop.pilotEmails = VORHER.pilot + config.shop.uploadDir = VORHER.uploadDir + config.publicBaseUrl = VORHER.basis + config.shop.formMinSekunden = VORHER.formMin +}) + +test('GET /:siteKey: Shop offen, Aufbau der Antwort und Kopfzeilen', async () => { + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 200) + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + assert.equal(kopfzeile(res, 'cache-control'), 'no-store') + assert.equal(kopfzeile(res, 'x-content-type-options'), 'nosniff') + const b = res.body + assert.equal(b.aktiv, true) + assert.equal(b.offen, true) + assert.equal(b.nurWiderruf, undefined) + assert.equal(b.shop.name, 'Brot & Mehr') + assert.equal(b.shop.akzent, '#aa3300') + assert.deepEqual(b.shop.anbieter, { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '0631 123' }) + assert.deepEqual(b.shop.lieferlaender, [{ code: 'DE', name: 'Deutschland' }, { code: 'AT', name: 'Österreich' }]) + assert.deepEqual(b.shop.abholung, { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }) + assert.equal(b.shop.lieferzeit, EINSTELLUNGEN.lieferzeit) + assert.equal(b.shop.zahlungsarten, 'Kreditkarte, Apple Pay, Google Pay, SEPA-Lastschrift') + assert.deepEqual(b.shop.zahlungsartenListe, ['Kreditkarte, Apple Pay, Google Pay', 'SEPA-Lastschrift']) + assert.equal(b.shop.versandText, 'Versand nach Deutschland 4,90 €, Österreich 9,90 €, ab 50,00 € versandkostenfrei') + assert.equal(b.shop.mindestbestellText, 'Mindestbestellwert 10,00 €') + assert.equal(b.shop.kleinunternehmer, false) + assert.equal(b.shop.steuerHinweisKurz, 'Alle Preise inkl. MwSt.') + assert.deepEqual(b.kategorien, ['Brot', 'Süßes']) + assert.deepEqual(b.produkte.map((p) => p.id), ['a1', 'a2', 'a3', 'a5']) + assert.equal(b.produkte[0].preisText, '4,50 €') + assert.deepEqual(b.produkte[0].bilder, [{ klein: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-s.webp`, gross: `${BASIS}/${SITE_KEY}/bild/${BILD_ID}-l.webp` }]) + assert.equal(b.rechtstexte.agb, 'AGB-Text') + assert.equal(b.rechtstexte.widerrufsbelehrung, 'Widerruf-Text') + assert.equal(b.rechtstexte.widerrufsformular, 'Formular-Text') + assert.match(b.rechtstexte.versandUndZahlung, /Liefergebiet/) + assert.equal(b.rechtstexte.impressumUrl, 'https://brot.example/impressum') + assert.equal(b.rechtstexte.datenschutzUrl, 'https://brot.example/datenschutz') + assert.equal(b.gewaehrleistung.bild, `https://portal.example${GEWAEHRLEISTUNG_BILD}`) + assert.match(b.gewaehrleistung.text, /GESETZLICHE GEWÄHRLEISTUNG/) + assert.equal(b.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`) + ohneInternes(b) +}) + +test('Kleinunternehmer-Hinweis, nur Abholung, ohne Mindestbestellwert', async () => { + const e = { ...EINSTELLUNGEN, kleinunternehmer: true, mindestbestellCent: null, versand: { ...EINSTELLUNGEN.versand, aktiv: false } } + await aufbau({ shop: { einstellungen: JSON.stringify(e) } }) + const b = (await api(`/${SITE_KEY}`)).body + assert.equal(b.shop.steuerHinweisKurz, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(b.shop.kleinunternehmer, true) + assert.deepEqual(b.shop.lieferlaender, []) + assert.equal(b.shop.versandText, 'Nur Abholung') + assert.equal(b.shop.mindestbestellText, '') +}) + +test('Formular-Token der Antwort gehoert zum siteKey und ist nach der Wartezeit gueltig', async () => { + const b = (await api(`/${SITE_KEY}`)).body + assert.match(b.formToken, /^[0-9a-z]+\.[A-Za-z0-9_-]{43}$/) + const spaeter = new Date(JETZT.getTime() + 10_000) + assert.equal(pruefeFormToken(SITE_KEY, b.formToken, spaeter), 'ok') + assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 1000)), 'zu_schnell') + assert.equal(pruefeFormToken('zzzzzzzzzzzzzzzz', b.formToken, spaeter), 'ungueltig') + assert.equal(pruefeFormToken(SITE_KEY, `${b.formToken}x`, spaeter), 'ungueltig') + assert.equal(pruefeFormToken(SITE_KEY, b.formToken, new Date(JETZT.getTime() + 3 * 3600_000)), 'abgelaufen') + config.shop.formMinSekunden = 0 + assert.equal(pruefeFormToken(SITE_KEY, formToken(SITE_KEY, JETZT), JETZT), 'ok') +}) + +test('Reservierungen offener Bestellungen mindern den Bestand in der Antwort', async () => { + const bestand = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2') + assert.equal(bestand.verfuegbar, true) + aw.seed('shopOrders', { + $id: 'o_1', + shopId: FEATURE, + status: 'offen', + ablaufAt: new Date(JETZT.getTime() + 10 * 60_000).toISOString(), + bestellung: JSON.stringify({ positionen: [{ produktId: 'a2', menge: 3 }] }), + }) + const ausverkauft = (await api(`/${SITE_KEY}`)).body.produkte.find((p) => p.id === 'a2') + assert.equal(ausverkauft.verfuegbar, false) +}) + +test('noch keine bestaetigten Rechtstexte: rechtstexte null, Shop geschlossen', async () => { + await aufbau({ shop: { rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: null }) } }) + const b = (await api(`/${SITE_KEY}`)).body + assert.equal(b.aktiv, true) + assert.equal(b.offen, false) + assert.equal(b.rechtstexte, null) + assert.ok(b.produkte.length > 0) +}) + +test('nicht geoeffnet (Haendler hat nicht geoeffnet): offen false', async () => { + await aufbau({ shop: { geoeffnet: false } }) + const b = (await api(`/${SITE_KEY}`)).body + assert.equal(b.aktiv, true) + assert.equal(b.offen, false) +}) + +test('abgeschalteter Shop (disabled): nur Widerruf, keine Artikel', async () => { + await aufbau({ feature: { state: 'disabled' } }) + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body).sort(), ['aktiv', 'formToken', 'nurWiderruf', 'offen', 'shop', 'widerrufUrl']) + assert.equal(res.body.aktiv, true) + assert.equal(res.body.offen, false) + assert.equal(res.body.nurWiderruf, true) + assert.deepEqual(Object.keys(res.body.shop).sort(), ['akzent', 'anbieter', 'name']) + assert.equal(res.body.shop.name, 'Brot & Mehr') + assert.equal(res.body.widerrufUrl, `https://portal.example/shop/${SITE_KEY}/widerruf`) + ohneInternes(res.body) +}) + +test('aktiv:false ohne Orakel: unbekannt, ungueltig, falscher Zustand, Modus off', async () => { + const nein = async (pfad) => { + const res = await api(pfad) + assert.equal(res.status, 200, pfad) + assert.deepEqual(res.body, { aktiv: false }, pfad) + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + } + await nein('/zzzzzzzzzzzzzzzz') // unbekannt + await nein('/abc') // kein siteKey + await nein('/ABCDEFGHIJKLMNOP') // falsche Schreibweise + await aufbau({ feature: { state: 'ordered' } }) + await nein(`/${SITE_KEY}`) + await aufbau({ feature: { state: 'off' } }) + await nein(`/${SITE_KEY}`) + await aufbau({ shop: null }) // kein shopConfig + await nein(`/${SITE_KEY}`) + await aufbau() + config.shop.mode = 'off' + await nein(`/${SITE_KEY}`) + config.shop.mode = 'on' + assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) +}) + +test('Pilotmodus: nur Besitzer auf der Pilotliste', async () => { + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['andere@example.com'] + assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false }) + K.leereCache() + config.shop.pilotEmails = ['inhaber@brot.example'] + assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) + K.leereCache() + config.shop.pilotEmails = ['*'] + assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) + // Kunde ohne Dokument: nie freigeschaltet + await aufbau({ kunde: null }) + config.shop.pilotEmails = ['inhaber@brot.example'] + assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false }) + // im Pilotmodus gilt dasselbe fuer die Bilder + await aufbau() + config.shop.pilotEmails = ['andere@example.com'] + assert.equal((await api(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)).status, 404) +}) + +// ---------------------------------------------------------------- Bilder + +async function legeBildAb(id = BILD_ID, siteKey = SITE_KEY) { + const ordner = path.join(uploadDir, siteKey) + mkdirSync(ordner, { recursive: true }) + const webp = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#cc3300' } }).webp().toBuffer() + writeFileSync(path.join(ordner, `${id}-s.webp`), webp) + writeFileSync(path.join(ordner, `${id}-l.webp`), webp) + writeFileSync(path.join(ordner, `${id}.json`), JSON.stringify({ customerId: KUNDE, shopId: FEATURE, createdAt: JETZT.toISOString(), claimed: true, w: 4, h: 4 })) + return webp +} + +test('Bild: Auslieferung mit Cache, nosniff und CORS', async () => { + const webp = await legeBildAb() + for (const g of ['s', 'l']) { + const res = await fetch(`${app.url}${BASIS}/${SITE_KEY}/bild/${BILD_ID}-${g}.webp`, { headers: { 'x-forwarded-for': ip } }) + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'image/webp') + assert.equal(res.headers.get('cache-control'), 'public, max-age=86400') + assert.equal(res.headers.get('x-content-type-options'), 'nosniff') + assert.equal(res.headers.get('access-control-allow-origin'), '*') + assert.deepEqual(Buffer.from(await res.arrayBuffer()), webp) + } +}) + +test('Bild: 404 bei fremdem, unbekanntem, inaktivem Artikel, schlechtem Namen und fehlender Datei', async () => { + await legeBildAb() + await legeBildAb(FREMD_BILD) // liegt im Ordner, gehoert aber zu keinem Artikel + const vier = async (pfad) => { + const res = await api(pfad) + assert.equal(res.status, 404, pfad) + assert.deepEqual(res.body, { fehler: { _: 'Nicht gefunden' } }, pfad) + assert.equal(kopfzeile(res, 'cache-control'), 'no-store') + return res + } + await vier(`/${SITE_KEY}/bild/${FREMD_BILD}-s.webp`) + await vier(`/${SITE_KEY}/bild/${BILD_ID}-m.webp`) // Groesse + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.png`) + await vier(`/${SITE_KEY}/bild/..%2F${BILD_ID}-s.webp`) + await vier(`/${SITE_KEY}/bild/${BILD_ID.toUpperCase()}-s.webp`) + await vier(`/zzzzzzzzzzzzzzzz/bild/${BILD_ID}-s.webp`) // fremder siteKey + // Artikel inaktiv + await aufbau({ produkte: [artikel('a1', { aktiv: false }, { bilder: [BILD_ID] })] }) + await legeBildAb() + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`) + // Artikel mit Bild, aber Datei fehlt (kein Sidecar) + await aufbau() + rmSync(path.join(uploadDir, SITE_KEY), { recursive: true, force: true }) + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`) + // abgeschalteter Shop liefert keine Bilder + await legeBildAb() + await aufbau({ feature: { state: 'disabled' } }) + await legeBildAb() + await vier(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`) +}) + +// ---------------------------------------------------------------- Grenzen + +test('Grenze je IP: 120 je Minute, danach 429 mit Retry-After', async () => { + for (let i = 0; i < 120; i++) assert.equal(darfAnfrage(ip, JETZT), true) + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 429) + assert.equal(kopfzeile(res, 'retry-after'), '60') + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + assert.deepEqual(Object.keys(res.body), ['fehler']) + // andere IP ist nicht betroffen + assert.equal((await api(`/${SITE_KEY}`, { ip: '203.0.113.9' })).status, 200) +}) + +test('Grenze je Shop: 600 je Minute ueber alle IPs, unbekannte Shops zaehlen nicht', async () => { + for (let i = 0; i < 600; i++) assert.equal(zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT), true) + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 429) + assert.equal(kopfzeile(res, 'retry-after'), '60') + // unbekannter Shop: weiter aktiv:false statt 429 (kein Zaehler je unbekanntem Schluessel) + assert.deepEqual((await api('/zzzzzzzzzzzzzzzz')).body, { aktiv: false }) +}) + +test('Fenster laeuft ab: nach einer Minute ist die Grenze weg', async () => { + for (let i = 0; i < 600; i++) zaehle(`shop:${SITE_KEY}`, 600, 60_000, JETZT) + assert.equal((await api(`/${SITE_KEY}`)).status, 429) + setzeUhrFuerTests(() => new Date(JETZT.getTime() + 61_000)) + assert.equal((await api(`/${SITE_KEY}`)).status, 200) +}) + +// ---------------------------------------------------------------- Rest + +test('unbekannter Pfad: 404 ohne Rohtext', async () => { + const r1 = await api(`/${SITE_KEY}/gibtsnicht`) + assert.equal(r1.status, 404) + assert.deepEqual(r1.body, { fehler: { _: 'Nicht gefunden' } }) + assert.equal(kopfzeile(r1, 'x-content-type-options'), 'nosniff') +}) + +test('eigener Parser: kaputter Rumpf 400, zu grosser Rumpf 413 (Router allein, ohne globalen Parser)', async () => { + const server = http.createServer(express().use('/api/public/shop/v1', router)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${server.address().port}${BASIS}/${SITE_KEY}` + try { + const kaputt = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{kaputt' }) + assert.equal(kaputt.status, 400) + assert.deepEqual(await kaputt.json(), { fehler: { _: 'Ungültige Anfrage.' } }) + assert.equal(kaputt.headers.get('x-content-type-options'), 'nosniff') + const gross = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ x: 'a'.repeat(20_000) }) }) + assert.equal(gross.status, 413) + assert.deepEqual(await gross.json(), { fehler: { _: 'Die Anfrage ist zu groß.' } }) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + } +}) + +test('Fehler der Datenhaltung: 503 ohne Rohtext', async () => { + // Datenhaltung nicht erreichbar: der Fehler (mit Adresse) gelangt nie nach aussen + const alt = config.appwrite.endpoint + config.appwrite.endpoint = 'http://127.0.0.1:1/v1' + try { + const res = await api(`/${SITE_KEY}`) + assert.equal(res.status, 503) + assert.deepEqual(res.body, { fehler: { _: 'Gerade nicht erreichbar' } }) + assert.equal(kopfzeile(res, 'access-control-allow-origin'), '*') + } finally { + config.appwrite.endpoint = alt + } +})