feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)
- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传) - Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效 - 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id - 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署 - 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在 - 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程 - 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0 - 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通 - docs 00-10 同步改写;README 更新
This commit is contained in:
@@ -104,7 +104,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
|
||||
.bind(email.id)
|
||||
@@ -175,7 +175,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
|
||||
const created = await mcpCall(t, mcpKey, {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/** authorize 决策矩阵测试(docs/11 §3/§7):accept/reject/temporary_error 语义与拒绝码 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { handleEmailEvent, default as workerMod, type EmailMessage } from "../worker/src/index.ts";
|
||||
import type { WorkerEnv } from "../worker/src/env.ts";
|
||||
import { makeTestEnv, signedAuthorizeRequest, TEST_MAIL } from "./helpers.ts";
|
||||
import { sha256Hex } from "../shared/src/index.ts";
|
||||
import { encryptSecret } from "../console/src/db.ts";
|
||||
|
||||
/** # 成员变量: 决策结果(decision/code) **/
|
||||
interface Decision {
|
||||
decision: "accept" | "reject" | "temporary_error";
|
||||
code?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 调 authorize 接口并解析决策
|
||||
*/
|
||||
async function authorize(
|
||||
t: Awaited<ReturnType<typeof makeTestEnv>>,
|
||||
recipient: string,
|
||||
sender = "sender@example.net",
|
||||
): Promise<{ status: number; body: Decision }> {
|
||||
const resp = await t.app.fetch(await signedAuthorizeRequest(t, recipient, sender), t.env);
|
||||
return { status: resp.status, body: (await resp.json()) as Decision };
|
||||
}
|
||||
|
||||
describe("POST /internal/email/authorize 决策矩阵", () => {
|
||||
test("存在的 catch-all 域名 -> accept(catch-all 下任意未知地址也接收)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const r = await authorize(t, "hello@example.test");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.decision).toBe("accept");
|
||||
const unknown = await authorize(t, "anyone@example.test");
|
||||
expect(unknown.body.decision).toBe("accept");
|
||||
});
|
||||
|
||||
test("不存在邮箱且无有效 catch-all -> reject MAILBOX_NOT_FOUND", async () => {
|
||||
const t = await makeTestEnv();
|
||||
// 删除 catch-all,只留精确地址(禁用的 catch-all 属于 MAILBOX_DISABLED 语义)
|
||||
await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run();
|
||||
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_exact2', 'dom_test1', 'only@example.test', 'active', 0, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
const r = await authorize(t, "unknown@example.test");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("MAILBOX_NOT_FOUND");
|
||||
expect(r.body.reason).toBe("Mailbox does not exist");
|
||||
});
|
||||
|
||||
test("禁用邮箱 -> reject MAILBOX_DISABLED", 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_disabled', 'dom_test1', 'off@example.test', 'disabled', 0, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
const r = await authorize(t, "off@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("MAILBOX_DISABLED");
|
||||
});
|
||||
|
||||
test("禁用 catch-all 邮箱 -> reject MAILBOX_DISABLED(不再回退)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await t.env.DB.prepare("UPDATE mailboxes SET status = 'disabled' WHERE id = ?").bind(t.mailboxId).run();
|
||||
const r = await authorize(t, "anyone@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("MAILBOX_DISABLED");
|
||||
});
|
||||
|
||||
test("pending 域名 -> reject DOMAIN_DISABLED(Email Workers 未启用)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await t.env.DB.prepare("UPDATE domains SET status = 'pending' WHERE id = ?").bind(t.domainId).run();
|
||||
const r = await authorize(t, "hello@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("DOMAIN_DISABLED");
|
||||
});
|
||||
|
||||
test("不存在的域名 -> reject DOMAIN_NOT_FOUND", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const r = await authorize(t, "hello@no-such-domain.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("DOMAIN_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Worker 归属校验:credential 所属账号与收件域名必须一致", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const now = new Date().toISOString();
|
||||
// 另一个账号持有同名域名 example.test;默认 credential 不应跨账号匹配
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) VALUES ('acct_other', 'other-account', 'cfacct-2', 'wmailhub-email-acct-other', 'active', '{}', ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
// 默认 credential 收件人域名不存在于本账号(先移除种子域名) -> DOMAIN_NOT_FOUND
|
||||
await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run();
|
||||
await t.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(t.domainId).run();
|
||||
const r = await authorize(t, "hello@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("DOMAIN_NOT_FOUND");
|
||||
|
||||
// other 账号的 credential + 它自己的域名 -> accept
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES ('dom_other', 'cfacct-2', 'acct_other', 'example.test', 'active', ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_other', 'dom_other', '*@example.test', 'active', 1, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
const otherSecret = "whs_other_secret_for_signing";
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred_other', 'acct_other', 'wmailhub-email-acct-other', ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(await sha256Hex(otherSecret), await encryptSecret(t.env, otherSecret), otherSecret.slice(-4), now)
|
||||
.run();
|
||||
const resp = await t.app.fetch(
|
||||
await signedAuthorizeRequest(t, "hello@example.test", "a@b.c", {
|
||||
workerId: "wmailhub-email-acct-other",
|
||||
secret: otherSecret,
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(resp.status).toBe(200);
|
||||
expect(((await resp.json()) as Decision).decision).toBe("accept");
|
||||
});
|
||||
|
||||
test("畸形收件人 -> reject MAILBOX_NOT_FOUND(不抛 500)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
for (const bad of ["no-at-sign", "@example.test", "a@"]) {
|
||||
const r = await authorize(t, bad);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.decision).toBe("reject");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** # 成员变量: fake console 行为配置 **/
|
||||
interface FakeConsoleOptions {
|
||||
/** authorize 阶段响应(缺省 accept) **/
|
||||
authorize?: (req: Request) => Response | Promise<Response>;
|
||||
/** ingest 阶段响应(缺省 202 成功) **/
|
||||
ingest?: (req: Request) => Response | Promise<Response>;
|
||||
/** 模拟 authorize 阶段网络异常(不可达/超时) **/
|
||||
authorizeThrows?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造 WorkerEnv:fetchImpl 按路径分发到 fake Console
|
||||
*/
|
||||
function makeWorkerEnv(opts: FakeConsoleOptions): WorkerEnv {
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(input, init);
|
||||
const pathname = new URL(req.url).pathname;
|
||||
if (pathname === "/internal/email/authorize") {
|
||||
if (opts.authorizeThrows) throw new Error(opts.authorizeThrows);
|
||||
return await (opts.authorize ?? (() => Response.json({ decision: "accept" })))(req);
|
||||
}
|
||||
if (pathname === "/internal/email/ingest") {
|
||||
return await (opts.ingest ?? (() => Response.json({ ok: true, id: "msg_x", mailbox_id: "mbx_x" }, { status: 202 })))(req);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${pathname}`);
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
CONSOLE_URL: "http://fake-console",
|
||||
WORKER_ID: "wmailhub-email-test",
|
||||
WORKER_SECRET: "whs_fake_secret",
|
||||
fetchImpl,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Email Worker 两阶段行为(临时错误语义)", () => {
|
||||
const MAIL_BYTES = new TextEncoder().encode(TEST_MAIL);
|
||||
|
||||
function message(): EmailMessage {
|
||||
return { from: "sender@example.net", to: "test@example.com", raw: MAIL_BYTES };
|
||||
}
|
||||
|
||||
test("accept -> ingest 原文提交成功(信封头 + message/rfc822)", async () => {
|
||||
let ingestBody: Uint8Array | null = null;
|
||||
const wenv = makeWorkerEnv({
|
||||
ingest: async (req) => {
|
||||
expect(req.headers.get("X-WmailHub-Recipient")).toBe("test@example.com");
|
||||
expect(req.headers.get("X-WmailHub-Sender")).toBe("sender@example.net");
|
||||
expect(req.headers.get("Content-Type")).toBe("message/rfc822");
|
||||
ingestBody = new Uint8Array(await req.arrayBuffer());
|
||||
return Response.json({ ok: true, id: "msg_1", mailbox_id: "mbx_1" }, { status: 202 });
|
||||
},
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("accepted");
|
||||
expect(result.email_id).toBe("msg_1");
|
||||
// 原始 MIME 原样提交(未转 JSON 字符串)
|
||||
expect(new TextDecoder().decode(ingestBody ?? new Uint8Array())).toContain("plain body");
|
||||
});
|
||||
|
||||
test("reject -> 不调用 ingest,返回拒绝码(setReject 由 email() 执行)", async () => {
|
||||
const wenv = makeWorkerEnv({
|
||||
authorize: () => Response.json({ decision: "reject", code: "MAILBOX_NOT_FOUND", reason: "Mailbox does not exist" }),
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("rejected");
|
||||
expect(result.code).toBe("MAILBOX_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Console 5xx -> temporary_error(不能变成 mailbox not found)", async () => {
|
||||
const wenv = makeWorkerEnv({
|
||||
authorize: () => new Response("boom", { status: 500 }),
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("temporary_error");
|
||||
expect(result.reason).toContain("500");
|
||||
});
|
||||
|
||||
test("Console 网络不可达/超时 -> temporary_error", async () => {
|
||||
const wenv = makeWorkerEnv({ authorizeThrows: "connect timeout" });
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("temporary_error");
|
||||
expect(result.reason).toContain("timeout");
|
||||
});
|
||||
|
||||
test("ingest 失败 -> 不伪造业务拒绝(temporary_error)", async () => {
|
||||
const wenv = makeWorkerEnv({
|
||||
ingest: () => new Response("storage down", { status: 500 }),
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("temporary_error");
|
||||
expect(result.reason).toContain("ingest");
|
||||
});
|
||||
|
||||
test("默认 email() handler:reject 调 setReject;temporary 抛异常", async () => {
|
||||
// reject -> setReject(SMTP 永久拒绝)
|
||||
const wenv1 = makeWorkerEnv({
|
||||
authorize: () =>
|
||||
Response.json({
|
||||
decision: "reject",
|
||||
code: "DOMAIN_DISABLED",
|
||||
reason: "Receiving for this domain is not enabled",
|
||||
}),
|
||||
});
|
||||
const reasons: string[] = [];
|
||||
await workerMod.email({ ...message(), setReject: (r) => reasons.push(r) }, wenv1);
|
||||
expect(reasons).toEqual(["Receiving for this domain is not enabled"]);
|
||||
|
||||
// temporary_error -> 抛异常交 Cloudflare 重试
|
||||
const wenv2 = makeWorkerEnv({ authorizeThrows: "console down" });
|
||||
await expect(workerMod.email(message(), wenv2)).rejects.toThrow("console down");
|
||||
});
|
||||
});
|
||||
Vendored
+7
-1
@@ -3,6 +3,12 @@ const KEY = "wmh_e2e_key_12345";
|
||||
Bun.serve({
|
||||
port: 18790,
|
||||
async fetch(req) {
|
||||
if (req.method === "GET" && new URL(req.url).pathname === "/__stats") {
|
||||
return Response.json({ ok: true, note: "see stdout for deliveries" });
|
||||
}
|
||||
if (req.method !== "POST" || new URL(req.url).pathname !== "/hook") {
|
||||
return Response.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
const body = await req.text();
|
||||
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
|
||||
const sig = req.headers.get("X-WmailHub-Signature") ?? "";
|
||||
@@ -17,7 +23,7 @@ Bun.serve({
|
||||
event: payload.event,
|
||||
subject: payload.subject,
|
||||
mailbox: payload.mailbox,
|
||||
custom_header: req.headers.get("X-E2E"),
|
||||
custom_header: req.headers.get("X-Custom"),
|
||||
}),
|
||||
);
|
||||
return Response.json({ ok: true });
|
||||
|
||||
+89
-41
@@ -12,7 +12,7 @@ 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 { hashPassword, sha256Hex, signWorkerRequest } from "../shared/src/index.ts";
|
||||
import { ids } from "../shared/src/index.ts";
|
||||
|
||||
/** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/
|
||||
@@ -54,17 +54,19 @@ export interface TestEnv {
|
||||
env: Env;
|
||||
app: ReturnType<typeof createApp>;
|
||||
db: Database;
|
||||
/** 默认节点 */
|
||||
nodeId: string;
|
||||
/** 默认节点 credential secret(明文,用于签名) */
|
||||
nodeSecret: string;
|
||||
/** 默认账号(cloudflare_accounts) */
|
||||
accountId: string;
|
||||
/** Email Worker 身份(worker_id,即部署脚本名) */
|
||||
workerId: string;
|
||||
/** 默认 worker credential secret(明文,用于签名) */
|
||||
workerSecret: string;
|
||||
/** catch-all 域名 example.test 对应的 mailbox id */
|
||||
mailboxId: string;
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox
|
||||
* 构造隔离测试环境并播种:admin 用户、账号、worker credential、domain、catch-all mailbox
|
||||
*/
|
||||
export async function makeTestEnv(): Promise<TestEnv> {
|
||||
const db = new Database(":memory:", { strict: true });
|
||||
@@ -72,6 +74,7 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
||||
const env: Env = {
|
||||
DB: new LocalD1(db),
|
||||
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
|
||||
PUBLIC_BASE_URL: "http://localhost",
|
||||
WMAILHUB_SIM_CF: "1",
|
||||
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
|
||||
};
|
||||
@@ -82,25 +85,26 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
||||
.bind(ids.user(), await hashPassword("admin"), now)
|
||||
.run();
|
||||
|
||||
const nodeId = "node_test1";
|
||||
const nodeSecret = "wns_test_secret_for_signing";
|
||||
const accountId = "acct_test1";
|
||||
const workerId = "wmailhub-email-acct-test1";
|
||||
const workerSecret = "whs_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', '{}', ?, ?)`,
|
||||
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at)
|
||||
VALUES (?, 'test-account', 'cfacct-1', ?, 'active', '{}', ?, ?)`,
|
||||
)
|
||||
.bind(nodeId, now, now)
|
||||
.bind(accountId, workerId, now, now)
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)",
|
||||
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred1', ?, ?, ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), now)
|
||||
.bind(accountId, workerId, await sha256Hex(workerSecret), await encryptSecret(env, workerSecret), workerSecret.slice(-4), 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', ?, ?)",
|
||||
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES (?, 'cfacct-1', ?, 'example.test', 'active', ?, ?)",
|
||||
)
|
||||
.bind(domainId, nodeId, now, now)
|
||||
.bind(domainId, accountId, now, now)
|
||||
.run();
|
||||
const mailboxId = "mbx_test1";
|
||||
await env.DB.prepare(
|
||||
@@ -109,7 +113,7 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
||||
.bind(mailboxId, domainId, now, now)
|
||||
.run();
|
||||
|
||||
return { env, app, db, nodeId, nodeSecret, mailboxId, domainId };
|
||||
return { env, app, db, accountId, workerId, workerSecret, mailboxId, domainId };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
@@ -130,42 +134,86 @@ export async function loginCookie(test: TestEnv): Promise<string> {
|
||||
return `wmh_session=${m[1]}`;
|
||||
}
|
||||
|
||||
/** # 成员变量: 签名请求构造的篡改选项 **/
|
||||
export interface SignOverrides {
|
||||
/** 换用错误 secret(签名必然不匹配) **/
|
||||
secret?: string;
|
||||
/** 声明其它 worker_id **/
|
||||
workerId?: string;
|
||||
/** 篡改 Authorization */
|
||||
tamperSignature?: boolean;
|
||||
/** 发送与签名不一致的 body(body hash mismatch) **/
|
||||
tamperBody?: Uint8Array;
|
||||
/** 覆盖 timestamp(超窗测试) **/
|
||||
timestamp?: string;
|
||||
/** 覆盖 nonce(replay 测试) **/
|
||||
reuseNonce?: string;
|
||||
/** 覆盖协议版本 */
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造已签名的 Node 邮件转发请求
|
||||
* 构造已签名的 authorize 请求(docs/11:recipient/sender JSON)
|
||||
*/
|
||||
export async function signedEmailRequest(
|
||||
export async function signedAuthorizeRequest(
|
||||
test: TestEnv,
|
||||
recipient: string,
|
||||
sender = "alice@example.org",
|
||||
overrides: SignOverrides = {},
|
||||
): Promise<Request> {
|
||||
const url = "http://localhost/internal/email/authorize";
|
||||
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender, message_id: "<t-1@example.org>" }));
|
||||
const headers = await signWorkerRequest(
|
||||
overrides.secret ?? test.workerSecret,
|
||||
overrides.workerId ?? test.workerId,
|
||||
"POST",
|
||||
url,
|
||||
body,
|
||||
);
|
||||
applyOverrides(headers, overrides);
|
||||
const payload = overrides.tamperBody ?? body;
|
||||
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造已签名的 ingest 请求(raw MIME + 信封头)
|
||||
*/
|
||||
export async function signedIngestRequest(
|
||||
test: TestEnv,
|
||||
body: Uint8Array,
|
||||
overrides: {
|
||||
secret?: string;
|
||||
nodeId?: string;
|
||||
tamperSignature?: boolean;
|
||||
tamperBody?: Uint8Array;
|
||||
timestamp?: string;
|
||||
reuseNonce?: string;
|
||||
} = {},
|
||||
overrides: SignOverrides & { recipient?: string; sender?: 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,
|
||||
const url = "http://localhost/internal/email/ingest";
|
||||
const headers = await signWorkerRequest(
|
||||
overrides.secret ?? test.workerSecret,
|
||||
overrides.workerId ?? test.workerId,
|
||||
"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;
|
||||
headers["X-WmailHub-Recipient"] = overrides.recipient ?? "hello@example.test";
|
||||
headers["X-WmailHub-Sender"] = overrides.sender ?? "alice@example.org";
|
||||
applyOverrides(headers, overrides);
|
||||
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 });
|
||||
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 应用测试篡改覆盖(签名/body/timestamp/nonce/协议版本)
|
||||
*/
|
||||
function applyOverrides(headers: Record<string, string>, o: SignOverrides) {
|
||||
if (o.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
|
||||
if (o.timestamp) headers["X-WmailHub-Timestamp"] = o.timestamp;
|
||||
if (o.reuseNonce) headers["X-WmailHub-Nonce"] = o.reuseNonce;
|
||||
if (o.protocol) headers["X-WmailHub-Protocol"] = o.protocol;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Uint8Array -> ArrayBuffer(Request body 需要)
|
||||
*/
|
||||
function toArrayBuffer(payload: Uint8Array): ArrayBuffer {
|
||||
return payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
+57
-45
@@ -1,13 +1,13 @@
|
||||
/** Cloudflare OAuth(本地 sim 模式)测试:自动建账号、域名同步、state CSRF、setup Email Workers、凭据存储 */
|
||||
/** Cloudflare OAuth(本地 sim 模式)测试:Connect Account 自动接入、域名同步、state CSRF、setup Email Workers、Worker 凭据 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
||||
|
||||
describe("Cloudflare OAuth(sim 模式)", () => {
|
||||
test("start 免 node_id 自动建账号;approve 同步全部域名;setup 配置 Email Workers", async () => {
|
||||
describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => {
|
||||
test("start 自动建账号;approve 完成自动接入并同步域名;setup 配置 Email Workers", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
// 添加账号:OAuth start 不带 node_id(服务端自动创建账号)
|
||||
// Connect Cloudflare Account:start 自动创建账号记录
|
||||
const start = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||
).json()) as { url: string; state: string };
|
||||
@@ -20,31 +20,43 @@ describe("Cloudflare OAuth(sim 模式)", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state: start.state }),
|
||||
})
|
||||
).json()) as { ok: boolean; error?: string; node_id: string };
|
||||
).json()) as { ok: boolean; error?: string; account_id: string };
|
||||
expect(approve.ok).toBe(true);
|
||||
|
||||
// 安装结果:发现 + 域名同步完成
|
||||
// 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发
|
||||
const detail = (await (
|
||||
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.node_id}`)
|
||||
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.account_id}`)
|
||||
).json()) as {
|
||||
account: { status: string; cloudflare_account_id: string | null; name: string };
|
||||
account: { status: string; cloudflare_account_id: string | null; name: string; worker_id: string | null };
|
||||
steps: Record<string, { status: string }>;
|
||||
domains: { name: string }[];
|
||||
credential: { worker_id: string; secret_last4: string; status: string } | null;
|
||||
};
|
||||
expect(detail.account.status).toBe("offline"); // 等待 Node 心跳上线
|
||||
expect(detail.account.status).toBe("active"); // 接入完成(无心跳概念)
|
||||
expect(detail.account.cloudflare_account_id).toBe("sim-account-0001");
|
||||
expect(detail.account.name).toBe("WmailHub Sim Account"); // 自动命名替换为 CF 账户名
|
||||
for (const key of ["oauth", "identity", "discover", "domains"]) {
|
||||
for (const key of ["oauth", "discover", "domains", "worker"]) {
|
||||
expect(detail.steps[key].status).toBe("done");
|
||||
}
|
||||
// OAuth 只同步域名,不自动部署 Email Worker(按域名手动触发)
|
||||
expect(detail.account.worker_id).toContain("wmailhub-email-"); // Worker credential 已签发(库中不存明文 secret)
|
||||
expect(detail.credential).not.toBeNull();
|
||||
expect(detail.credential!.worker_id).toBe(detail.account.worker_id!);
|
||||
const credRow = await t.env.DB.prepare(
|
||||
"SELECT secret_hash, secret_enc, secret_last4 FROM worker_credentials WHERE cloudflare_account_id = ?",
|
||||
)
|
||||
.bind(approve.account_id)
|
||||
.first<{ secret_hash: string; secret_enc: string; secret_last4: string }>();
|
||||
expect(credRow?.secret_enc).toBeTruthy();
|
||||
expect(credRow!.secret_last4.length).toBe(4);
|
||||
|
||||
// OAuth 只同步域名,Email Routing 按域名手动触发
|
||||
expect(detail.domains.map((d) => d.name)).toEqual(
|
||||
expect.arrayContaining(["example.test", "demo.test", "mailbox.dev"]),
|
||||
);
|
||||
const domList = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/domains")
|
||||
).json()) as { items: { id: string; name: string; status: string }[] };
|
||||
// 种子环境自带一个 acct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending
|
||||
).json()) as { items: { id: string; name: string; status: string; account_id: string | null }[] };
|
||||
// 种子环境自带一个 cfacct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending
|
||||
const synced = domList.items.filter((d) => ["demo.test", "mailbox.dev"].includes(d.name));
|
||||
expect(synced.length).toBe(2);
|
||||
for (const d of synced) expect(d.status).toBe("pending");
|
||||
@@ -53,7 +65,7 @@ describe("Cloudflare OAuth(sim 模式)", () => {
|
||||
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");
|
||||
|
||||
// setup:为同步出的 example.test 配置 Email Workers(Worker + Routing + catch-all 规则)
|
||||
// setup:为同步出的 example.test 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则)
|
||||
const example = domList.items.find((d) => d.name === "example.test" && d.status === "pending")!;
|
||||
const setup = (await (
|
||||
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
||||
@@ -116,50 +128,50 @@ describe("Cloudflare OAuth(sim 模式)", () => {
|
||||
expect(cb.status).toBe(400);
|
||||
});
|
||||
|
||||
test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => {
|
||||
test("rotate:Worker credential 轮换,secret 仅本次可见且已配置域名自动重部署", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
// 自动创建账号后,用 rotate 取得 enrollment token
|
||||
// 自动接入一个账号(approve 走完 worker 步骤:部署 + 签发凭据)
|
||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
|
||||
const accounts = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/accounts")
|
||||
).json()) as { items: { id: string }[] };
|
||||
const accountId = accounts.items[0].id;
|
||||
const start = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||
).json()) as { state: string };
|
||||
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; account_id: string };
|
||||
const accountId = approve.account_id;
|
||||
|
||||
const rotated = (await (
|
||||
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" })
|
||||
).json()) as { enrollment_token: string };
|
||||
expect(rotated.enrollment_token.startsWith("wenr_")).toBe(true);
|
||||
).json()) as { ok: boolean; worker_id: string; secret: string; redeployed: string[] };
|
||||
expect(rotated.ok).toBe(true);
|
||||
expect(rotated.secret.startsWith("whs_")).toBe(true);
|
||||
|
||||
const enroll = (await (
|
||||
await t.app.fetch(
|
||||
new Request("http://localhost/api/internal/enroll", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enrollment_token: rotated.enrollment_token }),
|
||||
}),
|
||||
t.env,
|
||||
)
|
||||
).json()) as { node_id: string; credential_secret: string; ingress_secret: string };
|
||||
expect(enroll.node_id).toBe(accountId);
|
||||
expect(enroll.credential_secret.startsWith("wns_")).toBe(true);
|
||||
|
||||
// 库中不存明文 secret
|
||||
const cred = await t.env.DB.prepare(
|
||||
"SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'",
|
||||
// 库中不存明文 secret;旧 credential 已撤销,新 credential 生效
|
||||
const creds = await t.env.DB.prepare(
|
||||
"SELECT status FROM worker_credentials WHERE cloudflare_account_id = ?",
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<{ secret_hash: string; secret_enc: string }>();
|
||||
expect(cred?.secret_hash).not.toBe(enroll.credential_secret);
|
||||
expect(cred?.secret_enc).not.toContain(enroll.credential_secret);
|
||||
// 无效 token 被拒
|
||||
const badEnroll = await t.app.fetch(
|
||||
.all<{ status: string }>();
|
||||
expect(creds.results.filter((r) => r.status === "active").length).toBe(1);
|
||||
expect(creds.results.filter((r) => r.status === "revoked").length).toBe(1);
|
||||
});
|
||||
|
||||
test("旧 Node 协议接口已移除(/api/internal/enroll 不再可用)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
// 该路径已无路由;管理面中间件按未授权拒绝,不会返回 enrollment 结果
|
||||
const resp = 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" }),
|
||||
body: JSON.stringify({ enrollment_token: "wenr_x" }),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(badEnroll.status).toBe(401);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
+52
-13
@@ -1,16 +1,16 @@
|
||||
/** 邮件管线测试:plain/html/附件/多收件人/malformed/超大邮件/TTL/凭证撤销 */
|
||||
/** 邮件管线测试(docs/11 两阶段):ingest 入库/附件/降级/限额/TTL/orphan/凭据撤销 */
|
||||
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";
|
||||
import { makeTestEnv, signedIngestRequest, TEST_MAIL } from "./helpers.ts";
|
||||
|
||||
describe("邮件接收管线", () => {
|
||||
describe("邮件接收管线(POST /internal/email/ingest)", () => {
|
||||
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);
|
||||
const resp = await t.app.fetch(await signedIngestRequest(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 匹配
|
||||
@@ -25,6 +25,7 @@ describe("邮件接收管线", () => {
|
||||
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");
|
||||
expect(email.account_id).toBe(t.accountId);
|
||||
// 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);
|
||||
@@ -42,7 +43,7 @@ describe("邮件接收管线", () => {
|
||||
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);
|
||||
const resp = await t.app.fetch(await signedIngestRequest(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);
|
||||
@@ -79,7 +80,7 @@ describe("邮件接收管线", () => {
|
||||
raw: new TextEncoder().encode(mail),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
|
||||
.bind(result.id)
|
||||
@@ -92,7 +93,7 @@ describe("邮件接收管线", () => {
|
||||
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);
|
||||
const resp = await t.app.fetch(await signedIngestRequest(t, big), t.env);
|
||||
expect(resp.status).toBe(413);
|
||||
});
|
||||
|
||||
@@ -124,12 +125,40 @@ describe("邮件接收管线", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const delta =
|
||||
(new Date(result.expiresAt).getTime() - new Date(now).getTime()) / 1000;
|
||||
expect(delta).toBeLessThan(400);
|
||||
});
|
||||
|
||||
test("收件人已不可接收时 ingest 被拒(409,authorize 与 ingest 之间状态变化)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
// 禁用 catch-all 邮箱 -> authorize 逻辑不再接受该收件人
|
||||
await t.env.DB.prepare("UPDATE mailboxes SET status = 'disabled' WHERE id = ?")
|
||||
.bind(t.mailboxId)
|
||||
.run();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env);
|
||||
expect(resp.status).toBe(409);
|
||||
const data = (await resp.json()) as { code: string };
|
||||
expect(data.code).toBe("MAILBOX_DISABLED");
|
||||
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
|
||||
expect(count?.n).toBe(0);
|
||||
});
|
||||
|
||||
test("域名归属其它账号的 ingest 请求被拒(DOMAIN_NOT_FOUND)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
// 收件人域名不在 credential 所属账号下
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const resp = await t.app.fetch(
|
||||
await signedIngestRequest(t, body, { recipient: "hello@other-domain.test" }),
|
||||
t.env,
|
||||
);
|
||||
expect(resp.status).toBe(409);
|
||||
const data = (await resp.json()) as { code: string };
|
||||
expect(data.code).toBe("DOMAIN_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TTL 清理与 orphan", () => {
|
||||
@@ -139,7 +168,7 @@ describe("TTL 清理与 orphan", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
// 人为把 expires_at 改到过去
|
||||
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
|
||||
@@ -175,16 +204,26 @@ describe("TTL 清理与 orphan", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential 撤销", () => {
|
||||
test("撤销后节点请求被 401,且不因重试恢复", async () => {
|
||||
describe("Worker credential 撤销", () => {
|
||||
test("撤销后 Worker 请求被 401,且不因重试恢复", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await t.env.DB.prepare("UPDATE node_credentials SET status='revoked', revoked_at=? WHERE id='cred1'")
|
||||
await t.env.DB.prepare("UPDATE worker_credentials SET status='revoked', revoked_at=? WHERE id='wcred1'")
|
||||
.bind(new Date().toISOString())
|
||||
.run();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
|
||||
const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env);
|
||||
expect(resp.status).toBe(401);
|
||||
const data = (await resp.json()) as { error: string };
|
||||
expect(data.error).toContain("revoked");
|
||||
});
|
||||
|
||||
test("账号 revoked 后 credential 立即失效", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await t.env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked' WHERE id = ?")
|
||||
.bind(t.accountId)
|
||||
.run();
|
||||
const req = await signedIngestRequest(t, new TextEncoder().encode(TEST_MAIL));
|
||||
const resp = await t.app.fetch(req, t.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-51
@@ -1,13 +1,15 @@
|
||||
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式 */
|
||||
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式(Worker-Console 协议) */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
canonicalString,
|
||||
computeSignature,
|
||||
generateWmailKey,
|
||||
isValidWmailKey,
|
||||
verifyNodeRequest,
|
||||
sha256Hex,
|
||||
verifyWorkerRequest,
|
||||
signWorkerRequest,
|
||||
} from "../shared/src/index.ts";
|
||||
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
|
||||
import { makeTestEnv, signedAuthorizeRequest, signedIngestRequest, TEST_MAIL } from "./helpers.ts";
|
||||
|
||||
describe("wmh_ Key 规范", () => {
|
||||
test("生成的 Key 以 wmh_ 开头且总长 32", () => {
|
||||
@@ -29,41 +31,35 @@ describe("wmh_ Key 规范", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("HMAC 请求签名", () => {
|
||||
describe("Worker-Console HMAC 请求签名", () => {
|
||||
test("canonical 串格式符合 docs/04", () => {
|
||||
const c = canonicalString({
|
||||
method: "POST",
|
||||
path: "/api/x",
|
||||
path: "/internal/email/authorize",
|
||||
timestamp: "123",
|
||||
nonce: "n",
|
||||
bodyHash: "h",
|
||||
});
|
||||
expect(c).toBe("POST\n/api/x\n123\nn\nh");
|
||||
expect(c).toBe("POST\n/internal/email/authorize\n123\nn\nh");
|
||||
});
|
||||
|
||||
test("正确签名可通过校验", async () => {
|
||||
test("正确签名可通过校验(Authorization: WmailHub-Worker <worker_id>)", async () => {
|
||||
const body = new TextEncoder().encode("hello");
|
||||
const headers = new Headers({
|
||||
"Authorization": "WmailHub-Node node_1",
|
||||
"Authorization": "WmailHub-Worker wmailhub-email-x1",
|
||||
"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-Body-SHA256": await 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);
|
||||
})(),
|
||||
bodyHash: await sha256Hex(body),
|
||||
}),
|
||||
"X-WmailHub-Protocol": "1",
|
||||
});
|
||||
const result = await verifyNodeRequest({
|
||||
const result = await verifyWorkerRequest({
|
||||
secret: "s",
|
||||
method: "POST",
|
||||
url: "http://x/p",
|
||||
@@ -71,30 +67,36 @@ describe("HMAC 请求签名", () => {
|
||||
headers,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.workerId).toBe("wmailhub-email-x1");
|
||||
});
|
||||
|
||||
test("错误 secret 签名被拒(伪造 Node 请求)", async () => {
|
||||
test("错误 secret 签名被拒(伪造 Worker 请求)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const body = new TextEncoder().encode(TEST_MAIL);
|
||||
const req = await signedEmailRequest(test, body, { secret: "wns_wrong_secret" });
|
||||
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { secret: "whs_wrong_secret" });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
|
||||
test("body 篡改被拒", async () => {
|
||||
test("未知 worker_id 被拒(公网匿名调用 /internal/email/*)", async () => {
|
||||
const test = await makeTestEnv();
|
||||
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { workerId: "wmailhub-email-unknown" });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
|
||||
test("body 篡改被拒(authorize 与 ingest 都校验 body hash)", 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 req = await signedIngestRequest(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 req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { timestamp: old });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(401);
|
||||
const data = (await resp.json()) as { error: string };
|
||||
@@ -103,39 +105,24 @@ describe("HMAC 请求签名", () => {
|
||||
|
||||
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 });
|
||||
// 同一组签名头发两次:第一次 nonce 入库通过,第二次构成 replay
|
||||
const url = "http://localhost/internal/email/authorize";
|
||||
const body = new TextEncoder().encode(JSON.stringify({ recipient: "hello@example.test", sender: "a@b.c" }));
|
||||
const headers = await signWorkerRequest(test.workerSecret, test.workerId, "POST", url, body);
|
||||
headers["Content-Type"] = "application/json";
|
||||
const replayReq = () => new Request(url, { method: "POST", headers, body: body.slice() });
|
||||
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(respFirst.status).toBe(200);
|
||||
const respReplay = await test.app.fetch(replayReq(), test.env);
|
||||
expect(respReplay.status).toBe(401);
|
||||
const data = (await respReplay.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,
|
||||
);
|
||||
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { protocol: "9" });
|
||||
const resp = await test.app.fetch(req, test.env);
|
||||
expect(resp.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-24
@@ -1,4 +1,4 @@
|
||||
/** 设置测试:全局 Webhook 读写与 key 语义、默认 Ingress、修改密码 */
|
||||
/** 设置测试:全局 Webhook 读写与 key 语义、修改密码 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
||||
|
||||
@@ -52,29 +52,6 @@ describe("设置(/api/settings)", () => {
|
||||
expect(info.webhook.url).toBe("");
|
||||
});
|
||||
|
||||
test("默认 Node Ingress 地址保存与读取", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_ingress_url: "https://node.example.com/ingress/email" }),
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
const info = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/settings")
|
||||
).json()) as { default_ingress_url: string };
|
||||
expect(info.default_ingress_url).toBe("https://node.example.com/ingress/email");
|
||||
|
||||
// 非法地址被拒
|
||||
const bad = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_ingress_url: "not-a-url" }),
|
||||
});
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
test("修改密码:原密码错误被拒;成功后新密码可登录", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
+2
-2
@@ -80,8 +80,8 @@ 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("WmailHub")));
|
||||
const drawerText = (await page.locator(".ant-drawer-title").textContent()) ?? "";
|
||||
check("邮件详情抽屉打开(标题=邮件主题)", drawerText.trim().length > 0, drawerText.trim());
|
||||
await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const dlv = await t.env.DB.prepare("SELECT id, target_type FROM webhook_deliveries").first<{
|
||||
id: string;
|
||||
@@ -129,7 +129,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
await processDueDeliveries(t.env, undefined, 20, true);
|
||||
expect(mailboxRecv.requests.length).toBe(1);
|
||||
@@ -145,7 +145,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
await processDueDeliveries(t.env, undefined, 20, true);
|
||||
expect(domainRecv.requests.length).toBe(1);
|
||||
@@ -157,7 +157,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
await processDueDeliveries(t.env, undefined, 20, true);
|
||||
expect(globalRecv.requests.length).toBe(1);
|
||||
@@ -171,7 +171,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM webhook_deliveries").first<{ n: number }>();
|
||||
expect(count?.n).toBe(0);
|
||||
@@ -200,7 +200,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts");
|
||||
const row0 = await t.env.DB.prepare(
|
||||
|
||||
Reference in New Issue
Block a user