汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
762 行
33 KiB
JavaScript
762 行
33 KiB
JavaScript
const Fastify = require("fastify");
|
|
const multipart = require("@fastify/multipart");
|
|
const ExcelJS = require("exceljs");
|
|
const JSZip = require("jszip");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const ts = require("../../../../client/node_modules/typescript");
|
|
|
|
const usersRoutes = require("../../../src/routes/users");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
// Exercise the actual response/logging callback without importing app/config,
|
|
// opening a database connection or starting background jobs.
|
|
function productionErrorHandler(logError) {
|
|
const source = fs.readFileSync(path.resolve(__dirname, "../../../src/app.js"), "utf8");
|
|
const ast = ts.createSourceFile("app.js", source, ts.ScriptTarget.Latest, true);
|
|
const handlers = [];
|
|
function visit(node) {
|
|
if (ts.isCallExpression(node) && node.expression.getText(ast) === "app.setErrorHandler") {
|
|
handlers.push(node.arguments[0].getText(ast));
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
visit(ast);
|
|
expect(handlers).toHaveLength(1);
|
|
return new Function("AppError", "app", `return (${handlers[0]});`)(AppError, { log: { error: logError } });
|
|
}
|
|
|
|
function buildMultipartRequest({ filename, contentType, content }) {
|
|
const boundary = "----codex-user-import-boundary";
|
|
const head = Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
|
);
|
|
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
const body = Buffer.concat([head, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), tail]);
|
|
|
|
return {
|
|
payload: body,
|
|
headers: {
|
|
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildXlsxBuffer(rows) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const worksheet = workbook.addWorksheet("users");
|
|
worksheet.addRow([
|
|
"employeeNo",
|
|
"name",
|
|
"phone",
|
|
"email",
|
|
"departmentName",
|
|
"title",
|
|
"directManagerEmployeeNo",
|
|
"roles",
|
|
"hireDate",
|
|
]);
|
|
|
|
for (const row of rows) {
|
|
worksheet.addRow([
|
|
row.employeeNo ?? "",
|
|
row.name ?? "",
|
|
row.phone ?? "",
|
|
row.email ?? "",
|
|
row.departmentName ?? "",
|
|
row.title ?? "",
|
|
row.directManagerEmployeeNo ?? "",
|
|
row.roles ?? "",
|
|
row.hireDate ?? "",
|
|
]);
|
|
}
|
|
|
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
|
}
|
|
|
|
async function buildApp(options = {}) {
|
|
const app = Fastify();
|
|
const actor = options.actor ?? {
|
|
id: "actor-1",
|
|
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
roleCodes: ["employee", "system_admin"],
|
|
permissionCodes: ["system.accounts.manage"],
|
|
};
|
|
const existingEmployees = options.existingEmployees ?? [];
|
|
const departments = options.departments ?? [];
|
|
const auditCreate = vi.fn().mockResolvedValue({});
|
|
const roleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
|
const employeeUpdate = vi.fn().mockResolvedValue({});
|
|
let createIndex = 0;
|
|
const employeeCreate = vi.fn(async ({ data }) => {
|
|
createIndex += 1;
|
|
return {
|
|
id: `00000000-0000-4000-8000-${String(createIndex).padStart(12, "0")}`,
|
|
employee_no: data.employee_no,
|
|
name: data.name,
|
|
phone: data.phone,
|
|
email: data.email,
|
|
status: data.status,
|
|
department_id: data.department_id,
|
|
title: data.title,
|
|
created_at: data.created_at,
|
|
updated_at: data.updated_at,
|
|
};
|
|
});
|
|
const employeeFindMany = vi.fn(async () => existingEmployees);
|
|
const departmentFindMany = vi.fn(async () => departments);
|
|
|
|
const tx = {
|
|
department: { findMany: departmentFindMany },
|
|
employee: {
|
|
findMany: employeeFindMany,
|
|
create: employeeCreate,
|
|
update: employeeUpdate,
|
|
},
|
|
employeeRole: {
|
|
createMany: roleCreateMany,
|
|
},
|
|
auditLog: {
|
|
create: auditCreate,
|
|
},
|
|
};
|
|
|
|
app.decorate("config", { appTimezone: "Asia/Shanghai" });
|
|
app.decorate("prisma", {
|
|
...tx,
|
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
|
});
|
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
|
request.employee = actor;
|
|
const permissions = request.employee.permissionCodes ?? [];
|
|
if (!permissions.includes("*") && !permissions.includes(permissionCode)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
});
|
|
app.decorate("authenticate", async (request) => {
|
|
request.employee = actor;
|
|
});
|
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
|
const permissions = employee?.permissionCodes ?? [];
|
|
return permissions.includes("*") || permissions.includes(permissionCode);
|
|
});
|
|
|
|
await app.register(multipart, {
|
|
attachFieldsToBody: false,
|
|
limits: { fileSize: options.fileSize ?? 1024 * 1024, files: 1 },
|
|
});
|
|
if (options.fileReader) {
|
|
app.addHook("preHandler", async (request) => { request.file = options.fileReader; });
|
|
}
|
|
app.setErrorHandler(options.logError ? productionErrorHandler(options.logError) :
|
|
(error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
|
await app.register(usersRoutes, { prefix: "/api" });
|
|
|
|
return {
|
|
app,
|
|
auditCreate,
|
|
departmentFindMany,
|
|
employeeCreate,
|
|
employeeFindMany,
|
|
employeeUpdate,
|
|
roleCreateMany,
|
|
};
|
|
}
|
|
|
|
describe("users import routes", () => {
|
|
it("rejects the legacy system.users permission before reading the import", async () => {
|
|
const { app, auditCreate, departmentFindMany, employeeFindMany } = await buildApp({
|
|
actor: {
|
|
id: "actor-1",
|
|
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
roleCodes: ["employee"],
|
|
permissionCodes: ["system.users"],
|
|
},
|
|
});
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
content: "employeeNo,name,phone,email,departmentName\nE001,Alice,13800000000,[email protected],行政部\n",
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/dry-run",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(departmentFindMany).not.toHaveBeenCalled();
|
|
expect(employeeFindMany).not.toHaveBeenCalled();
|
|
expect(auditCreate).not.toHaveBeenCalled();
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("supports xlsx dry-run and allows manager references to later rows in the same file", async () => {
|
|
const { app } = await buildApp({
|
|
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
|
});
|
|
const fileBuffer = await buildXlsxBuffer([
|
|
{
|
|
employeeNo: "E003",
|
|
name: "Carol",
|
|
phone: "13800000003",
|
|
email: "[email protected]",
|
|
departmentName: "行政部",
|
|
directManagerEmployeeNo: "E002",
|
|
hireDate: "2026-03-28",
|
|
},
|
|
{
|
|
employeeNo: "E002",
|
|
name: "Bob",
|
|
phone: "13800000002",
|
|
email: "[email protected]",
|
|
departmentName: "行政部",
|
|
title: "经理",
|
|
roles: "manager",
|
|
hireDate: "2026-03-27",
|
|
},
|
|
]);
|
|
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.xlsx",
|
|
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
content: fileBuffer,
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/dry-run",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const payload = response.json().data;
|
|
expect(payload.summary).toEqual({
|
|
totalRows: 2,
|
|
validRows: 2,
|
|
invalidRows: 0,
|
|
});
|
|
expect(payload.rows[0].normalized.title).toBe("员工");
|
|
expect(payload.rows[0].normalized.directManagerEmployeeNo).toBe("E002");
|
|
expect(payload.rows[0].errors).toEqual([]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("accepts an import without a phone header and persists a missing phone as null", async () => {
|
|
const { app, employeeCreate } = await buildApp({
|
|
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
|
});
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
content: [
|
|
"employeeNo,name,email,departmentName,title,roles,hireDate",
|
|
"E006,NoPhone,[email protected],行政部,员工,employee,2026-07-27",
|
|
].join("\n"),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/apply",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(employeeCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ phone: null }),
|
|
select: expect.any(Object),
|
|
});
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects dry-run uploads with unsupported mime type", async () => {
|
|
const { app } = await buildApp();
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "application/json",
|
|
content: "employeeNo,name,phone,email,departmentName\nE001,Alice,13800000000,[email protected],行政部\n",
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/dry-run",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("reports duplicate fields, archived departments and inactive managers in dry-run output", async () => {
|
|
const { app } = await buildApp({
|
|
departments: [{ id: "dept-archived", name: "归档部门", archived_at: new Date("2026-03-01T00:00:00.000Z") }],
|
|
existingEmployees: [
|
|
{
|
|
id: "emp-existing",
|
|
employee_no: "E001",
|
|
phone: "13800000009",
|
|
email: "[email protected]",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: "mgr-archived",
|
|
employee_no: "M001",
|
|
phone: "13800000008",
|
|
email: "[email protected]",
|
|
status: "archived",
|
|
},
|
|
],
|
|
});
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
content: [
|
|
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
|
"E001,Alice,13800000009,[email protected],归档部门,,M001,employee,2026-03-28",
|
|
"E001,Bob,13800000007,[email protected],缺失部门,,,employee,2026-03-28",
|
|
].join("\n"),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/dry-run",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const payload = response.json().data;
|
|
expect(payload.summary.invalidRows).toBe(2);
|
|
expect(payload.rows[0].errors).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ field: "employeeNo", message: "duplicate_existing" }),
|
|
expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }),
|
|
expect.objectContaining({ field: "departmentName", message: "archived" }),
|
|
expect.objectContaining({ field: "directManagerEmployeeNo", message: "inactive" }),
|
|
]),
|
|
);
|
|
expect(payload.rows[1].errors).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ field: "employeeNo", message: "duplicate_in_file" }),
|
|
expect.objectContaining({ field: "departmentName", message: "not_found" }),
|
|
]),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("applies a valid csv import in one transaction and returns one-time passwords", async () => {
|
|
const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({
|
|
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
|
});
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
content: [
|
|
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
|
"E003,Carol,13800000003,[email protected],行政部,,E002,employee,2026-03-28",
|
|
"E002,Bob,13800000002,[email protected],行政部,经理,,manager,2026-03-27",
|
|
].join("\n"),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/apply",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const payload = response.json().data;
|
|
expect(payload.summary).toEqual({
|
|
totalRows: 2,
|
|
validRows: 2,
|
|
invalidRows: 0,
|
|
});
|
|
expect(payload.imported).toHaveLength(2);
|
|
expect(payload.imported[0]).toEqual(
|
|
expect.objectContaining({
|
|
employeeNo: "E003",
|
|
email: "[email protected]",
|
|
}),
|
|
);
|
|
expect(payload.imported[0].oneTimePassword).toMatch(/^ResetA1[0-9a-f]{12}$/);
|
|
expect(employeeCreate).toHaveBeenCalledTimes(2);
|
|
expect(roleCreateMany).toHaveBeenCalledTimes(2);
|
|
expect(employeeUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: "00000000-0000-4000-8000-000000000001" },
|
|
data: expect.objectContaining({
|
|
direct_manager_id: "00000000-0000-4000-8000-000000000002",
|
|
}),
|
|
}),
|
|
);
|
|
expect(auditCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
action: "SYSTEM_USERS_IMPORTED",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it.each(["admin", "system_admin"])(
|
|
"prevents a system administrator from importing the protected %s role",
|
|
async (protectedRole) => {
|
|
const {
|
|
app,
|
|
auditCreate,
|
|
departmentFindMany,
|
|
employeeCreate,
|
|
employeeFindMany,
|
|
roleCreateMany,
|
|
} = await buildApp({
|
|
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
|
|
});
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
content: [
|
|
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
|
`E004,Dana,13800000004,[email protected],行政部,,,${protectedRole},2026-03-29`,
|
|
].join("\n"),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/apply",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(departmentFindMany).toHaveBeenCalledTimes(1);
|
|
expect(employeeFindMany).toHaveBeenCalledTimes(1);
|
|
expect(employeeCreate).not.toHaveBeenCalled();
|
|
expect(roleCreateMany).not.toHaveBeenCalled();
|
|
expect(auditCreate).not.toHaveBeenCalled();
|
|
|
|
await app.close();
|
|
},
|
|
);
|
|
|
|
it("refuses apply when preview contains invalid rows and does not create employees", async () => {
|
|
const { app, employeeCreate } = await buildApp();
|
|
const multipartRequest = buildMultipartRequest({
|
|
filename: "users.csv",
|
|
contentType: "text/csv",
|
|
content: [
|
|
"employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate",
|
|
"E003,Carol,13800000003,[email protected],不存在部门,,,employee,2026-03-28",
|
|
].join("\n"),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/users/import/apply",
|
|
...multipartRequest,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(employeeCreate).not.toHaveBeenCalled();
|
|
|
|
await app.close();
|
|
});
|
|
});
|
|
|
|
describe("user import calendar boundary", () => {
|
|
async function calendarUpload(format, dates) {
|
|
const rows = dates.map((hireDate, index) => ({ employeeNo: `000${index}`, name: `Synthetic ${index}`, email: `calendar${index}@example.test`, departmentName: "合成部门", title: "员工", roles: "employee", hireDate }));
|
|
const content = format === "xlsx" ? await buildXlsxBuffer(rows) : ["employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", ...rows.map((r) => [r.employeeNo, r.name, "", r.email, r.departmentName, r.title, "", r.roles, r.hireDate].join(","))].join("\n");
|
|
return buildMultipartRequest({ filename: `calendar.${format}`, contentType: format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv", content });
|
|
}
|
|
|
|
it.each(["csv", "xlsx"].flatMap((format) => ["dry-run", "apply"].map((action) => [format, action])))(
|
|
"%s %s keeps row errors and rejects a mixed invalid batch without writes", async (format, action) => {
|
|
const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/users/import/${action}`, ...await calendarUpload(format, ["2024-02-29", "2026-02-29", "2026-04-31"]) });
|
|
expect(response.statusCode).toBe(action === "apply" ? 409 : 200);
|
|
const result = action === "apply" ? response.json().error.details : response.json().data;
|
|
expect(result.summary).toEqual({ totalRows: 3, validRows: 1, invalidRows: 2 });
|
|
expect(result.rows[0].errors).toEqual([]);
|
|
for (const row of result.rows.slice(1)) expect(row.errors).toContainEqual({ field: "hireDate", message: "invalid" });
|
|
for (const write of [employeeCreate, employeeUpdate, roleCreateMany]) expect(write).not.toHaveBeenCalled();
|
|
if (action === "apply") {
|
|
expect(auditCreate).not.toHaveBeenCalled();
|
|
expect(result.reasonCode).toBe("user.import_rows_invalid");
|
|
}
|
|
else expect(auditCreate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: "SYSTEM_USERS_IMPORT_DRY_RUN" }) }));
|
|
} finally { await app.close(); }
|
|
},
|
|
);
|
|
|
|
it.each(["csv", "xlsx"])("%s applies leap day, month end and blank date using the existing default", async (format) => {
|
|
const { app, employeeCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
|
const { currentDateString } = require("../../../src/utils/date");
|
|
try {
|
|
const before = currentDateString("Asia/Shanghai");
|
|
const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...await calendarUpload(format, ["2024-02-29", "2026-04-30", ""]) });
|
|
expect(response.statusCode).toBe(200);
|
|
expect(employeeCreate).toHaveBeenCalledTimes(3);
|
|
expect(employeeCreate.mock.calls[0][0].data.employee_no).toBe("0000");
|
|
const dates = employeeCreate.mock.calls.map(([{ data }]) => data.hire_date.toISOString().slice(0, 10));
|
|
expect(dates.slice(0, 2)).toEqual(["2024-02-29", "2026-04-30"]);
|
|
expect([before, currentDateString("Asia/Shanghai")]).toContain(dates[2]);
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|
|
|
|
describe("user import file reasons", () => {
|
|
const header = "employeeNo,name,email,departmentName\n";
|
|
const row = "S001,合成员工,[email protected],合成部门\n";
|
|
const cases = [
|
|
["file_too_large", "user.import_file_too_large", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(20) }, { fileSize: 64 }, {}],
|
|
["missing", "user.import_file_missing", null, {}, {}],
|
|
["unsupported_extension", "user.import_extension_unsupported", { filename: "users.txt", contentType: "text/csv", content: header + row }, {}, {}],
|
|
["unsupported_mime_type", "user.import_mime_unsupported", { filename: "users.csv", contentType: "application/json", content: header + row }, {}, {}],
|
|
["missing_headers", "user.import_headers_missing", { filename: "users.csv", contentType: "text/csv", content: "employeeNo,name\nS001,合成员工\n" }, {}, { missingHeaders: ["email", "departmentName"] }],
|
|
["empty", "user.import_file_empty", { filename: "users.csv", contentType: "text/csv", content: header }, {}, {}],
|
|
["row_limit_exceeded", "user.import_row_limit_exceeded", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(501) }, {}, { limit: 500 }],
|
|
];
|
|
it.each(cases.flatMap((item) => ["dry-run", "apply"].map((operation) => [operation, ...item])))("explains %s %s before business access", async (operation, file, reasonCode, upload, options, extra) => {
|
|
const { app, ...spies } = await buildApp(options);
|
|
try {
|
|
const request = upload ? buildMultipartRequest(upload) : {
|
|
payload: "--synthetic-empty--\r\n",
|
|
headers: { "content-type": "multipart/form-data; boundary=synthetic-empty" },
|
|
};
|
|
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...request });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toEqual({ code: "VALID_001", details: { file, reasonCode, ...extra } });
|
|
for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled();
|
|
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it("allows 500 rows in preview and keeps the original preview audit", async () => {
|
|
const { app, employeeCreate, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
|
try {
|
|
const rows = Array.from({ length: 500 }, (_, index) => `S${index},合成员工${index},synthetic${index}@example.invalid,合成部门\n`).join("");
|
|
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", ...buildMultipartRequest({ filename: "users.csv", contentType: "text/csv", content: header + rows }) });
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.rows).toHaveLength(500);
|
|
expect(employeeCreate).not.toHaveBeenCalled();
|
|
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
|
expect(auditCreate).toHaveBeenCalledOnce();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it("keeps permission rejection before import parsing", async () => {
|
|
const { app, ...spies } = await buildApp({ actor: { id: "synthetic-denied", permissionCodes: [] } });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...buildMultipartRequest({ filename: "users.txt", contentType: "text/plain", content: "synthetic" }) });
|
|
expect(response.statusCode).toBe(403);
|
|
for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|
|
|
|
describe("user import parser boundary", () => {
|
|
const marker = "SYNTHETIC_CELL_CANARY";
|
|
const header = "employeeNo,name,email,departmentName\n";
|
|
const mime = (format) => format === "csv" ? "text/csv" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
let malformed;
|
|
|
|
beforeAll(async () => {
|
|
const xlsx = await buildXlsxBuffer([]);
|
|
malformed = {
|
|
"unclosed-csv": ["csv", Buffer.from(`${header}\"${marker}`)],
|
|
"bad-quote-csv": ["csv", Buffer.from(`${header}\"${marker}\"x,synthetic`)],
|
|
"non-zip-xlsx": ["xlsx", Buffer.from(marker)],
|
|
"truncated-xlsx": ["xlsx", xlsx.subarray(0, Math.floor(xlsx.length / 2))],
|
|
};
|
|
for (const [label, xml] of [
|
|
["closing-tag", `<worksheet><${marker}></wrong></worksheet>`],
|
|
["entity", `<worksheet><sheetData>&${marker};</sheetData></worksheet>`],
|
|
["attribute", `<worksheet ${marker}=unquoted></worksheet>`],
|
|
]) {
|
|
const zip = await JSZip.loadAsync(xlsx);
|
|
zip.file("xl/worksheets/sheet1.xml", xml);
|
|
malformed[`${label}-xlsx`] = ["xlsx", await zip.generateAsync({ type: "nodebuffer" })];
|
|
}
|
|
});
|
|
|
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
|
|
function expectNoBusinessAccess(app, spies) {
|
|
for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled();
|
|
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
|
}
|
|
|
|
for (const operation of ["dry-run", "apply"]) {
|
|
it.each(["unclosed-csv", "bad-quote-csv", "non-zip-xlsx", "truncated-xlsx", "closing-tag-xlsx", "entity-xlsx", "attribute-xlsx"])(`${operation} explains %s without logging cells or accessing business data`, async (label) => {
|
|
const logError = vi.fn();
|
|
const { app, ...spies } = await buildApp({ logError });
|
|
try {
|
|
const [format, content] = malformed[label];
|
|
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
|
...buildMultipartRequest({ filename: `${marker}.${format}`, contentType: mime(format), content }) });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toEqual({ code: "VALID_001", message: "参数校验失败",
|
|
details: { file: "invalid_format", reasonCode: "user.import_file_invalid" } });
|
|
expect(response.body).not.toContain(marker);
|
|
expect(logError).not.toHaveBeenCalled();
|
|
expectNoBusinessAccess(app, spies);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each(["csv", "xlsx"])(`${operation} preserves 500 and discards unknown %s parser error content`, async (format) => {
|
|
const parserClass = require(`exceljs/lib/${format}/${format}`);
|
|
const original = Object.assign(new Error(`Internal csv xlsx ${marker}`), {
|
|
cause: new Error(marker), payload: marker, statusCode: 400, code: "FST_REQ_FILE_TOO_LARGE",
|
|
});
|
|
vi.spyOn(parserClass.prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(original);
|
|
const logError = vi.fn();
|
|
const { app, ...spies } = await buildApp({ logError });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
|
...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) });
|
|
expect(response.statusCode).toBe(500);
|
|
expect(response.json().error).toEqual({ code: "SYS_001", message: "服务器内部错误", details: {} });
|
|
expect(response.body).not.toContain(marker);
|
|
expect(logError).toHaveBeenCalledOnce();
|
|
const logged = logError.mock.calls[0][0].err;
|
|
expect(logged).not.toBe(original);
|
|
expect(logged.message).toBe("User import parser failed");
|
|
expect(Object.getOwnPropertyNames(logged).sort()).toEqual(["message", "stack"]);
|
|
expect(logged.stack).not.toContain(marker);
|
|
expectNoBusinessAccess(app, spies);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it(`${operation} rejects permission before attempting even a malformed parse`, async () => {
|
|
const read = vi.spyOn(require("exceljs/lib/csv/csv").prototype, "read");
|
|
const logError = vi.fn();
|
|
const { app, ...spies } = await buildApp({ logError, actor: { id: "synthetic-denied", permissionCodes: [] } });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
|
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), content: `${header}\"${marker}` }) });
|
|
expect(response.statusCode).toBe(403);
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(logError).not.toHaveBeenCalled();
|
|
expectNoBusinessAccess(app, spies);
|
|
} finally { await app.close(); }
|
|
});
|
|
}
|
|
|
|
it.each([
|
|
["csv", new TypeError('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')],
|
|
["xlsx", new TypeError("Corrupted zip: can't find end of central directory")],
|
|
["csv", new Error("1:45: undefined entity.")],
|
|
["xlsx", new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')],
|
|
["xlsx", Object.assign(new Error("Corrupted zip: can't find end of central directory"), { code: "EIO" })],
|
|
["csv", new Error("Parse Error: expected: ',' OR new line got: 'x'! at 'synthetic")],
|
|
["csv", { message: 'Parse Error: missing closing: \'"\' in line: at \'synthetic\'' }],
|
|
])("does not classify a different error type or parser's message as %s input failure", async (format, error) => {
|
|
vi.spyOn(require(`exceljs/lib/${format}/${format}`).prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(error);
|
|
const logError = vi.fn();
|
|
const { app, ...spies } = await buildApp({ logError });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run",
|
|
...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) });
|
|
expect(response.statusCode).toBe(500);
|
|
expect(logError.mock.calls[0][0].err.message).toBe("User import parser failed");
|
|
expectNoBusinessAccess(app, spies);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each(["file", "toBuffer"])("keeps %s read failures outside the parser classification", async (stage) => {
|
|
const original = new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\'');
|
|
const fileReader = stage === "file" ? vi.fn().mockRejectedValue(original) : vi.fn().mockResolvedValue({
|
|
filename: "synthetic.csv", mimetype: "text/csv", toBuffer: vi.fn().mockRejectedValue(original),
|
|
});
|
|
const logError = vi.fn();
|
|
const { app, ...spies } = await buildApp({ logError, fileReader });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run" });
|
|
expect(response.statusCode).toBe(500);
|
|
expect(logError.mock.calls[0][0].err).toBe(original);
|
|
expectNoBusinessAccess(app, spies);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it("keeps quoted commas, escaped quotes and line breaks as CSV text", async () => {
|
|
const { app, employeeCreate, auditCreate } = await buildApp();
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run",
|
|
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"),
|
|
content: `${header}0001,"合成,\"\"姓名\"\"\n第二行",[email protected],合成部门\n` }) });
|
|
expect(response.statusCode).toBe(200);
|
|
const row = response.json().data.rows[0];
|
|
expect(row.normalized.employeeNo).toBe("0001");
|
|
expect(row.normalized.name).toBe('合成,"姓名"\n第二行');
|
|
expect(employeeCreate).not.toHaveBeenCalled();
|
|
expect(auditCreate).toHaveBeenCalledOnce();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each(["dry-run", "apply"])("keeps %s database failures outside format classification", async (operation) => {
|
|
const original = new Error("Corrupted zip: can't find end of central directory");
|
|
const logError = vi.fn();
|
|
const { app, departmentFindMany, employeeCreate, auditCreate } = await buildApp({ logError });
|
|
departmentFindMany.mockRejectedValueOnce(original);
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
|
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"),
|
|
content: `${header}S001,合成员工,[email protected],合成部门\n` }) });
|
|
expect(response.statusCode).toBe(500);
|
|
expect(logError.mock.calls[0][0].err).toBe(original);
|
|
expect(employeeCreate).not.toHaveBeenCalled();
|
|
expect(auditCreate).not.toHaveBeenCalled();
|
|
// Apply intentionally performs preview reads inside its transaction.
|
|
expect(app.prisma.$transaction).toHaveBeenCalledTimes(operation === "apply" ? 1 : 0);
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each(["transaction", "hash"])("does not translate an apply %s failure into a file error", async (stage) => {
|
|
const original = new Error("Corrupted zip: can't find end of central directory");
|
|
const logError = vi.fn();
|
|
const { app, employeeCreate, auditCreate } = await buildApp({ logError,
|
|
departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] });
|
|
if (stage === "transaction") app.prisma.$transaction.mockRejectedValueOnce(original);
|
|
else vi.spyOn(require("bcryptjs"), "hash").mockRejectedValueOnce(original);
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: "/api/users/import/apply",
|
|
...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"),
|
|
content: `${header.trim()},title,roles\nS001,合成员工,[email protected],合成部门,合成岗位,employee\n` }) });
|
|
expect(response.statusCode).toBe(500);
|
|
expect(app.prisma.$transaction).toHaveBeenCalledOnce();
|
|
expect(logError.mock.calls[0][0].err).toBe(original);
|
|
expect(employeeCreate).not.toHaveBeenCalled();
|
|
expect(auditCreate).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each([["dry-run", 500, 200], ["dry-run", 501, 400], ["apply", 501, 400]])("preserves XLSX %s row limit at %s", async (operation, count, status) => {
|
|
const content = await buildXlsxBuffer(Array.from({ length: count }, (_, index) => ({
|
|
employeeNo: `S${index}`, name: "合成员工", email: `synthetic${index}@example.invalid`, departmentName: "合成部门",
|
|
})));
|
|
const logError = vi.fn();
|
|
const { app, ...spies } = await buildApp({ logError });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`,
|
|
...buildMultipartRequest({ filename: "synthetic.xlsx", contentType: mime("xlsx"), content }) });
|
|
expect(response.statusCode).toBe(status);
|
|
expect(logError).not.toHaveBeenCalled();
|
|
if (count === 501) {
|
|
expect(response.json().error.details).toEqual({ file: "row_limit_exceeded", limit: 500, reasonCode: "user.import_row_limit_exceeded" });
|
|
expectNoBusinessAccess(app, spies);
|
|
} else {
|
|
expect(response.json().data.rows).toHaveLength(500);
|
|
expect(spies.auditCreate).toHaveBeenCalledOnce();
|
|
expect(spies.employeeCreate).not.toHaveBeenCalled();
|
|
expect(app.prisma.$transaction).not.toHaveBeenCalled();
|
|
}
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|