Files
Webklar-Kundenbereich/test/publicShopWiderruf.test.js
T
ClaudeandClaude Opus 5.5 b2072e4e7c Shop Review B M-2: Widerrufsfunktion laeuft auch bei SHOP_ENABLED=off und ausserhalb des Pilotkreises
GET /:siteKey liefert dann die Widerrufsseite (nurWiderruf), POST /:siteKey/widerruf samt
Preflight geht durch, solange die Funktion active oder disabled ist. Bestellen, Pruefen, Status
und Bilder bleiben gesperrt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:59:58 +00:00

410 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Oeffentliche Schnittstelle des Webshops, POST /:siteKey/widerruf (F3) samt Preflight: Herkunft, Honigtopf, Formular-Token,
// Fehlervertrag, Grenzen. Mails landen in der Mailsenke; nichts verlaesst den Prozess.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { formToken } = await import('../server/services/shop/token.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js')
const app = await startTestApp({ '/api/public/shop/v1': router })
after(async () => {
setMailTransportForTests(null)
await Promise.all([app.close(), aw.close()])
})
const BASIS = '/api/public/shop/v1'
const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin (MESZ)
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const O_LIVE = 'https://brot.example'
const O_PORTAL = 'https://portal.example'
const VORHER = { mode: config.shop.mode, pilot: [...config.shop.pilotEmails], basis: config.publicBaseUrl, formMin: config.shop.formMinSekunden, minAntwort: config.shop.minAntwortMs, from: config.smtp.from }
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
}
const EINGABE = { name: 'Max Mustermann', email: 'Max@Example.com', bestellnummer: '26-k7qm2x', nachricht: 'Ich widerrufe.' }
const bestellung = () => ({
$id: 'o_bestellung01',
shopId: FEATURE,
customerId: KUNDE,
bestellnummer: '26-K7QM2X',
status: 'bezahlt',
kaeuferEmail: 'max@example.com',
summeCent: 1200,
erstelltAt: JETZT.toISOString(),
bezahltAt: JETZT.toISOString(),
bestellung: JSON.stringify({ positionen: [{ name: 'Brot', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }], verlauf: [{ at: JETZT.toISOString(), was: 'angelegt' }] }),
})
let sink
let ip
let n = 0
const feature = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
const shop = () => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: {}, bestaetigt: null }),
geoeffnet: true,
version: 1,
})
async function aufbau({ state = 'active', mitBestellung = true } = {}) {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'termineOutbox', 'websiteProjects', 'customers']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
K.leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'brot.example' })
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'inhaber@brot.example' })
aw.seed('portalFeatures', feature({ state }))
aw.seed('shopConfig', shop())
if (mitBestellung) aw.seed('shopOrders', bestellung())
}
const token = (alterMs = 10000) => formToken(SITE_KEY, new Date(JETZT.getTime() - alterMs))
const koerper = (over = {}) => ({ ...EINGABE, formToken: token(), ...over })
const post = (pfad, body, { origin = O_LIVE, ip: adresse = ip, headers = {} } = {}) =>
send('POST', BASIS + pfad, { body, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
const widerruf = (body = koerper(), opts) => post(`/${SITE_KEY}/widerruf`, body, opts)
const kopf = (res, name) => res.headers.get(name)
const widerrufe = () => [...aw.docs('shopWiderrufe').values()]
const outbox = () => [...aw.docs('termineOutbox').values()]
beforeEach(async () => {
ip = `10.2.${Math.floor(++n / 250)}.${(n % 250) + 1}`
config.shop.mode = 'on'
config.shop.pilotEmails = []
config.shop.formMinSekunden = 3
config.shop.minAntwortMs = 0
config.publicBaseUrl = O_PORTAL
config.smtp.from = 'noreply@webklar.com'
setzeUhrFuerTests(() => JETZT)
sink = mailSink()
setMailTransportForTests(sink)
mock.method(console, 'warn', () => {})
await aufbau()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
resetPublicShopForTests()
config.shop.mode = VORHER.mode
config.shop.pilotEmails = VORHER.pilot
config.shop.formMinSekunden = VORHER.formMin
config.shop.minAntwortMs = VORHER.minAntwort
config.publicBaseUrl = VORHER.basis
config.smtp.from = VORHER.from
})
const VERBOTEN = ['customerId', 'projectId', 'featureId', '$id', 'o_bestellung01', KUNDE, PROJEKT, FEATURE, 'inhaber@brot.example', 'max@example.com']
const ohneInternes = (body) => {
const s = JSON.stringify(body)
for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`)
}
test('Konfiguration: formMinSekunden und minAntwortMs sind Zahlen (Vorgaben 3 und 300)', () => {
assert.equal(typeof VORHER.formMin, 'number')
assert.equal(VORHER.formMin, 3)
assert.equal(VORHER.minAntwort, 300)
})
test('Erfolg mit passender Bestellung: { ok, eingang } ohne IDs, Widerruf gespeichert, G12 in der Warteschlange', async () => {
const res = await widerruf()
assert.equal(res.status, 200)
assert.deepEqual(res.body, { ok: true, eingang: '05.10.2026, 14:03 Uhr' })
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.match(kopf(res, 'vary'), /Origin/)
assert.equal(kopf(res, 'cache-control'), 'no-store')
assert.equal(kopf(res, 'x-content-type-options'), 'nosniff')
ohneInternes(res.body)
await warteAufVersand()
const [doc] = widerrufe()
assert.equal(doc.orderId, 'o_bestellung01')
assert.equal(doc.shopId, FEATURE)
assert.equal(doc.email, 'max@example.com')
assert.equal(doc.bestellnummer, '26-K7QM2X')
const [g12] = outbox()
assert.equal(g12.art, 'G12')
assert.equal(g12.featureId, FEATURE)
const kaeufer = sink.sent.find((m) => m.to.some((t) => t.address === 'max@example.com'))
assert.ok(kaeufer, 'G12 versandt')
assert.ok(kaeufer.text.includes('05.10.2026, 14:03 Uhr'))
})
test('Erfolg ohne passende Bestellung: trotzdem erfasst und bestätigt', async () => {
await aufbau({ mitBestellung: false })
const res = await widerruf()
assert.equal(res.status, 200)
assert.equal(res.body.ok, true)
await warteAufVersand()
assert.equal(widerrufe().length, 1)
assert.equal(widerrufe()[0].orderId, '')
assert.equal(outbox()[0].art, 'G12')
})
test('Herkunft: Live-Domain des Betriebs, Portal und gleiche Herkunft sind erlaubt', async () => {
assert.equal((await widerruf(koerper(), { origin: O_PORTAL })).status, 200)
const gleich = await widerruf(koerper(), { origin: null, headers: { origin: app.url } })
assert.equal(gleich.status, 200)
assert.equal(kopf(gleich, 'access-control-allow-origin'), app.url)
})
test('fremde Origin: 403 ohne Eintrag und ohne erlaubende CORS-Kopfzeile', async () => {
for (const origin of ['https://fremd.example', 'https://brot.example.evil.example', 'https://brot.example/', 'HTTPS://BROT.EXAMPLE', 'null', 'ftp://brot.example']) {
const res = await widerruf(koerper(), { origin })
assert.equal(res.status, 403, origin)
assert.deepEqual(res.body, { fehler: { _: 'Nicht erlaubt' } })
assert.equal(kopf(res, 'access-control-allow-origin'), null)
}
assert.equal(widerrufe().length, 0)
})
test('fehlende Origin: 403', async () => {
const res = await widerruf(koerper(), { origin: null })
assert.equal(res.status, 403)
assert.deepEqual(res.body, { fehler: { _: 'Nicht erlaubt' } })
assert.equal(widerrufe().length, 0)
})
test('Preflight: erlaubte Origin bekommt 204 mit Methoden, Header, Max-Age und Vary: Origin (widerruf, pruefen, bestellung)', async () => {
for (const was of ['widerruf', 'pruefen', 'bestellung']) {
const res = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { 'x-forwarded-for': ip, origin: O_LIVE, 'access-control-request-method': 'POST' } })
assert.equal(res.status, 204, was)
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.equal(kopf(res, 'access-control-allow-methods'), 'GET, POST, OPTIONS')
assert.equal(kopf(res, 'access-control-allow-headers'), 'Content-Type')
assert.equal(kopf(res, 'access-control-max-age'), '86400')
assert.match(kopf(res, 'vary'), /Origin/)
}
})
test('Preflight: fremde und fehlende Origin 403, unbekannter Shop und unbekannte Route ohne Verrat', async () => {
const fremd = await send('OPTIONS', `${BASIS}/${SITE_KEY}/widerruf`, { headers: { 'x-forwarded-for': ip, origin: 'https://fremd.example' } })
assert.equal(fremd.status, 403)
assert.equal(kopf(fremd, 'access-control-allow-origin'), null)
const ohne = await send('OPTIONS', `${BASIS}/${SITE_KEY}/widerruf`, { headers: { 'x-forwarded-for': ip } })
assert.equal(ohne.status, 403)
// unbekannter siteKey: nichts zu pruefen, nichts zu verraten
const unbekannt = await send('OPTIONS', `${BASIS}/zzzzzzzzzzzzzzzz/widerruf`, { headers: { 'x-forwarded-for': ip, origin: 'https://fremd.example' } })
assert.equal(unbekannt.status, 204)
assert.equal(kopf(unbekannt, 'access-control-allow-origin'), '*')
// andere Pfade sind keine POST-Routen
const anders = await send('OPTIONS', `${BASIS}/${SITE_KEY}/gibtsnicht`, { headers: { 'x-forwarded-for': ip, origin: O_LIVE } })
assert.equal(anders.status, 404)
})
test('Honigtopf kontakt_url gefüllt: gleiche Erfolgsform mit aktueller Zeit, nichts gespeichert, keine Mail', async () => {
for (const honig of ['http://spam.example', 'x', 0, false, ['a'], {}]) {
const res = await widerruf(koerper({ kontakt_url: honig }))
assert.equal(res.status, 200, JSON.stringify(honig))
assert.deepEqual(res.body, { ok: true, eingang: '05.10.2026, 14:03 Uhr' })
}
await warteAufVersand()
assert.equal(widerrufe().length, 0)
assert.equal(outbox().length, 0)
assert.equal(sink.sent.length, 0)
// leer oder Leerraum zählt nicht als gefüllt
assert.equal((await widerruf(koerper({ kontakt_url: ' ' }))).status, 200)
await warteAufVersand()
assert.equal(widerrufe().length, 1)
})
test('Honigtopf bei fremder Origin: trotzdem 403 (Herkunft vor Falle)', async () => {
assert.equal((await widerruf(koerper({ kontakt_url: 'x' }), { origin: 'https://fremd.example' })).status, 403)
})
test('Token zu schnell: Scheinerfolg, nichts gespeichert', async () => {
const res = await widerruf(koerper({ formToken: token(1000) }))
assert.equal(res.status, 200)
assert.deepEqual(res.body, { ok: true, eingang: '05.10.2026, 14:03 Uhr' })
await warteAufVersand()
assert.equal(widerrufe().length, 0)
assert.equal(sink.sent.length, 0)
})
test('Token abgelaufen, fehlend, fremd oder kaputt: 400 "Bitte laden Sie die Seite neu." mit code neu_laden', async () => {
const faelle = [
token(3 * 60 * 60 * 1000),
undefined,
'',
'abc.def',
formToken('anderer-key-12345', new Date(JETZT.getTime() - 10000)),
12345,
'x'.repeat(500),
]
for (const t of faelle) {
const body = koerper({ formToken: t })
if (t === undefined) delete body.formToken
const res = await widerruf(body)
assert.equal(res.status, 400, String(t).slice(0, 20))
assert.deepEqual(res.body, { fehler: { _: 'Bitte laden Sie die Seite neu.' }, code: 'neu_laden' })
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
}
assert.equal(widerrufe().length, 0)
})
test('kein Objekt als Rumpf: 400; zu groß: 413', async () => {
for (const body of [[], 'text', 5]) {
const res = await send('POST', `${BASIS}/${SITE_KEY}/widerruf`, { body: JSON.stringify(body), headers: { 'x-forwarded-for': ip, origin: O_LIVE, 'content-type': 'application/json' } })
assert.ok(res.status === 400, JSON.stringify(body))
}
const gross = await widerruf(koerper({ nachricht: 'x'.repeat(20000) }))
assert.equal(gross.status, 413)
})
test('Ohne Bestellnummer: 400 fehler.nachricht, solange die Erklärung fehlt; mit Erklärung 200', async () => {
const leer = await widerruf(koerper({ bestellnummer: '', nachricht: '' }))
assert.equal(leer.status, 400)
assert.deepEqual(leer.body.fehler, { nachricht: 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.' })
const ok = await widerruf(koerper({ bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau' }))
assert.equal(ok.status, 200)
assert.equal(ok.body.ok, true)
})
test('Prüfung: 400 mit fehler je Feld, nichts gespeichert', async () => {
const res = await widerruf(koerper({ name: 'M', email: 'keine-adresse', bestellnummer: '', nachricht: 'x'.repeat(2001) }))
assert.equal(res.status, 400)
assert.deepEqual(Object.keys(res.body.fehler).sort(), ['email', 'nachricht', 'name'])
for (const t of Object.values(res.body.fehler)) assert.match(t, /Bitte|darf/)
assert.equal(res.body.code, undefined)
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.equal(widerrufe().length, 0)
})
test('Grenze je IP: nach 10 Versuchen je Stunde 429 mit Retry-After und Origin', async () => {
const eigene = '203.0.113.77'
for (let i = 0; i < 10; i++) assert.equal((await widerruf(koerper(), { ip: eigene })).status, 200, `Versuch ${i + 1}`)
const res = await widerruf(koerper(), { ip: eigene })
assert.equal(res.status, 429)
assert.equal(kopf(res, 'retry-after'), '3600')
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
assert.deepEqual(Object.keys(res.body), ['fehler'])
assert.match(res.body.fehler._, /Zu viele/)
})
test('Grenze je Shop (20 je Stunde): 429 für andere IPs', async () => {
for (let i = 0; i < 20; i++) assert.equal((await widerruf(koerper(), { ip: `198.51.100.${i + 1}` })).status, 200, `Versuch ${i + 1}`)
const res = await widerruf(koerper(), { ip: '198.51.100.99' })
assert.equal(res.status, 429)
assert.equal(res.body.fehler._.startsWith('Zu viele'), true)
})
test('Grenze für Anfragen je IP (darfAnfrage) kommt vor allem anderen: 429 mit Retry-After 60 und *', async () => {
const eigene = '203.0.113.88'
let letzte
for (let i = 0; i < 125; i++) {
letzte = await widerruf(koerper({ kontakt_url: 'x' }), { ip: eigene, origin: 'https://fremd.example' })
if (letzte.status === 429) break
}
assert.equal(letzte.status, 429)
assert.equal(kopf(letzte, 'retry-after'), '60')
assert.equal(kopf(letzte, 'access-control-allow-origin'), '*')
})
test('abgeschalteter Shop (disabled): Widerruf geht weiter', async () => {
await aufbau({ state: 'disabled' })
const res = await widerruf()
assert.equal(res.status, 200)
assert.equal(res.body.ok, true)
await warteAufVersand()
assert.equal(widerrufe().length, 1)
assert.equal(outbox()[0].art, 'G12')
})
test('unbekannter siteKey und andere Zustände: 200 { aktiv: false } ohne Origin-Prüfung', async () => {
const unbekannt = await post('/zzzzzzzzzzzzzzzz/widerruf', koerper(), { origin: 'https://fremd.example' })
assert.equal(unbekannt.status, 200)
assert.deepEqual(unbekannt.body, { aktiv: false })
assert.equal(kopf(unbekannt, 'access-control-allow-origin'), '*')
assert.equal(kopf(unbekannt, 'cache-control'), 'no-store')
const ungueltig = await post('/x/widerruf', koerper())
assert.deepEqual(ungueltig.body, { aktiv: false })
await aufbau({ state: 'pending' })
assert.deepEqual((await widerruf()).body, { aktiv: false })
assert.equal(widerrufe().length, 0)
})
test('M-2: der Widerruf geht auch bei SHOP_ENABLED=off und für Besitzer ausserhalb des Pilotkreises (active und disabled), Preflight ebenso', async () => {
for (const [modus, pilot] of [['off', []], ['pilot', ['jemand@anders.example']], ['pilot', []], ['on', []]]) {
config.shop.mode = modus
config.shop.pilotEmails = pilot
K.leereCache()
const vorher = widerrufe().length
const res = await widerruf()
assert.equal(res.body.ok, true, `${modus} ${pilot}`)
await warteAufVersand()
assert.equal(widerrufe().length, vorher + 1, `${modus}: Widerruf gespeichert`)
const preflight = await send('OPTIONS', `${BASIS}/${SITE_KEY}/widerruf`, { headers: { 'x-forwarded-for': ip, origin: O_LIVE, 'access-control-request-method': 'POST' } })
assert.equal(preflight.status, 204, `${modus}: Preflight`)
assert.equal(kopf(preflight, 'access-control-allow-origin'), O_LIVE)
}
// abgeschaltete Funktion: ebenfalls
config.shop.mode = 'off'
await aufbau({ state: 'disabled' })
assert.equal((await widerruf()).body.ok, true)
// aber die Bestellrouten bleiben bei off zu
const pruefen = await post(`/${SITE_KEY}/pruefen`, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'abholung' })
assert.deepEqual(pruefen.body, { aktiv: false })
// nicht active/disabled bleibt zu
await aufbau({ state: 'pending' })
assert.deepEqual((await widerruf()).body, { aktiv: false })
})
test('Mindestantwortzeit: auch Fehler und Fallen antworten nicht schneller als minAntwortMs', async () => {
config.shop.minAntwortMs = 250
for (const body of [koerper(), koerper({ kontakt_url: 'x' }), koerper({ formToken: 'kaputt' }), koerper({ name: '' })]) {
const t0 = performance.now()
await widerruf(body)
assert.ok(performance.now() - t0 >= 240, `zu schnell: ${Math.round(performance.now() - t0)} ms`)
}
})
test('Antworten enthalten nie IDs oder Adressen (Erfolg, Falle, Fehler)', async () => {
const antworten = [await widerruf(), await widerruf(koerper({ kontakt_url: 'x' })), await widerruf(koerper({ name: '' })), await widerruf(koerper({ formToken: 'x' }))]
for (const a of antworten) ohneInternes(a.body)
assert.deepEqual(Object.keys(antworten[0].body).sort(), ['eingang', 'ok'])
})