/ scripts / test-resend-webhook-sig.js
test-resend-webhook-sig.js
 1  #!/usr/bin/env node
 2  /**
 3   * Test Resend webhook signature verification.
 4   * Usage: RESEND_WEBHOOK_SECRET=whsec_... node scripts/test-resend-webhook-sig.js [worker-url]
 5   *
 6   * Sends three requests:
 7   *   1. Valid signed payload   → expect 200
 8   *   2. No signature headers   → expect 401
 9   *   3. Tampered body          → expect 401
10   */
11  
12  import { createHmac } from 'crypto';
13  
14  const secret = process.env.RESEND_WEBHOOK_SECRET;
15  if (!secret) {
16    // Skip gracefully when run by test runner without credentials — this is a manual integration test.
17    console.log('SKIP: Set RESEND_WEBHOOK_SECRET=whsec_... before running');
18    process.exit(0);
19  }
20  
21  const workerUrl = process.argv[2] || 'https://resend-webhook-worker.auditandfix.workers.dev';
22  const endpoint = `${workerUrl}/webhook/resend`;
23  
24  // Decode the whsec_ secret (strip prefix, base64-decode)
25  const rawSecret = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
26  
27  function sign(msgId, timestamp, body) {
28    const message = `${msgId}.${timestamp}.${body}`;
29    return `v1,${createHmac('sha256', rawSecret).update(message).digest('base64')}`;
30  }
31  
32  async function post(url, body, headers) {
33    const res = await fetch(url, {
34      method: 'POST',
35      headers: { 'Content-Type': 'application/json', ...headers },
36      body,
37    });
38    return { status: res.status, text: await res.text() };
39  }
40  
41  const body = JSON.stringify({
42    type: 'email.delivered',
43    created_at: new Date().toISOString(),
44    data: { email_id: 'sig-test-001' },
45  });
46  const msgId = `msg_test_${Date.now()}`;
47  const timestamp = String(Math.floor(Date.now() / 1000));
48  const sig = sign(msgId, timestamp, body);
49  
50  console.log(`\nTesting: ${endpoint}\n`);
51  
52  // Test 1: valid signature
53  const r1 = await post(endpoint, body, {
54    'svix-id': msgId,
55    'svix-timestamp': timestamp,
56    'svix-signature': sig,
57  });
58  const ok1 = r1.status === 200;
59  console.log(`[${ok1 ? 'PASS' : 'FAIL'}] Valid signature   → HTTP ${r1.status} (expected 200)`);
60  
61  // Test 2: no signature headers
62  const r2 = await post(endpoint, body, {});
63  const ok2 = r2.status === 401;
64  console.log(`[${ok2 ? 'PASS' : 'FAIL'}] No signature      → HTTP ${r2.status} (expected 401)`);
65  
66  // Test 3: tampered body (different content, same headers)
67  const tamperedBody = JSON.stringify({ type: 'email.delivered', data: { email_id: 'TAMPERED' } });
68  const r3 = await post(endpoint, tamperedBody, {
69    'svix-id': msgId,
70    'svix-timestamp': timestamp,
71    'svix-signature': sig,
72  });
73  const ok3 = r3.status === 401;
74  console.log(`[${ok3 ? 'PASS' : 'FAIL'}] Tampered body     → HTTP ${r3.status} (expected 401)`);
75  
76  // Test 4: stale timestamp (>5 min old)
77  const staleTs = String(Math.floor(Date.now() / 1000) - 400);
78  const staleSig = sign(`${msgId}_stale`, staleTs, body);
79  const r4 = await post(endpoint, body, {
80    'svix-id': `${msgId}_stale`,
81    'svix-timestamp': staleTs,
82    'svix-signature': staleSig,
83  });
84  const ok4 = r4.status === 401;
85  console.log(`[${ok4 ? 'PASS' : 'FAIL'}] Stale timestamp   → HTTP ${r4.status} (expected 401)`);
86  
87  const allPass = ok1 && ok2 && ok3 && ok4;
88  console.log(`\n${allPass ? '✅ All tests passed' : '❌ Some tests failed'}\n`);
89  process.exit(allPass ? 0 : 1);