Commit a520e6d
test(ci): guard the booking payment intent uniqueness (#2529)
* test(ci): guard the booking payment intent uniqueness
Invoice.providerPaymentIntentId is the entire idempotency guarantee of the
Stripe appointment-booking webhook. handleWebhookEvent never reads event.id,
so a redelivered event re-runs the whole dispatch, and this unique index is
the only thing stopping two concurrent deliveries minting two invoices for
one payment.
The jest suite cannot assert that, and it is worth being precise about why:
it mocks Prisma, so it exercises the handler's decision logic and never the
constraint those decisions rest on. Dropping the index leaves every one of
those tests green.
Asserted here instead, against the real Postgres this gate already runs. Two
rows with one intent must be refused; two rows with a NULL intent must still
coexist, because every invoice not raised by that webhook carries NULL and
Postgres keeps NULLs distinct.
Verified by extracting this step body and running it against postgres:16
-alpine with the full migration history: it passes with the index, fails with
'Two invoices were accepted for the same Stripe payment intent' once the index
is dropped, and passes again on restore. The trap clears its rows either way.
* test(ci): assert the intent collision is the unique index, not any error
Review caught that the first version of this guard proved less than it
claimed. It inserted two rows carrying one intent in a single statement and
treated any failure as success, so a later migration that dropped the index
while adding a check constraint, foreign key or trigger rejecting the test
value would still have reported uniqueness intact. A typo in the guard's own
SQL would have done the same. That is the exact failure shape the guard exists
to prevent, in the guard.
Now asserted three ways. The catalog must show the index present and
indisunique, which catches a drop directly and independently of any error
text. The rows go in as two statements, so the first must succeed before the
second is required to fail. And the failure must name both 'duplicate key
value violates unique constraint' and Invoice_providerPaymentIntentId_key, so
an unrelated rejection cannot pass for this one.
Verified against postgres:16-alpine with the full migration history, including
the reviewer's scenario: index dropped, a CHECK constraint rejecting the test
value. The previous guard printed 'The booking payment intent is unique' and
exited 0 on that state. This one exits 1. Baseline still passes and the trap
clears its rows either way.
* test(ci): bind the uniqueness check to the table it protects
Ran an adversarial pass over this step rather than waiting to be told a third
time that it proves less than it claims. Six angles, 23 candidate attacks,
seven confirmed after a judge threw out the ones that were wrong about
Postgres 16.
The catalog check now returns four facts and expects 1|0|1|1, because they
fail in four different ways and one count would hide which:
a. "Invoice" is still a plain table in public, not a view or a partitioned
parent.
b. It has no children. A partition or an INHERITS child holds invoices the
parent's index does not cover, and the old check could not see one.
c. The index is on THAT table (indrelid), live rather than a half-built
CONCURRENTLY corpse (indisvalid/indisready), unconditional, and keyed on
that column alone.
d. It is the ONLY unique index over the column. A second one, NULLS NOT
DISTINCT, ends the NULL coexistence every other invoice path depends on
without touching the first.
Two smaller ones. The index name is matched with its closing quote, so a
suffixed sibling cannot pass for it. And the NULL rows are inserted without
naming the column, the way every non-webhook path writes, so a DEFAULT on it
fails here instead of in production.
Measured on postgres:16-alpine with the full history. Previous step vs this
one: INHERITS child 0 -> 1, second unique index 0 -> 1, DEFAULT '' 0 -> 1.
Baseline stays 0 for both. The relocated-index case was already failing, but
only because this table's dropped-column slots put the attnum at 39 while a
LIKE-rebuilt copy puts it at 31; pinning indrelid makes that deliberate rather
than an accident of history.
What this still cannot see, and the comment says so: the column could stop
being written entirely and the index would stay unique and unused. That needs
a backend test that mints an invoice through StripeService, not a catalog
query.
---------
Co-authored-by: Ankit Upadhyay <ankit@dunexploration.com>1 parent dbbdee3 commit a520e6d
1 file changed
Lines changed: 147 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
226 | 373 | | |
227 | 374 | | |
228 | 375 | | |
| |||
0 commit comments