文件
hyb-oa/server/tests/unit/routes/wecomApprovalArchive.test.js
T

359 行
14 KiB
JavaScript

const Fastify = require("fastify");
const wecomApprovalArchiveRoutes = require("../../../src/routes/wecomApprovalArchive");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
const MANAGER_ID = "33333333-3333-4333-8333-333333333333";
const REPORT_ID = "44444444-4444-4444-8444-444444444444";
const PEER_ID = "55555555-5555-4555-8555-555555555555";
const FINANCE_ID = "66666666-6666-4666-8666-666666666666";
const HR_ID = "77777777-7777-4777-8777-777777777777";
const FULL_SP_NO = "SPWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
const FULL_TEMPLATE_ID = "TPLWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
const FULL_USER_ID = "wecom_full_userid_should_not_leak_warch2";
const FULL_PHONE = "13900001111";
const FULL_EMAIL = "[email protected]";
const FULL_FIELD_VALUE = "完整请假原因不应该出现在查询响应中";
const FULL_COMMENT = "审批评论不应该出现在查询响应中";
const FULL_SOURCE = "raw payload must not leak";
const FULL_ERROR = "internal processing error must not leak";
function employee(id, name, overrides = {}) {
return {
id,
employee_no: `NO-${id.slice(0, 4)}`,
name,
status: "active",
direct_manager_id: null,
department_id: "department-a",
...overrides,
};
}
const EMPLOYEES = [
employee(EMPLOYEE_ID, "员工本人"),
employee(OTHER_ID, "其他员工"),
employee(MANAGER_ID, "直属主管"),
employee(REPORT_ID, "直属下属", { direct_manager_id: MANAGER_ID }),
employee(PEER_ID, "同部门非直属"),
employee(FINANCE_ID, "财务"),
employee(HR_ID, "HR"),
];
function archiveRow(overrides = {}) {
const applicantId = overrides.applicant_employee_id === undefined ? EMPLOYEE_ID : overrides.applicant_employee_id;
const applicant = applicantId ? EMPLOYEES.find((item) => item.id === applicantId) : null;
return {
id: overrides.id || `archive-${Math.random().toString(36).slice(2)}`,
sp_no: overrides.sp_no || FULL_SP_NO,
template_id: overrides.template_id || FULL_TEMPLATE_ID,
template_type: overrides.template_type || "leave",
sp_name: overrides.sp_name || "OA-POC-请假",
normalized_status: overrides.normalized_status || "approved",
applicant_employee_id: applicantId,
applicant_external_user_id: overrides.applicant_external_user_id || FULL_USER_ID,
applied_at: new Date("2026-05-19T08:00:00.000Z"),
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
completed_at: new Date("2026-05-19T09:00:00.000Z"),
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
last_callback_at: null,
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
source_summary: { raw: FULL_SOURCE },
processing_error_message: FULL_ERROR,
error_message: FULL_ERROR,
applicant,
participants: overrides.participants || [],
actions: overrides.actions || [],
field_summaries: overrides.field_summaries || [{
id: `field-${overrides.id || "1"}`,
field_key: "reason",
field_index: 1,
field_name: "请假原因",
field_type: "Textarea",
normalized_value: FULL_FIELD_VALUE,
value_preview: FULL_FIELD_VALUE.slice(0, 10),
sensitive_level: "restricted",
display_allowed: false,
}],
attachment_summaries: [{
id: `attach-${overrides.id || "1"}`,
external_ref_summary: "attachment raw ref should not leak",
}],
reconcile_findings: overrides.reconcile_findings || [],
...overrides,
};
}
function participant(employeeId, overrides = {}) {
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
return {
id: `participant-${employeeId || "external"}`,
role: overrides.role || "approver",
node_index: overrides.node_index ?? 1,
external_user_id: overrides.external_user_id || FULL_USER_ID,
employee_id: employeeId,
is_current: false,
mapping_status: employeeId ? "mapped_or_external" : "external_only",
display_summary: FULL_USER_ID,
employee: linked,
...overrides,
};
}
function action(employeeId, overrides = {}) {
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
return {
id: `action-${employeeId || "external"}`,
action_type: overrides.action_type || "approve",
operator_external_user_id: FULL_USER_ID,
operator_employee_id: employeeId,
action_time: new Date("2026-05-19T09:00:00.000Z"),
node_index: 1,
comment_summary: FULL_COMMENT,
source: "compensation_pull",
operator: linked,
...overrides,
};
}
function matchesScalar(actual, condition) {
if (condition && typeof condition === "object" && !Array.isArray(condition)) {
if (condition.in) return condition.in.includes(actual);
if (Object.prototype.hasOwnProperty.call(condition, "not") && actual === condition.not) return false;
if (condition.gte && actual < condition.gte) return false;
if (condition.lte && actual > condition.lte) return false;
return true;
}
return actual === condition;
}
function matchesWhere(row, where) {
if (!where || !Object.keys(where).length) return true;
if (where.AND) return where.AND.every((item) => matchesWhere(row, item));
if (where.OR) return where.OR.some((item) => matchesWhere(row, item));
return Object.entries(where).every(([key, condition]) => {
if (key === "participants") {
return row.participants.some((item) => matchesWhere(item, condition.some || {}));
}
if (key === "actions") {
return row.actions.some((item) => matchesWhere(item, condition.some || {}));
}
if (key === "reconcile_findings") {
return row.reconcile_findings.some((item) => matchesWhere(item, condition.some || {}));
}
return matchesScalar(row[key], condition);
});
}
function buildPrisma(rows) {
return {
employee: {
findMany: vi.fn(async ({ where }) => EMPLOYEES.filter((item) => {
if (where?.status && item.status !== where.status) return false;
if (where?.direct_manager_id && item.direct_manager_id !== where.direct_manager_id) return false;
return true;
}).map((item) => ({ id: item.id }))),
},
wecomApprovalArchive: {
count: vi.fn(async ({ where }) => rows.filter((row) => matchesWhere(row, where)).length),
findMany: vi.fn(async ({ where, skip = 0, take = 20 }) => rows.filter((row) => matchesWhere(row, where)).slice(skip, skip + take)),
findFirst: vi.fn(async ({ where }) => rows.find((row) => matchesWhere(row, where)) || null),
},
};
}
async function buildApp({ actor, rows }) {
const app = Fastify();
app.decorate("prisma", buildPrisma(rows));
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: error.message || "服务器内部错误", details: {} },
});
});
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
return app;
}
function actor(id, roleCodes = ["employee"], permissionCodes = ["leave.view_own", "attendance.view_own"]) {
return {
id,
roleCodes,
permissionCodes,
department_id: "department-a",
};
}
function responseText(response) {
return JSON.stringify(response.json());
}
describe("wecomApprovalArchive routes", () => {
beforeEach(() => {
global.fetch = vi.fn(async () => {
throw new Error("WeCom API must not be called by archive query");
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("lets normal employees see only self applicant, participant or action histories", async () => {
const rows = [
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
archiveRow({ id: "participant-related", applicant_employee_id: OTHER_ID, participants: [participant(EMPLOYEE_ID)] }),
archiveRow({ id: "action-related", applicant_employee_id: OTHER_ID, actions: [action(EMPLOYEE_ID)] }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
archiveRow({ id: "unmapped-applicant-participant", applicant_employee_id: null, participants: [participant(EMPLOYEE_ID)] }),
archiveRow({ id: "unmapped-applicant-action", applicant_employee_id: null, actions: [action(EMPLOYEE_ID)] }),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
expect(response.statusCode).toBe(200);
const ids = response.json().data.items.map((item) => item.id);
expect(ids).toEqual(["self-applicant", "participant-related", "action-related"]);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("requires an archive view permission even for self-related histories", async () => {
const rows = [archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID })];
const app = await buildApp({ actor: actor(EMPLOYEE_ID, ["employee"], []), rows });
const listResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/self-applicant" });
expect(listResponse.statusCode).toBe(403);
expect(detailResponse.statusCode).toBe(403);
await app.close();
});
it("limits managers to direct-report applicant histories plus own related histories", async () => {
const rows = [
archiveRow({ id: "direct-report", applicant_employee_id: REPORT_ID }),
archiveRow({ id: "manager-related", applicant_employee_id: OTHER_ID, participants: [participant(MANAGER_ID)] }),
archiveRow({ id: "same-department-peer", applicant_employee_id: PEER_ID }),
archiveRow({ id: "unrelated", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({ actor: actor(MANAGER_ID, ["manager"], ["leave.view_all", "attendance.view_all"]), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=team" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items.map((item) => item.id)).toEqual(["direct-report", "manager-related"]);
await app.close();
});
it("does not treat finance leave or attendance view_all as global WeCom archive access", async () => {
const rows = [
archiveRow({ id: "finance-self", applicant_employee_id: FINANCE_ID }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({
actor: actor(FINANCE_ID, ["finance"], ["leave.view_all", "attendance.view_all"]),
rows,
});
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
expect(response.statusCode).toBe(200);
expect(response.json().data.scope).toBe("self");
expect(response.json().data.items.map((item) => item.id)).toEqual(["finance-self"]);
await app.close();
});
it("allows HR/admin all-scope access including callback-only or unmapped records", async () => {
const rows = [
archiveRow({ id: "normal", applicant_employee_id: OTHER_ID }),
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
];
const app = await buildApp({ actor: actor(HR_ID, ["hr"], ["leave.view_all"]), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items.map((item) => item.id)).toEqual(["normal", "callback-only"]);
await app.close();
});
it("prevents employeeId filters and detail id guessing from bypassing authorization", async () => {
const rows = [
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const listResponse = await app.inject({ method: "GET", url: `/api/wecom/approval-archive?employeeId=${OTHER_ID}` });
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/other-applicant" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toEqual([]);
expect(detailResponse.statusCode).toBe(404);
await app.close();
});
it("returns whitelist-only redacted detail responses", async () => {
const rows = [
archiveRow({
id: "sensitive-detail",
applicant_employee_id: EMPLOYEE_ID,
participants: [participant(EMPLOYEE_ID)],
actions: [action(EMPLOYEE_ID)],
reconcile_findings: [{
id: "finding-1",
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
description: FULL_FIELD_VALUE,
detected_at: new Date("2026-05-19T11:00:00.000Z"),
resolved_at: null,
}],
}),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/sensitive-detail" });
expect(response.statusCode).toBe(200);
const text = responseText(response);
expect(text).not.toContain(FULL_SP_NO);
expect(text).not.toContain(FULL_TEMPLATE_ID);
expect(text).not.toContain(FULL_USER_ID);
expect(text).not.toContain(FULL_PHONE);
expect(text).not.toContain(FULL_EMAIL);
expect(text).not.toContain(FULL_FIELD_VALUE);
expect(text).not.toContain(FULL_COMMENT);
expect(text).not.toContain(FULL_SOURCE);
expect(text).not.toContain(FULL_ERROR);
expect(text).not.toContain("normalized_value");
expect(text).not.toContain("value_preview");
expect(text).not.toContain("comment_summary");
expect(text).not.toContain("source_summary");
expect(text).not.toContain("external_ref_summary");
expect(text).not.toContain("processing_error_message");
expect(text).not.toContain("error_message");
await app.close();
});
});