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);