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
+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() {