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>
This commit is contained in:
1 parent
08d837d690
commit
b2072e4e7c
3 files changed
+84
-24
No files matched your search
@@ -26,9 +26,11 @@ import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js
|
|||||||
* Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar").
|
* 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
|
* - Antworten enthalten nie customerId, projectId, featureId, $id, Stripe-IDs oder E-Mail-Adressen ausser der oeffentlichen
|
||||||
* Anbieter-Adresse des Haendlers.
|
* Anbieter-Adresse des Haendlers.
|
||||||
* - Unbekannter siteKey, Modus off, Pilotmodus fuer einen Kunden ausserhalb der Pilotliste oder eine Funktion, die nicht active
|
* - Unbekannter siteKey oder eine Funktion, die nicht active oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Bestellen,
|
||||||
* oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Eine abgeschaltete Funktion (disabled) antwortet mit
|
* Pruefen, Status und Bilder zusaetzlich nur bei Modus on bzw. Pilotmodus mit Besitzer auf der Pilotliste. Eine abgeschaltete
|
||||||
* { aktiv: true, offen: false, nurWiderruf: true, ... }, damit Kaeufer den Widerruf weiter erklaeren koennen.
|
* 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
|
* - 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
|
* (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.
|
* Daten (429, 503, aktiv:false) tragen `*`, bei geprueftem POST die Origin.
|
||||||
@@ -234,6 +236,20 @@ async function erreichbarerShop(siteKey, now) {
|
|||||||
return ctx
|
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
|
// ---------------------------------------------------------------- Konfiguration
|
||||||
|
|
||||||
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
|
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
|
||||||
@@ -298,7 +314,7 @@ router.get(
|
|||||||
route(async (req, now) => {
|
route(async (req, now) => {
|
||||||
const { siteKey } = req.params
|
const { siteKey } = req.params
|
||||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
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 (!ctx) return aktivFalse(req)
|
||||||
if (!zaehle(`shop:${siteKey}`, SHOP_JE_MINUTE, MINUTE_MS, now)) throw zuViele(60)
|
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 widerrufUrl = `${basisUrl()}/shop/${siteKey}/widerruf`
|
||||||
const token = formToken(siteKey, now)
|
const token = formToken(siteKey, now)
|
||||||
|
|
||||||
// Abgeschaltet: nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar
|
// Abgeschaltet (Funktion disabled, SHOP_ENABLED=off oder ausserhalb des Pilotkreises): nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar
|
||||||
if (ctx.doc.state === 'disabled') {
|
if (ctx.doc.state === 'disabled' || !bestellbar) {
|
||||||
return {
|
return {
|
||||||
body: {
|
body: {
|
||||||
aktiv: true,
|
aktiv: true,
|
||||||
@@ -404,7 +420,8 @@ router.options(
|
|||||||
route(async (req, now, cors) => {
|
route(async (req, now, cors) => {
|
||||||
if (!POST_ROUTEN.has(req.params.was)) throw nichtGefunden()
|
if (!POST_ROUTEN.has(req.params.was)) throw nichtGefunden()
|
||||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
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('*')
|
if (!ctx) return vorabantwort('*')
|
||||||
pruefeHerkunft(req, cors, ctx.project)
|
pruefeHerkunft(req, cors, ctx.project)
|
||||||
return vorabantwort(cors.erlaubt)
|
return vorabantwort(cors.erlaubt)
|
||||||
@@ -433,7 +450,7 @@ router.post(
|
|||||||
async (req, now, cors) => {
|
async (req, now, cors) => {
|
||||||
const { siteKey } = req.params
|
const { siteKey } = req.params
|
||||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
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)
|
if (!ctx) return aktivFalse(req)
|
||||||
pruefeHerkunft(req, cors, ctx.project)
|
pruefeHerkunft(req, cors, ctx.project)
|
||||||
const b = req.body
|
const b = req.body
|
||||||
|
|||||||
+32
-5
@@ -276,26 +276,53 @@ test('aktiv:false ohne Orakel: unbekannt, ungueltig, falscher Zustand, Modus off
|
|||||||
await aufbau({ shop: null }) // kein shopConfig
|
await aufbau({ shop: null }) // kein shopConfig
|
||||||
await nein(`/${SITE_KEY}`)
|
await nein(`/${SITE_KEY}`)
|
||||||
await aufbau()
|
await aufbau()
|
||||||
config.shop.mode = 'off'
|
|
||||||
await nein(`/${SITE_KEY}`)
|
|
||||||
config.shop.mode = 'on'
|
config.shop.mode = 'on'
|
||||||
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
|
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.mode = 'pilot'
|
||||||
config.shop.pilotEmails = ['andere@example.com']
|
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(`/${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()
|
K.leereCache()
|
||||||
config.shop.pilotEmails = ['inhaber@brot.example']
|
config.shop.pilotEmails = ['inhaber@brot.example']
|
||||||
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
|
assert.equal((await api(`/${SITE_KEY}`)).body.aktiv, true)
|
||||||
|
assert.equal((await api(`/${SITE_KEY}`)).body.nurWiderruf, undefined)
|
||||||
K.leereCache()
|
K.leereCache()
|
||||||
config.shop.pilotEmails = ['*']
|
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
|
// Kunde ohne Dokument: nie freigeschaltet
|
||||||
await aufbau({ kunde: null })
|
await aufbau({ kunde: null })
|
||||||
config.shop.pilotEmails = ['inhaber@brot.example']
|
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
|
// im Pilotmodus gilt dasselbe fuer die Bilder
|
||||||
await aufbau()
|
await aufbau()
|
||||||
config.shop.pilotEmails = ['andere@example.com']
|
config.shop.pilotEmails = ['andere@example.com']
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ test('abgeschalteter Shop (disabled): Widerruf geht weiter', async () => {
|
|||||||
assert.equal(outbox()[0].art, 'G12')
|
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' })
|
const unbekannt = await post('/zzzzzzzzzzzzzzzz/widerruf', koerper(), { origin: 'https://fremd.example' })
|
||||||
assert.equal(unbekannt.status, 200)
|
assert.equal(unbekannt.status, 200)
|
||||||
assert.deepEqual(unbekannt.body, { aktiv: false })
|
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())
|
const ungueltig = await post('/x/widerruf', koerper())
|
||||||
assert.deepEqual(ungueltig.body, { aktiv: false })
|
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' })
|
await aufbau({ state: 'pending' })
|
||||||
assert.deepEqual((await widerruf()).body, { aktiv: false })
|
assert.deepEqual((await widerruf()).body, { aktiv: false })
|
||||||
assert.equal(widerrufe().length, 0)
|
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 () => {
|
test('Mindestantwortzeit: auch Fehler und Fallen antworten nicht schneller als minAntwortMs', async () => {
|
||||||
config.shop.minAntwortMs = 250
|
config.shop.minAntwortMs = 250
|
||||||
for (const body of [koerper(), koerper({ kontakt_url: 'x' }), koerper({ formToken: 'kaputt' }), koerper({ name: '' })]) {
|
for (const body of [koerper(), koerper({ kontakt_url: 'x' }), koerper({ formToken: 'kaputt' }), koerper({ name: '' })]) {
|
||||||
|
|||||||
Reference in new issue
Block a user