- 账号化:节点更名账号,/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
239 lines
9.0 KiB
TypeScript
239 lines
9.0 KiB
TypeScript
/** API v1 / MCP 测试:Key 隔离、过期邮件 410、MCP 协议与工具 */
|
|
import { describe, expect, test } from "bun:test";
|
|
import { ingestEmail } from "../console/src/services/email_ingest.ts";
|
|
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
|
|
|
|
/** # 函数
|
|
* 创建 Key 并返回明文
|
|
*/
|
|
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string, kind: "api" | "mcp") {
|
|
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ kind }),
|
|
});
|
|
return ((await resp.json()) as { key: string }).key;
|
|
}
|
|
|
|
describe("Console API v1(Bearer wmh_ Key)", () => {
|
|
test("api key 可读资源;mcp key 访问 v1 被 403(kind 隔离)", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const apiKey = await createKey(t, cookie, "api");
|
|
const mcpKey = await createKey(t, cookie, "mcp");
|
|
|
|
const ok = await t.app.fetch(
|
|
new Request("http://localhost/api/v1/mailboxes", {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(ok.status).toBe(200);
|
|
const data = (await ok.json()) as { items: { address: string }[] };
|
|
expect(data.items.map((m) => m.address)).toContain("*@example.test");
|
|
|
|
const bad = await t.app.fetch(
|
|
new Request("http://localhost/api/v1/mailboxes", {
|
|
headers: { Authorization: `Bearer ${mcpKey}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(bad.status).toBe(403);
|
|
});
|
|
|
|
test("无 key / 伪造 key / 已撤销 key 均被拒", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const noKey = await t.app.fetch(new Request("http://localhost/api/v1/mailboxes"), t.env);
|
|
expect(noKey.status).toBe(401);
|
|
|
|
const fake = await t.app.fetch(
|
|
new Request("http://localhost/api/v1/mailboxes", {
|
|
headers: { Authorization: `Bearer wmh_${"A".repeat(28)}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(fake.status).toBe(401);
|
|
|
|
const apiKey = await createKey(t, cookie, "api");
|
|
// 找到 key id 并撤销
|
|
const list = (await (
|
|
await authedFetch(t, cookie, "http://localhost/api/keys")
|
|
).json()) as { items: { id: string; revoked_at: string | null }[] };
|
|
const keyId = list.items.find((k) => !k.revoked_at)!.id;
|
|
await authedFetch(t, cookie, `http://localhost/api/keys/${keyId}/revoke`, { method: "POST" });
|
|
const revoked = await t.app.fetch(
|
|
new Request("http://localhost/api/v1/mailboxes", {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(revoked.status).toBe(401);
|
|
});
|
|
|
|
test("v1 可创建与删除 mailbox", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const apiKey = await createKey(t, cookie, "api");
|
|
const create = await t.app.fetch(
|
|
new Request("http://localhost/api/v1/mailboxes", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ domain_id: t.domainId, address: "newbox" }),
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(create.status).toBe(201);
|
|
const { id, address } = (await create.json()) as { id: string; address: string };
|
|
expect(address).toBe("newbox@example.test");
|
|
const del = await t.app.fetch(
|
|
new Request(`http://localhost/api/v1/mailboxes/${id}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(del.status).toBe(200);
|
|
});
|
|
|
|
test("过期邮件:v1 详情返回 410,列表不出现", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const apiKey = await createKey(t, cookie, "api");
|
|
const email = await ingestEmail(t.env, {
|
|
raw: new TextEncoder().encode(TEST_MAIL),
|
|
mailFrom: "a@b.c",
|
|
rcptTo: "hello@example.test",
|
|
nodeId: t.nodeId,
|
|
});
|
|
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
|
|
.bind(email.id)
|
|
.run();
|
|
const detail = await t.app.fetch(
|
|
new Request(`http://localhost/api/v1/emails/${email.id}`, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(detail.status).toBe(410);
|
|
const list = await t.app.fetch(
|
|
new Request("http://localhost/api/v1/emails", {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
const data = (await list.json()) as { items: { id: string }[] };
|
|
expect(data.items.find((e) => e.id === email.id)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
|
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, mcpKey: string, body: unknown) {
|
|
return t.app.fetch(
|
|
new Request("http://localhost/api/v1/mcp", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${mcpKey}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
t.env,
|
|
);
|
|
}
|
|
|
|
test("api key 不能访问 MCP(权限基于 credential kind)", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const apiKey = await createKey(t, cookie, "api");
|
|
const resp = await mcpCall(t, apiKey, { jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
expect(resp.status).toBe(403);
|
|
});
|
|
|
|
test("initialize + tools/list 返回五个工具与 serverInfo", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const mcpKey = await createKey(t, cookie, "mcp");
|
|
const init = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
|
const initData = (await init.json()) as { result: { serverInfo: { name: string }; protocolVersion: string } };
|
|
expect(initData.result.serverInfo.name).toBe("wmailhub");
|
|
expect(initData.result.protocolVersion).toBe("2025-06-18");
|
|
|
|
const tools = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } };
|
|
expect(toolsData.result.tools.map((x) => x.name)).toEqual([
|
|
"list_mailboxes",
|
|
"list_emails",
|
|
"get_email",
|
|
"create_mailbox",
|
|
"delete_mailbox",
|
|
]);
|
|
});
|
|
|
|
test("tools/call:create_mailbox -> list_mailboxes -> get_email -> delete_mailbox", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const mcpKey = await createKey(t, cookie, "mcp");
|
|
const email = await ingestEmail(t.env, {
|
|
raw: new TextEncoder().encode(TEST_MAIL),
|
|
mailFrom: "a@b.c",
|
|
rcptTo: "hello@example.test",
|
|
nodeId: t.nodeId,
|
|
});
|
|
|
|
const created = await mcpCall(t, mcpKey, {
|
|
jsonrpc: "2.0",
|
|
id: 3,
|
|
method: "tools/call",
|
|
params: { name: "create_mailbox", arguments: { domain_id: t.domainId, address: "mcptest" } },
|
|
});
|
|
const createdData = (await created.json()) as { result: { content: { text: string }[] } };
|
|
expect(JSON.parse(createdData.result.content[0].text).address).toBe("mcptest@example.test");
|
|
|
|
const list = await mcpCall(t, mcpKey, {
|
|
jsonrpc: "2.0",
|
|
id: 4,
|
|
method: "tools/call",
|
|
params: { name: "list_mailboxes", arguments: {} },
|
|
});
|
|
const listData = (await list.json()) as { result: { content: { text: string }[] } };
|
|
const mailboxes = JSON.parse(listData.result.content[0].text) as {
|
|
mailboxes: { id: string; address: string }[];
|
|
};
|
|
expect(mailboxes.mailboxes.map((m) => m.address)).toContain("mcptest@example.test");
|
|
|
|
const got = await mcpCall(t, mcpKey, {
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
method: "tools/call",
|
|
params: { name: "get_email", arguments: { id: email.id } },
|
|
});
|
|
const gotData = (await got.json()) as { result: { content: { text: string }[] } };
|
|
const gotEmail = JSON.parse(gotData.result.content[0].text) as { email: { subject: string } };
|
|
expect(gotEmail.email.subject).toBe("Test Mail");
|
|
|
|
const target = mailboxes.mailboxes.find((m) => m.address === "mcptest@example.test")!.id;
|
|
const del = await mcpCall(t, mcpKey, {
|
|
jsonrpc: "2.0",
|
|
id: 6,
|
|
method: "tools/call",
|
|
params: { name: "delete_mailbox", arguments: { id: target } },
|
|
});
|
|
const delData = (await del.json()) as { result: { content: { text: string }[] } };
|
|
expect(JSON.parse(delData.result.content[0].text).ok).toBe(true);
|
|
});
|
|
|
|
test("未知方法返回 -32601;GET 返回 405", async () => {
|
|
const t = await makeTestEnv();
|
|
const cookie = await loginCookie(t);
|
|
const mcpKey = await createKey(t, cookie, "mcp");
|
|
const bad = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 9, method: "no/such/method" });
|
|
const badData = (await bad.json()) as { error: { code: number } };
|
|
expect(badData.error.code).toBe(-32601);
|
|
const get = await t.app.fetch(
|
|
new Request("http://localhost/api/v1/mcp", {
|
|
headers: { Authorization: `Bearer ${mcpKey}` },
|
|
}),
|
|
t.env,
|
|
);
|
|
expect(get.status).toBe(405);
|
|
});
|
|
});
|