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 生成
This commit is contained in:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
/** API v1 / MCP 测试:Key 隔离、过期邮件 410、MCP 协议与工具 */
import { describe, expect, test } from "bun:test";
import { ingestEmail } from "../console/src/services/email_ingest.ts";
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
/** # 函数
* 创建 Key 并返回明文
*/
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string, kind: "api" | "mcp") {
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind }),
});
return ((await resp.json()) as { key: string }).key;
}
describe("Console API v1(Bearer wmh_ Key)", () => {
test("api key 可读资源;mcp key 访问 v1 被 403(kind 隔离)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
const mcpKey = await createKey(t, cookie, "mcp");
const ok = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(ok.status).toBe(200);
const data = (await ok.json()) as { items: { address: string }[] };
expect(data.items.map((m) => m.address)).toContain("*@example.test");
const bad = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer ${mcpKey}` },
}),
t.env,
);
expect(bad.status).toBe(403);
});
test("无 key / 伪造 key / 已撤销 key 均被拒", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const noKey = await t.app.fetch(new Request("http://localhost/api/v1/mailboxes"), t.env);
expect(noKey.status).toBe(401);
const fake = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer wmh_${"A".repeat(28)}` },
}),
t.env,
);
expect(fake.status).toBe(401);
const apiKey = await createKey(t, cookie, "api");
// 找到 key id 并撤销
const list = (await (
await authedFetch(t, cookie, "http://localhost/api/keys")
).json()) as { items: { id: string; revoked_at: string | null }[] };
const keyId = list.items.find((k) => !k.revoked_at)!.id;
await authedFetch(t, cookie, `http://localhost/api/keys/${keyId}/revoke`, { method: "POST" });
const revoked = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(revoked.status).toBe(401);
});
test("v1 可创建与删除 mailbox", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
const create = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain_id: t.domainId, address: "newbox" }),
}),
t.env,
);
expect(create.status).toBe(201);
const { id, address } = (await create.json()) as { id: string; address: string };
expect(address).toBe("newbox@example.test");
const del = await t.app.fetch(
new Request(`http://localhost/api/v1/mailboxes/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(del.status).toBe(200);
});
test("过期邮件:v1 详情返回 410,列表不出现", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
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("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
.bind(email.id)
.run();
const detail = await t.app.fetch(
new Request(`http://localhost/api/v1/emails/${email.id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(detail.status).toBe(410);
const list = await t.app.fetch(
new Request("http://localhost/api/v1/emails", {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
const data = (await list.json()) as { items: { id: string }[] };
expect(data.items.find((e) => e.id === email.id)).toBeUndefined();
});
});
describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, mcpKey: string, body: unknown) {
return t.app.fetch(
new Request("http://localhost/api/v1/mcp", {
method: "POST",
headers: { Authorization: `Bearer ${mcpKey}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
t.env,
);
}
test("api key 不能访问 MCP(权限基于 credential kind)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
const resp = await mcpCall(t, apiKey, { jsonrpc: "2.0", id: 1, method: "tools/list" });
expect(resp.status).toBe(403);
});
test("initialize + tools/list 返回六个工具与 serverInfo", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp");
const init = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
const initData = (await init.json()) as { result: { serverInfo: { name: string }; protocolVersion: string } };
expect(initData.result.serverInfo.name).toBe("wmailhub");
expect(initData.result.protocolVersion).toBe("2025-06-18");
const tools = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 2, method: "tools/list" });
const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } };
expect(toolsData.result.tools.map((x) => x.name)).toEqual([
"list_mailboxes",
"list_emails",
"get_email",
"create_mailbox",
"delete_mailbox",
"list_webhooks",
]);
});
test("tools/call:create_mailbox -> list_mailboxes -> get_email -> delete_mailbox", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp");
const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const created = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: { name: "create_mailbox", arguments: { domain_id: t.domainId, address: "mcptest" } },
});
const createdData = (await created.json()) as { result: { content: { text: string }[] } };
expect(JSON.parse(createdData.result.content[0].text).address).toBe("mcptest@example.test");
const list = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "list_mailboxes", arguments: {} },
});
const listData = (await list.json()) as { result: { content: { text: string }[] } };
const mailboxes = JSON.parse(listData.result.content[0].text) as {
mailboxes: { id: string; address: string }[];
};
expect(mailboxes.mailboxes.map((m) => m.address)).toContain("mcptest@example.test");
const got = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 5,
method: "tools/call",
params: { name: "get_email", arguments: { id: email.id } },
});
const gotData = (await got.json()) as { result: { content: { text: string }[] } };
const gotEmail = JSON.parse(gotData.result.content[0].text) as { email: { subject: string } };
expect(gotEmail.email.subject).toBe("Test Mail");
const target = mailboxes.mailboxes.find((m) => m.address === "mcptest@example.test")!.id;
const del = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 6,
method: "tools/call",
params: { name: "delete_mailbox", arguments: { id: target } },
});
const delData = (await del.json()) as { result: { content: { text: string }[] } };
expect(JSON.parse(delData.result.content[0].text).ok).toBe(true);
});
test("未知方法返回 -32601;GET 返回 405", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp");
const bad = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 9, method: "no/such/method" });
const badData = (await bad.json()) as { error: { code: number } };
expect(badData.error.code).toBe(-32601);
const get = await t.app.fetch(
new Request("http://localhost/api/v1/mcp", {
headers: { Authorization: `Bearer ${mcpKey}` },
}),
t.env,
);
expect(get.status).toBe(405);
});
});
+58
View File
@@ -0,0 +1,58 @@
/** 认证与会话测试:登录、错误密码、会话校验、登出、CSRF Origin */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("用户认证(默认 admin/admin)", () => {
test("正确账密登录并拿到会话", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const me = await authedFetch(t, cookie, "http://localhost/api/auth/me");
expect(me.status).toBe(200);
const data = (await me.json()) as { user: { username: string } };
expect(data.user.username).toBe("admin");
});
test("错误密码返回 401", async () => {
const t = await makeTestEnv();
const resp = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "wrong" }),
}),
t.env,
);
expect(resp.status).toBe(401);
});
test("无会话访问受保护接口返回 401", async () => {
const t = await makeTestEnv();
const resp = await t.app.fetch(new Request("http://localhost/api/nodes"), t.env);
expect(resp.status).toBe(401);
});
test("登出后会话失效(clients 清 cookie 后 401)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
await authedFetch(t, cookie, "http://localhost/api/auth/logout", { method: "POST" });
const me = await t.app.fetch(new Request("http://localhost/api/auth/me"), t.env);
expect(me.status).toBe(401);
});
test("伪造会话 cookie 被拒(签名不匹配)", async () => {
const t = await makeTestEnv();
const resp = await authedFetch(t, "wmh_session=eyJhIjoxfQ.deadbeef", "http://localhost/api/auth/me");
expect(resp.status).toBe(401);
});
test("跨域 Origin 写请求被拒(CSRF)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: "https://evil.example.com" },
body: JSON.stringify({ kind: "api" }),
});
expect(resp.status).toBe(403);
});
});
+24
View File
@@ -0,0 +1,24 @@
From: Alice Sender <alice@example.org>
To: hello@example.test
Cc: bob@example.net
Subject: WmailHub 端到端测试邮件
Message-ID: <test-001@example.org>
Date: Wed, 30 Sep 2026 03:20:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="BOUNDARY-XYZ"
--BOUNDARY-XYZ
Content-Type: text/plain; charset=utf-8
Hello WmailHub! This is a plain text body.
--BOUNDARY-XYZ
Content-Type: text/html; charset=utf-8
<html><body><h1>Hello WmailHub</h1><p>This is <b>HTML</b> body.</p></body></html>
--BOUNDARY-XYZ
Content-Type: text/plain; charset=utf-8; name="notes.txt"
Content-Disposition: attachment; filename="notes.txt"
Content-Transfer-Encoding: base64
VGhpcyBpcyBhbiBhdHRhY2htZW50IGZpbGUu
--BOUNDARY-XYZ--
+41
View File
@@ -0,0 +1,41 @@
/**
* 本地 Webhook 接收器(联调用):
* bun tests/fixtures/webhook-receiver.ts [port]
* 验证 X-WmailHub-Signature = HMAC-SHA256(secret, `${timestamp}.${body}`),打印收到的 payload。
*/
const port = Number(process.argv[2] ?? 18790);
/** # 成员变量: 与创建 webhook 时一致的 secret(由环境变量传入) **/
const SECRET = process.env.WEBHOOK_SECRET ?? "";
Bun.serve({
port,
async fetch(req) {
if (req.method !== "POST") return new Response("ok");
const body = await req.text();
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = req.headers.get("X-WmailHub-Signature") ?? "";
const delivery = req.headers.get("X-WmailHub-Delivery") ?? "";
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const expected = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
const expectedHex = [...new Uint8Array(expected)].map((b) => b.toString(16).padStart(2, "0")).join("");
const valid = expectedHex === sig;
console.log(
JSON.stringify({
t: new Date().toISOString(),
msg: "webhook received",
delivery,
signature_valid: valid,
payload: JSON.parse(body),
}),
);
return Response.json({ received: true });
},
});
console.log(`webhook receiver on :${port}`);
+172
View File
@@ -0,0 +1,172 @@
/**
* 测试基建:内存 D1 + 临时目录 R2 + Console app + 种子数据。
* 每个 test 文件独立建 env,互不干扰。
*/
import { Database } from "bun:sqlite";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createApp } from "../console/src/app.ts";
import { LocalD1 } from "../console/src/local/d1.ts";
import { LocalR2 } from "../console/src/local/r2.ts";
import type { Env } from "../console/src/env.ts";
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();
/** # 成员变量: 测试邮件样例(plain + html + 1 附件) **/
export const TEST_MAIL = `From: Alice <alice@example.org>\r
To: hello@example.test\r
Cc: bob@example.net\r
Subject: Test Mail\r
Message-ID: <t-1@example.org>\r
MIME-Version: 1.0\r
Content-Type: multipart/mixed; boundary="BB"\r
\r
--BB\r
Content-Type: text/plain\r
\r
plain body\r
--BB\r
Content-Type: text/html\r
\r
<b>html body</b>\r
--BB\r
Content-Type: text/plain; name="a.txt"\r
Content-Disposition: attachment; filename="a.txt"\r
\r
attachment-content\r
--BB--\r
`;
/** # 成员变量: 测试环境(内存库 + 临时 R2 目录) **/
export interface TestEnv {
env: Env;
app: ReturnType<typeof createApp>;
db: Database;
/** 默认节点 */
nodeId: string;
/** 默认节点 credential secret(明文,用于签名) */
nodeSecret: string;
/** catch-all 域名 example.test 对应的 mailbox id */
mailboxId: string;
domainId: string;
}
/** # 函数
* 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox
*/
export async function makeTestEnv(): Promise<TestEnv> {
const db = new Database(":memory:", { strict: true });
db.exec(MIGRATION_SQL);
const env: Env = {
DB: new LocalD1(db),
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
WMAILHUB_SIM_CF: "1",
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
};
const app = createApp();
const now = new Date().toISOString();
await env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, 'admin', ?, ?)")
.bind(ids.user(), await hashPassword("admin"), now)
.run();
const nodeId = "node_test1";
const nodeSecret = "wns_test_secret_for_signing";
await env.DB.prepare(
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, ingress_url, ingress_secret_enc, provision_steps, created_at, updated_at)
VALUES (?, 'test-node', 'acct-1', 'cred1', 'online', 'http://127.0.0.1:18788/ingress/email', 'enc', '{}', ?, ?)`,
)
.bind(nodeId, now, now)
.run();
await env.DB.prepare(
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)",
)
.bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), now)
.run();
const domainId = "dom_test1";
await env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, created_at, updated_at) VALUES (?, 'acct-1', ?, 'example.test', 'active', ?, ?)",
)
.bind(domainId, nodeId, now, now)
.run();
const mailboxId = "mbx_test1";
await env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES (?, ?, '*@example.test', 'active', 1, ?, ?)",
)
.bind(mailboxId, domainId, now, now)
.run();
return { env, app, db, nodeId, nodeSecret, mailboxId, domainId };
}
/** # 函数
* 构造登录会话 cookie(走真实登录接口)
*/
export async function loginCookie(test: TestEnv): Promise<string> {
const resp = await test.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
}),
test.env,
);
const setCookie = resp.headers.get("Set-Cookie") ?? "";
const m = /wmh_session=([^;]+)/.exec(setCookie);
if (!m) throw new Error("login failed: no session cookie");
return `wmh_session=${m[1]}`;
}
/** # 函数
* 构造已签名的 Node 邮件转发请求
*/
export async function signedEmailRequest(
test: TestEnv,
body: Uint8Array,
overrides: {
secret?: string;
nodeId?: string;
tamperSignature?: boolean;
tamperBody?: Uint8Array;
timestamp?: string;
reuseNonce?: string;
} = {},
): Promise<Request> {
const url = `http://localhost/api/internal/nodes/${overrides.nodeId ?? test.nodeId}/emails`;
// 签名始终基于原始 body;发送 tamperBody 即构成"签名-内容不一致"的篡改请求
const headers = await signNodeRequest(
overrides.secret ?? test.nodeSecret,
overrides.nodeId ?? test.nodeId,
"POST",
url,
body,
);
headers["Content-Type"] = "message/rfc822";
headers["X-WmailHub-Mail-From"] = "alice@example.org";
headers["X-WmailHub-Rcpt-To"] = "hello@example.test";
if (overrides.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
if (overrides.timestamp) headers["X-WmailHub-Timestamp"] = overrides.timestamp;
if (overrides.reuseNonce) headers["X-WmailHub-Nonce"] = overrides.reuseNonce;
const payload = overrides.tamperBody ?? body;
const ab = payload.buffer.slice(
payload.byteOffset,
payload.byteOffset + payload.byteLength,
) as ArrayBuffer;
return new Request(url, { method: "POST", headers, body: ab });
}
/** # 函数
* 会话 GET/POST 便捷封装
*/
export function authedFetch(test: TestEnv, cookie: string, url: string, init?: RequestInit) {
const headers = new Headers(init?.headers);
headers.set("Cookie", cookie);
return test.app.fetch(new Request(url, { ...init, headers }), test.env);
}
+154
View File
@@ -0,0 +1,154 @@
/** Cloudflare OAuth(本地 sim 模式)测试:state CSRF、自动安装幂等、连接存储 */
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 () => {
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
const start = (await (
await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`)
).json()) as { url: string; state: string };
expect(start.url).toContain("/oauth/sim/authorize?state=");
// sim approve(等价于用户在 Cloudflare 授权页点击同意)
const approve = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: start.state }),
})
).json()) as { ok: boolean; error?: string };
expect(approve.ok).toBe(true);
// 安装结果
const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/nodes/${created.node_id}`)
).json()) as {
node: { status: string; cloudflare_account_id: string | null };
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.steps[key].status).toBe("done");
}
expect(detail.domains.map((d) => d.name)).toContain("example.test");
// catch-all mailbox 已创建
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)
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("email_worker");
expect(kinds).toContain("routing_rule");
// OAuth token 已加密入库(不含明文 token)
const conn = await t.env.DB.prepare("SELECT encrypted_access_token FROM oauth_connections").first<{
encrypted_access_token: string;
}>();
expect(conn?.encrypted_access_token).toBeTruthy();
expect(conn!.encrypted_access_token.startsWith("sim_")).toBe(false);
});
test("state 一次性:重放回调 state 被拒(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}`)
).json()) as { state: string };
const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
expect(first.status).toBe(200);
// state 已消费,重放被拒
const second = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
expect(second.status).toBe(400);
});
test("伪造/过期 state 被拒;真实 callback 端点同样拒绝", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const bad = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: "cfstate_forged" }),
});
expect(bad.status).toBe(400);
const cb = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/callback?code=x&state=forged");
expect(cb.status).toBe(400);
});
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 };
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 }),
}),
t.env,
)
).json()) as { node_id: string; credential_secret: string; ingress_secret: string };
expect(enroll.node_id).toBe(created.node_id);
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)
.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);
// 无效 token 被拒
const badEnroll = await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: "wenr_invalid" }),
}),
t.env,
);
expect(badEnroll.status).toBe(401);
});
});
+190
View File
@@ -0,0 +1,190 @@
/** 邮件管线测试:plain/html/附件/多收件人/malformed/超大邮件/TTL/凭证撤销 */
import { describe, expect, test } from "bun:test";
import { ingestEmail, resolveMailbox } from "../console/src/services/email_ingest.ts";
import { runCron } from "../console/src/services/cleanup.ts";
import { processDueDeliveries } from "../console/src/services/webhook_delivery.ts";
import { MAX_EMAIL_BYTES } from "../shared/src/index.ts";
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
describe("邮件接收管线", () => {
test("完整解析:subject/from/to/cc/text/html/附件/邮箱关联/expires_at", async () => {
const t = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
expect(resp.status).toBe(202);
const data = (await resp.json()) as { id: string; mailbox_id: string; expires_at: string };
expect(data.mailbox_id).toBe(t.mailboxId); // catch-all 匹配
const email = (await t.env.DB.prepare("SELECT * FROM emails WHERE id = ?").bind(data.id).first()) as Record<
string,
unknown
>;
expect(email.subject).toBe("Test Mail");
expect(email.from_address).toBe("alice@example.org");
expect(JSON.parse(email.to_addresses as string)).toEqual(["hello@example.test"]);
expect(JSON.parse(email.cc_addresses as string)).toEqual(["bob@example.net"]);
expect(email.text_body).toContain("plain body");
expect(email.html_body).toContain("html body");
// expires_at ≈ received_at + 86400s
const delta = (new Date(email.expires_at as string).getTime() - new Date(email.received_at as string).getTime()) / 1000;
expect(delta).toBe(86_400);
const atts = await t.env.DB.prepare("SELECT * FROM email_attachments WHERE email_id = ?").bind(data.id).all();
expect(atts.results.length).toBe(1);
expect(atts.results[0].filename).toBe("a.txt");
// raw 已入 R2
const obj = await t.env.R2.get(email.raw_storage_key as string);
expect(obj).not.toBeNull();
const attObj = await t.env.R2.get(atts.results[0].storage_key as string);
expect(await attObj?.text()).toContain("attachment-content");
});
test("malformed 邮件不拒收:降级入库,raw 可取回", async () => {
const t = await makeTestEnv();
const garbage = new TextEncoder().encode("\x00\x01not-a-mime-at-all\r\n###");
const resp = await t.app.fetch(await signedEmailRequest(t, garbage), t.env);
expect(resp.status).toBe(202);
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
expect(count?.n).toBe(1);
const email = await t.env.DB.prepare("SELECT * FROM emails").first<Record<string, unknown>>();
expect(email?.subject).toBe("(unparsable mail)");
const obj = await t.env.R2.get(email?.raw_storage_key as string);
expect(obj).not.toBeNull();
});
test("超大附件被跳过,邮件本身照常入库(限额防护)", async () => {
const t = await makeTestEnv();
const { MAX_ATTACHMENT_BYTES } = await import("../shared/src/index.ts");
const bigB64 = Buffer.from(new Uint8Array(MAX_ATTACHMENT_BYTES + 1)).toString("base64");
const mail = [
"From: a@b.c",
"To: hello@example.test",
"Subject: big att",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=\"BB\"",
"",
"--BB",
"Content-Type: application/octet-stream; name=\"big.bin\"",
"Content-Disposition: attachment; filename=\"big.bin\"",
"Content-Transfer-Encoding: base64",
"",
bigB64,
"--BB",
"Content-Type: text/plain",
"",
"body",
"--BB--",
].join("\r\n");
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(mail),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
.bind(result.id)
.all();
expect(atts.results.length).toBe(0); // 超限附件跳过
const row = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(result.id).first();
expect(row).not.toBeNull(); // 邮件入库
});
test("超大邮件返回 413(资源耗尽防护)", async () => {
const t = await makeTestEnv();
const big = new Uint8Array(MAX_EMAIL_BYTES + 1);
const resp = await t.app.fetch(await signedEmailRequest(t, big), t.env);
expect(resp.status).toBe(413);
});
test("精确地址匹配优先于 catch-all", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_exact', 'dom_test1', 'hello@example.test', 'active', 0, ?, ?)",
)
.bind(now, now)
.run();
const hit = await resolveMailbox(t.env, ["hello@example.test"]);
expect(hit).toBe("mbx_exact");
const other = await resolveMailbox(t.env, ["nobody@example.test"]);
expect(other).toBe(t.mailboxId); // catch-all
const miss = await resolveMailbox(t.env, ["x@other.example"]);
expect(miss).toBeNull();
});
test("mailbox 自定义 retention 覆盖默认 1 天", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"UPDATE mailboxes SET retention_seconds = 300 WHERE id = ?",
)
.bind(t.mailboxId)
.run();
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const delta =
(new Date(result.expiresAt).getTime() - new Date(now).getTime()) / 1000;
expect(delta).toBeLessThan(400);
});
});
describe("TTL 清理与 orphan", () => {
test("过期邮件被清理(R2 + DB),未过期保留", async () => {
const t = await makeTestEnv();
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
// 人为把 expires_at 改到过去
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
.bind(result.id)
.run();
const stats = await runCron(t.env, () => processDueDeliveries(t.env));
expect(stats.expiredEmails).toBe(1);
const row = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(result.id).first();
expect(row).toBeNull();
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
.bind(result.id)
.all();
expect(atts.results.length).toBe(0);
const obj = await t.env.R2.get(result.id ? `emails/2000/01/01/${result.id}/raw.eml` : "nonexist");
expect(obj).toBeNull();
});
test("orphan 附件与投递记录被清理", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"INSERT INTO email_attachments (id, email_id, filename, size) VALUES ('att_orphan', 'msg_gone', 'x.txt', 1)",
)
.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, ?)",
)
.bind(now)
.run();
const stats = await runCron(t.env, () => processDueDeliveries(t.env));
expect(stats.orphanAttachments).toBe(1);
expect(stats.orphanDeliveries).toBe(1);
});
});
describe("credential 撤销", () => {
test("撤销后节点请求被 401,且不因重试恢复", async () => {
const t = await makeTestEnv();
await t.env.DB.prepare("UPDATE node_credentials SET status='revoked', revoked_at=? WHERE id='cred1'")
.bind(new Date().toISOString())
.run();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
expect(resp.status).toBe(401);
const data = (await resp.json()) as { error: string };
expect(data.error).toContain("revoked");
});
});
+141
View File
@@ -0,0 +1,141 @@
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式 */
import { describe, expect, test } from "bun:test";
import {
canonicalString,
computeSignature,
generateWmailKey,
isValidWmailKey,
verifyNodeRequest,
} from "../shared/src/index.ts";
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
describe("wmh_ Key 规范", () => {
test("生成的 Key 以 wmh_ 开头且总长 32", () => {
const key = generateWmailKey();
expect(key.startsWith("wmh_")).toBe(true);
expect(key.length).toBe(32);
expect(isValidWmailKey(key)).toBe(true);
});
test("Key 随机且格式校验拒绝非法输入", () => {
const a = generateWmailKey();
const b = generateWmailKey();
expect(a).not.toBe(b);
expect(isValidWmailKey("wmh_short")).toBe(false);
expect(isValidWmailKey("other_" + "x".repeat(28))).toBe(false);
expect(isValidWmailKey("wmh_" + "x".repeat(27))).toBe(false); // 31
expect(isValidWmailKey("wmh_" + "x".repeat(29))).toBe(false); // 33
expect(isValidWmailKey("wmh_" + "!".repeat(28))).toBe(false); // 非法字符
});
});
describe("HMAC 请求签名", () => {
test("canonical 串格式符合 docs/04", () => {
const c = canonicalString({
method: "POST",
path: "/api/x",
timestamp: "123",
nonce: "n",
bodyHash: "h",
});
expect(c).toBe("POST\n/api/x\n123\nn\nh");
});
test("正确签名可通过校验", async () => {
const body = new TextEncoder().encode("hello");
const headers = new Headers({
"Authorization": "WmailHub-Node node_1",
"X-WmailHub-Timestamp": String(Math.floor(Date.now() / 1000)),
"X-WmailHub-Nonce": "nonce-1",
"X-WmailHub-Body-SHA256": await (async () => {
const { sha256Hex } = await import("../shared/src/index.ts");
return sha256Hex(body);
})(),
"X-WmailHub-Signature": await computeSignature("s", {
method: "POST",
path: "/p",
timestamp: String(Math.floor(Date.now() / 1000)),
nonce: "nonce-1",
bodyHash: await (async () => {
const { sha256Hex } = await import("../shared/src/index.ts");
return sha256Hex(body);
})(),
}),
"X-WmailHub-Protocol": "1",
});
const result = await verifyNodeRequest({
secret: "s",
method: "POST",
url: "http://x/p",
body,
headers,
});
expect(result.ok).toBe(true);
});
test("错误 secret 签名被拒(伪造 Node 请求)", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req = await signedEmailRequest(test, body, { secret: "wns_wrong_secret" });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
});
test("body 篡改被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const tampered = new TextEncoder().encode(TEST_MAIL + "injected");
const req = await signedEmailRequest(test, body, { tamperBody: tampered });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
});
test("过期 timestamp 超窗被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const old = String(Math.floor(Date.now() / 1000) - 3600); // 1 小时前
const req = await signedEmailRequest(test, body, { timestamp: old });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
const data = (await resp.json()) as { error: string };
expect(data.error).toContain("timestamp");
});
test("nonce 重放被拒(replay attack)", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req1 = await signedEmailRequest(test, body);
const resp1 = await test.app.fetch(req1, test.env);
expect(resp1.status).toBe(202);
// 取第一次请求的 nonce 重放(重新签名同一 nonce)
const url = `http://localhost/api/internal/nodes/${test.nodeId}/emails`;
const { signNodeRequest } = await import("../shared/src/index.ts");
const headers = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
headers["Content-Type"] = "message/rfc822";
const resp2a = await test.app.fetch(new Request(url, { method: "POST", headers, body }), test.env);
expect(resp2a.status).toBe(202);
// 抓第二次请求的完整头,原样重发构成 replay(同 nonce 同签名)
const headers3 = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
headers3["Content-Type"] = "message/rfc822";
const replayReq = () => new Request(url, { method: "POST", headers: headers3, body });
const respFirst = await test.app.fetch(replayReq(), test.env);
expect(respFirst.status).toBe(202);
const resp3 = await test.app.fetch(replayReq(), test.env);
expect(resp3.status).toBe(401);
const data = (await resp3.json()) as { error: string };
expect(data.error).toContain("replay");
});
test("错误协议版本被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req = await signedEmailRequest(test, body);
const headers = new Headers(req.headers);
headers.set("X-WmailHub-Protocol", "9");
const resp = await test.app.fetch(
new Request(req.url, { method: "POST", headers, body }),
test.env,
);
expect(resp.status).toBe(400);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* UI 冒烟测试(Playwright headless):
* 登录 admin/admin → 概览/节点/域名/邮箱/邮件/Webhooks/Keys 各页渲染 → 截图。
* bun tests/ui-smoke.ts
*/
import { chromium } from "playwright-core";
const BASE = process.env.WMAILHUI_BASE ?? "http://127.0.0.1:18787";
const EXEC = `${process.env.HOME}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`;
const SHOT_DIR = "/tmp/wmh-shots";
await Bun.$`mkdir -p ${SHOT_DIR}`.quiet();
const browser = await chromium.launch({
executablePath: EXEC,
headless: true,
args: ["--no-sandbox"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(`console: ${msg.text()}`);
});
const results: { step: string; ok: boolean; note?: string }[] = [];
const check = (step: string, ok: boolean, note?: string) => {
results.push({ step, ok, note });
console.log(`${ok ? "✓" : "✗"} ${step}${note ? ` — ${note}` : ""}`);
};
// 1. 登录页
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("input[placeholder='用户名']", { timeout: 8000 });
check("登录页渲染(含 LOGO/表单)", (await page.title()).includes("WmailHub"));
await page.screenshot({ path: `${SHOT_DIR}/01-login.png` });
// 2. 登录 admin/admin
await page.fill("input[placeholder='用户名']", "admin");
await page.fill("input[placeholder='密码']", "admin");
await page.click("button[type=submit]");
await page.waitForURL(`${BASE}/`, { timeout: 8000 });
await page.waitForSelector("text=概览", { timeout: 8000 });
check("登录成功进入概览页", true);
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: "/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);
await page.screenshot({ path: `${SHOT_DIR}/${p.shot}` });
}
// 4. 邮件详情(点击第一行)
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` });
// 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')");
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` });
await browser.close();
const failed = results.filter((r) => !r.ok);
console.log(`\n==== UI 冒烟结果: ${results.length - failed.length}/${results.length} 通过 ====`);
if (errors.length > 0) {
console.log("浏览器错误:");
for (const e of errors.slice(0, 10)) console.log(" ", e);
}
process.exit(failed.length > 0 ? 1 : 0);
+172
View File
@@ -0,0 +1,172 @@
/** 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();
});
});