feat: initial public release
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-17 03:04:09 +00:00
commit 504145aec6
62 changed files with 18318 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { configureService, renderUnit, servicePaths, startService, uninstallService } from "../src/admin.js";
test("dingtalk-admin writes remote OAuth settings and secret-free hardened unit", () => {
const root = mkdtempSync(join(tmpdir(), "ding-admin-"));
const old = process.env.XDG_CONFIG_HOME;
process.env.XDG_CONFIG_HOME = join(root, "config");
try {
mkdirSync(join(root, ".secrets"), { mode: 0o700 });
writeFileSync(join(root, ".secrets", "dingtalk.json"), "{}", { mode: 0o600 });
writeFileSync(join(root, ".secrets", "xft.key"), "secret", { mode: 0o600 });
chmodSync(join(root, ".secrets", "dingtalk.json"), 0o600);
const paths = configureService({
projectRoot: root, publicBaseUrl: "https://approval.example.com", corpId: "ding-corp", port: 3000,
credentialsFile: ".secrets/dingtalk.json", xftOpenApiAppId: "app",
xftOpenApiSecretFile: ".secrets/xft.key", xftOpenApiEnterpriseId: "enterprise",
dingtalkClientOnlyProcessCodes: "PROC-SIGN,PROC-CLIENT",
});
assert.equal(statSync(paths.env).mode & 0o777, 0o600);
assert.equal(statSync(paths.refSecret).mode & 0o777, 0o600);
assert.equal(statSync(paths.sessionSecret).mode & 0o777, 0o600);
const runtime = readFileSync(paths.env, "utf8");
assert.match(runtime, /MCP_REMOTE_AUTH_ENABLED="true"/);
assert.match(runtime, /DINGTALK_CORP_ID="ding-corp"/);
assert.match(runtime, /MCP_AUTH_DATABASE=/);
assert.match(runtime, /APP_MODE="gateway"/);
assert.match(runtime, /DINGTALK_CLIENT_ONLY_PROCESS_CODES="PROC-SIGN,PROC-CLIENT"/);
assert.doesNotMatch(runtime, /DWS_/);
assert.doesNotMatch(runtime, /MCP_TRUSTED_LOCAL_USER_ID|CLIENT_SECRET/);
const unit = renderUnit(paths, "/usr/bin/node");
assert.match(unit, /ProtectSystem=strict/);
assert.doesNotMatch(unit, /ding-corp|CLIENT_SECRET/);
mkdirSync(join(root, "dist", "src"), { recursive: true });
writeFileSync(join(root, "dist", "src", "index.js"), "// built");
const calls: string[][] = [];
startService(root, (args) => calls.push(args));
assert.deepEqual(calls.map((call) => call[0]), ["daemon-reload", "enable", "restart"]);
uninstallService(root, (args) => calls.push(args));
assert.equal(existsSync(paths.env), false);
assert.equal(existsSync(paths.refSecret), true);
assert.equal(existsSync(paths.sessionSecret), true);
} finally {
if (old === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = old;
}
});
test("dingtalk-admin requires HTTPS and references XFT secret files", () => {
const root = mkdtempSync(join(tmpdir(), "ding-admin-openapi-"));
mkdirSync(join(root, ".secrets"), { mode: 0o700 });
writeFileSync(join(root, ".secrets", "dingtalk.json"), "{}", { mode: 0o600 });
writeFileSync(join(root, ".secrets", "xft.key"), "1".repeat(32), { mode: 0o600 });
assert.throws(() => configureService({
projectRoot: root, publicBaseUrl: "http://approval.example.com", corpId: "ding-corp", port: 3000,
credentialsFile: ".secrets/dingtalk.json",
}), /HTTPS/);
const paths = configureService({
projectRoot: root, publicBaseUrl: "https://approval.example.com", corpId: "ding-corp", port: 3000,
credentialsFile: ".secrets/dingtalk.json", xftOpenApiAppId: "open-app",
xftOpenApiSecretFile: ".secrets/xft.key", xftOpenApiEnterpriseId: "TEST0001",
});
const runtime = readFileSync(paths.env, "utf8");
assert.match(runtime, /XFT_OPEN_API_ENABLED="true"/);
assert.match(runtime, /XFT_OPEN_API_AUTHORITY_SECRET_FILE=/);
assert.doesNotMatch(runtime, new RegExp("1".repeat(32)));
});
+308
View File
@@ -0,0 +1,308 @@
import assert from "node:assert/strict";
import test from "node:test";
import { browseRawDetail, compactApprovalDetail, extractExpenseItems } from "../src/approval-detail.js";
test("extracts DingTalk TableField expense rows into stable agent fields", () => {
const raw = {
title: "测试用户甲提交的通用费用报销_北极大",
createTime: 1_786_179_600_000,
formComponentValues: [
{ name: "报销金额", value: "600" },
{
name: "报销明细",
componentType: "TableField",
value: JSON.stringify([{
rowValue: [
{ label: "费用发生日期", value: "2026-08-08" },
{ label: "费用类型", value: "员工团队保障经费/误餐费" },
{ label: "费用说明", value: "7月份误餐费" },
{ label: "实际报销金额", value: "¥600.00" },
{ label: "消费地点", value: "深圳" },
{ label: "接待对象", value: "张三、李四" },
{ label: "发票日期", value: "2026-06-19、2026-08-08" },
],
}]),
},
],
};
assert.deepEqual(extractExpenseItems(raw), [{
occurredOn: "2026-08-08",
expenseType: "员工团队保障经费/误餐费",
amount: 600,
currency: "CNY",
description: "7月份误餐费",
merchant: null,
location: "深圳",
customer: null,
contacts: ["张三", "李四"],
relatedTripId: null,
invoiceDates: ["2026-06-19", "2026-08-08"],
projectId: null,
crmRecordId: null,
}]);
const compact = compactApprovalDetail(raw, "summary");
assert.equal((compact.detail.expenseItems as unknown[]).length, 1);
assert.equal(JSON.stringify(compact).length < 20_000, true);
});
test("compact detail omits container placeholders and keeps invoice context in expense view", () => {
const raw = {
formComponentValues: [
{ name: "[]", value: [] },
{ name: "报销明细", value: [{ rowValue: [
{ label: "费用发生日期", value: "2026-08-10" },
{ label: "费用类型", value: "交通费" },
{ label: "实际报销金额", value: 88 },
] }] },
{ name: "发票号码", value: "INV-100" },
{ name: "付款主体", value: "深圳公司" },
],
};
const summary = compactApprovalDetail(raw, "summary");
assert.doesNotMatch(JSON.stringify(summary.detail.formFields), /\[0 items\]|\{\d+ fields\}|"\[\]"/);
const expense = compactApprovalDetail(raw, "expense");
assert.match(JSON.stringify(expense.detail.supportingFields), /发票号码|付款主体|INV-100|深圳公司/);
assert.match(JSON.stringify(expense.detail.formFields), /实际报销金额|88/);
assert.deepEqual(expense.detail.amount, { value: 88, currency: "CNY" });
assert.equal(expense.detail.amountSource, "expense_items_sum");
});
test("expense view safely sums multiple complete detail rows", () => {
const raw = { formComponentValues: [{ name: "报销明细", value: [{ rowValue: [
{ label: "费用类型", value: "交通费" }, { label: "实际报销金额", value: 100.11 },
] }, { rowValue: [
{ label: "费用类型", value: "住宿费" }, { label: "实际报销金额", value: 2504.38 },
] }] }] };
const expense = compactApprovalDetail(raw, "expense");
assert.deepEqual(expense.detail.amount, { value: 2604.49, currency: "CNY" });
assert.equal(expense.detail.amountSource, "expense_items_sum");
});
test("extracts expense fields from source-native XFT business data", () => {
const raw = {
form: {
values: { amount: 4812 },
businessData: {
expenseRows: [{
"费用发生日期": "2026/08/07",
"费用项目": "客户招待费",
"费用金额": 4812,
"消费地点": "上海",
"接待客户": "示例客户",
"项目编号": "P-001",
"CRM记录ID": "CRM-99",
}],
},
},
};
const items = extractExpenseItems(raw);
assert.equal(items.length, 1);
assert.equal(items[0]!.occurredOn, "2026-08-07");
assert.equal(items[0]!.expenseType, "客户招待费");
assert.equal(items[0]!.projectId, "P-001");
assert.equal(items[0]!.crmRecordId, "CRM-99");
});
test("epoch expense dates use Asia/Shanghai calendar boundaries", () => {
const items = extractExpenseItems({ form: { businessData: { expenseRows: [{
"费用发生日期": Date.parse("2026-08-13T16:30:00.000Z"),
"费用类型": "交通费",
"费用金额": 10,
}] } } });
assert.equal(items[0]?.occurredOn, "2026-08-14");
});
test("joins real XFT expense schema keys with values without exposing its schema", () => {
const raw = {
todo: { applicantName: "李冬梅", applicantDepartment: "奋进队", applyTime: "2026-08-08", amount: 820 },
form: {
values: {
TripReimburseExpense: {
value: {
expenseList: [{
values: {
TripExpReimAmount: { value: { currencyCode: "CNY", reimAmt: 820 } },
dateKey: { value: "2026-08-07" },
descriptionKey: { value: "客户招待" },
attachmentKey: { value: [{ fileNam: "secret.pdf" }] },
},
businessConfigDTO: { expenseSchema: { body: [
{ key: "TripExpReimAmount", name: "报销金额" },
{ key: "dateKey", name: "消费日期" },
{ key: "descriptionKey", name: "费用说明" },
{ key: "attachmentKey", name: "附件(支付记录、截图、小票)" },
] } },
businessDataDTO: { expBodyInsDto: {
expNam: "餐饮费",
wrTim: 1_786_032_000_000,
reimAmt: 820,
currencyCode: "CNY",
remark: "客户招待",
certificate: { invoiceCloudCertificates: [
{ invoiceDate: "2026-08-07" },
{ invoiceDate: "2026-08-06" },
] },
} },
}],
},
},
},
},
};
const compact = compactApprovalDetail(raw, "summary");
const items = compact.detail.expenseItems as Array<Record<string, unknown>>;
assert.equal(items.length, 1);
assert.equal(items[0]!.occurredOn, "2026-08-07");
assert.equal(items[0]!.expenseType, "餐饮费");
assert.equal(items[0]!.amount, 820);
assert.equal(items[0]!.description, "客户招待");
assert.deepEqual(items[0]!.invoiceDates, ["2026-08-07", "2026-08-06"]);
assert.equal((compact.detail.applicant as Record<string, unknown>).name, "李冬梅");
assert.doesNotMatch(JSON.stringify(compact), /secret\.pdf|expenseSchema|businessConfigDTO/);
});
test("extracts official XFT OpenAPI totals and expense fields", () => {
const raw = {
billId: "2099010200000001",
form: {
eventType: "COMMON_REIM",
eventName: "费用报销单",
applyTim: 1_786_083_489_000,
aplName: "李林峰",
aplDepName: "奋进队",
expTotalCnt: 2336,
billRemark: "客户招待",
billCustomFieldList: [
{ fieldName: "成本中心", showValue: "销售一部", type: "SELECT" },
{ fieldName: "附件(支付记录、截图、小票)", showValue: "secret.pdf", type: "ACCESSORIES" },
],
expenseList: [{
expTypeCode: "00",
expTypeName: "餐饮费",
expAttribute: "MEAL",
remark: "8月6日宴请联合电子张家万",
reimAmount: 2336,
wrTim: 1_785_974_400_000,
currencyCode: "CNY",
merchantName: null,
payCity: "98",
invoiceInfoList: [
{ invoiceDate: "2026-08-06", sellerName: "上海靓小余餐饮管理有限公司", invoiceAmount: 970 },
{ invoiceDate: "2026-08-05", sellerName: "另一商户", invoiceAmount: 1366 },
],
expCustomFieldList: [
{ fieldName: "接待客户", showValue: "联合电子" },
{ fieldName: "CRM记录ID", showValue: "CRM-100" },
{ fieldName: "附件", showValue: "private-image-data" },
],
}],
},
};
const compact = compactApprovalDetail(raw, "summary");
assert.deepEqual(compact.detail.amount, { value: 2336, currency: "CNY" });
assert.equal((compact.detail.applicant as Record<string, unknown>).name, "李林峰");
assert.equal((compact.detail.applicant as Record<string, unknown>).department, "奋进队");
assert.equal(compact.detail.summary, "客户招待");
const items = compact.detail.expenseItems as Array<Record<string, unknown>>;
assert.equal(items.length, 1);
assert.equal(items[0]!.occurredOn, "2026-08-06");
assert.equal(items[0]!.expenseType, "餐饮费");
assert.equal(items[0]!.amount, 2336);
assert.equal(items[0]!.currency, "CNY");
assert.equal(items[0]!.description, "8月6日宴请联合电子张家万");
assert.equal(items[0]!.merchant, "上海靓小余餐饮管理有限公司");
assert.equal(items[0]!.location, "98");
assert.equal(items[0]!.customer, "联合电子");
assert.equal(items[0]!.crmRecordId, "CRM-100");
assert.deepEqual(items[0]!.invoiceDates, ["2026-08-06", "2026-08-05"]);
assert.match(JSON.stringify(compact.detail.formFields), /单据类型|报销总额|成本中心/);
assert.doesNotMatch(JSON.stringify(compact), /secret\.pdf|private-image-data/);
});
test("extracts XFT generic detail expList aliases used by expense applications", () => {
const raw = {
todo: { applicantName: "测试用户丁", applicantDepartment: "测试部门", amount: 3000 },
form: {
billInfo: {
eventType: "EXPENSE_APPLY", applyTotalAmount: 3000,
customFieldValues: [
{ customFieldName: "招待地址", customFieldValue: "无锡", customFieldConfig: { name: "内部配置", value: "不应展示" } },
{ customFieldName: "招待日期", customFieldValue: "2026-08-12" },
{ customFieldName: "招待事由", customFieldValue: "客户招待" },
{ customFieldName: "家庭住址", customFieldValue: "不应展示的私人地址" },
],
},
expList: [{
expNam: "招待客户烟、酒、水果等", expId: "000", wrTim: "2026-08-13 00:00",
reimAmt: 3000, currencyCode: "CNY", remark: "买酒及招待费用", merchantNam: "公司", payCity: "深圳市",
}],
applyExpenseDtoList: [{ expTypeName: "招待客户烟、酒、水果等", applyAmount: 3000, remark: "买酒及招待费用" }],
},
};
const compact = compactApprovalDetail(raw, "expense");
const items = compact.detail.expenseItems as Array<Record<string, unknown>>;
assert.equal(items.length, 1);
assert.deepEqual(items[0], {
occurredOn: "2026-08-13", expenseType: "招待客户烟、酒、水果等", amount: 3000, currency: "CNY",
description: "买酒及招待费用", merchant: "公司", location: "深圳市", customer: null, contacts: [],
relatedTripId: null, invoiceDates: [], projectId: null, crmRecordId: null,
});
assert.match(JSON.stringify(compact.detail.formFields), /招待地址.*无锡.*招待日期.*2026-08-12.*招待事由.*客户招待/);
assert.match(JSON.stringify(compact.detail.supportingFields), /招待地址.*无锡.*招待日期.*2026-08-12.*招待事由.*客户招待/);
assert.doesNotMatch(JSON.stringify(compact), /内部配置|不应展示/);
});
test("official XFT expense date falls back to hotel check-in date", () => {
const raw = { form: { eventType: "TRAVEL_REIM", expTotalCnt: 843, expenseList: [{
expTypeName: "住宿费",
reimAmount: 843,
currencyCode: "CNY",
remark: "7/21-8/8出差住宿费用",
wrTim: null,
bgnTim: null,
checkInDate: 1_784_592_000_000,
checkOutDate: 1_786_147_200_000,
invoiceInfoList: [],
}] } };
const items = extractExpenseItems(raw);
assert.equal(items.length, 1);
assert.equal(items[0]!.occurredOn, "2026-07-21");
assert.equal(items[0]!.expenseType, "住宿费");
assert.equal(items[0]!.amount, 843);
});
test("raw detail is filtered, paginated, bounded, and traverses embedded JSON", () => {
const raw = {
businessKey: "B-1",
businessConfig: { hugeSchema: "must-not-be-visible" },
mobile: "13800138000",
formComponentValues: [{
name: "报销明细",
value: JSON.stringify([{ rowValue: [{ label: "费用说明", value: "x".repeat(10_000) }] }]),
}],
};
const root = browseRawDetail(raw, "", 0, 50);
assert.doesNotMatch(JSON.stringify(root), /businessConfig|must-not-be-visible|13800138000/);
const embedded = browseRawDetail(raw, "/formComponentValues/0/value/0/rowValue/0", 0, 20) as Record<string, unknown>;
assert.equal(embedded.kind, "container");
assert.equal(embedded.encodedAsJsonString, true);
assert.equal(JSON.stringify(embedded).length < 10_000, true);
const scalar = browseRawDetail(raw, "/formComponentValues/0/value/0/rowValue/0/value", 0, 20) as Record<string, unknown>;
assert.equal(scalar.kind, "string");
assert.equal(scalar.valueTruncated, true);
assert.equal(String(scalar.value).length, 4_000);
assert.throws(() => browseRawDetail(raw, "/businessConfig"), /不存在/);
assert.throws(() => browseRawDetail(raw, "/mobile"), /不存在/);
});
test("raw detail supports bounded field search and hides internal user and department IDs", () => {
const raw = { originatorUserid: "private-user", originatorDeptId: 123, formValueVOS: [
{ name: "普通字段", value: "无关" },
{ name: "关联申请单", value: "TRIP-20260811" },
] };
const result = browseRawDetail(raw, "/formValueVOS", 0, 20, "关联申请单") as Record<string, unknown>;
assert.equal(result.kind, "search");
assert.equal(result.total, 1);
assert.match(JSON.stringify(result), /关联申请单|TRIP-20260811/);
assert.doesNotMatch(JSON.stringify(browseRawDetail(raw)), /private-user|originatorUserid|originatorDeptId/);
});
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractApprovalListMetadata } from "../src/approval-metadata.js";
test("approval metadata normalizes official instance identity, time, amount, and summary", () => {
const metadata = extractApprovalListMetadata({
originatorUserName: "测试用户丁",
originatorUserId: "user-bin",
originatorDeptName: "销售部",
createTime: 1_786_179_600_000,
formComponentValues: [
{ name: "报销金额", value: "¥4,812.00" },
{ name: "报销事由", value: "客户现场出差" },
{ name: "银行卡号", value: "6222000012345678" },
],
});
assert.deepEqual(metadata, {
applicantName: "测试用户丁",
applicantUserId: "user-bin",
applicantDepartment: "销售部",
submittedAt: new Date(1_786_179_600_000).toISOString(),
amount: 4812,
currency: "CNY",
summary: "客户现场出差",
});
assert.doesNotMatch(JSON.stringify(metadata), /6222000012345678/);
});
test("approval metadata falls back to todo form summaries and chooses total amount", () => {
const metadata = extractApprovalListMetadata({
forms: [
{ title: "发起人", content: "测试用户戊" },
{ title: "部门", content: "产品部" },
{ title: "单项费用", content: "100" },
{ title: "合计金额", content: "USD 1,234.50" },
{ title: "申请原因", content: "采购测试设备" },
],
});
assert.equal(metadata.applicantName, "测试用户戊");
assert.equal(metadata.applicantDepartment, "产品部");
assert.equal(metadata.amount, 1234.5);
assert.equal(metadata.currency, "USD");
assert.equal(metadata.summary, "采购测试设备");
});
test("approval metadata can derive the applicant from a standard DingTalk title", () => {
assert.equal(
extractApprovalListMetadata({ title: "测试用户甲提交的通用费用报销_北极大" }).applicantName,
"测试用户甲",
);
});
+290
View File
@@ -0,0 +1,290 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer } from "../src/bridge.js";
test("stdio bridge creates a messenger-friendly device login challenge and protects local state", async () => {
const calls: string[] = [];
const fetchFn: typeof fetch = async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/register")) return Response.json({ client_id: "bridge-client" }, { status: 201 });
if (url.endsWith("/oauth/device/authorize")) return Response.json({
device_code: "device-secret", user_code: "ABCD-2345",
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=ABCD-2345",
expires_in: 600, interval: 5,
});
if (url.endsWith("/oauth/device/token")) return Response.json({ error: "authorization_pending" }, { status: 400 });
throw new Error(`unexpected ${url}`);
};
const root = mkdtempSync(join(tmpdir(), "ding-bridge-"));
const statePath = join(root, "private", "session.json");
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, fetchFn);
const first = await bridge.loginChallenge();
assert.equal(first.authenticated, false);
assert.ok("authChallenge" in first);
assert.equal(first.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=ABCD-2345");
assert.match(first.message, /https:\/\/approval\.example\.test\/oauth\/device\?user_code=ABCD-2345/);
assert.match(first.message, /\[打开钉钉授权登录\]\(https:\/\/approval\.example\.test\/oauth\/device\?user_code=ABCD-2345\)/);
assert.match(first.message, /不会另行推送或弹出/);
assert.match(first.message, /当前回复正文中原样显示/);
assert.match(first.message, /授权码:ABCD-2345/);
const toolResult = bridgeAuthToolResult(first);
assert.equal(toolResult.content[0]?.type, "text");
assert.match(toolResult.content[0]!.type === "text" ? toolResult.content[0].text : "", /备用原始链接:https:\/\/approval\.example\.test/);
assert.deepEqual(toolResult.content[1], {
type: "resource_link",
name: "dingtalk-approval-login",
title: "打开钉钉授权登录",
uri: "https://approval.example.test/oauth/device?user_code=ABCD-2345",
description: "当前用户的短期钉钉授权登录链接",
});
assert.equal(statSync(statePath).mode & 0o777, 0o600);
assert.equal(statSync(join(root, "private")).mode & 0o777, 0o700);
assert.match(readFileSync(statePath, "utf8"), /device-secret/);
const second = await bridge.loginChallenge();
assert.equal(second.authenticated, false);
assert.deepEqual(calls.map((url) => new URL(url).pathname), ["/register", "/oauth/device/authorize", "/oauth/device/token"]);
});
test("post-install onboarding waits for authentication and is visible exactly once in portable text", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-onboarding-login-"));
const statePath = join(root, "session.json");
let authorized = false;
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
const path = new URL(String(input)).pathname;
if (path === "/register") return Response.json({ client_id: "bridge-client" }, { status: 201 });
if (path === "/oauth/device/authorize") return Response.json({
device_code: "onboarding-device", user_code: "START-1234",
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=START-1234",
expires_in: 600, interval: 5,
});
if (path === "/oauth/device/token") return authorized
? Response.json({ access_token: "fresh-access", refresh_token: "fresh-refresh", expires_in: 900 })
: Response.json({ error: "authorization_pending" }, { status: 400 });
throw new Error(`unexpected ${path}`);
});
bridge.ensureOnboardingPending();
const challenge = await bridge.loginChallenge();
assert.equal(challenge.authenticated, false);
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "pending", "showing a login URL must not consume onboarding");
authorized = true;
const authenticated = await bridge.loginChallenge();
assert.equal(authenticated.authenticated, true);
assert.ok("onboarding" in authenticated && authenticated.onboarding);
assert.deepEqual(authenticated.onboarding.examples, [...APPROVAL_ONBOARDING_EXAMPLES]);
const portable = bridgeAuthToolResult(authenticated);
const portableText = portable.content[0]?.type === "text" ? portable.content[0].text : "";
assert.match(portableText, /首次登录已完成/);
assert.match(portableText, /帮我列出当前待处理的审批/);
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "delivered");
const repeated = await bridge.loginChallenge();
assert.equal("onboarding" in repeated, false);
assert.doesNotMatch(repeated.message, /首次登录已完成/);
});
test("the first successful business response can deliver onboarding without an explicit login tool call", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-onboarding-call-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
throw new Error("network should not be used by the injected client");
});
bridge.ensureOnboardingPending();
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool() { return { isError: true, content: [{ type: "text", text: "upstream failed" }] }; },
async close() {},
};
const failed = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
assert.equal(failed.isError, true);
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "pending", "an error response must not consume onboarding");
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool() { return { content: [{ type: "text", text: "已实时查询审批待办。" }], structuredContent: { items: [] } }; },
async close() {},
};
const first = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
assert.match(first.content.at(-1).text, /首次登录已完成/);
assert.deepEqual(first.structuredContent.onboarding.examples, [...APPROVAL_ONBOARDING_EXAMPLES]);
const second = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
assert.equal(second.content.length, 1);
assert.equal(second.structuredContent.onboarding, undefined);
});
test("stdio bridge discards a connected client after its access token expires", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-expired-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "expired-access", savedAt: Date.now() - 120_000, expires_in: 60 },
}));
const paths: string[] = [];
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
const path = new URL(String(input)).pathname;
paths.push(path);
if (path === "/oauth/device/authorize") return Response.json({
device_code: "replacement-device", user_code: "NEXT-1234",
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=NEXT-1234",
expires_in: 600, interval: 5,
});
throw new Error(`unexpected ${path}`);
});
let staleCalls = 0;
let closed = 0;
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool() { staleCalls += 1; return { structuredContent: { result: { stale: true } } }; },
async close() { closed += 1; },
};
const result = await bridge.call("approval_list_todos", { source: "xft", query: "", limit: 20 });
assert.equal(staleCalls, 0);
assert.equal(closed, 1);
assert.deepEqual(paths, ["/oauth/device/authorize"]);
assert.equal(result.structuredContent.result.authenticated, false);
assert.equal(result.structuredContent.result.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=NEXT-1234");
assert.match(result.content[0].text, /https:\/\/approval\.example\.test\/oauth\/device\?user_code=NEXT-1234/);
assert.match(result.content[0].text, /授权码:NEXT-1234/);
assert.equal(result.content[1].type, "resource_link");
assert.equal(result.content[1].uri, "https://approval.example.test/oauth/device?user_code=NEXT-1234");
});
test("MCP approval_login returns both portable text and a standard resource link", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-mcp-login-"));
const bridge = new RemoteBridge("https://approval.example.test/mcp", join(root, "session.json"), async (input) => {
const path = new URL(String(input)).pathname;
if (path === "/register") return Response.json({ client_id: "bridge-client" }, { status: 201 });
if (path === "/oauth/device/authorize") return Response.json({
device_code: "device-secret", user_code: "LINK-2345",
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=LINK-2345",
expires_in: 600, interval: 5,
});
throw new Error(`unexpected ${path}`);
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createBridgeMcpServer(bridge);
const client = new Client({ name: "bridge-login-test", version: "1.0.0" });
await server.connect(serverTransport);
try {
await client.connect(clientTransport);
const response = await client.callTool({ name: "approval_login", arguments: {} });
const content = response.content as Array<{ type: string; text?: string; uri?: string }>;
const text = content.find((item) => item.type === "text")?.text ?? "";
const link = content.find((item) => item.type === "resource_link");
assert.match(text, /\[打开钉钉授权登录\]\(https:\/\/approval\.example\.test\/oauth\/device\?user_code=LINK-2345\)/);
assert.match(text, /备用原始链接:https:\/\/approval\.example\.test\/oauth\/device\?user_code=LINK-2345/);
assert.equal(link?.uri, "https://approval.example.test/oauth/device?user_code=LINK-2345");
assert.equal((response.structuredContent as any).result.authChallenge.directUrl, link?.uri);
const tools = await client.listTools();
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
assert.equal((history.inputSchema as any).properties.startDate.default, "");
assert.equal((history.inputSchema as any).properties.endDate.default, "");
const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
assert.equal((batch.inputSchema as any).properties.items.maxItems, 20);
assert.match(batch.description ?? "", /最多 20 条.*零写入/s);
} finally {
await client.close();
await server.close();
}
});
test("stdio bridge refreshes an expired login before creating a new challenge", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-refresh-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: {
access_token: "expired-access", refresh_token: "refresh-1",
savedAt: Date.now() - 120_000, expires_in: 60,
},
}));
const paths: string[] = [];
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
const path = new URL(String(input)).pathname;
paths.push(path);
if (path === "/token") return Response.json({
access_token: "fresh-access", refresh_token: "refresh-2", expires_in: 900,
});
throw new Error(`unexpected ${path}`);
});
const result = await bridge.loginChallenge();
assert.equal(result.authenticated, true);
assert.deepEqual(paths, ["/token"]);
const saved = readFileSync(statePath, "utf8");
assert.match(saved, /fresh-access/);
assert.doesNotMatch(saved, /expired-access/);
});
test("stdio bridge preserves refresh credentials across a transient token endpoint failure", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-transient-refresh-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "expired-access", refresh_token: "keep-refresh", savedAt: Date.now() - 120_000, expires_in: 60 },
}));
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
assert.equal(new URL(String(input)).pathname, "/token");
return Response.json({ error: "server_error" }, { status: 503 });
});
await assert.rejects(bridge.loginChallenge(), /暂时无法刷新登录/);
const saved = readFileSync(statePath, "utf8");
assert.match(saved, /keep-refresh/);
assert.match(saved, /expired-access/);
});
test("stdio bridge adopts a newer token saved by another bridge process", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-shared-"));
const statePath = join(root, "session.json");
const oldSavedAt = Date.now() - 120_000;
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "expired-access", refresh_token: "stale-refresh", savedAt: oldSavedAt, expires_in: 60 },
}));
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
throw new Error("newer disk token should avoid refresh");
});
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "shared-fresh-access", refresh_token: "shared-refresh", savedAt: Date.now(), expires_in: 900 },
}));
const result = await bridge.loginChallenge();
assert.equal(result.authenticated, true);
});
test("stdio bridge bounds concurrent remote tool calls", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-concurrency-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
throw new Error("network should not be used by the injected client");
});
let active = 0;
let maximum = 0;
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool() {
active += 1;
maximum = Math.max(maximum, active);
await new Promise<void>((resolve) => setTimeout(resolve, 5));
active -= 1;
return { content: [{ type: "text", text: "ok" }] };
},
async close() {},
};
await Promise.all(Array.from({ length: 7 }, () => bridge.call("approval_get_actions", { approvalId: "opaque" })));
assert.equal(maximum, 3);
});
+351
View File
@@ -0,0 +1,351 @@
import assert from "node:assert/strict";
import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, delimiter, dirname, join } from "node:path";
import test from "node:test";
import {
cleanupLegacyInstallations, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest,
type ClientReleaseManifest,
} from "../src/client-installer.js";
test("client paths stay in stable per-user locations on all supported platforms", () => {
assert.equal(clientPaths({}, "linux", "/users/alice").root, "/users/alice/.local/share/dingtalk-approval");
assert.equal(clientPaths({ XDG_DATA_HOME: "/data/alice" }, "linux", "/users/alice").root, "/data/alice/dingtalk-approval");
assert.equal(clientPaths({}, "darwin", "/Users/alice").root, "/Users/alice/Library/Application Support/dingtalk-approval");
assert.equal(clientPaths({ LOCALAPPDATA: "C:\\Users\\alice\\AppData\\Local" }, "win32", "C:\\Users\\alice").root, "C:\\Users\\alice\\AppData\\Local\\DingTalkApproval");
assert.equal(clientPaths({}, "linux", "/users/alice").bridgeCommand, "/users/alice/.local/share/dingtalk-approval/bin/dingtalk-approval-bridge");
});
test("signed manifest verification rejects tampering and a foreign key", () => {
const trusted = generateKeyPairSync("ed25519");
const foreign = generateKeyPairSync("ed25519");
const publicPem = trusted.publicKey.export({ type: "spki", format: "pem" });
const keyId = releaseKeyId(publicPem);
const manifest = manifestFixture(keyId, publicPem);
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const signature = signatureEnvelope(keyId, sign(null, bytes, trusted.privateKey));
assert.equal(verifySignedManifest(bytes, signature, publicPem, "https://approval.example.test/downloads/manifest.json").version, "1.1.0-rc.5");
assert.throws(() => verifySignedManifest(Buffer.concat([bytes, Buffer.from(" ")]), signature, publicPem, "https://approval.example.test/downloads/manifest.json"), /签名无效/);
assert.throws(() => verifySignedManifest(bytes, signature, foreign.publicKey.export({ type: "spki", format: "pem" }), "https://approval.example.test/downloads/manifest.json"), /密钥编号不受信任/);
const stale = { ...manifest, createdAt: new Date(Date.now() - 181 * 24 * 60 * 60_000).toISOString() };
const staleBytes = Buffer.from(`${JSON.stringify(stale, null, 2)}\n`);
const staleSignature = signatureEnvelope(keyId, sign(null, staleBytes, trusted.privateKey));
assert.throws(() => verifySignedManifest(staleBytes, staleSignature, publicPem, "https://approval.example.test/downloads/manifest.json"), /超过 180 天/);
});
test("legacy cleanup removes only owned broken bins and exact old package", () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-legacy-"));
const home = join(root, "home");
const bin = join(home, ".nvm", "versions", "node", "v22.22.2", "bin");
const packageRoot = join(home, ".nvm", "versions", "node", "v22.22.2", "lib", "node_modules", "dingtalk-approval-mcp");
mkdirSync(bin, { recursive: true });
mkdirSync(packageRoot, { recursive: true });
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) {
symlinkSync(`../lib/node_modules/dingtalk-approval-mcp/dist/src/${name}.js`, join(bin, name));
}
symlinkSync("../lib/node_modules/some-other-package/cli.js", join(bin, "unrelated"));
const removed = cleanupLegacyInstallations({ PATH: bin }, "linux", home);
assert.equal(removed.filter((path) => path.includes("dingtalk-")).length, 5);
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) assert.equal(existsSync(join(bin, name)), false);
assert.equal(existsSync(packageRoot), false);
assert.equal(existsSync(join(bin, "unrelated")), false, "existsSync is false for dangling links");
assert.equal(lstatSync(join(bin, "unrelated")).isSymbolicLink(), true, "unrelated dangling link remains");
});
test("legacy cleanup removes owned Windows npm shims without touching unrelated commands", () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-windows-legacy-"));
const bin = join(root, "npm");
const packageRoot = join(bin, "node_modules", "dingtalk-approval-mcp");
mkdirSync(packageRoot, { recursive: true });
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
writeFileSync(join(bin, "dingtalk-approval-bridge.cmd"), "@node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\bridge.js %*\r\n");
writeFileSync(join(bin, "dingtalk-mcp.ps1"), "& node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\index.js $args\r\n");
writeFileSync(join(bin, "dingtalk-admin.cmd"), "@node C:\\npm\\node_modules\\another-package\\admin.js %*\r\n");
cleanupLegacyInstallations({ PATH: bin, APPDATA: bin }, "win32", join(root, "home"));
assert.equal(existsSync(join(bin, "dingtalk-approval-bridge.cmd")), false);
assert.equal(existsSync(join(bin, "dingtalk-mcp.ps1")), false);
assert.equal(existsSync(packageRoot), false);
assert.equal(existsSync(join(bin, "dingtalk-admin.cmd")), true);
});
test("generic installer preserves identity across safe updates, rollback, downgrade, and uninstall", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-install-"));
const appRoot = join(root, "dingtalk-approval");
const fakeBin = join(root, "fake-bin");
mkdirSync(fakeBin, { recursive: true });
createFakeNpm(fakeBin);
const keys = generateKeyPairSync("ed25519");
const publicPem = Buffer.from(keys.publicKey.export({ type: "spki", format: "pem" }));
const keyId = releaseKeyId(publicPem);
const bodies = {
installer: Buffer.from("installer"),
publicKey: publicPem,
client: Buffer.from("fake-client-tgz"),
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n\n# Test\n"),
};
const manifest = manifestFixture(keyId, publicPem, bodies);
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey));
const responses = new Map<string, Buffer>([
["https://approval.example.test/downloads/manifest.json", manifestBytes],
["https://approval.example.test/downloads/manifest.sig", signature],
...Object.entries(manifest.artifacts).map(([name, artifact]) => [artifact.url, bodies[name as keyof typeof bodies]] as [string, Buffer]),
]);
const fetchFn = async (input: string | URL | Request) => {
const url = String(input);
const body = responses.get(url);
return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 });
};
const publicPath = join(root, "release-public.pem");
writeFileSync(publicPath, publicPem, { mode: 0o600 });
const env: NodeJS.ProcessEnv = { PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter), DINGTALK_APPROVAL_CLIENT_HOME: appRoot };
const output: string[] = [];
const commandContext = {
env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch,
stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { output.push(value); },
};
const installed = await runClientInstaller([
"install", "--manifest-url", "https://approval.example.test/downloads/manifest.json",
"--public-key", publicPath, "--app-root", appRoot, "--json",
], commandContext);
assert.equal(installed, 0);
const installOutput = JSON.parse(output.pop()!);
assert.equal(installOutput.restartRequired, undefined);
assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/);
assert.match(installOutput.nextSteps.join(" "), /立即处理 postInstall.*不得等待用户首次查询审批/s);
assert.equal(installOutput.postInstall.status, "authorization_required");
assert.equal(installOutput.postInstall.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
assert.deepEqual(installOutput.postInstall.confirmTool, { name: "approval_login", arguments: {} });
assert.deepEqual(installOutput.postInstall.verifyTool, {
name: "approval_list_todos", arguments: { source: "all", query: "", limit: 1 },
});
assert.equal(installOutput.mcpConfig.mcpServers["dingtalk-approval"].args.length, 0);
const paths = clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: appRoot }, process.platform, join(root, "home"));
assert.equal(existsSync(paths.bridgeCommand), true);
assert.equal(existsSync(paths.skillFile), true);
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(state.version, "1.1.0-rc.5");
assert.equal(state.host, undefined);
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
const stableBridgePath = paths.bridgeCommand;
const initialRuntime = state.runtimeDir;
mkdirSync(dirname(paths.sessionFile), { recursive: true });
const sessionBody = Buffer.from(`${JSON.stringify({
authenticated: true,
marker: "preserve-across-updates",
onboarding: { schemaVersion: 1, status: "delivered", deliveredAt: "2030-01-01T00:00:00.000Z" },
})}\n`);
writeFileSync(paths.sessionFile, sessionBody, { mode: 0o600 });
output.length = 0;
assert.equal(await runClientInstaller(["print-path", "bridge", "--app-root", appRoot], commandContext), 0);
assert.equal(output.pop(), `${paths.bridgeCommand}\n`);
output.length = 0;
assert.equal(await runClientInstaller(["print-config", "--format", "mcp-json", "--app-root", appRoot, "--json"], commandContext), 0);
const mcpConfig = JSON.parse(output.pop()!);
assert.deepEqual(mcpConfig, { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } });
output.length = 0;
assert.equal(await runClientInstaller(["doctor", "--app-root", appRoot, "--json"], commandContext), 0);
const health = JSON.parse(output.pop()!);
assert.equal(health.ok, true);
assert.equal(health.checks.find((check: { name: string }) => check.name === "npm").ok, true);
output.length = 0;
assert.equal(await runClientInstaller([
"update", "--app-root", appRoot, "--json",
], { ...commandContext, env: { ...env, FAKE_BRIDGE_FAIL: "1" } }), 0, "login preparation failure does not roll back an installed client");
const sameVersionOutput = JSON.parse(output.pop()!);
assert.equal(sameVersionOutput.postInstall.status, "unavailable");
const sameVersionState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(sameVersionState.host, undefined);
assert.equal(sameVersionState.runtimeDir, initialRuntime, "same-version update reuses the complete runtime");
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session");
assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]);
output.length = 0;
assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1);
assert.match(JSON.parse(output.pop()!).error, /未知参数:--host/);
const upgradedBodies = {
...bodies,
client: Buffer.from("fake-client-tgz-v2"),
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test v2\n---\n\n# Test v2\n"),
};
const upgraded = manifestFixture(keyId, publicPem, upgradedBodies, "1.1.1");
const upgradedBytes = Buffer.from(`${JSON.stringify(upgraded, null, 2)}\n`);
responses.set("https://approval.example.test/downloads/manifest.json", upgradedBytes);
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, upgradedBytes, keys.privateKey)));
for (const [name, artifact] of Object.entries(upgraded.artifacts)) {
responses.set(artifact.url, upgradedBodies[name as keyof typeof upgradedBodies]);
}
output.length = 0;
assert.equal(await runClientInstaller([
"update", "--app-root", appRoot, "--json",
], commandContext), 0, "update reuses the pinned manifest URL and public key");
const upgradedOutput = JSON.parse(output.pop()!);
const upgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(upgradedOutput.version, "1.1.1");
assert.equal(upgradedOutput.restartRequired, undefined);
assert.equal(upgradedState.host, undefined);
assert.notEqual(upgradedState.runtimeDir, initialRuntime);
assert.equal(existsSync(join(appRoot, initialRuntime)), false, "successful upgrade removes the obsolete runtime");
assert.deepEqual(readdirSync(paths.versionsDir), [basename(upgradedState.runtimeDir)]);
assert.equal(readFileSync(paths.skillFile, "utf8"), upgradedBodies.skill.toString("utf8"));
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "cross-version update preserves the OAuth session");
assert.equal(paths.bridgeCommand, stableBridgePath, "the MCP command path remains stable across versions");
const beforeFailedState = readFileSync(paths.stateFile);
const beforeFailedSkill = readFileSync(paths.skillFile);
const beforeFailedVersions = readdirSync(paths.versionsDir);
const failedBodies = { ...upgradedBodies, client: Buffer.from("fake-client-tgz-v3") };
const failedUpgrade = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
const failedUpgradeBytes = Buffer.from(`${JSON.stringify(failedUpgrade, null, 2)}\n`);
responses.set("https://approval.example.test/downloads/manifest.json", failedUpgradeBytes);
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, failedUpgradeBytes, keys.privateKey)));
responses.set(failedUpgrade.artifacts.client.url, Buffer.from("corrupted-client-tgz"));
output.length = 0;
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 1);
assert.match(JSON.parse(output.pop()!).error, /SHA-256 不匹配/);
assert.deepEqual(readFileSync(paths.stateFile), beforeFailedState, "failed update keeps the active install state");
assert.deepEqual(readFileSync(paths.skillFile), beforeFailedSkill, "failed update keeps the active Skill");
assert.deepEqual(readdirSync(paths.versionsDir), beforeFailedVersions, "failed update keeps the active runtime");
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "failed update keeps the OAuth session");
const nextRelease = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
const nextBytes = Buffer.from(`${JSON.stringify(nextRelease, null, 2)}\n`);
responses.set("https://approval.example.test/downloads/manifest.json", nextBytes);
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, nextBytes, keys.privateKey)));
for (const [name, artifact] of Object.entries(nextRelease.artifacts)) {
responses.set(artifact.url, failedBodies[name as keyof typeof failedBodies]);
}
output.length = 0;
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 0);
assert.equal(JSON.parse(output.pop()!).version, "1.1.2");
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).host, undefined);
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
const downgraded = manifestFixture(keyId, publicPem, bodies, "1.0.0");
const downgradedBytes = Buffer.from(`${JSON.stringify(downgraded, null, 2)}\n`);
responses.set("https://approval.example.test/downloads/manifest.json", downgradedBytes);
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, downgradedBytes, keys.privateKey)));
output.length = 0;
assert.equal(await runClientInstaller([
"update", "--app-root", appRoot, "--json",
], commandContext), 1);
assert.match(JSON.parse(output.pop()!).error, /拒绝.*降级/);
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).version, "1.1.2");
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
for (const [name, artifact] of Object.entries(downgraded.artifacts)) {
responses.set(artifact.url, bodies[name as keyof typeof bodies]);
}
output.length = 0;
assert.equal(await runClientInstaller([
"update", "--allow-downgrade", "--app-root", appRoot, "--json",
], commandContext), 0, "an explicitly authorized downgrade remains possible");
const downgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(downgradedState.version, "1.0.0");
assert.equal(downgradedState.host, undefined);
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
assert.equal(readdirSync(paths.versionsDir).length, 1);
output.length = 0;
assert.equal(await runClientInstaller(["print-config", "--bogus", "--app-root", appRoot, "--json"], commandContext), 1);
assert.match(JSON.parse(output.pop()!).error, /未知参数/);
output.length = 0;
assert.equal(await runClientInstaller(["uninstall", "--yes", "--app-root", root, "--json"], commandContext), 1);
assert.match(JSON.parse(output.pop()!).error, /专用.*用户数据目录/);
output.length = 0;
const removed = await runClientInstaller(["uninstall", "--yes", "--app-root", appRoot, "--json"], {
...commandContext,
});
assert.equal(removed, 0);
assert.equal(existsSync(appRoot), false);
});
function manifestFixture(
keyId: string,
publicKey: string | Buffer,
bodies = {
installer: Buffer.from("installer"), publicKey: Buffer.from(publicKey), client: Buffer.from("client"),
skill: Buffer.from("skill"),
},
version = "1.1.0-rc.5",
): ClientReleaseManifest {
const base = "https://approval.example.test/downloads/";
const artifact = (name: keyof typeof bodies) => ({ url: `${base}${name}`, sha256: digest(bodies[name]) });
return {
schemaVersion: 3,
version,
createdAt: new Date().toISOString(),
gatewayUrl: "https://approval.example.test/mcp",
signing: { algorithm: "Ed25519", keyId },
requirements: { node: ">=22.13.0" },
artifacts: {
installer: artifact("installer"),
publicKey: { ...artifact("publicKey"), keyId },
client: artifact("client"),
skill: artifact("skill"),
},
};
}
function signatureEnvelope(keyId: string, value: Buffer): Buffer {
return Buffer.from(JSON.stringify({ schemaVersion: 1, algorithm: "Ed25519", keyId, signature: value.toString("base64") }));
}
function digest(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); }
function createFakeNpm(bin: string): void {
const fakeBridge = `#!/usr/bin/env node
if (process.env.FAKE_BRIDGE_FAIL === "1") process.exit(2);
if (process.argv.includes("--post-install-login")) {
console.log(JSON.stringify({
authenticated: false,
message: "需要完成钉钉授权登录。",
authChallenge: {
provider: "dingtalk",
type: "link",
directUrl: "https://approval.example.test/oauth/device?user_code=INSTALL-1234",
userCode: "INSTALL-1234",
expiresAt: "2030-01-01T00:00:00.000Z",
pollAfterSeconds: 5
}
}));
}
`;
const implementation = process.platform === "win32"
? join(bin, "node_modules", "npm", "bin", "npm-cli.js")
: join(bin, "fake-npm.mjs");
mkdirSync(join(implementation, ".."), { recursive: true });
writeFileSync(implementation, `#!/usr/bin/env node
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
if (process.argv[2] === "--version") { console.log("11.0.0"); process.exit(0); }
const index = process.argv.indexOf("--prefix");
if (index < 0 || !process.argv[index + 1]) { console.error("missing prefix"); process.exit(2); }
const prefix = process.argv[index + 1];
const target = join(prefix, "node_modules", "dingtalk-approval-client", "dist", "src");
mkdirSync(target, { recursive: true });
writeFileSync(join(target, "bridge.js"), ${JSON.stringify(fakeBridge)});
writeFileSync(join(target, "client-installer.js"), "#!/usr/bin/env node\\n");
`);
if (process.platform === "win32") {
writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n");
} else {
const executable = join(bin, "npm");
writeFileSync(executable, `#!/usr/bin/env sh\nexec "${process.execPath}" "$(dirname "$0")/fake-npm.mjs" "$@"\n`, { mode: 0o700 });
chmodSync(executable, 0o700);
}
}
+703
View File
@@ -0,0 +1,703 @@
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { ApprovalService } from "../src/approval-service.js";
import { loadConfig, type AppConfig } from "../src/config.js";
import { DingTalkClient, type FetchLike } from "../src/dingtalk.js";
import { DingTalkApiError } from "../src/errors.js";
const config: AppConfig = {
clientId: "app-key",
clientSecret: "app-secret",
corpId: "ding-corp",
apiBaseUrl: "https://api.example.test",
oapiBaseUrl: "https://oapi.example.test",
publicBaseUrl: "http://localhost:3000",
trustedLocalUser: { userId: "user-1", name: "测试用户" },
port: 3000,
requestTimeoutMs: 1_000,
officialLookbackDays: 120,
officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [],
approvalRefSecret: "fixed-reference-secret-for-tests",
batchLimit: 20,
};
const yidaConfig: AppConfig = {
...config,
yida: { adminUserId: "admin-1", organizationToken: "org-token" },
};
test("loadConfig can read DingTalk credentials from a protected JSON file", () => {
const directory = mkdtempSync(join(tmpdir(), "ding-mcp-test-"));
const credentialsPath = join(directory, "credentials.json");
writeFileSync(credentialsPath, JSON.stringify({ clientId: "file-key", clientSecret: "file-secret" }), { mode: 0o600 });
const loaded = loadConfig({
APP_MODE: "gateway",
DINGTALK_CREDENTIALS_FILE: credentialsPath,
DINGTALK_CORP_ID: "ding-corp",
MCP_PUBLIC_BASE_URL: "https://approval.example.test",
MCP_REMOTE_AUTH_ENABLED: "true",
MCP_SESSION_SECRET: "session-secret-for-tests-at-least-32-bytes",
MCP_APPROVAL_REF_SECRET: "approval-reference-secret-for-tests-32-bytes",
XFT_OPEN_API_ENABLED: "true",
XFT_OPEN_API_APP_ID: "xft-app",
XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64),
XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
DINGTALK_CLIENT_ONLY_PROCESS_CODES: "PROC-SIGN, PROC-SIGN,PROC-CLIENT",
APPROVAL_BATCH_LIMIT: "99",
YIDA_ENABLED: "false",
});
assert.equal(loaded.clientId, "file-key");
assert.equal(loaded.clientSecret, "file-secret");
assert.equal(loaded.yida, undefined);
assert.deepEqual(loaded.officialClientOnlyProcessCodes, ["PROC-SIGN", "PROC-CLIENT"]);
assert.equal(loaded.batchLimit, 20);
});
test("loadConfig rejects the removed local DWS architecture", () => {
assert.throws(() => loadConfig({ APP_MODE: "local", DWS_CLI_PATH: "/opt/bin/dws" }), /APP_MODE=gateway/);
assert.throws(() => loadConfig({
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
MCP_REMOTE_AUTH_ENABLED: "true", XFT_OPEN_API_ENABLED: "true", XFT_OPEN_API_APP_ID: "app",
XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64), XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
DWS_CLI_PATH: "/opt/bin/dws",
}), /禁止配置本地 DWS/);
});
test("loadConfig reads AuthoritySecret from the second line of an XFT portal credential file", () => {
const directory = mkdtempSync(join(tmpdir(), "ding-mcp-xft-openapi-config-"));
const secretPath = join(directory, "xft.key");
writeFileSync(secretPath, `${"a".repeat(32)}\n${"b".repeat(64)}\n`, { mode: 0o600 });
const loaded = loadConfig({
APP_MODE: "gateway",
DINGTALK_CLIENT_ID: "key",
DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp",
MCP_PUBLIC_BASE_URL: "https://approval.example.test",
MCP_REMOTE_AUTH_ENABLED: "true",
MCP_SESSION_SECRET: "session-secret-for-tests-at-least-32-bytes",
MCP_APPROVAL_REF_SECRET: "approval-reference-secret-for-tests-32-bytes",
XFT_OPEN_API_ENABLED: "true",
XFT_OPEN_API_APP_ID: "open-app",
XFT_OPEN_API_AUTHORITY_SECRET_FILE: secretPath,
XFT_OPEN_API_ENTERPRISE_ID: "TEST0001",
});
assert.equal(loaded.xftOpenApi?.authoritySecret, "b".repeat(64));
});
test("loadConfig requires identity configuration and rejects non-loopback binding", () => {
assert.throws(() => loadConfig({}), /必须显式设置 APP_MODE/);
assert.throws(() => loadConfig({
APP_MODE: "gateway",
DINGTALK_CLIENT_ID: "key",
DINGTALK_CLIENT_SECRET: "secret",
MCP_PUBLIC_BASE_URL: "https://mcp.example.test",
}), /DINGTALK_CORP_ID/);
assert.throws(() => loadConfig({
APP_MODE: "gateway",
MCP_BIND_HOST: "0.0.0.0",
}), /MCP_BIND_HOST 仅允许回环地址/);
});
test("unified gateway requires both remote identity and XFT OpenAPI", () => {
assert.throws(() => loadConfig({
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
}), /薪福通正式 OpenAPI/);
assert.throws(() => loadConfig({
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
MCP_REMOTE_AUTH_ENABLED: "true", MCP_TRUSTED_LOCAL_USER_ID: "victim",
XFT_OPEN_API_ENABLED: "true", XFT_OPEN_API_APP_ID: "app", XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64),
XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
}), /禁止配置 MCP_TRUSTED/);
});
test("DingTalkClient caches accessToken and unwraps official template response", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) {
return Response.json({ accessToken: "token-1", expireIn: 7200 });
}
return Response.json({
success: true,
result: { processList: [{ name: "采购", processCode: "PROC-1" }], nextToken: 0 },
});
};
const client = new DingTalkClient(config, fakeFetch);
const first = await client.listOfficialTemplates();
const second = await client.listOfficialTemplates();
assert.deepEqual(first.map((item) => item.processCode), ["PROC-1"]);
assert.equal(second.length, 1);
assert.equal(calls.filter((call) => call.url.endsWith("accessToken")).length, 1);
assert.equal(
new Headers(calls[1]!.init.headers).get("x-acs-dingtalk-access-token"),
"token-1",
);
});
test("DingTalkClient invalidates and retries an explicitly rejected app access token once", async () => {
let tokenCalls = 0;
let apiCalls = 0;
const client = new DingTalkClient(config, async (input, init = {}) => {
const url = String(input);
if (url.endsWith("/v1.0/oauth2/accessToken")) {
tokenCalls += 1;
return Response.json({ accessToken: `token-${tokenCalls}`, expireIn: 7200 });
}
apiCalls += 1;
const headers = new Headers(init.headers);
if (headers.get("x-acs-dingtalk-access-token") === "token-1") {
return Response.json({ code: "InvalidAccessToken", message: "expired" }, { status: 401 });
}
return Response.json({ success: true, result: { processList: [], nextToken: 0 } });
});
await client.listOfficialTemplates();
assert.equal(tokenCalls, 2);
assert.equal(apiCalls, 2);
});
test("DingTalkClient serializes official approval taskId as a JSON number", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) {
return Response.json({ accessToken: "token-1", expireIn: 7200 });
}
return Response.json({ result: true });
};
const client = new DingTalkClient(config, fakeFetch);
await client.executeOfficialApproval({
processInstanceId: "instance-1",
taskId: "222222222222",
actionerUserId: "manager-test",
result: "agree",
remark: "同意",
});
const request = calls.find((call) => call.url.endsWith("/v1.0/workflow/processInstances/execute"));
assert.ok(request);
const body = JSON.parse(String(request.init.body)) as Record<string, unknown>;
assert.equal(body.taskId, 222222222222);
assert.equal(typeof body.taskId, "number");
});
test("DingTalkClient reads and normalizes the authenticated user's department IDs", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.includes("/topapi/v2/user/get?")) return Response.json({ result: { dept_id_list: [123, "456", 123, 0, "invalid"] } });
throw new Error(`unexpected ${url}`);
};
const client = new DingTalkClient(config, fakeFetch);
assert.deepEqual(await client.getUserDepartmentIds("user-1"), [123, 456]);
const request = calls.find((call) => call.url.includes("/topapi/v2/user/get?"));
assert.deepEqual(JSON.parse(String(request?.init.body)), { userid: "user-1", language: "zh_CN" });
});
test("DingTalkClient forecasts through the enterprise API and creates with the authenticated gateway user", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.endsWith("/v1.0/workflow/processes/forecast")) {
return Response.json({ result: {
isForecastSuccess: true,
isStaticWorkflow: false,
workflowActivityRules: [{
activityId: "approve-1", activityName: "主管审批", activityType: "target_select", isTargetSelect: true,
activityActioners: [{ name: "测试主管", userId: "manager-1" }],
workflowActor: { actorKey: "manual-1", actorType: "approver", approvalMethod: "NONE" },
}],
workflowForecastNodes: [{ activityId: "approve-1", outId: "finish-1" }],
} });
}
if (url.endsWith("/v1.0/workflow/processInstances")) return Response.json({ result: "instance-new" });
throw new Error(`unexpected ${url}`);
};
const client = new DingTalkClient(config, fakeFetch);
const request = {
processCode: "PROC-1", originatorUserId: "user-1", deptId: -1,
formComponentValues: [{ name: "事由", value: "测试" }],
targetSelectActioners: [{ actionerKey: "manual-1", actionerStaffIds: ["manager-1"] }],
};
const forecast = await client.forecastOfficialProcess(request);
const instanceId = await client.createOfficialInstance(request);
assert.equal(forecast.forecastSuccess, true);
assert.equal(forecast.staticWorkflow, false);
assert.equal(forecast.supported, true);
assert.equal((forecast.workflowActivityRuleVOs as Array<Record<string, unknown>>)[0]?.targetSelect, true);
assert.equal(instanceId, "instance-new");
const forecastBody = JSON.parse(String(calls.find((call) => call.url.endsWith("/processes/forecast"))?.init.body)) as Record<string, unknown>;
assert.deepEqual(forecastBody, {
processCode: "PROC-1", userId: "user-1", deptId: -1,
formComponentValues: [{ name: "事由", value: "测试" }],
});
const createBody = JSON.parse(String(calls.find((call) => call.url.endsWith("/v1.0/workflow/processInstances"))?.init.body)) as Record<string, unknown>;
assert.equal(createBody.originatorUserId, "user-1");
assert.equal(createBody.processCode, "PROC-1");
assert.deepEqual(createBody.formComponentValues, [{ name: "事由", value: "测试" }]);
assert.deepEqual(createBody.targetSelectActioners, [{ actionerKey: "manual-1", actionerUserIds: ["manager-1"] }]);
});
test("DingTalkClient rejects failed or malformed enterprise forecasts before submission confirmation", async () => {
for (const result of [{ isForecastSuccess: false }, { processCode: "PROC-1" }]) {
const client = new DingTalkClient(config, async (input) => {
const url = String(input);
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.endsWith("/v1.0/workflow/processes/forecast")) return Response.json({ result });
throw new Error(`unexpected ${url}`);
});
await assert.rejects(client.forecastOfficialProcess({
processCode: "PROC-1", originatorUserId: "user-1", deptId: 123,
formComponentValues: [{ name: "事由", value: "测试" }],
}), /流程预测失败|缺少预测结果/);
}
});
test("DingTalkClient terminates an approval with server-derived operator identity", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch: FetchLike = async (input, init = {}) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.endsWith("/v1.0/workflow/processInstances/terminate")) return Response.json({ success: true });
throw new Error(`unexpected ${url}`);
};
const client = new DingTalkClient(config, fakeFetch);
await client.terminateOfficialInstance({ processInstanceId: "instance-1", operatingUserId: "user-1", remark: "测试撤销" });
const request = calls.find((call) => call.url.endsWith("/terminate"));
const body = JSON.parse(String(request?.init.body)) as Record<string, unknown>;
assert.deepEqual(body, { processInstanceId: "instance-1", operatingUserId: "user-1", remark: "测试撤销", isSystem: false });
});
test("ApprovalService revokes only the current user's running submission and verifies the transition", async () => {
let state = "RUNNING";
let terminateCalls = 0;
const fakeClient = {
async getOfficialInstance() { return { processInstanceId: "instance-1", originatorUserId: "user-1", status: state }; },
async terminateOfficialInstance(input: Record<string, unknown>) {
assert.equal(input.operatingUserId, "user-1");
terminateCalls += 1;
state = "TERMINATED";
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.revokeSubmission("user-1", "instance-1", "误发");
assert.equal(result.status, "revoked");
assert.equal(terminateCalls, 1);
state = "RUNNING";
(fakeClient as unknown as { getOfficialInstance(): Promise<Record<string, unknown>> }).getOfficialInstance = async () => ({
processInstanceId: "instance-1", originatorUserId: "other-user", status: state,
});
await assert.rejects(service.revokeSubmission("user-1", "instance-1"), /不是当前登录用户本人发起/);
assert.equal(terminateCalls, 1);
});
test("ApprovalService derives a submission department and rejects caller-selected foreign departments", async () => {
const fakeClient = {
async getUserDepartmentIds(userId: string) {
assert.equal(userId, "user-1");
return [123, 456];
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
assert.equal(await service.resolveSubmissionDepartment("user-1", -1), 123);
assert.equal(await service.resolveSubmissionDepartment("user-1", 456), 456);
await assert.rejects(service.resolveSubmissionDepartment("user-1", 999), /不属于当前登录用户/);
});
test("ApprovalService returns uncertain and does not retry an unconfirmed revoke write", async () => {
let terminateCalls = 0;
const fakeClient = {
async getOfficialInstance() { return { processInstanceId: "instance-1", originatorUserId: "user-1", status: "RUNNING" }; },
async terminateOfficialInstance() { terminateCalls += 1; throw new DingTalkApiError("timeout", 0); },
} as unknown as DingTalkClient;
const result = await new ApprovalService(fakeClient, config).revokeSubmission("user-1", "instance-1");
assert.equal(result.status, "outcome_uncertain");
assert.equal(terminateCalls, 1);
});
test("DingTalkClient retries temporary DingTalk QPS throttling", async () => {
let templateCalls = 0;
const fakeFetch: FetchLike = async (input) => {
const url = String(input);
if (url.endsWith("/v1.0/oauth2/accessToken")) {
return Response.json({ accessToken: "app-token", expireIn: 7200 });
}
templateCalls += 1;
if (templateCalls === 1) {
return Response.json({ code: "Forbidden.AccessDenied.QpsLimitForAppkeyAndApi", message: "qps流控" }, { status: 403 });
}
return Response.json({ result: { processList: [], nextToken: 0 } });
};
const client = new DingTalkClient(config, fakeFetch);
assert.deepEqual(await client.listOfficialTemplates(), []);
assert.equal(templateCalls, 2);
});
test("ApprovalService filters submission templates before applying the result limit", async () => {
const fakeClient = {
async listOfficialTemplates() {
return [
{ processCode: "BLOCKED", name: "其他申请", dirName: "其他分类" },
{ processCode: "ALLOWED-1", name: "请假", dirName: "北极大_人事假勤" },
{ processCode: "ALLOWED-2", name: "报销", dirName: "捷康_费用报销" },
];
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const templates = await service.listSubmissionTemplates(1, "", ["北极大_人事假勤", "捷康_费用报销"]);
assert.deepEqual(templates.map((item) => item.processCode), ["ALLOWED-1"]);
});
test("ApprovalService finds only NEW official tasks owned by current user", async () => {
const fakeClient = {
async listOfficialTodoWorkRecords() {
return {
list: [{ instance_id: "instance-1", task_id: 42, title: "采购电脑" }],
hasMore: false,
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 50);
assert.equal(result.items.length, 1);
assert.equal(result.items[0]!.taskId, "42");
assert.equal(result.items[0]!.source, "official_oa");
});
test("ApprovalService enriches official todo summaries without dropping items when one detail fails", async () => {
const fakeClient = {
async listOfficialTodoWorkRecords() {
return {
list: [
{ instance_id: "instance-1", task_id: 1, title: "费用审批", url: "https://aflow.dingtalk.com/dingtalk/mobile/homepage.htm?taskId=1" },
{ instance_id: "instance-2", task_id: 2, title: "备用审批", forms: [{ title: "发起人", content: "测试用户戊" }] },
],
hasMore: false,
};
},
async getOfficialInstance(processInstanceId: string) {
if (processInstanceId === "instance-2") throw new Error("detail unavailable");
return {
processCode: "PROC-SIGN",
originatorUserName: "测试用户丁",
originatorUserId: "user-bin",
originatorDeptName: "销售部",
createTime: "2026-08-08T08:30:00+08:00",
formComponentValues: [
{ name: "报销金额", value: "4812元" },
{ name: "报销事由", value: "客户出差" },
],
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 10);
assert.equal(result.items.length, 2);
assert.equal(result.items[0]!.applicantName, "测试用户丁");
assert.equal(result.items[0]!.applicantUserId, "user-bin");
assert.equal(result.items[0]!.applicantDepartment, "销售部");
assert.equal(result.items[0]!.amount, 4812);
assert.equal(result.items[0]!.summary, "客户出差");
assert.equal(result.items[0]!.processCode, "PROC-SIGN");
assert.match(result.items[0]!.clientUrl ?? "", /^https:\/\/aflow\.dingtalk\.com\//);
assert.equal(result.items[1]!.applicantName, "测试用户戊");
assert.equal(result.diagnostics.officialEnrichmentFailures, 1);
});
test("ApprovalService paginates direct official todo work records", async () => {
const offsets: number[] = [];
const firstPage = Array.from({ length: 20 }, (_value, index) => ({
instance_id: `instance-${index + 1}`,
task_id: index + 1,
}));
const fakeClient = {
async listOfficialTodoWorkRecords(_userId: string, offset: number) {
offsets.push(offset);
return offset === 0
? { list: firstPage, hasMore: true }
: { list: [{ instance_id: "instance-21", task_id: 21 }], hasMore: false };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 21);
assert.deepEqual(offsets, [0, 20]);
assert.equal(result.items.length, 21);
assert.equal(result.items.at(-1)!.processInstanceId, "instance-21");
});
test("ApprovalService stops on a short official todo page and removes duplicates", async () => {
let calls = 0;
const fakeClient = {
async listOfficialTodoWorkRecords() {
calls += 1;
return {
list: [
{ instance_id: "instance-1", task_id: 1 },
{ instance_id: "instance-1", task_id: 1 },
],
hasMore: true,
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.listMyTodos("user-1", "official_oa", 50);
assert.equal(calls, 1);
assert.equal(result.items.length, 1);
});
test("DingTalkClient queries completed work records and initiated instance IDs with server-derived user filters", async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const client = new DingTalkClient(config, async (input, init = {}) => {
const url = String(input);
const body = init.body ? JSON.parse(String(init.body)) as Record<string, unknown> : {};
calls.push({ url, body });
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
if (url.includes("/topapi/process/workrecord/task/query")) {
return Response.json({ errcode: 0, result: { list: [{ instance_id: "handled-1", task_id: 91 }], has_more: false } });
}
if (url.includes("/topapi/processinstance/listids")) {
return Response.json({ errcode: 0, result: { list: [{ process_instance_id: "submitted-1" }], next_cursor: "20" } });
}
throw new Error(`unexpected ${url}`);
});
const handled = await client.listOfficialTodoWorkRecords("user-1", 0, 20, 1);
const submitted = await client.listOfficialInitiatedInstanceIds("PROC-1", "user-1", 1000, 2000, 0, 20);
assert.equal(handled.list[0]?.instance_id, "handled-1");
assert.deepEqual(submitted, { instanceIds: ["submitted-1"], nextCursor: 20 });
assert.deepEqual(calls.find((call) => call.url.includes("workrecord/task/query"))?.body, {
userid: "user-1", offset: 0, count: 20, status: 1,
});
assert.deepEqual(calls.find((call) => call.url.includes("processinstance/listids"))?.body, {
process_code: "PROC-1", userid: "user-1", start_time: 1000, end_time: 2000, cursor: 0, size: 20,
});
});
test("ApprovalService lists and revalidates handled and submitted DingTalk history", async () => {
const statuses: number[] = [];
const initiatedRanges: Array<{ startTime: number; endTime: number }> = [];
const fakeClient = {
async listOfficialTodoWorkRecords(_userId: string, _offset: number, _count: number, status: number) {
statuses.push(status);
return { list: [{ instance_id: "handled-1", task_id: 91, title: "已处理采购" }], hasMore: false };
},
async listOfficialTemplates() { return [{ processCode: "PROC-1", name: "采购" }]; },
async listOfficialInitiatedInstanceIds(_processCode: string, _userId: string, startTime: number, endTime: number) {
initiatedRanges.push({ startTime, endTime });
return { instanceIds: ["submitted-1"] };
},
async getOfficialInstance(id: string) {
if (id === "handled-1") return {
processInstanceId: id, title: "已处理采购", status: "COMPLETED", result: "agree",
finishTime: "2026-08-15T02:00:00.000Z", originatorUserId: "other",
tasks: [{ taskId: 91, userId: "user-1", status: "COMPLETED", finishTime: "2026-08-14T02:00:00.000Z" }],
};
return {
processInstanceId: id, title: "本人差旅", status: "RUNNING", createTime: "2026-08-13T01:00:00.000Z",
originatorUserId: "user-1", tasks: [{ taskId: 92, userId: "manager", status: "NEW" }],
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const historyRange = { startTime: Date.parse("2026-08-01T00:00:00+08:00"), endTime: Date.parse("2026-08-31T23:59:59+08:00") };
const handled = await service.listMyHistory("user-1", "handled", 20, historyRange);
const submitted = await service.listMyHistory("user-1", "submitted", 20, historyRange);
assert.deepEqual(statuses, [1]);
assert.deepEqual(initiatedRanges, [historyRange]);
assert.equal(handled.items[0]?.historyScope, "handled");
assert.equal(handled.items[0]?.completedAt, "2026-08-14T02:00:00.000Z");
assert.equal(submitted.items[0]?.historyScope, "submitted");
assert.equal(submitted.items[0]?.submittedAt, "2026-08-13T01:00:00.000Z");
await service.getHistoryDetail("user-1", "handled", "handled-1", "91");
await service.getHistoryDetail("user-1", "submitted", "submitted-1");
await assert.rejects(service.getHistoryDetail("other", "submitted", "submitted-1"), /不是当前登录用户本人发起/);
});
test("ApprovalService rechecks official task ownership and returns partial batch results", async () => {
const executed: unknown[] = [];
const completed = new Set<string>();
const fakeClient = {
async getOfficialInstance(id: string) {
if (completed.has(id)) return { status: "COMPLETED", tasks: [{ taskId: 1, userId: "user-1", status: "COMPLETED" }] };
return id === "allowed"
? { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] }
: { status: "RUNNING", tasks: [{ taskId: 2, userId: "other", status: "NEW" }] };
},
async executeOfficialApproval(input: unknown) {
executed.push(input);
completed.add((input as { processInstanceId: string }).processInstanceId);
return { result: true };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.processBatch("user-1", "agree", "同意", [
{ source: "official_oa", processInstanceId: "allowed", taskId: "1" },
{ source: "official_oa", processInstanceId: "denied", taskId: "2" },
]);
assert.equal(result.succeeded, 1);
assert.equal(result.failed, 1);
assert.equal(executed.length, 1);
assert.deepEqual(executed[0], {
processInstanceId: "allowed",
taskId: "1",
actionerUserId: "user-1",
result: "agree",
remark: "同意",
});
});
test("ApprovalService accepts RUNNING as an official pending task status", async () => {
const executed: unknown[] = [];
let completed = false;
const fakeClient = {
async getOfficialInstance() {
if (completed) return { status: "COMPLETED", tasks: [{ taskId: 9, userId: "user-1", status: "COMPLETED" }] };
return { status: "RUNNING", tasks: [{ taskId: 9, userId: "user-1", status: "RUNNING", result: "NONE" }] };
},
async executeOfficialApproval(input: unknown) {
executed.push(input);
completed = true;
return { result: true };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.processBatch("user-1", "agree", "", [
{ source: "official_oa", processInstanceId: "instance-1", taskId: "9" },
]);
assert.equal(result.succeeded, 1);
assert.equal(executed.length, 1);
});
test("ApprovalService distinguishes a follow-up node from an unchanged original task after submit", async () => {
const reads = new Map<string, number>();
const fakeClient = {
async getOfficialInstance(id: string) {
const read = (reads.get(id) ?? 0) + 1;
reads.set(id, read);
if (read === 1) return { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] };
if (id === "follow-up") return { status: "RUNNING", tasks: [{ taskId: 2, userId: "user-1", status: "NEW" }] };
return { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] };
},
async executeOfficialApproval() { return { result: true }; },
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
const result = await service.processBatch("user-1", "agree", "同意", [
{ source: "official_oa", processInstanceId: "follow-up", taskId: "1" },
{ source: "official_oa", processInstanceId: "unchanged", taskId: "1" },
]);
assert.equal(result.succeeded, 1);
assert.equal(result.failed, 1);
assert.equal(result.results[0]!.followUpRequired, true);
assert.equal(result.results[0]!.completed, false);
assert.equal(result.results[0]!.nextTaskId, "2");
assert.match(String(result.results[1]!.error), /原任务仍在当前用户实时待办/);
});
test("ApprovalService reads Yida task field aliases and nested result pages", async () => {
const fakeClient = {
async listYidaCorpTasks() {
return {
result: {
data: [{
appType: "APP_A",
formInstId: "form-1",
taskId: "task-1",
taskStatus: "RUNNING",
processInstanceTitle: "宜搭采购",
}],
},
};
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, yidaConfig);
const result = await service.listMyTodos("user-1", "yida", 50);
assert.equal(result.items.length, 1);
assert.equal(result.items[0]!.processInstanceId, "form-1");
assert.equal(result.items[0]!.title, "宜搭采购");
});
test("ApprovalService validates and groups Yida approvals before batch execution", async () => {
const calls: unknown[] = [];
const fakeClient = {
async listYidaCorpTasks() {
return {
data: [
{ appType: "APP_A", formInstId: "form-1", taskId: "task-1", taskStatus: "RUNNING" },
{ appType: "APP_A", processInstanceId: "form-2", taskId: "task-2", status: "NEW" },
],
};
},
async listYidaApplications() {
return [{ appType: "APP_A", systemToken: "system-token" }];
},
async executeYidaBatch(app: unknown, userId: string, action: string, remark: string, tasks: unknown[]) {
calls.push({ app, userId, action, remark, tasks });
return { success: true };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, yidaConfig);
const result = await service.processBatch("user-1", "agree", "同意", [
{ source: "yida", appType: "APP_A", processInstanceId: "form-1", taskId: "task-1" },
{ source: "yida", appType: "APP_A", processInstanceId: "form-2", taskId: "task-2" },
{ source: "yida", appType: "APP_A", processInstanceId: "stale", taskId: "task-old" },
]);
assert.equal(result.succeeded, 2);
assert.equal(result.failed, 1);
assert.equal(calls.length, 1);
assert.deepEqual((calls[0] as { tasks: unknown[] }).tasks, [
{ taskId: "task-1", formInstId: "form-1" },
{ taskId: "task-2", formInstId: "form-2" },
]);
});
test("ApprovalService rejects official detail without a current task for the user", async () => {
const fakeClient = {
async getOfficialInstance() {
return { status: "RUNNING", tasks: [{ taskId: 1, userId: "another-user", status: "NEW" }] };
},
} as unknown as DingTalkClient;
const service = new ApprovalService(fakeClient, config);
await assert.rejects(
service.getDetail("user-1", { source: "official_oa", processInstanceId: "instance-1" }),
/当前没有属于登录用户的待审批任务/,
);
});
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
test("customer distribution is a generic single-login gateway bridge", () => {
const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
assert.match(packageMetadata.version, /^\d+\.\d+\.\d+-rc\.\d+$/);
assert.equal(existsSync("plugins/dingtalk-approval"), false);
const installer = readFileSync("distribution/agent-install.template.md", "utf8");
assert.match(installer, /Ed25519/);
assert.match(installer, /唯一通用安装入口/);
assert.match(installer, /使用 `mcpConfig`/);
assert.match(installer, /安装器本身不会猜测或修改 Agent 的私有配置/);
assert.doesNotMatch(installer, /--host|WorkBuddy|Codex|Claude/);
assert.match(installer, /不会通过其他消息通道自动推送链接.*完整登录 URL.*当前回复/s);
assert.match(installer, /读取返回的 `postInstall`.*不得等到用户第一次查询审批/s);
assert.match(installer, /onboarding\.firstRun=true.*简短示例.*不要自动执行/s);
assert.doesNotMatch(installer, /通过当前 Messenger/);
assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8");
assert.match(skill, /XFT recall is experimental.*XFTSYS9999/s);
assert.match(skill, /does not send or push.*current user-facing reply/s);
assert.match(skill, /handle the installer's `postInstall` result immediately.*waiting for the user's first approval request/s);
assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s);
const builder = readFileSync("scripts/build-release.mjs", "utf8");
assert.match(builder, /schemaVersion: 3,\s+version,/s);
assert.match(builder, /signData\(null, manifestBytes/);
assert.match(builder, /dingtalk-approval-client/);
assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/);
assert.doesNotMatch(builder, /dingtalk-workspace-cli|DWS_URL|DWS_SHA256/);
});
test("public install page explicitly declares UTF-8 for browser rendering", () => {
const nginx = readFileSync("deploy/approval.justconn.top.nginx.conf", "utf8");
const installLocation = nginx.match(/location = \/downloads\/dingmcp\/install\.md \{([\s\S]*?)\n \}/)?.[1] ?? "";
assert.match(installLocation, /default_type text\/markdown;/);
assert.match(installLocation, /charset utf-8;/);
assert.match(installLocation, /charset_types text\/markdown;/);
});
+20
View File
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { pathToFileURL } from "node:url";
import { isDirectEntrypoint } from "../src/entrypoint.js";
test("packaged CLI entrypoint recognizes an npm-style symlink", () => {
const root = mkdtempSync(join(tmpdir(), "ding-entrypoint-"));
const target = join(root, "dist", "bridge.js");
const bin = join(root, "dingtalk-approval-bridge");
mkdirSync(join(root, "dist"));
writeFileSync(target, "", { flag: "w" });
symlinkSync(target, bin);
assert.equal(isDirectEntrypoint(pathToFileURL(target).href, bin), true);
assert.equal(isDirectEntrypoint(pathToFileURL(target).href, join(root, "other")), false);
assert.equal(isDirectEntrypoint(pathToFileURL(target).href, undefined), false);
assert.equal(resolve(target), target);
});
+251
View File
@@ -0,0 +1,251 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import test from "node:test";
import type { AppConfig } from "../src/config.js";
import type { ApprovalService } from "../src/approval-service.js";
import {
extractInvoiceEvidence,
InvoiceDeduplicationService,
InvoiceDedupStore,
} from "../src/invoice-dedup.js";
import type { XftApprovalProvider } from "../src/xft.js";
import { UnifiedApprovalService } from "../src/unified-approval.js";
const secret = "invoice-dedup-test-secret-with-at-least-32-bytes";
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: 1000, officialLookbackDays: 120, officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: secret,
};
}
test("invoice evidence recognizes exact aliases and avoids unrelated sensitive numbers", () => {
const evidence = extractInvoiceEvidence({
invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321", invoiceDate: "2026-08-01" }],
certificate: { invoiceCloudCertificates: [{ digitalInvoiceNumber: "25312000000012345678" }] },
formFields: [
{ label: "发票代码", value: "044031900222" },
{ label: "发票号码", value: "11223344" },
{ label: "银行卡号", value: "6222021234567890" },
{ label: "身份证号", value: "440301199001011234" },
],
expenseList: [{ amount: 88.5, invoiceDate: "2026-08-01", merchantName: "深圳测试商户" }],
});
assert.ok(evidence.some((item) => item.kind === "invoice_code_number" && item.normalized.includes("87654321")));
assert.ok(evidence.some((item) => item.kind === "invoice_code_number" && item.normalized.includes("11223344")));
assert.ok(evidence.some((item) => item.kind === "digital_invoice_number"));
assert.ok(evidence.some((item) => item.kind === "expense_signature"));
assert.equal(evidence.some((item) => item.normalized.includes("6222021234567890")), false);
assert.equal(evidence.some((item) => item.normalized.includes("440301199001011234")), false);
assert.ok(evidence.every((item) => !item.maskedDisplay.includes("87654321")));
});
test("invoice store persists only HMAC fingerprints and isolates users across restart", () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-dedup-"));
const path = join(root, "dedup.sqlite");
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
const first = new InvoiceDedupStore(path, secret);
const userOne = first.userHash("private-user-one");
first.indexDetail(userOne, "xft", "business-1", "pending", "2026-08-01T01:00:00.000Z", evidence);
assert.ok(first.findMatches(userOne, evidence).length >= 1);
assert.equal(first.findMatches(first.userHash("private-user-two"), evidence).length, 0);
const restarted = new InvoiceDedupStore(path, secret);
assert.ok(restarted.findMatches(restarted.userHash("private-user-one"), evidence).length >= 1);
const databaseBytes = readFileSync(path).toString("latin1");
assert.doesNotMatch(databaseBytes, /044031900111|87654321|private-user-one/);
});
test("invoice fingerprints expire automatically after the bounded retention window", () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-retention-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
const userHash = store.userHash("user-1");
const now = Date.now();
store.indexDetail(userHash, "xft", "expired-business", "approved", new Date(now - 36 * 86_400_000).toISOString(), evidence);
store.indexDetail(userHash, "xft", "retained-business", "approved", new Date(now - 34 * 86_400_000).toISOString(), evidence);
assert.equal(store.findMatches(userHash, evidence).length, 4);
store.cleanupExpired(now);
const retained = store.findMatches(userHash, evidence);
assert.equal(retained.length, 2);
assert.ok(retained.every((item) => item.business_no === "retained-business"));
const expiredReservation = "expired-reservation";
assert.equal(store.reserve(userHash, evidence, expiredReservation, now - 1), true);
store.cleanupExpired(now);
assert.equal(store.reserve(userHash, evidence, "replacement-reservation", now + 60_000), true);
});
test("dedup preparation blocks historical duplicates and concurrent confirmations", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-prepare-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const detailCalls: string[] = [];
let observedRange: { startTime: number; endTime: number } | undefined;
const ding = {
async listMyHistory(_userId: string, _scope: string, _limit: number, range: { startTime: number; endTime: number }) {
observedRange = range;
return { items: [{
source: "official_oa", historyScope: "submitted", processInstanceId: "oa-existing",
status: "COMPLETED", result: "agree", submittedAt: "2026-08-01T01:00:00.000Z",
}], diagnostics: {} };
},
async getHistoryDetail() {
detailCalls.push("dingtalk");
return { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
},
} as unknown as ApprovalService;
const xft = {
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; },
} as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const user = { userId: "user-1" };
const values = { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
const first = await service.prepare(user, values, false, Date.now() + 600_000);
assert.equal(first.duplicateCheck.status, "confirmation_required");
assert.equal(first.duplicateCheck.matches[0]?.source, "dingtalk");
assert.equal(first.duplicateCheck.matches[0]?.disposition, "blocking");
assert.equal(first.reservationId, undefined);
assert.ok(observedRange);
assert.ok(Math.abs((observedRange!.endTime - observedRange!.startTime) - 30 * 86_400_000) < 1_000);
const confirmed = await service.prepare(user, values, true, Date.now() + 600_000);
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
assert.ok(confirmed.reservationId);
service.assertReservation(user, confirmed.reservationId);
const concurrent = await service.prepare(user, values, true, Date.now() + 600_000);
assert.equal(concurrent.reservationId, undefined);
assert.equal(concurrent.duplicateCheck.requiresUserConfirmation, true);
assert.match(concurrent.duplicateCheck.message, /另一个尚未过期的提单确认/);
assert.equal(detailCalls.length, 1, "already indexed history details are not downloaded again");
});
test("incomplete cross-source coverage is explicit and requires an override", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-partial-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService;
const xft = { async listMyHistory() { throw new Error("temporary XFT outage"); } } as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const values = { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
const blocked = await service.prepare({ userId: "user-1" }, values, false, Date.now() + 600_000);
assert.equal(blocked.duplicateCheck.status, "check_unavailable");
assert.equal(blocked.duplicateCheck.coverageComplete, false);
assert.equal(blocked.reservationId, undefined);
const confirmed = await service.prepare({ userId: "user-1" }, values, true, Date.now() + 600_000);
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
assert.ok(confirmed.reservationId);
});
test("a reimbursement with no usable invoice evidence never reports a clean check", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-missing-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const service = new InvoiceDeduplicationService(config(), store, {} as ApprovalService);
const blocked = await service.prepare(
{ userId: "user-1" }, { reimAmount: 100 }, false, Date.now() + 600_000, true,
);
assert.equal(blocked.duplicateCheck.status, "check_unavailable");
assert.equal(blocked.duplicateCheck.requiresUserConfirmation, true);
const confirmed = await service.prepare(
{ userId: "user-1" }, { reimAmount: 100 }, true, Date.now() + 600_000, true,
);
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
});
test("rejected prior bills warn instead of masquerading as active approvals", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-rejected-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
store.indexDetail(store.userHash("user-1"), "xft", "xft-rejected", "rejected", "2026-08-01T00:00:00.000Z", evidence);
const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService;
const xft = { async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; } } as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const result = await service.prepare(
{ userId: "user-1" }, { invoiceCode: "044031900111", invoiceNumber: "87654321" }, false, Date.now() + 600_000,
);
assert.equal(result.duplicateCheck.status, "confirmation_required");
assert.equal(result.duplicateCheck.matches[0]?.status, "rejected");
assert.equal(result.duplicateCheck.matches[0]?.disposition, "warning");
});
test("truncated history and detail failures make duplicate coverage incomplete", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-truncated-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const ding = {
async listMyHistory() {
return { items: [{
source: "official_oa", historyScope: "submitted", processInstanceId: "detail-fails", status: "RUNNING",
}], diagnostics: {} };
},
async getHistoryDetail() { throw new Error("detail unavailable"); },
} as unknown as ApprovalService;
const xft = {
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: { truncated: true } }; },
} as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
const result = await service.prepare(
{ userId: "user-1" }, { invoiceCode: "044031900111", invoiceNumber: "87654321" }, false, Date.now() + 600_000,
);
assert.equal(result.duplicateCheck.status, "check_unavailable");
assert.equal(result.duplicateCheck.sourceStatus.dingtalk?.status, "partial");
assert.equal(result.duplicateCheck.sourceStatus.xft?.status, "partial");
});
test("unified submission requires a duplicate override and commits the successful business number", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-invoice-unified-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const ding = {
async listMyHistory() {
return { items: [{
source: "official_oa", historyScope: "submitted", processInstanceId: "oa-prior",
status: "RUNNING", submittedAt: "2026-08-01T01:00:00.000Z",
}], diagnostics: {} };
},
async getHistoryDetail() {
return { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
},
} as unknown as ApprovalService;
const template = {
eventSeqId: "event-1", eventNumber: "XFT-1", eventName: "费用申请",
eventType: "EXPENSE_APPLY", kind: "expense_apply" as const,
};
let writes = 0;
const xft = {
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; },
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
async submitApproval() { writes += 1; return { billId: "2026081500000001", status: "submitted", diagnostics: {} }; },
async getMySubmissionStatus() {
return { billId: "2026081500000001", status: "1", statusName: "待审批", revoked: false, revokeAllowed: true, diagnostics: {} };
},
} as unknown as XftApprovalProvider;
const dedup = new InvoiceDeduplicationService(config(), store, ding, xft);
const unified = new UnifiedApprovalService(config(), ding, xft, undefined, undefined, dedup);
const user = { userId: "user-1" };
const templateId = String((await unified.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const values = {
applyExpenses: [{ expenseNumber: "MEAL", amount: 88.5 }],
invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }],
};
const blocked = await unified.prepareSubmission(user, { templateId, values });
assert.equal(blocked.status, "duplicate_confirmation_required");
assert.equal(blocked.confirmationId, undefined);
assert.equal(writes, 0);
const prepared = await unified.prepareSubmission(user, { templateId, values, allowDuplicateInvoices: true });
assert.equal(prepared.status, "confirmation_required");
assert.ok(prepared.confirmationId);
const submitted = await unified.submitApproval(
user, prepared.confirmationId!, { templateId, values, allowDuplicateInvoices: true },
);
assert.equal(submitted.status, "submitted");
assert.equal(writes, 1);
const matches = store.findMatches(store.userHash(user.userId), extractInvoiceEvidence(values));
assert.ok(matches.some((item) => item.business_no === "2026081500000001"));
});
+210
View File
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { AppConfig } from "../src/config.js";
import { DingTalkMcpOAuthProvider } from "../src/oauth.js";
function config(databasePath: string): AppConfig {
return {
clientId: "ding-app-key", clientSecret: "ding-app-secret", corpId: "ding-corp",
apiBaseUrl: "https://api.example.test", oapiBaseUrl: "https://oapi.example.test",
publicBaseUrl: "https://approval.example.test", bindHost: "127.0.0.1", port: 3000,
requestTimeoutMs: 1000, officialLookbackDays: 120, officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: "ref-secret",
remoteAuth: {
issuerUrl: "https://approval.example.test", databasePath, sessionSecret: "session-secret",
accessTokenTtlSeconds: 900, refreshTokenTtlSeconds: 3600,
},
};
}
function jsonResponse(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
}
test("OAuth provider performs DCR, DingTalk login, PKCE exchange and refresh rotation", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-oauth-"));
const database = join(root, "auth.sqlite");
const requests: Array<{ url: string; body?: Record<string, unknown> }> = [];
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : undefined;
requests.push({ url, body });
if (url.endsWith("/v1.0/oauth2/userAccessToken") && body?.grantType === "authorization_code") {
return jsonResponse({ accessToken: "ding-user-access", refreshToken: "ding-user-refresh", expireIn: 7200, corpId: "ding-corp" });
}
if (url.endsWith("/v1.0/contact/users/me")) {
return jsonResponse({ userId: "user-1", nick: "测试用户", corpId: "ding-corp" });
}
throw new Error(`unexpected request ${url}`);
};
const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn);
try {
const client = await provider.clientsStore.registerClient!({
redirect_uris: ["http://127.0.0.1:49152/callback"], token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
client_name: "test-agent",
});
let upstream = "";
await provider.authorize(client, {
redirectUri: "http://127.0.0.1:55001/callback", codeChallenge: "pkce-challenge",
scopes: ["mcp:tools"], state: "agent-state", resource: new URL("https://approval.example.test/mcp"),
}, { redirect: (_status: number, value: string) => { upstream = value; } } as never);
const loginState = new URL(upstream).searchParams.get("login_state");
assert.ok(loginState);
assert.deepEqual(provider.consentDetails(loginState!), {
clientName: "test-agent",
scopes: ["mcp:tools"],
redirectOrigin: "http://127.0.0.1:55001",
});
upstream = provider.approveConsent(loginState!);
assert.equal(new URL(upstream).searchParams.get("state"), loginState);
const callback = await provider.completeDingTalkLogin("ding-code", loginState!);
const returned = new URL(callback);
const authorizationCode = returned.searchParams.get("code");
assert.ok(authorizationCode);
assert.equal(returned.searchParams.get("state"), "agent-state");
assert.equal(await provider.challengeForAuthorizationCode(client, authorizationCode!), "pkce-challenge");
const tokens = await provider.exchangeAuthorizationCode(
client, authorizationCode!, "verifier", returned.origin + returned.pathname,
new URL("https://approval.example.test/mcp"),
);
const auth = await provider.verifyAccessToken(tokens.access_token);
assert.equal(auth.extra?.userId, "user-1");
assert.equal(await provider.getDingTalkUserAccessToken("user-1"), "ding-user-access");
await assert.rejects(provider.exchangeAuthorizationCode(client, authorizationCode!), /无效/);
const rotated = await provider.exchangeRefreshToken(client, tokens.refresh_token!);
await assert.rejects(provider.exchangeRefreshToken(client, tokens.refresh_token!), /无效/);
assert.equal((await provider.verifyAccessToken(rotated.access_token)).extra?.userId, "user-1");
await provider.revokeToken(client, { token: rotated.access_token });
await assert.rejects(provider.verifyAccessToken(rotated.access_token), /无效/);
const file = readFileSync(database);
assert.equal(file.includes(Buffer.from(tokens.access_token)), false);
assert.equal(file.includes(Buffer.from(tokens.refresh_token!)), false);
assert.equal(requests[0]!.body?.clientSecret, "ding-app-secret");
} finally { provider.close(); }
});
test("OAuth provider requires explicit client consent before DingTalk callback completion", async () => {
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-consent-")), "auth.sqlite");
let upstreamCalls = 0;
const provider = new DingTalkMcpOAuthProvider(config(database), async () => {
upstreamCalls += 1;
throw new Error("consent bypass must not reach DingTalk");
});
try {
const client = await provider.clientsStore.registerClient!({
redirect_uris: ["https://agent.example/callback"], token_endpoint_auth_method: "none",
grant_types: ["authorization_code"], response_types: ["code"], client_name: "untrusted-test-client",
});
let consentUrl = "";
await provider.authorize(client, {
redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"],
}, { redirect: (_status: number, value: string) => { consentUrl = value; } } as never);
const loginState = new URL(consentUrl).searchParams.get("login_state");
assert.ok(loginState);
await assert.rejects(provider.completeDingTalkLogin("code", loginState!), /尚未确认/);
assert.equal(upstreamCalls, 0);
} finally { provider.close(); }
});
test("OAuth provider refreshes a DingTalk identity once for concurrent callers", async () => {
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-refresh-")), "auth.sqlite");
let refreshCalls = 0;
const provider = new DingTalkMcpOAuthProvider(config(database), async (input, init) => {
const url = String(input);
const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : {};
if (url.endsWith("/v1.0/oauth2/userAccessToken") && body.grantType === "authorization_code") {
return jsonResponse({ accessToken: "initial-access", refreshToken: "refresh-1", expireIn: 1, corpId: "ding-corp" });
}
if (url.endsWith("/v1.0/oauth2/userAccessToken") && body.grantType === "refresh_token") {
refreshCalls += 1;
await new Promise<void>((resolve) => setTimeout(resolve, 20));
return jsonResponse({ accessToken: "fresh-access", refreshToken: "refresh-2", expireIn: 7200, corpId: "ding-corp" });
}
if (url.endsWith("/v1.0/contact/users/me")) return jsonResponse({ userId: "user-1", nick: "测试用户", corpId: "ding-corp" });
throw new Error(`unexpected request ${url}`);
});
try {
const client = await provider.clientsStore.registerClient!({
redirect_uris: ["https://agent.example/callback"], token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], client_name: "agent",
});
let consentUrl = "";
await provider.authorize(client, {
redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"],
}, { redirect: (_status: number, value: string) => { consentUrl = value; } } as never);
const loginState = new URL(consentUrl).searchParams.get("login_state")!;
provider.approveConsent(loginState);
await provider.completeDingTalkLogin("code", loginState);
const tokens = await Promise.all([
provider.getDingTalkUserAccessToken("user-1"),
provider.getDingTalkUserAccessToken("user-1"),
provider.getDingTalkUserAccessToken("user-1"),
]);
assert.deepEqual(tokens, ["fresh-access", "fresh-access", "fresh-access"]);
assert.equal(refreshCalls, 1);
} finally { provider.close(); }
});
test("OAuth provider rejects unsafe redirects and users from another corp", async () => {
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-corp-")), "auth.sqlite");
const fetchFn: typeof fetch = async (input) => String(input).endsWith("/userAccessToken")
? jsonResponse({ accessToken: "foreign-token", refreshToken: "refresh", expireIn: 7200, corpId: "other-corp" })
: jsonResponse({});
const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn);
try {
await assert.rejects(async () => provider.clientsStore.registerClient!({
redirect_uris: ["http://attacker.example/callback"],
token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"],
}), /HTTPS/);
const client = await provider.clientsStore.registerClient!({
redirect_uris: ["https://agent.example/callback"],
token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"],
});
let upstream = "";
await provider.authorize(client, {
redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"],
}, { redirect: (_status: number, value: string) => { upstream = value; } } as never);
const loginState = new URL(upstream).searchParams.get("login_state");
assert.ok(loginState);
provider.approveConsent(loginState!);
await assert.rejects(
provider.completeDingTalkLogin("code", loginState!),
/不属于允许的企业/,
);
} finally { provider.close(); }
});
test("device compatibility flow returns a link, polls, and issues a user-bound token", async () => {
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-device-")), "auth.sqlite");
const fetchFn: typeof fetch = async (input) => String(input).endsWith("/userAccessToken")
? jsonResponse({ accessToken: "device-user-token", refreshToken: "device-refresh", expireIn: 7200, corpId: "ding-corp" })
: jsonResponse({ userId: "device-user", nick: "设备用户", corpId: "ding-corp" });
const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn);
try {
const client = await provider.clientsStore.registerClient!({
redirect_uris: ["https://localhost.invalid/device"], token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
});
const challenge = provider.startDeviceAuthorization(client.client_id, ["mcp:tools"], "https://approval.example.test/mcp");
assert.match(challenge.user_code, /^[A-Z2-9]{4}-[A-Z2-9]{4}$/);
assert.equal(provider.exchangeDeviceCode(client.client_id, challenge.device_code), "authorization_pending");
const upstream = provider.beginDeviceLogin(challenge.user_code.toLowerCase());
const state = new URL(upstream).searchParams.get("login_state");
assert.ok(state);
provider.approveConsent(state!);
assert.equal(await provider.completeDingTalkLogin("device-code", state!), "https://approval.example.test/oauth/device/success");
const tokens = provider.exchangeDeviceCode(client.client_id, challenge.device_code);
assert.notEqual(typeof tokens, "string");
if (typeof tokens !== "string") {
assert.equal((await provider.verifyAccessToken(tokens.access_token)).extra?.userId, "device-user");
}
assert.throws(() => provider.exchangeDeviceCode(client.client_id, challenge.device_code), /无效/);
} finally { provider.close(); }
});
+247
View File
@@ -0,0 +1,247 @@
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() ?? "", /最多(?:处理 )?20 条.*零写入.*禁止自动.*重试/s);
assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/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 ?? "", /最多 20 条.*任一无效.*零写入/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);
});
+1180
View File
File diff suppressed because it is too large Load Diff
+706
View File
@@ -0,0 +1,706 @@
import assert from "node:assert/strict";
import test from "node:test";
import smCrypto from "sm-crypto";
import type { AppConfig } from "../src/config.js";
import { XftOpenApiClient } from "../src/xft-open-api.js";
const privateKey = "1".padStart(64, "0");
const { sm3, sm4 } = smCrypto;
function config(): AppConfig {
return {
clientId: "ding-app",
clientSecret: "ding-secret",
apiBaseUrl: "https://ding.test",
oapiBaseUrl: "https://ding-oapi.test",
publicBaseUrl: "http://localhost:3000",
trustedLocalUser: { userId: "ding-u1" },
port: 3000,
requestTimeoutMs: 2_000,
officialLookbackDays: 120,
officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [],
approvalRefSecret: "fixed-reference-secret-for-tests",
batchLimit: 20,
xftOpenApi: {
appId: "app-id",
authoritySecret: privateKey,
enterpriseId: "TEST0001",
apiBaseUrl: "https://xft-api.test",
encrypted: true,
dingTalkAppCode: "DDingTalk",
},
};
}
function encrypted(value: unknown): Response {
return new Response(String(sm4.encrypt(JSON.stringify(value), privateKey.slice(0, 32))), {
headers: { "content-type": "text/plain" },
});
}
function decodedBody(init?: RequestInit): Record<string, unknown> {
const envelope = JSON.parse(String(init?.body)) as { secretMsg: string };
return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record<string, unknown>;
}
test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => {
const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = [];
const fakeFetch: typeof fetch = async (input, init) => {
const url = new URL(String(input));
const body = decodedBody(init);
const headers = new Headers(init?.headers);
calls.push({ path: url.pathname, body, headers, url });
if (url.pathname.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1", userName: "用户一" }] } });
if (url.pathname.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010100000001", appCode: "xft-bpm", businessCode: "OA000001" }], totalSize: 1 } });
if (url.pathname.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010100000001", appCode: "TEST0001_TRIP", businessCode: "TEST0001_TRIP_TRIP_2099010100000002" }] });
if (url.pathname.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010100000001", eventType: "COMMON_REIM", title: "费用报销", applicantName: "测试用户丁", totalAmount: 4812 }] } });
throw new Error(`unexpected ${url.pathname}`);
};
const client = new XftOpenApiClient(config(), fakeFetch);
const result = await client.listMyTodos({ userId: "ding-u1" }, 50);
assert.equal(result.items[0]?.billId, "2099010100000001");
assert.equal(result.items[0]?.amount, 4812);
assert.equal(result.identity.enterpriseUserId, "EMP-1");
assert.deepEqual(calls[0]?.body.thirdStaffIds, ["ding-u1"]);
assert.equal(calls[0]?.body.appCode, "DDingTalk");
for (const call of calls) {
assert.equal(call.url.searchParams.get("CSCAPPUID"), "app-id");
assert.equal(call.url.searchParams.get("OPAUID"), "TEST0001");
assert.equal(call.headers.get("x-alb-verify"), "sm3withsm2");
assert.equal(call.headers.get("apisign")?.length, 128);
assert.equal(call.headers.get("x-alb-digest"), sm3(JSON.stringify({ secretMsg: sm4.encrypt(JSON.stringify(call.body), privateKey.slice(0, 32)) })));
}
const todoCall = calls.find((call) => call.path.endsWith("todo-task/page/by-user"));
assert.equal(todoCall?.url.searchParams.get("CSCUSRNBR"), "EMP-1");
assert.equal(todoCall?.url.searchParams.get("CSCPRJCOD"), "TEST0001");
});
test("Xft OpenAPI reads the official billAmount in todo summaries", async () => {
const fakeFetch: typeof fetch = async (input, init) => {
const path = new URL(String(input)).pathname;
void decodedBody(init);
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010200000001", appCode: "xft-bpm", businessCode: "OA000001" }] } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010200000001", appCode: "TEST0001_TRIP" }] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010200000001", eventType: "COMMON_REIM", billAmount: 2336 }] } });
throw new Error(`unexpected ${path}`);
};
const client = new XftOpenApiClient(config(), fakeFetch);
const result = await client.listMyTodos({ userId: "ding-u1" }, 50);
assert.equal(result.items[0]?.amount, 2336);
});
test("Xft OpenAPI lists handled tasks and本人 submitted bills as separate read-only history scopes", async () => {
const calls: Array<{ path: string; body: Record<string, unknown> }> = [];
const fakeFetch: typeof fetch = async (input, init) => {
const path = new URL(String(input)).pathname;
const body = decodedBody(init);
calls.push({ path, body });
if (path.includes("user-mapping")) {
return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
}
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
todoTaskId: "DONE-1", groupId: "TRIP_2026081400000001", appCode: "xft-bpm", businessCode: "OA000001",
dealTime: "2026-08-14T04:00:00.000Z",
}] } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{
todoTaskId: "DONE-1", businessId: "TRIP_2026081400000001", appCode: "TEST0001_TRIP",
}] });
if (path.endsWith("bills/queryBillByCondition")) return encrypted({ code: "0", data: { pageData: {
records: [
{ billId: "2026081400000002", billSts: "1", eventType: "COMMON_REIM", applyTime: "2026-08-14T05:00:00.000Z" },
{ billId: "2026070100000003", billSts: "2", eventType: "COMMON_REIM", applyTime: "2026-07-01T05:00:00.000Z" },
], total: 2,
} } });
if (path.endsWith("bill-list")) {
const ids = body.billIds as Array<string | number>;
return encrypted({ code: "0", data: { list: ids.map((billId) => ({
billId: String(billId), eventType: "COMMON_REIM",
applyTime: String(billId).endsWith("3") ? "2026-07-01T05:00:00.000Z" : "2026-08-14T03:00:00.000Z",
title: String(billId).endsWith("1") ? "已处理报销" : "本人发起报销",
applicantName: String(billId).endsWith("1") ? "同事甲" : "用户一", billAmount: 88,
})) } });
}
throw new Error(`unexpected ${path}`);
};
const client = new XftOpenApiClient(config(), fakeFetch);
const historyRange = { startTime: Date.parse("2026-08-01T00:00:00+08:00"), endTime: Date.parse("2026-08-31T23:59:59+08:00") };
const handled = await client.listMyHistory({ userId: "ding-u1" }, "handled", 20, "", historyRange);
const submitted = await client.listMyHistory({ userId: "ding-u1" }, "submitted", 20, "", historyRange);
assert.equal(handled.items[0]?.billId, "2026081400000001");
assert.equal(handled.items[0]?.historyScope, "handled");
assert.equal("allowPass" in handled.items[0]!, false);
assert.equal(submitted.items[0]?.billId, "2026081400000002");
assert.equal(submitted.items.length, 1, "submitted history excludes bills outside the requested time range");
assert.equal(submitted.items[0]?.historyScope, "submitted");
assert.equal(calls.find((call) => call.path.endsWith("todo-task/page/by-user"))?.body.dealStatus, "done");
assert.deepEqual(calls.find((call) => call.path.endsWith("bills/queryBillByCondition"))?.body, {
empId: "E001", start: 0, limit: 100,
});
});
test("Xft OpenAPI does not turn missing or blank amounts into zero", async () => {
for (const billAmount of [null, ""]) {
const fakeFetch: typeof fetch = async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010200000001", appCode: "xft-bpm", businessCode: "OA000001" }] } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010200000001", appCode: "TEST0001_TRIP" }] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010200000001", eventType: "COMMON_REIM", billAmount }] } });
throw new Error(`unexpected ${path}`);
};
const result = await new XftOpenApiClient(config(), fakeFetch).listMyTodos({ userId: "ding-u1" }, 50);
assert.equal(result.items[0]?.amount, undefined);
}
});
test("Xft OpenAPI continues past an irrelevant full first todo page", async () => {
const pages: number[] = [];
const irrelevant = Array.from({ length: 100 }, (_, index) => ({
todoTaskId: `OTHER-${index}`, groupId: `OTHER-${index}`, appCode: "other-app",
}));
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) {
const page = Number(decodedBody(init).currentPage);
pages.push(page);
return encrypted({ code: "0", data: { records: page === 1 ? irrelevant : [{
todoTaskId: "TD-TARGET", groupId: "2026081400000001", appCode: "xft-itrip",
}] } });
}
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{
billId: "2026081400000001", eventType: "COMMON_REIM", title: "目标费用单",
}] } });
throw new Error(`unexpected ${path}`);
});
const result = await client.listMyTodos({ userId: "ding-u1" }, 1);
assert.equal(result.items[0]?.billId, "2026081400000001");
assert.deepEqual(pages, [1, 2]);
});
test("Xft write transport failures are uncertain while deterministic rejections stay failed", async () => {
for (const [upstream, expected] of [
[{ code: "XFTSYS9999", message: "temporary unavailable", status: 503 }, "outcome_uncertain"],
[{ code: "BIZ001", message: "not allowed", status: 400 }, "failed"],
] as const) {
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
todoTaskId: "TD-1", groupId: "2026081400000001", appCode: "xft-itrip",
}] } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2026081400000001", eventType: "COMMON_REIM" }] } });
if (path.endsWith("approve/pass")) {
return new Response(String(sm4.encrypt(JSON.stringify({ code: upstream.code, message: upstream.message }), privateKey.slice(0, 32))), {
status: upstream.status,
});
}
throw new Error(`unexpected ${path}`);
});
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "2026081400000001" }]);
assert.equal(result.results[0]?.status, expected);
}
});
test("Xft OpenAPI fails closed when the DingTalk user has no enterprise mapping", async () => {
let calls = 0;
const client = new XftOpenApiClient(config(), async () => {
calls += 1;
return encrypted({ code: "0", data: { records: [] } });
});
await assert.rejects(client.listMyTodos({ userId: "unknown" }, 50), /未映射到薪福通企业成员/);
assert.equal(calls, 1);
});
test("Xft OpenAPI rejects non-exact, missing-identity, and ambiguous user mappings", async () => {
for (const records of [
[{ thirdStaffId: "victim-user", enterpriseUserId: "EMP-VICTIM" }],
[{ enterpriseUserId: "EMP-WITHOUT-DING-ID" }],
[
{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" },
{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-2" },
],
]) {
let mappingCalls = 0;
let downstreamCalls = 0;
const client = new XftOpenApiClient(config(), async (input) => {
if (new URL(String(input)).pathname.includes("user-mapping")) {
mappingCalls += 1;
return encrypted({ code: "0", data: { records } });
}
downstreamCalls += 1;
return encrypted({ code: "0", data: {} });
});
await assert.rejects(client.listMyTodos({ userId: "ding-u1" }, 50), /未映射|多个薪福通成员映射/);
assert.equal(mappingCalls, 1);
assert.equal(downstreamCalls, 0);
}
});
test("Xft OpenAPI surfaces XFT framework business errors", async () => {
const client = new XftOpenApiClient(config(), async () => encrypted({
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
}));
await assert.rejects(client.authStatus({ userId: "ding-u1" }), /应用状态错误/);
});
test("Xft OpenAPI preserves official GET endpoint query parameters in the signed target", async () => {
let capturedUrl: URL | undefined;
let capturedInit: RequestInit | undefined;
const client = new XftOpenApiClient(config(), async (input, init) => {
capturedUrl = new URL(String(input));
capturedInit = init;
return encrypted({ code: "0", data: { billId: "2099010200000001" } });
});
const request = client as unknown as {
request(path: string, body: undefined, method: "GET", identity: { enterpriseUserId: string }): Promise<unknown>;
};
await request.request(
"/itrip/xft-api/v1/reimburse/bill/common/detail?billId=2099010200000001",
undefined,
"GET",
{ enterpriseUserId: "EMP-1" },
);
assert.equal(capturedUrl?.pathname, "/itrip/xft-api/v1/reimburse/bill/common/detail");
assert.equal(capturedUrl?.searchParams.get("billId"), "2099010200000001");
assert.equal(capturedUrl?.searchParams.get("CSCUSRNBR"), "EMP-1");
assert.equal(capturedInit?.method, "GET");
assert.equal(capturedInit?.body, undefined);
assert.equal(new Headers(capturedInit?.headers).has("x-alb-digest"), false);
});
test("Xft OpenAPI derives empId server-side and validates ownership before and after a write", async () => {
let todoQueries = 0;
const actionBodies: Record<string, unknown>[] = [];
const fakeFetch: typeof fetch = async (input, init) => {
const path = new URL(String(input)).pathname;
const body = decodedBody(init);
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { list: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) {
todoQueries += 1;
return encrypted({ code: "0", data: todoQueries === 1 ? { records: [{ todoTaskId: "TD-1", groupId: "10000000001", appCode: "xft-itrip" }] } : { records: [] } });
}
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "10000000001", appCode: "xft-itrip" }] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "10000000001", eventType: "COMMON_REIM" }] } });
if (path.endsWith("approve/pass")) { actionBodies.push(body); return encrypted({ code: "0", data: {} }); }
throw new Error(`unexpected ${path}`);
};
const client = new XftOpenApiClient(config(), fakeFetch);
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "10000000001" }]);
assert.equal(result.succeeded, 1);
assert.equal(todoQueries, 2);
assert.equal(actionBodies[0]?.empId, "EMP-1");
});
test("Xft submission discovers live templates and derives protected create fields server-side", async () => {
const createBodies: Record<string, unknown>[] = [];
const fakeFetch: typeof fetch = async (input, init) => {
const path = new URL(String(input)).pathname;
const body = init?.body ? decodedBody(init) : {};
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{
thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1",
}] } });
if (path.endsWith("member/get/by-id")) {
assert.deepEqual(body, { id: "EMP-1", extFields: ["historyId", "org"] });
return encrypted({ returnCode: "SUC0000", body: {
memberId: "EMP-1", number: "E001", idRelation: { enterpriseUserId: "EMP-1", staffId: "0000000001" },
} });
}
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { total: 1, list: [{
eventSeqId: 12345, eventNumber: "FORM-1", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
}] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [{
fieldNumber: "C1", name: "说明", type: "INPUT", required: true, options: [],
}, {
fieldNumber: "C1", name: "说明", type: "INPUT", required: true, options: [],
}, {
fieldNumber: "billDepartmentCode", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
}, {
fieldNumber: "C-DYNAMIC-DEPT", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
}] });
if (path.endsWith("reimburse/bill/create")) {
createBodies.push(body);
return encrypted({ code: "SUC0000", body: 2026081200000001, success: true });
}
throw new Error(`unexpected ${path}`);
};
const client = new XftOpenApiClient(config(), fakeFetch);
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
assert.equal(templates[0]?.kind, "common_reimburse");
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[0]!);
assert.equal(schema.fields[0]?.fieldNumber, "C1");
assert.equal(schema.fields.length, 1);
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
const expenseList = standardFields.find((field) => field.key === "expenseList");
assert.deepEqual((expenseList?.itemFields as Array<Record<string, unknown>>).filter((field) => field.required).map((field) => field.key), [
"expTypeCode", "currencyCode", "originalCurrencyReimAmt",
]);
const identityCheck = await client.validateSubmissionIdentity({ userId: "ding-u1" });
assert.equal(identityCheck.validated, true);
assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/);
const result = await client.submitApproval({ userId: "ding-u1" }, templates[0]!, {
reimAmount: 10, reimAmountCurrencyCode: "CNY", expenseList: [{ amount: 10 }],
});
assert.equal(result.billId, "2026081200000001");
assert.equal(createBodies[0]?.eventNumber, "FORM-1");
assert.equal(createBodies[0]?.empNumber, "E001");
assert.equal(createBodies[0]?.billStatus, "APPRV_PEND");
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
});
test("Xft submission options expose only the current user's departments and searchable city codes", async () => {
const templateRecord = {
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",
};
let cityRequests = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.endsWith("member/get/by-id")) {
assert.deepEqual(decodedBody(init), { id: "EMP-1", extFields: ["historyId", "org"] });
return encrypted({ returnCode: "SUC0000", body: {
number: "SECRET-EMPLOYEE-NUMBER",
idRelation: { enterpriseUserId: "EMP-1" },
organizationRelations: [
{ organizationCode: "ORG-SZ", organizationName: "深圳总部" },
{ organization: { code: "ORG-RD", name: "研发部" } },
],
} });
}
if (path.endsWith("common/city/getAllCity")) {
cityRequests += 1;
return encrypted({ code: "0", data: {
regions: [{ cityCode: "440300", cityName: "深圳市" }, { cityCode: "441900", cityName: "东莞市" }],
} });
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
const departments = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "department");
assert.deepEqual(departments.items, [
{ code: "ORG-SZ", label: "深圳总部" },
{ code: "ORG-RD", label: "研发部" },
]);
assert.doesNotMatch(JSON.stringify(departments), /SECRET-EMPLOYEE-NUMBER|EMP-1/);
const cities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "深圳", 10);
assert.deepEqual(cities.items, [{ code: "440300", label: "深圳市" }]);
const cachedCities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "东莞", 10);
assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]);
assert.equal(cityRequests, 1);
});
test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => {
const recallBodies: Record<string, unknown>[] = [];
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
billId: 2026081200000001, empNumber: "E001", billSts: "1",
} });
if (path.endsWith("approve/recall")) {
recallBodies.push(decodedBody(init));
return encrypted({ code: "0", data: { success: true, error: "", successKey: [2026081200000001] } });
}
throw new Error(`unexpected ${path}`);
});
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001", "测试撤回");
assert.equal(result.status, "revoked");
assert.deepEqual(recallBodies[0], {
empId: "E001", businessId: 2026081200000001, approveCategory: "BILL", withdrawReason: "测试撤回",
});
});
test("Xft recall reports deterministic failure without retrying", async () => {
let recallCalls = 0;
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
billId: 2026081200000001, empNumber: "E001", billSts: "1",
} });
if (path.endsWith("approve/recall")) {
recallCalls += 1;
return encrypted({ code: "0", data: { success: false, error: {
"2026081200000001": { errorMessage: "单据状态不允许撤回", traceId: "must-not-leak" },
}, successKey: [] } });
}
throw new Error(`unexpected ${path}`);
});
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001");
assert.equal(result.status, "failed");
assert.match(result.message ?? "", /状态不允许/);
assert.doesNotMatch(result.message ?? "", /trace|must-not-leak/i);
assert.equal(recallCalls, 1);
});
test("Xft recall reconciles a framework service error against live bill status", async () => {
let recallCalls = 0;
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("approve/recall")) {
recallCalls += 1;
return encrypted({ code: "XFTSYS9999", message: "服务异常" });
}
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
billId: 2026081200000001, empNumber: "E001", billSts: "1",
} });
throw new Error(`unexpected ${path}`);
});
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001");
assert.equal(result.status, "failed");
assert.match(result.message ?? "", /服务异常.*待审批/);
assert.equal((result.diagnostics as Record<string, unknown>).postWriteVerified, true);
assert.equal(recallCalls, 1);
});
test("Xft submission status revalidates current-user ownership and returns only compact state", async () => {
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("bills/detail/2099010300000001")) return encrypted({ code: "0", data: {
billId: 2099010300000001, empNumber: "E001", billSts: "11", billApprSts: "WITHDRAWN", privateFormData: "not returned",
aprHisList: [{ nodeName: "发起", actions: [{ action: "start", empNumber: "must-not-leak" }] }],
} });
throw new Error(`unexpected ${path}`);
});
const result = await client.getMySubmissionStatus({ userId: "ding-u1" }, "2099010300000001");
assert.deepEqual(result, {
billId: "2099010300000001", status: "11", statusName: "已撤回", approvalStatus: "WITHDRAWN",
revoked: true, revokeAllowed: false,
flowHistory: [{ nodeName: "发起", actions: ["start"] }],
diagnostics: {
source: "xft_official_openapi", realtime: true, ownershipRevalidated: true,
applicantIdentity: "server_derived", persistedBusinessData: false,
},
});
assert.doesNotMatch(JSON.stringify(result), /privateFormData|must-not-leak|E001|EMP-1/);
});
test("Xft submission ownership ignores employee numbers from approval history", async () => {
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("bills/detail/2026081400000001")) return encrypted({ code: "0", data: {
billId: "2026081400000001", empNumber: "E999", billSts: "1",
aprHisList: [{ actions: [{ empNumber: "E001" }] }],
} });
throw new Error(`unexpected ${path}`);
});
await assert.rejects(client.getMySubmissionStatus({ userId: "ding-u1" }, "2026081400000001"), /不属于当前登录用户/);
});
test("Xft submission ownership accepts object rows from the condition-query fallback", async () => {
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("bills/detail/2026081400000001")) return encrypted({ code: "0", data: {
billId: "2026081400000001", billSts: "1",
} });
if (path.endsWith("bills/queryBillByCondition")) return encrypted({ code: "0", data: {
list: [{ billId: "2026081400000001" }],
} });
throw new Error(`unexpected ${path}`);
});
const result = await client.getMySubmissionStatus({ userId: "ding-u1" }, "2026081400000001");
assert.equal((result.diagnostics as Record<string, unknown>).ownershipRevalidated, true);
assert.equal(result.revokeAllowed, true);
});
test("Xft submission routes every supported template kind to its documented create endpoint", async () => {
const templateRecords = [
{ eventSeqId: 1, eventNumber: "T1", eventName: "差旅申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
{ eventSeqId: 2, eventNumber: "T2", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
{ eventSeqId: 3, eventNumber: "T3", eventName: "招待申请", eventStatus: "1", eventType: "ENTERTAINMENT_APPLY" },
{ eventSeqId: 8, eventNumber: "T8", eventName: "招待费用申请单", eventStatus: "1", eventType: "EXPENSE_APPLY" },
{ eventSeqId: 4, eventNumber: "T4", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY" },
{ eventSeqId: 5, eventNumber: "T5", eventName: "用车申请", eventStatus: "1", eventType: "USE_CAR_APPLY" },
{ eventSeqId: 6, eventNumber: "T6", eventName: "日常报销", eventStatus: "1", eventType: "COMMON_REIM" },
{ eventSeqId: 7, eventNumber: "T7", eventName: "差旅报销", eventStatus: "1", eventType: "TRAVEL_REIM" },
];
const createPaths: string[] = [];
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: templateRecords } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("/create")) {
createPaths.push(path);
return encrypted({ code: "0", data: { billId: String(1000 + createPaths.length) } });
}
throw new Error(`unexpected ${path}`);
});
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
assert.equal(templates.length, 8);
assert.equal(templates.find((item) => item.eventNumber === "T8")?.kind, "expense_apply");
for (const template of templates) await client.submitApproval({ userId: "ding-u1" }, template, {});
assert.deepEqual(createPaths.sort(), [
"/itrip/xft-api/v1/bills/apply/common/create",
"/itrip/xft-api/v1/bills/apply/entertainment/create",
"/itrip/xft-api/v1/bills/apply/expense/create",
"/itrip/xft-api/v1/bills/apply/expense/create",
"/itrip/xft-api/v1/bills/apply/travel/create",
"/itrip/xft-api/v1/bills/apply/use-car/create",
"/itrip/xft-api/v1/reimburse/bill/create",
"/itrip/xft-api/v1/reimburse/bill/travel/create",
]);
});
test("Xft submission fails closed when the authenticated member has no employee number", async () => {
let createCalls = 0;
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
}] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
memberId: "EMP-1", number: null, idRelation: { enterpriseUserId: "EMP-1", staffId: "0000000001" },
} });
if (path.endsWith("bills/apply/common/create")) createCalls += 1;
return encrypted({ code: "0", data: {} });
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}), /未维护员工号/);
assert.equal(createCalls, 0);
});
test("Xft submission rejects a member lookup that does not match the authenticated identity", async () => {
let createCalls = 0;
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
}] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
memberId: "EMP-OTHER", number: "VICTIM-001", idRelation: { enterpriseUserId: "EMP-OTHER" },
} });
if (path.endsWith("bills/apply/common/create")) createCalls += 1;
return encrypted({ code: "0", data: {} });
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}), /登录身份不一致/);
assert.equal(createCalls, 0);
});
test("Xft submission rejects caller-controlled applicant and status fields before create", async () => {
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
}] } });
throw new Error(`unexpected create call ${path} ${String(init?.body)}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, { empNumber: "victim" }), /Agent 不得传入/);
});
test("Xft OpenAPI tolerates eventual consistency after an accepted write", async () => {
let todoQueries = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) {
todoQueries += 1;
return encrypted({ code: "0", data: { records: todoQueries < 4 ? [{ todoTaskId: "TD-1", groupId: "10000000001", appCode: "xft-itrip" }] : [] } });
}
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "10000000001", eventType: "COMMON_REIM" }] } });
if (path.endsWith("approve/pass")) { void decodedBody(init); return encrypted({ code: "0", data: {} }); }
throw new Error(`unexpected ${path}`);
});
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "10000000001" }]);
assert.equal(result.succeeded, 1);
assert.equal(todoQueries, 4);
});
test("Xft detail falls back to generic bill detail when a specialized endpoint is unavailable", async () => {
const targetBillId = "2099010400000001";
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: targetBillId, appCode: "xft-itrip" }] } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: targetBillId, eventType: "EXPENSE_APPLY" }] } });
if (path.endsWith("bills/apply/expense/detail")) return new Response("Not Found", { status: 404 });
if (path.endsWith(`bills/detail/${targetBillId}`)) return encrypted({ code: "0", data: { billId: targetBillId, applyExpenses: [{ expenseNumber: "MEAL" }] } });
throw new Error(`unexpected ${path}`);
});
const detail = await client.getTodoDetail({ userId: "ding-u1" }, targetBillId);
assert.deepEqual(detail.form, { billId: targetBillId, applyExpenses: [{ expenseNumber: "MEAL" }] });
assert.equal((detail.diagnostics as Record<string, unknown>).detailFallback, "generic_bill_detail_after_specialized_404");
});
test("Xft ownership verification scans beyond the first 1000 pending tasks", async () => {
const targetBillId = "99999999999";
const requestedPages: number[] = [];
const firstPage = Array.from({ length: 1000 }, (_, index) => ({
todoTaskId: `TD-${index}`, groupId: String(10_000_000_000 + index), appCode: "xft-itrip",
}));
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) {
const body = decodedBody(init);
requestedPages.push(Number(body.currentPage));
return encrypted({ code: "0", data: { records: body.currentPage === 1 ? firstPage : [{ todoTaskId: "TD-TARGET", groupId: targetBillId, appCode: "xft-itrip" }] } });
}
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: targetBillId, eventType: "COMMON_REIM" }] } });
if (path.endsWith("reimburse/bill/common/detail")) return encrypted({ code: "0", data: { billId: targetBillId } });
throw new Error(`unexpected ${path}`);
});
const detail = await client.getTodoDetail({ userId: "ding-u1" }, targetBillId);
assert.equal(detail.todo.billId, targetBillId);
assert.deepEqual(requestedPages, [1, 2]);
});
test("Xft OpenAPI never calls an action endpoint for a bill outside the user's live todos", async () => {
let actionCalls = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { list: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [] } });
if (path.includes("/approve/")) actionCalls += 1;
void init;
return encrypted({ code: "0", data: {} });
});
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "", [{ billId: "other-user-bill" }]);
assert.equal(result.failed, 1);
assert.equal(actionCalls, 0);
});