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
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:
+89
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user