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
+1389 -32

No files matched your search

+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)
})