From 887e1e54a09236d7adb5b34194cf87fec8907f95 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:19:05 +0000 Subject: [PATCH] Abo-API: Nachweis-ID nach Appwrite-Regeln, Webhook bei dauerhaften Fehlern, Tests Co-Authored-By: Claude Fable 5.1 --- .../T02 Neues Wartungs-Abo.html | 6 +- server/routes/abo.js | 25 +- server/services/contractEvents.js | 38 ++- test/aboRoutes.test.js | 255 +++++++++++++----- test/contractEvents.test.js | 32 +++ test/mailTemplates.test.js | 12 + test/support.test.js | 21 ++ test/support/appwriteMock.js | 33 ++- test/support/fixtures.js | 61 +++-- test/support/testApp.js | 33 +-- 10 files changed, 409 insertions(+), 107 deletions(-) diff --git a/server/mail-templates/T02 Neues Wartungs-Abo.html b/server/mail-templates/T02 Neues Wartungs-Abo.html index 8a30e4a..c22e41a 100644 --- a/server/mail-templates/T02 Neues Wartungs-Abo.html +++ b/server/mail-templates/T02 Neues Wartungs-Abo.html @@ -8,7 +8,7 @@ Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{email}}, {{kunde_name}}, {{appwrite_id}}, {{stripe_abo_id}}, {{angebot_text}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler, nachweis_fehlt --> @@ -44,6 +44,10 @@ a{color:#111111;} + + + +   diff --git a/server/routes/abo.js b/server/routes/abo.js index fe82083..3da5de5 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -5,7 +5,7 @@ import { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js' -import { listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js' +import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js' import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' @@ -324,11 +324,12 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) { * Nachweis des Neuabschlusses als Vertragsereignis. Der Vertrag kommt mit dem Abschluss bei Stripe * zustande, die Widerrufsfrist zaehlt ab dann (details.contractAt = startedAt, F04); die Zustimmung * (Fassung, Zeitpunkt, IP, Browser) kommt aus den Abo-Metadaten, die der Spiegel traegt. Feste - * Dokument-ID je Abo: eine Webhook-Wiederholung legt kein zweites Ereignis an (F05). + * Dokument-ID je Abo (eventIdFor, 36 Zeichen): eine Webhook-Wiederholung legt kein zweites + * Ereignis an (F05). */ async function recordAbschluss(sub, session) { return recordContractEvent({ - id: `abschluss-${sub.$id}`, + id: eventIdFor('abschluss', sub.$id), customerId: sub.customerId, subscriptionId: sub.$id, type: 'abschluss', @@ -357,8 +358,20 @@ async function handleStripeEvent(event) { case 'checkout.session.completed': { if (obj.mode !== 'subscription' || !obj.subscription) return const sub = await syncSubscription(obj.subscription) - // Ein Fehler hier (etwa Collection fehlt) gibt 500 -> Stripe wiederholt, das Ereignis wird nachgeholt - if (sub) await recordAbschluss(sub, obj) + // Nachweis: voruebergehend nicht speicherbar (Appwrite nicht erreichbar, 5xx) -> 500, Stripe + // wiederholt und der Nachweis kommt nach. Dauerhaft nicht speicherbar (4xx, etwa Collection + // oder Attribut fehlt) -> Angebot, T02 und K01 laufen trotzdem, T02 sagt es dem Team; eine + // Wiederholung wuerde nichts aendern, der Kunde soll seine Bestaetigung nicht warten lassen. + let proofProblem = false + if (sub) { + try { + await recordAbschluss(sub, obj) + } catch (err) { + if (!err.status || err.status >= 500) throw err + console.error(`[abo] Nachweis des Abschlusses ${obj.subscription} nicht gespeichert (${err.status}):`, err.message) + proofProblem = true + } + } const offerProblem = await activateOfferFromCheckout(obj).then( () => false, (e) => { @@ -375,7 +388,7 @@ async function handleStripeEvent(event) { stripe_abo_id: obj.subscription, angebot_text: obj.metadata?.offerText || '', }, - blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem }, + blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem, nachweis_fehlt: proofProblem }, }) await sendContractConfirmation(sub, obj.customer_details?.name).catch((e) => console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message) diff --git a/server/services/contractEvents.js b/server/services/contractEvents.js index b1d975f..dd5105e 100644 --- a/server/services/contractEvents.js +++ b/server/services/contractEvents.js @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { config } from '../config.js' import { createDocument, getDocument, ID, listDocuments, Query, updateDocument } from './appwriteAdmin.js' import { withdrawalDeadline } from './deadlines.js' @@ -36,12 +37,36 @@ function isWithdrawable(event) { return true } +// Appwrite-Dokument-IDs: hoechstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, +// nicht mit einem Sonderzeichen beginnend (Appwrite 1.8, Validator UID) +const DOCUMENT_ID_MAX = 36 +const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +export function isValidDocumentId(id) { + return typeof id === 'string' && DOCUMENT_ID.test(id) +} + +/** + * Feste Ereignis-ID (F05): "-" + sha256 des Schluessels als Hex, insgesamt genau 36 Zeichen + * (bei abschluss 26 Hex-Zeichen). Gleicher Schluessel -> gleiche ID, ein Stripe-Webhook, der zweimal + * ankommt, legt kein zweites Dokument an. key ist die Stripe-Abo-ID (abschluss: eines je Abo) oder + * Abo-ID plus Rechnungs-/Zeitstempel, wenn es mehrere Ereignisse eines Typs je Abo gibt. Die Abo-ID + * selbst darf nicht in die ID: "abschluss-sub_..." (sub_ + 24 Zeichen) waeren 38 Zeichen. + */ +export function eventIdFor(type, key) { + if (!/^[a-z]+$/.test(String(type))) throw new Error(`eventIdFor: ungueltiger Typ "${type}"`) + if (!key) throw new Error('eventIdFor: Schluessel fehlt') + const hashLength = DOCUMENT_ID_MAX - type.length - 1 + return `${type}-${createHash('sha256').update(String(key)).digest('hex').slice(0, hashLength)}` +} + /** * Ereignis anlegen (Bestellung/Aenderung). consent hat die Form { termsVersion, consentAt, * consentIp, consentUserAgent, earlyStartConsentAt? } oder ist null (Speicher-Minus, siehe oben). - * details wird als JSON gespeichert. Mit id: erneutes Speichern derselben id ist kein Fehler - * (Appwrite meldet 409), sondern liefert das schon vorhandene Dokument zurueck (z. B. wenn ein - * Stripe-Webhook zweimal ankommt). + * details wird als JSON gespeichert. Mit id (aus eventIdFor): erneutes Speichern derselben id ist + * kein Fehler (Appwrite meldet 409), sondern liefert das schon vorhandene Dokument zurueck (z. B. + * wenn ein Stripe-Webhook zweimal ankommt). Eine id, die Appwrite nicht annimmt, wird vorher mit + * Status 400 abgelehnt statt erst als unklarer Appwrite-Fehler aufzufallen. */ export async function recordContractEvent({ customerId, @@ -54,6 +79,13 @@ export async function recordContractEvent({ dueAt = null, id = null, }) { + if (id !== null && !isValidDocumentId(id)) { + const err = new Error( + `Ungueltige Ereignis-ID "${id}": Appwrite erlaubt hoechstens ${DOCUMENT_ID_MAX} Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, nicht mit Sonderzeichen beginnend (eventIdFor benutzen)` + ) + err.status = 400 + throw err + } const documentId = id || ID.unique() const data = { customerId, diff --git a/test/aboRoutes.test.js b/test/aboRoutes.test.js index a002553..f6bb28c 100644 --- a/test/aboRoutes.test.js +++ b/test/aboRoutes.test.js @@ -16,13 +16,17 @@ const aw = await startAppwriteMock({ }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +// Testkonto (Testrabatt im Checkout): Kunde c4 der Fixtures +process.env.ABO_TEST_EMAILS = 'test@example.invalid' const { config } = await import('../server/config.js') const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js') -const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') +const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, viewAsCookie, seedFixtures, subscriptionDoc, stripeSubscription } = + await import('./support/fixtures.js') const { default: aboRoutes } = await import('../server/routes/abo.js') -const { recordContractEvent, listContractEvents } = await import('../server/services/contractEvents.js') +const { recordContractEvent, listContractEvents, eventIdFor } = await import('../server/services/contractEvents.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { withdrawalDeadline } = await import('../server/services/deadlines.js') +const { offerText } = await import('../server/services/offers.js') const app = await startTestApp({ '/api/abo': aboRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) @@ -30,6 +34,7 @@ seedFixtures(aw) const DAY_MS = 86400000 const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString() +const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) const orderBody = (productKey, extra = {}) => ({ productKey, acceptTerms: true, @@ -38,6 +43,29 @@ const orderBody = (productKey, extra = {}) => ({ offerId: '', ...extra, }) + +/** Bestellung abschicken; dazu der Checkout-Aufruf beim Stripe-Nachbau (nur Aufrufe ab jetzt, sonst null). */ +async function checkout(body, { cookie = customerCookie('c3') } = {}) { + const before = st.calls.length + const res = await post('/api/abo/checkout', body, { cookie }) + const call = st.calls.slice(before).find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') || null + return { res, call } +} +const message = (call) => call.body['custom_text[submit][message]'] +const priceIdFor = (lookupKey) => [...st.prices.values()].find((p) => p.lookup_key === lookupKey)?.id + +// Hinweistext ueber dem Bezahlknopf, wortgleich (Wartung wie vor Plan 2, plus § 19 UStG) +const INTRO_SOFORT = + 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande.' +const INTRO_FRIST = + 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' +const TAIL = 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.' + +const OFFER_O1 = { + $id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000, + validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '', +} + async function clearEvents(customerId) { for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) } @@ -63,12 +91,13 @@ test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async assert.equal(r.subscription.withdrawalOpen, false) assert.equal(r.subscription.withdrawalUntil, null) assert.equal(r.available, true) + assert.equal(r.testAccount, false) assert.equal(r.termsVersion, config.abo.termsVersion) assert.ok(r.lateStartAt) }) test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => { - aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', termsVersion: '2026-09-25-v3' }) + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, termsVersion: '2026-09-25-v3' })) const r = await get('/api/abo', { cookie: customerCookie() }) assert.equal(r.subscription.termsUrl, '/vertragsunterlagen/v3/abo-bedingungen.html'); assert.ok(r.ledger) assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf') @@ -79,7 +108,7 @@ test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async ( }) test('GET /api/abo: Altbestand ohne productKey gilt als Wartungs-Abo', async () => { - aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', productKey: undefined, termsVersion: '2026-09-25-v3' }) + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: undefined, termsVersion: '2026-09-25-v3' })) const r = await get('/api/abo', { cookie: customerCookie() }) assert.equal(r.subscription.productKey, 'wartung') assert.ok(r.ledger) @@ -93,18 +122,16 @@ test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => { }) test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => { - const res = await post('/api/abo/checkout', orderBody('hosting'), { cookie: customerCookie('c3') }) + const { res, call } = await checkout(orderBody('hosting')) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.match(res.body.url, /^https:\/\//) - const call = st.calls.find((c) => c.path === '/v1/checkout/sessions') assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo') - assert.match(call.body['custom_text[submit][message]'], /15,00 € monatlich/) + assert.match(message(call), /15,00 € monatlich/) assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting') - assert.match(call.body['custom_text[submit][message]'], /Mindestlaufzeit 12 Monate, danach monatlich kündbar/) - assert.match(call.body['custom_text[submit][message]'], /§ 19 UStG/) - const hostingPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_hosting_monatlich_1500') - assert.equal(call.body['line_items[0][price]'], hostingPrice.id) + assert.equal(message(call), `${INTRO_SOFORT} 15,00 € monatlich, ${TAIL}`) + assert.equal(call.body['line_items[0][price]'], priceIdFor('webklar_hosting_monatlich_1500')) assert.equal(call.body['discounts[0][coupon]'], undefined) + assert.equal(call.body['subscription_data[trial_end]'], undefined) // Nachweis der Zustimmung nur am Abo (F41c): an der Checkout-Sitzung bleiben Zuordnung und Produkt assert.equal(call.body['subscription_data[metadata][termsAcceptedAt]'] !== undefined, true) assert.ok(call.body['subscription_data[metadata][consentIp]']) @@ -116,43 +143,67 @@ test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => { assert.equal(call.body['metadata[productKey]'], 'hosting') }) -test('Checkout Wartung: 50,00 €, Beschreibung, Produkt in den Metadaten', async () => { - const before = st.calls.length - const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c3') }) +test('Checkout Hosting nach der Widerrufsfrist: Probezeit bis zum Leistungsbeginn, Hinweistext', async () => { + const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) + const { res, call } = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt })) assert.equal(res.status, 200, JSON.stringify(res.body)) - const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions') - assert.equal(call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo') - assert.match(call.body['custom_text[submit][message]'], /50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar/) - assert.equal(call.body['subscription_data[metadata][productKey]'], 'wartung') - const wartungPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_wartung_monatlich_5000') - assert.equal(call.body['line_items[0][price]'], wartungPrice.id) + assert.equal(call.body['subscription_data[trial_end]'], String(unix(lateStartAt))) + assert.equal(message(call), `${INTRO_FRIST} Danach 15,00 € monatlich, ${TAIL}`) + // Veralteter Leistungsbeginn (Datumswechsel seit dem Laden der Seite) -> 409, kein Checkout + const stale = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt: '2026-01-02T05:00:00.000Z' })) + assert.equal(stale.res.status, 409) + assert.equal(stale.call, null) +}) + +test('Checkout Wartung: 50,00 €, Beschreibung, Hinweistexte wortgleich', async () => { + const sofort = await checkout(orderBody('wartung')) + assert.equal(sofort.res.status, 200, JSON.stringify(sofort.res.body)) + assert.equal(sofort.call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo') + assert.equal(sofort.call.body['subscription_data[metadata][productKey]'], 'wartung') + assert.equal(sofort.call.body['line_items[0][price]'], priceIdFor('webklar_wartung_monatlich_5000')) + assert.equal(message(sofort.call), `${INTRO_SOFORT} 50,00 € monatlich, ${TAIL}`) + const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) + const frist = await checkout(orderBody('wartung', { startChoice: 'nach_frist', lateStartAt })) + assert.equal(frist.res.status, 200, JSON.stringify(frist.res.body)) + assert.equal(message(frist.call), `${INTRO_FRIST} Danach 50,00 € monatlich, ${TAIL}`) + assert.equal(frist.call.body['subscription_data[trial_end]'], String(unix(lateStartAt))) +}) + +test('Checkout Testkonto: Testrabatt des bestellten Produkts', async () => { + const r = await get('/api/abo', { cookie: customerCookie('c4') }) + assert.equal(r.testAccount, true) + const hosting = await checkout(orderBody('hosting'), { cookie: customerCookie('c4') }) + assert.equal(hosting.res.status, 200, JSON.stringify(hosting.res.body)) + assert.equal(hosting.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050-HOSTING') + const wartung = await checkout(orderBody('wartung'), { cookie: customerCookie('c4') }) + assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body)) + assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050') }) test('Checkout: unbekanntes Produkt → 400', async () => { - assert.equal((await post('/api/abo/checkout', orderBody('gold'), { cookie: customerCookie('c3') })).status, 400) - assert.equal((await post('/api/abo/checkout', { ...orderBody('hosting'), productKey: undefined }, { cookie: customerCookie('c3') })).status, 400) + const gold = await checkout(orderBody('gold')) + assert.equal(gold.res.status, 400) + assert.equal(gold.call, null) + assert.equal((await checkout({ ...orderBody('hosting'), productKey: undefined })).res.status, 400) }) test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', async () => { - aw.seed('aboOffers', { - $id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000, - validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '', - }) + aw.seed('aboOffers', OFFER_O1) try { const r = await get('/api/abo', { cookie: customerCookie('c3') }) assert.equal(r.offers.hosting, null) assert.equal(r.offers.wartung.id, 'o1') - const hosting = await post('/api/abo/checkout', orderBody('hosting', { offerId: 'o1' }), { cookie: customerCookie('c3') }) - assert.equal(hosting.status, 409) - assert.match(hosting.body.error, /anderes Abo/) - const before = st.calls.length - const wartung = await post('/api/abo/checkout', orderBody('wartung', { offerId: 'o1' }), { cookie: customerCookie('c3') }) - assert.equal(wartung.status, 200, JSON.stringify(wartung.body)) - const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions') - assert.equal(call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1') - assert.equal(call.body['subscription_data[metadata][offerId]'], 'o1') + const hosting = await checkout(orderBody('hosting', { offerId: 'o1' })) + assert.equal(hosting.res.status, 409) + assert.match(hosting.res.body.error, /anderes Abo/) + assert.equal(hosting.call, null) + const wartung = await checkout(orderBody('wartung', { offerId: 'o1' })) + assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body)) + assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1') + assert.equal(wartung.call.body['subscription_data[metadata][offerId]'], 'o1') + assert.equal(message(wartung.call), `${INTRO_SOFORT} Preis wie vereinbart: ${offerText(OFFER_O1)}. ${TAIL}`) // Der Webhook liest das Angebot von der Sitzung (activateOfferFromCheckout) - assert.equal(call.body['metadata[offerId]'], 'o1') + assert.equal(wartung.call.body['metadata[offerId]'], 'o1') } finally { await deleteDocument(config.collections.aboOffers, 'o1') } @@ -160,18 +211,40 @@ test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', test('Checkout mit laufendem Abo → 409', async () => { aw.seed('subscriptions', hostingDoc) - const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c1') }) + const { res, call } = await checkout(orderBody('wartung'), { cookie: customerCookie('c1') }) assert.equal(res.status, 409) assert.match(res.body.error, /bereits/) + assert.equal(call, null) +}) + +test('Checkout im Modus „Als Kunde ansehen“ → 403', async () => { + const { res, call } = await checkout(orderBody('hosting'), { cookie: viewAsCookie('c3') }) + assert.equal(res.status, 403) + assert.equal(call, null) }) test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => { - aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' }) + aw.seed('subscriptions', { ...hostingDoc, cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' }) const r = await get('/api/abo', { cookie: customerCookie() }) assert.equal(r.subscription.canSwitch, false); assert.equal(r.subscription.canChangeStorage, false) assert.equal(r.subscription.cancelAt, '2027-10-05T10:00:00.000Z') }) +test('Kein Wechsel und kein Speicher-Plus bei trialing, past_due oder Kündigung nur per cancelAt', async () => { + for (const patch of [ + { status: 'trialing', trialEnd: '2026-11-01T05:00:00.000Z', startChoice: 'nach_frist' }, + { status: 'past_due' }, + { cancelAtPeriodEnd: false, cancelAt: '2027-10-05T10:00:00.000Z' }, + ]) { + aw.seed('subscriptions', { ...hostingDoc, ...patch }) + const r = await get('/api/abo', { cookie: customerCookie() }) + assert.equal(r.subscription.canSwitch, false, JSON.stringify(patch)) + assert.equal(r.subscription.canChangeStorage, false, JSON.stringify(patch)) + assert.equal(r.subscription.active, true, JSON.stringify(patch)) + } + aw.seed('subscriptions', hostingDoc) +}) + test('Widerrufsfristen aus den Ereignissen', async () => { aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 }) const consentAt = daysAgo(2) @@ -241,7 +314,7 @@ test('Altbestand ohne Ereignis abschluss: Widerrufsfrist ab startedAt', async () // Mit Ereignis zaehlt allein das Ereignis (hier: Vertragsschluss vor 40 Tagen, Frist vorbei) await recordContractEvent({ - id: 'abschluss-sub_h', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting', + id: eventIdFor('abschluss', 'sub_h'), customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting', details: { contractAt: daysAgo(40) }, consent: { ...consent, consentAt: daysAgo(40) }, }) try { @@ -261,40 +334,59 @@ test('minTermEnd rechnet ab termStartAt, sonst ab trialEnd bzw. startedAt', asyn aw.seed('subscriptions', hostingDoc) }) -// F04/F05/F25: der Abschluss wird als Ereignis nachgewiesen, die Frist zaehlt ab dem Vertragsschluss bei -// Stripe (startedAt), eine Webhook-Wiederholung legt kein zweites Ereignis an. Zuletzt, weil danach c3 ein Abo hat. -test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => { - const stripeSub = stripeSubscription({ id: 'sub_new', customerId: 'c3', productKey: 'hosting', metadata: { termsAcceptedAt: '2026-09-20T10:00:00.000Z' } }) - st.subscriptions.set('sub_new', stripeSub) - const event = { - id: 'evt_1', - object: 'event', - type: 'checkout.session.completed', - created: Math.floor(Date.now() / 1000), - data: { - object: { - id: 'cs_done', object: 'checkout.session', mode: 'subscription', subscription: 'sub_new', customer: 'cus_c3', - customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, - metadata: { webklarCustomerId: 'c3', productKey: 'hosting' }, - }, +// --------------------------------------------------------------------------- +// Webhook checkout.session.completed (zuletzt: gibt c3 voruebergehend ein Abo) +// --------------------------------------------------------------------------- + +// Abo-IDs 28 Zeichen wie bei Stripe (sub_ + 24): 'abschluss-' + ID waeren 38 Zeichen, Appwrite erlaubt 36 +const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp' +const checkoutCompleted = (subscriptionId, metadata = {}) => ({ + id: `evt_${subscriptionId}`, + object: 'event', + type: 'checkout.session.completed', + created: Math.floor(Date.now() / 1000), + data: { + object: { + id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3', + customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, + metadata: { webklarCustomerId: 'c3', productKey: 'hosting', ...metadata }, }, - } + }, +}) +async function cleanupWebhook(subscriptionId, offerId = null) { + await clearEvents('c3') + await deleteDocument(config.collections.subscriptions, subscriptionId).catch(() => {}) + if (offerId) await deleteDocument(config.collections.aboOffers, offerId).catch(() => {}) +} + +// F04/F05/F25: der Abschluss wird als Ereignis nachgewiesen, die Frist zaehlt ab dem Vertragsschluss bei +// Stripe (startedAt), eine Webhook-Wiederholung legt kein zweites Ereignis an +test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => { + assert.equal(SUB_NEW.length, 28) + const stripeSub = stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'hosting' }) + // Zustimmung im Portal eine Minute vor dem Abschluss bei Stripe + const termsAcceptedAt = new Date((stripeSub.start_date - 60) * 1000).toISOString() + stripeSub.metadata.termsAcceptedAt = termsAcceptedAt + st.subscriptions.set(SUB_NEW, stripeSub) + const event = checkoutCompleted(SUB_NEW) try { const first = await postWebhook(event) assert.equal(first.status, 200, JSON.stringify(first.body)) const events = await listContractEvents('c3') assert.equal(events.length, 1) const [e] = events - assert.equal(e.$id, 'abschluss-sub_new') + assert.equal(e.$id, eventIdFor('abschluss', SUB_NEW)) + assert.equal(e.$id.length, 36) assert.equal(e.type, 'abschluss') + assert.equal(e.subscriptionId, SUB_NEW) assert.equal(e.productKey, 'hosting') assert.equal(e.status, 'erledigt') assert.equal(e.details.contractAt, new Date(stripeSub.start_date * 1000).toISOString()) assert.equal(e.termsVersion, config.abo.termsVersion) - assert.equal(e.consentAt, '2026-09-20T10:00:00.000Z') + assert.equal(e.consentAt, termsAcceptedAt) assert.equal(e.consentIp, '127.0.0.1') assert.equal(e.consentUserAgent, 'test') - assert.equal(aw.docs('subscriptions').get('sub_new').productKey, 'hosting') + assert.equal(aw.docs('subscriptions').get(SUB_NEW).productKey, 'hosting') const again = await postWebhook(event) assert.equal(again.status, 200) @@ -303,7 +395,48 @@ test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, // Falsche Signatur: 400, nichts verarbeitet assert.equal((await postWebhook(event, { secret: 'whsec_falsch' })).status, 400) } finally { - await clearEvents('c3') - await deleteDocument(config.collections.subscriptions, 'sub_new').catch(() => {}) + await cleanupWebhook(SUB_NEW) + } +}) + +// Fix 1, Important: ein dauerhafter Fehler beim Nachweis (4xx, z. B. Collection oder Attribut fehlt) darf +// Angebot, T02 und K01 nicht blockieren; ein voruebergehender (5xx, Netz) gibt 500, Stripe wiederholt +test('Webhook: Nachweis dauerhaft nicht speicherbar (400) → 200, Angebot trotzdem aktiviert', async () => { + const subId = 'sub_1SD6w0QxAbCdEfGhIjKlMnOq' + st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o2' } })) + aw.seed('aboOffers', { ...OFFER_O1, $id: 'o2', stripeCouponId: 'WEBKLAR-ANGEBOT-O2' }) + aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 400 }) + try { + const res = await postWebhook(checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o2' })) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal((await listContractEvents('c3')).length, 0) + assert.equal(aw.docs('aboOffers').get('o2').status, 'aktiv') + assert.equal(aw.docs('aboOffers').get('o2').stripeSubscriptionId, subId) + assert.equal(aw.docs('subscriptions').get(subId).productKey, 'wartung') + } finally { + await cleanupWebhook(subId, 'o2') + } +}) + +test('Webhook: Nachweis voruebergehend nicht speicherbar (500) → 500, Wiederholung holt alles nach', async () => { + const subId = 'sub_1SD6x1RxAbCdEfGhIjKlMnOr' + st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o3' } })) + aw.seed('aboOffers', { ...OFFER_O1, $id: 'o3', stripeCouponId: 'WEBKLAR-ANGEBOT-O3' }) + aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) + const event = checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o3' }) + try { + const failed = await postWebhook(event) + assert.equal(failed.status, 500) + assert.equal((await listContractEvents('c3')).length, 0) + assert.equal(aw.docs('aboOffers').get('o3').status, 'offen') + // Stripe wiederholt: Nachweis, Angebot und Hinweise kommen nach + const retry = await postWebhook(event) + assert.equal(retry.status, 200, JSON.stringify(retry.body)) + const events = await listContractEvents('c3') + assert.equal(events.length, 1) + assert.equal(events[0].$id, eventIdFor('abschluss', subId)) + assert.equal(aw.docs('aboOffers').get('o3').status, 'aktiv') + } finally { + await cleanupWebhook(subId, 'o3') } }) diff --git a/test/contractEvents.test.js b/test/contractEvents.test.js index 6428627..ae1d121 100644 --- a/test/contractEvents.test.js +++ b/test/contractEvents.test.js @@ -18,6 +18,7 @@ const { listOpenStorageTasks, completeContractEvent, updateContractEvent, + eventIdFor, } = await import('../server/services/contractEvents.js') test('Ereignis speichern und lesen (details als JSON)', async () => { @@ -163,3 +164,34 @@ test('Speicher-Verringerung ohne Zustimmung (consent: null) speichert keine Zust assert.equal(doc.consentUserAgent ?? null, null) assert.equal(doc.termsVersion ?? null, null) }) + +// F05 (Fix 1): feste Ereignis-IDs muessen Appwrites Regeln fuer Dokument-IDs einhalten (hoechstens +// 36 Zeichen) - 'abschluss-' + eine echte Stripe-Abo-ID (sub_ + 24) waeren 38. Deshalb +// '-' + sha256 des Schluessels, auf 36 Zeichen gekuerzt; gleicher Schluessel -> gleiche ID. +test('eventIdFor: 36 Zeichen nach Appwrite-Regeln, deterministisch', () => { + const subId = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp' + const id = eventIdFor('abschluss', subId) + assert.equal(id.length, 36) + assert.match(id, /^abschluss-[0-9a-f]{26}$/) + assert.equal(id, eventIdFor('abschluss', subId)) + assert.notEqual(id, eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOq')) + // Laengerer Typ -> kuerzerer Hash, immer 36 Zeichen + assert.equal(eventIdFor('kuendigung', subId).length, 36) + assert.match(eventIdFor('kuendigung', subId), /^kuendigung-[0-9a-f]{25}$/) + assert.throws(() => eventIdFor('abschluss', ''), /Schl/) + assert.throws(() => eventIdFor('Abschluss!', subId), /Typ/) +}) + +test('recordContractEvent lehnt IDs ab, die Appwrite nicht annimmt', async () => { + const base = { customerId: 'c6', subscriptionId: 'sub_6', type: 'abschluss', productKey: 'hosting' } + await assert.rejects( + recordContractEvent({ ...base, id: 'abschluss-sub_1SD6vPRxAbCdEfGhIjKlMnOp' }), + (e) => e.status === 400 && /36/.test(e.message) + ) + await assert.rejects(recordContractEvent({ ...base, id: '-abschluss' }), (e) => e.status === 400) + await assert.rejects(recordContractEvent({ ...base, id: 'abschluss/1' }), (e) => e.status === 400) + assert.equal((await listContractEvents('c6')).length, 0) + // Gueltige feste ID (aus eventIdFor) wird gespeichert + const ok = await recordContractEvent({ ...base, id: eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOp') }) + assert.equal(ok.$id.length, 36) +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 54c84cd..039d69b 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -137,3 +137,15 @@ test('K09: Gutschrift wird mit dem Abo verrechnet, auch nach einem Wechsel', () assert.match(mail.text, /Eine Auszahlung ist nicht möglich\. Wechseln Sie in ein anderes Abo, verrechnen wir die Gutschrift weiter; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest\./) assert.doesNotMatch(mail.html, /Wartungs-Abo|Vertragsende/) }) + +// Task 7 (Fix 1): konnte das Vertragsereignis "abschluss" dauerhaft nicht gespeichert werden (4xx bei +// Appwrite), erfaehrt das Team das im T02-Hinweis statt in einem stillen Webhook-Fehler +test('T02: Block nachweis_fehlt weist auf den fehlenden Nachweis hin', () => { + const tpl = loadTemplate('T02') + const values = sampleValues(tpl) + const withBlock = renderMail('T02', values, { nachweis_fehlt: true }) + assert.deepEqual(withBlock.problems, []) + assert.match(withBlock.text, /Nachweis nicht gespeichert/) + assert.match(withBlock.text, /contractEvents/) + assert.doesNotMatch(renderMail('T02', values, {}).text, /Nachweis nicht gespeichert/) +}) diff --git a/test/support.test.js b/test/support.test.js index 9ba00af..756a132 100644 --- a/test/support.test.js +++ b/test/support.test.js @@ -29,6 +29,27 @@ test('Appwrite-Nachbau: Schema wie Appwrite', async () => { await assert.rejects(createDocument('subscriptions', { unbekannt: 1 }, 'sub_b'), (e) => e.status === 400) }) +// Appwrite 1.8: Dokument-IDs hoechstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, +// nicht mit einem Sonderzeichen beginnend - sonst 400 (Task 7, Fix 1) +test('Appwrite-Nachbau: Dokument-ID nach Appwrite-Regeln', async () => { + const tooLong = 'abschluss-sub_1SD6vPRxAbCdEfGhIjKlMnOp' // 38 Zeichen + await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, tooLong), (e) => e.status === 400 && /documentId/.test(e.message)) + await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, '-abc'), (e) => e.status === 400) + await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, 'abc/def'), (e) => e.status === 400) + const ok = await createDocument('subscriptions', { customerId: 'c1' }, `abschluss-${'f'.repeat(26)}`) + assert.equal(ok.$id.length, 36) +}) + +test('Appwrite-Nachbau: failNext liefert einmal den gewuenschten Fehler', async () => { + aw.failNext({ collection: 'subscriptions', method: 'POST', status: 500 }) + await assert.rejects(createDocument('subscriptions', { customerId: 'c9' }, 'sub_fail'), (e) => e.status === 500) + assert.equal((await createDocument('subscriptions', { customerId: 'c9' }, 'sub_fail')).$id, 'sub_fail') + // Nur die passende Anfrage scheitert; andere Collections/Methoden laufen durch + aw.failNext({ collection: 'hourCredits', method: 'POST', status: 404 }) + assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail') + await assert.rejects(createDocument('hourCredits', { customerId: 'c9' }, 'hc_1'), (e) => e.status === 404) +}) + test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => { st.subscriptions.set('sub_1', { id: 'sub_1', object: 'subscription', items: { object: 'list', data: [] } }) assert.equal((await getStripe().subscriptions.retrieve('sub_1')).id, 'sub_1') diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index 723b523..a6650b4 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -3,11 +3,26 @@ import http from 'node:http' /** * Appwrite-Nachbau für Tests: die REST-Pfade, die server/services/appwriteAdmin.js benutzt, im * Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie - * Appwrite ("Invalid document structure: Unknown attribute"). + * Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite + * (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am + * Anfang). failNext({ collection?, method?, status, message? }) lässt die nächste passende Anfrage + * mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt"). */ export async function startAppwriteMock({ schema = {} } = {}) { const collections = new Map() let sequence = 0 + let pendingFailure = null + + // Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35} + const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id)) + const invalidDocumentId = [ + 400, + { + message: 'Invalid `documentId` param: UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char', + code: 400, + type: 'general_argument_invalid', + }, + ] const docs = (collection) => { if (!collections.has(collection)) collections.set(collection, new Map()) @@ -83,9 +98,21 @@ export async function startAppwriteMock({ schema = {} } = {}) { const store = docs(collection) const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }] + // Eingeplanter Fehler (failNext): genau eine passende Anfrage scheitert + if ( + pendingFailure && + (!pendingFailure.collection || pendingFailure.collection === collection) && + (!pendingFailure.method || pendingFailure.method === req.method) + ) { + const { status, message } = pendingFailure + pendingFailure = null + return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }] + } + if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]')) if (!id && req.method === 'POST') { const { documentId, data = {} } = await readBody(req) + if (!validDocumentId(documentId)) return invalidDocumentId if (store.has(documentId)) { return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }] } @@ -128,6 +155,10 @@ export async function startAppwriteMock({ schema = {} } = {}) { url: `http://127.0.0.1:${server.address().port}/v1`, docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])), seed, + /** Naechste passende Dokument-Anfrage scheitert mit status (einmalig). */ + failNext: ({ collection = null, method = null, status = 500, message = '' }) => { + pendingFailure = { collection, method, status, message } + }, close: () => new Promise((resolve) => server.close(resolve)), } } diff --git a/test/support/fixtures.js b/test/support/fixtures.js index 0d4ef08..1320c83 100644 --- a/test/support/fixtures.js +++ b/test/support/fixtures.js @@ -1,11 +1,12 @@ -// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() gelaufen ist, bevor config.js die Umgebung liest +// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() vor config.js gelaufen ist import { sessionCookie } from './testApp.js' import { config } from '../../server/config.js' /** - * Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c3, Abo-Dokumente, Zustimmung, + * Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c4, Abo-Dokumente, Zustimmung, * Session-Cookies. seedFixtures(aw) legt Kunden und Abos in den Appwrite-Nachbau; c3 hat kein Abo - * (Neuabschluss-Tests). Erst nach useTestEnv() dynamisch importieren (siehe testApp.js). + * (Neuabschluss-Tests), c4 ist ein Testkonto (ABO_TEST_EMAILS=test@example.invalid im Test setzen). + * Erst nach useTestEnv() dynamisch importieren (siehe testApp.js). */ const DAY_MS = 86400000 @@ -15,18 +16,20 @@ export const customers = { c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }, c2: { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true }, c3: { $id: 'c3', appwriteUserId: 'u3', email: 'k3@example.invalid', name: 'Kunde Drei', status: 'active', portalAccessEnabled: true }, + c4: { $id: 'c4', appwriteUserId: 'u4', email: 'test@example.invalid', name: 'Testkonto', status: 'active', portalAccessEnabled: true }, } +/** Abo-Dokument mit id als Dokument-ID und Stripe-Abo-ID (in Produktion immer gleich). */ +export const subscriptionDoc = (id, overrides = {}) => ({ ...overrides, $id: id, stripeSubscriptionId: id }) + // Abo seit 40 Tagen (Widerrufsfrist des Abschlusses vorbei), naechste Abbuchung in 20 Tagen const startedAt = daysFromNow(-40) /** Hosting-Abo sub_h von c1: aktiv, Fassung 4, ohne Zusatzspeicher. */ -export const hostingDoc = { - $id: 'sub_h', +export const hostingDoc = subscriptionDoc('sub_h', { customerId: 'c1', email: 'k@example.invalid', stripeCustomerId: 'cus_c1', - stripeSubscriptionId: 'sub_h', status: 'active', productKey: 'hosting', planKey: config.products.hosting.lookupKey, @@ -49,27 +52,23 @@ export const hostingDoc = { storageUnits: 0, storagePaidUnits: 0, storagePaidPeriodEnd: null, -} +}) -/** Wartungs-Abo sub_w von c1 (im Test meist als sub_h eingespielt, damit c1 nur ein Abo hat). */ -export const wartungDoc = { +/** Wartungs-Abo sub_w von c1 (im Test meist per subscriptionDoc('sub_h', { ...wartungDoc }) eingespielt). */ +export const wartungDoc = subscriptionDoc('sub_w', { ...hostingDoc, - $id: 'sub_w', - stripeSubscriptionId: 'sub_w', productKey: 'wartung', planKey: config.products.wartung.lookupKey, priceAmount: config.products.wartung.amountCents, -} +}) /** Zweites Hosting-Abo sub_h2 von c2. */ -export const hosting2Doc = { +export const hosting2Doc = subscriptionDoc('sub_h2', { ...hostingDoc, - $id: 'sub_h2', customerId: 'c2', email: 'k2@example.invalid', stripeCustomerId: 'cus_c2', - stripeSubscriptionId: 'sub_h2', -} +}) const consentAt = new Date().toISOString() @@ -90,7 +89,21 @@ export const customerCookie = (customerId = 'c1') => export const adminCookie = () => sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' }) -/** Kunden c1–c3 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */ +/** Admin im Modus "Als Kunde ansehen" (portalAdmin.js /view-as): Bestellen und Aendern sind gesperrt. */ +export const viewAsCookie = (customerId = 'c1') => + sessionCookie({ + role: 'admin', + viewAs: true, + isAdmin: true, + appwriteUserId: 'u_admin', + adminName: 'Kenso', + adminEmail: 'kenso@webklar.com', + customerId, + name: customers[customerId]?.name || '', + email: customers[customerId]?.email || '', + }) + +/** Kunden c1–c4 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */ export function seedFixtures(aw) { for (const customer of Object.values(customers)) aw.seed('customers', customer) aw.seed('subscriptions', hostingDoc) @@ -101,10 +114,18 @@ const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) /** * Stripe-Abo in der Form der SDK-API-Version für den Stripe-Nachbau: Abo-Posten je Produkt, - * dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. metadata ergaenzt/ueberschreibt die - * Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...). + * dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. Die Standard-ID ist 28 Zeichen lang wie + * bei Stripe (sub_ + 24); fuer den Spiegel von sub_h explizit id: 'sub_h' uebergeben. metadata + * ergaenzt/ueberschreibt die Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...). */ -export function stripeSubscription({ id = 'sub_h', customerId = 'c1', productKey = 'hosting', storageUnits = 0, metadata = {}, ...rest } = {}) { +export function stripeSubscription({ + id = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp', + customerId = 'c1', + productKey = 'hosting', + storageUnits = 0, + metadata = {}, + ...rest +} = {}) { const product = config.products[productKey] const items = [ { diff --git a/test/support/testApp.js b/test/support/testApp.js index 8c35e71..526ee17 100644 --- a/test/support/testApp.js +++ b/test/support/testApp.js @@ -1,44 +1,47 @@ import http from 'node:http' import express from 'express' -import cookieParser from 'cookie-parser' import signature from 'cookie-signature' import Stripe from 'stripe' +import { config } from '../../server/config.js' +import { sessionMiddleware } from '../../server/middleware/session.js' /** - * Express-App für Routen-Tests: wie server/index.js mit cookieParser(SESSION_SECRET) und - * express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der Test übergibt: + * Express-App für Routen-Tests: wie server/index.js mit sessionMiddleware() (cookieParser mit + * SESSION_SECRET) und express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der + * Test übergibt: * const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes }) * * Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module - * dynamisch importieren (assertTestEnv() weigert sich sonst, weil config.js die Umgebung beim - * ersten Import liest). Die Anfrage-Helfer sprechen die zuletzt gestartete App an. + * dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die + * Nachbauten, weigert sich dieses Modul beim Laden. Die Anfrage-Helfer sprechen die zuletzt + * gestartete App an. */ function assertTestEnv() { - for (const name of ['APPWRITE_ENDPOINT', 'STRIPE_API_BASE']) { + for (const [name, value] of [ + ['APPWRITE_ENDPOINT', config.appwrite.endpoint], + ['STRIPE_API_BASE', config.stripe.apiBase], + ]) { let host = null try { - host = new URL(process.env[name] || '').hostname + host = new URL(value || '').hostname } catch { host = null } if (host !== '127.0.0.1') { - throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() vor dem Import aufrufen`) + throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() muss vor dem ersten Import von config.js laufen`) } } } assertTestEnv() -const cookieName = () => process.env.SESSION_COOKIE_NAME || 'webklar_portal_session' -const sessionSecret = () => process.env.SESSION_SECRET || '' - /** * Signierter Session-Cookie wie setPortalSession (cookie-parser: Praefix "s:" + cookie-signature). * Kunde: { customerId, appwriteUserId }. Admin: { role: 'admin', appwriteUserId, name, email }. */ export function sessionCookie(session) { - const value = `s:${signature.sign(JSON.stringify(session), sessionSecret())}` - return `${cookieName()}=${encodeURIComponent(value)}` + const value = `s:${signature.sign(JSON.stringify(session), config.sessionSecret)}` + return `${config.cookieName}=${encodeURIComponent(value)}` } let baseUrl = null @@ -47,7 +50,7 @@ let baseUrl = null export async function startTestApp(routers = {}) { const app = express() app.set('trust proxy', 1) - app.use(cookieParser(sessionSecret())) + app.use(sessionMiddleware()) app.use( express.json({ limit: '2mb', @@ -110,7 +113,7 @@ export const post = (path, body, opts = {}) => request('POST', path, { ...opts, const webhookSigner = new Stripe('sk_test_fake') /** Stripe-Ereignis signiert an den Webhook schicken (F25) -> { status, body }. */ -export function postWebhook(event, { path = '/api/abo/webhook', secret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_test' } = {}) { +export function postWebhook(event, { path = '/api/abo/webhook', secret = config.stripe.webhookSecret || 'whsec_test' } = {}) { const payload = JSON.stringify(event) const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret }) return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })