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
+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",