From 958bd7abc5f552e22d4a7e8609ef6c3e83c74dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:48:10 +0000 Subject: [PATCH 01/10] fix(payments): refine route response contracts --- DEVELOPMENT.md | 5 ++ .../src/app/api/stripe/webhook/route.test.ts | 90 +++++++++++++++++++ apps/web/src/app/api/stripe/webhook/route.ts | 21 +++-- 3 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/api/stripe/webhook/route.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f93961c70b2..a3ebbf88f26 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -279,6 +279,11 @@ To test Stripe integration locally: If the Stripe CLI is not installed, `pnpm dev:start` skips webhook forwarding. To run only the webhook forwarder manually, use `pnpm --filter web stripe`. +The route response contract is covered by +`pnpm --filter web test src/app/api/stripe/webhook/route.test.ts --runInBand`. +The suite uses locally generated SDK signatures and isolated event-processing +and diagnostic dependencies; it does not require Stripe CLI forwarding. + ## Database Schema Changes 1. Edit the schema in `packages/db/src/schema.ts` 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..9808e0a4ad1 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/route.test.ts @@ -0,0 +1,90 @@ +import Stripe from 'stripe'; +import { headers } from 'next/headers'; +import { captureException } from '@sentry/nextjs'; +import { client } from '@kilocode/web-shared/lib/stripe-client'; +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: () => 'whsec_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' } }, +}); + +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', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(processStripePaymentEventHook).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + null, + 'fixture', + client.webhooks.generateTestHeaderString({ payload: body, secret: 'whsec_other_fixture' }), + ])('applies the request response contract %#', async signature => { + const response = await deliver(body, signature); + expect(response.status).toBe(400); + expect(await response.text()).toBe('Webhook Error'); + expect(processStripePaymentEventHook).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + 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'), + new Stripe.errors.StripeSignatureVerificationError('fixture', body, { + message: 'local processing failure', + }), + ])('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: local processing failure'); + 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..2f49c4868d6 100644 --- a/apps/web/src/app/api/stripe/webhook/route.ts +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -4,13 +4,14 @@ 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'; 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')) { @@ -19,11 +20,19 @@ export async function POST(req: Request): Promise> { } 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) { + return new NextResponse('Webhook Error', { status: 400 }); + } + throw err; + } await processStripePaymentEventHook(event); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; From 9e89a10b0696a89d9d358824f45320f87f591936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:50:14 +0000 Subject: [PATCH 02/10] test(payments): construct SDK fixture through verification --- .../src/app/api/stripe/webhook/route.test.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/api/stripe/webhook/route.test.ts b/apps/web/src/app/api/stripe/webhook/route.test.ts index 9808e0a4ad1..ef98f56db39 100644 --- a/apps/web/src/app/api/stripe/webhook/route.test.ts +++ b/apps/web/src/app/api/stripe/webhook/route.test.ts @@ -20,6 +20,18 @@ const body = JSON.stringify({ 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 }) @@ -62,19 +74,17 @@ describe('Stripe webhook responses', () => { expect(captureException).not.toHaveBeenCalled(); }); - test.each([ - new Error('local processing failure'), - new Stripe.errors.StripeSignatureVerificationError('fixture', body, { - message: 'local processing failure', - }), - ])('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: local processing failure'); - expect(captureException).toHaveBeenCalledTimes(1); - expect(captureException).toHaveBeenCalledWith(error); - }); + 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'); From 20bafca09190357f34bb384d07e619d510c607fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:05:39 +0000 Subject: [PATCH 03/10] test(cloud-agent): initialize terminal runtime fixture --- services/cloud-agent-next/README.md | 4 ++++ .../test/integration/control-plane-terminal-v2.test.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/services/cloud-agent-next/README.md b/services/cloud-agent-next/README.md index 2cc5d8fddf2..22c2b835b9d 100644 --- a/services/cloud-agent-next/README.md +++ b/services/cloud-agent-next/README.md @@ -786,6 +786,10 @@ pnpm --filter cloud-agent-next test:all **When to use:** Testing Durable Object behavior, queue consumers, WebSocket handling, SQLite storage, alarms, and any functionality that depends on the Cloudflare Workers runtime. Integration tests use utilities like `runInDurableObject`, `createMessageBatch`, and `getQueueResult` from `cloudflare:test`. +Terminal lifecycle fixtures use `installFakeRuntimeProxyPeer` for credential +issuance while retaining real Session DO notifications. A fake wrapper answers +`session.prepare` with `session.ready` before terminal operations begin. + #### Other Quality Checks ```bash diff --git a/services/cloud-agent-next/test/integration/control-plane-terminal-v2.test.ts b/services/cloud-agent-next/test/integration/control-plane-terminal-v2.test.ts index bddde7f666f..8b94e712b88 100644 --- a/services/cloud-agent-next/test/integration/control-plane-terminal-v2.test.ts +++ b/services/cloud-agent-next/test/integration/control-plane-terminal-v2.test.ts @@ -22,6 +22,7 @@ import { installFakeCredentialEnv, } from './helpers/fake-credentials.js'; import { FakeWrapper } from './helpers/fake-wrapper.js'; +import { installFakeRuntimeProxyPeer } from './helpers/fake-session-peer.js'; import { waitFor } from './wait-for.js'; type SandboxControlNamespace = DurableObjectNamespace; @@ -118,6 +119,7 @@ async function injectProvider( KILO_OPENROUTER_BASE: 'https://control.test', KILO_SESSION_INGEST_URL: 'https://control.test', }); + installFakeRuntimeProxyPeer(instance); Object.assign(instance, { createProviderAdapter: () => provider.adapter, provider: provider.adapter, @@ -330,6 +332,7 @@ async function startTerminalSession(): Promise<{ }); await waitFor(() => expect(provider.launchEnvs).toHaveLength(1)); const wrapper = await connectAndHello(provider, sandboxId, WRAPPER_ID); + await awaitFrame(wrapper, 'session.prepare'); await markRouteReady(sessionStub, wrapper, sessionId); return { sessionId, sandboxId, sessionStub, wrapper }; } From 80e59b0ec80a6c1c77ad0497dbcac2cd4c249a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:05:00 +0000 Subject: [PATCH 04/10] test(payments): cover unconfigured signing secret and stale signature Add the 500 response for a route with no signing secret and the SDK verification failure for a timestamp outside the tolerance window. --- .../src/app/api/stripe/webhook/route.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/stripe/webhook/route.test.ts b/apps/web/src/app/api/stripe/webhook/route.test.ts index ef98f56db39..a83f4504fc8 100644 --- a/apps/web/src/app/api/stripe/webhook/route.test.ts +++ b/apps/web/src/app/api/stripe/webhook/route.test.ts @@ -2,6 +2,7 @@ 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'; @@ -9,7 +10,9 @@ 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: () => 'whsec_local_fixture', + getEnvVariable: jest.fn((key: string) => + key === 'STRIPE_WEBHOOK_SECRET' ? 'whsec_local_fixture' : 'local_fixture' + ), })); const secret = 'whsec_local_fixture'; @@ -48,6 +51,11 @@ describe('Stripe webhook responses', () => { beforeEach(() => { jest.clearAllMocks(); jest.mocked(processStripePaymentEventHook).mockResolvedValue(undefined); + jest + .mocked(getEnvVariable) + .mockImplementation((key: string) => + key === 'STRIPE_WEBHOOK_SECRET' ? secret : 'local_fixture' + ); }); afterEach(() => { @@ -58,6 +66,11 @@ describe('Stripe webhook responses', () => { null, 'fixture', client.webhooks.generateTestHeaderString({ payload: body, secret: 'whsec_other_fixture' }), + client.webhooks.generateTestHeaderString({ + payload: body, + secret, + timestamp: Math.floor(Date.now() / 1000) - 3600, + }), ])('applies the request response contract %#', async signature => { const response = await deliver(body, signature); expect(response.status).toBe(400); @@ -66,6 +79,23 @@ describe('Stripe webhook responses', () => { expect(captureException).not.toHaveBeenCalled(); }); + 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).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith('STRIPE_WEBHOOK_SECRET is not set'); + }); + test('acknowledges event delivery', async () => { const response = await deliver(); expect(response.status).toBe(200); From 5a11f24cf8b833cdd47de4fd90d1fe819da85c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:34:18 +0000 Subject: [PATCH 05/10] chore: retrigger review From b889af55f88f0654129501ad57db4c500659be65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:00:33 +0000 Subject: [PATCH 06/10] chore: retrigger review From 6fdcea8f3ba7b30c7db3b18580cb919aae1a3814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:18:04 +0000 Subject: [PATCH 07/10] fix(payments): report webhook configuration errors --- apps/web/src/app/api/stripe/webhook/route.test.ts | 3 ++- apps/web/src/app/api/stripe/webhook/route.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/api/stripe/webhook/route.test.ts b/apps/web/src/app/api/stripe/webhook/route.test.ts index a83f4504fc8..e2eda209258 100644 --- a/apps/web/src/app/api/stripe/webhook/route.test.ts +++ b/apps/web/src/app/api/stripe/webhook/route.test.ts @@ -92,7 +92,8 @@ describe('Stripe webhook responses', () => { expect(response.status).toBe(500); expect(await response.text()).toBe('Missing STRIPE_WEBHOOK_SECRET'); expect(processStripePaymentEventHook).not.toHaveBeenCalled(); - expect(captureException).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'); }); diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts index 2f49c4868d6..dd3932f366c 100644 --- a/apps/web/src/app/api/stripe/webhook/route.ts +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -15,7 +15,9 @@ export async function POST(req: Request): Promise> { } 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 }); } From 2bd74df1d5d5a8320bbe81450f6259703b18d32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:38:39 +0000 Subject: [PATCH 08/10] style(extension): simplify change flag updates --- apps/extension/src/shared/browser-tool-redaction.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/extension/src/shared/browser-tool-redaction.ts b/apps/extension/src/shared/browser-tool-redaction.ts index 69ea59a9774..e45ba61a5e5 100644 --- a/apps/extension/src/shared/browser-tool-redaction.ts +++ b/apps/extension/src/shared/browser-tool-redaction.ts @@ -111,7 +111,7 @@ const redactJsonContainer = (value: Exclude): RedactedJson => { } const nested = redactJsonContainer(element); - changed = changed || nested.changed; + changed ||= nested.changed; return nested.value; }); @@ -130,7 +130,7 @@ const redactJsonContainer = (value: Exclude): RedactedJson => { changed = true; } else if (isJsonContainer(entry)) { const nested = redactJsonContainer(entry); - changed = changed || nested.changed; + changed ||= nested.changed; result[key] = nested.value; } else { result[key] = entry; From 83768e4327d8d9134d2aeb9415bbb25e2d6872e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:30:17 +0000 Subject: [PATCH 09/10] fix(payments): bound webhook failure reporting Report failed signature verification to monitoring at most once per instance per minute while keeping the fixed refusal response, and cover the reporting contract in the route tests. --- DEVELOPMENT.md | 5 -- .../src/app/api/stripe/webhook/route.test.ts | 51 +++++++++++++++---- apps/web/src/app/api/stripe/webhook/route.ts | 14 +++++ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 160c4d1176f..f62309a656e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -282,11 +282,6 @@ To test Stripe integration locally: If the Stripe CLI is not installed, `pnpm dev:start` skips webhook forwarding. To run only the webhook forwarder manually, use `pnpm --filter web stripe`. -The route response contract is covered by -`pnpm --filter web test src/app/api/stripe/webhook/route.test.ts --runInBand`. -The suite uses locally generated SDK signatures and isolated event-processing -and diagnostic dependencies; it does not require Stripe CLI forwarding. - ## Database Schema Changes 1. Edit the schema in `packages/db/src/schema.ts` diff --git a/apps/web/src/app/api/stripe/webhook/route.test.ts b/apps/web/src/app/api/stripe/webhook/route.test.ts index e2eda209258..aac5965c132 100644 --- a/apps/web/src/app/api/stripe/webhook/route.test.ts +++ b/apps/web/src/app/api/stripe/webhook/route.test.ts @@ -48,8 +48,12 @@ async function deliver( } 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) @@ -62,23 +66,48 @@ describe('Stripe webhook responses', () => { jest.restoreAllMocks(); }); - test.each([ - null, - 'fixture', - client.webhooks.generateTestHeaderString({ payload: body, secret: 'whsec_other_fixture' }), - client.webhooks.generateTestHeaderString({ - payload: body, - secret, - timestamp: Math.floor(Date.now() / 1000) - 3600, - }), - ])('applies the request response contract %#', async signature => { - const response = await deliver(body, signature); + 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); + expect(captureException).toHaveBeenCalledWith( + expect.any(Stripe.errors.StripeSignatureVerificationError), + { tags: { source: 'stripe_webhook_signature' } } + ); + }); + + test('limits repeated signature verification reports', async () => { + await deliver(body, 'fixture'); + await deliver(body, 'another fixture'); + + expect(captureException).toHaveBeenCalledTimes(1); + + now += 60_000; + await deliver(body, 'a third fixture'); + + expect(captureException).toHaveBeenCalledTimes(2); + }); + test('refuses delivery when the signing secret is not configured', async () => { jest .mocked(getEnvVariable) diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts index dd3932f366c..e02515048f9 100644 --- a/apps/web/src/app/api/stripe/webhook/route.ts +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -6,6 +6,19 @@ 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'); @@ -31,6 +44,7 @@ export async function POST(req: Request): Promise> { ); } catch (err: unknown) { if (err instanceof Stripe.errors.StripeSignatureVerificationError) { + reportSignatureFailure(err); return new NextResponse('Webhook Error', { status: 400 }); } throw err; From 9859ab86d3324128a0e55d36da5d42492937d66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:51:54 +0000 Subject: [PATCH 10/10] test(payments): pin the webhook reporting cadence Assert the reported failure is the provider SDK verification error, and that the cadence is shared across verification failure causes. --- .../src/app/api/stripe/webhook/route.test.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/api/stripe/webhook/route.test.ts b/apps/web/src/app/api/stripe/webhook/route.test.ts index aac5965c132..b5c7e4bf91d 100644 --- a/apps/web/src/app/api/stripe/webhook/route.test.ts +++ b/apps/web/src/app/api/stripe/webhook/route.test.ts @@ -90,20 +90,30 @@ describe('Stripe webhook responses', () => { expect(await response.text()).toBe('Webhook Error'); expect(processStripePaymentEventHook).not.toHaveBeenCalled(); expect(captureException).toHaveBeenCalledTimes(1); - expect(captureException).toHaveBeenCalledWith( - expect.any(Stripe.errors.StripeSignatureVerificationError), - { tags: { source: 'stripe_webhook_signature' } } - ); + + 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 repeated signature verification reports', async () => { + test('limits verification reports across failure causes', async () => { await deliver(body, 'fixture'); - await deliver(body, 'another fixture'); + await deliver( + body, + client.webhooks.generateTestHeaderString({ payload: body, secret: 'whsec_other_fixture' }) + ); expect(captureException).toHaveBeenCalledTimes(1); now += 60_000; - await deliver(body, 'a third fixture'); + await deliver( + body, + client.webhooks.generateTestHeaderString({ + payload: body, + secret, + timestamp: Math.floor(Date.now() / 1000) - 3600, + }) + ); expect(captureException).toHaveBeenCalledTimes(2); });