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> = []; 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> = []; 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) { 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 : {}; assert.deepEqual(compact.applicant, { name: "张三", department: "采购部" }); assert.equal(compact.submittedAt, "2026-08-11T01:00:00.000Z"); assert.deepEqual((compact.formFields as Array>)[0], { label: "项目", value: "完整字段保留" }); assert.equal((compact.formFields as Array>).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((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("unadapted XFT todos stay visible but detail and every write action remain client-only", async () => { let detailCalls = 0; let writes = 0; const ding = { async listMyTodos() { return { items: [{ source: "official_oa", processInstanceId: "mixed-instance", taskId: "mixed-task", title: "钉钉审批", raw: {}, }] }; }, } as unknown as ApprovalService; const xft = { async listMyTodos() { return { items: [{ source: "xft", taskId: "provider-task-secret", capability: "list_only", unavailableReason: "unsupported_task_type", title: "薪福通其他审批", allowPass: false, allowReject: false, }], diagnostics: { billApiCount: 0, clientOnlyCount: 1 }, }; }, async getTodoDetail() { detailCalls += 1; return {}; }, async listReturnNodes() { detailCalls += 1; return []; }, async processApprovalBatch() { writes += 1; return { results: [{ success: true }] }; }, }; const service = new UnifiedApprovalService(config(), ding, xft as never); const user = { userId: "user-1" }; const listed = await service.listTodos(user, "all", 10); assert.equal(listed.returned, 2); assert.equal("selectionId" in listed, false, "a mixed result containing client-only XFT tasks cannot become a batch selection"); assert.equal(listed.sourceStatus.xft?.billApiCount, 0); assert.equal(listed.sourceStatus.xft?.clientOnlyCount, 1); const item = listed.items.find((candidate) => candidate.title === "薪福通其他审批")!; assert.equal("businessNo" in item, false); assert.deepEqual(item.availableActions, []); assert.equal((item.manualAction as Record).reason, "unsupported_xft_task_type"); const approvalId = String(item.approvalId); assert.match(approvalId, /^v4\./); assert.doesNotMatch(approvalId, /provider-task-secret/); const detail = await service.getDetail(user, approvalId) as unknown as Record; assert.equal(detail.status, "client_required"); assert.deepEqual(detail.diagnostics, { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false }); assert.equal(detailCalls, 0, "client-only detail must not call an unverified upstream detail API"); const actions = await service.getActions(user, approvalId); assert.deepEqual(actions.actions.map((action) => [action.action, action.allowed]), [ ["approve", false], ["reject", false], ["return", false], ]); assert.equal((actions.manualAction as Record).reason, "unsupported_xft_task_type"); assert.equal(detailCalls, 0, "client-only action discovery must not call an unverified upstream action API"); const processed = await service.processBatch(user, "approve", "同意", [{ approvalId }]); assert.equal(processed.status, "preflight_failed"); assert.equal(processed.writesStarted, false); assert.equal(processed.invalidItems?.[0]?.code, "provider_not_supported"); 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).reason, "signature_required"); const approvalId = String(listed.items[0]!.approvalId); const actions = await service.getActions(user, approvalId) as unknown as { actions: Array>; manualAction: Record; }; 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).reason, "signature_required"); assert.match(String((result.results[0]?.manualAction as Record).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> = []; 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) { 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(); 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) { 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> = []; 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) { 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) { 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> = []; 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, 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("Xft local attachment metadata satisfies required fields, is preview-bound, and never exposes a path", async () => { const template = { eventSeqId: "1", eventNumber: "XFT-A", eventName: "费用报销单", eventType: "COMMON_REIM", kind: "common_reimburse" as const, }; const submittedAttachments: unknown[] = []; const xft = { async listSubmissionTemplates() { return [template]; }, async getSubmissionSchema() { return { template, fields: [{ fieldNumber: "ACCESSORIES_1", name: "发票附件", type: "ACCESSORIES", required: true, attachmentCapability: { status: "supported", input: "local_file", uploadWhen: "after_final_confirmation", maxFileBytes: 50 * 1024 * 1024, maxFilesPerSubmission: 10, }, }], inputContract: {}, diagnostics: {}, }; }, async getSubmissionOptions() { return { kind: "expense_type", items: [{ code: "MEAL", label: "业务招待费" }], diagnostics: {} }; }, async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; }, async submitApproval(_user: unknown, _template: unknown, _values: unknown, attachments: unknown[]) { submittedAttachments.push(...attachments); return { billId: "2026090100000001", status: "submitted", diagnostics: {} }; }, async getMySubmissionStatus() { return { billId: "2026090100000001", status: "1", statusName: "待审批", revoked: false, revokeAllowed: 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 values = { reimAmount: "551.00", reimAmountCurrencyCode: "CNY", expenseList: [{ expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "551.00" }], }; await assert.rejects(service.prepareSubmission(user, { templateId, values }), /发票附件/); const metadata = { fieldName: "发票附件", fileName: "invoice.pdf", size: 21, sha256: "a".repeat(64), }; const prepared = await service.prepareSubmission(user, { templateId, values, attachments: [metadata] }); assert.equal(prepared.status, "confirmation_required"); assert.match(JSON.stringify(prepared.preview), /invoice\.pdf/); assert.doesNotMatch(JSON.stringify(prepared.preview), /a{32}|localPath|attachmentRef/); const submitted = await service.submitApproval(user, prepared.confirmationId!, { templateId, values, attachments: [{ ...metadata, attachmentRef: "opaque-upload-reference-12345" }], }); assert.equal(submitted.status, "submitted"); assert.deepEqual(submittedAttachments, [{ ...metadata, fieldNumber: "ACCESSORIES_1", attachmentRef: "opaque-upload-reference-12345", }]); await assert.rejects(service.prepareSubmission(user, { templateId, values: { ...values, customFieldValues: [{ fieldNumber: "ACCESSORIES_1", fieldValue: "invented-file-id" }] }, attachments: [metadata], }), /必须通过 attachments.*不能直接提供附件编号/); }); 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>; 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 = { "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), /不在允许发起范围/); });