Lehnt Stripe die Kontoanlage ab, weil das Connect-Plattformprofil von WEBklar fehlt (oder Connect nicht aktiv ist), bekommt der Kunde 503 'plattform_nicht_bereit' mit eigenem Du-Text, und das Log nennt die Ursache (ohne Stripe-Text). Gefunden im Pilot 2026-10-06. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
15 KiB
JavaScript
269 lines
15 KiB
JavaScript
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('Plattformprofil fehlt (Connect nicht fertig): 503 mit eigenem Du-Text und Code, fester Logeintrag ohne Stripe-Text', async () => {
|
||
const feature = await aktiviere()
|
||
const log = []
|
||
const orig = console.error
|
||
console.error = (...a) => log.push(a.join(' '))
|
||
try {
|
||
for (const message of [
|
||
'You must complete your platform profile to use Connect and create live connected accounts. Visit your dashboard at https://dashboard.stripe.com/connect/accounts/overview to answer the questionnaire.',
|
||
"You can only create new accounts if you've signed up for Connect, which you can learn how to do at https://stripe.com/docs/connect.",
|
||
]) {
|
||
st.failNext({ method: 'POST', path: '/v1/accounts', status: 400, message })
|
||
const a = await post('einrichten')
|
||
assert.deepEqual([a.status, a.body.code, a.body.error], [503, 'plattform_nicht_bereit', 'Die Stripe-Anbindung ist bei WEBklar noch nicht fertig eingerichtet. Wir kümmern uns darum – schreib uns gern im Chat.'])
|
||
assert.equal(JSON.stringify(a.body).includes('dashboard.stripe.com'), false)
|
||
}
|
||
assert.equal(shopConfigDoc(feature).stripeAccountId || '', '')
|
||
} finally {
|
||
console.error = orig
|
||
}
|
||
assert.equal(log.filter((l) => l.includes('Plattformprofil')).length, 2)
|
||
assert.equal(log.some((l) => l.includes('questionnaire') || l.includes('stripe.com')), 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)
|
||
})
|