feat: Token 支持多项目关联且全部为 MCP 令牌;配置操作全量审计,支持按项目/键查询
build-and-deploy / build-and-deploy (push) Successful in 40s
build-and-deploy / build-and-deploy (push) Successful in 40s
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 操作记录(审计)单元测试
|
||||
* 覆盖:appendAuditLog/loadAuditLogs 读写与过滤、auditService.log 静默容错、getClientIp 支持 nginx 转发
|
||||
* 运行: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");
|
||||
|
||||
// 使用临时数据目录,避免污染真实 data/
|
||||
const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-audit-test-"));
|
||||
process.env.REGISTRY_DATA_DIR = tmpDataDir;
|
||||
|
||||
const duckdbAdapter = require("../src/persistence/duckdbAdapter");
|
||||
const { log: auditLog, getClientIp } = require("../src/services/auditService");
|
||||
|
||||
test.before(async () => {
|
||||
await duckdbAdapter.init();
|
||||
});
|
||||
|
||||
test("记录一条配置操作并可按项目/键查询", async () => {
|
||||
await auditLog({
|
||||
source: "web",
|
||||
actor: "admin",
|
||||
ip: "203.0.113.9",
|
||||
projectId: "proj-a",
|
||||
key: "db/host",
|
||||
action: "update",
|
||||
value: "db.internal",
|
||||
detail: { from: { key: "db/host", value: "old" }, to: { key: "db/host", value: "db.internal" } },
|
||||
});
|
||||
|
||||
const byProject = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a" });
|
||||
assert.strictEqual(byProject.length, 1);
|
||||
const byKey = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "db/host" });
|
||||
assert.strictEqual(byKey.length, 1);
|
||||
// 其它项目/键查不到
|
||||
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-b" })).length, 0);
|
||||
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "other" })).length, 0);
|
||||
|
||||
const rec = byKey[0];
|
||||
assert.strictEqual(rec.source, "web");
|
||||
assert.strictEqual(rec.actor, "admin");
|
||||
assert.strictEqual(rec.ip, "203.0.113.9");
|
||||
assert.strictEqual(rec.action, "update");
|
||||
assert.strictEqual(rec.value, "db.internal");
|
||||
assert.strictEqual(rec.detail.from.value, "old");
|
||||
assert.ok(rec.createdAt);
|
||||
});
|
||||
|
||||
test("记录按时间倒序返回,MCP 来源也正确记录 Token 名", async () => {
|
||||
await auditLog({ source: "mcp", actor: "ci-token", ip: "10.0.0.1", projectId: "proj-a", key: "k/1", action: "read", value: "x" });
|
||||
await auditLog({ source: "mcp", actor: "ci-token", ip: "10.0.0.1", projectId: "proj-a", key: "k/1", action: "create", value: "x" });
|
||||
|
||||
const logs = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "k/1" });
|
||||
assert.strictEqual(logs.length, 2);
|
||||
assert.ok(logs[0].createdAt >= logs[1].createdAt, "最新的应在前");
|
||||
assert.ok(logs.every((l) => l.source === "mcp" && l.actor === "ci-token"));
|
||||
});
|
||||
|
||||
test("getClientIp 优先取 X-Forwarded-For,其次 remoteAddress", () => {
|
||||
assert.strictEqual(getClientIp({ headers: { "x-forwarded-for": "203.0.113.50, 10.0.0.5" } }), "203.0.113.50");
|
||||
assert.strictEqual(getClientIp({ headers: {}, socket: { remoteAddress: "127.0.0.1" } }), "127.0.0.1");
|
||||
assert.strictEqual(getClientIp(null), "");
|
||||
});
|
||||
+42
-4
@@ -9,7 +9,7 @@ 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");
|
||||
const { init, loadSystem, saveSystem, loadAuditLogs } = require("../src/persistence/duckdbAdapter");
|
||||
|
||||
// 使用临时数据目录,避免污染真实 data/
|
||||
const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-mcp-test-"));
|
||||
@@ -35,10 +35,12 @@ test.after(async () => {
|
||||
* 直接调用已注册工具的处理函数
|
||||
* @param {string} name - 工具名
|
||||
* @param {Object} args - 工具参数
|
||||
* @param {Object} [srv] - MCP 服务器实例(默认使用全局 server)
|
||||
* @returns {Promise<Object>} 工具结果
|
||||
*/
|
||||
async function callTool(name, args) {
|
||||
const tool = server._registeredTools[name];
|
||||
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, {});
|
||||
@@ -153,13 +155,49 @@ test("edit 模式下可以修改配置说明", async () => {
|
||||
assert.strictEqual(updatedObj.value, "localhost", "仅改说明,值不应变化");
|
||||
|
||||
// 清理测试项目
|
||||
const delProject = await callTool("delete_project", { projectId: project.id });
|
||||
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.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.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]);
|
||||
|
||||
+56
-39
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Token 服务单元测试
|
||||
* 覆盖:mcpEnabled 标记、MCP token 列表过滤、历史 MCP 配置迁移
|
||||
* 覆盖:多项目关联、名称唯一、全部 Token 均为 MCP 令牌、历史 MCP 配置迁移
|
||||
* 运行:npm test
|
||||
*/
|
||||
const test = require("node:test");
|
||||
@@ -20,61 +20,78 @@ test.before(async () => {
|
||||
await init();
|
||||
});
|
||||
|
||||
test("createToken 保存 mcpEnabled 标记", async () => {
|
||||
const mcpToken = await tokenService.createToken({
|
||||
name: "mcp-key",
|
||||
projectId: "all",
|
||||
permission: "readwrite",
|
||||
mcpEnabled: true,
|
||||
expiresInDays: 30,
|
||||
test("创建 Token:支持多个关联项目与全部项目", async () => {
|
||||
const multi = await tokenService.createToken({
|
||||
name: "multi-proj",
|
||||
projectIds: ["proj-a", "proj-b"],
|
||||
expiresInDays: 0,
|
||||
});
|
||||
assert.strictEqual(mcpToken.mcpEnabled, true);
|
||||
const plainToken = await tokenService.createToken({
|
||||
name: "plain",
|
||||
mcpEnabled: false,
|
||||
});
|
||||
assert.strictEqual(plainToken.mcpEnabled, false);
|
||||
assert.deepStrictEqual(multi.projectIds, ["proj-a", "proj-b"]);
|
||||
assert.strictEqual(tokenService.coversProject(multi, "proj-a"), true);
|
||||
assert.strictEqual(tokenService.coversProject(multi, "proj-b"), true);
|
||||
assert.strictEqual(tokenService.coversProject(multi, "proj-c"), false);
|
||||
|
||||
const all = await tokenService.createToken({ name: "all-proj", projectIds: ["all"] });
|
||||
assert.deepStrictEqual(all.projectIds, ["all"]);
|
||||
assert.strictEqual(tokenService.coversProject(all, "任意项目"), true);
|
||||
|
||||
const legacyProjectId = await tokenService.createToken({ name: "legacy-field", projectId: "proj-x" });
|
||||
assert.deepStrictEqual(legacyProjectId.projectIds, ["proj-x"]);
|
||||
});
|
||||
|
||||
test("getMcpTokenValues 仅返回生效的 MCP token 明文", async () => {
|
||||
const values = await tokenService.getMcpTokenValues();
|
||||
assert.ok(values.length >= 1);
|
||||
// 明文值以 reg_ 开头
|
||||
assert.ok(values.every((v) => typeof v === "string" && v.startsWith("reg_")));
|
||||
test("Token 名称唯一:重复名称创建与更新均报错", async () => {
|
||||
await tokenService.createToken({ name: "dup-name", projectIds: ["all"] });
|
||||
await assert.rejects(
|
||||
() => tokenService.createToken({ name: "dup-name", projectIds: ["all"] }),
|
||||
/名称已存在/
|
||||
);
|
||||
|
||||
// 更新为已存在的名称也报错
|
||||
const other = await tokenService.createToken({ name: "dup-other", projectIds: ["all"] });
|
||||
await assert.rejects(
|
||||
() => tokenService.updateToken(other.id, { name: "dup-name" }),
|
||||
/名称已存在/
|
||||
);
|
||||
|
||||
// 更新自身名称不改动、且可改成唯一名
|
||||
await tokenService.updateToken(other.id, { name: "dup-other" });
|
||||
await tokenService.updateToken(other.id, { name: "dup-renamed" });
|
||||
assert.strictEqual((await tokenService.findById(other.id)).name, "dup-renamed");
|
||||
});
|
||||
|
||||
test("updateToken 可切换 mcpEnabled", async () => {
|
||||
const plain = await tokenService.createToken({ name: "toggle", mcpEnabled: false });
|
||||
const before = await tokenService.getMcpTokenValues();
|
||||
assert.ok(!before.includes(plain.value));
|
||||
await tokenService.updateToken(plain.id, { mcpEnabled: true });
|
||||
const after = await tokenService.getMcpTokenValues();
|
||||
assert.ok(after.includes(plain.value));
|
||||
test("所有 Token 均为 MCP 令牌:全部生效 Token 明文可获取,通过 resolveActiveToken 鉴权", async () => {
|
||||
const t = await tokenService.createToken({ name: "mcp-all", projectIds: ["all"] });
|
||||
const values = await tokenService.getActiveTokenValues();
|
||||
assert.ok(values.includes(t.value), "新 Token 应直接生效于 MCP");
|
||||
|
||||
const resolved = await tokenService.resolveActiveToken(t.value);
|
||||
assert.strictEqual(resolved.name, "mcp-all");
|
||||
assert.strictEqual(await tokenService.resolveActiveToken("bad-token"), null);
|
||||
});
|
||||
|
||||
test("吊销或过期后 MCP token 不再生效", async () => {
|
||||
const t = await tokenService.createToken({ name: "will-revoke", mcpEnabled: true, expiresInDays: 30 });
|
||||
const ok = await tokenService.getMcpTokenValues();
|
||||
assert.ok(ok.includes(t.value));
|
||||
test("吊销或过期后 Token 不再生效", async () => {
|
||||
const t = await tokenService.createToken({ name: "will-revoke", projectIds: ["all"], expiresInDays: 30 });
|
||||
assert.ok((await tokenService.getActiveTokenValues()).includes(t.value));
|
||||
await tokenService.revokeToken(t.id);
|
||||
const revoked = await tokenService.getMcpTokenValues();
|
||||
assert.ok(!revoked.includes(t.value));
|
||||
assert.ok(!(await tokenService.getActiveTokenValues()).includes(t.value));
|
||||
assert.strictEqual(await tokenService.resolveActiveToken(t.value), null);
|
||||
});
|
||||
|
||||
test("migrateLegacyMcpToken 把历史 mcp_kv 配置迁移为 Token 记录", async () => {
|
||||
// 清理当前 token 表,模拟空 MCP 标记状态
|
||||
const { loadApiTokens, saveApiTokens, saveMcpConfig, loadMcpConfig } = require("../src/persistence/duckdbAdapter");
|
||||
await saveApiTokens([]);
|
||||
const existing = await loadApiTokens();
|
||||
await saveMcpConfig({ enabled: true, token: "legacy-mcp-token-abc" });
|
||||
await tokenService.migrateLegacyMcpToken();
|
||||
|
||||
const tokens = await loadApiTokens();
|
||||
const migrated = tokens.find((t) => t.mcpEnabled);
|
||||
assert.ok(migrated, "应生成一条 mcpEnabled 的 Token");
|
||||
assert.strictEqual(migrated.value, "legacy-mcp-token-abc");
|
||||
const values = await tokenService.getMcpTokenValues();
|
||||
assert.ok(values.includes("legacy-mcp-token-abc"));
|
||||
const migrated = tokens.find((t) => t.value === "legacy-mcp-token-abc");
|
||||
assert.ok(migrated, "应生成一条包含旧 token 的记录");
|
||||
assert.strictEqual(migrated.projectIds[0], "all");
|
||||
assert.ok((await tokenService.getActiveTokenValues()).includes("legacy-mcp-token-abc"));
|
||||
// 旧配置已清空
|
||||
const mcp = await loadMcpConfig();
|
||||
assert.strictEqual(mcp.token, "");
|
||||
|
||||
// 清掉迁移产生的记录,避免影响后续测试
|
||||
await saveApiTokens(existing);
|
||||
});
|
||||
Reference in New Issue
Block a user