Shop C7: Kundenrouten der Verwaltung (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder)

- routes/shop.js: gemeinsame Pruefung mitShop (Schalter, eigenes Projekt, Funktion setup|active, lesend auch disabled, shopConfig
  bei Bedarf anlegen); GET /status erweitert (Bereitschaft, geoeffnet, Snippet, Vorschau, Stripe ohne Konto-ID, Zahlen);
  Einstellungen, Artikel (mit Bild-Pruefung, Beanspruchen und Aufraeumen), Sortierung, Rechtstexte (Entwurf, Bestaetigen mit Hash),
  Oeffnen/Schliessen, Bild-Upload (octet-stream, 8 MB) und Vorschau; Schreib-Limit 300 je Stunde und Kunde
- services/shop/bilder.js: Artikelbilder nach dem Muster von newsImages.js (1600/480 px WebP, Sidecar, 24-h-Aufraeumen, 40 Uploads/h)
- Kundenansicht: sieben Verwaltungs-Pfade erlaubt und als shop_aenderung protokolliert; Anfrage, Oeffnen, Bestaetigen, Stripe bleiben gesperrt
- Tests: shopRoutes, shopBilder; Erwartungen von kundenansichtGuard, customerViewLog und shopAnfrage angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:52:53 +00:00
1 parent 9d19a2cbad
commit 9722f0a938
10 files changed
+1388 -31

No files matched your search

+10
View File
@@ -24,6 +24,16 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
// Webshop: Artikel, Einstellungen, Rechtstexte-Entwurf und Bilder darf das Team mit dem Kunden ausfuellen (protokolliert). Genau diese
// sieben Pfade, jeder einzeln: Anfrage, Oeffnen, Bestaetigen der Rechtstexte (der Haendler bestaetigt persoenlich), Stripe und
// Erstattungen bleiben gesperrt, ohne dass es einen Eintrag braucht.
['POST', /^\/api\/shop\/produkte\/?$/],
['POST', /^\/api\/shop\/produkte\/sortierung\/?$/],
['PUT', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['DELETE', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['PUT', /^\/api\/shop\/einstellungen\/?$/],
['PUT', /^\/api\/shop\/rechtstexte\/?$/],
['POST', /^\/api\/shop\/bild\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
+404 -25
View File
@@ -1,30 +1,55 @@
import { Router } from 'express'
import express, { Router } from 'express'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { countDocuments, getDocument, listAllDocuments, Query, updateDocument } from '../services/appwriteAdmin.js'
import { config } from '../config.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { withLocks } from '../services/keyedLock.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
import { shopFuerKunde } from '../services/shop/schalter.js'
import { uploadSlot } from './chat.js'
import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js'
import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js'
import {
bildIdsInGebrauch, holeProdukt, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt,
} from '../services/shop/produkte.js'
import {
BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular,
normalisiereRechtstexte, rechtstexteHash, versandUndZahlung, vollstaendig,
} from '../services/shop/rechtstexte.js'
import {
BILD_MAX_BYTES, beanspruche, bildLimitErreicht, bildMeta, bildPfad, loescheBild, setzeBildLimitZurueck, speichereBild,
} from '../services/shop/bilder.js'
/**
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Dieser Stand (Task B1) enthaelt
* die Anfrage: GET /status, POST /anfrage, POST /anfrage/zurueckziehen. Weitere Routen kommen mit C7, D2 und E5.
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und
* Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe (D2) und Bestellungen (E) kommen dazu.
*
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Schreib-Routen sind in der Kundenansicht gesperrt
* (kundenansichtGuard: nicht auf der Erlaubnisliste; hier zusaetzlich 403) und zaehlen gegen 20 Aufrufe je Kunde und Stunde.
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter
* (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an).
* Kundenansicht: Artikel, Einstellungen, Rechtstexte (nur Entwurf speichern) und Bilder darf das Team mit ausfuellen und werden
* protokolliert (kundenansichtGuard); Anfrage, Oeffnen und Bestaetigen der Rechtstexte bleiben dem Kunden vorbehalten (hier 403).
* Schreib-Routen zaehlen gegen 300 Aufrufe je Kunde und Stunde (Anfrage-Routen gegen 20), Bilder zusaetzlich gegen 40 je Stunde.
* Fehler: { error: <Text>, fehler?: { <feld>: Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie.
*/
const router = Router()
const MAX_WRITES_PER_HOUR = 20
const MAX_ANFRAGE_WRITES_PER_HOUR = 20
const MAX_WRITES_PER_HOUR = 300
const PRODUKT_DOC_MAX_JSON = 100000
const BILD_DATEI_RE = /^([a-z0-9-]{1,64})-(l|s)\.webp$/
const MELDUNG = {
projekt: 'Bitte wähle ein Projekt.',
fremd: 'Kein Zugriff auf dieses Projekt.',
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.',
nichtEingerichtet: 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.',
zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.',
bildFehlt: 'Bild nicht gefunden.',
eingabe: 'Die Eingabe fehlt oder ist unlesbar.',
}
/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */
@@ -35,7 +60,12 @@ function fail(res, err) {
return res.status(500).json({ error: 'Interner Serverfehler' })
}
const body = { ...err.body, error: err.message }
if (err.fehler && typeof err.fehler === 'object') body.fehler = err.fehler
if (err.fehler && typeof err.fehler === 'object') {
body.fehler = err.fehler
const erster = Object.values(err.fehler).find((t) => typeof t === 'string')
if (erster) body.error = erster
}
if (typeof err.code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(err.code) && body.code === undefined) body.code = err.code
return res.status(err.status).json(body)
}
@@ -47,11 +77,15 @@ const route = (fn) => async (req, res, next) => {
}
}
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR })
const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetShopRoutesForTests() {
limiter.reset()
anfrageLimiter.reset()
schreibLimiter.reset()
setzeBildLimitZurueck()
}
const nurKunde = (req, res, next) => {
@@ -61,7 +95,7 @@ const nurKunde = (req, res, next) => {
const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' })
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
const raw = req.params?.projectId ?? req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
@@ -70,9 +104,57 @@ router.use((_req, res, next) => {
next()
})
const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt']
const BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen']
/** Stripe-Status fuer den Browser: nur bekannte Felder, nie Konto-ID oder Schluessel. */
function stripeAnsicht(shop) {
const roh = shop?.stripeStatus
const status = roh ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(roh, k)).map((k) => [k, roh[k]])) : null
return { verbunden: Boolean(shop?.stripeAccountId), status }
}
/** Kennzahlen der Uebersicht (nur Anzeige): Artikel und, best effort, Bestellungen. Ein Lesefehler bei den Bestellungen zaehlt als 0. */
async function kennzahlen(shopId, produkte, now = new Date()) {
const zahlen = {
artikel: produkte.length,
aktiveArtikel: produkte.filter((d) => d.aktiv !== false).length,
offeneBestellungen: 0,
bestellungen30: 0,
umsatz30Cent: 0,
}
try {
zahlen.offeneBestellungen = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')])
const seit = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const neu = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.greaterThanEqual('erstelltAt', seit.toISOString())])
for (const o of neu) {
if (!BEZAHLT.includes(o.status)) continue
zahlen.bestellungen30 += 1
if (Number.isInteger(o.summeCent)) zahlen.umsatz30Cent += o.summeCent
}
} catch (err) {
console.warn(`[shop] Kennzahlen der Bestellungen nicht lesbar: ${String(err?.message ?? '').slice(0, 120)}`)
}
return zahlen
}
const einbauSnippet = (siteKey) => `<div data-webklar-shop="${siteKey}"></div>\n<script src="${config.publicBaseUrl}/embed/shop.js" defer></script>`
const vorschauUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}`
const widerrufUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}/widerruf`
async function ticketWoid(ticketId) {
if (!ticketId) return null
try {
return (await getDocument(config.collections.workorders, ticketId))?.woid || null
} catch {
return null
}
}
/**
* Stand des Webshops fuer ein Projekt: { verfuegbar, state, anfrage }. verfuegbar = Schalter fuer diesen Kunden;
* state = Zustand der Funktion (off, wenn es noch kein Dokument gibt); anfrage = anfrageAnsicht oder null. Legt nichts an.
* Stand des Webshops fuer ein Projekt. verfuegbar = Schalter fuer diesen Kunden; state = Zustand der Funktion (off ohne Dokument);
* anfrage = anfrageAnsicht oder null; mit shopConfig dazu: ticketWoid, bereitschaft ({ bereit, punkte }), geoeffnet (verkauft der Shop
* gerade), schalter (Schalter des Kunden), siteKey, snippet, vorschauUrl, stripe ({ verbunden, status }), zahlen. Legt nichts an.
*/
router.get('/status', requireSession, route(async (req, res) => {
const projectId = projectIdOf(req)
@@ -85,18 +167,38 @@ router.get('/status', requireSession, route(async (req, res) => {
}
const verfuegbar = shopFuerKunde(req.session?.email)
const feature = await getFeatureDoc(customerId, projectId, 'shop')
let shop = null
if (feature) {
try {
shop = await getDocument(config.collections.shopConfig, feature.$id)
} catch (err) {
if (!(err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found')) throw err
const shopDoc = feature ? await leseShopConfig(feature.$id) : null
const antwort = {
verfuegbar,
state: feature?.state || 'off',
ticketWoid: null,
anfrage: shopDoc ? anfrageAnsicht(shopDoc) : null,
bereitschaft: null,
geoeffnet: false,
schalter: false,
siteKey: feature?.siteKey || null,
snippet: null,
vorschauUrl: null,
stripe: { verbunden: false, status: null },
zahlen: null,
}
if (feature && shopDoc) {
const shop = ladeShop(shopDoc)
const produkte = await listeProdukte(shop.id)
const ctx = { doc: feature, shop, project: null }
antwort.ticketWoid = await ticketWoid(feature.ticketId)
antwort.bereitschaft = await bereitschaft(ctx, { produkte })
antwort.geoeffnet = await istGeoeffnet(ctx, { produkte })
antwort.schalter = shop.geoeffnet
antwort.snippet = einbauSnippet(shop.siteKey || feature.siteKey)
antwort.vorschauUrl = vorschauUrl(shop.siteKey || feature.siteKey)
antwort.stripe = stripeAnsicht(shop)
antwort.zahlen = await kennzahlen(shop.id, produkte)
}
res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null })
res.json(antwort)
}))
router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => {
router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
const anfrage = await stelleAnfrage({
customerId: getSessionCustomerId(req),
projectId: projectIdOf(req),
@@ -106,9 +208,286 @@ router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req,
res.json({ anfrage })
}))
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) })
res.json({ anfrage })
}))
// ---------------------------------------------------------------- Verwaltung (C7)
const stop = (status, text, code, fehlerFelder) =>
Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}), ...(fehlerFelder ? { fehler: fehlerFelder } : {}) })
const eingabeFehler = (fehlerFelder) => stop(400, Object.values(fehlerFelder)[0], undefined, fehlerFelder)
const personLabel = (p) => (p.name ? `${p.name}${p.email ? ` <${p.email}>` : ''}` : p.email || 'Kunde')
/**
* Gemeinsame Pruefung der Verwaltungs-Routen: projectId, eigenes Projekt (403; archiviert nur lesend), Schalter (409
* nicht_verfuegbar), Funktion im Zustand zustaende (Vorgabe: schreibend setup|active, lesend setup|active|disabled; sonst 409
* nicht_eingerichtet) und shopConfig (fehlt es und der Zustand ist setup|active, wird es angelegt).
* @returns {Promise<{ customerId: string, projectId: string, project: object, doc: object, shopDoc: object, shop: object }>}
*/
async function mitShop(req, { schreiben = false, zustaende } = {}) {
const projectId = projectIdOf(req)
if (!projectId) throw stop(400, MELDUNG.projekt)
const customerId = getSessionCustomerId(req)
let project
try {
project = await assertOwnProject(customerId, projectId, { forNewOrder: schreiben })
} catch (err) {
throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err
}
if (!shopFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
const erlaubt = zustaende || (schreiben ? ['setup', 'active'] : ['setup', 'active', 'disabled'])
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc || !erlaubt.includes(doc.state)) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet')
let shopDoc = await leseShopConfig(doc.$id)
if (!shopDoc && ['setup', 'active'].includes(doc.state)) shopDoc = await ensureShopConfig(doc)
if (!shopDoc) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet')
return { customerId, projectId, project, doc, shopDoc, shop: ladeShop(shopDoc) }
}
/** Middleware: mitShop vor dem Einlesen eines Uploads; Ergebnis in req.shopCtx. */
const mitShopSchreibend = route(async (req, _res, next) => {
req.shopCtx = await mitShop(req, { schreiben: true })
next()
})
const protokoll = (req, text) => logIfKundenansicht(req, 'shop_aenderung', text)
// ---- Einstellungen
const einstellungenAntwort = (shop) => ({ einstellungen: shop.einstellungen, version: shop.version, vollstaendig: istVollstaendig(shop.einstellungen) })
router.get('/einstellungen', requireSession, route(async (req, res) => {
const { shop } = await mitShop(req)
res.json(einstellungenAntwort(shop))
}))
router.put('/einstellungen', requireSession, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const gespeichert = await speichereEinstellungen(shop.id, req.body?.einstellungen, { erwarteteVersion: Number.isInteger(req.body?.version) ? req.body.version : undefined })
await protokoll(req, 'Einstellungen gespeichert')
res.json({ einstellungen: gespeichert.einstellungen, version: gespeichert.version, vollstaendig: istVollstaendig(gespeichert.einstellungen) })
}))
// ---- Artikel
/** Artikel fuer den Haendler: alle gespeicherten Felder, dazu id, aktiv, sort und Vorschau-Adressen der Bilder. */
function produktAnsicht(doc, projectId) {
const p = parseProdukt(doc) || {}
const bilder = Array.isArray(p.bilder) ? p.bilder.filter((b) => typeof b === 'string') : []
return {
...p,
id: doc.$id,
name: typeof p.name === 'string' ? p.name : doc.name || '',
aktiv: doc.aktiv !== false,
sort: Number.isInteger(doc.sort) ? doc.sort : 0,
bilder,
bildUrls: bilder.map((id) => ({
id,
klein: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`,
gross: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-l.webp`,
})),
}
}
const bildIdsVon = (produkt) => (Array.isArray(produkt?.bilder) ? produkt.bilder.filter((b) => typeof b === 'string') : [])
/** Bilder, die nach einer Aenderung kein Artikel des Shops mehr verwendet, loeschen. */
async function raeumeBilderAuf(shop, kandidaten) {
if (kandidaten.length === 0) return
const inGebrauch = await bildIdsInGebrauch(shop.id)
for (const id of kandidaten) if (!inGebrauch.has(id)) await loescheBild(shop.siteKey, id)
}
router.get('/produkte', requireSession, route(async (req, res) => {
const { shop, projectId } = await mitShop(req)
const docs = await listeProdukte(shop.id)
res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)), maxArtikel: config.shop.maxArtikel, maxBilder: config.shop.maxBilder })
}))
/** Artikel anlegen (id null) oder aendern: normalisieren, Bilder pruefen und beanspruchen, entfernte Bilder aufraeumen. */
async function speichereArtikel(req, res, id) {
const { shop, customerId, projectId } = await mitShop(req, { schreiben: true })
const { produkt, fehler } = normalisiereProdukt(req.body?.produkt, { einstellungen: shop.einstellungen })
if (fehler) throw eingabeFehler(fehler)
const neueBilder = bildIdsVon(produkt)
const doc = await withLocks([`shop-bilder:${shop.id}`], async () => {
let vorher = []
if (id) {
const alt = await holeProdukt(shop.id, id)
if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).')
vorher = bildIdsVon(parseProdukt(alt))
}
for (const bildId of neueBilder) {
const meta = await bildMeta(shop.siteKey, bildId)
if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' })
}
const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt)
await beanspruche(shop.siteKey, neueBilder)
await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b)))
return gespeichert
})
await protokoll(req, `Artikel ${id ? 'geändert' : 'angelegt'}: ${produkt.name}`)
res.status(id ? 200 : 201).json({ produkt: produktAnsicht(doc, projectId) })
}
router.post('/produkte', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, null)))
router.post('/produkte/sortierung', requireSession, limitWrites, route(async (req, res) => {
const { shop, projectId } = await mitShop(req, { schreiben: true })
const docs = await sortiere(shop.id, req.body?.ids)
await protokoll(req, 'Artikel sortiert')
res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)) })
}))
router.put('/produkte/:id', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, req.params.id)))
router.delete('/produkte/:id', requireSession, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const name = await withLocks([`shop-bilder:${shop.id}`], async () => {
const alt = await holeProdukt(shop.id, req.params.id)
if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).')
await loescheProdukt(shop.id, req.params.id)
await raeumeBilderAuf(shop, bildIdsVon(parseProdukt(alt)))
return alt.name || ''
})
await protokoll(req, `Artikel gelöscht: ${name}`)
res.json({ ok: true })
}))
// ---- Rechtstexte
/** Antwort der Rechtstexte: Entwurf, zuletzt bestaetigte Fassung, Muster zum Zuruecksetzen, erzeugte Texte und Hash des Entwurfs. */
function rechtstexteAntwort(shop) {
const e = shop.einstellungen
const { entwurf, bestaetigt } = shop.rechtstexte
const hash = rechtstexteHash(entwurf, e)
return {
entwurf,
bestaetigt: bestaetigt ? { texte: bestaetigt.texte, at: bestaetigt.at, von: bestaetigt.von, aktuell: bestaetigt.hash === hash } : null,
muster: {
agb: musterAgb(e),
widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: widerrufUrl(shop.siteKey) }),
widerrufsformular: musterWiderrufsformular(e),
},
versandUndZahlung: versandUndZahlung(e),
gewaehrleistung: { bild: GEWAEHRLEISTUNG_BILD, text: gewaehrleistungsHinweis() },
hash,
bestaetigungText: BESTAETIGUNG_TEXT,
vollstaendig: vollstaendig(entwurf),
}
}
/** shopConfig frisch lesen (unter der Sperre) und als geparsten Shop liefern. */
async function frischerShop(shopId) {
return ladeShop(await getDocument(config.collections.shopConfig, shopId))
}
router.get('/rechtstexte', requireSession, route(async (req, res) => {
const { shop } = await mitShop(req)
res.json(rechtstexteAntwort(shop))
}))
router.put('/rechtstexte', requireSession, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const { rechtstexte, fehler } = normalisiereRechtstexte(req.body?.rechtstexte)
if (fehler) throw eingabeFehler(fehler)
const neu = await withLocks([`shop-rt:${shop.id}`], async () => {
const aktuell = await frischerShop(shop.id)
const json = JSON.stringify({ entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt })
if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.')
await updateDocument(config.collections.shopConfig, shop.id, { rechtstexte: json })
return { ...aktuell, rechtstexte: { entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt } }
})
leereCache(shop.siteKey)
await protokoll(req, 'Rechtstexte-Entwurf gespeichert')
res.json(rechtstexteAntwort(neu))
}))
router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
const { shop } = await mitShop(req, { schreiben: true })
const hash = typeof req.body?.hash === 'string' ? req.body.hash : ''
const von = personLabel(person(req))
const neu = await withLocks([`shop-rt:${shop.id}`], async () => {
const aktuell = await frischerShop(shop.id)
const { entwurf } = aktuell.rechtstexte
if (hash === '' || hash !== rechtstexteHash(entwurf, aktuell.einstellungen)) {
throw stop(409, 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', 'texte_geaendert')
}
if (!vollstaendig(entwurf)) throw stop(400, 'Bitte fülle AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz aus, bevor Du bestätigst.')
const at = new Date().toISOString()
const bestaetigt = { texte: { ...entwurf }, hash, at, von }
const json = JSON.stringify({ entwurf, bestaetigt })
if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.')
await updateDocument(config.collections.shopConfig, shop.id, {
rechtstexte: json,
rechtstexteBestaetigtAt: at,
rechtstexteBestaetigtVon: von,
rechtstexteHash: hash,
})
return { ...aktuell, rechtstexte: { entwurf, bestaetigt } }
})
leereCache(shop.siteKey)
res.json(rechtstexteAntwort(neu))
}))
// ---- Shop oeffnen und schliessen
router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
if (typeof req.body?.offen !== 'boolean') throw stop(400, 'Bitte wähle, ob der Shop geöffnet oder geschlossen sein soll.')
const { shop, doc } = await mitShop(req, { schreiben: true })
const produkte = await listeProdukte(shop.id)
const ctx = { doc, shop, project: null }
if (req.body.offen) {
const stand = await bereitschaft(ctx, { produkte })
if (!stand.bereit) throw Object.assign(stop(409, 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', 'nicht_bereit'), { body: { code: 'nicht_bereit', punkte: stand.punkte } })
}
await updateDocument(config.collections.shopConfig, shop.id, { geoeffnet: req.body.offen, geoeffnetAt: new Date().toISOString() })
leereCache(shop.siteKey)
const neu = { ...shop, geoeffnet: req.body.offen }
res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) })
}))
// ---- Bilder
// Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen
router.post(
'/bild',
requireSession,
limitWrites,
mitShopSchreibend,
(req, res, next) => {
if (bildLimitErreicht(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuVieleBilder })
return next()
},
uploadSlot,
express.raw({ type: 'application/octet-stream', limit: BILD_MAX_BYTES }),
route(async (req, res) => {
const { shop, customerId } = req.shopCtx
const bild = await speichereBild({ customerId, shopId: shop.id, siteKey: shop.siteKey, buffer: req.body })
await protokoll(req, 'Bild hochgeladen')
res.status(201).json({ imageId: bild.imageId, w: bild.w, h: bild.h })
})
)
// Vorschau im Portal: nur fuer den Kunden, dem das Projekt gehoert
router.get('/bild/:projectId/:datei', requireSession, route(async (req, res) => {
const { shop } = await mitShop(req)
const treffer = BILD_DATEI_RE.exec(req.params.datei)
const datei = treffer && (await bildMeta(shop.siteKey, treffer[1])) && bildPfad(shop.siteKey, treffer[1], treffer[2])
if (!datei) return res.status(404).json({ error: MELDUNG.bildFehlt })
res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' })
return res.sendFile(datei, (err) => {
if (err && !res.headersSent) res.status(404).json({ error: MELDUNG.bildFehlt })
})
}))
// Fehler beim Einlesen eines Uploads (zu gross, abgebrochen): verstaendlich beantworten, nie Rohtext
router.use((err, _req, res, next) => {
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Bild ist größer als 8 MB.' })
if (err?.status >= 400 && err.status < 500 && res.headersSent === false) return res.status(err.status).json({ error: 'Die Anfrage ist ungültig.' })
return next(err)
})
export default router
+1
View File
@@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [
'termin_einstellungen',
'termin_aktion',
'termin_manuell',
'shop_aenderung',
]
const DETAIL_MAX = 300
+166
View File
@@ -0,0 +1,166 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import sharp from 'sharp'
import { config } from '../../config.js'
/**
* Artikelbilder des Webshops (Plan 2026-10-05-webshop.md, Task C7; verallgemeinert aus services/newsImages.js, das unveraendert
* bleibt). Jedes Bild wird neu kodiert (WebP, laengste Kante 1600 px, dazu Vorschau 480 px); dabei fallen alle Metadaten (GPS,
* Kamera, Drehung) weg. Nur JPEG, PNG und WebP, hoechstens 8 MB.
*
* Ablage: <config.shop.uploadDir>/<siteKey>/<id>-l.webp, <id>-s.webp und Sidecar <id>.json
* { customerId, shopId, createdAt, claimed, w, h }. Der Sidecar wird zuletzt geschrieben: ohne ihn gilt ein Bild als nicht
* vorhanden. Hochgeladene, nie an einen Artikel gebundene Bilder loescht raeumeUnbeanspruchteAuf() nach 24 h.
* Fehler tragen status (400/413/415) und einen deutschen Text.
*/
export const BILD_MAX_BYTES = 8 * 1024 * 1024
const UNBEANSPRUCHT_MS = 24 * 60 * 60 * 1000
const MAX_UPLOADS_PRO_STUNDE = 40
const ID_RE = /^[a-z0-9-]{1,64}$/
const SITE_KEY_RE = /^[a-z2-7]{16}$/
// Hoechstens 40 Megapixel je Bild: ein winziges PNG/WebP kann sonst Gigabytes beim Dekodieren belegen
const MAX_INPUT_PIXELS = 40_000_000
// Fehlertexte von sharp/libvips bei kaputten, unbekannten oder zu grossen Bildern
const DECODE_ERROR_RE = /unsupported image format|pixel limit|corrupt|truncated|invalid|bad seek|bad (?:header|format)|not a|Input (?:buffer|file|Buffer)|Vips|heif|jpeg|png|webp|premature end|decode/i
const FORMATE = new Set(['jpeg', 'png', 'webp'])
const fehler = (status, text) => Object.assign(new Error(text), { status })
const idOk = (id) => typeof id === 'string' && ID_RE.test(id)
const keyOk = (siteKey) => typeof siteKey === 'string' && SITE_KEY_RE.test(siteKey)
const wurzel = () => path.resolve(config.shop.uploadDir)
const ordner = (siteKey) => path.join(wurzel(), siteKey)
const sidecar = (siteKey, id) => path.join(ordner(siteKey), `${id}.json`)
/** Pfad zu einer Bilddatei ('l' gross, 's' Vorschau) oder null bei unsauberem siteKey, unsauberer ID oder Groesse. */
export function bildPfad(siteKey, imageId, groesse) {
if (!keyOk(siteKey) || !idOk(imageId)) return null
if (groesse !== 'l' && groesse !== 's') return null
return path.join(ordner(siteKey), `${imageId}-${groesse}.webp`)
}
/**
* Bild kodieren und ablegen.
* @returns {Promise<{ imageId: string, w: number, h: number }>}
*/
export async function speichereBild({ customerId, shopId, siteKey, buffer }) {
if (!keyOk(siteKey)) throw fehler(400, 'Ungültiger Shop.')
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw fehler(415, 'Bitte lade ein Foto hoch (JPEG, PNG oder WebP).')
if (buffer.length > BILD_MAX_BYTES) throw fehler(413, 'Das Bild ist größer als 8 MB.')
const nichtUnterstuetzt = () => fehler(415, 'Dieses Dateiformat können wir nicht verwenden. Bitte lade ein JPEG, PNG oder WebP hoch.')
let gross, klein
try {
const opts = { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS }
const meta = await sharp(buffer, opts).metadata()
if (!FORMATE.has(meta.format)) throw nichtUnterstuetzt()
// rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten
gross = await sharp(buffer, opts).rotate()
.resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 }).toBuffer({ resolveWithObject: true })
// Vorschau aus dem schon verkleinerten Ergebnis (spart eine Dekodierung des Originals)
klein = await sharp(gross.data)
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 75 }).toBuffer()
} catch (err) {
if (err.status) throw err
if (DECODE_ERROR_RE.test(String(err && err.message))) throw nichtUnterstuetzt()
console.error(`[shop] Bild-Verarbeitung fehlgeschlagen: ${String(err?.message ?? '').slice(0, 200)}`)
throw err
}
const imageId = randomUUID()
const { width: w, height: h } = gross.info
await mkdir(ordner(siteKey), { recursive: true })
await writeFile(bildPfad(siteKey, imageId, 'l'), gross.data)
await writeFile(bildPfad(siteKey, imageId, 's'), klein)
await schreibeSidecar(siteKey, imageId, { customerId, shopId, createdAt: new Date().toISOString(), claimed: false, w, h })
return { imageId, w, h }
}
async function schreibeSidecar(siteKey, imageId, meta) {
const datei = sidecar(siteKey, imageId)
await writeFile(`${datei}.tmp`, JSON.stringify(meta))
await rename(`${datei}.tmp`, datei)
}
/** Sidecar eines Bildes { customerId, shopId, createdAt, claimed, w, h } oder null, wenn es das Bild nicht gibt. */
export async function bildMeta(siteKey, imageId) {
if (!keyOk(siteKey) || !idOk(imageId)) return null
try {
const meta = JSON.parse(await readFile(sidecar(siteKey, imageId), 'utf8'))
return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : null
} catch {
return null
}
}
/** Markiert Bilder als an einen Artikel gebunden (werden nicht mehr aufgeraeumt). Unbekannte IDs werden uebergangen. */
export async function beanspruche(siteKey, ids) {
for (const id of Array.isArray(ids) ? ids : []) {
const meta = await bildMeta(siteKey, id)
if (!meta || meta.claimed === true) continue
await schreibeSidecar(siteKey, id, { ...meta, claimed: true })
}
}
/** Loescht ein Bild (beide Groessen und Sidecar); fehlt es, passiert nichts. */
export async function loescheBild(siteKey, imageId) {
if (!keyOk(siteKey) || !idOk(imageId)) return
await Promise.all([
rm(bildPfad(siteKey, imageId, 'l'), { force: true }),
rm(bildPfad(siteKey, imageId, 's'), { force: true }),
rm(sidecar(siteKey, imageId), { force: true }),
rm(`${sidecar(siteKey, imageId)}.tmp`, { force: true }),
])
}
/** Loescht alle Bilder eines Shops (Ordner samt Inhalt), z. B. beim Entfernen der Shop-Daten. */
export async function loescheAlleBilder(siteKey) {
if (!keyOk(siteKey)) return
await rm(ordner(siteKey), { recursive: true, force: true })
}
/** Loescht nicht beanspruchte Bilder, die aelter als 24 h sind (Sidecar ohne Datum gilt als alt). Gibt die Anzahl zurueck. */
export async function raeumeUnbeanspruchteAuf(now = new Date()) {
const jetzt = now instanceof Date ? now.getTime() : Number(now)
const shops = await readdir(wurzel()).catch(() => [])
let entfernt = 0
for (const siteKey of shops) {
if (!keyOk(siteKey)) continue
const dateien = await readdir(ordner(siteKey)).catch(() => [])
for (const datei of dateien) {
if (!datei.endsWith('.json')) continue
const id = datei.slice(0, -5)
if (!idOk(id)) continue
const meta = await bildMeta(siteKey, id)
if (!meta || meta.claimed === true) continue
const erstellt = Date.parse(meta.createdAt)
if (Number.isFinite(erstellt) && jetzt - erstellt <= UNBEANSPRUCHT_MS) continue
await loescheBild(siteKey, id)
entfernt++
}
}
return entfernt
}
// Missbrauchsschutz: Uploads je Kunde und Stunde (im Speicher, reicht fuer einen Prozess). Jeder Aufruf zaehlt.
const uploads = new Map()
export function bildLimitErreicht(customerId) {
const jetzt = Date.now()
for (const [key, zeiten] of uploads) {
const frisch = zeiten.filter((t) => jetzt - t < 60 * 60 * 1000)
if (frisch.length === 0) uploads.delete(key)
else if (frisch.length !== zeiten.length) uploads.set(key, frisch)
}
const liste = uploads.get(customerId) || []
liste.push(jetzt)
uploads.set(customerId, liste)
return liste.length > MAX_UPLOADS_PRO_STUNDE
}
/** Setzt den Upload-Zaehler zurueck (Tests). */
export function setzeBildLimitZurueck() {
uploads.clear()
}
+1 -1
View File
@@ -295,7 +295,7 @@ export function normalisiereProdukt(eingabe, { einstellungen } = {}) {
}
/** Gespeicherten JSON-Text sicher lesen; null bei kaputten Daten. */
function parseProdukt(doc) {
export function parseProdukt(doc) {
try {
const p = JSON.parse(doc?.produkt)
return istObjekt(p) ? p : null
+2 -2
View File
@@ -17,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()]
const seedLog = (id, customerId, createdAt, action = 'geoeffnet') =>
aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt })
test('VIEW_ACTIONS: genau die zehn Aktionen', () => {
test('VIEW_ACTIONS: genau die elf Aktionen', () => {
assert.deepEqual(VIEW_ACTIONS, [
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
'termin_einstellungen', 'termin_aktion', 'termin_manuell',
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'shop_aenderung',
])
})
+29 -2
View File
@@ -119,7 +119,7 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a
}
})
test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => {
test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Analyse-Link, Ende, Logout und Login', () => {
const ok = [
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
@@ -127,9 +127,11 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
['POST', '/api/shop/produkte'], ['POST', '/api/shop/produkte/'], ['POST', '/api/shop/produkte/sortierung'], ['PUT', '/api/shop/produkte/abc'],
['DELETE', '/api/shop/produkte/abc'], ['PUT', '/api/shop/einstellungen'], ['PUT', '/api/shop/rechtstexte'], ['POST', '/api/shop/bild'],
]
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard')
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 7, 'genau sechs Termine- und sieben Webshop-Eintraege dazu, keine Wildcard')
for (const [m, p] of [
['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'],
// Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen
@@ -143,6 +145,12 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'],
// Webshop: Anfrage stellen und zurueckziehen sind in der Kundenansicht gesperrt (nicht auf der Liste)
['POST', '/api/shop/anfrage'], ['POST', '/api/shop/anfrage/zurueckziehen'],
// Webshop: Oeffnen und Bestaetigen der Rechtstexte macht der Haendler selbst; Stripe und Erstattungen sind nie dabei
['POST', '/api/shop/oeffnen'], ['POST', '/api/shop/rechtstexte/bestaetigen'], ['POST', '/api/shop/stripe/verbinden'], ['POST', '/api/shop/bestellungen/abc/erstatten'],
// und nur die ausgeschriebenen Pfade und Methoden der Verwaltung
['POST', '/api/shop/rechtstexte'], ['POST', '/api/shop/einstellungen'], ['PATCH', '/api/shop/produkte/abc'], ['POST', '/api/shop/produkte/abc'],
['PUT', '/api/shop/produkte'], ['DELETE', '/api/shop/produkte'], ['PUT', '/api/shop/bild'], ['POST', '/api/shop/bild/x'],
['POST', '/api/shopX/produkte'], ['PUT', '/api/shop/produkte/abc/def'], ['DELETE', '/api/shop/einstellungen'],
]) {
assert.ok(!allowed(m, p), `${m} ${p}`)
}
@@ -175,6 +183,25 @@ test('unbekanntes POST/PUT unter /api/termine in der Kundenansicht -> 403 view_b
}
})
// Router-Abgleich Webshop: genau die sieben Verwaltungs-Schreib-Routen stehen auf der Liste, jeder Eintrag trifft genau eine echte Route
test('Router-Abgleich: genau sieben Schreib-Routen des Webshops stehen auf der Erlaubnisliste', () => {
const shopRouter = MOUNTS.find(([prefix]) => prefix === '/api/shop')[1]
const routen = writeRoutes([['/api/shop', shopRouter]])
assert.deepEqual(
routen.filter(({ method, path: p }) => allowed(method, p)).map(({ method, path: p }) => `${method} ${p}`).sort(),
['DELETE /api/shop/produkte/x', 'POST /api/shop/bild', 'POST /api/shop/produkte', 'POST /api/shop/produkte/sortierung',
'PUT /api/shop/einstellungen', 'PUT /api/shop/produkte/x', 'PUT /api/shop/rechtstexte']
)
const shopEintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('shop'))
assert.equal(shopEintraege.length, 7)
for (const [m, re] of shopEintraege) assert.equal(routen.filter((r) => r.method === m && re.test(r.path)).length, 1, `${m} ${re}`)
// gesperrt bleiben insbesondere Anfrage, Oeffnen und Bestaetigen
for (const p of ['/api/shop/anfrage', '/api/shop/anfrage/zurueckziehen', '/api/shop/oeffnen', '/api/shop/rechtstexte/bestaetigen']) {
assert.ok(routen.some((r) => r.method === 'POST' && r.path === p), `Route ${p} existiert`)
assert.ok(!allowed('POST', p), p)
}
})
// Router-Abgleich: die Erlaubnisliste und die echten Schreib-Routen des Terminplaners decken sich genau; eine neue Route faellt
// im Test "jede Schreib-Route ausserhalb der Erlaubnisliste -> 403" auf (sie muss gesperrt sein), ein toter Eintrag hier
test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf der Erlaubnisliste', () => {
+4 -1
View File
@@ -105,7 +105,10 @@ test('GET /status: ohne Dokumente off und leer, legt nichts an; Schalter, fremde
await leereAlles()
const res = await status()
assert.equal(res.status, 200)
assert.deepEqual(res.body, { verfuegbar: true, state: 'off', anfrage: null })
assert.deepEqual(res.body, {
verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null,
})
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0, 'GET legt nichts an')
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync, existsSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
// Webshop C7: Artikelbilder (services/shop/bilder.js), verallgemeinert aus newsImages.js
process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-bilder-'))
const {
speichereBild, bildPfad, bildMeta, beanspruche, loescheBild, loescheAlleBilder, raeumeUnbeanspruchteAuf, bildLimitErreicht,
setzeBildLimitZurueck, BILD_MAX_BYTES,
} = await import('../server/services/shop/bilder.js')
const KEY = 'abcdefghijklmnop'
const KEY2 = 'qrstuvwxyz234567'
const solid = (width, height) => sharp({ create: { width, height, channels: 3, background: '#36c' } })
const speichere = (buffer, extra = {}) => speichereBild({ customerId: 'c1', shopId: 's1', siteKey: KEY, buffer, ...extra })
test('EXIF/GPS entfernt und gedreht; Sidecar mit Besitzer, Shop und Groesse', async () => {
const input = await solid(1200, 800)
.withMetadata({ orientation: 6 })
.withExif({ IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '48/1 30/1 0/1' } })
.jpeg().toBuffer()
assert.ok((await sharp(input).metadata()).exif, 'Fixture hat EXIF')
const { imageId, w, h } = await speichere(input)
assert.ok(w < h, 'gedreht')
for (const size of ['l', 's']) {
const meta = await sharp(bildPfad(KEY, imageId, size)).metadata()
assert.equal(meta.exif, undefined)
assert.equal(meta.format, 'webp')
}
const meta = await bildMeta(KEY, imageId)
assert.deepEqual([meta.customerId, meta.shopId, meta.claimed, meta.w, meta.h], ['c1', 's1', false, w, h])
assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/)
})
test('verkleinert auf 1600 und 480, Dateinamen <id>-l.webp und <id>-s.webp unter <uploadDir>/<siteKey>/', async () => {
const { imageId, w, h } = await speichere(await solid(4000, 3000).png().toBuffer())
assert.deepEqual([w, h], [1600, 1200])
const l = await sharp(bildPfad(KEY, imageId, 'l')).metadata()
const s = await sharp(bildPfad(KEY, imageId, 's')).metadata()
assert.deepEqual([l.width, l.height, s.width, s.height], [1600, 1200, 480, 360])
assert.equal(bildPfad(KEY, imageId, 'l'), path.join(process.env.SHOP_UPLOAD_DIR, KEY, `${imageId}-l.webp`))
// kleine Bilder werden nicht vergroessert
const klein = await speichere(await solid(100, 50).webp().toBuffer())
assert.deepEqual([klein.w, klein.h], [100, 50])
})
test('zu gross 413, falsches Format 415 (PDF, GIF, kaputt, leer, kein Buffer), ungueltiger Shop 400', async () => {
await assert.rejects(speichere(Buffer.alloc(BILD_MAX_BYTES + 1)), { status: 413 })
assert.equal(BILD_MAX_BYTES, 8 * 1024 * 1024)
await assert.rejects(speichere(Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n')), { status: 415 })
await assert.rejects(speichere(await solid(10, 10).gif().toBuffer()), { status: 415 })
await assert.rejects(speichere(Buffer.from('kein Bild')), { status: 415 })
await assert.rejects(speichere(Buffer.alloc(0)), { status: 415 })
await assert.rejects(speichere({}), { status: 415 })
await assert.rejects(speichere(await solid(10, 10).png().toBuffer(), { siteKey: '../x' }), { status: 400 })
})
test('Pfade nur fuer saubere siteKeys, IDs und Groessen', () => {
assert.equal(bildPfad(KEY, '../x', 'l'), null)
assert.equal(bildPfad(KEY, 'a/b', 'l'), null)
assert.equal(bildPfad(KEY, 'ABC', 'l'), null)
assert.equal(bildPfad('../abcdefghijkl', 'abc-1', 'l'), null)
assert.equal(bildPfad('abcdefghijklmno', 'abc-1', 'l'), null, 'zu kurz')
assert.equal(bildPfad('abcdefghijklmnop1', 'abc-1', 'l'), null, 'zu lang')
assert.equal(bildPfad('abcdefghijklmno1', 'abc-1', 'l'), null, 'Ziffer 1 ist kein base32')
assert.equal(bildPfad(KEY, 'abc-1', 'xl'), null)
assert.equal(bildPfad(KEY, 'x'.repeat(65), 'l'), null)
assert.ok(bildPfad(KEY, 'abc-1', 's').endsWith(path.join(KEY, 'abc-1-s.webp')))
})
test('bildMeta: unbekannte oder unsaubere IDs => null; beanspruche setzt claimed, ignoriert Unbekanntes und Wiederholung', async () => {
const { imageId } = await speichere(await solid(50, 50).png().toBuffer())
assert.equal(await bildMeta(KEY, 'gibtsnicht'), null)
assert.equal(await bildMeta(KEY, '../x'), null)
assert.equal(await bildMeta('zu-kurz', imageId), null)
await beanspruche(KEY, [imageId, 'gibtsnicht', '../x', 42])
assert.equal((await bildMeta(KEY, imageId)).claimed, true)
await beanspruche(KEY, [imageId])
assert.equal((await bildMeta(KEY, imageId)).claimed, true)
await beanspruche(KEY, undefined)
})
test('loescheBild entfernt beide Groessen und den Sidecar; loescheAlleBilder den ganzen Shop; Fehlendes ist kein Fehler', async () => {
const a = await speichere(await solid(50, 50).png().toBuffer())
const b = await speichere(await solid(50, 50).png().toBuffer())
const andere = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: await solid(50, 50).png().toBuffer() })
await loescheBild(KEY, a.imageId)
await loescheBild(KEY, a.imageId)
await loescheBild(KEY, '../x')
for (const size of ['l', 's']) assert.equal(existsSync(bildPfad(KEY, a.imageId, size)), false)
assert.equal(await bildMeta(KEY, a.imageId), null)
assert.ok(await bildMeta(KEY, b.imageId))
await loescheAlleBilder(KEY)
await loescheAlleBilder('../x')
await loescheAlleBilder('gibtsnicht1234567')
assert.equal(await bildMeta(KEY, b.imageId), null)
assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, KEY)), false)
assert.ok(await bildMeta(KEY2, andere.imageId), 'anderer Shop bleibt')
})
test('raeumeUnbeanspruchteAuf: nur Unbeanspruchte aelter als 24 h, in allen Shops', async () => {
const png = await solid(50, 50).png().toBuffer()
const alt = await speichere(png)
const behalten = await speichere(png)
const frisch = await speichere(png)
const altAnderer = await speichereBild({ customerId: 'c2', shopId: 's2', siteKey: KEY2, buffer: png })
await beanspruche(KEY, [behalten.imageId])
const zurueckdatieren = async (key, id, stunden) => {
const datei = path.join(process.env.SHOP_UPLOAD_DIR, key, `${id}.json`)
const meta = JSON.parse(await readFile(datei, 'utf8'))
meta.createdAt = new Date(Date.now() - stunden * 3600 * 1000).toISOString()
await writeFile(datei, JSON.stringify(meta))
}
await zurueckdatieren(KEY, alt.imageId, 25)
await zurueckdatieren(KEY, behalten.imageId, 48)
await zurueckdatieren(KEY, frisch.imageId, 23)
await zurueckdatieren(KEY2, altAnderer.imageId, 30)
assert.equal(await raeumeUnbeanspruchteAuf(), 2)
assert.equal(await bildMeta(KEY, alt.imageId), null)
assert.equal(existsSync(bildPfad(KEY, alt.imageId, 'l')), false)
assert.equal(await bildMeta(KEY2, altAnderer.imageId), null)
assert.ok(await bildMeta(KEY, behalten.imageId), 'beansprucht bleibt')
assert.ok(await bildMeta(KEY, frisch.imageId), 'frisch bleibt')
// mit spaeterem Zeitpunkt wird auch das frische geraeumt
assert.equal(await raeumeUnbeanspruchteAuf(new Date(Date.now() + 3 * 3600 * 1000)), 1)
assert.equal(await bildMeta(KEY, frisch.imageId), null)
})
test('bildLimitErreicht: 40 je Stunde und Kunde, danach true; andere Kunden unberuehrt', () => {
setzeBildLimitZurueck()
for (let i = 0; i < 40; i++) assert.equal(bildLimitErreicht('c1'), false, `Aufruf ${i + 1}`)
assert.equal(bildLimitErreicht('c1'), true)
assert.equal(bildLimitErreicht('c2'), false)
setzeBildLimitZurueck()
assert.equal(bildLimitErreicht('c1'), false)
})
+631
View File
@@ -0,0 +1,631 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync, existsSync } from 'node:fs'
import { readdir, rm } from 'node:fs/promises'
import sharp from 'sharp'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop C7: Verwaltungs-Routen /api/shop (Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder), Kundenansicht
process.env.SHOP_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'shop-routes-'))
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: SHOP_DATETIMES,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send, post } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { shopPerSiteKey } = await import('../server/services/shop/kontext.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei', archived: false })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true })
const cookie = customerCookie('c1')
const KV = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
shopname: 'Töpferei Keramik',
anbieter: { name: 'Töpferei Keramik', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@toepferei.example', telefon: '', ustId: '' },
}
const TEXTE = {
agb: 'Unsere AGB.',
widerrufsbelehrung: 'Unsere Widerrufsbelehrung.',
widerrufsformular: 'Unser Widerrufsformular.',
impressumUrl: 'https://toepferei.example/impressum',
datenschutzUrl: 'https://toepferei.example/datenschutz',
}
const ARTIKEL = { name: 'Tasse', preisCent: 1800 }
const q = (projectId = 'pt1') => `projectId=${projectId}`
const lesen = (pfad, projectId = 'pt1', opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}${q(projectId)}`, opts)
const schreiben = (method, pfad, body = {}, opts = { cookie }) => send(method, `/api/shop/${pfad}`, { ...opts, body: { projectId: 'pt1', ...body } })
async function aktiviere(projectId = 'pt1', state = 'active') {
const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
return sf.getFeatureDoc('c1', projectId, 'shop')
}
const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id)
const dateien = async (siteKey) => (await readdir(path.join(process.env.SHOP_UPLOAD_DIR, siteKey)).catch(() => [])).sort()
async function png(w = 64, h = 48) {
return sharp({ create: { width: w, height: h, channels: 3, background: '#c63' } }).png().toBuffer()
}
async function lade(buffer, { projectId = 'pt1', opts = { cookie }, type = 'application/octet-stream' } = {}) {
const res = await fetch(`${app.url}/api/shop/bild?projectId=${projectId}`, {
method: 'POST',
headers: { 'content-type': type, ...(opts.cookie ? { cookie: opts.cookie } : {}), ...(opts.headers || {}) },
body: buffer,
})
const text = await res.text()
let body = text
try {
body = JSON.parse(text)
} catch {
/* Text */
}
return { status: res.status, body }
}
/** Shop bis "bereit zum Oeffnen": active, Einstellungen, Rechtstexte bestaetigt, Stripe bereit, ein Artikel. */
async function machBereit({ mitArtikel = true } = {}) {
const feature = await aktiviere()
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200)
if (mitArtikel) assert.equal((await schreiben('POST', 'produkte', { produkt: ARTIKEL })).status, 201)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })).status, 200)
const hash = (await lesen('rechtstexte')).body.hash
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash })).status, 200)
await updateDocument(config.collections.shopConfig, feature.$id, {
stripeAccountId: 'acct_test1',
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geheim: 'nie', geprueftAt: '2026-10-05T10:00:00.000Z' }),
})
return feature
}
let origin
beforeEach(async () => {
origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails }
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
await rm(process.env.SHOP_UPLOAD_DIR, { recursive: true, force: true })
const { leereCache } = await import('../server/services/shop/kontext.js')
leereCache()
})
// ---------------------------------------------------------------- Zugang
test('Zugang: ohne Session 401, ohne Projekt 400, fremdes oder unbekanntes Projekt 403, Schalter aus 409, Funktion nicht eingerichtet 409', async () => {
await aktiviere()
assert.equal((await send('GET', '/api/shop/einstellungen?projectId=pt1')).status, 401)
assert.equal((await send('GET', '/api/shop/einstellungen', { cookie })).status, 400)
assert.equal((await lesen('einstellungen', 'pt3')).status, 403)
assert.equal((await lesen('einstellungen', 'gibtsnicht')).status, 403)
for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad, 'pt2')).status, 409, `${pfad}: pt2 hat keine Funktion`)
const keine = await lesen('einstellungen', 'pt2')
assert.deepEqual([keine.body.code, keine.body.error], ['nicht_eingerichtet', 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.'])
config.shop.mode = 'off'
const aus = await lesen('einstellungen')
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_verfuegbar'])
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['jemand@example.invalid']
assert.equal((await lesen('produkte')).status, 409)
})
test('Zustaende: ordered und off => 409; disabled nur lesend; setup legt shopConfig an (ID = portalFeatures-ID, siteKey kopiert)', async () => {
const feature = await aktiviere('pt1', 'ordered')
assert.equal((await lesen('einstellungen')).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('setup'))
assert.equal(aw.docs('shopConfig').size, 0)
const setup = await lesen('einstellungen')
assert.equal(setup.status, 200)
assert.equal(setup.body.einstellungen.lieferzeit, '2–4 Werktage')
assert.equal(setup.body.vollstaendig, false)
const angelegt = shopConfigDoc(feature)
assert.deepEqual([angelegt.customerId, angelegt.projectId, angelegt.siteKey], ['c1', 'pt1', feature.siteKey])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'setup darf schreiben')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
for (const pfad of ['einstellungen', 'produkte', 'rechtstexte']) assert.equal((await lesen(pfad)).status, 200, `${pfad} lesbar`)
for (const [m, pfad, body] of [
['PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }], ['POST', 'produkte', { produkt: ARTIKEL }], ['PUT', 'rechtstexte', { rechtstexte: TEXTE }],
['POST', 'oeffnen', { offen: false }], ['POST', 'produkte/sortierung', { ids: [] }],
]) {
const res = await schreiben(m, pfad, body)
assert.deepEqual([res.status, res.body.code], [409, 'nicht_eingerichtet'], `${m} ${pfad}`)
}
assert.equal((await lesen('einstellungen', 'pt4')).status, 409)
})
test('archiviertes Projekt: Lesen ja (falls Funktion da), Schreiben 409 wie bei Bestellungen', async () => {
const feature = await sf.ensureFeatureDoc('c1', 'pt4', 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active'))
assert.equal((await lesen('einstellungen', 'pt4')).status, 200)
const res = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, projectId: 'pt4' })
assert.equal(res.status, 409)
})
// ---------------------------------------------------------------- Status
test('GET /status ohne Funktion: gleiche Form mit leeren Werten, legt nichts an', async () => {
const res = await lesen('status')
assert.equal(res.status, 200)
assert.deepEqual(res.body, {
verfuegbar: true, state: 'off', ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null,
snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null,
})
assert.equal(aw.docs('portalFeatures').size + aw.docs('shopConfig').size, 0)
assert.equal((await lesen('status', 'pt3')).status, 403)
})
test('GET /status mit Shop: Bereitschaft, Snippet, Vorschau, Stripe ohne Konto-ID und fremde Felder, Zahlen; geoeffnet erst nach Oeffnen', async () => {
const feature = await machBereit()
const res = await lesen('status')
assert.equal(res.status, 200)
const b = res.body
assert.deepEqual([b.verfuegbar, b.state, b.siteKey, b.schalter, b.geoeffnet], [true, 'active', feature.siteKey, false, false])
assert.equal(b.bereitschaft.bereit, true)
assert.deepEqual(b.bereitschaft.punkte.map((p) => [p.id, p.ok]), [['einbau', true], ['stripe', true], ['einstellungen', true], ['rechtstexte', true], ['artikel', true]])
assert.equal(b.snippet, `<div data-webklar-shop="${feature.siteKey}"></div>\n<script src="${config.publicBaseUrl}/embed/shop.js" defer></script>`)
assert.equal(b.vorschauUrl, `${config.publicBaseUrl}/shop/${feature.siteKey}`)
assert.deepEqual(b.stripe, {
verbunden: true,
status: { charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z' },
})
assert.ok(!JSON.stringify(b).includes('acct_test1') && !JSON.stringify(b).includes('geheim'))
assert.deepEqual(b.zahlen, { artikel: 1, aktiveArtikel: 1, offeneBestellungen: 0, bestellungen30: 0, umsatz30Cent: 0 })
assert.equal(b.ticketWoid, null)
assert.equal((await schreiben('POST', 'oeffnen', { offen: true })).status, 200)
assert.equal((await lesen('status')).body.geoeffnet, true)
assert.equal((await lesen('status')).body.schalter, true)
})
// ---------------------------------------------------------------- Einstellungen
test('Einstellungen: speichern, Version zaehlt, veraltete Version 409, Fehler je Feld 400', async () => {
await aktiviere()
const leer = await lesen('einstellungen')
assert.equal(leer.body.version, 0)
const gut = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 })
assert.equal(gut.status, 200)
assert.deepEqual([gut.body.version, gut.body.vollstaendig, gut.body.einstellungen.shopname], [1, true, 'Töpferei Keramik'])
assert.equal((await lesen('einstellungen')).body.einstellungen.anbieter.ort, 'Kaiserslautern')
const veraltet = await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN, version: 0 })
assert.deepEqual([veraltet.status, veraltet.body.code], [409, 'EINSTELLUNG_VERALTET'])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 200, 'ohne Version keine Pruefung')
const schlecht = await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, akzent: 'rot' } })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.fehler && Object.keys(schlecht.body.fehler).length > 0)
assert.equal(schlecht.body.error, Object.values(schlecht.body.fehler)[0])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: 'text' })).status, 400)
})
test('Einstellungen speichern leert den 30-s-Speicher von shopPerSiteKey', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const vorher = await shopPerSiteKey(feature.siteKey)
assert.equal(vorher.shop.einstellungen.shopname, 'Töpferei Keramik')
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, shopname: 'Neu' } })
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.einstellungen.shopname, 'Neu')
})
// ---------------------------------------------------------------- Artikel
test('Artikel: anlegen 201, lesen, aendern, Fehler 400 je Feld, unbekannte ID 404, Kleinunternehmer-Steuer', async () => {
await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) // Kleinunternehmer => 0 %
const neu = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, kurz: 'Handgemacht', extra: 'ignoriert' } })
assert.equal(neu.status, 201)
const id = neu.body.produkt.id
assert.deepEqual([neu.body.produkt.name, neu.body.produkt.preisCent, neu.body.produkt.steuersatz, neu.body.produkt.aktiv, neu.body.produkt.sort], ['Tasse', 1800, 0, true, 0])
assert.equal(neu.body.produkt.extra, undefined)
const liste = await lesen('produkte')
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.produkte.map((p) => p.id), [id])
assert.deepEqual([liste.body.maxArtikel, liste.body.maxBilder], [config.shop.maxArtikel, config.shop.maxBilder])
const geaendert = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Große Tasse', aktiv: false } })
assert.equal(geaendert.status, 200)
assert.deepEqual([geaendert.body.produkt.id, geaendert.body.produkt.name, geaendert.body.produkt.aktiv], [id, 'Große Tasse', false])
assert.equal((await lesen('produkte')).body.produkte[0].aktiv, false)
assert.equal(aw.docs('shopProducts').size, 1)
const schlecht = await schreiben('POST', 'produkte', { produkt: { name: '', preisCent: -5 } })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.fehler.name && schlecht.body.fehler.preisCent)
assert.equal((await schreiben('POST', 'produkte', { produkt: 'text' })).status, 400)
assert.equal((await schreiben('POST', 'produkte', {})).status, 400)
assert.equal((await schreiben('PUT', 'produkte/gibtsnicht', { produkt: ARTIKEL })).status, 404)
assert.equal((await schreiben('DELETE', 'produkte/gibtsnicht')).status, 404)
assert.equal(aw.docs('shopProducts').size, 1)
})
test('Artikel mit Regelbesteuerung: Steuersatz kommt aus den gespeicherten Einstellungen', async () => {
await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, kleinunternehmer: false } })
const res = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, steuersatz: 7 } })
assert.equal(res.body.produkt.steuersatz, 7)
})
test('Artikel gehoeren zu ihrem Shop: fremder Shop kann sie weder sehen noch aendern', async () => {
await aktiviere()
const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL })
await aktiviere('pt2')
assert.deepEqual((await lesen('produkte', 'pt2')).body.produkte, [])
assert.equal((await schreiben('PUT', `produkte/${neu.body.produkt.id}`, { produkt: ARTIKEL, projectId: 'pt2' })).status, 404)
assert.equal((await schreiben('DELETE', `produkte/${neu.body.produkt.id}`, {}, { cookie }) ).status, 200)
})
test('Sortierung: neue Reihenfolge, falsche Liste 400', async () => {
await aktiviere()
const ids = []
for (const name of ['A', 'B', 'C']) ids.push((await schreiben('POST', 'produkte', { produkt: { name, preisCent: 100 } })).body.produkt.id)
const res = await schreiben('POST', 'produkte/sortierung', { ids: [ids[2], ids[0], ids[1]] })
assert.equal(res.status, 200)
assert.deepEqual(res.body.produkte.map((p) => p.name), ['C', 'A', 'B'])
assert.deepEqual((await lesen('produkte')).body.produkte.map((p) => p.name), ['C', 'A', 'B'])
assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [ids[0]] })).status, 400)
assert.equal((await schreiben('POST', 'produkte/sortierung', {})).status, 400)
})
// ---------------------------------------------------------------- Bilder
test('Bild hochladen: 201, Sidecar mit Shop und Kunde, Vorschau lesbar; Typfehler 415, zu gross 413, ohne Session 401, fremdes Projekt 403', async () => {
const feature = await aktiviere()
const res = await lade(await png(2000, 1000))
assert.equal(res.status, 201)
assert.deepEqual([res.body.w, res.body.h], [1600, 800])
assert.match(res.body.imageId, /^[a-z0-9-]{36}$/)
const dateiListe = await dateien(feature.siteKey)
assert.deepEqual(dateiListe, [`${res.body.imageId}-l.webp`, `${res.body.imageId}-s.webp`, `${res.body.imageId}.json`].sort())
const meta = JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${res.body.imageId}.json`), 'utf8'))
assert.deepEqual([meta.customerId, meta.shopId, meta.claimed], ['c1', feature.$id, false])
for (const datei of [`${res.body.imageId}-s.webp`, `${res.body.imageId}-l.webp`]) {
const bild = await fetch(`${app.url}/api/shop/bild/pt1/${datei}`, { headers: { cookie } })
assert.equal(bild.status, 200)
assert.equal(bild.headers.get('content-type'), 'image/webp')
assert.equal(bild.headers.get('x-content-type-options'), 'nosniff')
assert.equal(bild.headers.get('cache-control'), 'private, max-age=3600')
assert.equal((await sharp(Buffer.from(await bild.arrayBuffer())).metadata()).format, 'webp')
}
assert.equal((await lade(Buffer.from('%PDF-1.4 kein Bild'))).status, 415)
assert.equal((await lade(await png(), { type: 'text/plain' })).status, 415, 'kein octet-stream')
const zuGross = await lade(Buffer.alloc(8 * 1024 * 1024 + 1))
assert.deepEqual([zuGross.status, zuGross.body.error], [413, 'Das Bild ist größer als 8 MB.'])
assert.equal((await lade(await png(), { opts: {} })).status, 401)
assert.equal((await lade(await png(), { projectId: 'pt3' })).status, 403)
assert.equal((await lade(await png(), { projectId: 'pt2' })).status, 409, 'pt2 hat keine Funktion')
assert.equal((await dateien(feature.siteKey)).length, 3, 'nichts Weiteres abgelegt')
})
test('Bild-Vorschau: nur eigene Projekte, nur gueltige Dateinamen, nur vorhandene Bilder', async () => {
const feature = await aktiviere()
const { body } = await lade(await png())
const url = (projekt, datei) => `${app.url}/api/shop/bild/${projekt}/${datei}`
const hole = (projekt, datei, opts = { cookie }) => fetch(url(projekt, datei), { headers: opts.cookie ? { cookie: opts.cookie } : {} })
assert.equal((await hole('pt1', `${body.imageId}-s.webp`, {})).status, 401)
assert.equal((await hole('pt3', `${body.imageId}-s.webp`)).status, 403)
assert.equal((await hole('pt1', `${body.imageId}-xl.webp`)).status, 404)
assert.equal((await hole('pt1', `${body.imageId}.json`)).status, 404, 'Sidecar wird nicht ausgeliefert')
assert.equal((await hole('pt1', '..%2F..%2Fetc%2Fpasswd')).status, 404)
assert.equal((await hole('pt1', 'gibtsnicht-s.webp')).status, 404)
assert.equal((await hole('pt1', `${body.imageId}-s.webp`, { cookie: customerCookie('c2') })).status, 403)
// Datei ohne Sidecar gilt als nicht vorhanden
await rm(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${body.imageId}.json`))
assert.equal((await hole('pt1', `${body.imageId}-s.webp`)).status, 404)
})
test('Bilder: 40 Uploads je Stunde, der 41. wird mit 429 abgelehnt', async () => {
await aktiviere()
const klein = await png(8, 8)
for (let i = 0; i < 40; i++) assert.equal((await lade(klein)).status, 201, `Upload ${i + 1}`)
const zuViele = await lade(klein)
assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.'])
assert.equal((await lade(klein, { opts: { cookie: customerCookie('c1') } })).status, 429)
})
test('Artikel mit Bildern: unbekannte oder fremde ID 400, Beanspruchen, Entfernen loescht, geteiltes Bild bleibt bis zuletzt, Loeschen des Artikels raeumt auf', async () => {
const feature = await aktiviere()
const a = (await lade(await png())).body.imageId
const b = (await lade(await png())).body.imageId
const c = (await lade(await png())).body.imageId
const meta = async (id) => JSON.parse(await (await import('node:fs/promises')).readFile(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}.json`), 'utf8'))
const da = (id) => existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${id}-l.webp`))
const unbekannt = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, 'gibtsnicht'] } })
assert.equal(unbekannt.status, 400)
assert.ok(unbekannt.body.fehler.bilder)
assert.equal(aw.docs('shopProducts').size, 0)
assert.equal((await meta(a)).claimed, false, 'nichts beansprucht, wenn der Artikel nicht gespeichert wurde')
// Bild eines anderen Shops
const f2 = await aktiviere('pt2')
const fremd = (await lade(await png(), { projectId: 'pt2' })).body.imageId
const mitFremd = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [fremd] } })
assert.equal(mitFremd.status, 400)
assert.ok(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, f2.siteKey, `${fremd}-l.webp`)))
const mitPfad = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: ['../x'] } })
assert.equal(mitPfad.status, 400)
const p1 = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bilder: [a, b] } })
assert.equal(p1.status, 201)
assert.deepEqual(p1.body.produkt.bilder, [a, b])
assert.deepEqual(p1.body.produkt.bildUrls[0], { id: a, klein: `/api/shop/bild/pt1/${a}-s.webp`, gross: `/api/shop/bild/pt1/${a}-l.webp` })
assert.equal((await meta(a)).claimed, true)
assert.equal((await meta(b)).claimed, true)
assert.equal((await meta(c)).claimed, false)
const p2 = await schreiben('POST', 'produkte', { produkt: { name: 'Zweiter', preisCent: 500, bilder: [b] } })
assert.equal(p2.status, 201)
// a entfernen => weg; b entfernen aus p1, aber p2 nutzt es noch => bleibt
const ohne = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: [] } })
assert.equal(ohne.status, 200)
assert.equal(da(a), false)
assert.equal(existsSync(path.join(process.env.SHOP_UPLOAD_DIR, feature.siteKey, `${a}.json`)), false)
assert.equal(da(b), true, 'p2 nutzt b noch')
assert.equal(da(c), true, 'unbenutzt hochgeladene Bilder raeumt nur der Nachtjob auf')
// p2 loeschen => b weg
assert.equal((await schreiben('DELETE', `produkte/${p2.body.produkt.id}`)).status, 200)
assert.equal(da(b), false)
assert.equal(aw.docs('shopProducts').size, 1)
// Reihenfolge und Anzahl der Bilder bleiben, mehr als maxBilder abgelehnt
const viele = []
for (let i = 0; i < config.shop.maxBilder + 1; i++) viele.push((await lade(await png(8, 8))).body.imageId)
const zuViele = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele } })
assert.equal(zuViele.status, 400)
assert.ok(zuViele.body.fehler.bilder)
const ok = await schreiben('PUT', `produkte/${p1.body.produkt.id}`, { produkt: { ...ARTIKEL, bilder: viele.slice(0, 2).reverse() } })
assert.deepEqual(ok.body.produkt.bilder, viele.slice(0, 2).reverse())
})
// ---------------------------------------------------------------- Rechtstexte
test('Rechtstexte GET: Entwurf leer, nichts bestaetigt, Muster, erzeugte Texte, Gewaehrleistung, Hash, Bestaetigungstext', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const res = await lesen('rechtstexte')
assert.equal(res.status, 200)
const b = res.body
assert.deepEqual(Object.keys(b).sort(), ['bestaetigt', 'bestaetigungText', 'entwurf', 'gewaehrleistung', 'hash', 'muster', 'versandUndZahlung', 'vollstaendig'])
assert.deepEqual(b.entwurf, { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' })
assert.equal(b.bestaetigt, null)
assert.equal(b.vollstaendig, false)
assert.match(b.hash, /^[0-9a-f]{64}$/)
assert.equal(b.hash, rechtstexteHash(b.entwurf, (await lesen('einstellungen')).body.einstellungen))
for (const k of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) assert.ok(b.muster[k].length > 100, k)
assert.ok(b.muster.agb.includes('Töpferei Keramik'))
assert.ok(b.muster.widerrufsbelehrung.includes(`${config.publicBaseUrl}/shop/${feature.siteKey}/widerruf`))
assert.ok(b.versandUndZahlung.includes('Versand'))
assert.deepEqual([b.gewaehrleistung.bild, b.gewaehrleistung.text.startsWith('GESETZLICHE GEWÄHRLEISTUNG')], ['/img/gewaehrleistung-eu.webp', true])
assert.match(b.bestaetigungText, /^Ich habe die Texte geprüft/)
})
test('Rechtstexte PUT: speichert den Entwurf, normalisiert, Fehler 400; Hash aendert sich mit dem Text', async () => {
const feature = await aktiviere()
const leer = (await lesen('rechtstexte')).body.hash
const res = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: ' Neue AGB \r\n\r\n\r\n\r\nZeile', extra: 'x' } })
assert.equal(res.status, 200)
assert.equal(res.body.entwurf.agb, 'Neue AGB\n\nZeile')
assert.equal(res.body.vollstaendig, true)
assert.notEqual(res.body.hash, leer)
assert.equal((await lesen('rechtstexte')).body.entwurf.widerrufsformular, 'Unser Widerrufsformular.')
assert.equal(JSON.parse(shopConfigDoc(feature).rechtstexte).bestaetigt, null)
const schlecht = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, impressumUrl: 'http://unsicher.example', datenschutzUrl: 'javascript:alert(1)' } })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.fehler.impressumUrl && schlecht.body.fehler.datenschutzUrl)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: 'text' })).status, 400)
assert.equal((await schreiben('PUT', 'rechtstexte', {})).status, 400)
const zuLang = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'x'.repeat(30001) } })
assert.equal(zuLang.status, 400)
assert.equal((await lesen('rechtstexte')).body.entwurf.agb, 'Neue AGB\n\nZeile', 'Fehler aendert nichts')
})
test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400, sonst Kopie mit Zeit, Person und Hash', async () => {
const feature = await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
const unvollstaendig = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, datenschutzUrl: '' } })
const hashUnvollstaendig = unvollstaendig.body.hash
const leer = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })
assert.equal(leer.status, 400)
assert.equal(shopConfigDoc(feature).rechtstexteHash || '', '')
const voll = await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })
const falsch = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: 'abc' })
assert.deepEqual([falsch.status, falsch.body.code], [409, 'texte_geaendert'])
assert.match(falsch.body.error, /^Die Texte wurden inzwischen geändert/)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', {})).status, 409)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: hashUnvollstaendig })).status, 409, 'Hash des frueheren Entwurfs')
const ok = await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })
assert.equal(ok.status, 200)
assert.deepEqual(ok.body.bestaetigt.texte, TEXTE)
assert.equal(ok.body.bestaetigt.von, `${customers.c1.name} <${customers.c1.email}>`)
assert.equal(ok.body.bestaetigt.aktuell, true)
assert.ok(Math.abs(Date.now() - Date.parse(ok.body.bestaetigt.at)) < 10_000)
const doc = shopConfigDoc(feature)
assert.equal(doc.rechtstexteHash, voll.body.hash)
assert.equal(doc.rechtstexteBestaetigtVon, `${customers.c1.name} <${customers.c1.email}>`)
assert.equal(new Date(doc.rechtstexteBestaetigtAt).toISOString(), new Date(ok.body.bestaetigt.at).toISOString())
const gespeichert = JSON.parse(doc.rechtstexte)
assert.deepEqual(gespeichert.entwurf, TEXTE)
assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [TEXTE, voll.body.hash])
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.rechtstexte.bestaetigt.hash, voll.body.hash, 'Speicher wurde geleert')
// Entwurf aendern: die bestaetigte Fassung bleibt, ist aber nicht mehr aktuell; Bereitschaft verlangt neue Bestaetigung
const geaendert = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'Geaenderte AGB' } })
assert.deepEqual(geaendert.body.bestaetigt.texte, TEXTE)
assert.equal(geaendert.body.bestaetigt.aktuell, false)
assert.equal(geaendert.body.entwurf.agb, 'Geaenderte AGB')
const stand = (await lesen('status')).body.bereitschaft.punkte.find((p) => p.id === 'rechtstexte')
assert.equal(stand.ok, false)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: voll.body.hash })).status, 409, 'alter Hash')
})
test('Rechtstexte: Hash haengt auch an den Einstellungen (Versand und Zahlung), eine Aenderung dort verlangt neue Bestaetigung', async () => {
await aktiviere()
await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })
await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE })
const alt = (await lesen('rechtstexte')).body.hash
await schreiben('PUT', 'einstellungen', { einstellungen: { ...EINSTELLUNGEN, versand: { ...EINSTELLUNGEN.versand, kostenCent: { DE: 690 } } } })
const neu = (await lesen('rechtstexte')).body.hash
assert.notEqual(alt, neu)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: alt })).status, 409)
assert.equal((await schreiben('POST', 'rechtstexte/bestaetigen', { hash: neu })).status, 200)
})
// ---------------------------------------------------------------- Oeffnen
test('Oeffnen: nur wenn bereit (sonst 409 mit Punkten), Schliessen immer, Speicher geleert, Eingabe geprueft', async () => {
const feature = await machBereit({ mitArtikel: false })
const nicht = await schreiben('POST', 'oeffnen', { offen: true })
assert.deepEqual([nicht.status, nicht.body.code], [409, 'nicht_bereit'])
assert.deepEqual(nicht.body.punkte.filter((p) => !p.ok).map((p) => p.id), ['artikel'])
assert.ok(nicht.body.punkte.every((p) => typeof p.text === 'string'))
assert.equal(shopConfigDoc(feature).geoeffnet !== true, true)
assert.equal((await schreiben('POST', 'oeffnen', { offen: 'ja' })).status, 400)
assert.equal((await schreiben('POST', 'oeffnen', {})).status, 400)
await schreiben('POST', 'produkte', { produkt: ARTIKEL })
const vorher = await shopPerSiteKey(feature.siteKey)
assert.equal(vorher.shop.geoeffnet, false)
const auf = await schreiben('POST', 'oeffnen', { offen: true })
assert.equal(auf.status, 200)
assert.deepEqual([auf.body.schalter, auf.body.geoeffnet, auf.body.bereitschaft.bereit], [true, true, true])
assert.equal(shopConfigDoc(feature).geoeffnet, true)
assert.match(shopConfigDoc(feature).geoeffnetAt, /^\d{4}-\d\d-\d\d/)
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, true, 'Speicher geleert')
const zu = await schreiben('POST', 'oeffnen', { offen: false })
assert.deepEqual([zu.status, zu.body.schalter, zu.body.geoeffnet], [200, false, false])
assert.equal((await shopPerSiteKey(feature.siteKey)).shop.geoeffnet, false)
// Stripe faellt aus: Oeffnen scheitert, Schliessen geht trotzdem
await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify({ charges_enabled: false }) })
const stripeAus = await schreiben('POST', 'oeffnen', { offen: true })
assert.deepEqual([stripeAus.status, stripeAus.body.punkte.filter((p) => !p.ok).map((p) => p.id)], [409, ['stripe']])
assert.equal((await schreiben('POST', 'oeffnen', { offen: false })).status, 200)
})
// ---------------------------------------------------------------- Kundenansicht
test('Kundenansicht: Einstellungen, Artikel, Rechtstexte-Entwurf und Bilder erlaubt und protokolliert; Lesen ebenfalls', async () => {
const feature = await aktiviere()
assert.equal((await lesen('einstellungen', 'pt1', KV)).status, 200)
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }, KV)).status, 200)
const neu = await schreiben('POST', 'produkte', { produkt: ARTIKEL }, KV)
assert.equal(neu.status, 201)
const id = neu.body.produkt.id
assert.equal((await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Kanne' } }, KV)).status, 200)
assert.equal((await schreiben('POST', 'produkte/sortierung', { ids: [id] }, KV)).status, 200)
assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: TEXTE }, KV)).status, 200)
const bild = await lade(await png(), { opts: KV })
assert.equal(bild.status, 201)
assert.equal((await schreiben('DELETE', `produkte/${id}`, {}, KV)).status, 200)
assert.equal((await dateien(feature.siteKey)).length, 3, 'das hochgeladene Bild gehoert keinem Artikel und wartet auf den Nachtjob')
const eintraege = [...aw.docs('customerViewLog').values()]
assert.ok(eintraege.every((e) => e.action === 'shop_aenderung' && e.staffName === 'Andrej' && e.customerId === 'c1'))
assert.deepEqual(eintraege.map((e) => e.detail).sort(), [
'Artikel angelegt: Tasse', 'Artikel geändert: Kanne', 'Artikel gelöscht: Kanne', 'Artikel sortiert', 'Bild hochgeladen',
'Einstellungen gespeichert', 'Rechtstexte-Entwurf gespeichert',
].sort())
})
test('Kundenansicht: Anfrage, Oeffnen und Bestaetigen sind gesperrt (Waechter), Stripe-Pfade auch', async () => {
await machBereit()
const hash = (await lesen('rechtstexte')).body.hash
for (const [pfad, body] of [['oeffnen', { offen: true }], ['rechtstexte/bestaetigen', { hash }], ['anfrage', {}], ['stripe/verbinden', {}]]) {
const res = await schreiben('POST', pfad, body, KV)
assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad)
}
assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true)
})
test('Kundenansicht: der Router sperrt Oeffnen und Bestaetigen selbst, auch ohne Waechter (alte Ansicht und Kundenansicht)', async () => {
await machBereit()
const hash = (await lesen('rechtstexte')).body.hash
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const [pfad, body] of [['/api/shop/oeffnen', { projectId: 'pt1', offen: true }], ['/api/shop/rechtstexte/bestaetigen', { projectId: 'pt1', hash }]]) {
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
const res = await fetch(url + pfad, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify(body) })
assert.equal(res.status, 403, pfad)
assert.equal((await res.json()).error, 'In der Kundenansicht kann das nicht geändert werden.')
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(JSON.parse(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).rechtstexte).bestaetigt.hash, hash, 'unveraendert')
assert.equal(shopConfigDoc(await sf.getFeatureDoc('c1', 'pt1', 'shop')).geoeffnet !== true, true)
})
// ---------------------------------------------------------------- Rate-Limit und Fehler
test('Schreib-Limit: 300 je Stunde und Kunde, danach 429; Lesen zaehlt nicht; anderer Kunde unberuehrt', async () => {
await aktiviere()
for (let i = 0; i < 300; i++) {
const res = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1' } })
assert.equal(res.status, 400, `Aufruf ${i + 1}`)
}
const zuViele = await send('POST', '/api/shop/oeffnen', { cookie, body: { projectId: 'pt1', offen: false } })
assert.deepEqual([zuViele.status, zuViele.body.error], [429, 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'])
assert.equal((await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN })).status, 429)
assert.equal((await lesen('einstellungen')).status, 200)
assert.equal((await lesen('status')).status, 200)
assert.equal((await schreiben('POST', 'oeffnen', { offen: false }, { cookie: customerCookie('c2') })).status !== 429, true)
})
test('Fehlertexte von Appwrite verlassen den Server nie (500 ohne Rohtext)', async () => {
await aktiviere()
aw.failNext({ collection: 'shopProducts', method: 'GET', status: 500, message: 'GEHEIM interner Appwrite-Text' })
const res = await lesen('produkte')
assert.equal(res.status, 500)
assert.deepEqual(res.body, { error: 'Interner Serverfehler' })
assert.equal((await lesen('produkte')).status, 200)
})