/** * Cloudflare API 抓包桩(仅测试用): * 拦截 globalThis.fetch,对 dash.cloudflare.com / api.cloudflare.com 返回固定响应, * 并记录真实发出的 HTTP 调用。生产代码里没有任何模拟实现——接入链路跑的始终是 * RealCFApi,测试只是在传输层把它要访问的 Cloudflare 换成本地桩。 */ /** # 成员变量: 记录到的 Cloudflare API 调用 **/ export interface CfCall { /** 请求方法 **/ method: string; /** 完整 URL **/ url: string; /** 路径(去掉 base) **/ path: string; /** Authorization 头(Cloudflare API 是 OAuth Bearer token;token 端点是 Basic) **/ authorization: string | null; /** 请求体文本(URL 编码或 JSON);multipart 上传记 null **/ body: string | null; } /** # 成员变量: 桩的返回数据配置 **/ export interface CfMockOptions { /** /zones 返回的域名列表;每个 zone 自带 account(与真实 Cloudflare 响应一致) **/ zones?: { id: string; name: string; account_id: string; status: string; account?: { id: string; name: string } }[]; /** OAuth token 端点签发的 access token **/ accessToken?: string; /** token 端点 HTTP 状态(测失败分支用) **/ tokenStatus?: number; /** 授权端点(dash.cloudflare.com/oauth2/auth)的固定 Location 跳转目标;不传则按 scopesAllowed 判定 **/ authorizeRedirect?: string; /** 授权端点允许的 scope 集合;其余一律 invalid_scope 并点名第一个被拒的(不传 = 全拒) **/ scopesAllowed?: string[]; /** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/ authorizeError?: string; /** Email Routing 开启被拒(模拟 2008:域名上已有非 Cloudflare 的 MX 记录) **/ emailRoutingBlocked?: boolean; /** emailRoutingBlocked 时 /dns_records 返回的 MX 列表(用于验证中文提示点名了冲突记录) **/ foreignMx?: { id?: string; name: string; content: string; priority?: number }[]; } /** # 成员变量: Cloudflare API 桩句柄 **/ export interface CfMock { /** 已记录的调用 **/ calls: CfCall[]; /** 恢复原始 fetch **/ restore(): void; /** 是否发生过指定 method + 路径子串的调用 **/ hit(method: string, pathPart: string): boolean; /** 取出第一条匹配调用的 body **/ bodyOf(method: string, pathPart: string): string | null; } const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token"; const CF_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth"; const REDIRECT_URI = "http://localhost/api/oauth/cloudflare/callback"; const API_BASE = "https://api.cloudflare.com/client/v4"; /** # 函数 * 安装 Cloudflare API 桩:接管 globalThis.fetch,返回记录与恢复句柄 * 参数: opts.zones 决定发现到的资源,tokenStatus 可模拟 token 兑换失败 */ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { const account = { id: "cf-acct-real-1", name: "Real Cloudflare Account" }; // zone 自带 account(真实 Cloudflare 就在 zone 上附带),账号名因此无需账号级读权限 const zones = opts.zones ?? [ { id: "zone-example", name: "example.com", account_id: account.id, status: "active", account }, { id: "zone-demo", name: "demo.com", account_id: account.id, status: "active", account }, ]; const accessToken = opts.accessToken ?? "cf-oauth-access-token-xyz"; const calls: CfCall[] = []; const original = globalThis.fetch; // Email Routing 的真实开关状态:PUT/PATCH 不会改它,只有 POST .../enable 会 const state = { emailRoutingEnabled: false }; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); const headers = new Headers( init?.headers ?? (input instanceof Request ? input.headers : undefined), ); const rawBody = init?.body; const body = typeof rawBody === "string" ? rawBody : rawBody instanceof URLSearchParams ? rawBody.toString() : null; calls.push({ method, url, path: url.startsWith(API_BASE) ? url.slice(API_BASE.length) : url, authorization: headers.get("authorization"), // multipart 上传(Email Worker 脚本)读不出文本,只记方法与路径 body: rawBody instanceof FormData ? null : body, }); const json = (result: unknown, status = 200) => new Response(JSON.stringify({ success: true, errors: [], messages: [], result }), { status, headers: { "Content-Type": "application/json" }, }); /** Cloudflare 风格的失败信封(success:false + errors 数组) */ const jsonError = (errors: { code: number; message: string }[], status: number) => new Response(JSON.stringify({ success: false, errors, messages: [], result: null }), { status, headers: { "Content-Type": "application/json" }, }); if (url.startsWith(CF_AUTHORIZE_URL)) { if (opts.authorizeError) { return new Response(null, { status: 302, headers: { Location: `https://dash.cloudflare.com/oauth/error?error=${opts.authorizeError}&state=wmh_preflight` }, }); } if (opts.authorizeRedirect) { return new Response(null, { status: 302, headers: { Location: opts.authorizeRedirect } }); } // 默认行为:只有 scopesAllowed 里的 scope 才放行,否则点名第一个被拒的 const allowed = opts.scopesAllowed ?? []; const asked = (new URL(url).searchParams.get("scope") ?? "").split(" ").filter(Boolean); const rejected = asked.find((sc) => !allowed.includes(sc)); if (rejected) { const desc = `The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope '${rejected}'.`; return new Response(null, { status: 302, headers: { Location: `${REDIRECT_URI}?error=invalid_scope&error_description=${encodeURIComponent(desc)}&state=wmh_preflight`, }, }); } return new Response(null, { status: 302, headers: { Location: "https://dash.cloudflare.com/login" } }); } if (url === TOKEN_URL) { // 复刻 Cloudflare 的客户端认证要求:只接受 HTTP Basic(client_secret_basic)。 // 把 client_id/secret 放进 POST body(client_secret_post)会被 401 invalid_client 拒绝, // 且错误描述会点名 "method 'client_secret_post' was requested"(2026-09-30 线上踩过)。 const auth = headers.get("authorization") ?? ""; if (!auth.startsWith("Basic ")) { const desc = "Client authentication failed (e.g., unknown client, no client authentication included, " + "or unsupported authentication method). The OAuth 2.0 Client supports client authentication " + "method 'client_secret_basic', but method 'client_secret_post' was requested."; return new Response(JSON.stringify({ error: "invalid_client", error_description: desc }), { status: 401, headers: { "Content-Type": "application/json" }, }); } if ((opts.tokenStatus ?? 200) !== 200) { return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus }); } // OAuth token 端点是裸 JSON(不是 Cloudflare API 的 {success,result} 信封) return new Response(JSON.stringify({ access_token: accessToken, expires_in: 3600, token_type: "bearer" }), { status: 200, headers: { "Content-Type": "application/json" }, }); } if (url.startsWith(`${API_BASE}/zones`)) { if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" }); // 开启 Email Routing 只有一个接口真的干活,这里照抄 Cloudflare 的真实行为: // POST .../email/routing/enable -> 真的把 enabled 翻成 true // PUT / PATCH .../email/routing -> 回 success:true 但 enabled 原封不动(纯回显) // 2026-09-30 线上正是因为信了 PUT 的返回值,把没开成 Email Routing 的域名标成了「已配置」, // 表现为域名显示配好了却收不到邮件、Dashboard 邮件路由里也找不到它。 if (url.endsWith("/email/routing/enable")) { if (method !== "POST") { return jsonError( [{ code: 10405, message: "Method not allowed for this authentication scheme" }], 405, ); } if (opts.emailRoutingBlocked) { return jsonError([{ code: 2008, message: "Non-Cloudflare MX records exist" }], 400); } state.emailRoutingEnabled = true; return json({ id: url.split("/").at(-3), enabled: true, skip_wizard: true, status: "ready" }); } if (url.endsWith("/email/routing")) { // 回显型接口:无论 PUT 还是 PATCH,一律 success 但绝不改 enabled return json({ id: url.split("/").at(-2), name: zones[0]?.name ?? "example.com", enabled: state.emailRoutingEnabled, skip_wizard: true, status: state.emailRoutingEnabled ? "ready" : "unconfigured", }); } // 开启被 2008 挡下时,补一次 MX 查询,验证中文提示里确实点名了冲突记录 if (url.includes("/dns_records")) return json(opts.foreignMx ?? []); return json(zones); } if (url.includes("/workers/scripts/")) return json({ id: url.split("/").pop() }); return json(null, 404); }) as typeof globalThis.fetch; return { calls, restore: () => { globalThis.fetch = original; }, hit: (method, pathPart) => calls.some((c) => c.method === method.toUpperCase() && c.path.includes(pathPart)), bodyOf: (method, pathPart) => calls.find((c) => c.method === method.toUpperCase() && c.path.includes(pathPart))?.body ?? null, }; }