Files
wmailhub/tests/oauth.test.ts
T
bigcode 421cfb2f04 feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链
- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建)
- 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选
- Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口
- 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除)
- UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框
- 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react)
- 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录
- 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
2026-09-30 11:37:38 +08:00

166 lines
7.7 KiB
TypeScript

/** Cloudflare OAuth(本地 sim 模式)测试:自动建账号、域名同步、state CSRF、setup Email Workers、凭据存储 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("Cloudflare OAuth(sim 模式)", () => {
test("start 免 node_id 自动建账号;approve 同步全部域名;setup 配置 Email Workers", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 添加账号:OAuth start 不带 node_id(服务端自动创建账号)
const start = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { url: string; state: string };
expect(start.url).toContain("/oauth/sim/authorize?state=");
// sim approve(等价于用户在 Cloudflare 授权页点击同意)
const approve = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: start.state }),
})
).json()) as { ok: boolean; error?: string; node_id: string };
expect(approve.ok).toBe(true);
// 安装结果:发现 + 域名同步完成
const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.node_id}`)
).json()) as {
account: { status: string; cloudflare_account_id: string | null; name: string };
steps: Record<string, { status: string }>;
domains: { name: string }[];
};
expect(detail.account.status).toBe("offline"); // 等待 Node 心跳上线
expect(detail.account.cloudflare_account_id).toBe("sim-account-0001");
expect(detail.account.name).toBe("WmailHub Sim Account"); // 自动命名替换为 CF 账户名
for (const key of ["oauth", "identity", "discover", "domains"]) {
expect(detail.steps[key].status).toBe("done");
}
// OAuth 只同步域名,不自动部署 Email Worker(按域名手动触发)
expect(detail.domains.map((d) => d.name)).toEqual(
expect.arrayContaining(["example.test", "demo.test", "mailbox.dev"]),
);
const domList = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string }[] };
// 种子环境自带一个 acct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending
const synced = domList.items.filter((d) => ["demo.test", "mailbox.dev"].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.test");
// setup:为同步出的 example.test 配置 Email Workers(Worker + Routing + catch-all 规则)
const example = domList.items.find((d) => d.name === "example.test" && 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);
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-");
// sim 资源记录(Email Worker / Routing rule 只在 setup 后出现)
const sim = await t.env.DB.prepare("SELECT kind FROM cf_sim_resources").all<{ kind: string }>();
const kinds = sim.results.map((r) => r.kind);
expect(kinds).toContain("zone");
expect(kinds).toContain("email_worker");
expect(kinds).toContain("routing_rule");
// 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.startsWith("sim_")).toBe(false);
});
test("state 一次性:重放 approve 被拒(OAuth CSRF 防护)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const { state } = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { state: string };
const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
expect(first.status).toBe(200);
// state 已消费,重放被拒
const second = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
expect(second.status).toBe(400);
});
test("伪造/过期 state 被拒;真实 callback 端点同样拒绝", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const bad = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: "cfstate_forged" }),
});
expect(bad.status).toBe(400);
const cb = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/callback?code=x&state=forged");
expect(cb.status).toBe(400);
});
test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 自动创建账号后,用 rotate 取得 enrollment token
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
const accounts = (await (
await authedFetch(t, cookie, "http://localhost/api/accounts")
).json()) as { items: { id: string }[] };
const accountId = accounts.items[0].id;
const rotated = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" })
).json()) as { enrollment_token: string };
expect(rotated.enrollment_token.startsWith("wenr_")).toBe(true);
const enroll = (await (
await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: rotated.enrollment_token }),
}),
t.env,
)
).json()) as { node_id: string; credential_secret: string; ingress_secret: string };
expect(enroll.node_id).toBe(accountId);
expect(enroll.credential_secret.startsWith("wns_")).toBe(true);
// 库中不存明文 secret
const cred = await t.env.DB.prepare(
"SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'",
)
.bind(accountId)
.first<{ secret_hash: string; secret_enc: string }>();
expect(cred?.secret_hash).not.toBe(enroll.credential_secret);
expect(cred?.secret_enc).not.toContain(enroll.credential_secret);
// 无效 token 被拒
const badEnroll = await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: "wenr_invalid" }),
}),
t.env,
);
expect(badEnroll.status).toBe(401);
});
});