diff --git a/server/config.js b/server/config.js index 1c63937..b0c69fc 100644 --- a/server/config.js +++ b/server/config.js @@ -812,6 +812,10 @@ export const config = { maxVarianten: 30, // Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J anonymisierenJahre: 3, + // Mindestzeit (Sekunden) zwischen Laden und Absenden des Widerrufs-/Bestellformulars (schneller = Bot) + formMinSekunden: nonNegativeNumber(process.env.SHOP_FORM_MIN_SEKUNDEN, 3), + // Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle (verwischt Zeitunterschiede). Tests setzen 0. + minAntwortMs: nonNegativeNumber(process.env.SHOP_MIN_ANTWORT_MS, 300), // Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten loeschTageNachAbschalten: 90, // Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js index 4f05fbe..691a5d1 100644 --- a/server/routes/publicShop.js +++ b/server/routes/publicShop.js @@ -1,5 +1,6 @@ import express, { Router } from 'express' import { config } from '../config.js' +import { analyseDomains } from '../services/analyse.js' import { getDocument } from '../services/appwriteAdmin.js' import { bildMeta, bildPfad } from '../services/shop/bilder.js' import { reserviert } from '../services/shop/bestellungen.js' @@ -8,7 +9,8 @@ import { euro } from '../services/shop/geld.js' import { listeProdukte, parseProdukt, produktOeffentlich } from '../services/shop/produkte.js' import { GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, versandUndZahlung, zahlungsartenText } from '../services/shop/rechtstexte.js' import { shopFuerKunde, shopModus } from '../services/shop/schalter.js' -import { formToken } from '../services/shop/token.js' +import { formToken, pruefeFormToken } from '../services/shop/token.js' +import { eingangText, erfasseWiderruf } from '../services/shop/widerrufe.js' import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js' /** @@ -25,7 +27,9 @@ import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js * - Unbekannter siteKey, Modus off, Pilotmodus fuer einen Kunden ausserhalb der Pilotliste oder eine Funktion, die nicht active * oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Eine abgeschaltete Funktion (disabled) antwortet mit * { aktiv: true, offen: false, nurWiderruf: true, ... }, damit Kaeufer den Widerruf weiter erklaeren koennen. - * - CORS: GET fuer alle (*). Meldungen ohne Daten (429, 503, aktiv:false) tragen `*`. + * - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals + * (config.publicBaseUrl) und gleicher Herkunft (Host-Header), Origin in kanonischer Schreibweise; sonst 403. Meldungen ohne + * Daten (429, 503, aktiv:false) tragen `*`, bei geprueftem POST die Origin. * - Grenzen: je IP 120 je Minute (darfAnfrage), je Shop 600 je Minute (Schluessel 'shop:'); die Artikelbilder haben * eigene, groessere Zaehler, damit eine Seite mit vielen Bildern die Konfiguration nicht aussperrt. */ @@ -338,6 +342,114 @@ router.get( }), ) +// ---------------------------------------------------------------- Herkunft, POST-Hilfen + +const MELDUNG_NEU_LADEN = 'Bitte laden Sie die Seite neu.' +const nichtErlaubt = () => fehlerMit(403, { _: 'Nicht erlaubt' }) +/** POST-Routen, deren Preflight dieselbe Pruefung bekommt (F3 widerruf; F2 pruefen, bestellung). */ +const POST_ROUTEN = new Set(['widerruf', 'pruefen', 'bestellung']) + +/** + * Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project) (Seiten des Betriebs), Host von + * config.publicBaseUrl (Seiten des Portals) oder gleiche Herkunft wie die Anfrage (Host-Header). Sonst null (fehlende, fremde, + * unlesbare Origin oder nicht in kanonischer Schreibweise). + */ +function erlaubteHerkunft(req, project) { + const origin = req.get('origin') + if (typeof origin !== 'string' || origin === '') return null + let url + try { + url = new URL(origin) + } catch { + return null + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null + const erlaubt = new Set([String(req.get('host') || '').toLowerCase()]) + try { + erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase()) + } catch { + // Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft + } + if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain) + return erlaubt.has(url.host) ? origin : null +} + +/** Prueft die Herkunft eines POST und merkt sie in cors; sonst 403. */ +function pruefeHerkunft(req, cors, project) { + const origin = erlaubteHerkunft(req, project) + if (!origin) throw nichtErlaubt() + cors.erlaubt = origin +} + +/** Antwort auf einen Preflight (204): erlaubte Methoden, Content-Type, 24 Stunden gueltig. */ +const vorabantwort = (origin) => ({ + status: 204, + body: '', + headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }, +}) + +// Preflight unter /:siteKey/: erlaubt fuer Herkunft des Betriebs, des Portals und gleiche Herkunft; ohne +// erreichbaren Shop (unbekannt, off, Pilot) gibt es nichts zu pruefen und nichts zu verraten. +router.options( + '/:siteKey/:was', + route(async (req, now, cors) => { + if (!POST_ROUTEN.has(req.params.was)) throw nichtGefunden() + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await erreichbarerShop(req.params.siteKey, now) + if (!ctx) return vorabantwort('*') + pruefeHerkunft(req, cors, ctx.project) + return vorabantwort(cors.erlaubt) + }), +) + +// ---------------------------------------------------------------- Widerruf + +/** + * Honigtopf `kontakt_url` (jeder Wert ausser leer) und Formular-Token: Honigtopf oder zu schnell abgeschickt => dieselbe + * Erfolgsantwort wie sonst, gespeichert wird nichts; fehlendes, fremdes oder altes Token => 400 "Seite neu laden". + * @returns {boolean} true, wenn nur so getan werden soll (Falle) + */ +function istFalle(siteKey, b, now) { + const honig = b.kontakt_url + if (honig !== undefined && honig !== null && !(typeof honig === 'string' && honig.trim() === '')) return true + const token = pruefeFormToken(siteKey, b.formToken, now) + if (token === 'zu_schnell') return true + if (token !== 'ok') throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) + return false +} + +router.post( + '/:siteKey/widerruf', + route( + async (req, now, cors) => { + const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await erreichbarerShop(siteKey, now) // active und disabled: der Widerruf bleibt moeglich + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + const b = req.body + if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) + if (istFalle(siteKey, b, now)) return { body: { ok: true, eingang: eingangText(now) } } + + let ergebnis + try { + ergebnis = await erfasseWiderruf(ctx, { name: b.name, email: b.email, bestellnummer: b.bestellnummer, nachricht: b.nachricht }, { now, ip: req.ip }) + } catch (err) { + if (err?.status === 429) throw zuViele(3600) + throw err + } + return { body: { ok: true, eingang: ergebnis.eingang } } + }, + { mindestdauer: true }, + ), +) + // ---------------------------------------------------------------- Artikelbilder router.get( diff --git a/test/publicShopWiderruf.test.js b/test/publicShopWiderruf.test.js new file mode 100644 index 0000000..0659264 --- /dev/null +++ b/test/publicShopWiderruf.test.js @@ -0,0 +1,382 @@ +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)), + 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('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(), ['bestellnummer', '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, Modus off, Pilot ohne Treffer, 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 }) + + config.shop.mode = 'off' + assert.deepEqual((await widerruf()).body, { aktiv: false }) + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['jemand@anders.example'] + assert.deepEqual((await widerruf()).body, { aktiv: false }) + config.shop.pilotEmails = ['inhaber@brot.example'] + assert.equal((await widerruf()).body.ok, true) + await warteAufVersand() + assert.equal(widerrufe().length, 1, 'nur der Pilot-Treffer') + + await aufbau({ state: 'pending' }) + assert.deepEqual((await widerruf()).body, { aktiv: false }) + assert.equal(widerrufe().length, 0) +}) + +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']) +})