import assert from 'node:assert/strict'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { claimWebhookDeliveries, createUser, createWebhook, enqueueWebhookDeliveries, initDatabase, listWebhookDeliveries } from '../src/db.js'; import { assertSafeWebhookUrl, isBlockedIpAddress, processWebhookBatch, startWebhookWorker, stopWebhookWorker } from '../src/webhook-dispatcher.js'; import { MAX_WEBHOOK_ATTEMPTS, signWebhookBody } from '../src/webhook-model.js'; test('blocks private and loopback addresses', () => { assert.equal(isBlockedIpAddress('10.0.0.5'), true); assert.equal(isBlockedIpAddress('192.168.1.1'), true); assert.equal(isBlockedIpAddress('127.0.0.1'), true); assert.equal(isBlockedIpAddress('169.254.169.254'), true); assert.equal(isBlockedIpAddress('172.16.0.1'), true); assert.equal(isBlockedIpAddress('::1'), true); assert.equal(isBlockedIpAddress('fc00::1'), true); assert.equal(isBlockedIpAddress('fe80::1'), true); assert.equal(isBlockedIpAddress('::ffff:10.0.0.1'), true); assert.equal(isBlockedIpAddress('1.1.1.1'), false); assert.equal(isBlockedIpAddress('8.8.8.8'), false); }); test('assertSafeWebhookUrl requires https and blocks private DNS results', async () => { await assert.rejects( () => assertSafeWebhookUrl('http://example.com/hook'), /https/i ); await assert.rejects( () => assertSafeWebhookUrl('https://hooks.example.com/hook', { dnsLookup: async () => [{ address: '10.1.2.3', family: 4 }] }), /blocked/i ); const ok = await assertSafeWebhookUrl('https://hooks.example.com/hook', { dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }] }); assert.equal(ok.hostname, 'hooks.example.com'); await assert.rejects( () => assertSafeWebhookUrl('http://127.0.0.1:9999/hook', { allowHttpLocal: false }), /https/i ); const local = await assertSafeWebhookUrl('http://127.0.0.1:9999/hook', { allowHttpLocal: true }); assert.equal(local.hostname, '127.0.0.1'); }); test('posts signed body and marks success on 2xx', async () => { initDatabase(tempDataDir(), 'test-secret'); const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' }); const webhook = createWebhook(alice.id, { name: 'Primary', url: 'https://hooks.example.com/mail', events: ['sent'] }); enqueueWebhookDeliveries({ id: 11, userId: alice.id, domainId: null, status: 'sent', sender: 'noreply@example.com', recipients: ['user@example.com'], subject: 'Hello', detail: 'ok', queueId: 'Q11', deliveredAt: '2026-07-09T12:00:01.000Z' }); const [before] = listWebhookDeliveries(alice.id); const fetchCalls = []; const fixedSeconds = 1_700_000_000; const result = await processWebhookBatch({ batchSize: 5, nowSeconds: () => fixedSeconds, dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }], fetchImpl: async (url, options) => { fetchCalls.push({ url, options }); return { status: 204, text: async () => '' }; } }); assert.equal(result.claimed, 1); assert.equal(fetchCalls.length, 1); assert.equal(fetchCalls[0].url, 'https://hooks.example.com/mail'); assert.equal(fetchCalls[0].options.method, 'POST'); assert.equal(fetchCalls[0].options.redirect, 'manual'); assert.equal(fetchCalls[0].options.headers['Content-Type'], 'application/json'); assert.equal(fetchCalls[0].options.headers['User-Agent'], 'MailHub-Webhook/1.0'); assert.equal(fetchCalls[0].options.headers['X-MailHub-Event'], 'email.sent'); assert.equal(fetchCalls[0].options.headers['X-MailHub-Delivery'], `whd_${before.id}`); assert.equal( fetchCalls[0].options.headers['X-MailHub-Signature'], signWebhookBody(before.payloadJson, webhook.secret, fixedSeconds) ); assert.equal(fetchCalls[0].options.body, before.payloadJson); const [after] = listWebhookDeliveries(alice.id); assert.equal(after.status, 'success'); assert.equal(after.attemptCount, 1); assert.equal(after.responseStatus, 204); assert.equal(after.error, ''); }); test('schedules retry on 500', async () => { initDatabase(tempDataDir(), 'test-secret'); const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' }); createWebhook(alice.id, { name: 'Retry', url: 'https://hooks.example.com/retry', events: ['failed'] }); enqueueWebhookDeliveries({ id: 12, userId: alice.id, domainId: null, status: 'failed', sender: 'noreply@example.com', recipients: ['user@example.com'], subject: 'Nope', detail: 'bounce', queueId: 'Q12' }); await processWebhookBatch({ dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }], fetchImpl: async () => ({ status: 500, text: async () => 'upstream error body' }) }); const [delivery] = listWebhookDeliveries(alice.id); assert.equal(delivery.status, 'pending'); assert.equal(delivery.attemptCount, 1); assert.equal(delivery.responseStatus, 500); assert.match(delivery.error, /HTTP 500/); assert.match(delivery.responseBodyPreview, /upstream error/); assert.ok(Date.parse(delivery.nextAttemptAt) > Date.now()); }); test('marks dead after max attempts', async () => { initDatabase(tempDataDir(), 'test-secret'); const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' }); const webhook = createWebhook(alice.id, { name: 'Dead', url: 'https://hooks.example.com/dead', events: ['bounced'] }); enqueueWebhookDeliveries({ id: 13, userId: alice.id, domainId: null, status: 'bounced', sender: 'noreply@example.com', recipients: ['user@example.com'], subject: 'Bounced', detail: '', queueId: 'Q13' }); // Inject claim so retries are not blocked by future next_attempt_at backoff. for (let attempt = 0; attempt < MAX_WEBHOOK_ATTEMPTS; attempt += 1) { const delivery = listWebhookDeliveries(alice.id)[0]; await processWebhookBatch({ claim: () => [ { delivery: { ...delivery, status: 'processing' }, webhook: { id: webhook.id, url: webhook.url, secret: webhook.secret } } ], reap: () => 0, dnsLookup: async () => [{ address: '1.1.1.1', family: 4 }], fetchImpl: async () => ({ status: 503, text: async () => 'down' }) }); } const dead = listWebhookDeliveries(alice.id)[0]; assert.equal(dead.status, 'dead'); assert.equal(dead.attemptCount, MAX_WEBHOOK_ATTEMPTS); }); test('rejects private IP targets without calling fetch', async () => { initDatabase(tempDataDir(), 'test-secret'); const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' }); createWebhook(alice.id, { name: 'Internal', url: 'https://metadata.internal/hook', events: ['sent'] }); enqueueWebhookDeliveries({ id: 14, userId: alice.id, domainId: null, status: 'sent', sender: 'noreply@example.com', recipients: ['user@example.com'], subject: 'SSRF', detail: '', queueId: 'Q14' }); let fetchCalled = false; await processWebhookBatch({ dnsLookup: async () => [{ address: '169.254.169.254', family: 4 }], fetchImpl: async () => { fetchCalled = true; return { status: 200, text: async () => 'ok' }; } }); assert.equal(fetchCalled, false); const [delivery] = listWebhookDeliveries(alice.id); assert.equal(delivery.status, 'pending'); assert.equal(delivery.attemptCount, 1); assert.match(delivery.error, /blocked/i); assert.equal(claimWebhookDeliveries(5).length, 0); }); test('startWebhookWorker can be skipped and stopped', async () => { assert.equal(startWebhookWorker({ enabled: false }), null); const handle = startWebhookWorker({ enabled: true, intervalMs: 60_000, fetchImpl: async () => ({ status: 200, text: async () => '' }) }); assert.ok(handle); assert.equal(typeof handle.stop, 'function'); stopWebhookWorker(); stopWebhookWorker(); }); function tempDataDir() { return mkdtempSync(path.join(tmpdir(), 'mailhub-webhook-dispatcher-')); }