From b2072e4e7c832487a450ef6ac844188129e6e9fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:59:58 +0000 Subject: [PATCH] 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 --- server/routes/publicShop.js | 33 +++++++++++++++++++++------- test/publicShop.test.js | 37 +++++++++++++++++++++++++++----- test/publicShopWiderruf.test.js | 38 +++++++++++++++++++++++---------- 3 files changed, 84 insertions(+), 24 deletions(-) diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js index 638f958..5d788b9 100644 --- a/server/routes/publicShop.js +++ b/server/routes/publicShop.js @@ -26,9 +26,11 @@ import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js * Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar"). * - Antworten enthalten nie customerId, projectId, featureId, $id, Stripe-IDs oder E-Mail-Adressen ausser der oeffentlichen * Anbieter-Adresse des Haendlers. - * - 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. + * - Unbekannter siteKey oder eine Funktion, die nicht active oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Bestellen, + * Pruefen, Status und Bilder zusaetzlich nur bei Modus on bzw. Pilotmodus mit Besitzer auf der Pilotliste. Eine abgeschaltete + * Funktion (disabled), Modus off und ein Besitzer ausserhalb der Pilotliste antworten beim Lesen mit + * { aktiv: true, offen: false, nurWiderruf: true, ... }, und POST /widerruf (samt Preflight) geht weiter: die Widerrufsfunktion + * ist gesetzliche Pflicht (§ 356a BGB), solange es Bestellungen geben kann. * - 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. @@ -234,6 +236,20 @@ async function erreichbarerShop(siteKey, now) { return ctx } +/** + * Zugang fuer die Widerrufsfunktion (§ 356a BGB): sie bleibt erreichbar, solange die Funktion active oder disabled ist, auch bei + * SHOP_ENABLED=off und wenn der Besitzer nicht (mehr) zum Pilotkreis gehoert; die gesetzliche Pflicht laeuft mit den Bestellungen + * weiter. Bestellen bleibt gesperrt (`bestellbar`). Bei off wird die Pilotliste gar nicht erst gelesen. + * @returns {Promise<{ ctx: object|null, bestellbar: boolean }>} + */ +async function widerrufZugang(siteKey, now) { + const ctx = await shopPerSiteKey(siteKey, { now }) + if (!ctx) return { ctx: null, bestellbar: false } + const modus = shopModus() + const bestellbar = modus === 'on' || (modus === 'pilot' && shopFuerKunde(await besitzerEmail(ctx))) + return { ctx, bestellbar } +} + // ---------------------------------------------------------------- Konfiguration const text = (wert) => (typeof wert === 'string' ? wert.trim() : '') @@ -298,7 +314,7 @@ router.get( route(async (req, now) => { const { siteKey } = req.params if (!darfAnfrage(req.ip, now)) throw zuViele(60) - const ctx = await erreichbarerShop(siteKey, now) + const { ctx, bestellbar } = await widerrufZugang(siteKey, now) if (!ctx) return aktivFalse(req) if (!zaehle(`shop:${siteKey}`, SHOP_JE_MINUTE, MINUTE_MS, now)) throw zuViele(60) @@ -306,8 +322,8 @@ router.get( const widerrufUrl = `${basisUrl()}/shop/${siteKey}/widerruf` const token = formToken(siteKey, now) - // Abgeschaltet: nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar - if (ctx.doc.state === 'disabled') { + // Abgeschaltet (Funktion disabled, SHOP_ENABLED=off oder ausserhalb des Pilotkreises): nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar + if (ctx.doc.state === 'disabled' || !bestellbar) { return { body: { aktiv: true, @@ -404,7 +420,8 @@ router.options( 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) + // Widerruf: auch ohne Bestellmodus erreichbar (siehe widerrufZugang); alle anderen Routen nur bei erreichbarem Shop + const ctx = req.params.was === 'widerruf' ? (await widerrufZugang(req.params.siteKey, now)).ctx : await erreichbarerShop(req.params.siteKey, now) if (!ctx) return vorabantwort('*') pruefeHerkunft(req, cors, ctx.project) return vorabantwort(cors.erlaubt) @@ -433,7 +450,7 @@ router.post( 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 + const { ctx } = await widerrufZugang(siteKey, now) // active und disabled, unabhaengig von SHOP_ENABLED und Pilotliste: der Widerruf bleibt moeglich if (!ctx) return aktivFalse(req) pruefeHerkunft(req, cors, ctx.project) const b = req.body diff --git a/test/publicShop.test.js b/test/publicShop.test.js index 186b29c..0f43909 100644 --- a/test/publicShop.test.js +++ b/test/publicShop.test.js @@ -276,26 +276,53 @@ test('aktiv:false ohne Orakel: unbekannt, ungueltig, falscher Zustand, Modus off await aufbau({ shop: null }) // kein shopConfig await nein(`/${SITE_KEY}`) await aufbau() - config.shop.mode = 'off' - await nein(`/${SITE_KEY}`) config.shop.mode = 'on' assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) }) -test('Pilotmodus: nur Besitzer auf der Pilotliste', async () => { +test('M-2: Modus off und Pilotkreis ohne Besitzer sperren das Bestellen, nicht die Widerrufsseite: GET liefert nurWiderruf, Bestellrouten bleiben aktiv:false', async () => { + const nurWiderruf = (body) => { + assert.equal(body.aktiv, true) + assert.equal(body.offen, false) + assert.equal(body.nurWiderruf, true) + assert.ok(body.formToken && body.widerrufUrl.endsWith(`/shop/${SITE_KEY}/widerruf`)) + assert.equal(body.shop.name, 'Brot & Mehr') + assert.equal(body.produkte, undefined) + assert.equal(body.rechtstexte, undefined) + } + config.shop.mode = 'off' + nurWiderruf((await api(`/${SITE_KEY}`)).body) + assert.equal((await api(`/${SITE_KEY}/bild/${BILD_ID}-s.webp`)).status, 404) + K.leereCache() config.shop.mode = 'pilot' config.shop.pilotEmails = ['andere@example.com'] + nurWiderruf((await api(`/${SITE_KEY}`)).body) + K.leereCache() + config.shop.pilotEmails = ['inhaber@brot.example'] + assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, undefined, 'im Pilotkreis wie gewohnt') + // unbekannter Shop und andere Zustaende bleiben aktiv:false, auch bei off + config.shop.mode = 'off' + await aufbau({ feature: { state: 'off' } }) assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false }) + assert.deepEqual((await api('/zzzzzzzzzzzzzzzz')).body, { aktiv: false }) +}) + +test('Pilotmodus: nur Besitzer auf der Pilotliste bekommen den Shop, alle anderen nur die Widerrufsseite', async () => { + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['andere@example.com'] + const gesperrt = (await api(`/${SITE_KEY}`)).body + assert.deepEqual([gesperrt.aktiv, gesperrt.nurWiderruf, gesperrt.produkte], [true, true, undefined]) K.leereCache() config.shop.pilotEmails = ['inhaber@brot.example'] assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) + assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, undefined) K.leereCache() config.shop.pilotEmails = ['*'] - assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true) + assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, undefined) // Kunde ohne Dokument: nie freigeschaltet await aufbau({ kunde: null }) config.shop.pilotEmails = ['inhaber@brot.example'] - assert.deepEqual((await api(`/${SITE_KEY}`)).body, { aktiv: false }) + assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, true) // im Pilotmodus gilt dasselbe fuer die Bilder await aufbau() config.shop.pilotEmails = ['andere@example.com'] diff --git a/test/publicShopWiderruf.test.js b/test/publicShopWiderruf.test.js index 96b0a32..48f8163 100644 --- a/test/publicShopWiderruf.test.js +++ b/test/publicShopWiderruf.test.js @@ -353,7 +353,7 @@ test('abgeschalteter Shop (disabled): Widerruf geht weiter', async () => { 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 () => { +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 }) @@ -362,21 +362,37 @@ test('unbekannter siteKey, Modus off, Pilot ohne Treffer, andere Zustände: 200 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('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: '' })]) {