Files
wmailhub/tests/authorize.test.ts

311 lines
14 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("同名 worker 跨账号:凭据按签名归属,不得只取最新一条(v2.9.3 回归)", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
// 第二个账号部署了与默认账号同名的 worker 脚本,凭据创建更晚
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_later', 'later-account', 'cfacct-2', ?, 'active', '{}', ?, ?)",
)
.bind(t.workerId, now, now)
.run();
const laterSecret = "whs_later_account_secret";
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_later', 'acct_later', ?, ?, ?, ?, 'active', ?)",
)
.bind(t.workerId, await sha256Hex(laterSecret), await encryptSecret(t.env, laterSecret), laterSecret.slice(-4), now)
.run();
await t.env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES ('dom_later', 'cfacct-2', 'acct_later', 'later.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_later', 'dom_later', '*@later.test', 'active', 1, ?, ?)",
)
.bind(now, now)
.run();
// 先接入账号的 secret 签名(身份=脚本名,兼容路径) -> 必须验到先接入的凭据上(v2.9.3 前被最新凭据压住而 401)
const first = await authorize(t, "hello@example.test");
expect(first.status).toBe(200);
expect(first.body.decision).toBe("accept");
// 后接入账号的 secret 签名(身份=脚本名,兼容路径) -> 归属到后接入账号,它自己的域名 accept
const later = await t.app.fetch(
await signedAuthorizeRequest(t, "hello@later.test", "a@b.c", { workerId: t.workerId, secret: laterSecret }),
t.env,
);
expect(later.status).toBe(200);
expect(((await later.json()) as Decision).decision).toBe("accept");
// v2.10.0 一账号一 token:身份=凭据 id,精确匹配;token 钉死凭据,错 secret 直接 401
const tokenFirst = await t.app.fetch(
await signedAuthorizeRequest(t, "hello@example.test", "a@b.c", { workerId: "wcred1", secret: "wrong-secret" }),
t.env,
);
expect(tokenFirst.status).toBe(401);
const tokenLater = await t.app.fetch(
await signedAuthorizeRequest(t, "hello@later.test", "a@b.c", { workerId: "wcred_later", secret: laterSecret }),
t.env,
);
expect(tokenLater.status).toBe(200);
expect(((await tokenLater.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",
CREDENTIAL_TOKEN: "wcred_test_token",
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");
});
});