feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)
- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传) - Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效 - 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id - 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署 - 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在 - 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程 - 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0 - 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通 - docs 00-10 同步改写;README 更新
This commit is contained in:
+57
-45
@@ -1,13 +1,13 @@
|
||||
/** Cloudflare OAuth(本地 sim 模式)测试:自动建账号、域名同步、state CSRF、setup Email Workers、凭据存储 */
|
||||
/** Cloudflare OAuth(本地 sim 模式)测试:Connect Account 自动接入、域名同步、state CSRF、setup Email Workers、Worker 凭据 */
|
||||
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 () => {
|
||||
describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => {
|
||||
test("start 自动建账号;approve 完成自动接入并同步域名;setup 配置 Email Workers", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
// 添加账号:OAuth start 不带 node_id(服务端自动创建账号)
|
||||
// Connect Cloudflare Account:start 自动创建账号记录
|
||||
const start = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||
).json()) as { url: string; state: string };
|
||||
@@ -20,31 +20,43 @@ describe("Cloudflare OAuth(sim 模式)", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state: start.state }),
|
||||
})
|
||||
).json()) as { ok: boolean; error?: string; node_id: string };
|
||||
).json()) as { ok: boolean; error?: string; account_id: string };
|
||||
expect(approve.ok).toBe(true);
|
||||
|
||||
// 安装结果:发现 + 域名同步完成
|
||||
// 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发
|
||||
const detail = (await (
|
||||
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.node_id}`)
|
||||
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.account_id}`)
|
||||
).json()) as {
|
||||
account: { status: string; cloudflare_account_id: string | null; name: string };
|
||||
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("offline"); // 等待 Node 心跳上线
|
||||
expect(detail.account.status).toBe("active"); // 接入完成(无心跳概念)
|
||||
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"]) {
|
||||
for (const key of ["oauth", "discover", "domains", "worker"]) {
|
||||
expect(detail.steps[key].status).toBe("done");
|
||||
}
|
||||
// OAuth 只同步域名,不自动部署 Email Worker(按域名手动触发)
|
||||
expect(detail.account.worker_id).toContain("wmailhub-email-"); // Worker credential 已签发(库中不存明文 secret)
|
||||
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(approve.account_id)
|
||||
.first<{ secret_hash: string; secret_enc: string; secret_last4: string }>();
|
||||
expect(credRow?.secret_enc).toBeTruthy();
|
||||
expect(credRow!.secret_last4.length).toBe(4);
|
||||
|
||||
// OAuth 只同步域名,Email Routing 按域名手动触发
|
||||
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
|
||||
).json()) as { items: { id: string; name: string; status: string; account_id: string | null }[] };
|
||||
// 种子环境自带一个 cfacct-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");
|
||||
@@ -53,7 +65,7 @@ describe("Cloudflare OAuth(sim 模式)", () => {
|
||||
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 规则)
|
||||
// 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" })
|
||||
@@ -116,50 +128,50 @@ describe("Cloudflare OAuth(sim 模式)", () => {
|
||||
expect(cb.status).toBe(400);
|
||||
});
|
||||
|
||||
test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => {
|
||||
test("rotate:Worker credential 轮换,secret 仅本次可见且已配置域名自动重部署", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
// 自动创建账号后,用 rotate 取得 enrollment token
|
||||
// 自动接入一个账号(approve 走完 worker 步骤:部署 + 签发凭据)
|
||||
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 start = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||
).json()) as { state: string };
|
||||
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; account_id: string };
|
||||
const accountId = approve.account_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);
|
||||
).json()) as { ok: boolean; worker_id: string; secret: string; redeployed: string[] };
|
||||
expect(rotated.ok).toBe(true);
|
||||
expect(rotated.secret.startsWith("whs_")).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'",
|
||||
// 库中不存明文 secret;旧 credential 已撤销,新 credential 生效
|
||||
const creds = await t.env.DB.prepare(
|
||||
"SELECT status FROM worker_credentials WHERE cloudflare_account_id = ?",
|
||||
)
|
||||
.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(
|
||||
.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("旧 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_invalid" }),
|
||||
body: JSON.stringify({ enrollment_token: "wenr_x" }),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(badEnroll.status).toBe(401);
|
||||
expect(resp.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user