feat: v2.3.0 设置内配置 Cloudflare OAuth 客户端 + 全列表批量删除
- 设置新增「Cloudflare 接入」tab:Client ID / Client Secret(Secret AES-GCM 加密入库,
只回显末 4 位),并展示可复制的授权回调地址;settings 优先、env 变量兜底,
保存后立即生效无需重新部署;DELETE /api/settings/cf-oauth 清除
- 四个资源列表(账号/域名/邮箱/收件箱)加复选框批量选择与批量删除
- 新增 POST /api/{accounts,domains,mailboxes,emails}/batch-delete:
账号软删+撤销凭据;域名/邮箱级联删除其下邮件;邮件连 R2 原文与附件一并清理
- 抽出 deleteEmailById / deleteEmailsByMailbox / revokeAccount 供单条与批量共用
- 点击复选框不再触发行点击(避免误开详情抽屉)
- 新增 tests/batch.test.ts(6 例)与 settings OAuth 凭据用例(2 例);ui-smoke 扩到 17 步
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
/** 批量操作测试(列表复选框):四个资源的批量删除与子资源级联 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { authedFetch, loginCookie, makeTestEnv, signedIngestRequest, TEST_MAIL } from "./helpers.ts";
|
||||
import { nowIso } from "../console/src/util.ts";
|
||||
|
||||
/** # 函数
|
||||
* 调批量删除接口
|
||||
*/
|
||||
async function batchDelete(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string, path: string, ids: unknown) {
|
||||
const resp = await authedFetch(t, cookie, `http://localhost/api${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
return { status: resp.status, body: (await resp.json()) as { deleted: number; mailboxes?: number; emails?: number; failed: { id: string; reason: string }[] } };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 往某个邮箱灌一封邮件(走真实两阶段 ingest),返回邮件 id
|
||||
*/
|
||||
async function ingestInto(t: Awaited<ReturnType<typeof makeTestEnv>>, recipient: string): Promise<string> {
|
||||
const resp = await t.app.fetch(
|
||||
await signedIngestRequest(t, new TextEncoder().encode(TEST_MAIL), { recipient }),
|
||||
t.env,
|
||||
);
|
||||
if (resp.status !== 202) throw new Error(`ingest failed: ${resp.status}`);
|
||||
return ((await resp.json()) as { id: string }).id;
|
||||
}
|
||||
|
||||
describe("批量删除(POST /*/batch-delete)", () => {
|
||||
test("批量删除邮件:连同 R2 原文/附件/delivery 一并清理;不存在的 id 计入 failed", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const a = await ingestInto(t, "hello@example.test");
|
||||
const b = await ingestInto(t, "hello@example.test");
|
||||
|
||||
const res = await batchDelete(t, cookie, "/emails/batch-delete", [a, b, "msg_not_exist"]);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deleted).toBe(2);
|
||||
expect(res.body.failed.length).toBe(1);
|
||||
|
||||
const left = await t.env.DB.prepare("SELECT id FROM emails").all<{ id: string }>();
|
||||
expect(left.results.length).toBe(0);
|
||||
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments").all<{ id: string }>();
|
||||
expect(atts.results.length).toBe(0);
|
||||
// R2 对象已删除
|
||||
const obj = await t.env.R2.head("emails/2026/09/30/xx/msg_1/raw.eml").catch(() => null);
|
||||
expect(obj).toBeNull();
|
||||
});
|
||||
|
||||
test("批量删除邮箱:级联删除其下邮件", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const emailId = await ingestInto(t, "hello@example.test");
|
||||
|
||||
const res = await batchDelete(t, cookie, "/mailboxes/batch-delete", [t.mailboxId, "mbx_missing"]);
|
||||
expect(res.body.deleted).toBe(1);
|
||||
expect(res.body.emails).toBe(1);
|
||||
expect(res.body.failed.length).toBe(1);
|
||||
|
||||
const mail = await t.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(t.mailboxId).first();
|
||||
expect(mail).toBeNull();
|
||||
const em = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(emailId).first();
|
||||
expect(em).toBeNull();
|
||||
});
|
||||
|
||||
test("批量删除域名:级联删除 catch-all 邮箱与其邮件", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const emailId = await ingestInto(t, "hello@example.test");
|
||||
|
||||
const res = await batchDelete(t, cookie, "/domains/batch-delete", [t.domainId, "dom_missing"]);
|
||||
expect(res.body.deleted).toBe(1);
|
||||
expect(res.body.mailboxes).toBe(1);
|
||||
expect(res.body.emails).toBe(1);
|
||||
expect(res.body.failed.length).toBe(1);
|
||||
|
||||
const dom = await t.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(t.domainId).first();
|
||||
expect(dom).toBeNull();
|
||||
const mb = await t.env.DB.prepare("SELECT id FROM mailboxes").all<{ id: string }>();
|
||||
expect(mb.results.length).toBe(0);
|
||||
const em = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(emailId).first();
|
||||
expect(em).toBeNull();
|
||||
});
|
||||
|
||||
test("批量删除账号:软删 + 撤销 Worker 凭据;域名数据保留", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const res = await batchDelete(t, cookie, "/accounts/batch-delete", [t.accountId, "acct_missing"]);
|
||||
expect(res.body.deleted).toBe(1);
|
||||
expect(res.body.failed.length).toBe(1);
|
||||
|
||||
const acct = await t.env.DB.prepare("SELECT status FROM cloudflare_accounts WHERE id = ?")
|
||||
.bind(t.accountId)
|
||||
.first<{ status: string }>();
|
||||
expect(acct?.status).toBe("revoked");
|
||||
const cred = await t.env.DB.prepare("SELECT status FROM worker_credentials WHERE cloudflare_account_id = ?")
|
||||
.bind(t.accountId)
|
||||
.first<{ status: string }>();
|
||||
expect(cred?.status).toBe("revoked");
|
||||
// 域名仍在
|
||||
const dom = await t.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(t.domainId).first();
|
||||
expect(dom).not.toBeNull();
|
||||
});
|
||||
|
||||
test("边界:空数组 / 非字符串 / 重复 id 均安全处理;需要登录", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const empty = await batchDelete(t, cookie, "/emails/batch-delete", []);
|
||||
expect(empty.status).toBe(200);
|
||||
expect(empty.body.deleted).toBe(0);
|
||||
|
||||
// 非字符串与重复 id 被过滤/去重
|
||||
const emailId = await ingestInto(t, "hello@example.test");
|
||||
const mixed = await batchDelete(t, cookie, "/emails/batch-delete", [emailId, emailId, 123, null, ""]);
|
||||
expect(mixed.body.deleted).toBe(1);
|
||||
|
||||
// 未登录 401
|
||||
const anon = await t.app.fetch(
|
||||
new Request("http://localhost/api/emails/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: ["x"] }),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(anon.status).toBe(401);
|
||||
});
|
||||
|
||||
test("单条删除语义不变:有邮箱的域名仍返回 409", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const resp = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}`, { method: "DELETE" });
|
||||
expect(resp.status).toBe(409);
|
||||
const row = await t.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(t.domainId).first();
|
||||
expect(row).not.toBeNull();
|
||||
// 时间戳工具仍在使用(避免 noUnusedLocals 之外的隐式问题)
|
||||
expect(typeof nowIso()).toBe("string");
|
||||
});
|
||||
});
|
||||
+96
-1
@@ -1,6 +1,7 @@
|
||||
/** 设置测试:全局 Webhook 读写与 key 语义、修改密码 */
|
||||
/** 设置测试:全局 Webhook 读写与 key 语义、修改密码、Cloudflare OAuth 客户端配置 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
||||
import { CF_OAUTH_SETTING_KEYS, getOAuthClient } from "../console/src/services/cfapi.ts";
|
||||
|
||||
describe("设置(/api/settings)", () => {
|
||||
test("全局 Webhook:保存 -> 读取(仅回 last4);key 缺省保留、空格清除", async () => {
|
||||
@@ -52,6 +53,100 @@ describe("设置(/api/settings)", () => {
|
||||
expect(info.webhook.url).toBe("");
|
||||
});
|
||||
|
||||
test("Cloudflare OAuth 客户端:设置页保存(secret 加密) -> 生效;env 兜底;清除", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const info = async () =>
|
||||
(await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||
cf_oauth: { client_id: string; has_secret: boolean; secret_last4: string; source: string };
|
||||
redirect_uri: string;
|
||||
};
|
||||
|
||||
// 测试环境默认带 env 凭据:mode 走 env 兜底
|
||||
let cur = await info();
|
||||
expect(cur.cf_oauth.source).toBe("env");
|
||||
expect(cur.cf_oauth.client_id).toBe("test-client-id");
|
||||
expect(cur.redirect_uri).toBe("http://localhost/api/oauth/cloudflare/callback");
|
||||
|
||||
// 在设置里保存:source 切到 settings,接口只回 last4
|
||||
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cf_oauth: { client_id: "settings-client", secret: "settings-secret-9876" } }),
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
cur = await info();
|
||||
expect(cur.cf_oauth.source).toBe("settings");
|
||||
expect(cur.cf_oauth.client_id).toBe("settings-client");
|
||||
expect(cur.cf_oauth.secret_last4).toBe("9876");
|
||||
|
||||
// secret 加密入库,接口与库中都不出现明文
|
||||
const encRow = await t.env.DB.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(CF_OAUTH_SETTING_KEYS.secretEnc)
|
||||
.first<{ value: string }>();
|
||||
expect(encRow?.value).toBeTruthy();
|
||||
expect(encRow!.value).not.toContain("settings-secret-9876");
|
||||
|
||||
// OAuth 授权 URL 用设置里的 client id
|
||||
const start = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||
).json()) as { url: string };
|
||||
expect(start.url).toContain("client_id=settings-client");
|
||||
|
||||
// secret 缺省 = 保留;只改 client id 也生效
|
||||
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cf_oauth: { client_id: "settings-client-2" } }),
|
||||
});
|
||||
expect((await getOAuthClient(t.env))?.clientSecret).toBe("settings-secret-9876");
|
||||
cur = await info();
|
||||
expect(cur.cf_oauth.client_id).toBe("settings-client-2");
|
||||
|
||||
// DELETE 清除后台凭据:回落到 env 兜底
|
||||
const del = await authedFetch(t, cookie, "http://localhost/api/settings/cf-oauth", { method: "DELETE" });
|
||||
expect(del.status).toBe(200);
|
||||
cur = await info();
|
||||
expect(cur.cf_oauth.source).toBe("env");
|
||||
expect(cur.cf_oauth.client_id).toBe("test-client-id");
|
||||
});
|
||||
|
||||
test("Cloudflare OAuth 客户端:仅填 Client ID(无既有 secret)被拒;secret 传空格清除", async () => {
|
||||
const t = await makeTestEnv();
|
||||
// 清空 env 兜底,模拟"完全未配置"
|
||||
delete t.env.CF_OAUTH_CLIENT_ID;
|
||||
delete t.env.CF_OAUTH_CLIENT_SECRET;
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
const noSecret = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cf_oauth: { client_id: "only-id" } }),
|
||||
});
|
||||
expect(noSecret.status).toBe(400);
|
||||
|
||||
// 接入接口仍是 503(没有任何降级)
|
||||
const start = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
|
||||
expect(start.status).toBe(503);
|
||||
|
||||
// 先正常保存,再用空格清除
|
||||
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cf_oauth: { client_id: "cid", secret: "s1" } }),
|
||||
});
|
||||
let mode = (await (await authedFetch(t, cookie, "http://localhost/api/oauth/mode")).json()) as { mode: string };
|
||||
expect(mode.mode).toBe("cloudflare");
|
||||
|
||||
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cf_oauth: { client_id: "cid", secret: " " } }),
|
||||
});
|
||||
mode = (await (await authedFetch(t, cookie, "http://localhost/api/oauth/mode")).json()) as { mode: string };
|
||||
expect(mode.mode).toBe("unconfigured");
|
||||
});
|
||||
|
||||
test("修改密码:原密码错误被拒;成功后新密码可登录", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
|
||||
+38
-2
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* UI 冒烟测试(Playwright headless):
|
||||
* 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 设置模态窗口 → 生成 Key → 截图。
|
||||
* 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 列表复选框批量选择 →
|
||||
* 设置模态窗口(含 Cloudflare 接入 tab)→ 生成 Key → 截图。
|
||||
* bun tests/ui-smoke.ts
|
||||
*/
|
||||
|
||||
@@ -91,9 +92,25 @@ await page.locator(".ant-dropdown-trigger, .app-header .ant-dropdown-trigger").f
|
||||
await page.waitForSelector(".ant-dropdown:not(.ant-dropdown-hidden)", { timeout: 8000 });
|
||||
await page.click(".ant-dropdown .ant-dropdown-menu-item:has-text('设置')");
|
||||
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
|
||||
check("设置模态窗口经头像下拉打开(仅 全局 Webhook / 账号密码 两 tab)", true);
|
||||
const tabLabels = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts();
|
||||
check(
|
||||
"设置模态窗口经头像下拉打开(全局 Webhook / Cloudflare 接入 / 账号密码 三 tab)",
|
||||
tabLabels.length === 3 && tabLabels.some((t) => t.includes("Cloudflare 接入")),
|
||||
tabLabels.join(" | "),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09-settings-webhook.png` });
|
||||
|
||||
// Cloudflare 接入 tab:回调地址可复制 + Client ID/Secret 表单
|
||||
await page.click(".ant-modal .ant-tabs-tab:has-text('Cloudflare 接入')");
|
||||
await page.waitForSelector(".ant-modal :text('Client ID')", { timeout: 8000 });
|
||||
// 回调地址是 Typography.Paragraph(className=mono);同模态内还有 API Key 的 ****last4,按文本过滤
|
||||
const redirectText =
|
||||
(await page.locator(".ant-modal .mono").filter({ hasText: "/api/oauth/cloudflare/callback" }).first().textContent()) ?? "";
|
||||
check("Cloudflare 接入 tab 显示授权回调地址", redirectText.includes("/api/oauth/cloudflare/callback"), redirectText.trim());
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09b-settings-cloudflare.png` });
|
||||
await page.click(".ant-modal .ant-tabs-tab:has-text('全局 Webhook')");
|
||||
await page.waitForSelector(".ant-modal :text('Webhook URL')", { timeout: 8000 });
|
||||
|
||||
// 单一 API/MCP Key:在 全局 Webhook tab 内生成
|
||||
const genBtn = page.getByRole("button", { name: /生成/ });
|
||||
await genBtn.waitFor({ state: "visible", timeout: 8000 });
|
||||
@@ -107,6 +124,25 @@ await page.screenshot({ path: `${SHOT_DIR}/10-api-key-created.png` });
|
||||
const themeBtn = await page.locator("button[aria-label='切换主题']").count();
|
||||
check("主题切换按钮存在(头像左侧)", themeBtn === 1);
|
||||
|
||||
// 列表复选框批量选择:四个列表页勾选表头全选后出现批量操作条
|
||||
for (const path of ["/accounts", "/domains", "/mailboxes", "/inboxs"]) {
|
||||
await page.goto(`${BASE}${path}`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".ant-table-thead .ant-checkbox-input", { timeout: 8000 });
|
||||
const boxes = page.locator(".ant-table-thead .ant-checkbox-input");
|
||||
if ((await boxes.count()) === 0) {
|
||||
check(`${path} 复选框批量选择`, false, "无表头复选框");
|
||||
continue;
|
||||
}
|
||||
await boxes.first().click({ force: true });
|
||||
const barVisible = await page
|
||||
.waitForSelector("button:has-text('批量删除')", { timeout: 6000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const selectedText = (await page.locator("text=/已选 \\d+ 项/").first().textContent().catch(() => "")) ?? "";
|
||||
check(`${path} 复选框批量选择`, barVisible && /已选/.test(selectedText), selectedText.trim());
|
||||
if (path === "/mailboxes") await page.screenshot({ path: `${SHOT_DIR}/11-batch-selected.png` });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
|
||||
Reference in New Issue
Block a user