test(integration): Stripe webhook route handler end-to-end (F4)
Covers the biggest untested orchestration (where the Phase-3 P0s + the deeper-audit D5 bug lived): real signature verify (good/bad/missing), per-space vault secret+config resolution, stripe_webhook_events replay idempotency, applySubscription -> member_billing upsert + space_members status, and the D5 out-of-order monotonic guard proven end-to-end (a stale event does NOT rewind current_period_end). Invokes the actual POST handler against local Supabase with Stripe-signed events (customer.subscription.updated -> no network, no payments enum). 26 integration tests green; default pnpm test 532/26 hermetic; build clean. Non-runtime.
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
// End-to-end integration of the Stripe webhook ROUTE HANDLER — the biggest
|
||||||
|
// untested orchestration (the harness's other files cover its DB-layer and
|
||||||
|
// pure-logic pieces; this exercises the WIRING where the Phase-3 P0s and the
|
||||||
|
// deeper-audit D5 bug actually lived): real signature verification, per-space
|
||||||
|
// secret/config resolution from the vault, the stripe_webhook_events
|
||||||
|
// idempotency, applySubscription, the D5 out-of-order monotonic guard, and
|
||||||
|
// the status-update path. Uses customer.subscription.updated so there is no
|
||||||
|
// Stripe API network call and no payments/enum dependency.
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { dbReady, execService, rows, seedSpace, seedMember, dropSpace, uuid } from './db'
|
||||||
|
|
||||||
|
const WHSEC = 'whsec_itest_secret'
|
||||||
|
const signer = new Stripe('sk_test_dummy')
|
||||||
|
|
||||||
|
// Local Supabase REST creds so the route's createAdminClient hits the local
|
||||||
|
// stack. Parsed from `supabase status -o env`; skip the suite if unavailable.
|
||||||
|
function loadSupabaseEnv(): boolean {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('supabase', ['status', '-o', 'env'], { encoding: 'utf8' })
|
||||||
|
const get = (k: string) =>
|
||||||
|
out.split('\n').find(l => l.startsWith(k + '='))?.slice(k.length + 1).replace(/^"|"$/g, '')
|
||||||
|
const url = get('API_URL')
|
||||||
|
const key = get('SERVICE_ROLE_KEY')
|
||||||
|
if (!url || !key) return false
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL = url
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY = key
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const envOk = loadSupabaseEnv()
|
||||||
|
const d = describe.skipIf(!dbReady || !envOk)
|
||||||
|
|
||||||
|
function subEvent(opts: {
|
||||||
|
eventId?: string
|
||||||
|
memberId: string
|
||||||
|
spaceId: string
|
||||||
|
status?: string
|
||||||
|
periodEndDaysFromNow?: number
|
||||||
|
}) {
|
||||||
|
const periodEnd =
|
||||||
|
Math.floor(Date.now() / 1000) + (opts.periodEndDaysFromNow ?? 30) * 86400
|
||||||
|
return {
|
||||||
|
id: opts.eventId ?? `evt_${uuid()}`,
|
||||||
|
type: 'customer.subscription.updated',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: `sub_${uuid()}`,
|
||||||
|
status: opts.status ?? 'active',
|
||||||
|
customer: `cus_${uuid()}`,
|
||||||
|
metadata: { member_id: opts.memberId, space_id: opts.spaceId },
|
||||||
|
items: { data: [{ current_period_end: periodEnd }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postEvent(spaceId: string, event: object, opts: { badSig?: boolean; noSig?: boolean } = {}) {
|
||||||
|
// POST is imported lazily AFTER env is set so createAdminClient binds local.
|
||||||
|
const { POST } = await import('@/app/api/stripe/webhook/[space]/route')
|
||||||
|
const raw = JSON.stringify(event)
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||||
|
if (!opts.noSig) {
|
||||||
|
headers['stripe-signature'] = opts.badSig
|
||||||
|
? 't=1,v1=deadbeef'
|
||||||
|
: signer.webhooks.generateTestHeaderString({ payload: raw, secret: WHSEC })
|
||||||
|
}
|
||||||
|
const req = new NextRequest(`http://itest/api/stripe/webhook/${spaceId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: raw,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ space: spaceId }) })
|
||||||
|
return { status: res.status, body: (await res.json()) as Record<string, unknown> }
|
||||||
|
}
|
||||||
|
|
||||||
|
d('Stripe webhook route handler (end-to-end)', () => {
|
||||||
|
let spaceId: string
|
||||||
|
let memberId: string
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
spaceId = seedSpace().spaceId
|
||||||
|
memberId = seedMember(spaceId, { status: 'current' }).memberId
|
||||||
|
const keyRef = uuid()
|
||||||
|
const whRef = uuid()
|
||||||
|
execService(
|
||||||
|
`insert into secrets (id, space_id, label, value) values ('${keyRef}','${spaceId}','stripe key','sk_test_dummy');
|
||||||
|
insert into secrets (id, space_id, label, value) values ('${whRef}','${spaceId}','stripe whsec','${WHSEC}');
|
||||||
|
insert into integrations (space_id, platform, config)
|
||||||
|
values ('${spaceId}','stripe','{"secret_key_ref":"${keyRef}","webhook_secret_ref":"${whRef}"}'::jsonb);`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
afterAll(() => dropSpace(spaceId))
|
||||||
|
|
||||||
|
it('missing signature -> 400', async () => {
|
||||||
|
const r = await postEvent(spaceId, subEvent({ memberId, spaceId }), { noSig: true })
|
||||||
|
expect(r.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bad signature -> 400 with generic message (E3)', async () => {
|
||||||
|
const r = await postEvent(spaceId, subEvent({ memberId, spaceId }), { badSig: true })
|
||||||
|
expect(r.status).toBe(400)
|
||||||
|
expect(r.body.error).toBe('Invalid signature')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valid subscription.updated -> billing upserted + member current + event recorded', async () => {
|
||||||
|
const evt = subEvent({ memberId, spaceId, status: 'active', periodEndDaysFromNow: 30 })
|
||||||
|
const r = await postEvent(spaceId, evt)
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
const ev = rows<{ n: number }>(
|
||||||
|
`select count(*)::int n from stripe_webhook_events where event_id='${evt.id}'`,
|
||||||
|
)
|
||||||
|
expect(ev[0].n).toBe(1)
|
||||||
|
const mb = rows<{ subscription_status: string; cpe: string }>(
|
||||||
|
`select subscription_status, current_period_end cpe from member_billing where space_id='${spaceId}' and member_id='${memberId}'`,
|
||||||
|
)
|
||||||
|
expect(mb).toHaveLength(1)
|
||||||
|
expect(mb[0].subscription_status).toBe('active')
|
||||||
|
expect(new Date(mb[0].cpe).getTime()).toBeGreaterThan(Date.now())
|
||||||
|
const sm = rows<{ status: string; lpa: string | null }>(
|
||||||
|
`select status, last_paid_at lpa from space_members where id='${memberId}'`,
|
||||||
|
)
|
||||||
|
expect(sm[0].status).toBe('current')
|
||||||
|
expect(sm[0].lpa).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replay of the same event id -> 200 duplicate, no second processing', async () => {
|
||||||
|
const evt = subEvent({ memberId, spaceId })
|
||||||
|
const first = await postEvent(spaceId, evt)
|
||||||
|
expect(first.status).toBe(200)
|
||||||
|
const replay = await postEvent(spaceId, evt) // identical event.id
|
||||||
|
expect(replay.status).toBe(200)
|
||||||
|
expect(replay.body.duplicate).toBe(true)
|
||||||
|
const cnt = rows<{ n: number }>(
|
||||||
|
`select count(*)::int n from stripe_webhook_events where event_id='${evt.id}'`,
|
||||||
|
)
|
||||||
|
expect(cnt[0].n).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('D5: a stale out-of-order event must NOT rewind current_period_end', async () => {
|
||||||
|
const m2 = seedMember(spaceId, { status: 'current' }).memberId
|
||||||
|
await postEvent(spaceId, subEvent({ memberId: m2, spaceId, periodEndDaysFromNow: 60 }))
|
||||||
|
const after1 = rows<{ cpe: string }>(
|
||||||
|
`select current_period_end cpe from member_billing where member_id='${m2}'`,
|
||||||
|
)[0].cpe
|
||||||
|
// A late/stale event carrying an OLDER period (distinct event id).
|
||||||
|
await postEvent(spaceId, subEvent({ memberId: m2, spaceId, periodEndDaysFromNow: 10 }))
|
||||||
|
const after2 = rows<{ cpe: string }>(
|
||||||
|
`select current_period_end cpe from member_billing where member_id='${m2}'`,
|
||||||
|
)[0].cpe
|
||||||
|
expect(new Date(after2).getTime()).toBe(new Date(after1).getTime()) // not rewound
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user