build-and-deploy / build-and-deploy (push) Successful in 37s
- 新增 src/persistence/duckdbAdapter:与 jsonAdapter 同接口的异步适配器, 数据存于 data/registry.duckdb,首次启动自动从 JSON 迁移并备份 json-backup-<ts> - 全部服务层(project/config/user/token)与路由改为 async/await - MCP 工具与鉴权同步异步化,系统 mode 读写迁移至 DuckDB - 服务启动流程:init DB -> 自动迁移 -> 确保默认管理员 -> listen - 新增 DuckDB 往返/迁移测试,测试目录隔离 REGISTRY_DATA_DIR
158 lines
5.8 KiB
JavaScript
158 lines
5.8 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 } = 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 - 工具参数
|
|
* @returns {Promise<Object>} 工具结果
|
|
*/
|
|
async function callTool(name, args) {
|
|
const tool = server._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("配置删除同样为两步式确认", 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");
|
|
}); |