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();
|
||||
|
||||
@@ -103,8 +103,8 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
|
||||
assert.match(localDrafts, /Never store[\s\S]*templateId[\s\S]*confirmationId[\s\S]*complete upstream schema/);
|
||||
assert.match(localDrafts, /outcome_uncertain[\s\S]*freeze[\s\S]*Never prepare or submit it again automatically/);
|
||||
assert.match(localDrafts, /never on the approval gateway/);
|
||||
assert.match(localDrafts, /local attachment path.*never a valid upstream attachment field value.*native client/s);
|
||||
assert.match(createApproval, /Local file paths.*not valid attachment field values.*native client/s);
|
||||
assert.match(localDrafts, /localPath.*signed Bridge.*does not upload.*final confirmation.*native-client completion/s);
|
||||
assert.match(createApproval, /Local file paths.*not valid `values` or `customFieldValues`.*signed Bridge `attachments`.*submit uploads only after final confirmation.*native client/s);
|
||||
assert.match(createApproval, /every XFT selection.*label-only.*optionSource.*returned `label` values.*`code` only inside/s);
|
||||
assert.match(createApproval, /Never show, explain, or ask.*city code.*organization code.*employee number.*numeric cabin value.*`expenseNumber`.*`expTypeCode`/s);
|
||||
assert.match(createApproval, /selectionCapability\.status=client_only.*required field stops.*optional field may be skipped/s);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: "北极大_人事假勤" }]; },
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user