/** * 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,模拟跨站重定向) */ async function connectAccount( t: Awaited>, cookie: string, ): Promise<{ accountId: string; cf: CfMock; resp: Response }> { const cf = installCloudflareMock(); 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; 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).toContain("wmailhub-email-"); // Worker credential 已签发 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 -> 账户发现 -> 域名发现 -> 上传 Email Worker 脚本 expect(cf.hit("POST", "/oauth2/token")).toBe(true); expect(cf.hit("GET", "/accounts")).toBe(true); expect(cf.calls.some((c) => c.path.startsWith("/zones?") && c.path.includes("account.id="))).toBe(true); expect(cf.hit("PUT", "/workers/scripts/")).toBe(true); // token 兑换请求带真实 code 与 client 凭据 const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? ""; expect(tokenBody).toContain("grant_type=authorization_code"); expect(tokenBody).toContain("code=auth-code-1"); expect(tokenBody).toContain("client_secret=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); expect(cf.hit("POST", "/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).toContain("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_id 反推账户,接入照样完成", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); // /accounts 返回 403(没有 account 读取权限),zones 仍可用且带 account_id const cf = installCloudflareMock({ zones: [ { id: "zone-a", name: "alpha.com", account_id: "cf-acct-from-zone", status: "active" }, { id: "zone-b", name: "beta.com", account_id: "cf-acct-from-zone", status: "active" }, ], failAccounts: true, }); 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("alpha.com 等 2 个域名"); expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["alpha.com", "beta.com"])); }); 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("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 反推) expect(scope).toBe("zone.read workers-scripts.write email-routing-rule.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"] }); res = (await ( await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") ).json()) as PreflightBody; expect(res.ok).toBe(true); // 主动请求可选的 account-settings.read 而 App 没勾 => 逐轮剔除被点名的,收敛出 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: "account-settings.read zone.read workers-scripts.write email-routing-rule.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 没勾的(所需的 account-settings.read + 4 个备选)都会被点名剔除 expect(res.scopes.denied).toEqual( expect.arrayContaining([ "account-settings.read", "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"]), ); // 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); }); });