feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链

- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建)
- 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选
- Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口
- 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除)
- UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框
- 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react)
- 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录
- 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
This commit is contained in:
bigcode
2026-09-30 11:37:38 +08:00
parent d4c1334143
commit 421cfb2f04
47 changed files with 2316 additions and 1431 deletions
+1 -2
View File
@@ -147,7 +147,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
expect(resp.status).toBe(403);
});
test("initialize + tools/list 返回六个工具与 serverInfo", async () => {
test("initialize + tools/list 返回五个工具与 serverInfo", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp");
@@ -164,7 +164,6 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
"get_email",
"create_mailbox",
"delete_mailbox",
"list_webhooks",
]);
});
+1 -1
View File
@@ -27,7 +27,7 @@ describe("用户认证(默认 admin/admin)", () => {
test("无会话访问受保护接口返回 401", async () => {
const t = await makeTestEnv();
const resp = await t.app.fetch(new Request("http://localhost/api/nodes"), t.env);
const resp = await t.app.fetch(new Request("http://localhost/api/accounts"), t.env);
expect(resp.status).toBe(401);
});
+26
View File
@@ -0,0 +1,26 @@
/** E2E 临时接收器:18790,打印签名/Authorization 校验结果 */
const KEY = "wmh_e2e_key_12345";
Bun.serve({
port: 18790,
async fetch(req) {
const body = await req.text();
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = req.headers.get("X-WmailHub-Signature") ?? "";
const { hmacSha256Hex } = await import("../../shared/src/index.ts");
const expect = await hmacSha256Hex(KEY, `${ts}.${body}`);
const payload = JSON.parse(body) as Record<string, unknown>;
console.log(
JSON.stringify({
got: "webhook",
auth: req.headers.get("Authorization"),
signature_valid: sig === expect,
event: payload.event,
subject: payload.subject,
mailbox: payload.mailbox,
custom_header: req.headers.get("X-E2E"),
}),
);
return Response.json({ ok: true });
},
});
console.log("receiver on :18790");
+8 -2
View File
@@ -15,8 +15,14 @@ import { encryptSecret } from "../console/src/db.ts";
import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts";
import { ids } from "../shared/src/index.ts";
/** # 成员变量: 迁移 SQL(一次读取,多处使用) **/
export const MIGRATION_SQL = await Bun.file(new URL("../migrations/0001_init.sql", import.meta.url)).text();
/** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/
export const MIGRATION_SQL = (
await Promise.all(
[...new Bun.Glob("migrations/*.sql").scanSync({ cwd: new URL("..", import.meta.url).pathname })]
.sort()
.map(async (f) => await Bun.file(new URL(`../${f}`, import.meta.url)).text()),
)
).join("\n");
/** # 成员变量: 测试邮件样例(plain + html + 1 附件) **/
export const TEST_MAIL = `From: Alice <alice@example.org>\r
+54 -43
View File
@@ -1,25 +1,15 @@
/** Cloudflare OAuth(本地 sim 模式)测试:state CSRF、自动安装幂等、连接存储 */
/** Cloudflare OAuth(本地 sim 模式)测试:自动建账号、域名同步、state CSRF、setup Email Workers、凭据存储 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("Cloudflare OAuth(sim 模式)", () => {
test("start 生成 state;sim approve 完成自动安装(OAuth→identity→discover→worker→routing→domains→mailboxes)", async () => {
test("start 免 node_id 自动建账号;approve 同步全部域名;setup 配置 Email Workers", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// Add Node
const created = (await (
await authedFetch(t, cookie, "http://localhost/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Sim Node", ingress_url: "http://127.0.0.1:18788/ingress/email" }),
})
).json()) as { node_id: string; enrollment_token: string };
expect(created.enrollment_token.startsWith("wenr_")).toBe(true);
// OAuth start
// 添加账号:OAuth start 不带 node_id(服务端自动创建账号)
const start = (await (
await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`)
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { url: string; state: string };
expect(start.url).toContain("/oauth/sim/authorize?state=");
@@ -30,31 +20,56 @@ describe("Cloudflare OAuth(sim 模式)", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: start.state }),
})
).json()) as { ok: boolean; error?: string };
).json()) as { ok: boolean; error?: string; node_id: string };
expect(approve.ok).toBe(true);
// 安装结果
// 安装结果:发现 + 域名同步完成
const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/nodes/${created.node_id}`)
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.node_id}`)
).json()) as {
node: { status: string; cloudflare_account_id: string | null };
account: { status: string; cloudflare_account_id: string | null; name: string };
steps: Record<string, { status: string }>;
domains: { name: string }[];
};
expect(detail.node.status).toBe("offline"); // 等待 Node 心跳上线
expect(detail.node.cloudflare_account_id).toBe("sim-account-0001");
for (const key of ["oauth", "identity", "discover", "email_worker", "email_routing", "domains", "mailboxes"]) {
expect(detail.account.status).toBe("offline"); // 等待 Node 心跳上线
expect(detail.account.cloudflare_account_id).toBe("sim-account-0001");
expect(detail.account.name).toBe("WmailHub Sim Account"); // 自动命名替换为 CF 账户名
for (const key of ["oauth", "identity", "discover", "domains"]) {
expect(detail.steps[key].status).toBe("done");
}
expect(detail.domains.map((d) => d.name)).toContain("example.test");
// OAuth 只同步域名,不自动部署 Email Worker(按域名手动触发)
expect(detail.domains.map((d) => d.name)).toEqual(
expect.arrayContaining(["example.test", "demo.test", "mailbox.dev"]),
);
const domList = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string }[] };
// 种子环境自带一个 acct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending
const synced = domList.items.filter((d) => ["demo.test", "mailbox.dev"].includes(d.name));
expect(synced.length).toBe(2);
for (const d of synced) expect(d.status).toBe("pending");
// catch-all mailbox 已创建
// catch-all 邮箱已随同步创建
const mb = await t.env.DB.prepare("SELECT address FROM mailboxes WHERE is_catch_all = 1").all<{ address: string }>();
expect(mb.results.map((r) => r.address)).toContain("*@example.test");
// sim 资源记录(Email Worker / Routing rule)
// setup:为同步出的 example.test 配置 Email Workers(Worker + Routing + catch-all 规则)
const example = domList.items.find((d) => d.name === "example.test" && d.status === "pending")!;
const setup = (await (
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
).json()) as { ok: boolean; error?: string };
expect(setup.ok).toBe(true);
const after = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] };
const exampleAfter = after.items.find((d) => d.id === example.id)!;
expect(exampleAfter.status).toBe("active");
expect(exampleAfter.worker_id).toContain("wmailhub-email-");
// sim 资源记录(Email Worker / Routing rule 只在 setup 后出现)
const sim = await t.env.DB.prepare("SELECT kind FROM cf_sim_resources").all<{ kind: string }>();
const kinds = sim.results.map((r) => r.kind);
expect(kinds).toContain("zone");
expect(kinds).toContain("email_worker");
expect(kinds).toContain("routing_rule");
@@ -66,18 +81,11 @@ describe("Cloudflare OAuth(sim 模式)", () => {
expect(conn!.encrypted_access_token.startsWith("sim_")).toBe(false);
});
test("state 一次性:重放回调 state 被拒(OAuth CSRF 防护)", async () => {
test("state 一次性:重放 approve 被拒(OAuth CSRF 防护)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const created = (await (
await authedFetch(t, cookie, "http://localhost/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "n" }),
})
).json()) as { node_id: string };
const { state } = (await (
await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`)
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { state: string };
const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
@@ -111,32 +119,35 @@ describe("Cloudflare OAuth(sim 模式)", () => {
test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const created = (await (
await authedFetch(t, cookie, "http://localhost/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "n2" }),
})
).json()) as { node_id: string; enrollment_token: string };
// 自动创建账号后,用 rotate 取得 enrollment token
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
const accounts = (await (
await authedFetch(t, cookie, "http://localhost/api/accounts")
).json()) as { items: { id: string }[] };
const accountId = accounts.items[0].id;
const rotated = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" })
).json()) as { enrollment_token: string };
expect(rotated.enrollment_token.startsWith("wenr_")).toBe(true);
const enroll = (await (
await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: created.enrollment_token }),
body: JSON.stringify({ enrollment_token: rotated.enrollment_token }),
}),
t.env,
)
).json()) as { node_id: string; credential_secret: string; ingress_secret: string };
expect(enroll.node_id).toBe(created.node_id);
expect(enroll.node_id).toBe(accountId);
expect(enroll.credential_secret.startsWith("wns_")).toBe(true);
// 库中不存明文 secret
const cred = await t.env.DB.prepare(
"SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'",
)
.bind(created.node_id)
.bind(accountId)
.first<{ secret_hash: string; secret_enc: string }>();
expect(cred?.secret_hash).not.toBe(enroll.credential_secret);
expect(cred?.secret_enc).not.toContain(enroll.credential_secret);
+1 -1
View File
@@ -165,7 +165,7 @@ describe("TTL 清理与 orphan", () => {
)
.run();
await t.env.DB.prepare(
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('dlv_orphan', 'whk_gone', 'msg_gone', 'pending', 0, ?)",
"INSERT INTO webhook_deliveries (id, email_id, target_type, url, status, attempt_count, created_at) VALUES ('dlv_orphan', 'msg_gone', 'global', 'https://hooks.example.com/x', 'pending', 0, ?)",
)
.bind(now)
.run();
+116
View File
@@ -0,0 +1,116 @@
/** 设置测试:全局 Webhook 读写与 key 语义、默认 Ingress、修改密码 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("设置(/api/settings)", () => {
test("全局 Webhook:保存 -> 读取(仅回 last4);key 缺省保留、空格清除", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 保存(带 key)
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "https://hooks.example.com/a", headers: { "X-A": "1" }, key: "wmh_key_abcdefgh" } }),
});
expect(save.status).toBe(200);
let info = (await (
await authedFetch(t, cookie, "http://localhost/api/settings")
).json()) as { webhook: { url: string; has_key: boolean; key_last4: string; headers: Record<string, string> } };
expect(info.webhook.url).toBe("https://hooks.example.com/a");
expect(info.webhook.has_key).toBe(true);
expect(info.webhook.key_last4).toBe("efgh");
expect(info.webhook.headers).toEqual({ "X-A": "1" });
// 再次保存不带 key:key 保留
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "https://hooks.example.com/b" } }),
});
info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as typeof info;
expect(info.webhook.url).toBe("https://hooks.example.com/b");
expect(info.webhook.has_key).toBe(true);
// key 传空格:清除 key
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "https://hooks.example.com/b", key: " " } }),
});
info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as typeof info;
expect(info.webhook.has_key).toBe(false);
// url 置空:停用全局 webhook
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "" } }),
});
info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as typeof info;
expect(info.webhook.url).toBe("");
});
test("默认 Node Ingress 地址保存与读取", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_ingress_url: "https://node.example.com/ingress/email" }),
});
expect(save.status).toBe(200);
const info = (await (
await authedFetch(t, cookie, "http://localhost/api/settings")
).json()) as { default_ingress_url: string };
expect(info.default_ingress_url).toBe("https://node.example.com/ingress/email");
// 非法地址被拒
const bad = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_ingress_url: "not-a-url" }),
});
expect(bad.status).toBe(400);
});
test("修改密码:原密码错误被拒;成功后新密码可登录", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const bad = await authedFetch(t, cookie, "http://localhost/api/settings/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: "wrong", new_password: "newpass123" }),
});
expect(bad.status).toBe(400);
const ok = await authedFetch(t, cookie, "http://localhost/api/settings/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: "admin", new_password: "newpass123" }),
});
expect(ok.status).toBe(200);
// 旧密码登录失败,新密码登录成功
const oldLogin = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
}),
t.env,
);
expect(oldLogin.status).toBe(401);
const newLogin = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "newpass123" }),
}),
t.env,
);
expect(newLogin.status).toBe(200);
});
});
+35 -18
View File
@@ -1,6 +1,6 @@
/**
* UI 冒烟测试(Playwright headless):
* 登录 admin/admin → 概览/节点/域名/邮箱/邮件/Webhooks/Keys 各页渲染 → 截图。
* 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 设置模态窗口 → 生成 Key → 截图。
* bun tests/ui-smoke.ts
*/
@@ -42,45 +42,62 @@ await page.click("button[type=submit]");
await page.waitForURL(`${BASE}/`, { timeout: 8000 });
await page.waitForSelector("text=概览", { timeout: 8000 });
check("登录成功进入概览页", true);
// 品牌区版本号 tag(v1.1.0,来源 package.json)
const versionTag = await page.locator(".brand-version").textContent();
check("LOGO 版本号 tag 显示", Boolean(versionTag && /^v\d+\.\d+\.\d+$/.test(versionTag.trim())), versionTag?.trim());
await page.waitForTimeout(600);
await page.screenshot({ path: `${SHOT_DIR}/02-dashboard.png` });
// 3. 各页面导航渲染
const pages = [
{ path: "/nodes", expect: "节点管理", shot: "03-nodes.png" },
{ path: "/domains", expect: "域名管理", shot: "04-domains.png" },
{ path: "/mailboxes", expect: "邮箱管理", shot: "05-mailboxes.png" },
{ path: "/accounts", expect: "账号", shot: "03-accounts.png" },
{ path: "/domains", expect: "域名", shot: "04-domains.png" },
{ path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" },
{ path: "/emails", expect: "邮件", shot: "06-emails.png" },
{ path: "/webhooks", expect: "Webhooks", shot: "07-webhooks.png" },
{ path: "/keys", expect: "API / MCP Keys", shot: "08-keys.png" },
];
for (const p of pages) {
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(`text=${p.expect}`, { timeout: 8000 });
await page.waitForTimeout(500);
const hasTable = (await page.locator(".ant-table").count()) > 0;
check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}`, true);
const hasSearch = (await page.locator("input[placeholder*='搜索']").count()) > 0;
check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}${hasSearch ? "(含搜索框)" : ""}`, hasTable);
await page.screenshot({ path: `${SHOT_DIR}/${p.shot}` });
}
// 4. 邮件详情(点击第一行)
// 4. 域名详情抽屉(点击第一行:Email Workers + Webhook 配置)
await page.goto(`${BASE}/domains`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
await page.waitForSelector("text=域名级 Webhook", { timeout: 8000 });
check("域名详情抽屉(Email Workers + Webhook 配置)", true);
await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
await page.keyboard.press("Escape");
// 5. 邮件详情(点击第一行)
await page.goto(`${BASE}/emails`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
const drawerText = await page.locator(".ant-drawer-title").textContent();
check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("端到端")));
await page.screenshot({ path: `${SHOT_DIR}/09-email-detail.png` });
check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("WmailHub")));
await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` });
await page.keyboard.press("Escape");
// 5. 生成 API Key 流程
await page.goto(`${BASE}/keys`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("text=生成 Key", { timeout: 8000 });
await page.click("button:has-text('生成 Key')");
await page.click(".ant-dropdown-menu-item:has-text('生成 API Key')");
// 6. 设置模态窗口:基本设置 + 生成 MCP Key
await page.locator("button[aria-label='设置']").click();
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
check("设置模态窗口打开(全局 Webhook / Ingress / 密码)", true);
await page.screenshot({ path: `${SHOT_DIR}/09-settings-general.png` });
await page.click(".ant-tabs-tab:has-text('MCP Keys')");
await page.waitForSelector("button:has-text('生成 MCP Key')", { timeout: 8000 });
await page.click("button:has-text('生成 MCP Key')");
await page.waitForSelector(".ant-modal:has-text('仅显示一次')", { timeout: 8000 });
const keyText = await page.locator(".ant-modal .mono").first().textContent();
check("生成 API Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
await page.screenshot({ path: `${SHOT_DIR}/10-key-created.png` });
const keyText = await page.locator(".ant-modal:has-text('仅显示一次') .mono").first().textContent();
check("生成 MCP Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
await page.screenshot({ path: `${SHOT_DIR}/10-mcp-key-created.png` });
await browser.close();
+137 -86
View File
@@ -1,7 +1,7 @@
/** Webhook 测试:SSRF 校验、投递签名、重试与退避、失败状态、手动测试投递 */
/** Webhook 测试(v1.1.0 三级模型):SSRF 校验、三级优先级、签名/key/自定义头、重试与退避 */
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 { attemptDelivery, processDueDeliveries, resolveWebhookConfig } from "../console/src/services/webhook_delivery.ts";
import { hmacSha256Hex } from "../shared/src/index.ts";
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
@@ -21,8 +21,25 @@ function startReceiver(mode: "ok" | "fail500"): { url: string; requests: { heade
return { url: `http://127.0.0.1:${server.port}/hook`, requests, stop: () => server.stop(true) };
}
describe("Webhook", () => {
test("创建 webhook 时拒绝私网/localhost endpoint(SSRF)", async () => {
/** # 函数
* 直接给域名/邮箱行写 webhook 配置(key 以空占位,投递 key 传 "" 即可)
*/
async function setScopedWebhook(
t: Awaited<ReturnType<typeof makeTestEnv>>,
table: "mailboxes" | "domains",
id: string,
url: string | null,
headers: Record<string, string> = {},
) {
await t.env.DB.prepare(
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, webhook_key_enc = NULL WHERE id = ?`,
)
.bind(url, Object.keys(headers).length ? JSON.stringify(headers) : null, id)
.run();
}
describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
test("保存配置时拒绝私网/localhost endpoint(SSRF)", async () => {
const t = await makeTestEnv();
// SSRF 防护在"未开启本地联调开关"时生效(生产默认态)
delete (t.env as { WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string }).WMAILHUB_ALLOW_PRIVATE_WEBHOOKS;
@@ -35,57 +52,54 @@ describe("Webhook", () => {
"http://[::1]/hook",
"ftp://example.com/x",
]) {
const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", {
method: "POST",
const resp = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "x", endpoint }),
body: JSON.stringify({ webhook: { url: endpoint } }),
});
expect(resp.status).toBe(400);
const resp2 = await authedFetch(t, cookie, `http://localhost/api/mailboxes/${t.mailboxId}/webhook`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: endpoint }),
});
expect(resp2.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 () => {
test("全局 Webhook 投递:key 走 Authorization,签名与自定义头正确", 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 cookie = await loginCookie(t);
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
webhook: { url: recv.url, headers: { "X-Custom": "wmailhub" }, key: "wmh_global_key_1234567890" },
}),
});
expect(save.status).toBe(200);
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 dlv = await t.env.DB.prepare("SELECT id, target_type FROM webhook_deliveries").first<{
id: string;
target_type: string;
}>();
expect(dlv?.target_type).toBe("global");
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];
expect(req.headers.get("Authorization") ?? null).toBe("Bearer wmh_global_key_1234567890");
expect(req.headers.get("X-Custom") ?? null).toBe("wmailhub");
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = await hmacSha256Hex("whsec_test", String(ts).concat(".", req.body));
const sig = await hmacSha256Hex("wmh_global_key_1234567890", 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);
@@ -96,77 +110,114 @@ describe("Webhook", () => {
recv.stop();
});
test("5xx 失败进入重试队列,退避时间递增,超过上限 exhausted", async () => {
test("三级优先级:邮箱 > 域名 > 全局,命中即止;逐级回退", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
const globalRecv = startReceiver("ok");
const domainRecv = startReceiver("ok");
const mailboxRecv = startReceiver("ok");
// 三级全配
await setScopedWebhook(t, "domains", t.domainId, domainRecv.url);
await setScopedWebhook(t, "mailboxes", t.mailboxId, mailboxRecv.url);
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, ?)",
"INSERT INTO settings (key, value) VALUES ('global_webhook', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(now)
.bind(JSON.stringify({ url: globalRecv.url, headers: {} }))
.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, {
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 会失败)
await processDueDeliveries(t.env, undefined, 20, true);
expect(mailboxRecv.requests.length).toBe(1);
expect(domainRecv.requests.length).toBe(0);
expect(globalRecv.requests.length).toBe(0);
const level1 = await t.env.DB.prepare("SELECT target_type FROM webhook_deliveries ORDER BY created_at DESC LIMIT 1").first<{ target_type: string }>();
expect(level1?.target_type).toBe("mailbox");
// 清除邮箱级 -> 回退域名级
await setScopedWebhook(t, "mailboxes", t.mailboxId, null);
expect((await resolveWebhookConfig(t.env, t.mailboxId))?.targetType).toBe("domain");
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
await processDueDeliveries(t.env, undefined, 20, true);
expect(domainRecv.requests.length).toBe(1);
// 再清除域名级 -> 回退全局
await setScopedWebhook(t, "domains", t.domainId, null);
expect((await resolveWebhookConfig(t.env, t.mailboxId))?.targetType).toBe("global");
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
await processDueDeliveries(t.env, undefined, 20, true);
expect(globalRecv.requests.length).toBe(1);
for (const r of [globalRecv, domainRecv, mailboxRecv]) r.stop();
});
test("三级都未配置:不入队投递", async () => {
const t = await makeTestEnv();
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM webhook_deliveries").first<{ n: number }>();
expect(count?.n).toBe(0);
});
test("连续失败进入重试并最终 exhausted(退避递增)", async () => {
const t = await makeTestEnv();
// 直接连一个不可达地址,验证 attemptDelivery 失败路径
const result = await attemptDelivery(
{ id: "whk_gone", endpoint: "http://127.0.0.1:1/hook", secret: "s" },
"d2",
{ url: "http://127.0.0.1:1/hook", headers: {}, key: "" },
"d_test",
{ event: "email.received" },
undefined,
true,
);
expect(result.ok).toBe(false);
// 状态机:连续失败到上限 → exhausted(每轮把 next_retry_at 拉回现在模拟时间流逝)
// 状态机:把全局 webhook 指向不可达地址,反复处理直到 exhausted
const cookie = await loginCookie(t);
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "http://127.0.0.1:1/hook" } }),
});
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts");
let attempts = 0;
const row0 = await t.env.DB.prepare(
"SELECT id FROM webhook_deliveries WHERE status = 'pending' ORDER BY created_at DESC LIMIT 1",
).first<{ id: string }>();
expect(row0).not.toBeNull();
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())
await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id = ?")
.bind(new Date().toISOString(), row0!.id)
.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();
const final = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id = ?")
.bind(row0!.id)
.first<{ status: string; attempt_count: number }>();
expect(final?.status).toBe("exhausted");
expect(final?.attempt_count).toBe(WEBHOOK_MAX_ATTEMPTS);
});
});