Files
registry/test/mcp.test.js
T
jif2.zhang dc4c67e386
build-and-deploy / build-and-deploy (push) Successful in 43s
feat: 访问记录后台分页,支持时间范围/操作/键过滤
2026-09-21 10:49:55 +08:00

228 lines
9.0 KiB
JavaScript

/**
* MCP 工具单元测试
* 覆盖:工具注册完整性、删除两步式确认、只读模式下拒绝写操作
* 运行:npm test
*/
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createMcpServer } = require("../src/mcp/tools");
const { init, loadSystem, saveSystem, loadAuditLogs } = require("../src/persistence/duckdbAdapter");
// 使用临时数据目录,避免污染真实 data/
const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-mcp-test-"));
process.env.REGISTRY_DATA_DIR = tmpDataDir;
let server;
let originalMode = "edit";
// 初始化 DuckDB,记录测试前模式;测试结束后恢复原模式
test.before(async () => {
await init();
originalMode = (await loadSystem() || {}).mode || "edit";
server = createMcpServer();
});
test.after(async () => {
const system = await loadSystem() || {};
system.mode = originalMode;
await saveSystem(system);
});
/**
* 直接调用已注册工具的处理函数
* @param {string} name - 工具名
* @param {Object} args - 工具参数
* @param {Object} [srv] - MCP 服务器实例(默认使用全局 server)
* @returns {Promise<Object>} 工具结果
*/
async function callTool(name, args, srv) {
const target = srv || server;
const tool = target._registeredTools[name];
assert.ok(tool, `工具不存在: ${name}`);
try {
const result = await tool.handler(args, {});
return {
text: (result.content || []).map((c) => c.text).join("\n"),
structuredContent: result.structuredContent,
isError: result.isError === true,
};
} catch (err) {
// 直接调用底层 handler 时,抛错不会自动转换为 isError 结果,这里统一转换
return {
text: err && err.message ? err.message : String(err),
structuredContent: { error: true },
isError: true,
};
}
}
test("注册了全部项目/配置 CRUD 及确认删除工具", () => {
const names = new Set(Object.keys(server._registeredTools));
for (const name of [
"get_mode", "set_mode",
"list_projects", "get_project", "create_project", "update_project_mappings",
"delete_project", "confirm_delete_project",
"list_configs", "get_config", "create_config", "update_config",
"delete_config", "confirm_delete_config",
]) {
assert.ok(names.has(name), `缺少工具: ${name}`);
}
});
test("删除项目为两步式:先登记待确认,确认后才真正删除", async () => {
const { text: createText } = await callTool("create_project", {
name: "MCP-Unittest-Proj",
});
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
const pid = project.id;
const del = await callTool("delete_project", { projectId: pid });
assert.strictEqual(del.structuredContent.status, "awaiting_confirmation");
assert.ok(del.structuredContent.confirmation_token);
// 未确认前项目仍然存在
const stillThere = await callTool("get_project", { projectId: pid });
assert.ok(!stillThere.isError);
// 确认后项目被删除
const confirm = await callTool("confirm_delete_project", {
confirmation_token: del.structuredContent.confirmation_token,
});
assert.strictEqual(confirm.structuredContent.status, "deleted");
const gone = await callTool("get_project", { projectId: pid });
assert.ok(gone.isError, "确认删除后项目应不存在");
});
test("重复使用确认令牌会失败(一次性令牌)", async () => {
const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Token" });
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
const del = await callTool("delete_project", { projectId: project.id });
const tok = del.structuredContent.confirmation_token;
await callTool("confirm_delete_project", { confirmation_token: tok });
const second = await callTool("confirm_delete_project", { confirmation_token: tok });
assert.ok(second.isError);
assert.strictEqual(second.structuredContent.status, "invalid_or_expired");
});
test("readonly 模式下拒绝项目/配置写操作,切换回 edit 后恢复", async () => {
await callTool("set_mode", { mode: "readonly" });
const createProject = await callTool("create_project", { name: "不应创建" });
assert.ok(createProject.isError);
assert.match(createProject.text, /只读模式/);
const delProject = await callTool("delete_project", { projectId: "proj-mrlip49o-qsr2yx" });
assert.ok(delProject.isError);
assert.match(delProject.text, /只读模式/);
const createConfig = await callTool("create_config", {
projectId: "proj-mrlip49o-qsr2yx",
key: "x",
description: "x",
});
assert.ok(createConfig.isError);
assert.match(createConfig.text, /只读模式/);
await callTool("set_mode", { mode: "edit" });
});
test("edit 模式下可以修改配置说明", async () => {
await callTool("set_mode", { mode: "edit" });
const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Desc" });
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
const cfg = await callTool("create_config", {
projectId: project.id,
key: "db/host",
description: "原始说明",
value: "localhost",
});
const cfgObj = JSON.parse(cfg.text.match(/\{[\s\S]*\}/)[0]);
const updated = await callTool("update_config", {
projectId: project.id,
configId: cfgObj.id,
description: "新的说明",
});
assert.ok(!updated.isError, "edit 模式下应允许修改说明");
const updatedObj = JSON.parse(updated.text.match(/\{[\s\S]*\}/)[0]);
assert.strictEqual(updatedObj.description, "新的说明");
assert.strictEqual(updatedObj.value, "localhost", "仅改说明,值不应变化");
// 清理测试项目
const delProject = await callTool("delete_project", { projectId: project.id });
const confirmProject = await callTool("confirm_delete_project", {
confirmation_token: delProject.structuredContent.confirmation_token,
});
assert.strictEqual(confirmProject.structuredContent.status, "deleted");
});
test("Token 项目范围限制 MCP 配置访问,越权操作被拒绝且不入记录", async () => {
// 先创建真实项目,再构造仅允许该项目访问的 Token 会话
const projText = (await callTool("create_project", { name: "MCP-Scope-Proj" })).text;
const project = JSON.parse(projText.match(/\{[\s\S]*\}/)[0]);
const scoped = createMcpServer({ token: { name: "scope-token", projectIds: [project.id] }, ip: "10.1.2.3" });
// 无权访问的项目:读取/写入被拒绝
const deniedRead = await callTool("list_configs", { projectId: "other-proj" }, scoped);
assert.ok(deniedRead.isError);
assert.match(deniedRead.text, /无权访问/);
const deniedWrite = await callTool("create_config", { projectId: "other-proj", key: "x", description: "x" }, scoped);
assert.ok(deniedWrite.isError);
assert.match(deniedWrite.text, /无权访问/);
// 有权限:可读可写,并留下审计记录
const createdReal = await callTool("create_config", { projectId: project.id, key: "sql/dsn", description: "d", value: "v1" }, scoped);
assert.ok(!createdReal.isError);
const logsReal = await loadAuditLogs({ projectId: project.id, key: "sql/dsn" });
const createLog = logsReal.items.find((l) => l.action === "create");
assert.ok(createLog, "应在 audit_logs 中存在 create 记录");
assert.strictEqual(createLog.source, "mcp");
assert.strictEqual(createLog.actor, "scope-token");
assert.strictEqual(createLog.ip, "10.1.2.3");
assert.strictEqual(createLog.value, "v1");
// 越权操作不产生记录(other-proj 无任何记录)
const deniedLogs = await loadAuditLogs({ projectId: "other-proj" });
assert.strictEqual(deniedLogs.items.length, 0);
// 清理测试项目
const delProject = await callTool("delete_project", { projectId: project.id });
await callTool("confirm_delete_project", { confirmation_token: delProject.structuredContent.confirmation_token });
});
test("配置删除同样为两步式确认", async () => {
const { text: createText } = await callTool("create_project", { name: "MCP-Unittest-Cfg" });
const project = JSON.parse(createText.match(/\{[\s\S]*\}/)[0]);
const cfg = await callTool("create_config", {
projectId: project.id,
key: "a/b",
description: "unit",
value: "1",
type: "number",
});
const cfgObj = JSON.parse(cfg.text.match(/\{[\s\S]*\}/)[0]);
const del = await callTool("delete_config", { projectId: project.id, configId: cfgObj.id });
assert.strictEqual(del.structuredContent.status, "awaiting_confirmation");
const confirm = await callTool("confirm_delete_config", {
confirmation_token: del.structuredContent.confirmation_token,
});
assert.strictEqual(confirm.structuredContent.status, "deleted");
// 清理测试项目
const delProject = await callTool("delete_project", { projectId: project.id });
const confirmProject = await callTool("confirm_delete_project", {
confirmation_token: delProject.structuredContent.confirmation_token,
});
assert.strictEqual(confirmProject.structuredContent.status, "deleted");
});