/ __quarantined_tests__ / e2e / workers.test.js
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 (resend-webhook-worker.auditandfix.workers.dev)
  7   *      - Svix HMAC-SHA256 signature verification (4 cases)
  8   *      - Full flow: POST event → verify stored in R2 → cleanup
  9   *
 10   *   2. Unsubscribe Worker (unsubscribe-worker.auditandfix.workers.dev)
 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 || 'https://resend-webhook-worker.auditandfix.workers.dev';
 36  
 37  const { UNSUBSCRIBE_SECRET } = process.env;
 38  const UNSUBSCRIBE_WORKER_URL =
 39    process.env.UNSUBSCRIBE_WORKER_URL || 'https://unsubscribe-worker.auditandfix.workers.dev';
 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  });