feat: support secure XFT submission attachments
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-09-01 03:44:36 +00:00
parent ba2cfa48cb
commit b7313ab74d
21 changed files with 1078 additions and 98 deletions
+89 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
@@ -49,6 +50,87 @@ test("stdio bridge converts a remote batch timeout into a structured uncertain o
assert.match(result.content[0].text, /重新列出实时待办核对/);
});
test("stdio bridge keeps draft paths local, previews attachment metadata, and uploads only after final confirmation", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-attachment-"));
const statePath = join(root, "session.json");
const filePath = join(root, "invoice.pdf");
const content = Buffer.from("%PDF-1.7\nmock invoice", "utf8");
const sha256 = createHash("sha256").update(content).digest("hex");
writeFileSync(filePath, content);
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
const uploads: Array<{ headers: Headers; body: Buffer }> = [];
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input, init) => {
assert.equal(new URL(String(input)).pathname, "/xft/submission-attachments");
uploads.push({ headers: new Headers(init?.headers), body: Buffer.from(init?.body as ArrayBuffer) });
return Response.json({
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
attachmentRef: "opaque-upload-reference-12345",
});
});
const calls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
(bridge as unknown as { client: { callTool(request: any): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool(request: any) {
calls.push(request);
return { content: [{ type: "text", text: "ok" }], structuredContent: { result: { status: "ok" } } };
},
async close() {},
};
const localAttachment = {
fieldName: "发票附件", displayName: "invoice.pdf", localPath: filePath,
size: content.length, modifiedAt: new Date().toISOString(), sha256,
};
await bridge.prepareSubmission({ templateId: "template-reference-12345", values: {}, attachments: [localAttachment] });
assert.equal(uploads.length, 0, "preview must have no upstream upload side effect");
const preparedArgs = calls[0]!.arguments;
assert.deepEqual(preparedArgs.attachments, [{
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
}]);
assert.doesNotMatch(JSON.stringify(preparedArgs), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
await bridge.submitApproval({
confirmationId: "confirmation-reference-12345", templateId: "template-reference-12345",
values: {}, attachments: [localAttachment],
});
assert.equal(uploads.length, 1);
assert.deepEqual(uploads[0]!.body, content);
assert.equal(uploads[0]!.headers.get("authorization"), "Bearer fresh-access");
assert.equal(decodeURIComponent(uploads[0]!.headers.get("x-dingmcp-field-name")!), "发票附件");
assert.equal(decodeURIComponent(uploads[0]!.headers.get("x-dingmcp-file-name")!), "invoice.pdf");
assert.equal(uploads[0]!.headers.get("x-dingmcp-file-sha256"), sha256);
const submittedArgs = calls[1]!.arguments;
assert.deepEqual(submittedArgs.attachments, [{
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
attachmentRef: "opaque-upload-reference-12345",
}]);
assert.doesNotMatch(JSON.stringify(submittedArgs), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
test("stdio bridge rejects symlinked or changed draft attachments before any upload", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-attachment-link-"));
const statePath = join(root, "session.json");
const realPath = join(root, "invoice.pdf");
const linkPath = join(root, "invoice-link.pdf");
writeFileSync(realPath, "%PDF-1.7\nmock invoice");
symlinkSync(realPath, linkPath);
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
let networkCalls = 0;
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
networkCalls += 1;
throw new Error("network must not be called");
});
await assert.rejects(bridge.prepareSubmission({
templateId: "template-reference-12345", values: {},
attachments: [{ fieldName: "发票附件", localPath: linkPath }],
}), /无法安全读取附件.*符号链接/);
assert.equal(networkCalls, 0);
});
test("legacy updater post-install syncs the complete bundled Skill", () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-"));
const bundled = join(root, "package", "skill", "dingtalk-approval");
@@ -301,6 +383,12 @@ test("MCP approval_login returns both portable text and a standard resource link
assert.match(options.description ?? "", /所有选项只向用户展示label.*values.*code.*不能填label.*工具内部/s);
const submissionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_schema")!;
assert.match(submissionSchema.description ?? "", /minimalExample.*尖括号必须替换/);
for (const toolName of ["approval_prepare_submission", "approval_submit"]) {
const submission = tools.tools.find((tool) => tool.name === toolName)!;
const attachmentItem = (submission.inputSchema as any).properties.attachments.items;
assert.ok(attachmentItem.properties.localPath, `${toolName} must accept Bridge-local paths`);
assert.equal(attachmentItem.properties.attachmentRef, undefined);
}
} finally {
await client.close();
await server.close();
+2 -2
View File
@@ -103,8 +103,8 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
assert.match(localDrafts, /Never store[\s\S]*templateId[\s\S]*confirmationId[\s\S]*complete upstream schema/);
assert.match(localDrafts, /outcome_uncertain[\s\S]*freeze[\s\S]*Never prepare or submit it again automatically/);
assert.match(localDrafts, /never on the approval gateway/);
assert.match(localDrafts, /local attachment path.*never a valid upstream attachment field value.*native client/s);
assert.match(createApproval, /Local file paths.*not valid attachment field values.*native client/s);
assert.match(localDrafts, /localPath.*signed Bridge.*does not upload.*final confirmation.*native-client completion/s);
assert.match(createApproval, /Local file paths.*not valid `values` or `customFieldValues`.*signed Bridge `attachments`.*submit uploads only after final confirmation.*native client/s);
assert.match(createApproval, /every XFT selection.*label-only.*optionSource.*returned `label` values.*`code` only inside/s);
assert.match(createApproval, /Never show, explain, or ask.*city code.*organization code.*employee number.*numeric cabin value.*`expenseNumber`.*`expTypeCode`/s);
assert.match(createApproval, /selectionCapability\.status=client_only.*required field stops.*optional field may be skipped/s);
+36 -1
View File
@@ -5,7 +5,8 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { AppConfig } from "../src/config.js";
import type { ApprovalService } from "../src/approval-service.js";
import type { XftApprovalProvider } from "../src/xft.js";
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js";
import { SignedReferenceCodec } from "../src/signed-reference.js";
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, assertAttachmentConfirmation, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js";
function config(): AppConfig {
return {
@@ -96,6 +97,17 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
assert.match(descriptions.approval_prepare_submission ?? "", /values.*code.*不能直接填label.*预览.*业务名称/s);
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
for (const toolName of ["approval_prepare_submission", "approval_submit"]) {
const submission = tools.tools.find((tool) => tool.name === toolName)!;
const attachmentItem = (submission.inputSchema as any).properties.attachments.items;
assert.ok(attachmentItem.properties.fileName, `${toolName} must accept validated attachment metadata`);
assert.ok(attachmentItem.properties.sha256);
assert.equal(attachmentItem.properties.localPath, undefined, "the gateway must never accept local paths");
}
const prepareAttachmentItem = prepareSchema.properties.attachments.items;
assert.equal(prepareAttachmentItem.properties.attachmentRef, undefined, "prepare must not accept pre-uploaded references");
const submitSchema = tools.tools.find((tool) => tool.name === "approval_submit")?.inputSchema as any;
assert.ok(submitSchema.properties.attachments.items.properties.attachmentRef);
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
assert.match(descriptions.approval_submit ?? "", /禁止自动重试/);
assert.match(descriptions.approval_revoke_submission ?? "", /禁止自动重试.*薪福通.*实验能力/);
@@ -139,6 +151,29 @@ test("OAuth consent CSP permits only the DingTalk login redirect after same-orig
assert.doesNotMatch(policy, /form-action[^;]*\*/);
});
test("attachment uploads are bound to the signed-in user and the exact confirmed preview", () => {
const signer = new SignedReferenceCodec("fixed-reference-secret-for-tests");
const attachment = {
fieldName: "发票附件", fileName: "invoice.pdf", size: 551,
sha256: "a".repeat(64),
};
const confirmationId = signer.sign({
kind: "submission_confirmation",
source: "xft",
userId: "user-1",
attachmentManifest: [attachment],
exp: Date.now() + 60_000,
});
assert.doesNotThrow(() => assertAttachmentConfirmation(signer, { userId: "user-1" }, confirmationId, attachment));
assert.throws(() => assertAttachmentConfirmation(
signer, { userId: "user-1" }, confirmationId, { ...attachment, fileName: "unconfirmed.pdf" },
), /不在用户刚刚确认的提单预览中/);
assert.throws(() => assertAttachmentConfirmation(
signer, { userId: "another-user" }, confirmationId, attachment,
), /不属于当前用户/);
});
test("remote gateway lists DingTalk OA and XFT through one authenticated MCP session", async () => {
const dingTalk = {
async listMyTodos() {
+62
View File
@@ -1079,6 +1079,68 @@ test("Xft travel reimbursement preparation validates nested and custom required
}), /第 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: "北极大_人事假勤" }]; },
+74 -1
View File
@@ -487,6 +487,8 @@ test("Xft submission discovers live templates and derives protected create field
fieldNumber: "billDepartmentCode", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
}, {
fieldNumber: "C-DYNAMIC-DEPT", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
}, {
fieldNumber: "ACCESSORIES_1", name: "发票附件", type: "ACCESSORIES", required: false, options: [],
}] });
if (path.endsWith("reimburse/bill/create")) {
createBodies.push(body);
@@ -499,7 +501,14 @@ test("Xft submission discovers live templates and derives protected create field
assert.equal(templates[0]?.kind, "common_reimburse");
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[0]!);
assert.equal(schema.fields[0]?.fieldNumber, "C1");
assert.equal(schema.fields.length, 1);
assert.equal(schema.fields.length, 2);
assert.deepEqual(schema.fields.find((field) => field.fieldNumber === "ACCESSORIES_1")?.attachmentCapability, {
status: "supported",
input: "local_file",
uploadWhen: "after_final_confirmation",
maxFileBytes: 50 * 1024 * 1024,
maxFilesPerSubmission: 10,
});
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
const minimalExample = (schema.inputContract.values as Record<string, unknown>).minimalExample as Record<string, unknown>;
assert.deepEqual(Object.keys(minimalExample), ["reimAmount", "reimAmountCurrencyCode", "expenseList"]);
@@ -526,6 +535,70 @@ test("Xft submission discovers live templates and derives protected create field
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
});
test("Xft attachment upload follows the official multipart signing mode and injects opaque file IDs only at create", async () => {
const file = Buffer.from("%PDF-1.7\nmock invoice", "utf8");
const sha256 = (await import("node:crypto")).createHash("sha256").update(file).digest("hex");
let uploadHeaders: Headers | undefined;
let uploadBody: Buffer | undefined;
let createBody: Record<string, unknown> | undefined;
const fakeFetch: typeof fetch = async (input, init) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{
thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1",
}] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
}] } });
if (path.endsWith("accessory/custom-field/upload")) {
uploadHeaders = new Headers(init?.headers);
uploadBody = Buffer.from(init?.body as ArrayBuffer);
assert.equal(url.searchParams.get("uploaderNumber"), "E001");
return encrypted({ code: "SUC0000", body: { fileId: "file-secret-1" } });
}
if (path.endsWith("reimburse/bill/create")) {
createBody = decodedBody(init);
return encrypted({ code: "SUC0000", body: 2026090100000001 });
}
throw new Error(`unexpected ${path}`);
};
const client = new XftOpenApiClient(config(), fakeFetch);
const confirmationId = "confirmation-bound-to-user-preview";
const uploaded = await client.uploadSubmissionAttachment({ userId: "ding-u1" }, {
fieldName: "发票附件", fileName: "invoice.pdf", size: file.length, sha256, content: file, confirmationId,
});
assert.equal(uploadHeaders?.has("x-alb-digest"), false, "official SDK omits body digest for multipart uploads");
assert.equal(uploadHeaders?.get("x-alb-verify"), "sm3withsm2");
assert.equal(uploadHeaders?.get("apisign")?.length, 128);
assert.match(uploadHeaders?.get("content-type") ?? "", /^multipart\/form-data;.*boundary=/);
assert.match(uploadBody?.toString("utf8") ?? "", /name="file"; filename="invoice\.pdf"/);
assert.ok(uploadBody?.includes(file));
assert.doesNotMatch(uploaded.attachmentRef, /file-secret-1/);
const template = {
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单",
eventType: "COMMON_REIM", kind: "common_reimburse" as const,
};
await client.submitApproval({ userId: "ding-u1" }, template, {
reimAmount: "551.00", reimAmountCurrencyCode: "CNY", expenseList: [{ expTypeCode: "MEAL" }],
}, [{
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
}], confirmationId);
assert.deepEqual(createBody?.customFieldValues, [{
fieldNumber: "ACCESSORIES_1", fieldValue: JSON.stringify(["file-secret-1"]),
}]);
assert.doesNotMatch(JSON.stringify(createBody), /confirmation-bound|attachmentRef|sha256/);
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}, [{
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
}], "another-confirmation"), /与本次用户确认.*不一致/);
});
test("Xft submission options expose only departments, cities, and the matching expense data source", async () => {
const templateRecord = {
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",