Files
wmailhub/tests/authorize.test.ts
T
bigcode fec96e49cc 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 更新
2026-09-30 13:10:26 +08:00

257 lines
11 KiB
TypeScript

/** 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");
});
});