feat: v2.1.0 界面与凭据改进 —— 单一 API/MCP Key、邮件 Markdown 正文、/inboxs 域名筛选、暗色主题
- 单一 Key:API 与 MCP 共用一个 wmh_ Key,凭据迁移至 settings 表(0004 删除 api_credentials) - Markdown:入库时 HTML→Markdown(mailhook 同思路),webhook payload 新增 markdown 字段 - /emails 路由改为 /inboxs 并支持域名筛选;设置按钮移入头像下拉 - 主题切换按钮(头像左侧,亮/暗持久化);设置模态仅 全局 Webhook/账号密码 两 tab 且高度限屏 - 列表操作列按钮加 SVG 图标;mailboxes 去 Webhook 列;最近邮件加收件人列
This commit is contained in:
+51
-54
@@ -1,44 +1,42 @@
|
||||
/** API v1 / MCP 测试:Key 隔离、过期邮件 410、MCP 协议与工具 */
|
||||
/** API v1 / MCP 测试:单一 Key(共用)、过期邮件 410、MCP 协议与工具(v2.1.0) */
|
||||
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 并返回明文
|
||||
* 创建(轮换)单一 API/MCP 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 }),
|
||||
});
|
||||
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string) {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "POST" });
|
||||
expect(resp.status).toBe(201);
|
||||
return ((await resp.json()) as { key: string }).key;
|
||||
}
|
||||
|
||||
describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
test("api key 可读资源;mcp key 访问 v1 被 403(kind 隔离)", async () => {
|
||||
test("单一 Key 同时可访问 API 与 MCP(不再区分 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 key = await createKey(t, cookie);
|
||||
|
||||
const ok = await t.app.fetch(
|
||||
const v1 = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
const data = (await ok.json()) as { items: { address: string }[] };
|
||||
expect(v1.status).toBe(200);
|
||||
const data = (await v1.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}` },
|
||||
const mcp = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(bad.status).toBe(403);
|
||||
expect(mcp.status).toBe(200);
|
||||
});
|
||||
|
||||
test("无 key / 伪造 key / 已撤销 key 均被拒", async () => {
|
||||
@@ -55,30 +53,37 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
);
|
||||
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 key = await createKey(t, cookie);
|
||||
// 撤销(删除 settings 中的 Key)
|
||||
const del = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "DELETE" });
|
||||
expect(del.status).toBe(200);
|
||||
const revoked = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(revoked.status).toBe(401);
|
||||
|
||||
// 重新生成后旧 Key 依旧失效(轮换语义)
|
||||
await createKey(t, cookie);
|
||||
const rotated = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(rotated.status).toBe(401);
|
||||
});
|
||||
|
||||
test("v1 可创建与删除 mailbox", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const key = await createKey(t, cookie);
|
||||
const create = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain_id: t.domainId, address: "newbox" }),
|
||||
}),
|
||||
t.env,
|
||||
@@ -89,7 +94,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
const del = await t.app.fetch(
|
||||
new Request(`http://localhost/api/v1/mailboxes/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
@@ -99,7 +104,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
test("过期邮件:v1 详情返回 410,列表不出现", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const key = await createKey(t, cookie);
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
@@ -111,14 +116,14 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
.run();
|
||||
const detail = await t.app.fetch(
|
||||
new Request(`http://localhost/api/v1/emails/${email.id}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(detail.status).toBe(410);
|
||||
const list = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/emails", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
@@ -128,35 +133,27 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
});
|
||||
|
||||
describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, mcpKey: string, body: unknown) {
|
||||
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, key: string, body: unknown) {
|
||||
return t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${mcpKey}`, "Content-Type": "application/json" },
|
||||
headers: { Authorization: `Bearer ${key}`, "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 key = await createKey(t, cookie);
|
||||
const init = await mcpCall(t, key, { 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 tools = await mcpCall(t, key, { 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",
|
||||
@@ -170,7 +167,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
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 key = await createKey(t, cookie);
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
@@ -178,7 +175,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
accountId: t.accountId,
|
||||
});
|
||||
|
||||
const created = await mcpCall(t, mcpKey, {
|
||||
const created = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
@@ -187,7 +184,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
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, {
|
||||
const list = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
method: "tools/call",
|
||||
@@ -199,7 +196,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
};
|
||||
expect(mailboxes.mailboxes.map((m) => m.address)).toContain("mcptest@example.test");
|
||||
|
||||
const got = await mcpCall(t, mcpKey, {
|
||||
const got = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "tools/call",
|
||||
@@ -210,7 +207,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
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, {
|
||||
const del = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
method: "tools/call",
|
||||
@@ -223,13 +220,13 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
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 key = await createKey(t, cookie);
|
||||
const bad = await mcpCall(t, key, { 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}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user