/** Webhook 测试:SSRF 校验、投递签名、重试与退避、失败状态、手动测试投递 */ import { describe, expect, test } from "bun:test"; import { ingestEmail } from "../console/src/services/email_ingest.ts"; import { attemptDelivery, processDueDeliveries } from "../console/src/services/webhook_delivery.ts"; import { hmacSha256Hex } from "../shared/src/index.ts"; import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts"; /** # 函数 * 起一个本地 HTTP 接收器,记录请求并可控返回状态 */ function startReceiver(mode: "ok" | "fail500"): { url: string; requests: { headers: Headers; body: string }[]; stop: () => void } { const requests: { headers: Headers; body: string }[] = []; const server = Bun.serve({ port: 0, fetch: async (req) => { requests.push({ headers: req.headers, body: await req.text() }); if (mode === "fail500") return new Response("no", { status: 500 }); return Response.json({ ok: true }); }, }); return { url: `http://127.0.0.1:${server.port}/hook`, requests, stop: () => server.stop(true) }; } describe("Webhook", () => { test("创建 webhook 时拒绝私网/localhost endpoint(SSRF)", async () => { const t = await makeTestEnv(); // SSRF 防护在"未开启本地联调开关"时生效(生产默认态) delete (t.env as { WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string }).WMAILHUB_ALLOW_PRIVATE_WEBHOOKS; const cookie = await loginCookie(t); for (const endpoint of [ "http://127.0.0.1:8080/hook", "http://localhost/hook", "http://169.254.169.254/latest/meta-data", "http://10.0.0.5/hook", "http://[::1]/hook", "ftp://example.com/x", ]) { const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "x", endpoint }), }); expect(resp.status).toBe(400); } }); test("允许公网 endpoint 创建成功", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "ok", endpoint: "https://hooks.example.com/wmail" }), }); expect(resp.status).toBe(201); const data = (await resp.json()) as { secret: string }; expect(data.secret.startsWith("whsec_")).toBe(true); }); test("投递成功:签名头正确且 2xx 落 success", async () => { const t = await makeTestEnv(); const recv = startReceiver("ok"); const now = new Date().toISOString(); await t.env.DB.prepare( "INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk1', 'w', ?, 'whsec_test', 1, ?, ?)", ) .bind(recv.url, now, now) .run(); const email = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", nodeId: t.nodeId, }); const dlv = await t.env.DB.prepare("SELECT id FROM webhook_deliveries").first<{ id: string }>(); expect(dlv).not.toBeNull(); const stats = await processDueDeliveries(t.env, undefined, 20, true); expect(stats.success).toBe(1); const row = await t.env.DB.prepare("SELECT status, delivered_at FROM webhook_deliveries").first<{ status: string; delivered_at: string | null; }>(); expect(row?.status).toBe("success"); // 验证签名 const req = recv.requests[0]; const ts = req.headers.get("X-WmailHub-Timestamp") ?? ""; const sig = await hmacSha256Hex("whsec_test", String(ts).concat(".", req.body)); expect(req.headers.get("X-WmailHub-Signature") ?? null).toBe(sig); expect(req.headers.get("X-WmailHub-Event") ?? null).toBe("email.received"); expect(req.headers.get("X-WmailHub-Delivery") ?? null).toBe(dlv?.id ?? null); const payload = JSON.parse(req.body) as Record; expect(payload.event).toBe("email.received"); expect(payload.id).toBe(email.id); expect(payload.mailbox).toBe("*@example.test"); recv.stop(); }); test("5xx 失败进入重试队列,退避时间递增,超过上限 exhausted", async () => { const t = await makeTestEnv(); const now = new Date().toISOString(); await t.env.DB.prepare( "INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d1', 'whk_gone', 'msg_gone', 'pending', 0, ?)", ) .bind(now) .run(); // msg_gone 不存在(触发 email expired 分支前先看 join):join 不到直接跳过 — 改用真实邮件 await t.env.DB.prepare("DELETE FROM webhook_deliveries WHERE id='d1'").run(); // 建真实 webhook 行(endpoint 不可达),投递 join 需要 await t.env.DB.prepare( "INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk_gone', 'gone', 'http://127.0.0.1:1/hook', 's', 1, ?, ?)", ) .bind(now, now) .run(); const email = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", nodeId: t.nodeId, }); await t.env.DB.prepare( "INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d2', 'whk_gone', ?, 'pending', 0, ?)", ) .bind(email.id, now) .run(); // 直连 attemptDelivery 到一个 5xx 地址(fetch 会失败) const result = await attemptDelivery( { id: "whk_gone", endpoint: "http://127.0.0.1:1/hook", secret: "s" }, "d2", { event: "email.received" }, undefined, true, ); expect(result.ok).toBe(false); // 状态机:连续失败到上限 → exhausted(每轮把 next_retry_at 拉回现在模拟时间流逝) const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts"); let attempts = 0; for (let i = 0; i < WEBHOOK_MAX_ATTEMPTS + 1; i++) { const stats = await processDueDeliveries(t.env, undefined, 20, true); if (stats.processed === 0) break; attempts++; await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id='d2'") .bind(new Date().toISOString()) .run(); } const row = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id='d2'").first<{ status: string; attempt_count: number; }>(); // 目标不可达(端口 1),每次都失败,最终 exhausted expect(row?.status).toBe("exhausted"); expect(row?.attempt_count).toBe(attempts); }); test("手动 test 投递走同一条签名链路", async () => { const t = await makeTestEnv(); const recv = startReceiver("ok"); const cookie = await loginCookie(t); const create = await authedFetch(t, cookie, "http://localhost/api/webhooks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "t", endpoint: recv.url }), }); const { id } = (await create.json()) as { id: string }; const testResp = await authedFetch(t, cookie, `http://localhost/api/webhooks/${id}/test`, { method: "POST" }); const data = (await testResp.json()) as { ok: boolean }; expect(data.ok).toBe(true); expect(recv.requests.length).toBeGreaterThanOrEqual(1); recv.stop(); }); });