also looking at this
feat(billing): issue the license from Stripe, not only from the thank-you page
#49
9 files
+439
-2
| @@ -0,0 +1,34 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { licenses } from '../../Billing/license-store' | |
| 5 | import { bodyFor, receiveWebhook, statusFor, webhookSecret } from '../../Billing/webhook' | |
| 6 | ||
| 7 | /** | |
| 8 | * POST /api/billing/webhook - Stripe's copy of what happened, so a license is | |
| 9 | * issued even when the buyer never reaches the thank-you page (#48). | |
| 10 | * | |
| 11 | * `rawBody()` rather than the parsed body: Stripe signs the bytes it sent, and | |
| 12 | * a re-serialized JSON body does not match the HMAC. The router keeps them for | |
| 13 | * exactly this. | |
| 14 | */ | |
| 15 | export default new Action({ | |
| 16 | name: 'Stripe Webhook', | |
| 17 | description: 'Issue a license from a signed Stripe checkout event', | |
| 18 | method: 'POST', | |
| 19 | ||
| 20 | async handle(request: RequestInstance) { | |
| 21 | const outcome = await receiveWebhook( | |
| 22 | await request.rawBody?.() ?? null, | |
| 23 | request.headers.get('stripe-signature'), | |
| 24 | { secret: webhookSecret(), issue: async sessionId => (await licenses()).issue(sessionId) }, | |
| 25 | ) | |
| 26 | ||
| 27 | // Stripe shows these in the dashboard, and they are the only trace of a | |
| 28 | // purchase that did not become a license. | |
| 29 | if (outcome.kind !== 'licensed') | |
| 30 | console.error(`[stripe-webhook] ${outcome.kind}: ${JSON.stringify(bodyFor(outcome))}`) | |
| 31 | ||
| 32 | return response.json(bodyFor(outcome), statusFor(outcome)) | |
| 33 | }, | |
| 34 | }) | |
| @@ -0,0 +1,155 @@ | ||
| 1 | import type { LicenseRecord } from './licenses' | |
| 2 | import process from 'node:process' | |
| 3 | ||
| 4 | /** | |
| 5 | * The Stripe webhook that issues a license when the thank-you page does not. | |
| 6 | * | |
| 7 | * Until this existed, `issue()` had exactly one caller: rendering | |
| 8 | * `/thanks?session_id=...`, which is checkout's `success_url`. So the redirect | |
| 9 | * after payment was the only event in the system that ever created a license, | |
| 10 | * and a buyer who closed the tab, lost the network at that moment, or paid by | |
| 11 | * a method that settles after the redirect, was charged and got nothing. There | |
| 12 | * was no resend path and no way for them to ask (#48). | |
| 13 | * | |
| 14 | * Nothing about issuing is reimplemented here. `issue()` is idempotent by | |
| 15 | * construction: it returns the existing row for a session it has already seen, | |
| 16 | * and only emails a key for one created just now. So the webhook and the page | |
| 17 | * can arrive in either order, or twice, and the buyer ends up with one license | |
| 18 | * and one email. | |
| 19 | */ | |
| 20 | ||
| 21 | /** The events that mean a checkout is paid for and a license is owed. */ | |
| 22 | const ISSUING_EVENTS = new Set([ | |
| 23 | 'checkout.session.completed', | |
| 24 | // Payment methods that settle after the redirect. Stripe sends `completed` | |
| 25 | // first, while `payment_status` is still unpaid, and this once it clears. | |
| 26 | 'checkout.session.async_payment_succeeded', | |
| 27 | ]) | |
| 28 | ||
| 29 | export type WebhookOutcome = | |
| 30 | /** There is a license for this checkout, issued now or already there. */ | |
| 31 | | { kind: 'licensed', key: string } | |
| 32 | /** Verified, and there is nothing to issue: another event, or not paid yet. */ | |
| 33 | | { kind: 'ignored', reason: string } | |
| 34 | /** Not from Stripe, or not signed with this endpoint's secret. */ | |
| 35 | | { kind: 'rejected', reason: string } | |
| 36 | /** No signing secret, so nothing can be verified. Never issue on this. */ | |
| 37 | | { kind: 'unconfigured' } | |
| 38 | /** Verified, and it should have worked. Stripe retries a 500. */ | |
| 39 | | { kind: 'failed', error: string } | |
| 40 | ||
| 41 | export interface WebhookDeps { | |
| 42 | secret: string | |
| 43 | issue: (sessionId: string) => Promise<LicenseRecord | null> | |
| 44 | /** Seconds of clock skew allowed between Stripe and this server. */ | |
| 45 | tolerance?: number | |
| 46 | } | |
| 47 | ||
| 48 | export function webhookSecret(env = process.env): string { | |
| 49 | return (env.STRIPE_WEBHOOK_SECRET ?? '').trim() | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * What to do with one delivery. `raw` must be the bytes Stripe sent: the | |
| 54 | * signature is over those, and a re-serialized JSON body will not match. | |
| 55 | */ | |
| 56 | export async function receiveWebhook(raw: string | null, signature: string | null, deps: WebhookDeps): Promise<WebhookOutcome> { | |
| 57 | if (!deps.secret) | |
| 58 | return { kind: 'unconfigured' } | |
| 59 | if (!raw) | |
| 60 | // The router hands the action `rawBody()`; without it there is nothing to | |
| 61 | // verify, and issuing on an unverified body is worse than not issuing. | |
| 62 | return { kind: 'rejected', reason: 'No request body to verify.' } | |
| 63 | if (!signature) | |
| 64 | return { kind: 'rejected', reason: 'No Stripe-Signature header.' } | |
| 65 | ||
| 66 | try { | |
| 67 | // `@stacksjs/security` rather than the payments package's `constructEvent`: | |
| 68 | // this one is a plain HMAC check with no Stripe client behind it, so it | |
| 69 | // cannot hit the SubtleCrypto trap that made the SDK path 401 every real | |
| 70 | // delivery on Bun (stacksjs/stacks#2355). | |
| 71 | const { verifyStripe } = await import('@stacksjs/security') | |
| 72 | verifyStripe(deps.secret, signature, raw, deps.tolerance === undefined ? undefined : { toleranceSeconds: deps.tolerance }) | |
| 73 | } | |
| 74 | catch (error) { | |
| 75 | return { kind: 'rejected', reason: message(error) } | |
| 76 | } | |
| 77 | ||
| 78 | const event = parse(raw) | |
| 79 | if (!event) | |
| 80 | return { kind: 'rejected', reason: 'Signed, but the body is not a Stripe event.' } | |
| 81 | if (!ISSUING_EVENTS.has(event.type)) | |
| 82 | return { kind: 'ignored', reason: `Nothing to do for ${event.type}.` } | |
| 83 | ||
| 84 | const session = event.data?.object | |
| 85 | if (session?.object !== 'checkout.session' || typeof session.id !== 'string') | |
| 86 | return { kind: 'rejected', reason: `${event.type} carried no checkout session.` } | |
| 87 | ||
| 88 | try { | |
| 89 | const license = await deps.issue(session.id) | |
| 90 | // Null is Stripe saying the session is not paid, or naming a plan this | |
| 91 | // build does not have. Neither improves by being retried: for the first, | |
| 92 | // `async_payment_succeeded` is already on its way. | |
| 93 | return license | |
| 94 | ? { kind: 'licensed', key: license.key } | |
| 95 | : { kind: 'ignored', reason: `${session.id} is not a paid checkout for a plan Uplink sells.` } | |
| 96 | } | |
| 97 | catch (error) { | |
| 98 | // A database or Stripe failure, which a retry may well fix, and the | |
| 99 | // purchase is real either way. | |
| 100 | return { kind: 'failed', error: message(error) } | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Stripe retries anything that is not a 2xx, so the code says whether trying | |
| 106 | * again could help. A bad signature never improves; a database that was down | |
| 107 | * might. | |
| 108 | */ | |
| 109 | export function statusFor(outcome: WebhookOutcome): 200 | 400 | 500 { | |
| 110 | switch (outcome.kind) { | |
| 111 | case 'licensed': | |
| 112 | case 'ignored': | |
| 113 | return 200 | |
| 114 | case 'rejected': | |
| 115 | return 400 | |
| 116 | case 'unconfigured': | |
| 117 | case 'failed': | |
| 118 | return 500 | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | /** What to send back. Never the license key: this answers Stripe, not a buyer. */ | |
| 123 | export function bodyFor(outcome: WebhookOutcome): Record<string, unknown> { | |
| 124 | switch (outcome.kind) { | |
| 125 | case 'licensed': | |
| 126 | return { received: true, licensed: true } | |
| 127 | case 'ignored': | |
| 128 | return { received: true, licensed: false, reason: outcome.reason } | |
| 129 | case 'rejected': | |
| 130 | return { error: outcome.reason } | |
| 131 | case 'unconfigured': | |
| 132 | return { error: 'This server has no Stripe webhook secret, so it cannot verify deliveries.' } | |
| 133 | case 'failed': | |
| 134 | return { error: outcome.error } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | interface StripeEvent { | |
| 139 | type: string | |
| 140 | data?: { object?: Record<string, any> } | |
| 141 | } | |
| 142 | ||
| 143 | function parse(raw: string): StripeEvent | null { | |
| 144 | try { | |
| 145 | const body = JSON.parse(raw) as StripeEvent | |
| 146 | return body && typeof body.type === 'string' ? body : null | |
| 147 | } | |
| 148 | catch { | |
| 149 | return null | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | function message(error: unknown): string { | |
| 154 | return error instanceof Error ? error.message : String(error) | |
| 155 | } | |
| @@ -14,6 +14,13 @@ export default { | ||
| 14 | 14 | stripe: { |
| 15 | 15 | publishableKey: env.STRIPE_PUBLISHABLE_KEY || '', |
| 16 | 16 | secretKey: env.STRIPE_SECRET_KEY || '', |
| 17 | /** | |
| 18 | * The signing secret of the account webhook endpoint, | |
| 19 | * `/api/billing/webhook`, which is what issues a license for a checkout | |
| 20 | * whose buyer never reached the thank-you page. Stripe shows it as | |
| 21 | * `whsec_...` when the endpoint is created. | |
| 22 | */ | |
| 23 | webhookSecret: env.STRIPE_WEBHOOK_SECRET || '', | |
| 17 | 24 | }, |
| 18 | 25 | |
| 19 | 26 | /** |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { SaasConfig } from '@stacksjs/types' |
| 2 | import process from 'node:process' | |
| 2 | 3 | import { PLANS, PRODUCT_NAME, SIX_MONTHS_FREE } from '../app/Billing/plans' |
| 3 | 4 | |
| 4 | 5 | /** |
| @@ -27,9 +28,12 @@ export default { | ||
| 27 | 28 | }, |
| 28 | 29 | }, |
| 29 | 30 | ], |
| 31 | // Where Stripe sends what happened. `STRIPE_WEBHOOK_SECRET` is the signing | |
| 32 | // secret Stripe shows when the endpoint is created; without it the endpoint | |
| 33 | // verifies nothing and refuses every delivery, which is the safe direction. | |
| 30 | 34 | webhook: { |
| 31 | endpoint: 'your-webhook-endpoint', | |
| 32 | secret: 'your-webhook-secret', | |
| 35 | endpoint: '/api/billing/webhook', | |
| 36 | secret: process.env.STRIPE_WEBHOOK_SECRET ?? '', | |
| 33 | 37 | }, |
| 34 | 38 | currencies: ['usd'], |
| 35 | 39 | // Six months of Monthly, free: 100% off six monthly invoices. The code is |
| @@ -27,6 +27,10 @@ route.get('/hello', () => response.text('hello world')) | ||
| 27 | 27 | // there is nothing for a CSRF token to protect, and the app has none to send. |
| 28 | 28 | route.post('/license/check', 'Actions/License/CheckLicenseAction').skipCsrf() |
| 29 | 29 | route.post('/billing/portal', 'Actions/Billing/OpenPortalAction').skipCsrf() |
| 30 | // Stripe's own copy of a completed checkout. It carries a signature rather | |
| 31 | // than a session cookie, so it skips CSRF like the two above, and it is the | |
| 32 | // reason a buyer who never loads /thanks still gets a key. | |
| 33 | route.post('/billing/webhook', 'Actions/Billing/StripeWebhookAction').skipCsrf() | |
| 30 | 34 | |
| 31 | 35 | // `/coming-soon` is served as an STX view from |
| 32 | 36 | // `storage/framework/defaults/resources/views/coming-soon.stx`. The |
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | // would make every compilation that touches a name resolve every module. |
| 7 | 7 | export const actions = { |
| 8 | 8 | 'Actions/Billing/OpenPortalAction': '../../../app/Actions/Billing/OpenPortalAction.ts', |
| 9 | 'Actions/Billing/StripeWebhookAction': '../../../app/Actions/Billing/StripeWebhookAction.ts', | |
| 9 | 10 | 'Actions/License/CheckLicenseAction': '../../../app/Actions/License/CheckLicenseAction.ts', |
| 10 | 11 | 'Actions/NotifyUser': '../../../app/Actions/NotifyUser.ts', |
| 11 | 12 | 'Actions/SendWelcomeEmail': '../../../app/Actions/SendWelcomeEmail.ts', |
| @@ -0,0 +1,225 @@ | ||
| 1 | import type { LicenseRecord, LicenseStore, LicenseStripe } from '../../../app/Billing/licenses' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { createHmac } from 'node:crypto' | |
| 4 | import process from 'node:process' | |
| 5 | import { Licenses } from '../../../app/Billing/licenses' | |
| 6 | import { bodyFor, receiveWebhook, statusFor, webhookSecret } from '../../../app/Billing/webhook' | |
| 7 | ||
| 8 | const SECRET = 'whsec_test_secret' | |
| 9 | const PERIOD_END = 1_800_000_000 | |
| 10 | ||
| 11 | function memoryStore(): LicenseStore & { rows: LicenseRecord[] } { | |
| 12 | const rows: LicenseRecord[] = [] | |
| 13 | return { | |
| 14 | rows, | |
| 15 | byKey: async key => rows.find(r => r.key === key) ?? null, | |
| 16 | bySession: async id => rows.find(r => r.stripeCheckoutSessionId === id) ?? null, | |
| 17 | create: async (license) => { rows.push({ ...license }) }, | |
| 18 | update: async (key, patch) => { Object.assign(rows.find(r => r.key === key)!, patch) }, | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | function fakeStripe(): LicenseStripe { | |
| 23 | const sessions: Record<string, any> = { | |
| 24 | cs_test_monthly: { status: 'complete', payment_status: 'paid', metadata: { plan: 'monthly' }, subscription: 'sub_1', customer: 'cus_1', customer_details: { email: 'a@example.com' } }, | |
| 25 | // Paid by a method that settles after the redirect: `completed` arrives | |
| 26 | // while this is still unpaid, and `async_payment_succeeded` follows. | |
| 27 | cs_test_pending: { status: 'complete', payment_status: 'unpaid', metadata: { plan: 'monthly' }, subscription: 'sub_1', customer: 'cus_2', customer_details: { email: 'b@example.com' } }, | |
| 28 | } | |
| 29 | return { | |
| 30 | checkoutSession: async (id: string) => sessions[id], | |
| 31 | subscription: async () => ({ status: 'active', items: { data: [{ current_period_end: PERIOD_END }] }, cancel_at_period_end: false }) as any, | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | /** A delivery as Stripe sends it: the signature is over these exact bytes. */ | |
| 36 | function delivery(type: string, sessionId: string, at = Math.floor(Date.now() / 1000)): { raw: string, signature: string } { | |
| 37 | const raw = JSON.stringify({ id: 'evt_1', type, data: { object: { object: 'checkout.session', id: sessionId } } }) | |
| 38 | return { raw, signature: sign(raw, at) } | |
| 39 | } | |
| 40 | ||
| 41 | function sign(raw: string, at: number, secret = SECRET): string { | |
| 42 | return `t=${at},v1=${createHmac('sha256', secret).update(`${at}.${raw}`).digest('hex')}` | |
| 43 | } | |
| 44 | ||
| 45 | /** The webhook and the thank-you page both go through `issue`. */ | |
| 46 | function billing() { | |
| 47 | const store = memoryStore() | |
| 48 | const emails: string[] = [] | |
| 49 | const licenses = new Licenses(store, fakeStripe(), undefined, async (license) => { emails.push(license.key) }) | |
| 50 | return { | |
| 51 | store, | |
| 52 | emails, | |
| 53 | page: (sessionId: string) => licenses.issue(sessionId), | |
| 54 | post: (sent: { raw: string, signature: string }, secret = SECRET) => | |
| 55 | receiveWebhook(sent.raw, sent.signature, { secret, issue: id => licenses.issue(id) }), | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | describe('the Stripe webhook', () => { | |
| 60 | it('issues the license for a paid checkout, and says nothing about the key', async () => { | |
| 61 | const { post, store, emails } = billing() | |
| 62 | const outcome = await post(delivery('checkout.session.completed', 'cs_test_monthly')) | |
| 63 | ||
| 64 | expect(outcome.kind).toBe('licensed') | |
| 65 | expect(statusFor(outcome)).toBe(200) | |
| 66 | expect(store.rows).toHaveLength(1) | |
| 67 | expect(emails).toHaveLength(1) | |
| 68 | // The reply goes to Stripe, not to a buyer, so it carries no key. | |
| 69 | expect(JSON.stringify(bodyFor(outcome))).not.toContain(store.rows[0].key) | |
| 70 | }) | |
| 71 | ||
| 72 | it('issues on the event that follows a payment settling after the redirect', async () => { | |
| 73 | const { post, store } = billing() | |
| 74 | // `completed` arrives first, while Stripe still says unpaid. Retrying that | |
| 75 | // would not help, so it is acknowledged rather than failed. | |
| 76 | const early = await post(delivery('checkout.session.completed', 'cs_test_pending')) | |
| 77 | expect(early.kind).toBe('ignored') | |
| 78 | expect(statusFor(early)).toBe(200) | |
| 79 | expect(store.rows).toHaveLength(0) | |
| 80 | }) | |
| 81 | ||
| 82 | /** | |
| 83 | * The acceptance for #48: the webhook and the thank-you page can arrive in | |
| 84 | * either order, or twice, and the buyer ends up with one license and one | |
| 85 | * email. `issue` was already idempotent, so this pins it rather than adding | |
| 86 | * anything. | |
| 87 | */ | |
| 88 | it('produces one license and one email however the two arrive', async () => { | |
| 89 | for (const order of ['webhook first', 'page first'] as const) { | |
| 90 | const { post, page, store, emails } = billing() | |
| 91 | const sent = delivery('checkout.session.completed', 'cs_test_monthly') | |
| 92 | ||
| 93 | if (order === 'webhook first') { | |
| 94 | await post(sent) | |
| 95 | await post(sent) | |
| 96 | await page('cs_test_monthly') | |
| 97 | } | |
| 98 | else { | |
| 99 | await page('cs_test_monthly') | |
| 100 | await post(sent) | |
| 101 | await post(sent) | |
| 102 | } | |
| 103 | ||
| 104 | expect(store.rows).toHaveLength(1) | |
| 105 | expect(emails).toHaveLength(1) | |
| 106 | } | |
| 107 | }) | |
| 108 | ||
| 109 | it('acknowledges an event it has nothing to do with, rather than failing it', async () => { | |
| 110 | const { post, store } = billing() | |
| 111 | const outcome = await post(delivery('invoice.paid', 'cs_test_monthly')) | |
| 112 | expect(outcome.kind).toBe('ignored') | |
| 113 | expect(statusFor(outcome)).toBe(200) | |
| 114 | expect(store.rows).toHaveLength(0) | |
| 115 | }) | |
| 116 | ||
| 117 | it('refuses anything it cannot verify, and issues nothing', async () => { | |
| 118 | const { post, store, emails } = billing() | |
| 119 | const sent = delivery('checkout.session.completed', 'cs_test_monthly') | |
| 120 | ||
| 121 | const cases = [ | |
| 122 | ['signed with another secret', { ...sent, signature: sign(sent.raw, Math.floor(Date.now() / 1000), 'whsec_someone_else') }], | |
| 123 | ['no signature at all', { ...sent, signature: '' }], | |
| 124 | ['a body swapped after signing', { raw: sent.raw.replace('cs_test_monthly', 'cs_test_other'), signature: sent.signature }], | |
| 125 | ['an empty body', { raw: '', signature: sent.signature }], | |
| 126 | ['replayed from an hour ago', delivery('checkout.session.completed', 'cs_test_monthly', Math.floor(Date.now() / 1000) - 3600)], | |
| 127 | ] as const | |
| 128 | ||
| 129 | for (const [what, sentCase] of cases) { | |
| 130 | const outcome = await post(sentCase) | |
| 131 | expect(`${what}: ${outcome.kind}`).toBe(`${what}: rejected`) | |
| 132 | expect(statusFor(outcome)).toBe(400) | |
| 133 | } | |
| 134 | expect(store.rows).toHaveLength(0) | |
| 135 | expect(emails).toHaveLength(0) | |
| 136 | }) | |
| 137 | ||
| 138 | it('refuses a signed body that is not a Stripe event', async () => { | |
| 139 | const { post } = billing() | |
| 140 | for (const raw of ['not json', '{}', '[]', JSON.stringify({ type: 'checkout.session.completed', data: { object: { object: 'invoice', id: 'in_1' } } })]) { | |
| 141 | const outcome = await post({ raw, signature: sign(raw, Math.floor(Date.now() / 1000)) }) | |
| 142 | expect(outcome.kind).toBe('rejected') | |
| 143 | } | |
| 144 | }) | |
| 145 | ||
| 146 | /** | |
| 147 | * No secret means nothing can be verified, so nothing may be issued. A 500 | |
| 148 | * rather than a 200, because Stripe retries a 500 for days and this is a | |
| 149 | * misconfiguration someone can still fix in that time. | |
| 150 | */ | |
| 151 | it('issues nothing when the server has no signing secret', async () => { | |
| 152 | const { post, store } = billing() | |
| 153 | const outcome = await post(delivery('checkout.session.completed', 'cs_test_monthly'), '') | |
| 154 | expect(outcome.kind).toBe('unconfigured') | |
| 155 | expect(statusFor(outcome)).toBe(500) | |
| 156 | expect(store.rows).toHaveLength(0) | |
| 157 | }) | |
| 158 | ||
| 159 | it('asks Stripe to try again when issuing fails for a reason that might pass', async () => { | |
| 160 | const sent = delivery('checkout.session.completed', 'cs_test_monthly') | |
| 161 | const outcome = await receiveWebhook(sent.raw, sent.signature, { | |
| 162 | secret: SECRET, | |
| 163 | issue: async () => { throw new Error('the database is down') }, | |
| 164 | }) | |
| 165 | expect(outcome.kind).toBe('failed') | |
| 166 | expect(statusFor(outcome)).toBe(500) | |
| 167 | expect(bodyFor(outcome)).toEqual({ error: 'the database is down' }) | |
| 168 | }) | |
| 169 | ||
| 170 | it('reads its secret from the environment, trimmed', () => { | |
| 171 | expect(webhookSecret({ STRIPE_WEBHOOK_SECRET: ' whsec_x \n' })).toBe('whsec_x') | |
| 172 | expect(webhookSecret({})).toBe('') | |
| 173 | }) | |
| 174 | }) | |
| 175 | ||
| 176 | /** | |
| 177 | * The action itself, not the module under it. The one thing that cannot be | |
| 178 | * caught by reading the code is whether it verifies the bytes Stripe sent or a | |
| 179 | * re-serialized body, and a re-serialized one never matches the HMAC. | |
| 180 | */ | |
| 181 | describe('the webhook action', () => { | |
| 182 | function fakeRequest(raw: string, signature: string | null) { | |
| 183 | const reads = { rawBody: 0, parsed: 0 } | |
| 184 | return { | |
| 185 | reads, | |
| 186 | request: { | |
| 187 | headers: new Headers(signature === null ? {} : { 'stripe-signature': signature }), | |
| 188 | rawBody: async () => { reads.rawBody++; return raw }, | |
| 189 | // If the action ever reached for these, the signature would be over | |
| 190 | // bytes it never saw. | |
| 191 | get all() { reads.parsed++; return () => JSON.parse(raw) }, | |
| 192 | get jsonBody() { reads.parsed++; return JSON.parse(raw) }, | |
| 193 | } as any, | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | it('verifies the bytes on the wire, not the parsed body', async () => { | |
| 198 | const action = (await import('../../../app/Actions/Billing/StripeWebhookAction')).default | |
| 199 | const sent = delivery('checkout.session.completed', 'cs_test_monthly') | |
| 200 | // Signed correctly, but this server has no secret, so it stops before | |
| 201 | // anything touches the database. | |
| 202 | const { request, reads } = fakeRequest(sent.raw, sent.signature) | |
| 203 | ||
| 204 | const answer = await action.handle(request) | |
| 205 | expect(answer.status).toBe(500) | |
| 206 | expect(reads.rawBody).toBe(1) | |
| 207 | expect(reads.parsed).toBe(0) | |
| 208 | }) | |
| 209 | ||
| 210 | it('answers 400 for a delivery it cannot verify', async () => { | |
| 211 | const action = (await import('../../../app/Actions/Billing/StripeWebhookAction')).default | |
| 212 | const sent = delivery('checkout.session.completed', 'cs_test_monthly') | |
| 213 | process.env.STRIPE_WEBHOOK_SECRET = SECRET | |
| 214 | try { | |
| 215 | const { request } = fakeRequest(sent.raw, 't=1,v1=deadbeef') | |
| 216 | expect((await action.handle(request)).status).toBe(400) | |
| 217 | ||
| 218 | const missing = fakeRequest(sent.raw, null) | |
| 219 | expect((await action.handle(missing.request)).status).toBe(400) | |
| 220 | } | |
| 221 | finally { | |
| 222 | delete process.env.STRIPE_WEBHOOK_SECRET | |
| 223 | } | |
| 224 | }) | |
| 225 | }) | |