Files
wmailhub/tests/webhook.test.ts
T
BigCode d4c1334143 feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)
- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API
  (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin)
- Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出)
- Email Worker:Cloudflare email handler + 本地联调入口
- shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护
- 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天
- Webhook:签名投递、指数退避重试、SSRF 校验、投递日志
- API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离)
- Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令)
- 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟
- LOGO/favicon 由 codex CLI 生成
2026-09-30 03:34:37 +08:00

173 lines
7.3 KiB
TypeScript

/** 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<string, unknown>;
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();
});
});