diff --git a/apps/web/src/app/api/stripe/webhook/route.test.ts b/apps/web/src/app/api/stripe/webhook/route.test.ts new file mode 100644 index 00000000000..b5c7e4bf91d --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/route.test.ts @@ -0,0 +1,170 @@ +import Stripe from 'stripe'; +import { headers } from 'next/headers'; +import { captureException } from '@sentry/nextjs'; +import { client } from '@kilocode/web-shared/lib/stripe-client'; +import { getEnvVariable } from '@kilocode/web-shared/lib/dotenvx'; +import { processStripePaymentEventHook } from '@/lib/stripe'; +import { POST } from './route'; + +jest.mock('next/headers', () => ({ headers: jest.fn() })); +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })); +jest.mock('@/lib/stripe', () => ({ processStripePaymentEventHook: jest.fn() })); +jest.mock('@kilocode/web-shared/lib/dotenvx', () => ({ + getEnvVariable: jest.fn((key: string) => + key === 'STRIPE_WEBHOOK_SECRET' ? 'whsec_local_fixture' : 'local_fixture' + ), +})); + +const secret = 'whsec_local_fixture'; +const body = JSON.stringify({ + id: 'evt_local_fixture', + object: 'event', + type: 'checkout.session.completed', + data: { object: { id: 'cs_local_fixture' } }, +}); + +function signatureError(): Stripe.errors.StripeSignatureVerificationError { + try { + client.webhooks.constructEvent(body, 'fixture', secret); + } catch (error) { + if (error instanceof Stripe.errors.StripeSignatureVerificationError) { + return error; + } + throw error; + } + throw new Error('Expected SDK verification error'); +} + +async function deliver( + payload = body, + signature: string | null = client.webhooks.generateTestHeaderString({ payload, secret }) +) { + jest + .mocked(headers) + .mockResolvedValue(new Headers(signature === null ? {} : { 'stripe-signature': signature })); + return POST( + new Request('http://localhost/api/stripe/webhook', { method: 'POST', body: payload }) + ); +} + +describe('Stripe webhook responses', () => { + let now = Date.UTC(2026, 0, 1); + + beforeEach(() => { + jest.clearAllMocks(); + now += 60_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + jest.mocked(processStripePaymentEventHook).mockResolvedValue(undefined); + jest + .mocked(getEnvVariable) + .mockImplementation((key: string) => + key === 'STRIPE_WEBHOOK_SECRET' ? secret : 'local_fixture' + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('refuses a request without a signature header', async () => { + const response = await deliver(body, null); + expect(response.status).toBe(400); + expect(await response.text()).toBe('Webhook Error'); + expect(processStripePaymentEventHook).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + test.each([ + () => 'fixture', + () => + client.webhooks.generateTestHeaderString({ payload: body, secret: 'whsec_other_fixture' }), + () => + client.webhooks.generateTestHeaderString({ + payload: body, + secret, + timestamp: Math.floor(Date.now() / 1000) - 3600, + }), + ])('refuses and reports a failed signature verification %#', async buildSignature => { + const response = await deliver(body, buildSignature()); + expect(response.status).toBe(400); + expect(await response.text()).toBe('Webhook Error'); + expect(processStripePaymentEventHook).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledTimes(1); + + const [reported, options] = jest.mocked(captureException).mock.calls[0]; + expect(reported).toBeInstanceOf(Stripe.errors.StripeSignatureVerificationError); + expect(options).toEqual({ tags: { source: 'stripe_webhook_signature' } }); + }); + + test('limits verification reports across failure causes', async () => { + await deliver(body, 'fixture'); + await deliver( + body, + client.webhooks.generateTestHeaderString({ payload: body, secret: 'whsec_other_fixture' }) + ); + + expect(captureException).toHaveBeenCalledTimes(1); + + now += 60_000; + await deliver( + body, + client.webhooks.generateTestHeaderString({ + payload: body, + secret, + timestamp: Math.floor(Date.now() / 1000) - 3600, + }) + ); + + expect(captureException).toHaveBeenCalledTimes(2); + }); + + test('refuses delivery when the signing secret is not configured', async () => { + jest + .mocked(getEnvVariable) + .mockImplementation((key: string) => + key === 'STRIPE_WEBHOOK_SECRET' ? '' : 'local_fixture' + ); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await deliver(); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('Missing STRIPE_WEBHOOK_SECRET'); + expect(processStripePaymentEventHook).not.toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(new Error('STRIPE_WEBHOOK_SECRET is not set')); + expect(consoleError).toHaveBeenCalledWith('STRIPE_WEBHOOK_SECRET is not set'); + }); + + test('acknowledges event delivery', async () => { + const response = await deliver(); + expect(response.status).toBe(200); + expect(await response.text()).toBe('OK'); + expect(processStripePaymentEventHook).toHaveBeenCalledWith(JSON.parse(body)); + expect(captureException).not.toHaveBeenCalled(); + }); + + test.each([new Error('local processing failure'), signatureError()])( + 'retains processing diagnostics %#', + async error => { + jest.mocked(processStripePaymentEventHook).mockRejectedValueOnce(error); + const response = await deliver(); + expect(response.status).toBe(400); + expect(await response.text()).toBe(`Webhook Error: ${error.message}`); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(error); + } + ); + + test('retains unexpected diagnostics', async () => { + const error = new Error('local verification failure'); + jest.spyOn(client.webhooks, 'constructEvent').mockImplementationOnce(() => { + throw error; + }); + const response = await deliver(); + expect(response.status).toBe(400); + expect(await response.text()).toBe('Webhook Error: local verification failure'); + expect(captureException).toHaveBeenCalledWith(error); + expect(processStripePaymentEventHook).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts index 5ff830d069a..e02515048f9 100644 --- a/apps/web/src/app/api/stripe/webhook/route.ts +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -4,26 +4,51 @@ import { NextResponse } from 'next/server'; import { processStripePaymentEventHook } from '@/lib/stripe'; import { captureException } from '@sentry/nextjs'; import { client } from '@kilocode/web-shared/lib/stripe-client'; +import Stripe from 'stripe'; + +const SIGNATURE_FAILURE_REPORT_INTERVAL_MS = 60_000; + +let lastSignatureFailureReportedAt = 0; + +function reportSignatureFailure(error: Stripe.errors.StripeSignatureVerificationError): void { + const now = Date.now(); + if (now - lastSignatureFailureReportedAt < SIGNATURE_FAILURE_REPORT_INTERVAL_MS) { + return; + } + lastSignatureFailureReportedAt = now; + captureException(error, { tags: { source: 'stripe_webhook_signature' } }); +} export async function POST(req: Request): Promise> { const body = await req.text(); const signature = (await headers()).get('stripe-signature'); if (!signature) { - return new NextResponse('No stripe-signature header found', { status: 400 }); + return new NextResponse('Webhook Error', { status: 400 }); } if (!getEnvVariable('STRIPE_WEBHOOK_SECRET')) { - console.error('STRIPE_WEBHOOK_SECRET is not set'); + const error = new Error('STRIPE_WEBHOOK_SECRET is not set'); + console.error(error.message); + captureException(error); return new NextResponse('Missing STRIPE_WEBHOOK_SECRET', { status: 500 }); } try { - const event = client.webhooks.constructEvent( - body, - signature, - getEnvVariable('STRIPE_WEBHOOK_SECRET') - ); + let event: Stripe.Event; + try { + event = client.webhooks.constructEvent( + body, + signature, + getEnvVariable('STRIPE_WEBHOOK_SECRET') + ); + } catch (err: unknown) { + if (err instanceof Stripe.errors.StripeSignatureVerificationError) { + reportSignatureFailure(err); + return new NextResponse('Webhook Error', { status: 400 }); + } + throw err; + } await processStripePaymentEventHook(event); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Unknown error';