v2.6.0: 接入不再自动建 catch-all 邮箱(手动添加);删除账号/域名时清理 Cloudflare 侧资源(关闭 Email Routing + 删除 Email Worker,尽力而为+告警)
This commit is contained in:
+17
-1
@@ -41,6 +41,8 @@ export interface CfMockOptions {
|
||||
emailRoutingStatusAfterEnable?: string;
|
||||
/** emailRoutingStatusAfterEnable=misconfigured 时状态对象里的 errors **/
|
||||
routingErrors?: { code?: string; existing?: { content?: string }; missing?: { content?: string } }[];
|
||||
/** 删除清理(POST .../routing/disable、DELETE .../workers/scripts/*)返回失败,测告警降级路径 **/
|
||||
teardownError?: "disable" | "worker";
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare API 桩句柄 **/
|
||||
@@ -169,6 +171,15 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
||||
// PUT / PATCH .../email/routing -> 回 success:true 但 enabled 原封不动(纯回显)
|
||||
// 2026-09-30 线上正是因为信了 PUT 的返回值,把没开成 Email Routing 的域名标成了「已配置」,
|
||||
// 表现为域名显示配好了却收不到邮件、Dashboard 邮件路由里也找不到它。
|
||||
if (url.endsWith("/email/routing/disable")) {
|
||||
// 关闭 Email Routing 与 enable 同理只有 POST 真生效
|
||||
if (method !== "POST") return jsonError([{ code: 10405, message: "Method not allowed" }], 405);
|
||||
if (opts.teardownError === "disable") {
|
||||
return jsonError([{ code: 10000, message: "email routing disable not permitted" }], 400);
|
||||
}
|
||||
state.emailRoutingEnabled = false;
|
||||
return json({ id: url.split("/").at(-3), enabled: false, status: "disabled" });
|
||||
}
|
||||
if (url.endsWith("/email/routing/enable")) {
|
||||
if (method !== "POST") {
|
||||
return jsonError(
|
||||
@@ -199,7 +210,12 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
||||
if (url.includes("/dns_records")) return json(opts.foreignMx ?? []);
|
||||
return json(zones);
|
||||
}
|
||||
if (url.includes("/workers/scripts/")) return json({ id: url.split("/").pop() });
|
||||
if (url.includes("/workers/scripts/")) {
|
||||
if (method === "DELETE" && opts.teardownError === "worker") {
|
||||
return jsonError([{ code: 10000, message: "workers.delete not permitted" }], 400);
|
||||
}
|
||||
return json({ id: url.split("/").pop() });
|
||||
}
|
||||
return json(null, 404);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
|
||||
+6
-3
@@ -103,9 +103,12 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
||||
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");
|
||||
// 同步不再自动创建 catch-all 邮箱(v2.6.0 起,邮箱全部手动添加)
|
||||
const mb = await t.env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM mailboxes m JOIN domains d ON m.domain_id = d.id
|
||||
WHERE d.name IN ('example.com', 'demo.com')`,
|
||||
).first<{ n: number }>();
|
||||
expect(mb?.n).toBe(0);
|
||||
|
||||
// setup:为同步出的 example.com 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则)
|
||||
const example = domList.items.find((d) => d.name === "example.com" && d.status === "pending")!;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 删除账号/域名的 Cloudflare 侧清理测试(v2.6.0):
|
||||
* - 删域名:关闭该域名的 Email Routing(与开启同理只有 POST 真生效)
|
||||
* - 删账号:逐域名关闭 Email Routing + 删除账号级 Email Worker 脚本
|
||||
* - Cloudflare 清理失败只产生告警,不阻断本地删除
|
||||
* Cloudflare 侧由 tests/cf-mock.ts 在 fetch 层打桩,断言真实发出的 HTTP 调用。
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { encryptConnectionToken } from "../console/src/db.ts";
|
||||
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;
|
||||
});
|
||||
|
||||
/** # 函数
|
||||
* 把播种账号 acct_test1 / 域名 dom_test1 配成「已装 Email Workers」:
|
||||
* 补 zone 绑定 + 一条未过期的 Cloudflare 凭据(decryptAccountToken 优先按账号取)。
|
||||
* 播种的 catch-all 邮箱一并删掉,否则单域名删除会被「有邮箱」409 挡住。
|
||||
*/
|
||||
async function makeConfigured(t: Awaited<ReturnType<typeof makeTestEnv>>) {
|
||||
const now = new Date().toISOString();
|
||||
await t.env.DB.prepare("UPDATE domains SET zone_id = 'zone-example' WHERE id = 'dom_test1'").run();
|
||||
await t.env.DB.prepare("DELETE FROM mailboxes WHERE domain_id = 'dom_test1'").run();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, expires_at, created_at, updated_at) VALUES ('conn_tear', 'cloudflare', ?, ?, NULL, ?, ?)",
|
||||
).bind(t.accountId, await encryptConnectionToken(t.env, "cf-oauth-access-token-xyz"), now, now).run();
|
||||
}
|
||||
|
||||
describe("删除账号/域名的 Cloudflare 侧清理", () => {
|
||||
test("删除已配置域名:POST 关闭 Email Routing,本地行删除,无告警", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await makeConfigured(t);
|
||||
const cf = installCloudflareMock();
|
||||
mock = cf;
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/domains/dom_test1", { method: "DELETE" });
|
||||
expect(resp.status).toBe(200);
|
||||
const data = (await resp.json()) as { ok: boolean; warnings: string[] };
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.warnings).toEqual([]);
|
||||
expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true);
|
||||
expect(await t.env.DB.prepare("SELECT id FROM domains WHERE id = 'dom_test1'").first()).toBeNull();
|
||||
const log = await t.env.DB.prepare("SELECT id FROM operation_logs WHERE action = 'domain.delete'").first();
|
||||
expect(log).not.toBeNull();
|
||||
});
|
||||
|
||||
test("未配置 Email Workers 的域名删除:不产生 Cloudflare 调用", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await makeConfigured(t);
|
||||
await t.env.DB.prepare("UPDATE domains SET status = 'pending', zone_id = NULL WHERE id = 'dom_test1'").run();
|
||||
const cf = installCloudflareMock();
|
||||
mock = cf;
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/domains/dom_test1", { method: "DELETE" });
|
||||
expect(resp.status).toBe(200);
|
||||
expect(cf.hit("POST", "/email/routing/disable")).toBe(false);
|
||||
});
|
||||
|
||||
test("Cloudflare 关闭 Routing 失败:仍完成本地删除,响应带告警并记日志", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await makeConfigured(t);
|
||||
const cf = installCloudflareMock({ teardownError: "disable" });
|
||||
mock = cf;
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/domains/dom_test1", { method: "DELETE" });
|
||||
expect(resp.status).toBe(200);
|
||||
const data = (await resp.json()) as { ok: boolean; warnings: string[] };
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.warnings.length).toBe(1);
|
||||
expect(data.warnings[0]).toContain("Email Routing");
|
||||
expect(await t.env.DB.prepare("SELECT id FROM domains WHERE id = 'dom_test1'").first()).toBeNull();
|
||||
const log = await t.env.DB
|
||||
.prepare("SELECT detail FROM operation_logs WHERE action = 'domain.delete'")
|
||||
.first<{ detail: string }>();
|
||||
expect(log?.detail).toContain("Email Routing");
|
||||
});
|
||||
|
||||
test("批量删除域名:逐域名清理 Routing,级联删邮箱,无告警", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await makeConfigured(t);
|
||||
// makeConfigured 删掉了播种邮箱,这里补一个以验证级联删除
|
||||
const now = new Date().toISOString();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_tear', 'dom_test1', 'a@example.test', 'active', 0, ?, ?)",
|
||||
).bind(now, now).run();
|
||||
const cf = installCloudflareMock();
|
||||
mock = cf;
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/domains/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: ["dom_test1"] }),
|
||||
});
|
||||
const data = (await resp.json()) as { deleted: number; mailboxes: number; warnings: string[] };
|
||||
expect(data.deleted).toBe(1);
|
||||
expect(data.mailboxes).toBe(1);
|
||||
expect(data.warnings).toEqual([]);
|
||||
expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true);
|
||||
});
|
||||
|
||||
test("删除账号:关闭各域名 Email Routing + 删除 Email Worker 脚本", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await makeConfigured(t);
|
||||
const cf = installCloudflareMock();
|
||||
mock = cf;
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const resp = await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}`, { method: "DELETE" });
|
||||
expect(resp.status).toBe(200);
|
||||
const data = (await resp.json()) as { ok: boolean; warnings: string[] };
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.warnings).toEqual([]);
|
||||
expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true);
|
||||
expect(cf.hit("DELETE", "/workers/scripts/wmailhub-email-acct-test1")).toBe(true);
|
||||
// 软删生效:列表不再出现,操作日志带清理摘要
|
||||
const list = (await (await authedFetch(t, cookie, "http://localhost/api/accounts")).json()) as { total: number };
|
||||
expect(list.total).toBe(0);
|
||||
const log = await t.env.DB
|
||||
.prepare("SELECT detail FROM operation_logs WHERE action = 'account.delete'")
|
||||
.first<{ detail: string }>();
|
||||
expect(log?.detail).toContain("wmailhub-email-acct-test1");
|
||||
});
|
||||
|
||||
test("删除账号但 Cloudflare 删 Worker 失败:告警不阻断,Routing 照常关闭", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await makeConfigured(t);
|
||||
const cf = installCloudflareMock({ teardownError: "worker" });
|
||||
mock = cf;
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const resp = await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}`, { method: "DELETE" });
|
||||
expect(resp.status).toBe(200);
|
||||
const data = (await resp.json()) as { ok: boolean; warnings: string[] };
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.warnings.length).toBe(1);
|
||||
expect(data.warnings[0]).toContain("Email Worker");
|
||||
expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true);
|
||||
expect(cf.hit("DELETE", "/workers/scripts/wmailhub-email-acct-test1")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user