Files
wmailhub/tests/cf-mock.ts
T
bigcode e7db6762fd fix(email): 开启 Email Routing 改用 PUT,并补上 zone-settings.write 权限
POST /zones/{id}/email/routing 不是有效路由,Cloudflare 返回
10405 Method not allowed for this authentication scheme。正确接口是
PUT /zones/{id}/email/routing(Apply Email Routing settings);
POST .../email/routing/enable 能用但官方已标 Deprecated。

该接口的 Accepted Permission 是 Zone Settings Write,而不是 Email Routing 权限族
—— 权限名和接口所属资源组对不上,Email Routing 四个权限全勾也不够。

- CF_OAUTH_SCOPES_DEFAULT / 探测候选集加 zone-settings.write;设置页文案同步
- cfapi 的 API 错误带上 Cloudflare 错误码(如 10405),便于区分路径错与权限错
- cf-mock 对非 PUT 的 /email/routing 返回 10405,旧写法会被打回
- 88/88 测试
2026-09-30 17:26:29 +08:00

179 lines
8.1 KiB
TypeScript

/**
* 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;
}
/** # 成员变量: 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;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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" },
});
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" });
if (url.endsWith("/email/routing")) {
// 开启 Email Routing 的正确方法是 PUT;POST 到同一路径不是有效路由,
// 真实 Cloudflare 返回 10405(2026-09-30 线上踩过),这里照抄以便锁住修复。
if (method !== "PUT") {
return json(
{ success: false, errors: [{ code: 10405, message: "Method not allowed for this authentication scheme" }], result: null },
405,
);
}
return json({ id: url.split("/").at(-2), enabled: true, skip_wizard: true, status: "ready" });
}
if (url.includes("/email/routing")) return json({ enabled: true });
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,
};
}