255 lines
14 KiB
TypeScript
255 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
import type { AppConfig } from "../src/config.js";
|
|
import type { ApprovalService } from "../src/approval-service.js";
|
|
import type { XftApprovalProvider } from "../src/xft.js";
|
|
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js";
|
|
|
|
function config(): AppConfig {
|
|
return {
|
|
clientId: "client",
|
|
clientSecret: "secret",
|
|
apiBaseUrl: "https://api.test",
|
|
oapiBaseUrl: "https://oapi.test",
|
|
publicBaseUrl: "http://localhost:3000",
|
|
trustedLocalUser: { userId: "user-1" },
|
|
port: 3000,
|
|
requestTimeoutMs: 1_000,
|
|
officialLookbackDays: 120,
|
|
officialScanConcurrency: 2,
|
|
officialClientOnlyProcessCodes: [],
|
|
batchLimit: 20,
|
|
approvalRefSecret: "fixed-reference-secret-for-tests",
|
|
};
|
|
}
|
|
|
|
test("MCP initialization teaches the workflow while tools/list stays compact", async () => {
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const server = createApprovalMcpServer(config(), {} as ApprovalService);
|
|
const client = new Client({ name: "instructions-test", version: "1.0.0" });
|
|
await server.connect(serverTransport);
|
|
try {
|
|
await client.connect(clientTransport);
|
|
assert.equal(client.getInstructions(), APPROVAL_MCP_INSTRUCTIONS);
|
|
assert.match(client.getInstructions() ?? "", /approval_list_todos.*approval_get_detail/s);
|
|
assert.match(client.getInstructions() ?? "", /approval_list_history.*只读/s);
|
|
assert.match(client.getInstructions() ?? "", /approval_process_batch/);
|
|
assert.match(client.getInstructions() ?? "", /completed.*followUpRequired/);
|
|
assert.match(client.getInstructions() ?? "", /不会通过其他消息通道.*directUrl.*当前面向用户的回复/s);
|
|
assert.doesNotMatch(client.getInstructions() ?? "", /通过当前 Messenger/);
|
|
assert.match(client.getInstructions() ?? "", /必填字段缺失.*继续询问/s);
|
|
assert.match(client.getInstructions() ?? "", /非必填字段.*是否补充/s);
|
|
assert.match(client.getInstructions() ?? "", /出差申请.*外出.*业务招待申请.*差旅费用报销.*业务招待费用报销/s);
|
|
assert.match(client.getInstructions() ?? "", /revokeId.*approval_revoke_submission/);
|
|
assert.match(client.getInstructions() ?? "", /单次条数限制.*内部调用细节.*绝不向用户.*一键全选.*如何分组/s);
|
|
assert.match(client.getInstructions() ?? "", /用户只是在查看.*业务信息.*不主动讨论.*自动分批.*不得造成重复确认/s);
|
|
assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s);
|
|
assert.match(client.getInstructions() ?? "", /草稿只能由 Agent 保存在.*当前 OS 用户私有数据目录/s);
|
|
assert.match(client.getInstructions() ?? "", /schemaVersion=1.*dingtalk-approval-local-draft.*submit_uncertain/s);
|
|
assert.match(client.getInstructions() ?? "", /不得上传网关.*不得.*完整上游 Schema/s);
|
|
assert.match(client.getInstructions() ?? "", /恢复时.*实时读取 Schema/s);
|
|
assert.match(client.getInstructions() ?? "", /自动清理超过 90 天/s);
|
|
assert.match(client.getInstructions() ?? "", /outcome_uncertain.*冻结.*禁止自动重试/s);
|
|
|
|
const tools = await client.listTools();
|
|
assert.deepEqual(tools.tools.map((tool) => tool.name), [
|
|
"approval_list_todos",
|
|
"approval_list_history",
|
|
"approval_get_detail",
|
|
"approval_get_actions",
|
|
"approval_process_batch",
|
|
"approval_list_submission_templates",
|
|
"approval_get_submission_schema",
|
|
"approval_get_submission_options",
|
|
"approval_prepare_submission",
|
|
"approval_submit",
|
|
"approval_revoke_submission",
|
|
"approval_get_submission_status",
|
|
]);
|
|
const descriptions = Object.fromEntries(tools.tools.map((tool) => [tool.name, tool.description ?? ""]));
|
|
assert.match(descriptions.approval_list_todos ?? "", /入口.*approvalId/);
|
|
assert.match(descriptions.approval_list_history ?? "", /handled.*处理过.*submitted.*本人发起.*只读/s);
|
|
assert.match(descriptions.approval_get_detail ?? "", /expenseItems.*raw/);
|
|
assert.match(descriptions.approval_process_batch ?? "", /outcome_uncertain|禁止自动重试/);
|
|
assert.match(descriptions.approval_get_actions ?? "", /targetId.*approval_process_batch/);
|
|
const historySchema = tools.tools.find((tool) => tool.name === "approval_list_history")?.inputSchema as any;
|
|
assert.equal(historySchema.properties.startDate.default, "");
|
|
assert.equal(historySchema.properties.endDate.default, "");
|
|
assert.match(historySchema.properties.startDate.description, /最近30天/);
|
|
assert.match(descriptions.approval_process_batch ?? "", /用户明确.*不二次确认/);
|
|
assert.match(descriptions.approval_process_batch ?? "", /succeeded.*completed.*followUpRequired/);
|
|
assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
|
|
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
|
|
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
|
|
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市编码/);
|
|
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
|
|
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
|
|
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
|
|
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
|
|
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
|
|
assert.match(descriptions.approval_submit ?? "", /禁止自动重试/);
|
|
assert.match(descriptions.approval_revoke_submission ?? "", /禁止自动重试.*薪福通.*实验能力/);
|
|
assert.match(descriptions.approval_get_submission_status ?? "", /本人.*归属/);
|
|
const batchTool = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
|
|
const itemSchema = (batchTool.inputSchema as any).properties.items;
|
|
assert.equal(itemSchema.maxItems, 20);
|
|
const tooMany = await client.callTool({
|
|
name: "approval_process_batch",
|
|
arguments: {
|
|
action: "approve",
|
|
remark: "",
|
|
items: Array.from({ length: 21 }, () => ({ approvalId: "v4.this-reference-is-long-enough" })),
|
|
},
|
|
});
|
|
assert.equal(tooMany.isError, true);
|
|
assert.match(JSON.stringify(tooMany.content), /一次最多处理 20 条审批/);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("local-only server recognizes loopback addresses", () => {
|
|
assert.equal(isLoopbackAddress("127.0.0.1"), true);
|
|
assert.equal(isLoopbackAddress("::1"), true);
|
|
assert.equal(isLoopbackAddress("::ffff:127.0.0.1"), true);
|
|
assert.equal(isLoopbackAddress("192.168.1.13"), false);
|
|
assert.equal(isLoopbackAddress(undefined), false);
|
|
});
|
|
|
|
test("HTTP MCP accepts the configured public host without dropping loopback health checks", () => {
|
|
assert.deepEqual(mcpAllowedHosts("https://approval.justconn.top"), [
|
|
"approval.justconn.top", "127.0.0.1", "localhost", "[::1]",
|
|
]);
|
|
});
|
|
|
|
test("OAuth consent CSP permits only the DingTalk login redirect after same-origin submit", () => {
|
|
const policy = oauthHtmlContentSecurityPolicy();
|
|
assert.match(policy, /form-action 'self' https:\/\/login\.dingtalk\.com(?:;|\s)/);
|
|
assert.doesNotMatch(policy, /form-action[^;]*\*/);
|
|
});
|
|
|
|
test("remote gateway lists DingTalk OA and XFT through one authenticated MCP session", async () => {
|
|
const dingTalk = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "official_oa", processInstanceId: "oa-1", taskId: "task-1", title: "采购审批", raw: {} }] };
|
|
},
|
|
} as unknown as ApprovalService;
|
|
const xft = {
|
|
async listMyTodos() {
|
|
return { items: [{ source: "xft", billId: "xft-1", title: "费用报销" }], totalCount: 1, identity: {}, diagnostics: {} };
|
|
},
|
|
} as unknown as XftApprovalProvider;
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const server = createApprovalMcpServer(
|
|
{ ...config(), appMode: "gateway", trustedLocalUser: undefined },
|
|
dingTalk, xft, undefined, undefined, { userId: "user-1" },
|
|
);
|
|
const client = new Client({ name: "unified-gateway-test", version: "1.0.0" });
|
|
await server.connect(serverTransport);
|
|
try {
|
|
await client.connect(clientTransport);
|
|
const response = await client.callTool({ name: "approval_list_todos", arguments: { source: "all", query: "", limit: 20 } });
|
|
const result = (response.structuredContent as { result: { items: Array<{ source: string; approvalId: string }>; selectionId: string } }).result;
|
|
assert.deepEqual(result.items.map((item) => item.source).sort(), ["dingtalk", "xft"]);
|
|
const blocks = response.content as Array<{ type: string; text?: string }>;
|
|
const text = blocks.find((item) => item.type === "text")?.text ?? "";
|
|
assert.match(text, /采购审批/);
|
|
assert.match(text, /费用报销/);
|
|
assert.ok(result.items.every((item) => text.includes(item.approvalId)));
|
|
assert.ok(text.includes(result.selectionId));
|
|
assert.doesNotMatch(text, /diagnostics|enterpriseUserId|processInstanceId|taskId|userId/);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("batch summaries distinguish total failure, partial success, and full success", () => {
|
|
assert.match(approvalBatchSummary({ total: 2, succeeded: 0 }), /未有任何.*成功/);
|
|
assert.match(approvalBatchSummary({ total: 2, succeeded: 1 }), /部分成功.*1\/2/);
|
|
assert.match(approvalBatchSummary({ total: 2, succeeded: 2 }), /全部提交成功.*2\/2/);
|
|
assert.match(approvalBatchSummary({ total: 2, succeeded: 0, uncertain: 1 }), /禁止自动重试/);
|
|
assert.match(approvalBatchSummary({ status: "preflight_failed", writesStarted: false, invalidItems: [{}] }), /未开始.*0 张已写入.*禁止自动重试/);
|
|
});
|
|
|
|
test("portable tool output exposes compact domain data without upstream internals", () => {
|
|
const response = compactResult("已完成。", {
|
|
action: "approve",
|
|
total: 1,
|
|
results: [{
|
|
approvalId: "v3.portable.reference.value",
|
|
processInstanceId: "internal-instance",
|
|
taskId: "internal-task",
|
|
userId: "internal-user",
|
|
status: "processed",
|
|
success: true,
|
|
response: { traceId: "upstream-trace" },
|
|
}],
|
|
diagnostics: { realtimeValidation: true },
|
|
});
|
|
const text = (response.content[0] as { type: "text"; text: string }).text;
|
|
assert.match(text, /v3\.portable\.reference\.value/);
|
|
assert.match(text, /"status":"processed"/);
|
|
assert.doesNotMatch(text, /internal-instance|internal-task|internal-user|upstream-trace|diagnostics/);
|
|
assert.deepEqual(response.structuredContent.result, JSON.parse(text.split("\n")[1]!));
|
|
});
|
|
|
|
test("QR auth challenges instruct the agent to reply directly instead of claiming a separate push", () => {
|
|
const response = compactResult("需要重新登录。", {
|
|
sourceStatus: {
|
|
xft: {
|
|
status: "auth_required",
|
|
authChallenge: {
|
|
provider: "xft", type: "qr", challengeId: "qr-1",
|
|
directUrl: "https://approval.example.test/xft/login",
|
|
},
|
|
},
|
|
},
|
|
}, {
|
|
async getOrStart() {
|
|
return {
|
|
provider: "xft" as const, type: "qr" as const, challengeId: "qr-1",
|
|
qrImageUrl: "https://approval.example.test/xft/qr",
|
|
directUrl: "https://approval.example.test/xft/login",
|
|
expiresAt: new Date(Date.now() + 60_000).toISOString(), pollAfterSeconds: 3,
|
|
};
|
|
},
|
|
getImage: () => Buffer.from("png"),
|
|
});
|
|
const text = (response.content[0] as { type: "text"; text: string }).text;
|
|
assert.match(text, /不会另行推送登录内容/);
|
|
assert.match(text, /原样写入当前回复/);
|
|
assert.doesNotMatch(text, /通过当前 Messenger/);
|
|
assert.equal(response.content[1]?.type, "image");
|
|
});
|
|
|
|
test("portable presentation preserves submission fields and opaque write references", () => {
|
|
const response = compactResult("审批尚未提交。", {
|
|
templateId: "v3.template.reference.value",
|
|
fields: [{ fieldNumber: "trip_reason", name: "出差事由", required: true, valueFormat: "文本字符串" }],
|
|
inputContract: { values: { required: ["businessTrip"] } },
|
|
confirmationId: "v3.confirmation.reference.value",
|
|
preview: {
|
|
template: { name: "出差申请", processCode: "internal-process-code" },
|
|
formSummary: { businessTrip: { reason: "客户拜访" } },
|
|
identityValidation: { validated: true, employeeNumber: "internal-employee" },
|
|
},
|
|
revokeId: "v3.revoke.reference.value",
|
|
billId: "2026081300000001",
|
|
diagnostics: { persistedBusinessData: false },
|
|
});
|
|
const visible = response.structuredContent.result as Record<string, any>;
|
|
assert.equal(visible.templateId, "v3.template.reference.value");
|
|
assert.equal(visible.confirmationId, "v3.confirmation.reference.value");
|
|
assert.equal(visible.revokeId, "v3.revoke.reference.value");
|
|
assert.equal(visible.businessId, "2026081300000001");
|
|
assert.equal(visible.fields[0].fieldNumber, "trip_reason");
|
|
assert.equal(visible.preview.formSummary.businessTrip.reason, "客户拜访");
|
|
assert.equal(visible.preview.template.processCode, undefined);
|
|
assert.equal(visible.preview.identityValidation.employeeNumber, undefined);
|
|
assert.equal(visible.diagnostics, undefined);
|
|
});
|