Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions apps/web/src/app/api/stripe/webhook/route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
39 changes: 32 additions & 7 deletions apps/web/src/app/api/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse<unknown>> {
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';
Expand Down
Loading