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
+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: "北极大_人事假勤" }]; },