实测 adx9.com 全链路时发现的新缺口:/enable 成功后 enabled=true,但 zone 的
Email Routing status 可能仍是 misconfigured(SPF 指向旧邮件服务)——
照 v2.4.0 的校验(enabled===true)照样会把域名标成「已配置」,而邮件根本不会投递,
与 MX 假成功是同一类问题。
- cfapi: 回读校验增加 status 判定:
- ready / unlocked(过渡态,实测邮件照常投递)视为成功
- misconfigured* 判失败,并把状态对象里的 errors 翻译成中文
(spf.foreign = 现有 SPF 指向其它邮件服务;spf.missing = 给出应替换的 SPF 内容)
- cf-mock: 读回的状态随 emailRoutingStatusAfterEnable 可配置
- oauth.test 新增 2 例:misconfigured 判失败(不标 active)、unlocked 视为成功
线上排查记录(2026-09-30,已手工修复):
adx9.com 删除自建 MX(10 mx.adx9.com)→ POST /email/routing/enable 成功但
status=misconfigured/locked(errors: spf.foreign/spf.missing)→ PATCH
/email/routing/dns(unlock)→ 替换 SPF TXT 为 v=spf1 include:_spf.mx.cloudflare.net
~all → Graph API 发测试邮件 → Console 收到(msg_7eb6afeba6179293),全链路打通。
744 lines
34 KiB
TypeScript
744 lines
34 KiB
TypeScript
/**
|
|
* Cloudflare OAuth(真实授权链路)测试:
|
|
* Connect Cloudflare Account 自动接入、域名同步、state CSRF、setup Email Workers、Worker 凭据。
|
|
* Cloudflare 侧由 tests/cf-mock.ts 在 fetch 层打桩,断言的是真实发出的 HTTP 调用。
|
|
*/
|
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
|
import { installCloudflareMock, type CfMock } from "./cf-mock";
|
|
|
|
/** # 成员变量: 当前 Cloudflare 桩(afterEach 恢复原始 fetch) **/
|
|
let mock: CfMock | null = null;
|
|
afterEach(() => {
|
|
mock?.restore();
|
|
mock = null;
|
|
});
|
|
|
|
/** # 函数
|
|
* 走完 start -> callback,返回账号 id 与桩(不携带会话 cookie 调 callback,模拟跨站重定向)
|
|
* 参数: opts - 传给 Cloudflare 桩的配置(如模拟 Email Routing 开启失败)
|
|
*/
|
|
async function connectAccount(
|
|
t: Awaited<ReturnType<typeof makeTestEnv>>,
|
|
cookie: string,
|
|
opts: Parameters<typeof installCloudflareMock>[0] = {},
|
|
): Promise<{ accountId: string; cf: CfMock; resp: Response }> {
|
|
const cf = installCloudflareMock(opts);
|
|
mock = cf;
|
|
const start = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { url: string; state: string };
|
|
expect(start.url).toContain("https://dash.cloudflare.com/oauth2/auth");
|
|
expect(start.url).toContain("client_id=test-client-id");
|
|
|
|
// 授权回调:浏览器从 Cloudflare 跨站跳回,不带 SameSite=Strict 会话 cookie
|
|
const resp = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=auth-code-1&state=${start.state}`),
|
|
t.env,
|
|
);
|
|
const location = resp.headers.get("Location") ?? "";
|
|
const accountId = new URLSearchParams(location.split("?")[1] ?? "").get("provision") ?? "";
|
|
return { accountId, cf, resp };
|
|
}
|
|
|
|
describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|
test("start 自动建账号;callback 完成自动接入并同步域名;setup 配置 Email Workers", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
|
|
const { accountId, cf, resp } = await connectAccount(t, cookie);
|
|
expect(resp.status).toBe(302);
|
|
expect(resp.headers.get("Location")).toContain("ok=1");
|
|
|
|
// 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发
|
|
const detail = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`)
|
|
).json()) as {
|
|
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("active"); // 接入完成(无心跳概念)
|
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-real-1");
|
|
expect(detail.account.name).toBe("Real Cloudflare Account"); // 自动命名替换为 CF 账户名
|
|
for (const key of ["oauth", "discover", "domains", "worker"]) {
|
|
expect(detail.steps[key].status).toBe("done");
|
|
}
|
|
expect(detail.account.worker_id).toBe("wmailhub-email"); // 统一 Worker 名(已存在则覆盖更新)
|
|
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(accountId)
|
|
.first<{ secret_hash: string; secret_enc: string; secret_last4: string }>();
|
|
expect(credRow?.secret_enc).toBeTruthy();
|
|
expect(credRow!.secret_last4.length).toBe(4);
|
|
|
|
// 真实发出的 Cloudflare 调用:换 token -> 域名发现(账号名随 zone 返回)-> 上传 Email Worker 脚本
|
|
expect(cf.hit("POST", "/oauth2/token")).toBe(true);
|
|
expect(cf.hit("GET", "/zones?")).toBe(true);
|
|
expect(cf.hit("PUT", "/workers/scripts/")).toBe(true);
|
|
// token 兑换请求:body 只带协议参数,客户端凭据走 HTTP Basic 头(RFC 6749 §2.3.1)
|
|
const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? "";
|
|
expect(tokenBody).toContain("grant_type=authorization_code");
|
|
expect(tokenBody).toContain("code=auth-code-1");
|
|
expect(tokenBody).toContain("redirect_uri=");
|
|
// 凭据不能出现在 body 里:Cloudflare 的 client_secret_basic 会判 client_secret_post 并 401
|
|
expect(tokenBody).not.toContain("client_secret");
|
|
expect(tokenBody).not.toContain("client_id");
|
|
const basic = (cf.calls.find((c) => c.path.includes("/oauth2/token"))?.authorization ?? "").replace(
|
|
/^Basic /,
|
|
"",
|
|
);
|
|
expect(atob(basic)).toBe("test-client-id:test-client-secret");
|
|
|
|
// OAuth 只同步域名,Email Routing 按域名手动触发
|
|
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["example.com", "demo.com"]));
|
|
const domList = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; name: string; status: string; account_id: string | null }[] };
|
|
const synced = domList.items.filter((d) => ["example.com", "demo.com"].includes(d.name));
|
|
expect(synced.length).toBe(2);
|
|
for (const d of synced) expect(d.status).toBe("pending");
|
|
|
|
// catch-all 邮箱已随同步创建
|
|
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.com");
|
|
|
|
// setup:为同步出的 example.com 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则)
|
|
const example = domList.items.find((d) => d.name === "example.com" && d.status === "pending")!;
|
|
const setup = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
|
).json()) as { ok: boolean; error?: string };
|
|
expect(setup.ok).toBe(true);
|
|
// 开启 Email Routing 必须走 POST .../email/routing/enable。
|
|
// PUT/PATCH /email/routing 会回 success 但 enabled 不动(桩已复刻这个行为),
|
|
// 照它的返回值判断就会把没开成 Email Routing 的域名标成「已配置」——
|
|
// 这正是 2026-09-30 线上「域名显示配好了却收不到邮件」的根因。
|
|
expect(cf.hit("POST", "/zones/zone-example/email/routing/enable")).toBe(true);
|
|
expect(cf.calls.some((c) => c.method === "POST" && c.path === "/zones/zone-example/email/routing")).toBe(false);
|
|
expect(
|
|
cf.calls.some(
|
|
(c) =>
|
|
(c.method === "PUT" || c.method === "PATCH") &&
|
|
c.path === "/zones/zone-example/email/routing",
|
|
),
|
|
).toBe(false);
|
|
// 开启后回读状态做校验:接口说成功不算数,enabled 为 true 才算开成
|
|
expect(cf.hit("GET", "/zones/zone-example/email/routing")).toBe(true);
|
|
expect(cf.hit("PUT", "/zones/zone-example/email/routing/rules/catch_all")).toBe(true);
|
|
const after = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] };
|
|
const exampleAfter = after.items.find((d) => d.id === example.id)!;
|
|
expect(exampleAfter.status).toBe("active");
|
|
expect(exampleAfter.worker_id).toBe("wmailhub-email");
|
|
|
|
// 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).not.toContain("cf-oauth-access-token-xyz");
|
|
});
|
|
|
|
test("账号名从 zone 自带的 account 取:不需要任何账号级读权限", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
// 真实 Cloudflare 的 GET /zones 每个 zone 都自带 account {id, name},只要 zone.read
|
|
const account = { id: "cf-acct-from-zone", name: "Phptits Account" };
|
|
const cf = installCloudflareMock({
|
|
zones: [
|
|
{ id: "zone-a", name: "alpha.com", account_id: "", status: "active", account },
|
|
{ id: "zone-b", name: "beta.com", account_id: "", status: "active", account },
|
|
],
|
|
});
|
|
mock = cf;
|
|
const { state } = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { state: string };
|
|
const resp = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
|
t.env,
|
|
);
|
|
expect(resp.status).toBe(302);
|
|
expect(resp.headers.get("Location")).toContain("ok=1");
|
|
|
|
const accountId = new URL("http://x" + resp.headers.get("Location")).searchParams.get("provision")!;
|
|
const detail = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`)
|
|
).json()) as {
|
|
account: { status: string; cloudflare_account_id: string | null; name: string };
|
|
domains: { name: string }[];
|
|
};
|
|
expect(detail.account.status).toBe("active");
|
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-from-zone");
|
|
// 展示名就是真实账号名
|
|
expect(detail.account.name).toBe("Phptits Account");
|
|
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["alpha.com", "beta.com"]));
|
|
});
|
|
|
|
test("zone 未附带 account 时:回退用 account_id + 第一个域名展示", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const cf = installCloudflareMock({
|
|
zones: [
|
|
{ id: "zone-a", name: "alpha.com", account_id: "cf-acct-legacy", status: "active" },
|
|
{ id: "zone-b", name: "beta.com", account_id: "cf-acct-legacy", status: "active" },
|
|
],
|
|
});
|
|
mock = cf;
|
|
const { state } = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { state: string };
|
|
const resp = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
|
t.env,
|
|
);
|
|
const accountId = new URL("http://x" + resp.headers.get("Location")).searchParams.get("provision")!;
|
|
const detail = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`)
|
|
).json()) as { account: { cloudflare_account_id: string | null; name: string } };
|
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-legacy");
|
|
expect(detail.account.name).toBe("alpha.com 等 2 个域名");
|
|
});
|
|
|
|
test("state 一次性:重放授权回调被拒(OAuth CSRF 防护)", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
mock = installCloudflareMock();
|
|
const { state } = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { state: string };
|
|
|
|
const first = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
|
t.env,
|
|
);
|
|
expect(first.status).toBe(302);
|
|
// state 已消费,重放被拒
|
|
const second = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
|
t.env,
|
|
);
|
|
expect(second.status).toBe(400);
|
|
});
|
|
|
|
test("伪造 state 被拒;授权回调不依赖会话 cookie(跨站重定向场景)", async () => {
|
|
const t = await makeTestEnv();
|
|
mock = installCloudflareMock();
|
|
// 无 Cookie:SameSite=Strict 下浏览器从 Cloudflare 跳回时就是这种请求
|
|
const forged = await t.app.fetch(
|
|
new Request("http://localhost/api/oauth/cloudflare/callback?code=x&state=cfstate_forged"),
|
|
t.env,
|
|
);
|
|
expect(forged.status).toBe(400);
|
|
|
|
// 合法 state + 无 Cookie 依然放行(CSRF 由一次性 state 承担)
|
|
const cookie = await loginCookie(t);
|
|
const { state } = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { state: string };
|
|
const okResp = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c2&state=${state}`),
|
|
t.env,
|
|
);
|
|
expect(okResp.status).toBe(302);
|
|
});
|
|
|
|
test("未配置 Cloudflare OAuth 时:mode=unconfigured,start/callback 返回 503(无降级通道)", async () => {
|
|
const t = await makeTestEnv();
|
|
delete t.env.CF_OAUTH_CLIENT_ID;
|
|
delete t.env.CF_OAUTH_CLIENT_SECRET;
|
|
const cookie = await loginCookie(t);
|
|
|
|
const mode = (await (await authedFetch(t, cookie, "http://localhost/api/oauth/mode")).json()) as {
|
|
mode: string;
|
|
authorize_url_base: string | null;
|
|
};
|
|
expect(mode.mode).toBe("unconfigured");
|
|
expect(mode.authorize_url_base).toBeNull();
|
|
|
|
const start = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
|
|
expect(start.status).toBe(503);
|
|
// 不会凭空建账号
|
|
const rows = await t.env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE status = 'pending_oauth'").all();
|
|
expect(rows.results.length).toBe(0);
|
|
|
|
const cb = await t.app.fetch(
|
|
new Request("http://localhost/api/oauth/cloudflare/callback?code=x&state=y"),
|
|
t.env,
|
|
);
|
|
expect(cb.status).toBe(503);
|
|
});
|
|
|
|
test("token 兑换失败时不落库连接,账号停留在待授权", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
mock = installCloudflareMock({ tokenStatus: 400 });
|
|
const { state } = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { state: string };
|
|
|
|
const resp = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=bad&state=${state}`),
|
|
t.env,
|
|
);
|
|
expect(resp.status).toBe(500);
|
|
// 兑换失败发生在接入之前:不写 oauth_connections,账号仍是待授权
|
|
const conns = await t.env.DB.prepare("SELECT id FROM oauth_connections").all();
|
|
expect(conns.results.length).toBe(0);
|
|
const acct = await t.env.DB.prepare("SELECT status FROM cloudflare_accounts WHERE status = 'pending_oauth'").all();
|
|
expect(acct.results.length).toBe(1);
|
|
});
|
|
|
|
test("token 端点只接受 HTTP Basic:失败时把 Cloudflare 的原始描述带回页面", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
mock = installCloudflareMock();
|
|
const { state } = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { state: string };
|
|
|
|
// 把 token 端点换成「只认 client_secret_basic」的桩(与真实 Cloudflare 一致)
|
|
const realFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url === "https://dash.cloudflare.com/oauth2/token") {
|
|
const headers = new Headers(init?.headers ?? {});
|
|
if (!(headers.get("authorization") ?? "").startsWith("Basic ")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: "invalid_client",
|
|
error_description:
|
|
"Client authentication failed. The OAuth 2.0 Client supports client authentication " +
|
|
"method 'client_secret_basic', but method 'client_secret_post' was requested.",
|
|
}),
|
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
}
|
|
return realFetch(input, init);
|
|
}) as typeof globalThis.fetch;
|
|
|
|
const resp = await t.app.fetch(
|
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
|
t.env,
|
|
);
|
|
// 我们用的是 Basic,所以这里应当兑换成功而不是 401
|
|
expect(resp.status).toBe(302);
|
|
expect(resp.headers.get("Location")).toContain("ok=1");
|
|
});
|
|
|
|
test("rotate:Worker credential 轮换,secret 仅本次可见且旧 credential 撤销", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const { accountId } = await connectAccount(t, cookie);
|
|
|
|
const rotated = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" })
|
|
).json()) as { ok: boolean; worker_id: string; secret: string; redeployed: string[] };
|
|
expect(rotated.ok).toBe(true);
|
|
expect(rotated.secret.startsWith("whs_")).toBe(true);
|
|
|
|
// 库中不存明文 secret;旧 credential 已撤销,新 credential 生效
|
|
const creds = await t.env.DB.prepare(
|
|
"SELECT status FROM worker_credentials WHERE cloudflare_account_id = ?",
|
|
)
|
|
.bind(accountId)
|
|
.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("scope 用点号分隔(冒号会被 Cloudflare 判 invalid_scope),且可在设置里改", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
|
|
// 默认 scope 是点号格式
|
|
const start = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { url: string };
|
|
const scope = new URL(start.url).searchParams.get("scope") ?? "";
|
|
// 默认请求接入必需的四项(最后一项 zone-settings.write 用于开启 Email Routing)
|
|
expect(scope).toBe("zone.read workers-scripts.write email-routing-rule.write zone-settings.write");
|
|
expect(scope).not.toContain(":");
|
|
|
|
// settings 里自定义 scope 后生效
|
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ cf_oauth: { client_id: "test-client-id", scope: " account.read zone.read " } }),
|
|
});
|
|
const start2 = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { url: string };
|
|
expect(new URL(start2.url).searchParams.get("scope")).toBe("account.read zone.read");
|
|
});
|
|
|
|
test("授权失败(error=invalid_scope):回调不兑换 token,改跳控制台并带回原因", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
installCloudflareMock(); // 若发生 token 兑换会被记录,下面断言没有
|
|
const { state } = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
|
).json()) as { state: string };
|
|
|
|
const resp = await t.app.fetch(
|
|
new Request(
|
|
`http://localhost/api/oauth/cloudflare/callback?error=invalid_scope&error_description=${encodeURIComponent(
|
|
"The OAuth 2.0 Client is not allowed to request scope 'account:read'.",
|
|
)}&state=${state}`,
|
|
),
|
|
t.env,
|
|
);
|
|
expect(resp.status).toBe(302);
|
|
const location = resp.headers.get("Location") ?? "";
|
|
expect(location).toContain("/accounts?oauth_error=invalid_scope");
|
|
expect(location).toContain("oauth_error_description=");
|
|
// 不落库任何连接
|
|
const conns = await t.env.DB.prepare("SELECT id FROM oauth_connections").all();
|
|
expect(conns.results.length).toBe(0);
|
|
});
|
|
|
|
test("授权配置自检:/cloudflare/preflight 汇总 Cloudflare 的判定并给出处置建议", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
|
|
// 未配置 -> not_configured
|
|
delete t.env.CF_OAUTH_CLIENT_ID;
|
|
delete t.env.CF_OAUTH_CLIENT_SECRET;
|
|
type PreflightBody = {
|
|
ok: boolean;
|
|
error: string | null;
|
|
hint: string | null;
|
|
description?: string | null;
|
|
used: { redirect_uri: string };
|
|
scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null };
|
|
};
|
|
let res = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
|
).json()) as PreflightBody;
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error).toBe("not_configured");
|
|
expect(res.hint).toBeTruthy();
|
|
expect(res.used.redirect_uri).toBe("http://localhost/api/oauth/cloudflare/callback");
|
|
|
|
// 通过设置写入凭据(env 已删),再自检
|
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ cf_oauth: { client_id: "preflight-client", secret: "preflight-secret" } }),
|
|
});
|
|
|
|
// Cloudflare 把请求弹回我们的回调地址 => invalid_scope,并给出可执行建议
|
|
const redirectUri = "http://localhost/api/oauth/cloudflare/callback";
|
|
installCloudflareMock({
|
|
authorizeRedirect: `${redirectUri}?error=invalid_scope&error_description=The+requested+scope+is+invalid&state=wmh_preflight`,
|
|
});
|
|
res = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
|
).json()) as PreflightBody;
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error).toBe("invalid_scope");
|
|
expect(res.description).toContain("scope");
|
|
expect(res.hint).toContain("Promote to public");
|
|
|
|
// 错误也可能落在 Cloudflare 自己的 /oauth/error 页(不弹回回调地址)
|
|
installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/oauth/error?error=invalid_request&error_description=redirect_uri+does+not+match&state=wmh_preflight" });
|
|
res = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
|
).json()) as PreflightBody;
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error).toBe("invalid_request");
|
|
expect(res.hint).toContain("Redirect URL");
|
|
|
|
// 默认 scope(四项)全被授权 => 直接通过
|
|
installCloudflareMock({
|
|
scopesAllowed: [
|
|
"zone.read",
|
|
"workers-scripts.write",
|
|
"email-routing-rule.write",
|
|
"zone-settings.write",
|
|
],
|
|
});
|
|
res = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
|
).json()) as PreflightBody;
|
|
expect(res.ok).toBe(true);
|
|
|
|
// 申请了 App 没勾的 scope => 逐轮剔除被点名的,收敛出 allowed/denied
|
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
cf_oauth: {
|
|
client_id: "preflight-client",
|
|
scope: "zone.read email-routing-address.write",
|
|
},
|
|
}),
|
|
});
|
|
res = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
|
).json()) as PreflightBody;
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error).toBe("invalid_scope");
|
|
expect(res.scopes.probed).toBe(true);
|
|
// 候选集里 App 没勾的 4 个备选都会被点名剔除
|
|
expect(res.scopes.denied).toEqual(
|
|
expect.arrayContaining([
|
|
"email-routing-account-rule.read",
|
|
"email-routing-address.write",
|
|
"email-routing-suppression.write",
|
|
"zone-settings.read",
|
|
]),
|
|
);
|
|
expect(res.scopes.allowed).toEqual(
|
|
expect.arrayContaining([
|
|
"zone.read",
|
|
"workers-scripts.write",
|
|
"email-routing-rule.write",
|
|
"zone-settings.write",
|
|
]),
|
|
);
|
|
|
|
// App 什么都没勾时:全部候选都算待勾选
|
|
installCloudflareMock({ scopesAllowed: [] });
|
|
res = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
|
).json()) as PreflightBody;
|
|
expect(res.scopes.allowed).toEqual([]);
|
|
expect(res.scopes.denied.length).toBeGreaterThan(0);
|
|
|
|
// 跳去登录/同意页 => 配置可用
|
|
installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/login" });
|
|
res = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
|
).json()) as PreflightBody;
|
|
expect(res.ok).toBe(true);
|
|
expect(res.error).toBeNull();
|
|
});
|
|
|
|
test("sim 通道已彻底移除:无 sim 授权端点、无 cf_sim_resources 表", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const table = await t.db
|
|
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cf_sim_resources'")
|
|
.get();
|
|
expect(table).toBeNull();
|
|
const resp = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ state: "cfstate_x" }),
|
|
});
|
|
expect(resp.status).not.toBe(200);
|
|
});
|
|
|
|
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_x" }),
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(resp.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("Email Routing 开启失败与回读校验(2026-09-30 线上 bug 的回归锁)", () => {
|
|
test("域名上已有非 Cloudflare MX(2008):标 error 并点名冲突记录,不谎报已配置", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const { accountId, cf } = await connectAccount(t, cookie, {
|
|
emailRoutingBlocked: true,
|
|
foreignMx: [{ name: "example.com", content: "mx.example.com", priority: 10 }],
|
|
});
|
|
|
|
const domList = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; name: string; status: string }[] };
|
|
const example = domList.items.find((d) => d.name === "example.com")!;
|
|
const setup = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
|
).json()) as { ok: boolean; error?: string };
|
|
|
|
// 关键:开不成就不能标 active —— 这正是线上"显示已配置却收不到邮件"的成因
|
|
expect(setup.ok).toBe(false);
|
|
const after = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; status: string; setup_error: string | null }[] };
|
|
const row = after.items.find((d) => d.id === example.id)!;
|
|
expect(row.status).toBe("error");
|
|
// 错误信息要能指导用户行动:说清是 MX 冲突,并点名是哪条记录
|
|
expect(row.setup_error).toContain("非 Cloudflare 的 MX 记录");
|
|
expect(row.setup_error).toContain("mx.example.com");
|
|
// catch-all 规则不该在 Routing 没开成的情况下被改写
|
|
expect(cf.hit("PUT", "/zones/zone-example/email/routing/rules/catch_all")).toBe(false);
|
|
// 账号仍要有 Worker 凭据(脚本已部署,只是 Routing 没开成)
|
|
const detail = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`)
|
|
).json()) as { credential: unknown };
|
|
expect(detail.credential).not.toBeNull();
|
|
});
|
|
|
|
test("开启成功但回读 enabled=false:判失败而不是标记已配置", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
await connectAccount(t, cookie);
|
|
const domList = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; name: string }[] };
|
|
const example = domList.items.find((d) => d.name === "example.com")!;
|
|
|
|
// 让桩的 /enable 变成"回 success 但不改状态":直接把 fetch 换成半吊子实现
|
|
const base = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.endsWith("/email/routing/enable")) {
|
|
return new Response(JSON.stringify({ success: true, errors: [], result: null }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
return base(input as RequestInfo, init);
|
|
}) as typeof globalThis.fetch;
|
|
|
|
const setup = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
|
).json()) as { ok: boolean; error?: string };
|
|
expect(setup.ok).toBe(false);
|
|
expect(setup.error).toContain("enabled=false");
|
|
});
|
|
|
|
test("开启成功但 status=misconfigured(SPF 冲突):判失败并翻译 errors,不谎报已配置", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
await connectAccount(t, cookie, {
|
|
emailRoutingStatusAfterEnable: "misconfigured",
|
|
routingErrors: [
|
|
{ code: "spf.foreign", existing: { content: "v=spf1 mx -all" } },
|
|
{ code: "spf.missing", missing: { content: "v=spf1 include:_spf.mx.cloudflare.net ~all" } },
|
|
],
|
|
});
|
|
const domList = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; name: string }[] };
|
|
const example = domList.items.find((d) => d.name === "example.com")!;
|
|
|
|
const setup = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
|
).json()) as { ok: boolean; error?: string };
|
|
// enabled=true 但 misconfigured 照样不能标已配置 —— 这也是"显示配好了却收不到邮件"的一种
|
|
expect(setup.ok).toBe(false);
|
|
expect(setup.error).toContain("misconfigured");
|
|
expect(setup.error).toContain("SPF");
|
|
expect(setup.error).toContain("v=spf1 include:_spf.mx.cloudflare.net ~all");
|
|
|
|
const after = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; status: string }[] };
|
|
expect(after.items.find((d) => d.id === example.id)!.status).toBe("error");
|
|
});
|
|
|
|
test("开启成功但 status=unlocked(过渡态):视为成功,邮件照常投递", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
await connectAccount(t, cookie, { emailRoutingStatusAfterEnable: "unlocked" });
|
|
const domList = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
|
).json()) as { items: { id: string; name: string }[] };
|
|
const example = domList.items.find((d) => d.name === "example.com")!;
|
|
const setup = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
|
).json()) as { ok: boolean };
|
|
expect(setup.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("API Token 方式添加账号(与 OAuth 并列的第二种接入方式)", () => {
|
|
test("填 Token 即可接入:token 加密入库并绑定到该账号,域名同步完成", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const cf = installCloudflareMock({ accessToken: "cf-user-api-token-abc" });
|
|
mock = cf;
|
|
|
|
const resp = await authedFetch(t, cookie, "http://localhost/api/accounts/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: "我的主账号",
|
|
cloudflare_account_id: "cf-acct-real-1",
|
|
token: "cf-user-api-token-abc",
|
|
}),
|
|
});
|
|
const body = (await resp.json()) as { ok: boolean; account_id: string; error?: string };
|
|
expect(resp.status).toBe(200);
|
|
expect(body.ok).toBe(true);
|
|
|
|
// 账号建好且按 Cloudflare 实际账户命名
|
|
const detail = (await (
|
|
await authedFetch(t, cookie, `http://localhost/api/accounts/${body.account_id}`)
|
|
).json()) as {
|
|
account: { name: string; cloudflare_account_id: string; worker_id: string; status: string };
|
|
domains: { name: string; status: string }[];
|
|
};
|
|
expect(detail.account.status).toBe("active");
|
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-real-1");
|
|
expect(detail.account.worker_id).toBe("wmailhub-email");
|
|
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["example.com", "demo.com"]));
|
|
// 同步出的域名默认 pending,要逐个「设置 Email Workers」
|
|
expect(detail.domains.every((d) => d.status === "pending")).toBe(true);
|
|
|
|
// token 加密入库,不落明文;provider 标成 cloudflare-token
|
|
const conn = await t.env.DB.prepare(
|
|
"SELECT provider, account_id, encrypted_access_token, expires_at FROM oauth_connections WHERE account_id = ?",
|
|
)
|
|
.bind(body.account_id)
|
|
.first<{ provider: string; account_id: string; encrypted_access_token: string; expires_at: string | null }>();
|
|
expect(conn?.provider).toBe("cloudflare-token");
|
|
expect(conn!.encrypted_access_token).not.toContain("cf-user-api-token-abc");
|
|
expect(conn!.expires_at).toBeNull(); // API Token 长期有效
|
|
});
|
|
|
|
test("Token 无效 / 账户 ID 对不上:当场报错,不留下僵尸账号", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const cf = installCloudflareMock({ zones: [] }); // 一个域名都看不到 = token 没权限
|
|
mock = cf;
|
|
|
|
const before = ((await (await authedFetch(t, cookie, "http://localhost/api/accounts")).json()) as {
|
|
total: number;
|
|
}).total;
|
|
const resp = await authedFetch(t, cookie, "http://localhost/api/accounts/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: "坏 Token", token: "bad-token" }),
|
|
});
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error: string };
|
|
expect(body.error).toContain("API Token 校验失败");
|
|
const after = ((await (await authedFetch(t, cookie, "http://localhost/api/accounts")).json()) as {
|
|
total: number;
|
|
}).total;
|
|
expect(after).toBe(before); // 没建出账号
|
|
|
|
// 账户 ID 与 token 实际所属账户不一致 -> 明确指出两个 ID
|
|
installCloudflareMock();
|
|
const mismatch = await authedFetch(t, cookie, "http://localhost/api/accounts/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: "对不上", cloudflare_account_id: "cf-acct-wrong", token: "tok" }),
|
|
});
|
|
expect(mismatch.status).toBe(400);
|
|
const mBody = (await mismatch.json()) as { error: string };
|
|
expect(mBody.error).toContain("cf-acct-wrong");
|
|
expect(mBody.error).toContain("cf-acct-real-1");
|
|
});
|
|
});
|