diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index 297d7a7..126ce0b 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -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\/?$/], diff --git a/server/routes/shop.js b/server/routes/shop.js index 80a6629..99fb22e 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -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: , fehler?: { : 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) => `
\n` +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, } - res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : 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(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 diff --git a/server/services/customerViewLog.js b/server/services/customerViewLog.js index 9a8b2fe..128dc1e 100644 --- a/server/services/customerViewLog.js +++ b/server/services/customerViewLog.js @@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [ 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + 'shop_aenderung', ] const DETAIL_MAX = 300 diff --git a/server/services/shop/bilder.js b/server/services/shop/bilder.js new file mode 100644 index 0000000..db8a11c --- /dev/null +++ b/server/services/shop/bilder.js @@ -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: //-l.webp, -s.webp und Sidecar .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() +} diff --git a/server/services/shop/produkte.js b/server/services/shop/produkte.js index 524d30e..9ab166e 100644 --- a/server/services/shop/produkte.js +++ b/server/services/shop/produkte.js @@ -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 diff --git a/test/customerViewLog.test.js b/test/customerViewLog.test.js index 008fa44..5ebd404 100644 --- a/test/customerViewLog.test.js +++ b/test/customerViewLog.test.js @@ -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', ]) }) diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 218ab52..41fd5cd 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -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', () => { diff --git a/test/shopAnfrage.test.js b/test/shopAnfrage.test.js index 2006163..84a834f 100644 --- a/test/shopAnfrage.test.js +++ b/test/shopAnfrage.test.js @@ -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') diff --git a/test/shopBilder.test.js b/test/shopBilder.test.js new file mode 100644 index 0000000..fbf7996 --- /dev/null +++ b/test/shopBilder.test.js @@ -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 -l.webp und -s.webp unter //', 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) +}) diff --git a/test/shopRoutes.test.js b/test/shopRoutes.test.js new file mode 100644 index 0000000..514935d --- /dev/null +++ b/test/shopRoutes.test.js @@ -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, `
\n`) + 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) +})