Shop D2: Kundenrouten Stripe-Konto (einrichten, Link, aktualisieren) mit Sperre gegen Doppelklick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:56:04 +00:00
1 parent 98d1e6613d
commit 38bd56fe8b
2 files changed
+319 -2

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop D2: Stripe-Konto einrichten, neuer Onboarding-Link, Status aktualisieren (Fake-Stripe)
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: SHOP_DATETIMES,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { leereCache } = await import('../server/services/shop/kontext.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
// Je Test ein frisches Projekt: Kontoanlage ist je Projekt idempotent (Idempotency-Key), alte Konten wuerden sonst wiederkehren
let P = 'pt1'
let zaehler = 0
const cookie = customerCookie('c1')
const GEDULD = 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.'
const post = (pfad, body = { projectId: P }, opts = { cookie }) => send('POST', `/api/shop/stripe/${pfad}`, { ...opts, body })
const status = (projectId = P) => send('GET', `/api/shop/status?projectId=${projectId}`, { cookie })
const plattform = () => st.calls.filter((c) => c.path === '/v1/accounts' || c.path === '/v1/account_links' || c.path.startsWith('/v1/accounts/'))
async function aktiviere(projectId = P, state = 'active') {
const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
return sf.getFeatureDoc('c1', projectId, 'shop')
}
const kontenFuer = (projectId) => [...st.accounts.values()].filter((a) => a.metadata?.projectId === projectId)
const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id)
beforeEach(async () => {
P = `pn${(zaehler += 1)}`
aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Töpferei', liveDomain: 'toepferei.example', archived: false })
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
st.calls.length = 0
leereCache()
})
test('einrichten: legt ein Konto an, speichert Konto-ID und Status, liefert den Onboarding-Link mit Rücksprung-Adressen', async () => {
const feature = await aktiviere()
const res = await post('einrichten')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\/acct_/)
assert.deepEqual(Object.keys(res.body), ['url'])
const doc = shopConfigDoc(feature)
assert.match(doc.stripeAccountId, /^acct_/)
assert.equal(kontenFuer(P).length, 1)
const gespeichert = JSON.parse(doc.stripeStatus)
assert.equal(gespeichert.charges_enabled, false)
assert.deepEqual(gespeichert.currently_due, ['external_account'])
// Plattform-Aufrufe ohne Stripe-Account-Kopfzeile, Konto mit Kunden-E-Mail und Projekt in den Metadaten
assert.ok(plattform().length >= 3)
for (const c of plattform()) assert.equal(c.account, undefined, `${c.method} ${c.path} darf keine Stripe-Account-Kopfzeile tragen`)
const anlage = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/accounts')
assert.equal(anlage.body.email, customers.c1.email)
assert.equal(anlage.body['metadata[projectId]'], P)
const link = st.calls.find((c) => c.path === '/v1/account_links')
assert.equal(link.body.account, doc.stripeAccountId)
assert.equal(link.body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`)
assert.equal(link.body.refresh_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=neu&projekt=${P}`)
// GET /status zeigt das Konto als verbunden, ohne die Konto-ID
const s = (await status()).body
assert.equal(s.stripe.verbunden, true)
assert.equal(JSON.stringify(s).includes(doc.stripeAccountId), false)
})
test('einrichten: zwei parallele Aufrufe (Doppelklick) legen nur ein Konto an', async () => {
const feature = await aktiviere(P, 'setup')
const [a, b] = await Promise.all([post('einrichten'), post('einrichten')])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/accounts').length, 1)
assert.equal(kontenFuer(P).length, 1)
assert.equal(st.calls.filter((c) => c.path === '/v1/account_links').length, 2)
assert.ok(a.body.url && b.body.url)
// weiterer Aufruf: Konto bleibt dasselbe
const id = shopConfigDoc(feature).stripeAccountId
assert.equal((await post('einrichten')).status, 200)
assert.equal(shopConfigDoc(feature).stripeAccountId, id)
assert.equal(kontenFuer(P).length, 1)
})
test('link: braucht ein Konto (409), liefert danach einen neuen Link mit den Rücksprung-Adressen', async () => {
await aktiviere()
const ohne = await post('link')
assert.deepEqual([ohne.status, ohne.body.error, ohne.body.code], [409, 'Es ist noch kein Stripe-Konto angelegt.', 'kein_konto'])
assert.equal((await post('einrichten')).status, 200)
st.calls.length = 0
const res = await post('link')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\//)
assert.equal(st.calls.length, 1)
assert.equal(st.calls[0].account, undefined)
assert.equal(st.calls[0].body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`)
assert.equal(kontenFuer(P).length, 1)
})
test('aktualisieren: braucht ein Konto (409), liest den Status neu und speichert ihn', async () => {
const feature = await aktiviere()
assert.equal((await post('aktualisieren')).status, 409)
await post('einrichten')
const id = shopConfigDoc(feature).stripeAccountId
st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
const res = await post('aktualisieren')
assert.equal(res.status, 200)
assert.equal(res.body.stripe.verbunden, true)
assert.equal(res.body.stripe.status.charges_enabled, true)
assert.deepEqual(res.body.stripe.status.currently_due, [])
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).charges_enabled, true)
assert.equal(JSON.stringify(res.body).includes(id), false)
// gleiche Form wie GET /status
assert.deepEqual((await status()).body.stripe, res.body.stripe)
})
test('aktualisieren: Hinweis zu Klarna/SEPA bleibt nur, solange Zahlungen möglich sind und eine der Zahlungsarten gewählt ist', async () => {
const feature = await aktiviere()
await post('einrichten')
const id = shopConfigDoc(feature).stripeAccountId
const setze = async (stripeStatus, zahlungsarten) => {
const e = { zahlungsarten }
await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify(stripeStatus), einstellungen: JSON.stringify(e) })
leereCache()
}
st.setAccount(id, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'klarna'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, 'Klarna ist noch nicht freigeschaltet.')
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined)
st.setAccount(id, { charges_enabled: false })
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'sepa'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined)
})
test('Stripe-Fehler: 502 mit festem Du-Text, nie der Stripe-Text; bei gescheiterter Kontoanlage bleibt kein Konto gespeichert', async () => {
const feature = await aktiviere()
const log = []
const orig = console.error
console.error = (...a) => log.push(a.join(' '))
try {
st.failNext({ method: 'POST', path: '/v1/accounts', status: 400, message: 'Geheimer Stripe-Text sk_live_123' })
const a = await post('einrichten')
assert.deepEqual([a.status, a.body.error], [502, GEDULD])
assert.equal(JSON.stringify(a.body).includes('Geheim'), false)
assert.equal(shopConfigDoc(feature).stripeAccountId || '', '')
// Konto da, Link scheitert: Konto bleibt gespeichert, 502
st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' })
const b = await post('einrichten')
assert.deepEqual([b.status, b.body.error], [502, GEDULD])
assert.match(shopConfigDoc(feature).stripeAccountId, /^acct_/)
st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' })
assert.deepEqual([(await post('link')).status], [502])
st.failNext({ method: 'GET', path: /^\/v1\/accounts\//, status: 400, message: 'Geheimer Lesetext' })
const c = await post('aktualisieren')
assert.deepEqual([c.status, c.body.error], [502, GEDULD])
} finally {
console.error = orig
}
assert.equal(log.some((l) => l.includes('Geheim') || l.includes('sk_live')), false)
})
test('Kundenansicht: der Wächter sperrt die Routen (nicht auf der Erlaubnisliste), der Router selbst auch ohne Wächter (Kundenansicht und Ansehen als)', async () => {
const feature = await aktiviere()
for (const pfad of ['einrichten', 'link', 'aktualisieren']) {
const res = await post(pfad, { projectId: P }, { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } })
assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad)
}
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const pfad of ['einrichten', 'link', 'aktualisieren']) {
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
const res = await fetch(`${url}/api/shop/stripe/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: P }) })
assert.equal(res.status, 403, pfad)
assert.equal((await res.json()).error, 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.')
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(st.calls.length, 0)
assert.equal(shopConfigDoc(feature)?.stripeAccountId || '', '')
})
test('Zugang und Zustand: fremdes Projekt 403, ohne Projekt 400, ohne Session 401, nicht eingerichtet/deaktiviert/Schalter aus 409', async () => {
const feature = await aktiviere()
assert.equal((await post('einrichten', { projectId: 'pt3' })).status, 403)
assert.equal((await post('einrichten', {})).status, 400)
assert.equal((await send('POST', '/api/shop/stripe/einrichten', { body: { projectId: P } })).status, 401)
assert.equal((await post('einrichten', { projectId: 'pt2' })).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
const aus = await post('einrichten')
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_eingerichtet'])
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('off'))
assert.equal((await post('aktualisieren')).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active'))
config.shop.mode = 'off'
const schalter = await post('link')
assert.deepEqual([schalter.status, schalter.body.code], [409, 'nicht_verfuegbar'])
assert.equal(st.calls.length, 0)
})
test('Rate-Limit: die 301. Schreibanfrage in der Stunde wird abgewiesen', async () => {
await aktiviere()
let letzter
for (let i = 0; i < 301; i += 1) letzter = await post('link')
assert.equal(letzter.status, 429)
})