1
1
'use strict'
2
2
import { FastifyInstance } from 'fastify'
3
3
import { createHmac } from 'node:crypto'
4
- import { runMigrations } from '@supabase/stripe-sync-engine'
4
+ import { PostgresClient , runMigrations } from '@supabase/stripe-sync-engine'
5
5
import { beforeAll , describe , test , expect , afterAll , vitest } from 'vitest'
6
6
import { getConfig } from '../utils/config'
7
7
import { createServer } from '../app'
@@ -12,6 +12,11 @@ import { StripeSync } from '@supabase/stripe-sync-engine'
12
12
const unixtime = Math . floor ( new Date ( ) . getTime ( ) / 1000 )
13
13
const stripeWebhookSecret = getConfig ( ) . stripeWebhookSecret
14
14
15
+ const postgresClient = new PostgresClient ( {
16
+ databaseUrl : getConfig ( ) . databaseUrl ,
17
+ schema : getConfig ( ) . schema ,
18
+ } )
19
+
15
20
describe ( 'POST /webhooks' , ( ) => {
16
21
let server : FastifyInstance
17
22
@@ -35,22 +40,31 @@ describe('POST /webhooks', () => {
35
40
await server . close ( )
36
41
} )
37
42
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
+
38
56
test . each ( [
39
57
'customer_updated.json' ,
40
58
'customer_deleted.json' ,
41
59
'customer_tax_id_created.json' ,
42
- 'customer_tax_id_deleted.json' ,
43
60
'customer_tax_id_updated.json' ,
44
61
'product_created.json' ,
45
- 'product_deleted.json' ,
46
62
'product_updated.json' ,
47
63
'price_created.json' ,
48
- 'price_deleted.json' ,
49
64
'price_updated.json' ,
50
65
'subscription_created.json' ,
51
66
'subscription_deleted.json' ,
52
67
'subscription_updated.json' ,
53
- 'invoice_deleted.json' ,
54
68
'invoice_paid.json' ,
55
69
'invoice_updated.json' ,
56
70
'invoice_finalized.json' ,
@@ -77,32 +91,81 @@ describe('POST /webhooks', () => {
77
91
'payment_method_automatically_updated.json' ,
78
92
'payment_method_detached.json' ,
79
93
'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' ,
106
169
] ) ( 'process event %s' , async ( jsonFile ) => {
107
170
const eventBody = await import ( `./stripe/${ jsonFile } ` ) . then ( ( { default : myData } ) => myData )
108
171
const signature = createHmac ( 'sha256' , stripeWebhookSecret )
@@ -124,4 +187,88 @@ describe('POST /webhooks', () => {
124
187
expect ( response . statusCode ) . toBe ( 200 )
125
188
expect ( JSON . parse ( response . body ) ) . toMatchObject ( { received : true } )
126
189
} )
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
+ } )
127
274
} )
0 commit comments