webhook-security.test.js
1 /** 2 * Webhook Security Tests 3 * 4 * Tests security hardening of the PayPal webhook handler: 5 * - Idempotency gate: replay attack prevention via processed_webhooks table 6 * - Amount verification: rejects payments that don't match expected pricing 7 * - Currency mismatch detection 8 * - Concurrent duplicate webhook handling (race condition prevention) 9 * 10 * Uses mocked db.js with in-memory idempotency state. Real PG atomicity 11 * (INSERT ... ON CONFLICT DO NOTHING) is covered by the schema definition 12 * in db/pg-schema.sql (UNIQUE PRIMARY KEY on order_id). 13 * PayPal API and report generation are mocked. 14 */ 15 16 import { describe, test, mock, beforeEach, afterEach } from 'node:test'; 17 import assert from 'node:assert/strict'; 18 import { randomUUID } from 'node:crypto'; 19 20 // ── Environment ────────────────────────────────────────────────────────────── 21 22 process.env.NODE_ENV = 'test'; 23 process.env.LOGS_DIR = '/tmp/test-logs'; 24 25 // ── In-memory DB state ─────────────────────────────────────────────────────── 26 // 27 // Simulates PG tables for idempotency and payment record verification. 28 // processed_webhooks uses a Set to simulate the UNIQUE PRIMARY KEY constraint. 29 // The atomic INSERT ... ON CONFLICT DO NOTHING behavior is simulated by 30 // checking set membership and returning changes=0 for duplicates. 31 32 let processedWebhooks; // Set<orderId> 33 let purchasesTable; // Map<paypalOrderId, record> 34 let messagesTable; // Map<id, record> 35 let sitesTable; // Map<id, record> 36 let dbRunCalls; 37 38 function resetDbState() { 39 processedWebhooks = new Set(); 40 purchasesTable = new Map(); 41 messagesTable = new Map(); 42 sitesTable = new Map(); 43 dbRunCalls = []; 44 } 45 46 // ── Country pricing mock data ───────────────────────────────────────────────── 47 48 const COUNTRY_PRICING = { 49 US: { countryCode: 'US', currency: 'USD', priceLocal: 297, priceUsd: 297, exchangeRate: 1.0, currencySymbol: '$', formattedPrice: '$297' }, 50 AU: { countryCode: 'AU', currency: 'AUD', priceLocal: 447, priceUsd: 297, exchangeRate: 1.50, currencySymbol: 'A$', formattedPrice: 'A$447' }, 51 GB: { countryCode: 'GB', currency: 'GBP', priceLocal: 159, priceUsd: 199, exchangeRate: 0.79, currencySymbol: '£', formattedPrice: '£159' }, 52 }; 53 54 const getPriceMock = (cc) => COUNTRY_PRICING[cc] || null; 55 56 // ── Mocks ──────────────────────────────────────────────────────────────────── 57 58 const verifyPaymentMock = mock.fn(); 59 60 mock.module('../../src/payment/paypal.js', { 61 namedExports: { 62 verifyPayment: verifyPaymentMock, 63 }, 64 }); 65 66 mock.module('../../src/utils/country-pricing.js', { 67 namedExports: { getPrice: getPriceMock }, 68 defaultExport: { getPrice: getPriceMock }, 69 }); 70 71 mock.module('dotenv', { 72 defaultExport: { config: () => {} }, 73 namedExports: { config: () => {} }, 74 }); 75 76 mock.module('../../src/utils/logger.js', { 77 defaultExport: class MockLogger { 78 info() {} 79 warn() {} 80 error() {} 81 success() {} 82 debug() {} 83 }, 84 }); 85 86 mock.module('../../src/reports/report-orchestrator.js', { 87 namedExports: { 88 generateAuditReportForPurchase: mock.fn(async () => ({ 89 score: 72, 90 grade: 'C', 91 })), 92 }, 93 }); 94 95 mock.module('../../src/reports/report-delivery.js', { 96 namedExports: { 97 deliverReport: mock.fn(async () => ({ success: true })), 98 }, 99 }); 100 101 // express mock — not used directly in these tests but required by the module 102 let registeredRoutes = {}; 103 mock.module('express', { 104 defaultExport: Object.assign( 105 () => ({ 106 use() {}, 107 get(path, handler) { registeredRoutes[`GET ${path}`] = handler; }, 108 post(path, handler) { registeredRoutes[`POST ${path}`] = handler; }, 109 listen(port, cb) { if (cb) cb(); return { close() {} }; }, 110 }), 111 { json: () => 'json-middleware' } 112 ), 113 }); 114 115 // ── db.js mock ──────────────────────────────────────────────────────────────── 116 // 117 // Simulates the full db.js API using in-memory tables. 118 // The idempotency check (INSERT ... ON CONFLICT DO NOTHING) is simulated by 119 // checking processedWebhooks Set membership — returns changes=1 if new, 0 if dup. 120 121 mock.module('../../src/utils/db.js', { 122 namedExports: { 123 getOne: async (sql, params) => { 124 if (sql.includes('FROM sites WHERE id')) { 125 return sitesTable.get(params[0]) ?? null; 126 } 127 if (sql.includes('country_code FROM sites')) { 128 return sitesTable.get(params[0]) ?? null; 129 } 130 if (sql.includes('landing_page_url')) { 131 return sitesTable.get(params[0]) ?? null; 132 } 133 if (sql.includes('site_id FROM messages')) { 134 return messagesTable.get(params[0]) ?? null; 135 } 136 if (sql.includes('SELECT id, payment_id FROM messages')) { 137 const msg = messagesTable.get(params[0]); 138 if (!msg) return null; 139 return { id: msg.id, payment_id: msg.payment_id }; 140 } 141 if (sql.includes('FROM purchases WHERE paypal_order_id')) { 142 return purchasesTable.get(params[0]) ?? null; 143 } 144 return null; 145 }, 146 run: async (sql, params) => { 147 dbRunCalls.push({ sql, params }); 148 149 // Simulate INSERT ... ON CONFLICT DO NOTHING for processed_webhooks 150 if (sql.includes('INSERT INTO processed_webhooks')) { 151 const orderId = params[0]; 152 if (processedWebhooks.has(orderId)) { 153 return { changes: 0, lastInsertRowid: undefined }; 154 } 155 processedWebhooks.add(orderId); 156 return { changes: 1, lastInsertRowid: undefined }; 157 } 158 159 if (sql.includes('DELETE FROM processed_webhooks')) { 160 processedWebhooks.delete(params[0]); 161 return { changes: 1, lastInsertRowid: undefined }; 162 } 163 164 if (sql.includes('UPDATE messages SET payment_id')) { 165 const msg = messagesTable.get(params[2]); 166 if (msg) { 167 msg.payment_id = params[0]; 168 msg.payment_amount = params[1]; 169 } 170 return { changes: 1, lastInsertRowid: undefined }; 171 } 172 173 if (sql.includes('UPDATE sites SET resulted_in_sale')) { 174 const site = sitesTable.get(params[1]); 175 if (site) { 176 site.resulted_in_sale = 1; 177 site.sale_amount = params[0]; 178 } 179 return { changes: 1, lastInsertRowid: undefined }; 180 } 181 182 if (sql.includes('INSERT INTO purchases')) { 183 const id = purchasesTable.size + 1; 184 const record = { id, paypal_order_id: params[2], status: 'paid' }; 185 purchasesTable.set(params[2], record); 186 return { changes: 1, lastInsertRowid: id }; 187 } 188 189 if (sql.includes('UPDATE sites') && sql.includes('conversation_status')) { 190 return { changes: 1, lastInsertRowid: undefined }; 191 } 192 193 if (sql.includes('UPDATE purchases')) { 194 return { changes: 1, lastInsertRowid: undefined }; 195 } 196 197 return { changes: 1, lastInsertRowid: undefined }; 198 }, 199 getAll: async () => [], 200 query: async () => ({ rows: [], rowCount: 0 }), 201 withTransaction: async (fn) => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 202 }, 203 }); 204 205 // ── Import module under test ───────────────────────────────────────────────── 206 207 const { processPaymentComplete } = await import('../../src/payment/webhook-handler.js'); 208 209 // ── Helpers ────────────────────────────────────────────────────────────────── 210 211 let siteCounter = 0; 212 let messageCounter = 0; 213 214 function seedSiteAndMessage(countryCode = 'US') { 215 const siteId = ++siteCounter; 216 const messageId = ++messageCounter; 217 218 sitesTable.set(siteId, { 219 id: siteId, 220 domain: 'example.com', 221 landing_page_url: 'https://example.com', 222 country_code: countryCode, 223 status: 'enriched', 224 resulted_in_sale: 0, 225 sale_amount: null, 226 }); 227 228 messagesTable.set(messageId, { 229 id: messageId, 230 site_id: siteId, 231 payment_id: null, 232 payment_amount: null, 233 }); 234 235 return { siteId, messageId }; 236 } 237 238 function makeVerifiedPayment(siteId, messageId, overrides = {}) { 239 return { 240 isPaid: true, 241 status: 'COMPLETED', 242 orderId: overrides.orderId || `ORDER_${randomUUID().slice(0, 8)}`, 243 payerEmail: 'buyer@example.com', 244 payerName: 'John Doe', 245 amount: 297, 246 currency: 'USD', 247 referenceId: `site_${siteId}_conv_${messageId}`, 248 ...overrides, 249 }; 250 } 251 252 // ── Tests ──────────────────────────────────────────────────────────────────── 253 254 describe('webhook security hardening', () => { 255 beforeEach(() => { 256 registeredRoutes = {}; 257 verifyPaymentMock.mock.resetCalls(); 258 resetDbState(); 259 }); 260 261 // ── 1. Replay attack prevention (idempotency gate) ───────────────────── 262 263 describe('idempotency gate - replay attack prevention', () => { 264 test('first webhook delivery processes successfully', async () => { 265 const { siteId, messageId } = seedSiteAndMessage('US'); 266 const orderId = `ORDER_${randomUUID().slice(0, 8)}`; 267 268 verifyPaymentMock.mock.mockImplementation(async () => 269 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297, currency: 'USD' }) 270 ); 271 272 const result = await processPaymentComplete(orderId); 273 274 assert.equal(result.success, true); 275 assert.equal(result.message, 'Payment processed successfully'); 276 assert.equal(result.orderId, orderId); 277 278 // Verify processed_webhooks row was created 279 assert.ok(processedWebhooks.has(orderId), 'processed_webhooks should contain orderId'); 280 }); 281 282 test('replayed webhook (same order_id) returns already processed, no duplicate DB records', async () => { 283 const { siteId, messageId } = seedSiteAndMessage('US'); 284 const orderId = `ORDER_REPLAY_${randomUUID().slice(0, 8)}`; 285 286 verifyPaymentMock.mock.mockImplementation(async () => 287 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297, currency: 'USD' }) 288 ); 289 290 // First call — should process 291 const first = await processPaymentComplete(orderId); 292 assert.equal(first.success, true); 293 assert.equal(first.message, 'Payment processed successfully'); 294 295 // Second call (replay) — should be blocked by idempotency gate 296 const second = await processPaymentComplete(orderId); 297 assert.equal(second.success, true); 298 assert.equal(second.message, 'Payment already processed'); 299 300 // Verify only one processed_webhooks entry 301 assert.equal(processedWebhooks.size, 1, 'Should have exactly 1 processed_webhooks entry'); 302 303 // Verify only one purchase record was created 304 const purchaseEntries = [...purchasesTable.values()].filter(p => p.paypal_order_id === orderId); 305 assert.equal(purchaseEntries.length, 1, 'Should have exactly 1 purchase row for this order'); 306 }); 307 308 test('third replay also blocked — idempotency is persistent', async () => { 309 const { siteId, messageId } = seedSiteAndMessage('US'); 310 const orderId = `ORDER_TRIPLE_${randomUUID().slice(0, 8)}`; 311 312 verifyPaymentMock.mock.mockImplementation(async () => 313 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297, currency: 'USD' }) 314 ); 315 316 await processPaymentComplete(orderId); 317 await processPaymentComplete(orderId); 318 const third = await processPaymentComplete(orderId); 319 320 assert.equal(third.success, true); 321 assert.equal(third.message, 'Payment already processed'); 322 323 // verifyPayment is called each time (we still verify with PayPal), 324 // but the DB mutation only happens once 325 assert.equal(verifyPaymentMock.mock.calls.length, 3); 326 327 assert.equal(processedWebhooks.size, 1, 'processed_webhooks should have exactly 1 entry'); 328 }); 329 330 test('different order IDs are processed independently', async () => { 331 const { siteId, messageId: messageId1 } = seedSiteAndMessage('US'); 332 const { messageId: messageId2 } = seedSiteAndMessage('US'); 333 messagesTable.get(messageId2).site_id = siteId; 334 335 const orderId1 = `ORDER_A_${randomUUID().slice(0, 8)}`; 336 const orderId2 = `ORDER_B_${randomUUID().slice(0, 8)}`; 337 338 verifyPaymentMock.mock.mockImplementation(async (oid) => { 339 if (oid === orderId1) { 340 return makeVerifiedPayment(siteId, messageId1, { orderId: orderId1, amount: 297, currency: 'USD' }); 341 } 342 return makeVerifiedPayment(siteId, messageId2, { orderId: orderId2, amount: 297, currency: 'USD' }); 343 }); 344 345 const r1 = await processPaymentComplete(orderId1); 346 const r2 = await processPaymentComplete(orderId2); 347 348 assert.equal(r1.success, true); 349 assert.equal(r1.message, 'Payment processed successfully'); 350 assert.equal(r2.success, true); 351 assert.equal(r2.message, 'Payment processed successfully'); 352 353 assert.equal(processedWebhooks.size, 2, 'Should have 2 processed_webhooks entries'); 354 }); 355 }); 356 357 // ── 2. Amount verification ───────────────────────────────────────────── 358 359 describe('amount verification', () => { 360 test('correct amount for US ($297 USD) is accepted', async () => { 361 const { siteId, messageId } = seedSiteAndMessage('US'); 362 const orderId = `ORDER_AMT_OK_${randomUUID().slice(0, 8)}`; 363 364 verifyPaymentMock.mock.mockImplementation(async () => 365 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297, currency: 'USD' }) 366 ); 367 368 const result = await processPaymentComplete(orderId); 369 assert.equal(result.success, true); 370 assert.equal(result.message, 'Payment processed successfully'); 371 }); 372 373 test('correct amount for AU (A$447 AUD) is accepted', async () => { 374 const { siteId, messageId } = seedSiteAndMessage('AU'); 375 const orderId = `ORDER_AMT_AU_${randomUUID().slice(0, 8)}`; 376 377 verifyPaymentMock.mock.mockImplementation(async () => 378 makeVerifiedPayment(siteId, messageId, { orderId, amount: 447, currency: 'AUD' }) 379 ); 380 381 const result = await processPaymentComplete(orderId); 382 assert.equal(result.success, true); 383 assert.equal(result.message, 'Payment processed successfully'); 384 }); 385 386 test('correct amount for GB (GBP 159) is accepted', async () => { 387 const { siteId, messageId } = seedSiteAndMessage('GB'); 388 const orderId = `ORDER_AMT_GB_${randomUUID().slice(0, 8)}`; 389 390 verifyPaymentMock.mock.mockImplementation(async () => 391 makeVerifiedPayment(siteId, messageId, { orderId, amount: 159, currency: 'GBP' }) 392 ); 393 394 const result = await processPaymentComplete(orderId); 395 assert.equal(result.success, true); 396 assert.equal(result.message, 'Payment processed successfully'); 397 }); 398 399 test('underpaid amount ($50 instead of $297) is REJECTED', async () => { 400 const { siteId, messageId } = seedSiteAndMessage('US'); 401 const orderId = `ORDER_UNDERPAY_${randomUUID().slice(0, 8)}`; 402 403 verifyPaymentMock.mock.mockImplementation(async () => 404 makeVerifiedPayment(siteId, messageId, { orderId, amount: 50, currency: 'USD' }) 405 ); 406 407 const result = await processPaymentComplete(orderId); 408 assert.equal(result.success, false); 409 assert.ok( 410 result.message.includes('Amount verification failed'), 411 `Expected rejection message, got: ${result.message}` 412 ); 413 assert.ok(result.message.includes('Amount mismatch')); 414 415 // Verify no purchase was created 416 const purchaseEntries = [...purchasesTable.values()].filter(p => p.paypal_order_id === orderId); 417 assert.equal(purchaseEntries.length, 0, 'No purchase should be created for rejected amount'); 418 419 // Verify processed_webhooks was cleaned up (so it can be re-evaluated) 420 assert.ok(!processedWebhooks.has(orderId), 'processed_webhooks row should be removed on amount rejection'); 421 }); 422 423 test('overpaid amount ($500 instead of $297) is REJECTED', async () => { 424 const { siteId, messageId } = seedSiteAndMessage('US'); 425 const orderId = `ORDER_OVERPAY_${randomUUID().slice(0, 8)}`; 426 427 verifyPaymentMock.mock.mockImplementation(async () => 428 makeVerifiedPayment(siteId, messageId, { orderId, amount: 500, currency: 'USD' }) 429 ); 430 431 const result = await processPaymentComplete(orderId); 432 assert.equal(result.success, false); 433 assert.ok(result.message.includes('Amount mismatch')); 434 }); 435 436 test('amount within tolerance ($297.01) is accepted', async () => { 437 const { siteId, messageId } = seedSiteAndMessage('US'); 438 const orderId = `ORDER_TOLERANCE_${randomUUID().slice(0, 8)}`; 439 440 verifyPaymentMock.mock.mockImplementation(async () => 441 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297.01, currency: 'USD' }) 442 ); 443 444 const result = await processPaymentComplete(orderId); 445 assert.equal(result.success, true); 446 }); 447 448 test('amount outside tolerance ($297.05) is rejected', async () => { 449 const { siteId, messageId } = seedSiteAndMessage('US'); 450 const orderId = `ORDER_OVERTOLERANCE_${randomUUID().slice(0, 8)}`; 451 452 verifyPaymentMock.mock.mockImplementation(async () => 453 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297.05, currency: 'USD' }) 454 ); 455 456 const result = await processPaymentComplete(orderId); 457 assert.equal(result.success, false); 458 assert.ok(result.message.includes('Amount mismatch')); 459 }); 460 461 test('test-price orders (< $5) bypass amount verification', async () => { 462 const { siteId, messageId } = seedSiteAndMessage('US'); 463 const orderId = `ORDER_TESTPRICE_${randomUUID().slice(0, 8)}`; 464 465 verifyPaymentMock.mock.mockImplementation(async () => 466 makeVerifiedPayment(siteId, messageId, { orderId, amount: 1.00, currency: 'USD' }) 467 ); 468 469 const result = await processPaymentComplete(orderId); 470 assert.equal(result.success, true, 'Test-price orders should be allowed through'); 471 }); 472 }); 473 474 // ── 3. Currency mismatch detection ───────────────────────────────────── 475 476 describe('currency mismatch detection', () => { 477 test('paying in USD for an AU site (expected AUD) is REJECTED', async () => { 478 const { siteId, messageId } = seedSiteAndMessage('AU'); 479 const orderId = `ORDER_CURRMIS_${randomUUID().slice(0, 8)}`; 480 481 verifyPaymentMock.mock.mockImplementation(async () => 482 makeVerifiedPayment(siteId, messageId, { orderId, amount: 447, currency: 'USD' }) 483 ); 484 485 const result = await processPaymentComplete(orderId); 486 assert.equal(result.success, false); 487 assert.ok( 488 result.message.includes('Currency mismatch'), 489 `Expected currency mismatch, got: ${result.message}` 490 ); 491 assert.ok(result.message.includes('paid USD')); 492 assert.ok(result.message.includes('expected AUD')); 493 }); 494 495 test('paying in EUR for a US site (expected USD) is REJECTED', async () => { 496 const { siteId, messageId } = seedSiteAndMessage('US'); 497 const orderId = `ORDER_EUR_${randomUUID().slice(0, 8)}`; 498 499 verifyPaymentMock.mock.mockImplementation(async () => 500 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297, currency: 'EUR' }) 501 ); 502 503 const result = await processPaymentComplete(orderId); 504 assert.equal(result.success, false); 505 assert.ok(result.message.includes('Currency mismatch')); 506 }); 507 508 test('paying in GBP for a GB site is accepted', async () => { 509 const { siteId, messageId } = seedSiteAndMessage('GB'); 510 const orderId = `ORDER_GBP_OK_${randomUUID().slice(0, 8)}`; 511 512 verifyPaymentMock.mock.mockImplementation(async () => 513 makeVerifiedPayment(siteId, messageId, { orderId, amount: 159, currency: 'GBP' }) 514 ); 515 516 const result = await processPaymentComplete(orderId); 517 assert.equal(result.success, true); 518 }); 519 520 test('site with no country_code skips verification (graceful degradation)', async () => { 521 // Insert site without country_code 522 const siteId = ++siteCounter; 523 const messageId = ++messageCounter; 524 sitesTable.set(siteId, { 525 id: siteId, 526 domain: 'nocountry.com', 527 landing_page_url: 'https://nocountry.com', 528 country_code: null, 529 status: 'enriched', 530 }); 531 messagesTable.set(messageId, { 532 id: messageId, 533 site_id: siteId, 534 payment_id: null, 535 }); 536 537 const orderId = `ORDER_NOCOUNTRY_${randomUUID().slice(0, 8)}`; 538 539 verifyPaymentMock.mock.mockImplementation(async () => 540 makeVerifiedPayment(siteId, messageId, { orderId, amount: 999, currency: 'XYZ' }) 541 ); 542 543 const result = await processPaymentComplete(orderId); 544 assert.equal(result.success, true, 'Should succeed when country_code is NULL (skip verification)'); 545 }); 546 }); 547 548 // ── 4. Concurrent duplicate webhooks ─────────────────────────────────── 549 550 describe('concurrent duplicate webhooks (race condition)', () => { 551 test('Promise.all with same order_id: only one creates DB records', async () => { 552 const { siteId, messageId } = seedSiteAndMessage('US'); 553 const orderId = `ORDER_RACE_${randomUUID().slice(0, 8)}`; 554 555 verifyPaymentMock.mock.mockImplementation(async () => 556 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297, currency: 'USD' }) 557 ); 558 559 // Fire 5 concurrent webhook deliveries for the same order 560 const results = await Promise.all([ 561 processPaymentComplete(orderId), 562 processPaymentComplete(orderId), 563 processPaymentComplete(orderId), 564 processPaymentComplete(orderId), 565 processPaymentComplete(orderId), 566 ]); 567 568 // All should succeed (either processed or already processed) 569 for (const r of results) { 570 assert.equal(r.success, true); 571 } 572 573 // Exactly one should say "Payment processed successfully" 574 const processed = results.filter(r => r.message === 'Payment processed successfully'); 575 const duplicates = results.filter(r => r.message === 'Payment already processed'); 576 577 assert.equal( 578 processed.length, 579 1, 580 `Exactly 1 should process, got ${processed.length}` 581 ); 582 assert.equal( 583 duplicates.length, 584 4, 585 `Exactly 4 should be deduplicated, got ${duplicates.length}` 586 ); 587 588 // Verify exactly 1 processed_webhooks entry 589 assert.equal(processedWebhooks.size, 1, 'processed_webhooks should have exactly 1 entry'); 590 591 // Verify exactly 1 purchase 592 const purchaseEntries = [...purchasesTable.values()].filter(p => p.paypal_order_id === orderId); 593 assert.equal(purchaseEntries.length, 1, 'purchases should have exactly 1 row'); 594 }); 595 596 test('concurrent webhooks for DIFFERENT orders all succeed independently', async () => { 597 const ids = []; 598 for (let i = 0; i < 3; i++) { 599 const { siteId, messageId } = seedSiteAndMessage('US'); 600 ids.push({ siteId, messageId, orderId: `ORDER_INDEP_${i}_${randomUUID().slice(0, 8)}` }); 601 } 602 603 verifyPaymentMock.mock.mockImplementation(async (oid) => { 604 const match = ids.find(i => i.orderId === oid); 605 return makeVerifiedPayment(match.siteId, match.messageId, { 606 orderId: oid, amount: 297, currency: 'USD', 607 }); 608 }); 609 610 const results = await Promise.all( 611 ids.map(i => processPaymentComplete(i.orderId)) 612 ); 613 614 for (const r of results) { 615 assert.equal(r.success, true); 616 assert.equal(r.message, 'Payment processed successfully'); 617 } 618 619 assert.equal(processedWebhooks.size, 3, 'All 3 independent orders should be recorded'); 620 }); 621 }); 622 623 // ── 5. Edge cases and error handling ─────────────────────────────────── 624 625 describe('edge cases', () => { 626 test('payment not completed (isPaid=false) does not create processed_webhooks row', async () => { 627 const { siteId, messageId } = seedSiteAndMessage('US'); 628 const orderId = `ORDER_NOTPAID_${randomUUID().slice(0, 8)}`; 629 630 verifyPaymentMock.mock.mockImplementation(async () => 631 makeVerifiedPayment(siteId, messageId, { 632 orderId, 633 isPaid: false, 634 status: 'CREATED', 635 }) 636 ); 637 638 const result = await processPaymentComplete(orderId); 639 assert.equal(result.success, false); 640 641 // Should NOT create a processed_webhooks row for unpaid orders 642 assert.ok(!processedWebhooks.has(orderId), 'Unpaid orders should not be recorded in processed_webhooks'); 643 }); 644 645 test('invalid reference ID does not create processed_webhooks row', async () => { 646 const orderId = `ORDER_BADREF_${randomUUID().slice(0, 8)}`; 647 648 verifyPaymentMock.mock.mockImplementation(async () => ({ 649 isPaid: true, 650 status: 'COMPLETED', 651 orderId, 652 payerEmail: 'buyer@example.com', 653 amount: 297, 654 currency: 'USD', 655 referenceId: 'invalid_format', 656 })); 657 658 await assert.rejects( 659 () => processPaymentComplete(orderId), 660 err => { 661 assert.ok(err.message.includes('Invalid reference ID format')); 662 return true; 663 } 664 ); 665 666 // Should not create a processed_webhooks row (error occurs before idempotency gate) 667 assert.ok(!processedWebhooks.has(orderId), 'Invalid reference should not be recorded'); 668 }); 669 670 test('PayPal API error does not create processed_webhooks row', async () => { 671 const orderId = `ORDER_APIERR_${randomUUID().slice(0, 8)}`; 672 673 verifyPaymentMock.mock.mockImplementation(async () => { 674 throw new Error('PayPal API 503 Service Unavailable'); 675 }); 676 677 await assert.rejects( 678 () => processPaymentComplete(orderId), 679 err => { 680 assert.ok(err.message.includes('PayPal API 503')); 681 return true; 682 } 683 ); 684 685 assert.ok(!processedWebhooks.has(orderId), 'API errors should not create processed_webhooks rows'); 686 }); 687 688 test('amount rejection cleans up processed_webhooks so order can be re-evaluated', async () => { 689 const { siteId, messageId } = seedSiteAndMessage('US'); 690 const orderId = `ORDER_CLEANUP_${randomUUID().slice(0, 8)}`; 691 692 // First attempt: wrong amount 693 verifyPaymentMock.mock.mockImplementation(async () => 694 makeVerifiedPayment(siteId, messageId, { orderId, amount: 100, currency: 'USD' }) 695 ); 696 697 const rejected = await processPaymentComplete(orderId); 698 assert.equal(rejected.success, false); 699 700 // Verify the processed_webhooks row was cleaned up 701 assert.ok(!processedWebhooks.has(orderId), 'Row should be removed after amount rejection'); 702 703 // Now the same order comes again with correct amount (e.g., after PayPal retry) 704 verifyPaymentMock.mock.mockImplementation(async () => 705 makeVerifiedPayment(siteId, messageId, { orderId, amount: 297, currency: 'USD' }) 706 ); 707 708 const accepted = await processPaymentComplete(orderId); 709 assert.equal(accepted.success, true, 'Re-evaluation with correct amount should succeed'); 710 }); 711 }); 712 });