- 两阶段接收: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 更新
257 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|