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();