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']) })