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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+24
@@ -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--
|
||||
Vendored
+41
@@ -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}`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user