Skip to content

Commit d486b41

Browse files
authored
Merge pull request #177 from supabase/chore/add-upsert-timestamp-protection
chore: add upsert timestamp protection
2 parents 96e6b39 + a3ee613 commit d486b41

File tree

6 files changed

+529
-91
lines changed

6 files changed

+529
-91
lines changed

packages/fastify-app/src/test/webhooks.test.ts

Lines changed: 178 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict'
22
import { FastifyInstance } from 'fastify'
33
import { createHmac } from 'node:crypto'
4-
import { runMigrations } from '@supabase/stripe-sync-engine'
4+
import { PostgresClient, runMigrations } from '@supabase/stripe-sync-engine'
55
import { beforeAll, describe, test, expect, afterAll, vitest } from 'vitest'
66
import { getConfig } from '../utils/config'
77
import { createServer } from '../app'
@@ -12,6 +12,11 @@ import { StripeSync } from '@supabase/stripe-sync-engine'
1212
const unixtime = Math.floor(new Date().getTime() / 1000)
1313
const stripeWebhookSecret = getConfig().stripeWebhookSecret
1414

15+
const postgresClient = new PostgresClient({
16+
databaseUrl: getConfig().databaseUrl,
17+
schema: getConfig().schema,
18+
})
19+
1520
describe('POST /webhooks', () => {
1621
let server: FastifyInstance
1722

@@ -35,22 +40,31 @@ describe('POST /webhooks', () => {
3540
await server.close()
3641
})
3742

43+
function getTableName(entityType: string): string {
44+
if (entityType.includes('.')) {
45+
// Handle cases where entityType has a prefix (e.g., "radar.early_fraud_warning")
46+
return entityType.split('.').pop() || entityType
47+
}
48+
return entityType
49+
}
50+
51+
async function deleteTestData(entityType: string, entityId: string) {
52+
const tableName = getTableName(entityType)
53+
await postgresClient.query(`DELETE FROM stripe.${tableName}s WHERE id = $1`, [entityId])
54+
}
55+
3856
test.each([
3957
'customer_updated.json',
4058
'customer_deleted.json',
4159
'customer_tax_id_created.json',
42-
'customer_tax_id_deleted.json',
4360
'customer_tax_id_updated.json',
4461
'product_created.json',
45-
'product_deleted.json',
4662
'product_updated.json',
4763
'price_created.json',
48-
'price_deleted.json',
4964
'price_updated.json',
5065
'subscription_created.json',
5166
'subscription_deleted.json',
5267
'subscription_updated.json',
53-
'invoice_deleted.json',
5468
'invoice_paid.json',
5569
'invoice_updated.json',
5670
'invoice_finalized.json',
@@ -77,32 +91,81 @@ describe('POST /webhooks', () => {
7791
'payment_method_automatically_updated.json',
7892
'payment_method_detached.json',
7993
'payment_method_updated.json',
80-
'charge_dispute_closed',
81-
'charge_dispute_created',
82-
'charge_dispute_funds_reinstated',
83-
'charge_dispute_funds_withdrawn',
84-
'charge_dispute_updated',
85-
'plan_created',
86-
'plan_deleted',
87-
'plan_updated',
88-
'payment_intent_amount_capturable_updated',
89-
'payment_intent_canceled',
90-
'payment_intent_created',
91-
'payment_intent_partially_funded',
92-
'payment_intent_payment_failed',
93-
'payment_intent_processing',
94-
'payment_intent_requires_action',
95-
'payment_intent_succeeded',
96-
'credit_note_created',
97-
'credit_note_updated',
98-
'credit_note_voided',
99-
'early_fraud_warning_created',
100-
'early_fraud_warning_updated',
101-
'review_closed',
102-
'review_opened',
103-
'refund_created',
104-
'refund_failed',
105-
'refund_updated',
94+
'charge_dispute_closed.json',
95+
'charge_dispute_created.json',
96+
'charge_dispute_funds_reinstated.json',
97+
'charge_dispute_funds_withdrawn.json',
98+
'charge_dispute_updated.json',
99+
'plan_created.json',
100+
'plan_updated.json',
101+
'payment_intent_amount_capturable_updated.json',
102+
'payment_intent_canceled.json',
103+
'payment_intent_created.json',
104+
'payment_intent_partially_funded.json',
105+
'payment_intent_payment_failed.json',
106+
'payment_intent_processing.json',
107+
'payment_intent_requires_action.json',
108+
'payment_intent_succeeded.json',
109+
'credit_note_created.json',
110+
'credit_note_updated.json',
111+
'credit_note_voided.json',
112+
'early_fraud_warning_created.json',
113+
'early_fraud_warning_updated.json',
114+
'review_closed.json',
115+
'review_opened.json',
116+
'refund_created.json',
117+
'refund_failed.json',
118+
'refund_updated.json',
119+
])('event %s is upserted', async (jsonFile) => {
120+
const eventBody = await import(`./stripe/${jsonFile}`).then(({ default: myData }) => myData)
121+
// Update the event body created timestamp to be the current time
122+
eventBody.created = unixtime
123+
const signature = createHmac('sha256', stripeWebhookSecret)
124+
.update(`${unixtime}.${JSON.stringify(eventBody)}`, 'utf8')
125+
.digest('hex')
126+
const entity = eventBody.data.object
127+
const entityId = entity.id
128+
const entityType = entity.object
129+
await deleteTestData(entityType, entityId)
130+
131+
const response = await server.inject({
132+
url: `/webhooks`,
133+
method: 'POST',
134+
headers: {
135+
'stripe-signature': `t=${unixtime},v1=${signature},v0=ff`,
136+
},
137+
payload: eventBody,
138+
})
139+
140+
if (response.statusCode != 200) {
141+
logger.error('error: ', response.body)
142+
}
143+
expect(response.statusCode).toBe(200)
144+
145+
const tableName = getTableName(entityType)
146+
const result = await postgresClient.query(`SELECT * FROM stripe.${tableName}s WHERE id = $1`, [
147+
entityId,
148+
])
149+
150+
const rows = result.rows
151+
expect(rows.length).toBe(1)
152+
153+
const dbEntity = rows[0]
154+
expect(dbEntity.id).toBe(entityId)
155+
156+
const syncTimestamp = new Date(eventBody.created * 1000).toISOString()
157+
expect(dbEntity.last_synced_at.toISOString()).toBe(syncTimestamp)
158+
})
159+
160+
test.each([
161+
'customer_tax_id_deleted.json',
162+
'product_deleted.json',
163+
'price_deleted.json',
164+
'invoice_deleted.json',
165+
'plan_deleted.json',
166+
'refund_created.json',
167+
'refund_failed.json',
168+
'refund_updated.json',
106169
])('process event %s', async (jsonFile) => {
107170
const eventBody = await import(`./stripe/${jsonFile}`).then(({ default: myData }) => myData)
108171
const signature = createHmac('sha256', stripeWebhookSecret)
@@ -124,4 +187,88 @@ describe('POST /webhooks', () => {
124187
expect(response.statusCode).toBe(200)
125188
expect(JSON.parse(response.body)).toMatchObject({ received: true })
126189
})
190+
191+
test('webhook with older timestamp does not override newer data', async () => {
192+
const eventBody = await import('./stripe/charge_updated.json').then(
193+
({ default: myData }) => myData
194+
)
195+
const entity = eventBody.data.object
196+
const entityId = entity.id
197+
const entityType = entity.object
198+
const tableName = getTableName(entityType)
199+
200+
// Clean up any existing test data
201+
await deleteTestData(entityType, entityId)
202+
203+
// First, send a webhook with current timestamp (newer data)
204+
const newerTimestamp = unixtime
205+
const newerEventBody = { ...eventBody, created: newerTimestamp }
206+
const newerSignature = createHmac('sha256', stripeWebhookSecret)
207+
.update(`${newerTimestamp}.${JSON.stringify(newerEventBody)}`, 'utf8')
208+
.digest('hex')
209+
210+
const newerResponse = await server.inject({
211+
url: `/webhooks`,
212+
method: 'POST',
213+
headers: {
214+
'stripe-signature': `t=${newerTimestamp},v1=${newerSignature},v0=ff`,
215+
},
216+
payload: newerEventBody,
217+
})
218+
219+
expect(newerResponse.statusCode).toBe(200)
220+
221+
// Verify the newer data was stored
222+
const newerResult = await postgresClient.query(
223+
`SELECT * FROM stripe.${tableName}s WHERE id = $1`,
224+
[entityId]
225+
)
226+
expect(newerResult.rows.length).toBe(1)
227+
const newerDbEntity = newerResult.rows[0]
228+
const newerSyncTimestamp = new Date(newerTimestamp * 1000).toISOString()
229+
expect(newerDbEntity.last_synced_at.toISOString()).toBe(newerSyncTimestamp)
230+
231+
// Now send a webhook with an older timestamp and different paid value (should not override)
232+
const olderTimestamp = newerTimestamp - 60 // 1 minute older
233+
const olderEventBody = {
234+
...eventBody,
235+
created: olderTimestamp,
236+
data: {
237+
...eventBody.data,
238+
object: {
239+
...eventBody.data.object,
240+
paid: !eventBody.data.object.paid, // Flip the paid value
241+
},
242+
},
243+
}
244+
const olderSignature = createHmac('sha256', stripeWebhookSecret)
245+
.update(`${olderTimestamp}.${JSON.stringify(olderEventBody)}`, 'utf8')
246+
.digest('hex')
247+
248+
const olderResponse = await server.inject({
249+
url: `/webhooks`,
250+
method: 'POST',
251+
headers: {
252+
'stripe-signature': `t=${olderTimestamp},v1=${olderSignature},v0=ff`,
253+
},
254+
payload: olderEventBody,
255+
})
256+
257+
expect(olderResponse.statusCode).toBe(200)
258+
259+
// Verify the data still has the newer timestamp and newer paid value (not overridden)
260+
const olderResult = await postgresClient.query(
261+
`SELECT * FROM stripe.${tableName}s WHERE id = $1`,
262+
[entityId]
263+
)
264+
expect(olderResult.rows.length).toBe(1)
265+
const olderDbEntity = olderResult.rows[0]
266+
expect(olderDbEntity.last_synced_at.toISOString()).toBe(newerSyncTimestamp)
267+
expect(olderDbEntity.last_synced_at.toISOString()).not.toBe(
268+
new Date(olderTimestamp * 1000).toISOString()
269+
)
270+
// Verify the paid field still reflects the newer webhook's value
271+
expect(olderDbEntity.paid).toBe(newerEventBody.data.object.paid)
272+
expect(olderDbEntity.paid).not.toBe(olderEventBody.data.object.paid)
273+
})
127274
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
-- Add last_synced_at column to all Stripe tables for tracking sync status
2+
3+
-- Charges
4+
alter table "stripe"."charges"
5+
add column IF NOT EXISTS "last_synced_at" timestamptz;
6+
7+
-- Coupons
8+
alter table "stripe"."coupons"
9+
add column IF NOT EXISTS "last_synced_at" timestamptz;
10+
11+
-- Credit Notes
12+
alter table "stripe"."credit_notes"
13+
add column IF NOT EXISTS "last_synced_at" timestamptz;
14+
15+
-- Customers
16+
alter table "stripe"."customers"
17+
add column IF NOT EXISTS "last_synced_at" timestamptz;
18+
19+
-- Disputes
20+
alter table "stripe"."disputes"
21+
add column IF NOT EXISTS "last_synced_at" timestamptz;
22+
23+
-- Early Fraud Warnings
24+
alter table "stripe"."early_fraud_warnings"
25+
add column IF NOT EXISTS "last_synced_at" timestamptz;
26+
27+
-- Events
28+
alter table "stripe"."events"
29+
add column IF NOT EXISTS "last_synced_at" timestamptz;
30+
31+
-- Invoices
32+
alter table "stripe"."invoices"
33+
add column IF NOT EXISTS "last_synced_at" timestamptz;
34+
35+
-- Payment Intents
36+
alter table "stripe"."payment_intents"
37+
add column IF NOT EXISTS "last_synced_at" timestamptz;
38+
39+
-- Payment Methods
40+
alter table "stripe"."payment_methods"
41+
add column IF NOT EXISTS "last_synced_at" timestamptz;
42+
43+
-- Payouts
44+
alter table "stripe"."payouts"
45+
add column IF NOT EXISTS "last_synced_at" timestamptz;
46+
47+
-- Plans
48+
alter table "stripe"."plans"
49+
add column IF NOT EXISTS "last_synced_at" timestamptz;
50+
51+
-- Prices
52+
alter table "stripe"."prices"
53+
add column IF NOT EXISTS "last_synced_at" timestamptz;
54+
55+
-- Products
56+
alter table "stripe"."products"
57+
add column IF NOT EXISTS "last_synced_at" timestamptz;
58+
59+
-- Refunds
60+
alter table "stripe"."refunds"
61+
add column IF NOT EXISTS "last_synced_at" timestamptz;
62+
63+
-- Reviews
64+
alter table "stripe"."reviews"
65+
add column IF NOT EXISTS "last_synced_at" timestamptz;
66+
67+
-- Setup Intents
68+
alter table "stripe"."setup_intents"
69+
add column IF NOT EXISTS "last_synced_at" timestamptz;
70+
71+
-- Subscription Items
72+
alter table "stripe"."subscription_items"
73+
add column IF NOT EXISTS "last_synced_at" timestamptz;
74+
75+
-- Subscription Schedules
76+
alter table "stripe"."subscription_schedules"
77+
add column IF NOT EXISTS "last_synced_at" timestamptz;
78+
79+
-- Subscriptions
80+
alter table "stripe"."subscriptions"
81+
add column IF NOT EXISTS "last_synced_at" timestamptz;
82+
83+
-- Tax IDs
84+
alter table "stripe"."tax_ids"
85+
add column IF NOT EXISTS "last_synced_at" timestamptz;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- Remove all foreign key constraints
2+
3+
ALTER TABLE "stripe"."subscriptions" DROP CONSTRAINT IF EXISTS "subscriptions_customer_fkey";
4+
5+
ALTER TABLE "stripe"."prices" DROP CONSTRAINT IF EXISTS "prices_product_fkey";
6+
7+
ALTER TABLE "stripe"."invoices" DROP CONSTRAINT IF EXISTS "invoices_customer_fkey";
8+
9+
ALTER TABLE "stripe"."invoices" DROP CONSTRAINT IF EXISTS "invoices_subscription_fkey";
10+
11+
ALTER TABLE "stripe"."subscription_items" DROP CONSTRAINT IF EXISTS "subscription_items_price_fkey";
12+
13+
ALTER TABLE "stripe"."subscription_items" DROP CONSTRAINT IF EXISTS "subscription_items_subscription_fkey";

0 commit comments

Comments
 (0)