/ tests / payments / webhook-security.test.js
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  });