diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js index 691a5d1..f7f0ffc 100644 --- a/server/routes/publicShop.js +++ b/server/routes/publicShop.js @@ -3,9 +3,11 @@ 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' +import { bestellungLesen, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js' +import { sessionHolen } from '../services/shop/connect.js' import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js' import { euro } from '../services/shop/geld.js' +import { berechne } from '../services/shop/preise.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' @@ -81,6 +83,7 @@ function fehlerErgebnis(err) { if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) { const body = { fehler } if (typeof err.code === 'string') body.code = err.code + if (typeof err.produktId === 'string') body.produktId = err.produktId return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } } console.error(`[publicShop] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) @@ -450,6 +453,172 @@ router.post( ), ) +// ---------------------------------------------------------------- Pruefen, Bestellung, Status (F2) + +const PRUEFEN_JE_IP = 60 +const BESTELLUNG_JE_IP = 10 +const BESTELLUNG_FENSTER_MS = 10 * MINUTE_MS +const BESTELLUNG_JE_SHOP = 60 +const STATUS_JE_IP = 120 +const STUNDE_MS = 60 * MINUTE_MS +const BESTELL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ +const BESTELL_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/ +const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.' +const GESCHLOSSEN = () => fehlerMit(409, { _: GESCHLOSSEN_TEXT }, { code: 'geschlossen' }) +// Fehlercodes aus berechne/legeBestellungAn, die Kaeufer sehen duerfen (Stripe-Fehler haben ebenfalls einen String-code) +const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'zu_viele', 'zahlung_nicht_moeglich']) +const KONFLIKT_CODES = new Set(['ausverkauft', 'nicht_verfuegbar', 'nur_abholung']) + +/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON (wie bei legeBestellungAn). */ +function produktKarte(docs) { + const karte = new Map() + for (const doc of docs) { + let p + try { + p = JSON.parse(doc.produkt) + } catch { + continue + } + if (istObjekt(p)) karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false }) + } + return karte +} + +/** Fehler aus legeBestellungAn fuer den Kaeufer: eigene Fehler (bekannter code) mit ihrem Text, alles andere 503 ohne Rohtext. */ +function bestellFehler(err) { + if (typeof err?.code === 'string' && KAEUFER_CODES.has(err.code) && Number.isInteger(err.status) && err.status >= 400 && err.status < 600) { + const extra = { code: err.code, ...(typeof err.produktId === 'string' ? { produktId: err.produktId } : {}) } + if (err.status < 500) return fehlerMit(err.status, { _: String(err.message) }, extra) + return Object.assign(new Error(String(err.message)), { status: 503, fehler: { _: String(err.message) }, ...extra, dienst: true }) + } + return err +} + +// 5xx mit Kaeufertext (zahlung_nicht_moeglich): fehlerErgebnis kennt nur 4xx, also hier eigene Abbildung +const bestellErgebnis = (err) => { + const e = bestellFehler(err) + if (e?.dienst) return { status: 503, body: { fehler: e.fehler, code: e.code } } + throw e +} + +/** Eingabe-Objekt des Kaeufers: nur positionen, lieferart und land (Preise, Namen u. a. werden nie gelesen). */ +const warenkorbEingabe = (b) => ({ positionen: b.positionen, lieferart: b.lieferart, land: b.land }) + +router.post( + '/:siteKey/pruefen', + route(async (req, now, cors) => { + const { siteKey } = req.params + if (!zaehle(`ip:shop-pruefen:${ipSchluessel(req.ip)}`, PRUEFEN_JE_IP, MINUTE_MS, now)) throw zuViele(60) + const ctx = await erreichbarerShop(siteKey, now) + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + const b = req.body + if (!istObjekt(b)) throw fehlerMit(400, UNGUELTIG.fehler) + + const docs = await listeProdukte(ctx.shop.id) + const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: docs }), reserviert(ctx.shop.id, now)]) + if (!offen) throw GESCHLOSSEN() + const eingabe = warenkorbEingabe(b) + const ergebnis = berechne({ produkte: produktKarte(docs), ...eingabe, einstellungen: ctx.shop.einstellungen, reserviert: belegt }) + if (!ergebnis.ok) { + const { code, text, produktId } = ergebnis.fehler + throw fehlerMit(KONFLIKT_CODES.has(code) ? 409 : 400, { _: text }, { code, ...(produktId ? { produktId } : {}) }) + } + const widerrufTexte = new Map() + for (const doc of docs) widerrufTexte.set(doc.$id, produktOeffentlich(doc, { einstellungen: ctx.shop.einstellungen, siteKey })?.widerrufText ?? '') + return { + body: { + ok: true, + positionen: ergebnis.positionen.map((p) => ({ + produktId: p.produktId, + varianteId: p.varianteId, + name: p.name, + varianteText: p.varianteText, + menge: p.menge, + einzelText: p.einzelText, + summeText: p.summeText, + widerrufText: widerrufTexte.get(p.produktId) ?? '', + })), + texte: ergebnis.texte, + lieferart: ergebnis.lieferart, + land: ergebnis.land, + }, + } + }), +) + +router.post( + '/:siteKey/bestellung', + route( + async (req, now, cors) => { + const { siteKey } = req.params + if (!zaehle(`ip:shop-bestellung:${ipSchluessel(req.ip)}`, BESTELLUNG_JE_IP, BESTELLUNG_FENSTER_MS, now)) throw zuViele(600) + const ctx = await erreichbarerShop(siteKey, now) + if (!ctx) return aktivFalse(req) + pruefeHerkunft(req, cors, ctx.project) + if (!zaehle(`shop-bestellung:${siteKey}`, BESTELLUNG_JE_SHOP, STUNDE_MS, now)) throw zuViele(3600) + const b = req.body + if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' }) + if (istFalle(siteKey, b, now)) return { body: { url: pruefeRueckUrl(b.rueckUrl, ctx) } } + + try { + const { url } = await legeBestellungAn(ctx, { ...warenkorbEingabe(b), rueckUrl: b.rueckUrl }, { now }) + return { body: { url } } + } catch (err) { + return bestellErgebnis(err) + } + }, + { mindestdauer: true }, + ), +) + +/** E-Mail-Adresse verdeckt: erstes Zeichen, ***, Domain ('m***@example.com'); leer, wenn es keine gibt. */ +function maskiereEmail(email) { + const t = typeof email === 'string' ? email.trim() : '' + const at = t.lastIndexOf('@') + if (at < 1 || at === t.length - 1) return '' + return `${t[0]}***@${t.slice(at + 1)}` +} + +router.get( + '/:siteKey/bestellung/:id', + route(async (req, now) => { + const { siteKey, id } = req.params + if (!zaehle(`ip:shop-status:${ipSchluessel(req.ip)}`, STATUS_JE_IP, MINUTE_MS, now)) throw zuViele(60) + const keine = () => fehlerMit(404, { _: 'Bestellung nicht gefunden.' }) // fuer jeden Fehlerfall dieselbe Antwort + const t = typeof req.query.t === 'string' ? req.query.t : '' + if (!BESTELL_ID_RE.test(id) || !BESTELL_TOKEN_RE.test(t)) throw keine() + const ctx = await erreichbarerShop(siteKey, now) // active und disabled + if (!ctx) throw keine() + let order = await bestellungLesen(id) + if (!order || order.shopId !== ctx.shop.id || !tokenPasst(order, t)) throw keine() + + // Noch offen: einmal bei Stripe nachsehen (der Webhook kann spaeter kommen als die Rueckkehr des Kaeufers) + const accountId = ctx.shop.stripeAccountId + if (order.status === 'offen' && order.stripeSessionId && accountId) { + try { + const session = await sessionHolen(accountId, order.stripeSessionId) + if (session?.status === 'complete' && session.payment_status === 'paid') { + await schliesseAb({ accountId, session, now }) + order = (await bestellungLesen(id)) ?? order + } + } catch (err) { + console.warn(`[publicShop] Bestellstatus: Stripe-Abgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`) + } + } + const gesamt = order.daten?.summen?.texte?.gesamt + return { + body: { + status: String(order.status ?? ''), + bestellnummer: String(order.bestellnummer ?? ''), + gesamtText: typeof gesamt === 'string' && gesamt !== '' ? gesamt : euro(Number.isInteger(order.summeCent) ? order.summeCent : 0), + lieferart: order.daten?.lieferart === 'abholung' ? 'abholung' : 'versand', + email: maskiereEmail(order.kaeuferEmail), + }, + } + }), +) + // ---------------------------------------------------------------- Artikelbilder router.get( diff --git a/test/publicShopKasse.test.js b/test/publicShopKasse.test.js new file mode 100644 index 0000000..769569b --- /dev/null +++ b/test/publicShopKasse.test.js @@ -0,0 +1,474 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.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, Kasse (F2): POST /pruefen, POST /bestellung, GET /bestellung/:id. Stripe ist der Nachbau, +// Mails landen in der Mailsenke; Preise kommen immer aus der Datenbank. +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, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +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 { normalisiereProdukt } = await import('../server/services/shop/produkte.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') +const { formToken } = await import('../server/services/shop/token.js') +const { euro } = await import('../server/services/shop/geld.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(), st.close()]) +}) + +const BASIS = '/api/public/shop/v1' +const JETZT = new Date('2026-10-05T12:03:00.000Z') +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_shop' +const SITE_KEY = 'abcdefghijklmnop' +const ACCOUNT = 'acct_haendler1' +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: '' }, + abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null }, + zahlungsarten: ['card'], +} +const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' } +const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN) + +const feature = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over }) +const shop = (over = {}) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: SITE_KEY, + anfrageStatus: 'angenommen', + stripeAccountId: ACCOUNT, + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(EINSTELLUNGEN), + rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }), + geoeffnet: true, + version: 1, + ...over, +}) +const artikel = (id, eingabe = {}) => { + const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) } +} + +let sink +let ip +let n = 0 + +async function aufbau({ state = 'active', geoeffnet = true, produkte = [artikel('a1')] } = {}) { + 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({ geoeffnet })) + for (const p of produkte) aw.seed('shopProducts', p) + st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) +} + +const token = (alterMs = 10000) => formToken(SITE_KEY, new Date(JETZT.getTime() - alterMs)) +const warenkorb = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', ...over }) +const kopf = (res, name) => res.headers.get(name) +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 pruefen = (body = warenkorb(), opts) => post(`/${SITE_KEY}/pruefen`, body, opts) +const bestellen = (body = {}, opts) => + post(`/${SITE_KEY}/bestellung`, { ...warenkorb(), rueckUrl: `${O_LIVE}/laden?x=1#top`, formToken: token(), ...body }, opts) +const status = (id, t, { ip: adresse = ip } = {}) => + send('GET', `${BASIS}/${SITE_KEY}/bestellung/${id}${t === undefined ? '' : `?t=${encodeURIComponent(t)}`}`, { headers: { 'x-forwarded-for': adresse } }) +const orders = () => [...aw.docs('shopOrders').values()] +const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST') +const tokenAusUrl = (url) => new URL(url).searchParams.get('wk_t') + +beforeEach(async () => { + ip = `10.3.${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', () => {}) + st.calls.length = 0 + 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', KUNDE, PROJEKT, FEATURE, ACCOUNT, 'inhaber@brot.example', 'stripeSessionId', 'tokenHash'] +const ohneInternes = (body) => { + const s = JSON.stringify(body) + for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`) +} + +// ---------------------------------------------------------------- pruefen + +test('pruefen: Texte vom Server, Preisfelder des Browsers werden ignoriert, nichts wird angelegt', async () => { + const res = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2, preisCent: 1, einzelCent: 1, summeCent: 1, name: 'Hack' }], gesamtCent: 1 })) + assert.equal(res.status, 200) + assert.deepEqual(res.body, { + ok: true, + positionen: [{ produktId: 'a1', varianteId: null, name: 'Artikel a1', varianteText: '', menge: 2, einzelText: euro(1250), summeText: euro(2500), widerrufText: '' }], + texte: { zwischensumme: euro(2500), versand: euro(490), gesamt: euro(2990), steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' }, + lieferart: 'versand', + land: 'DE', + }) + assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE) + assert.equal(kopf(res, 'cache-control'), 'no-store') + ohneInternes(res.body) + assert.equal(orders().length, 0) + assert.equal(sessionCalls().length, 0) +}) + +test('pruefen: Abholung ohne Land, widerrufText aus dem Artikel', async () => { + await aufbau({ produkte: [artikel('a1', { widerruf: 'verderblich' })] }) + const res = await pruefen(warenkorb({ lieferart: 'abholung', land: undefined })) + assert.equal(res.status, 200) + assert.equal(res.body.texte.versand, 'Abholung – kostenlos') + assert.equal(res.body.land, null) + assert.equal(res.body.positionen[0].widerrufText, 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware') +}) + +test('pruefen: geschlossener Shop (geoeffnet false oder disabled): 409 geschlossen', async () => { + for (const variante of [{ geoeffnet: false }, { state: 'disabled' }]) { + await aufbau(variante) + const res = await pruefen() + assert.equal(res.status, 409, JSON.stringify(variante)) + assert.deepEqual(res.body, { fehler: { _: 'Der Shop ist gerade geschlossen.' }, code: 'geschlossen' }) + } +}) + +test('pruefen: ausverkauft 409 mit produktId, zu große Menge, unbekannter Artikel, kaputter Rumpf', async () => { + await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + const aus = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2 }] })) + assert.equal(aus.status, 409) + assert.equal(aus.body.code, 'ausverkauft') + assert.equal(aus.body.produktId, 'a1') + assert.match(aus.body.fehler._, /nur noch 1/) + + const menge = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 1000 }] })) + assert.equal(menge.status, 400) + assert.equal(menge.body.code, 'menge') + + const unbekannt = await pruefen(warenkorb({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] })) + assert.equal(unbekannt.status, 409) + assert.equal(unbekannt.body.code, 'nicht_verfuegbar') + + const leer = await pruefen(warenkorb({ positionen: [] })) + assert.equal(leer.status, 400) + assert.equal(leer.body.code, 'leer') + + const kaputt = await pruefen([1, 2]) + assert.equal(kaputt.status, 400) +}) + +test('pruefen: offene Bestellungen reservieren den Bestand', async () => { + await aufbau({ produkte: [artikel('a1', { bestand: 3 })] }) + assert.equal((await bestellen({ positionen: [{ produktId: 'a1', menge: 2 }] })).status, 200) + const res = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2 }] })) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'ausverkauft') +}) + +test('pruefen: fremde Origin 403, unbekannter Shop aktiv:false, Grenze 60 je Minute', async () => { + const fremd = await pruefen(warenkorb(), { origin: 'https://fremd.example' }) + assert.equal(fremd.status, 403) + assert.equal(kopf(fremd, 'access-control-allow-origin'), null) + const ohneOrigin = await pruefen(warenkorb(), { origin: null }) + assert.equal(ohneOrigin.status, 403) + + const unbekannt = await post('/zzzzzzzzzzzzzzzz/pruefen', warenkorb()) + assert.equal(unbekannt.status, 200) + assert.deepEqual(unbekannt.body, { aktiv: false }) + + const eigene = `10.9.9.${(n % 250) + 1}` + for (let i = 0; i < 60; i += 1) assert.equal((await pruefen(warenkorb(), { ip: eigene })).status, 200, `Anfrage ${i + 1}`) + const zuViele = await pruefen(warenkorb(), { ip: eigene }) + assert.equal(zuViele.status, 429) + assert.equal(kopf(zuViele, 'retry-after'), '60') +}) + +// ---------------------------------------------------------------- bestellung + +test('bestellung: legt offene Bestellung an und liefert die Stripe-Adresse; Konto des Händlers, Preise aus der Datenbank', async () => { + const res = await bestellen({ + positionen: [{ produktId: 'a1', menge: 2, preisCent: 1, einzelCent: 1, summeCent: 1 }], + gesamtCent: 1, + summeCent: 1, + versandCent: 0, + kontakt_url: '', + }) + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body), ['url']) + assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/) + assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE) + assert.equal(kopf(res, 'cache-control'), 'no-store') + ohneInternes(res.body) + + const [order] = orders() + assert.equal(order.status, 'offen') + assert.equal(order.summeCent, 2 * 1250 + 490) + const [call] = sessionCalls() + assert.equal(call.account, ACCOUNT) + assert.equal(call.body['line_items[0][price_data][unit_amount]'], '1250') + assert.equal(call.body['line_items[0][quantity]'], '2') + assert.equal(call.body['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490') + assert.match(call.body.success_url, /^https:\/\/brot\.example\/laden\?x=1&wk_bestellung=/) + assert.equal(call.body.cancel_url, 'https://brot.example/laden?x=1&wk_abbruch=1') +}) + +test('bestellung: Honigtopf oder zu schnell abgeschickt ⇒ scheinbarer Erfolg ohne Bestellung und ohne Stripe', async () => { + const honig = await bestellen({ kontakt_url: 'https://spam.example' }) + assert.equal(honig.status, 200) + assert.deepEqual(honig.body, { url: 'https://brot.example/laden?x=1' }) + const schnell = await bestellen({ formToken: token(500) }) + assert.equal(schnell.status, 200) + assert.deepEqual(schnell.body, { url: 'https://brot.example/laden?x=1' }) + // fremde Rueck-URL ⇒ Standardseite des Portals + const fremd = await bestellen({ kontakt_url: 'x', rueckUrl: 'https://boese.example/' }) + assert.deepEqual(fremd.body, { url: `${O_PORTAL}/shop/${SITE_KEY}` }) + assert.equal(orders().length, 0) + assert.equal(sessionCalls().length, 0) +}) + +test('bestellung: fehlendes, fremdes oder abgelaufenes Token ⇒ 400 Seite neu laden, nichts angelegt', async () => { + for (const formToken of [undefined, '', 'x.y', formToken_(SITE_KEY, 'anderersitekey1'), token(3 * 60 * 60 * 1000)]) { + const res = await bestellen({ formToken }) + assert.equal(res.status, 400) + assert.deepEqual(res.body, { fehler: { _: 'Bitte laden Sie die Seite neu.' }, code: 'neu_laden' }) + } + assert.equal(orders().length, 0) +}) +function formToken_(_siteKey, fremder) { + return formToken(fremder, new Date(JETZT.getTime() - 10000)) +} + +test('bestellung: geschlossener Shop ⇒ 409 geschlossen; ausverkauft ⇒ 409 mit produktId; Stripe nicht aufgerufen', async () => { + await aufbau({ geoeffnet: false }) + const zu = await bestellen() + assert.equal(zu.status, 409) + assert.deepEqual(zu.body, { fehler: { _: 'Der Shop ist gerade geschlossen.' }, code: 'geschlossen' }) + + await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + const aus = await bestellen({ positionen: [{ produktId: 'a1', menge: 5 }] }) + assert.equal(aus.status, 409) + assert.equal(aus.body.code, 'ausverkauft') + assert.equal(aus.body.produktId, 'a1') + assert.equal(orders().length, 0) + assert.equal(sessionCalls().length, 0) +}) + +test('bestellung: Fehler von Stripe ⇒ 503 mit Text für Käufer, ohne Rohtext; Bestellung storniert', async () => { + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'geheimer Stripe-Rohtext' }) + const res = await bestellen() + assert.equal(res.status, 503) + assert.equal(res.body.code, 'zahlung_nicht_moeglich') + assert.ok(!JSON.stringify(res.body).includes('geheimer')) + assert.equal(orders()[0].status, 'storniert') +}) + +test('bestellung: fremde Origin 403 und unbekannter Shop aktiv:false; kein Rumpf ⇒ 400', async () => { + const fremd = await bestellen({}, { origin: 'https://fremd.example' }) + assert.equal(fremd.status, 403) + assert.equal(kopf(fremd, 'access-control-allow-origin'), null) + const unbekannt = await post('/zzzzzzzzzzzzzzzz/bestellung', { ...warenkorb(), formToken: token() }) + assert.deepEqual(unbekannt.body, { aktiv: false }) + const kaputt = await post(`/${SITE_KEY}/bestellung`, [1]) + assert.equal(kaputt.status, 400) + assert.equal(orders().length, 0) +}) + +test('bestellung: Grenze 10 je IP und 10 Minuten (Retry-After 600), Grenze 60 je Shop und Stunde (3600)', async () => { + const eigene = `10.8.8.${(n % 250) + 1}` + for (let i = 0; i < 10; i += 1) assert.equal((await bestellen({ kontakt_url: 'x' }, { ip: eigene })).status, 200, `Versuch ${i + 1}`) + const zuViele = await bestellen({ kontakt_url: 'x' }, { ip: eigene }) + assert.equal(zuViele.status, 429) + assert.equal(kopf(zuViele, 'retry-after'), '600') + + resetTermineLimitsForTests() + let ok = 0 + for (let i = 0; i < 60; i += 1) { + const res = await bestellen({ kontakt_url: 'x' }, { ip: `10.7.${Math.floor(i / 200)}.${(i % 200) + 1}` }) + if (res.status === 200) ok += 1 + } + assert.equal(ok, 60) + const shopGrenze = await bestellen({ kontakt_url: 'x' }, { ip: '10.7.9.9' }) + assert.equal(shopGrenze.status, 429) + assert.equal(kopf(shopGrenze, 'retry-after'), '3600') +}) + +test('bestellung: Mindestantwortzeit gilt auch bei Fehlern', async () => { + config.shop.minAntwortMs = 200 + const t0 = performance.now() + const res = await bestellen({ formToken: '' }) + assert.equal(res.status, 400) + assert.ok(performance.now() - t0 >= 190) +}) + +test('Preflight für pruefen und bestellung: erlaubte Origin ja, fremde nein', async () => { + for (const was of ['pruefen', 'bestellung']) { + const ok = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { origin: O_LIVE, 'x-forwarded-for': ip } }) + assert.equal(ok.status, 204) + assert.equal(kopf(ok, 'access-control-allow-origin'), O_LIVE) + const fremd = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { origin: 'https://fremd.example', 'x-forwarded-for': ip } }) + assert.equal(fremd.status, 403) + } +}) + +// ---------------------------------------------------------------- Status + +async function offeneBestellung() { + const res = await bestellen() + assert.equal(res.status, 200) + const [order] = orders() + const [call] = sessionCalls() + return { orderId: order.$id, t: tokenAusUrl(call.body.success_url), sessionId: order.stripeSessionId, order } +} + +test('Status: offene Bestellung mit gutem Token ⇒ offen, ohne Käuferdaten und ohne IDs', async () => { + const { orderId, t } = await offeneBestellung() + const res = await status(orderId, t) + assert.equal(res.status, 200) + assert.equal(res.body.status, 'offen') + assert.match(res.body.bestellnummer, /^26-[A-Z2-9]{6}$/) + assert.deepEqual(Object.keys(res.body).sort(), ['bestellnummer', 'email', 'gesamtText', 'lieferart', 'status']) + assert.equal(res.body.gesamtText, euro(2990)) + assert.equal(res.body.lieferart, 'versand') + assert.equal(res.body.email, '') + assert.equal(kopf(res, 'access-control-allow-origin'), '*') + assert.equal(kopf(res, 'cache-control'), 'no-store') + assert.ok(!JSON.stringify(res.body).includes(orderId)) + ohneInternes(res.body) +}) + +test('Status: schlechter oder fehlender Token, fremde Bestellung, kaputte ID ⇒ immer dieselbe 404', async () => { + const { orderId, t } = await offeneBestellung() + const erwartet = { fehler: { _: 'Bestellung nicht gefunden.' } } + const faelle = [ + await status(orderId, 'falsch-falsch-falsch-falsch'), + await status(orderId, undefined), + await status(orderId, ''), + await status(orderId, 'x'), + await status('o_gibtesnicht', t), + await status('..%2f..%2fetc', t), + await status('a'.repeat(80), t), + ] + for (const res of faelle) { + assert.equal(res.status, 404) + assert.deepEqual(res.body, erwartet) + } + // Bestellung eines anderen Shops mit richtigem Token + const anderer = 'f_p_projekt02_shop' + aw.seed('shopOrders', { ...orders()[0], $id: 'o_fremd01', shopId: anderer }) + const fremd = await status('o_fremd01', t) + assert.equal(fremd.status, 404) + assert.deepEqual(fremd.body, erwartet) + // Hash als Token geht nicht + assert.equal((await status(orderId, orders().find((o) => o.$id === orderId).tokenHash)).status, 404) +}) + +test('Status: offen ⇒ bezahlt, sobald Stripe die Sitzung als bezahlt meldet; E-Mail verdeckt, kein Name und keine Adresse', async () => { + const { orderId, t, sessionId } = await offeneBestellung() + const vorher = await status(orderId, t) + assert.equal(vorher.body.status, 'offen') + + st.completeSession(ACCOUNT, sessionId, { + payment_status: 'paid', + amount_total: 2990, + customer_details: { name: 'Max Mustermann', email: 'Max@Example.com', phone: '0170 1234567', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } }, + shipping: { name: 'Max Mustermann', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } }, + payment_intent: 'pi_test1', + }) + const res = await status(orderId, t) + assert.equal(res.status, 200) + assert.equal(res.body.status, 'bezahlt') + assert.equal(res.body.email, 'M***@Example.com') + const s = JSON.stringify(res.body) + for (const verboten of ['Mustermann', 'Musterweg', 'Max@', '0170', 'pi_test1', sessionId]) assert.ok(!s.includes(verboten), verboten) + ohneInternes(res.body) + assert.equal(orders()[0].status, 'bezahlt') + + // Zweiter Abruf: bleibt bezahlt, kein zweiter Abschluss (eine Mail an den Käufer) + await warteAufVersand() + const mailsVorher = sink.sent.length + const nochmal = await status(orderId, t) + assert.equal(nochmal.body.status, 'bezahlt') + await warteAufVersand() + assert.equal(sink.sent.length, mailsVorher) +}) + +test('Status: nicht bezahlte Sitzung (z. B. SEPA unterwegs) bleibt offen; Stripe-Fehler beim Abgleich ⇒ trotzdem Antwort', async () => { + const { orderId, t, sessionId } = await offeneBestellung() + st.completeSession(ACCOUNT, sessionId, { payment_status: 'unpaid', amount_total: 2990 }) + assert.equal((await status(orderId, t)).body.status, 'offen') + st.failNext({ method: 'GET', path: /checkout\/sessions\//, status: 400, message: 'kaputt' }) + const res = await status(orderId, t) + assert.equal(res.status, 200) + assert.equal(res.body.status, 'offen') +}) + +test('Status: abgeschalteter Shop (disabled) zeigt Bestellungen weiter; Grenze 120 je Minute', async () => { + const { orderId, t } = await offeneBestellung() + aw.seed('portalFeatures', feature({ state: 'disabled' })) + K.leereCache() + assert.equal((await status(orderId, t)).status, 200) + + const eigene = `10.6.6.${(n % 250) + 1}` + for (let i = 0; i < 120; i += 1) assert.equal((await status(orderId, 'x', { ip: eigene })).status, 404) + const zuViele = await status(orderId, t, { ip: eigene }) + assert.equal(zuViele.status, 429) + assert.equal(kopf(zuViele, 'retry-after'), '60') +})