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 = "secret@example.test"; 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(); }); });