Files
dingMCP/test/unified.test.ts
T

1418 lines
72 KiB
TypeScript

import assert from "node:assert/strict";
import { createCipheriv, createHash, randomBytes } from "node:crypto";
import test from "node:test";
import { deflateRawSync } from "node:zlib";
import type { AppConfig } from "../src/config.js";
import { UnifiedApprovalService } from "../src/unified-approval.js";
import type { ApprovalService } from "../src/approval-service.js";
import type { XftApprovalProvider, XftSubmissionField, XftSubmissionTemplate } from "../src/xft.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: 1000, officialLookbackDays: 120,
officialScanConcurrency: 2, officialClientOnlyProcessCodes: [], batchLimit: 20,
approvalRefSecret: "fixed-reference-secret-for-tests",
};
}
test("unified history lists handled/submitted records as read-only references and revalidates detail ownership", async () => {
const detailCalls: Array<Record<string, unknown>> = [];
const ding = {
async listMyHistory(_userId: string, scope: string) {
return { items: [{
source: "official_oa", historyScope: scope, processInstanceId: `${scope}-oa`,
...(scope === "handled" ? { taskId: "task-1", completedAt: "2026-08-14T02:00:00.000Z" } : {}),
title: scope === "handled" ? "我处理过的采购" : "我发起的采购", applicantName: "申请人甲",
submittedAt: "2026-08-13T01:00:00.000Z", status: scope === "handled" ? "COMPLETED" : "RUNNING", raw: {},
}] };
},
async getHistoryDetail(userId: string, scope: string, processInstanceId: string, taskId?: string) {
detailCalls.push({ provider: "dingtalk", userId, scope, processInstanceId, taskId });
return { title: "历史采购", originatorUserName: "申请人甲", formComponentValues: [{ name: "事由", value: "测试" }] };
},
} as unknown as ApprovalService;
const xft = {
async listMyHistory(_user: unknown, scope: string) {
return { items: [{
source: "xft", historyScope: scope, billId: `${scope === "handled" ? "2026081400000001" : "2026081400000002"}`,
title: scope === "handled" ? "我处理过的报销" : "我发起的报销", applicantName: "申请人乙",
applyTime: "2026-08-13T03:00:00.000Z", amount: 99,
}], totalCount: 1, diagnostics: {} };
},
async getHistoryDetail(_user: unknown, scope: string, billId: string) {
detailCalls.push({ provider: "xft", scope, billId });
return { todo: { title: "历史报销", amount: 99 }, form: { billId, reason: "测试报销" } };
},
} as unknown as XftApprovalProvider;
const service = new UnifiedApprovalService(config(), ding, xft);
const user = { userId: "user-1" };
const handled = await service.listHistory(user, "handled", "all", 20, "", "2026-08-01", "2026-08-31");
assert.equal(handled.returned, 2);
assert.equal(handled.timeRange.startDate, "2026-07-31T16:00:00.000Z");
assert.equal(handled.timeRange.endDate, "2026-08-31T15:59:59.999Z");
assert.equal(handled.timeRange.defaultedToRecent30Days, false);
assert.equal(handled.items[0]?.source, "dingtalk", "combined history is globally ordered by completion/submission time");
assert.equal("selectionId" in handled, false);
assert.ok(handled.items.every((item) => item.readOnly === true && Array.isArray(item.availableActions) && item.availableActions.length === 0));
const dingId = String(handled.items.find((item) => item.source === "dingtalk")?.approvalId);
const xftId = String(handled.items.find((item) => item.source === "xft")?.approvalId);
assert.match(dingId, /^v4\./);
assert.match(xftId, /^v4\./);
await service.getDetail(user, dingId);
await service.getDetail(user, xftId);
assert.deepEqual(detailCalls[0], {
provider: "dingtalk", userId: "user-1", scope: "handled", processInstanceId: "handled-oa", taskId: "task-1",
});
assert.deepEqual(detailCalls[1], { provider: "xft", scope: "handled", billId: "2026081400000001" });
await assert.rejects(service.getActions(user, dingId), /历史审批引用仅用于读取/);
const blocked = await service.processBatch(user, "approve", "", [{ approvalId: dingId }]);
assert.equal(blocked.status, "preflight_failed");
assert.equal(blocked.writesStarted, false);
assert.equal(blocked.invalidItems[0]?.code, "history_reference_read_only");
const submitted = await service.listHistory(user, "submitted", "dingtalk", 20, "", "2026-08-01", "2026-08-31");
const submittedId = String(submitted.items[0]?.approvalId);
assert.ok(submittedId.length < dingId.length, "submitted history references do not need a task ID");
await service.getDetail(user, submittedId);
await assert.rejects(service.getDetail({ userId: "other" }, submittedId), /不属于当前用户|已损坏/);
await assert.rejects(service.listHistory(user, "handled", "all", 20, "", "2026-01-01", "2026-08-31"), /不能超过 120 天/);
await assert.rejects(service.listHistory(user, "handled", "all", 20, "", "2026-02-30", "2026-03-01"), /不是有效日期/);
const beforeDefault = Date.now();
const recent = await service.listHistory(user, "handled", "dingtalk", 20);
const afterDefault = Date.now();
const defaultStart = Date.parse(recent.timeRange.startDate);
const defaultEnd = Date.parse(recent.timeRange.endDate);
assert.equal(recent.timeRange.defaultedToRecent30Days, true);
assert.ok(defaultEnd >= beforeDefault && defaultEnd <= afterDefault);
assert.equal(defaultEnd - defaultStart, 30 * 86_400_000);
});
test("unified approval API returns opaque user-bound IDs and routes DingTalk actions", async () => {
const calls: Array<Record<string, unknown>> = [];
const ding = {
async listMyTodos() {
return { items: [{
source: "official_oa", processInstanceId: "instance-secret", taskId: "task-secret", title: "采购审批",
applicantName: "张三", applicantDepartment: "采购部", submittedAt: "2026-08-11T01:00:00.000Z",
amount: 100, currency: "CNY", raw: {},
}] };
},
async getDetail(_userId: string, item: Record<string, unknown>) {
calls.push({ kind: "detail", ...item });
return {
mobile: "13800138000",
amount: 100,
todo: {
applicantName: "张三",
applicantDepartment: "采购部",
submittedAt: "2026-08-11T01:00:00.000Z",
amount: 100,
currency: "CNY",
},
formComponentValues: [
{ name: "项目", value: "完整字段保留" },
{ name: "银行卡号", value: "6222000012345678" },
{
name: "报销明细",
componentType: "TableField",
value: JSON.stringify([{ label: "收款信息", value: { cardNo: "6214833686733752", payerAddress: "深圳市测试路13800138000" } }]),
},
{
name: "收款账户",
componentType: "RecipientAccountField",
value: "测试用户甲",
extValue: JSON.stringify({ cardNo: "6214833686733752", instName: "招商银行" }),
},
],
};
},
async assertOfficialTaskPending() {},
async processBatch(userId: string, action: string, remark: string, items: unknown[]) {
calls.push({ kind: "process", userId, action, remark, items });
return { results: [{ success: true }] };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1", name: "测试用户乙" };
const listed = await service.listTodos(user, "all", 50);
const approvalId = String(listed.items[0]!.approvalId);
assert.deepEqual(listed.items[0]!.amount, { value: 100, currency: "CNY" });
assert.equal(listed.items[0]!.submittedAt, "2026-08-11T09:00:00.000+08:00");
assert.equal("submittedAtLocal" in listed.items[0]!, false);
assert.equal("submittedTimeZone" in listed.items[0]!, false);
assert.equal("sourceType" in listed.items[0]!, false);
assert.equal("actionsHint" in listed.items[0]!, false);
assert.match(approvalId, /^v4\./);
assert.ok(approvalId.length <= 128, `common DingTalk approvalId should be compact, got ${approvalId.length} characters`);
assert.doesNotMatch(approvalId, /instance-secret|task-secret/);
assert.doesNotMatch(Buffer.from(approvalId.split(".")[1]!, "base64url").toString("utf8"), /instance-secret|task-secret/);
const detail = await service.getDetail(user, approvalId);
assert.equal("view" in detail ? detail.view : undefined, "summary");
assert.doesNotMatch(JSON.stringify(detail), /6222000012345678|6214833686733752|13800138000/);
const compact = "detail" in detail ? detail.detail as Record<string, unknown> : {};
assert.deepEqual(compact.applicant, { name: "张三", department: "采购部" });
assert.equal(compact.submittedAt, "2026-08-11T01:00:00.000Z");
assert.deepEqual((compact.formFields as Array<Record<string, unknown>>)[0], { label: "项目", value: "完整字段保留" });
assert.equal((compact.formFields as Array<Record<string, unknown>>).some((field) => field.label === "银行卡号"), false);
const raw = await service.getDetail(user, approvalId, { view: "raw", rawPath: "/formComponentValues", rawLimit: 10 });
assert.doesNotMatch(JSON.stringify(raw), /6222000012345678|6214833686733752|13800138000/);
const actions = await service.getActions(user, approvalId);
assert.deepEqual(actions.actions.map((item) => item.action), ["approve", "reject", "return"]);
assert.equal(actions.actions[0]!.allowed, true);
assert.equal(actions.actions[0]!.guaranteed, false);
assert.equal(actions.actions[0]!.submissionSupported, true);
assert.equal(actions.actions[2]!.allowed, false);
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
assert.equal(result.succeeded, 1);
assert.equal(calls.find((call) => call.kind === "process")?.action, "agree");
await assert.rejects(service.getDetail({ userId: "user-2", name: "其他人" }, approvalId), /不属于当前用户/);
const tampered = `${approvalId.slice(0, -1)}${approvalId.endsWith("A") ? "B" : "A"}`;
await assert.rejects(service.getDetail(user, tampered), /(?:格式不完整|已损坏).*重新列出/);
});
test("selectionId processes exactly the confirmed live result set and rejects drift", async () => {
let changed = false;
let writes = 0;
const ding = {
async listMyTodos() {
return { items: [
{ source: "official_oa", processInstanceId: "instance-a", taskId: "101", title: "测试用户丙费用一", raw: {} },
...(!changed ? [{ source: "official_oa", processInstanceId: "instance-b", taskId: "102", title: "测试用户丙费用二", raw: {} }] : []),
] };
},
async processBatch(_userId: string, _action: string, _remark: string, items: unknown[]) {
writes += items.length;
return { results: [{ success: true }] };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const listed = await service.listTodos(user, "dingtalk", 10, "测试用户丙");
const selectionId = String("selectionId" in listed ? listed.selectionId : "");
assert.match(selectionId, /^v3\./);
assert.ok(selectionId.length <= 512, `selectionId should stay portable, got ${selectionId.length} characters`);
const processed = await service.processBatch(user, "approve", "同意", [], selectionId);
assert.equal(processed.succeeded, 2);
assert.equal(writes, 2);
const stale = await service.listTodos(user, "dingtalk", 10, "测试用户丙");
changed = true;
await assert.rejects(
service.processBatch(user, "approve", "同意", [], String("selectionId" in stale ? stale.selectionId : "")),
/列表在确认后发生变化/,
);
assert.equal(writes, 2);
});
test("todo lists keep transport batch policy internal", async () => {
const user = { userId: "user-1" };
const manyDing = {
async listMyTodos() {
return { items: Array.from({ length: 21 }, (_, index) => ({
source: "official_oa", processInstanceId: `many-${index}`, taskId: String(index + 1), raw: {},
})) };
},
} as unknown as ApprovalService;
const overflow = await new UnifiedApprovalService({ ...config(), batchLimit: 99 }, manyDing).listTodos(user, "dingtalk", 30);
assert.equal(overflow.returned, 21);
assert.equal("selectionId" in overflow, false);
assert.equal("selectionUnavailableReason" in overflow, false);
assert.equal("selectionHint" in overflow, false);
assert.equal("batchPolicy" in overflow, false);
const emptyDing = { async listMyTodos() { return { items: [] }; } } as unknown as ApprovalService;
const empty = await new UnifiedApprovalService(config(), emptyDing).listTodos(user, "dingtalk", 10);
assert.equal("selectionId" in empty, false);
assert.equal("selectionUnavailableReason" in empty, false);
assert.equal("batchPolicy" in empty, false);
const xftUnavailable = { async listMyTodos() { throw new Error("XFT unavailable"); } };
const partial = await new UnifiedApprovalService(config(), manyDing, xftUnavailable as never).listTodos(user, "all", 1);
assert.equal("selectionId" in partial, false);
assert.equal("selectionUnavailableReason" in partial, false);
assert.equal("batchPolicy" in partial, false);
});
test("combined todo lists are deterministic regardless of source response timing", async () => {
const ding = {
async listMyTodos() {
await new Promise<void>((resolve) => setTimeout(resolve, 10));
return { items: [{ source: "official_oa", processInstanceId: "oa-instance", taskId: "101", title: "OA", raw: {} }] };
},
} as unknown as ApprovalService;
const xft = {
async listMyTodos() {
return { items: [{ source: "xft", billId: "2026081300000001", title: "XFT", applyTime: "2026-08-13T01:30:00.000Z" }] };
},
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const listed = await service.listTodos({ userId: "user-1" }, "all", 2);
assert.deepEqual(listed.items.map((item) => item.source), ["dingtalk", "xft"]);
assert.equal(listed.items[1]!.submittedAt, "2026-08-13T09:30:00.000+08:00");
assert.equal("submittedAtLocal" in listed.items[1]!, false);
assert.equal("submittedTimeZone" in listed.items[1]!, false);
});
test("v4 references remain backward compatible with unexpired v2 and v3 approvals", async () => {
const ding = {
async getDetail() { return { title: "旧引用详情", status: "RUNNING", tasks: [{ taskId: 101, userId: "user-1", taskStatus: "RUNNING" }] }; },
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const legacyValue = {
kind: "approval", userId: "user-1", provider: "official_oa",
processInstanceId: "legacy-instance", taskId: "101", exp: Date.now() + 60_000,
};
for (const legacy of [legacyV2Reference(legacyValue), legacyV3Reference(legacyValue)]) {
const detail = await service.getDetail({ userId: "user-1" }, legacy);
assert.equal(detail.source, "dingtalk");
}
});
test("approvalId stays portable when upstream display metadata is very large", async () => {
const ding = {
async listMyTodos() {
return { items: [{
source: "official_oa", processInstanceId: "instance-1", taskId: "101",
title: "超长标题".repeat(300), applicantName: "申请人".repeat(100),
clientUrl: `https://aflow.dingtalk.com/dingtalk/mobile/aflow.htm?payload=${randomBytes(1500).toString("base64url")}`,
raw: {},
}] };
},
} as unknown as ApprovalService;
const listed = await new UnifiedApprovalService(config(), ding).listTodos({ userId: "user-1" }, "dingtalk", 10);
const approvalId = String(listed.items[0]!.approvalId);
assert.match(approvalId, /^v4\./);
assert.ok(approvalId.length <= 128, `approvalId should ignore display metadata, got ${approvalId.length} characters`);
});
test("v4 approval references stay compact for representative DingTalk, Yida, and XFT identifiers", async () => {
const ding = {
async listMyTodos(_userId: string, source: string) {
return { items: source === "all" || source === "official_oa" ? [{
source: "official_oa",
processInstanceId: "5VXimwfMSBSWhjzttVCyWA10041785394274",
taskId: "111111111111",
raw: {},
}, {
source: "yida",
processInstanceId: "FINST-7b7ffc7800d942b7b8f6e70e16efab34",
taskId: "987654321012",
appType: "APP_MOCK_EXPENSE_2026",
raw: {},
}] : [] };
},
} as unknown as ApprovalService;
const xft = {
async listMyTodos() { return { items: [{ source: "xft", billId: "2099010100000001", title: "薪福通费用" }] }; },
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const listed = await service.listTodos({ userId: "user-1" }, "all", 10);
const official = String(listed.items.find((item) => item.businessNo === "5VXimwfMSBSWhjzttVCyWA10041785394274")?.approvalId);
const yida = String(listed.items.find((item) => item.businessNo === "FINST-7b7ffc7800d942b7b8f6e70e16efab34")?.approvalId);
const xftId = String(listed.items.find((item) => item.source === "xft")?.approvalId);
assert.match(official, /^v4\./);
assert.ok(official.length <= 128, `DingTalk reference is ${official.length} characters`);
assert.ok(yida.length <= 160, `Yida reference is ${yida.length} characters`);
assert.ok(xftId.length <= 80, `XFT reference is ${xftId.length} characters`);
});
test("v4 approval references survive service restart and reject another secret", async () => {
const ding = {
async listMyTodos() {
return { items: [{ source: "official_oa", processInstanceId: "restart-instance", taskId: "101", raw: {} }] };
},
async getDetail() { return { title: "重启后详情" }; },
} as unknown as ApprovalService;
const user = { userId: "user-1" };
const first = new UnifiedApprovalService(config(), ding);
const approvalId = String((await first.listTodos(user, "dingtalk", 10)).items[0]?.approvalId);
const restarted = new UnifiedApprovalService(config(), ding);
assert.equal((await restarted.getDetail(user, approvalId)).source, "dingtalk");
const foreign = new UnifiedApprovalService({ ...config(), approvalRefSecret: "another-secret" }, ding);
await assert.rejects(foreign.getDetail(user, approvalId), /已损坏|不同密钥/);
});
test("a truncated approval reference aborts the entire batch before any write", async () => {
let writes = 0;
const ding = {
async listMyTodos() {
return { items: Array.from({ length: 6 }, (_, index) => ({
source: "official_oa",
processInstanceId: `instance-${index + 1}`,
taskId: String(100 + index),
raw: {},
})) };
},
async processBatch(_userId: string, _action: string, _remark: string, values: unknown[]) {
writes += values.length;
return { results: [{ success: true }] };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const listed = await service.listTodos(user, "dingtalk", 10);
const items = listed.items.map((item) => ({ approvalId: String(item.approvalId) }));
items[5]!.approvalId = items[5]!.approvalId.slice(0, -8);
const result = await service.processBatch(user, "approve", "同意", items);
assert.equal(result.status, "preflight_failed");
assert.equal(result.writesStarted, false);
assert.equal(result.notAttempted, 6);
assert.equal(result.invalidItems?.[0]?.index, 6);
assert.equal(result.invalidItems?.[0]?.code, "reference_truncated");
assert.equal(writes, 0);
});
test("XFT action restrictions are enforced during zero-write preflight", async () => {
let writes = 0;
const ding = { async listMyTodos() { return { items: [] }; } } as unknown as ApprovalService;
const xft = {
async listMyTodos() {
return { items: [{ source: "xft", billId: "2099010100000001", allowPass: false, allowReject: true }] };
},
async processApprovalBatch() { writes += 1; return { results: [{ success: true }] }; },
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const user = { userId: "user-1" };
const approvalId = String((await service.listTodos(user, "xft", 10)).items[0]?.approvalId);
const result = await service.processBatch(user, "approve", "", [{ approvalId }]);
assert.equal(result.status, "preflight_failed");
assert.equal(result.invalidItems?.[0]?.code, "action_not_allowed");
assert.equal(writes, 0);
});
test("approval reference expiry is checked once before the batch starts", async () => {
const originalNow = Date.now;
let now = originalNow();
let writes = 0;
Date.now = () => now;
try {
const ding = {
async listMyTodos() {
return { items: [
{ source: "official_oa", processInstanceId: "expiry-a", taskId: "201", raw: {} },
{ source: "official_oa", processInstanceId: "expiry-b", taskId: "202", raw: {} },
] };
},
async processBatch() {
writes += 1;
if (writes === 1) now += 31 * 60_000;
return { results: [{ success: true }] };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const items = (await service.listTodos(user, "dingtalk", 10)).items
.map((item) => ({ approvalId: String(item.approvalId) }));
const result = await service.processBatch(user, "approve", "同意", items);
assert.equal(result.succeeded, 2);
assert.equal(writes, 2);
} finally {
Date.now = originalNow;
}
});
test("an already expired v4 approval reference fails preflight without writing", async () => {
const originalNow = Date.now;
let now = originalNow();
let writes = 0;
Date.now = () => now;
try {
const ding = {
async listMyTodos() {
return { items: [{ source: "official_oa", processInstanceId: "expired", taskId: "401", raw: {} }] };
},
async processBatch() { writes += 1; return { results: [{ success: true }] }; },
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]?.approvalId);
now += 31 * 60_000;
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
assert.equal(result.status, "preflight_failed");
assert.equal(result.invalidItems?.[0]?.code, "reference_expired");
assert.equal(writes, 0);
} finally {
Date.now = originalNow;
}
});
function legacyV2Reference(value: object): string {
const key = createHash("sha256").update(config().approvalRefSecret).digest();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const payload = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
return `v2.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
}
function legacyV3Reference(value: object): string {
const key = createHash("sha256").update(config().approvalRefSecret).digest();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
}
test("signature-required DingTalk templates return a client link and never call the approval API", async () => {
let processCalls = 0;
const ding = {
async listMyTodos() {
return { items: [{
source: "official_oa",
processInstanceId: "signed-instance",
taskId: "111111111111",
processCode: "PROC-SIGN",
clientUrl: "https://aflow.dingtalk.com/dingtalk/mobile/homepage.htm?taskId=111111111111",
title: "差旅报销",
raw: {},
}] };
},
async processBatch() { processCalls += 1; return { results: [{ success: true }] }; },
} as unknown as ApprovalService;
const appConfig = { ...config(), officialClientOnlyProcessCodes: ["PROC-SIGN"] };
const service = new UnifiedApprovalService(appConfig, ding);
const user = { userId: "user-1", name: "测试用户乙" };
const listed = await service.listTodos(user, "dingtalk", 10);
assert.deepEqual(listed.items[0]!.availableActions, []);
assert.equal((listed.items[0]!.manualAction as Record<string, unknown>).reason, "signature_required");
const approvalId = String(listed.items[0]!.approvalId);
const actions = await service.getActions(user, approvalId) as unknown as {
actions: Array<Record<string, unknown>>;
manualAction: Record<string, unknown>;
};
assert.equal(actions.actions[0]!.allowed, false);
assert.equal(actions.actions[0]!.signatureRequired, true);
assert.match(String(actions.manualAction.url), /^https:\/\/aflow\.dingtalk\.com\//);
const processed = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
assert.equal(processed.succeeded, 0);
assert.equal(processed.results[0]!.status, "manual_action_required");
assert.equal(processCalls, 0);
});
test("a signature error discovered at submit time becomes a client-action result", async () => {
const ding = {
async listMyTodos() {
return { items: [{
source: "official_oa", processInstanceId: "signed-late", taskId: "123456789012",
title: "费用报销", clientUrl: "https://aflow.dingtalk.com/detail/signed-late", raw: {},
}] };
},
async processBatch() { return { results: [{ success: false, error: "该节点需要手写签名 signature required" }] }; },
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]?.approvalId);
const result = await service.processBatch(user, "approve", "", [{ approvalId }]);
assert.equal(result.results[0]?.status, "manual_action_required");
assert.equal((result.results[0]?.manualAction as Record<string, unknown>).reason, "signature_required");
assert.match(String((result.results[0]?.manualAction as Record<string, unknown>).url), /aflow\.dingtalk\.com/);
});
test("unified approval reports a newly assigned DingTalk node as follow-up instead of completion", async () => {
const ding = {
async listMyTodos() {
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "task-1", title: "出差申请", raw: {} }] };
},
async processBatch() {
return { results: [{ success: true, completed: false, followUpRequired: true, nextTaskId: "task-2" }] };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
assert.equal(result.succeeded, 1);
assert.equal(result.failed, 0);
assert.equal(result.completed, 0);
assert.equal(result.followUpRequired, 1);
assert.equal(result.results[0]!.status, "follow_up_required");
assert.equal(result.results[0]!.completed, false);
});
test("unified approval preserves an accepted-but-unconfirmed write as uncertain", async () => {
const ding = {
async listMyTodos() {
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "task-1", title: "采购审批", raw: {} }] };
},
async processBatch() {
return { results: [{ success: false, status: "outcome_uncertain", outcomeUncertain: true, message: "禁止自动重试" }] };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const approvalId = String((await service.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
const result = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
assert.equal(result.succeeded, 0);
assert.equal(result.failed, 0);
assert.equal(result.uncertain, 1);
assert.equal(result.results[0]!.status, "outcome_uncertain");
assert.match(String(result.results[0]!.message), /禁止自动重试/);
});
test("unified OA return signs live targets, revalidates them, and confirms task completion", async () => {
let pending = true;
const calls: Array<Record<string, unknown>> = [];
const ding = {
async listMyTodos() {
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "101", title: "采购审批", raw: {} }] };
},
async assertOfficialTaskPending(userId: string, processInstanceId: string, taskId: string) {
calls.push({ kind: "assert", userId, processInstanceId, taskId });
if (!pending) throw new Error("not pending");
},
async isOfficialTaskPending(userId: string, processInstanceId: string, taskId: string) {
calls.push({ kind: "verify", userId, processInstanceId, taskId });
return pending;
},
} as unknown as ApprovalService;
const returns = {
supportsUser(userId: string) { return userId === "user-1"; },
async listRevertActivities(userId: string, taskId: string) {
calls.push({ kind: "activities", userId, taskId });
return [
{ activityId: "sid-startevent", activityName: "发起人", revertAction: "REVERT_FOR_RESUBMIT" as const, approverNames: ["申请人"] },
{ activityId: "leader-node", activityName: "部门负责人", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: ["李经理"] },
];
},
async revertTask(userId: string, request: Record<string, unknown>) {
calls.push({ kind: "revert", userId, ...request });
pending = false;
},
};
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
const user = { userId: "user-1", name: "测试用户乙" };
const listed = await service.listTodos(user, "dingtalk", 10);
assert.deepEqual(listed.items[0]!.availableActions, ["approve", "reject"]);
assert.equal("actionsHint" in listed.items[0]!, false);
const approvalId = String(listed.items[0]!.approvalId);
const actions = await service.getActions(user, approvalId);
const returnAction = actions.actions[2]! as {
allowed: boolean;
targets?: Array<{ targetId: string }>;
};
assert.equal(returnAction.allowed, true);
assert.equal(returnAction.targets?.length, 2);
assert.match(String(returnAction.targets?.[0]?.targetId), /^v3\./);
const result = await service.processBatch(user, "return", "请补充附件", [{
approvalId,
targetId: String(returnAction.targets?.[1]?.targetId),
}]);
assert.equal(result.succeeded, 1);
assert.equal(calls.filter((call) => call.kind === "activities").length, 2);
const reverted = calls.find((call) => call.kind === "revert")!;
assert.equal(reverted.targetActivityId, "leader-node");
assert.equal(reverted.revertAction, "REVERT_FOR_APPROVAL");
assert.equal(reverted.remark, "请补充附件");
assert.equal(calls.at(-1)?.kind, "verify");
});
test("unified OA return rejects stale, tampered, cross-user, and unsupported targets", async () => {
let activities = [{ activityId: "node-1", activityName: "上一级", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: [] }];
let writes = 0;
const ding = {
async listMyTodos() {
return { items: [{ source: "official_oa", processInstanceId: "instance-1", taskId: "101", raw: {} }] };
},
async assertOfficialTaskPending() {},
async isOfficialTaskPending() { return false; },
} as unknown as ApprovalService;
const returns = {
supportsUser() { return true; },
async listRevertActivities() { return activities; },
async revertTask() { writes += 1; },
};
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
const user1 = { userId: "user-1" };
const listed1 = await service.listTodos(user1, "dingtalk", 10);
const approval1 = String(listed1.items[0]!.approvalId);
const targetAction1 = (await service.getActions(user1, approval1)).actions[2]! as { targets?: Array<{ targetId: string }> };
const target1 = String(targetAction1.targets?.[0]?.targetId);
const tampered = `${target1.slice(0, -1)}${target1.endsWith("A") ? "B" : "A"}`;
let result = await service.processBatch(user1, "return", "", [{ approvalId: approval1, targetId: tampered }]);
assert.equal(result.status, "preflight_failed");
assert.equal(result.writesStarted, false);
assert.equal(result.failed, 0);
assert.match(String(result.invalidItems?.[0]?.message), /(?:无效|格式不完整|已损坏)/);
activities = [];
result = await service.processBatch(user1, "return", "", [{ approvalId: approval1, targetId: target1 }]);
assert.equal(result.failed, 1);
assert.match(String(result.results[0]!.message), /已不在当前可退回节点/);
activities = [{ activityId: "node-1", activityName: "上一级", revertAction: "REVERT_FOR_APPROVAL", approverNames: [] }];
const user2 = { userId: "user-2" };
const approval2 = String((await service.listTodos(user2, "dingtalk", 10)).items[0]!.approvalId);
result = await service.processBatch(user2, "return", "", [{ approvalId: approval2, targetId: target1 }]);
assert.equal(result.status, "preflight_failed");
assert.equal(result.failed, 0);
assert.match(String(result.invalidItems?.[0]?.message), /不属于当前用户/);
assert.equal(writes, 0);
});
test("one invalid return target prevents every write in the batch", async () => {
let writes = 0;
const ding = {
async listMyTodos() {
return { items: [
{ source: "official_oa", processInstanceId: "return-a", taskId: "301", raw: {} },
{ source: "official_oa", processInstanceId: "return-b", taskId: "302", raw: {} },
] };
},
async assertOfficialTaskPending() {},
async isOfficialTaskPending() { return false; },
} as unknown as ApprovalService;
const returns = {
supportsUser() { return true; },
async listRevertActivities() {
return [{ activityId: "node-1", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: [] }];
},
async revertTask() { writes += 1; },
};
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
const user = { userId: "user-1" };
const listed = await service.listTodos(user, "dingtalk", 10);
const items = await Promise.all(listed.items.map(async (item) => {
const approvalId = String(item.approvalId);
const action = (await service.getActions(user, approvalId)).actions[2] as { targets?: Array<{ targetId: string }> };
return { approvalId, targetId: String(action.targets?.[0]?.targetId) };
}));
items[1]!.targetId = items[1]!.targetId.slice(0, -9);
const result = await service.processBatch(user, "return", "请补充", items);
assert.equal(result.status, "preflight_failed");
assert.equal(result.writesStarted, false);
assert.equal(result.invalidItems?.[0]?.index, 2);
assert.equal(writes, 0);
});
test("unified OA return batch is non-transactional and marks an unconfirmed item uncertain", async () => {
const completed = new Set<string>();
const ding = {
async listMyTodos() {
return { items: [
{ source: "official_oa", processInstanceId: "good", taskId: "1", raw: {} },
{ source: "official_oa", processInstanceId: "still-pending", taskId: "2", raw: {} },
] };
},
async assertOfficialTaskPending() {},
async isOfficialTaskPending(_userId: string, processInstanceId: string) { return !completed.has(processInstanceId); },
} as unknown as ApprovalService;
const returns = {
supportsUser() { return true; },
async listRevertActivities() {
return [{ activityId: "node-1", revertAction: "REVERT_FOR_APPROVAL" as const, approverNames: [] }];
},
async revertTask(_userId: string, request: Record<string, unknown>) {
if (request.processInstanceId === "good") completed.add("good");
},
};
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
const user = { userId: "user-1" };
const listed = await service.listTodos(user, "dingtalk", 10);
const actionTargets = await Promise.all(listed.items.map(async (item) => {
const approvalId = String(item.approvalId);
const action = (await service.getActions(user, approvalId)).actions[2]! as { targets?: Array<{ targetId: string }> };
return { approvalId, targetId: action.targets?.[0]!.targetId };
}));
const result = await service.processBatch(user, "return", "", actionTargets);
assert.equal(result.succeeded, 1);
assert.equal(result.failed, 0);
assert.equal(result.uncertain, 1);
assert.equal(result.results[0]!.success, true);
assert.equal(result.results[1]!.status, "outcome_uncertain");
assert.match(String(result.results[1]!.message), /禁止自动重试/);
});
test("unified OA actions expose return-provider failures as unavailable and keep Yida unsupported", async () => {
const ding = {
async listMyTodos() {
return { items: [{ source: "official_oa", processInstanceId: "i1", taskId: "t1", raw: {} }] };
},
async assertOfficialTaskPending() {},
} as unknown as ApprovalService;
const returns = {
supportsUser() { return true; },
async listRevertActivities() { throw new Error("return provider unavailable"); },
async revertTask() {},
};
const service = new UnifiedApprovalService(config(), ding, undefined, undefined, returns);
const user = { userId: "user-1" };
const oaId = String((await service.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
const actions = await service.getActions(user, oaId);
const unavailable = actions.actions[2]! as { allowed: boolean; unavailable?: boolean; message?: string };
assert.equal(unavailable.allowed, false);
assert.equal(unavailable.unavailable, true);
assert.match(String(unavailable.message), /return provider unavailable/);
const yidaDing = {
async listMyTodos() { return { items: [{ source: "yida", processInstanceId: "y1", taskId: "yt1", appType: "app", raw: {} }] }; },
} as unknown as ApprovalService;
const yidaService = new UnifiedApprovalService(config(), yidaDing, undefined, undefined, returns);
const yidaId = String((await yidaService.listTodos(user, "dingtalk", 10)).items[0]!.approvalId);
const yidaResult = await yidaService.processBatch(user, "return", "", [{ approvalId: yidaId, targetId: "v2.placeholder.placeholder.placeholder" }]);
assert.equal(yidaResult.status, "preflight_failed");
assert.equal(yidaResult.failed, 0);
assert.match(String(yidaResult.invalidItems?.[0]?.message), /宜搭审批当前不支持退回/);
});
test("unified list preserves healthy sources when XFT requires authentication", async () => {
const ding = {
async listMyTodos() { return { items: [{ source: "official_oa", processInstanceId: "i1", taskId: "t1", title: "OA", raw: {} }] }; },
} as unknown as ApprovalService;
const xft = {
async listMyTodos() {
const { XftSessionExpiredError } = await import("../src/errors.js");
throw new XftSessionExpiredError();
},
};
const challenges = {
async getOrStart() {
return { provider: "xft" as const, type: "qr" as const, challengeId: "challenge", qrImageUrl: "https://mcp.test/qr", expiresAt: new Date(Date.now() + 60_000).toISOString(), pollAfterSeconds: 3 };
},
};
const service = new UnifiedApprovalService(config(), ding, xft as never, challenges);
const result = await service.listTodos({ userId: "user-1", name: "测试用户乙" }, "all", 50);
assert.equal(result.items.length, 1);
assert.equal(result.sourceStatus.dingtalk?.status, "ok");
assert.equal(result.sourceStatus.xft?.status, "auth_required");
assert.doesNotMatch(JSON.stringify(result), /cookie|token/i);
});
test("DingTalk submission requires a content-bound one-time confirmation", async () => {
const writes: Array<Record<string, unknown>> = [];
const ding = {
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "业务招待申请_捷康", dirName: "捷康_费用报销" }]; },
async getSubmissionSchema() {
return {
template: { processCode: "PROC-1", name: "业务招待申请_捷康", dirName: "捷康_费用报销" },
schema: { content: JSON.stringify({ items: [
{ componentName: "TextField", props: { label: "事由", required: true } },
{ componentName: "MoneyField", props: { label: "金额", required: true } },
] }) },
};
},
async resolveSubmissionDepartment() { return 123; },
async forecastSubmission(userId: string, request: Record<string, unknown>) {
assert.equal(userId, "user-1");
assert.equal("originatorUserId" in request, false);
return {
forecastSuccess: true,
staticWorkflow: false,
workflowActivityRuleVOs: [{
activityId: "approve-1", activityName: "主管审批", activityType: "target_select", targetSelect: true,
workflowActor: { actorKey: "manual-1", actorType: "approver", approvalMethod: "NONE" },
}],
};
},
async submitApproval(userId: string, request: Record<string, unknown>) {
writes.push({ userId, ...request });
return "instance-new";
},
async revokeSubmission(userId: string, processInstanceId: string, remark: string) {
assert.equal(userId, "user-1");
assert.equal(processInstanceId, "instance-new");
assert.equal(remark, "测试撤销");
return { status: "revoked", processInstanceId };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1", name: "测试用户" };
const listed = await service.listSubmissionTemplates(user, "dingtalk", 20);
const templateId = String(listed.items[0]?.templateId);
const incompleteInput = { templateId, values: { "事由": "购买耗材", "金额": "500" }, deptId: -1, targetSelectActioners: [] };
await assert.rejects(service.prepareSubmission(user, incompleteInput), /需要选择处理人/);
const input = {
...incompleteInput,
targetSelectActioners: [{ actionerKey: "manual-1", actionerStaffIds: ["manager-1"] }],
};
const prepared = await service.prepareSubmission(user, input);
assert.deepEqual(prepared.preview.selectionRequirements, [{
actionerKey: "manual-1", activityName: "主管审批", actorType: "approver", required: true,
}]);
assert.deepEqual(prepared.preview.processForecast, {
supported: true,
forecastSuccess: true,
staticWorkflow: false,
nodes: [{
activityId: "approve-1", activityName: "主管审批", activityType: "target_select", targetSelect: true,
actorType: "approver", approvalMethod: "NONE", actioners: [],
}],
});
const restartedService = new UnifiedApprovalService(config(), ding);
await assert.rejects(
restartedService.submitApproval(user, prepared.confirmationId!, input),
/先前的服务进程/,
);
await assert.rejects(
service.submitApproval(user, prepared.confirmationId!, { ...input, values: { ...input.values, "金额": "501" } }),
/与用户确认时不一致/,
);
const preparedAgain = await service.prepareSubmission(user, input);
const submitted = await service.submitApproval(user, preparedAgain.confirmationId!, input);
assert.equal(submitted.status, "submitted");
assert.equal(submitted.processInstanceId, "instance-new");
assert.ok(submitted.revokeId);
assert.equal(writes[0]?.userId, "user-1");
const revoked = await service.revokeSubmission(user, submitted.revokeId, "测试撤销");
assert.equal(revoked.status, "revoked");
await assert.rejects(service.revokeSubmission({ userId: "other-user" }, submitted.revokeId), /不属于当前登录用户|无效/);
await assert.rejects(service.submitApproval(user, preparedAgain.confirmationId!, input), /已使用/);
});
test("submission template listing deterministically keeps both sources", async () => {
const ding = {
async listSubmissionTemplates() {
return Array.from({ length: 10 }, (_, index) => ({
processCode: `PROC-${index}`, name: "外出_北极大",
dirName: index % 2 === 0 ? "北极大_人事假勤" : "其他分类",
}));
},
} as unknown as ApprovalService;
const xft = {
async listSubmissionTemplates() {
return [{ eventSeqId: "1", eventNumber: "XFT-1", eventName: "薪福通报销", eventType: "COMMON_REIM", kind: "common_reimburse" }];
},
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const listed = await service.listSubmissionTemplates({ userId: "user-1" }, "all", 4);
assert.deepEqual(listed.items.map((item) => item.source), ["dingtalk", "xft", "dingtalk", "dingtalk"]);
});
test("Xft submission confirmation expires when the live custom-field schema changes", async () => {
let schemaReads = 0;
let writes = 0;
const template = { eventSeqId: "1", eventNumber: "XFT-1", eventName: "费用申请", eventType: "EXPENSE_APPLY", kind: "expense_apply" as const };
const xft = {
async listSubmissionTemplates() { return [template]; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
async getSubmissionSchema() {
schemaReads += 1;
return {
template,
fields: [{ fieldNumber: "C1", name: "说明", type: schemaReads === 1 ? "INPUT" : "TEXTAREA", required: false }],
inputContract: {}, diagnostics: {},
};
},
async submitApproval() { writes += 1; return { billId: "1", status: "submitted", diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const input = { templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 1 }] } };
const prepared = await service.prepareSubmission(user, input);
await assert.rejects(service.submitApproval(user, prepared.confirmationId!, input), /表单在确认后发生变化/);
assert.equal(writes, 0);
});
test("Xft submission returns a user-bound revokeId and routes recall without exposing empId", async () => {
const template = { eventSeqId: "1", eventNumber: "XFT-1", eventName: "费用申请", eventType: "EXPENSE_APPLY", kind: "expense_apply" as const };
const recalls: Array<Record<string, unknown>> = [];
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
async submitApproval() { return { billId: "2026081200000001", status: "submitted", diagnostics: {} }; },
async recallSubmission(user: Record<string, unknown>, billId: string, reason: string) {
recalls.push({ user, billId, reason });
return { status: "revoked", billId, diagnostics: { applicantIdentity: "server_derived" } };
},
async getMySubmissionStatus() {
return {
billId: "2026081200000001", status: "1", statusName: "待审批",
revoked: false, revokeAllowed: true, diagnostics: { ownershipRevalidated: true },
};
},
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const input = { templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 1 }] } };
const prepared = await service.prepareSubmission(user, input);
const submitted = await service.submitApproval(user, prepared.confirmationId!, input);
assert.equal(submitted.source, "xft");
assert.ok(submitted.revokeId);
const recovered = await service.getSubmissionStatus(user, "xft", "2026081200000001");
assert.ok(recovered.revokeId);
await assert.rejects(service.revokeSubmission({ userId: "other-user" }, recovered.revokeId!), /不属于当前登录用户|无效/);
const revoked = await service.revokeSubmission(user, submitted.revokeId, "测试撤回");
assert.equal(revoked.status, "revoked");
assert.deepEqual(recalls, [{ user, billId: "2026081200000001", reason: "测试撤回" }]);
await assert.rejects(service.revokeSubmission({ userId: "other-user" }, submitted.revokeId), /不属于当前登录用户|无效/);
});
test("Xft submission reports an immediately completed workflow without issuing revokeId", async () => {
const template = { eventSeqId: "1", eventNumber: "XFT-1", eventName: "费用申请", eventType: "EXPENSE_APPLY", kind: "expense_apply" as const };
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
async submitApproval() { return { billId: "2026081300000001", status: "submitted", diagnostics: {} }; },
async getMySubmissionStatus() {
return {
billId: "2026081300000001", status: "2", statusName: "审批通过",
revoked: false, revokeAllowed: false, flowHistory: [{ nodeName: "发起申请", actions: ["start"] }], diagnostics: {},
};
},
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const input = { templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 1 }] } };
const prepared = await service.prepareSubmission(user, input);
const submitted = await service.submitApproval(user, prepared.confirmationId!, input);
assert.equal(submitted.status, "completed");
assert.equal(submitted.observedStatusName, "审批通过");
assert.equal(submitted.completionMode, "workflow_completed_immediately");
assert.equal("revokeId" in submitted, false);
assert.equal(submitted.diagnostics.automaticRetryAllowed, false);
});
test("Xft travel reimbursement preparation validates nested and custom required fields", async () => {
const template = { eventSeqId: "1", eventNumber: "XFT-T", eventName: "差旅报销单", eventType: "TRAVEL_REIM", kind: "travel_reimburse" as const };
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return {
template,
fields: [{ fieldNumber: "-2", name: "承担部门", type: "ADV_DEPARTMENT", required: true }],
inputContract: {}, diagnostics: {},
};
},
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const base = {
templateId,
values: {
reimAmount: "10.00",
reimAmountCurrencyCode: "CNY",
expenseList: [{ expTypeCode: "TYPE-1", currencyCode: "CNY", originalCurrencyReimAmt: "10.00", wrTim: "2026-08-12" }],
businessTrip: { businessTripDetails: [{ departCityCode: "25", destinationCityCode: "1", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00" }] },
},
};
await assert.rejects(service.prepareSubmission(user, base), /承担部门/);
const prepared = await service.prepareSubmission(user, {
...base,
values: { ...base.values, customFieldValues: [{ fieldNumber: "-2", fieldValue: "[\"ORG-1\"]" }] },
});
assert.equal(prepared.status, "confirmation_required");
await assert.rejects(service.prepareSubmission(user, {
...base,
values: { ...base.values, customFieldValues: [{ fieldNumber: "-2", fieldValue: "[\"ORG-1\"]" }], businessTrip: { businessTripDetails: [{}] } },
}), /第 1 段行程缺少/);
});
test("DingTalk submission rejects required client-only generated fields", async () => {
const ding = {
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "外出_北极大", dirName: "北极大_人事假勤" }]; },
async getSubmissionSchema() {
return { template: { processCode: "PROC-1", name: "外出_北极大", dirName: "北极大_人事假勤" }, schema: { content: JSON.stringify({ items: [
{ componentName: "SeqNumberField", props: { label: "流水号", required: true } },
] }) } };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
await assert.rejects(service.prepareSubmission(user, { templateId, values: {} }), /钉钉客户端发起/);
});
test("DingTalk table submission template is client-only after live enterprise API incompatibility", async () => {
const ding = {
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "业务招待申请_北极大", dirName: "北极大_人事假勤" }]; },
async getSubmissionSchema() {
return { template: { processCode: "PROC-1", name: "业务招待申请_北极大", dirName: "北极大_人事假勤" }, schema: { schemaContent: {
items: [
{ componentName: "InnerContactField", props: { id: "applicant", label: "申请人", required: true } },
{ componentName: "DepartmentField", props: { id: "department", label: "部门", required: true } },
{ componentName: "TableField", props: { id: "entertainmentRows", label: "业务招待申请明细", required: true }, children: [
{ componentName: "TextField", props: { id: "reason", label: "招待事由", required: true } },
{ componentName: "NumberField", props: { id: "amount", label: "招待预估金额", required: true } },
] },
],
} } };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1", name: "测试用户" };
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
const schema = await service.getSubmissionSchema(user, templateId);
assert.equal(schema.fields.length, 5);
assert.equal(schema.submissionCapability.status, "client_only");
const schemaFields = schema.fields as Array<Record<string, unknown>>;
assert.equal(schemaFields.find((field) => field.name === "部门")?.serverManaged, true);
await assert.rejects(service.prepareSubmission(user, {
templateId, values: { 业务招待申请明细: [{ 招待事由: "客户拜访", 招待预估金额: "100" }] },
}), /钉钉客户端|DWS/);
});
test("DingTalk finance suites are marked client-only even when their schema has no label", async () => {
const ding = {
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "差旅费用报销_捷康", dirName: "捷康_费用报销" }]; },
async getSubmissionSchema() {
return { template: { processCode: "PROC-1", name: "差旅费用报销_捷康", dirName: "捷康_费用报销" }, schema: { schemaContent: { items: [{
componentName: "DDBizSuite", props: { bizType: "dingtalk.businessFinance.reimbursement" }, children: [
{ componentName: "MoneyField", props: { label: "报销总额(元)" } },
],
}] } } };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
const schema = await service.getSubmissionSchema(user, templateId);
assert.equal(schema.submissionCapability?.status, "client_only");
await assert.rejects(service.prepareSubmission(user, { templateId, values: {} }), /费控业务套件/);
});
test("Xft application templates support API submission with live post-write status verification", async () => {
let template: XftSubmissionTemplate = { eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单", eventType: "INVITATION_APPLY", kind: "entertainment_apply" };
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
let templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
assert.equal((await service.getSubmissionSchema(user, templateId)).submissionCapability.status, "api_supported_with_constraints");
const entertainmentPrepared = await service.prepareSubmission(user, { templateId, values: { entertainmentApplyApiCreateDTO: {
entertainmentCityCode: "1", entertainmentPersonCount: 2, entertainmentStartTime: "2026-08-12", entertainmentEndTime: "2026-08-13",
} } });
assert.equal(entertainmentPrepared.status, "confirmation_required");
template = { eventSeqId: "2", eventNumber: "XFT-T", eventName: "出差费用申请单", eventType: "TRAVEL_APPLY", kind: "travel_apply" };
templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
assert.equal((await service.getSubmissionSchema(user, templateId)).submissionCapability.status, "api_supported_with_constraints");
const travelPrepared = await service.prepareSubmission(user, { templateId, values: { businessTrip: { businessTripDetails: [{
departCityCode: "1", destinationCityCode: "2", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00",
}] } } });
assert.equal(travelPrepared.status, "confirmation_required");
});
test("Xft preparation preview resolves every selection to user-readable business names", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "event-travel", eventNumber: "XFT-TRAVEL", eventName: "差旅报销单",
eventType: "TRAVEL_REIM", kind: "travel_reimburse",
};
const fields: XftSubmissionField[] = [
{
fieldNumber: "C-TOPIC", name: "申请事项", type: "SELECT", required: true,
options: [{ code: "PROCUREMENT", label: "采购事项" }],
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
},
{
fieldNumber: "C-TABLE", name: "补充明细", type: "TABLE", required: true,
itemFields: [{
titleCode: "T-TYPE", label: "明细类型", type: "SELECT", required: true,
options: [{ code: "MARKETING", label: "市场活动" }],
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
}],
},
];
const standardFields = [
{ key: "reimAmount", label: "报销总金额", type: "decimal" },
{ key: "reimAmountCurrencyCode", label: "报销币种", type: "string", enumLabels: { CNY: "人民币" } },
{
key: "expenseList", label: "费用明细", type: "array", itemFields: [
{ key: "expTypeCode", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
{ key: "currencyCode", label: "费用币种", type: "string", enumLabels: { CNY: "人民币" } },
{ key: "originalCurrencyReimAmt", label: "费用金额", type: "decimal" },
{ key: "payCityCode", label: "消费城市", type: "string", optionSource: { kind: "city" } },
],
},
{
key: "businessTrip", label: "差旅行程", type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", type: "array", itemFields: [
{ key: "departCityCode", label: "出发城市", type: "string", optionSource: { kind: "city" } },
{ key: "destinationCityCode", label: "目的城市", type: "string", optionSource: { kind: "city" } },
{ key: "beginTime", label: "出发时间", type: "datetime" },
{ key: "endTime", label: "结束时间", type: "datetime" },
{ key: "travelMode", label: "出行方式", type: "comma_separated_enum", multiple: true, enumLabels: { AirPlane: "飞机", Train: "火车" } },
{ key: "roundTrip", label: "是否往返", type: "string", enumLabels: { Y: "往返", N: "单程" } },
],
}],
},
{ key: "customFieldValues", userVisible: false, type: "array" },
];
const optionCalls: Array<{ kind: string; query: string }> = [];
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return { template, fields, inputContract: { values: { standardFields } }, diagnostics: {} };
},
async getSubmissionOptions(_user: unknown, _template: unknown, kind: string, query: string) {
optionCalls.push({ kind, query });
const labels: Record<string, string> = {
"440300": "深圳市", "310100": "上海市", MEAL: "餐饮费",
};
return {
kind,
items: labels[query] ? [{ code: query, label: labels[query] }] : [],
diagnostics: {},
};
},
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const prepared = await service.prepareSubmission(user, {
templateId,
values: {
reimAmount: "100.00",
reimAmountCurrencyCode: "CNY",
expenseList: [{
expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "100.00", payCityCode: "440300",
}],
businessTrip: { businessTripDetails: [{
departCityCode: "440300", destinationCityCode: "310100",
beginTime: "2026-08-20 09:00", endTime: "2026-08-21 18:00",
travelMode: "AirPlane,Train", roundTrip: "Y",
}] },
customFieldValues: [
{ fieldNumber: "C-TOPIC", fieldValue: "PROCUREMENT" },
{ fieldNumber: "C-TABLE", fieldValue: JSON.stringify([{ sequence: 1, columnValues: [{ titleCode: "T-TYPE", value: "MARKETING" }] }]) },
],
},
});
assert.equal(prepared.status, "confirmation_required");
const summary = JSON.stringify(prepared.preview.formSummary);
for (const label of ["深圳市", "上海市", "餐饮费", "人民币", "飞机", "火车", "往返", "采购事项", "市场活动"]) {
assert.match(summary, new RegExp(label));
}
for (const internal of ["440300", "310100", "MEAL", "C-TOPIC", "PROCUREMENT", "CNY", "T-TYPE", "MARKETING"]) {
assert.doesNotMatch(summary, new RegExp(internal));
}
assert.deepEqual(optionCalls, [
{ kind: "expense_type", query: "MEAL" },
{ kind: "city", query: "440300" },
{ kind: "city", query: "310100" },
]);
});
test("Xft preparation fails before confirmation when a selected code has no readable mapping", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return {
template,
fields: [],
inputContract: { values: { standardFields: [{
key: "applyExpenses", label: "事前申请费用列表", type: "array", itemFields: [
{ key: "expenseNumber", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
{ key: "amount", label: "申请金额", type: "decimal" },
],
}] } },
diagnostics: {},
};
},
async getSubmissionOptions() { return { kind: "expense_type", items: [], diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects(
service.prepareSubmission(user, {
templateId, values: { applyExpenses: [{ expenseNumber: "UNKNOWN", amount: "10.00" }] },
}),
/无法映射为业务名称.*重新查询可用名称/,
);
});
test("Xft use-car application is omitted and old signed references are rejected", async () => {
const useCarTemplate: XftSubmissionTemplate = {
eventSeqId: "event-car", eventNumber: "XFT-CAR", eventName: "用车申请单",
eventType: "USE_CAR_APPLY", kind: "use_car_apply",
};
const expenseTemplate: XftSubmissionTemplate = {
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
let schemaReads = 0;
const xft = {
async listSubmissionTemplates() { return [useCarTemplate, expenseTemplate]; },
async getSubmissionSchema() {
schemaReads += 1;
return { template: useCarTemplate, fields: [], inputContract: { values: { standardFields: [] } }, diagnostics: {} };
},
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const listed = await service.listSubmissionTemplates(user, "xft", 10);
assert.deepEqual(listed.items.map((item) => item.name), ["费用申请单"]);
assert.equal(listed.sourceStatus.xft?.count, 1);
const templateId = (service as any).signer.sign({
kind: "submission_template", userId: user.userId, source: "xft",
xftTemplate: useCarTemplate, exp: Date.now() + 60_000,
});
await assert.rejects(service.getSubmissionSchema(user, templateId), /不在 MCP 支持的发起范围/);
await assert.rejects(
service.prepareSubmission(user, { templateId, values: { useCarInfo: { purpose: "客户拜访" } } }),
/不在 MCP 支持的发起范围/,
);
assert.equal(schemaReads, 0);
});
test("Xft preparation reports standard and live custom required fields together", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return {
template,
fields: [
{ fieldNumber: "C1", name: "招待事由", type: "MULTI_INPUT", required: true },
{ fieldNumber: "C2", name: "招待日期", type: "DATE", required: true },
],
inputContract: {}, diagnostics: {},
};
},
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects(
service.prepareSubmission(user, { templateId, values: {} }),
/业务字段:事前申请费用;自定义字段:招待事由、招待日期/,
);
});
test("Xft preparation blocks fields whose user-readable choices cannot be resolved", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-E", eventName: "费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
let fields: XftSubmissionField[] = [{
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true,
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
}];
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields, inputContract: {}, diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const schema = await service.getSubmissionSchema(user, templateId);
assert.equal(schema.submissionCapability.status, "client_only");
assert.match(String(schema.submissionCapability.reason), /参与人员/);
await assert.rejects(service.prepareSubmission(user, {
templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }] },
}), /无法安全填写的必填项:参与人员.*薪福通客户端/);
fields = [{
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: false,
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
}];
await assert.rejects(service.prepareSubmission(user, {
templateId,
values: {
applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }],
customFieldValues: [{ fieldNumber: "P1", fieldValue: "[\"EMP-1\"]" }],
},
}), /参与人员.*无法通过 MCP.*薪福通客户端/);
});
test("Xft preparation refuses optional internal-value fields instead of asking the user for codes", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-T", eventName: "出差申请单",
eventType: "TRAVEL_APPLY", kind: "travel_apply",
};
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects(service.prepareSubmission(user, {
templateId,
values: {
businessTrip: { businessTripDetails: [{
departCityCode: "1", destinationCityCode: "2", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00",
}] },
peerEmpNumbers: ["EMP-1"],
},
}), /同行人员.*不要提供内部编码或 ID/);
});
test("submission rejects caller-supplied applicant identity", async () => {
const ding = {
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }]; },
async getSubmissionSchema() {
return { template: { processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }, schema: { content: JSON.stringify({ items: [] }) } };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "dingtalk", 10)).items[0]?.templateId);
await assert.rejects(service.prepareSubmission(user, {
templateId, values: { originatorUserId: "victim" },
}), /不得包含发起人身份/);
});
test("DingTalk submission scope filters names and revalidates after template listing", async () => {
let category = "捷康_费用报销";
let name = "业务招待费用报销_捷康";
const ding = {
async listSubmissionTemplates() {
return [
{ processCode: "ALLOWED", name, dirName: category },
{ processCode: "BJ-TRAVEL", name: "出差申请单_北极大", dirName: "北极大_人事假勤" },
{ processCode: "BJ-OUT", name: "外出_北极大", dirName: "北极大_人事假勤" },
{ processCode: "BJ-ENT", name: "业务招待申请_北极大", dirName: "北极大_人事假勤" },
{ processCode: "JK-TRAVEL", name: "差旅费用报销_捷康", dirName: "捷康_费用报销" },
{ processCode: "JK-ENT-APPLY", name: "业务招待申请_捷康", dirName: "捷康_费用报销" },
{ processCode: "SAME-CATEGORY-BLOCKED", name: "日常费用报销_捷康", dirName: "捷康_费用报销" },
{ processCode: "SAME-CATEGORY-BLOCKED-2", name: "请假_北极大", dirName: "北极大_人事假勤" },
{ processCode: "OTHER-CATEGORY-BLOCKED", name: "其他申请", dirName: "其他分类" },
];
},
async getSubmissionSchema(processCode: string) {
return { template: { processCode, name, dirName: category }, schema: { content: JSON.stringify({ items: [] }) } };
},
} as unknown as ApprovalService;
const service = new UnifiedApprovalService(config(), ding);
const user = { userId: "user-1" };
const listed = await service.listSubmissionTemplates(user, "dingtalk", 10);
assert.deepEqual(listed.items.map((item) => item.name), [
"业务招待费用报销_捷康",
"出差申请单_北极大",
"外出_北极大",
"业务招待申请_北极大",
"差旅费用报销_捷康",
"业务招待申请_捷康",
]);
const templateId = String(listed.items[0]?.templateId);
name = "日常费用报销_捷康";
await assert.rejects(service.getSubmissionSchema(user, templateId), /不在允许发起范围/);
});