workers.test.js
1 /** 2 * Cloudflare Workers End-to-End Tests 3 * 4 * Tests the live deployed workers against the real Cloudflare infrastructure: 5 * 6 * 1. Resend Webhook Worker (via EMAIL_EVENTS_WORKER_URL env var) 7 * - Svix HMAC-SHA256 signature verification (4 cases) 8 * - Full flow: POST event → verify stored in R2 → cleanup 9 * 10 * 2. Unsubscribe Worker (via UNSUBSCRIBE_WORKER_URL env var) 11 * - HMAC token validation (valid, invalid, missing params) 12 * - Full flow: POST unsubscribe → verify stored in R2 → cleanup 13 * - Idempotency: duplicate unsubscribe returns 200 without duplicating record 14 * 15 * Prerequisites (all present in .env): 16 * RESEND_WEBHOOK_SECRET - Svix signing secret (whsec_...) 17 * EMAIL_EVENTS_WORKER_URL - Resend webhook worker base URL 18 * UNSUBSCRIBE_SECRET - HMAC secret (must match CF Worker secret) 19 * UNSUBSCRIBE_WORKER_URL - Unsubscribe worker base URL 20 * 21 * Run: 22 * node --test tests/e2e/workers.test.js 23 */ 24 25 import { test, describe, before, after } from 'node:test'; 26 import assert from 'node:assert/strict'; 27 import { createHmac } from 'crypto'; 28 import dotenv from 'dotenv'; 29 dotenv.config(); 30 31 // ── Config ────────────────────────────────────────────────────────────────── 32 33 const { RESEND_WEBHOOK_SECRET } = process.env; 34 const RESEND_WORKER_URL = 35 process.env.EMAIL_EVENTS_WORKER_URL; 36 37 const { UNSUBSCRIBE_SECRET } = process.env; 38 const UNSUBSCRIBE_WORKER_URL = 39 process.env.UNSUBSCRIBE_WORKER_URL; 40 41 // Fake outreach ID used for E2E unsubscribe tests (unlikely to collide with real data) 42 const E2E_OUTREACH_ID = 999999999; 43 44 // ── Svix signing helpers ───────────────────────────────────────────────────── 45 46 function svixSign(secret, msgId, timestamp, body) { 47 const rawSecret = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); 48 const message = `${msgId}.${timestamp}.${body}`; 49 return `v1,${createHmac('sha256', rawSecret).update(message).digest('base64')}`; 50 } 51 52 function makeSvixHeaders(secret, body, { staleBy = 0 } = {}) { 53 const msgId = `msg_e2e_${Date.now()}`; 54 const timestamp = String(Math.floor(Date.now() / 1000) - staleBy); 55 const sig = svixSign(secret, msgId, timestamp, body); 56 return { 57 'svix-id': msgId, 58 'svix-timestamp': timestamp, 59 'svix-signature': sig, 60 }; 61 } 62 63 // ── HMAC token helper (matches email.js generateUnsubscribeToken) ──────────── 64 65 function generateUnsubscribeToken(outreachId, secret) { 66 const hmac = createHmac('sha256', secret); 67 hmac.update(String(outreachId)); 68 return hmac.digest('hex').substring(0, 16); 69 } 70 71 // ── Fetch helpers ──────────────────────────────────────────────────────────── 72 73 async function postWebhook(url, body, extraHeaders = {}) { 74 const res = await fetch(url, { 75 method: 'POST', 76 headers: { 'Content-Type': 'application/json', ...extraHeaders }, 77 body, 78 }); 79 return { status: res.status, json: await res.json() }; 80 } 81 82 async function getR2(url) { 83 const res = await fetch(url); 84 if (!res.ok) return []; 85 try { 86 return await res.json(); 87 } catch { 88 return []; 89 } 90 } 91 92 // ── Resend Webhook Tests ───────────────────────────────────────────────────── 93 94 describe('Resend Webhook Worker (Svix signature + R2 storage)', { timeout: 30_000 }, () => { 95 let testMsgId; 96 97 before(() => { 98 if (!RESEND_WEBHOOK_SECRET) { 99 throw new Error('RESEND_WEBHOOK_SECRET not set — skipping Resend webhook E2E tests'); 100 } 101 testMsgId = `msg_e2e_${Date.now()}`; 102 }); 103 104 after(async () => { 105 // Clean up: delete the test event from R2 by clearing and re-writing without it. 106 // The worker exposes DELETE /email-events.json to wipe the whole file. 107 // For a real cleanup we'd need a filtered delete endpoint — for now we leave 108 // the test event in place (it's harmless test data) unless the file only 109 // contains test data, in which case we clear it. 110 try { 111 const events = await getR2(`${RESEND_WORKER_URL}/email-events.json`); 112 const onlyTestData = events.every(e => e.data?.email_id?.startsWith('e2e-')); 113 if (onlyTestData && events.length > 0) { 114 await fetch(`${RESEND_WORKER_URL}/email-events.json`, { method: 'DELETE' }); 115 } 116 } catch { 117 // Best-effort cleanup 118 } 119 }); 120 121 test('rejects webhook with no Svix headers → 401', async () => { 122 const body = JSON.stringify({ 123 type: 'email.delivered', 124 created_at: new Date().toISOString(), 125 data: { email_id: 'e2e-no-headers' }, 126 }); 127 const { status } = await postWebhook(`${RESEND_WORKER_URL}/webhook/resend`, body); 128 assert.equal(status, 401, 'Expected 401 for missing Svix headers'); 129 }); 130 131 test('rejects webhook with tampered body → 401', async () => { 132 const originalBody = JSON.stringify({ 133 type: 'email.delivered', 134 created_at: new Date().toISOString(), 135 data: { email_id: 'e2e-original' }, 136 }); 137 const headers = makeSvixHeaders(RESEND_WEBHOOK_SECRET, originalBody); 138 139 // Send different body with the same headers 140 const tamperedBody = JSON.stringify({ 141 type: 'email.delivered', 142 created_at: new Date().toISOString(), 143 data: { email_id: 'e2e-TAMPERED' }, 144 }); 145 const { status } = await postWebhook( 146 `${RESEND_WORKER_URL}/webhook/resend`, 147 tamperedBody, 148 headers 149 ); 150 assert.equal(status, 401, 'Expected 401 for tampered body'); 151 }); 152 153 test('rejects webhook with stale timestamp (>5 min old) → 401', async () => { 154 const body = JSON.stringify({ 155 type: 'email.delivered', 156 created_at: new Date().toISOString(), 157 data: { email_id: 'e2e-stale' }, 158 }); 159 const headers = makeSvixHeaders(RESEND_WEBHOOK_SECRET, body, { staleBy: 400 }); 160 const { status } = await postWebhook(`${RESEND_WORKER_URL}/webhook/resend`, body, headers); 161 assert.equal(status, 401, 'Expected 401 for stale timestamp (replay protection)'); 162 }); 163 164 test('accepts valid signed webhook → 200, event stored in R2', async () => { 165 const emailId = `e2e-sig-test-${Date.now()}`; 166 const body = JSON.stringify({ 167 type: 'email.delivered', 168 created_at: new Date().toISOString(), 169 data: { email_id: emailId }, 170 }); 171 const headers = makeSvixHeaders(RESEND_WEBHOOK_SECRET, body); 172 173 const { status, json } = await postWebhook( 174 `${RESEND_WORKER_URL}/webhook/resend`, 175 body, 176 headers 177 ); 178 assert.equal(status, 200, `Expected 200, got ${status}: ${JSON.stringify(json)}`); 179 assert.equal(json.success, true); 180 181 // Verify the event was stored in R2 182 const events = await getR2(`${RESEND_WORKER_URL}/email-events.json`); 183 const stored = events.find(e => e.data?.email_id === emailId); 184 assert.ok(stored, `Event ${emailId} not found in R2 after accepted webhook`); 185 assert.equal(stored.type, 'email.delivered'); 186 assert.equal(stored.signature_verified, true); 187 assert.ok(stored.worker_created_at, 'worker_created_at timestamp should be set'); 188 }); 189 190 test('accepts email.bounced event → stored with correct type', async () => { 191 const emailId = `e2e-bounce-${Date.now()}`; 192 const body = JSON.stringify({ 193 type: 'email.bounced', 194 created_at: new Date().toISOString(), 195 data: { email_id: emailId, bounce_type: 'hard' }, 196 }); 197 const headers = makeSvixHeaders(RESEND_WEBHOOK_SECRET, body); 198 199 const { status } = await postWebhook(`${RESEND_WORKER_URL}/webhook/resend`, body, headers); 200 assert.equal(status, 200); 201 202 const events = await getR2(`${RESEND_WORKER_URL}/email-events.json`); 203 const stored = events.find(e => e.data?.email_id === emailId); 204 assert.ok(stored, 'Bounce event not found in R2'); 205 assert.equal(stored.type, 'email.bounced'); 206 }); 207 208 test('rejects payload missing created_at → 400', async () => { 209 const body = JSON.stringify({ 210 type: 'email.delivered', 211 data: { email_id: 'e2e-no-created-at' }, 212 }); 213 const headers = makeSvixHeaders(RESEND_WEBHOOK_SECRET, body); 214 const { status } = await postWebhook(`${RESEND_WORKER_URL}/webhook/resend`, body, headers); 215 assert.equal(status, 400, 'Expected 400 for missing created_at'); 216 }); 217 }); 218 219 // ── Unsubscribe Worker Tests ───────────────────────────────────────────────── 220 221 describe('Unsubscribe Worker (HMAC token + R2 storage)', { timeout: 30_000 }, () => { 222 let validToken; 223 224 before(() => { 225 if (!UNSUBSCRIBE_SECRET) { 226 throw new Error('UNSUBSCRIBE_SECRET not set — skipping Unsubscribe E2E tests'); 227 } 228 validToken = generateUnsubscribeToken(E2E_OUTREACH_ID, UNSUBSCRIBE_SECRET); 229 }); 230 231 after(async () => { 232 // Clean up: remove the E2E test entry from unsubscribes.json. 233 // The worker has no filtered-delete endpoint, so we read the file, 234 // remove our entry, and write it back — but only if we can do so safely. 235 // If only our test data is in there, just DELETE the whole file. 236 try { 237 const records = await getR2(`${UNSUBSCRIBE_WORKER_URL}/unsubscribes.json`); 238 const onlyTestData = records.every(r => r.outreachId === E2E_OUTREACH_ID); 239 if (onlyTestData && records.length > 0) { 240 // No filtered-delete endpoint: we can't surgically remove our entry. 241 // Leave it — it's a fake outreach ID that will never match a real record. 242 } 243 } catch { 244 // Best-effort 245 } 246 }); 247 248 test('rejects request with missing id/token → 400', async () => { 249 const res = await fetch(UNSUBSCRIBE_WORKER_URL, { 250 method: 'POST', 251 headers: { 'Content-Type': 'application/json' }, 252 body: JSON.stringify({}), 253 }); 254 assert.equal(res.status, 400); 255 const json = await res.json(); 256 assert.equal(json.success, false); 257 }); 258 259 test('rejects request with non-numeric id → 400', async () => { 260 const res = await fetch(UNSUBSCRIBE_WORKER_URL, { 261 method: 'POST', 262 headers: { 'Content-Type': 'application/json' }, 263 body: JSON.stringify({ id: 'not-a-number', token: 'abc' }), 264 }); 265 assert.equal(res.status, 400); 266 }); 267 268 test('rejects request with invalid HMAC token → 403', async () => { 269 const res = await fetch(UNSUBSCRIBE_WORKER_URL, { 270 method: 'POST', 271 headers: { 'Content-Type': 'application/json' }, 272 body: JSON.stringify({ id: E2E_OUTREACH_ID, token: 'deadbeefdeadbeef' }), 273 }); 274 assert.equal(res.status, 403); 275 const json = await res.json(); 276 assert.equal(json.success, false); 277 }); 278 279 test('accepts valid HMAC token → 200, stored in R2', async () => { 280 const res = await fetch(UNSUBSCRIBE_WORKER_URL, { 281 method: 'POST', 282 headers: { 'Content-Type': 'application/json' }, 283 body: JSON.stringify({ id: E2E_OUTREACH_ID, token: validToken }), 284 }); 285 assert.equal(res.status, 200); 286 const json = await res.json(); 287 assert.equal(json.success, true); 288 289 // Verify stored in R2 290 const records = await getR2(`${UNSUBSCRIBE_WORKER_URL}/unsubscribes.json`); 291 const stored = records.find(r => r.outreachId === E2E_OUTREACH_ID); 292 assert.ok(stored, `Unsubscribe record for outreach ${E2E_OUTREACH_ID} not found in R2`); 293 assert.ok(stored.timestamp, 'timestamp should be set'); 294 }); 295 296 test('duplicate unsubscribe returns 200 without creating duplicate record', async () => { 297 // Send the same unsubscribe again 298 const res = await fetch(UNSUBSCRIBE_WORKER_URL, { 299 method: 'POST', 300 headers: { 'Content-Type': 'application/json' }, 301 body: JSON.stringify({ id: E2E_OUTREACH_ID, token: validToken }), 302 }); 303 assert.equal(res.status, 200); 304 305 // Should still be exactly one record for this outreach ID 306 const records = await getR2(`${UNSUBSCRIBE_WORKER_URL}/unsubscribes.json`); 307 const matches = records.filter(r => r.outreachId === E2E_OUTREACH_ID); 308 assert.equal(matches.length, 1, 'Duplicate unsubscribe should not create a second record'); 309 }); 310 });