From a5eac04a06cd5cb6a78edb58a6204a818be89dca Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 28 Apr 2026 19:24:42 +0800 Subject: [PATCH] =?UTF-8?q?R71-A2=20=E5=AF=B9=E9=BD=90=E7=BB=8F=E7=90=86?= =?UTF-8?q?=E5=9B=A2=E9=98=9F=E6=97=A5=E5=8E=86=E6=9D=83=E9=99=90=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/constants/permissions.js | 1 + .../tests/unit/constants/permissions.test.js | 7 + .../unit/routes/leave.list-scope.test.js | 188 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 server/tests/unit/routes/leave.list-scope.test.js diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 55e470d6..a6a2d347 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -124,6 +124,7 @@ const ROLE_PERMISSIONS = { "expense.transfer", "expense.view_all", "leave.approve", + "leave.view_all", "procurement.approve", "procurement.view_all", "travel.approve", diff --git a/server/tests/unit/constants/permissions.test.js b/server/tests/unit/constants/permissions.test.js index 91cd859d..dcdc48fd 100644 --- a/server/tests/unit/constants/permissions.test.js +++ b/server/tests/unit/constants/permissions.test.js @@ -21,6 +21,13 @@ describe("Permission Constants", () => { expect(hasPermission(resolvePermissionsForRoles(["hr"]), "leave.approve")).toBe(false); }); + it("grants manager leave view-all without leave.manage", () => { + const managerPermissions = resolvePermissionsForRoles(["manager"]); + + expect(managerPermissions).toEqual(expect.arrayContaining(["leave.view_all"])); + expect(hasPermission(managerPermissions, "leave.manage")).toBe(false); + }); + it("grants master data permissions to finance and finance manager roles", () => { expect(hasPermission(resolvePermissionsForRoles(["finance"]), "masterdata.view")).toBe(true); diff --git a/server/tests/unit/routes/leave.list-scope.test.js b/server/tests/unit/routes/leave.list-scope.test.js new file mode 100644 index 00000000..2d5285ea --- /dev/null +++ b/server/tests/unit/routes/leave.list-scope.test.js @@ -0,0 +1,188 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const leaveRoutes = require("../../../src/routes/leave"); + +function buildLeaveRow({ + id = "11111111-1111-4111-8111-111111111111", + ownerId = "employee-1", + departmentId = "11111111-1111-4111-8111-111111111112", +} = {}) { + const now = new Date("2026-04-28T09:00:00.000Z"); + return { + id, + owner_id: ownerId, + department_id: departmentId, + leave_type_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + start_date: new Date("2026-04-29T00:00:00.000Z"), + end_date: new Date("2026-04-29T00:00:00.000Z"), + start_session: "am", + end_session: "pm", + requested_days: 1, + reason: "团队请假测试", + attachment_original_name: null, + attachment_mime_type: null, + status: "reviewing", + created_at: now, + updated_at: now, + leave_type: { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + code: "annual", + name: "年假", + requires_attachment: false, + attachment_mime_types: [], + }, + owner: { + id: ownerId, + employee_no: "E0101", + name: "Employee", + }, + }; +} + +function buildPrisma(items = [buildLeaveRow()]) { + return { + leaveRequest: { + findMany: vi.fn().mockResolvedValue(items), + count: vi.fn().mockResolvedValue(items.length), + }, + }; +} + +async function buildApp({ employee, prisma }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { + cutoverConsoleEnabled: false, + leaveHolidayCalendarEnabled: false, + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ + code: "AUTH_001", + message: "未登录", + statusCode: 401, + }); + } + + request.employee = employee; + }); + app.decorate("hasPermission", (actor, permissionCode) => { + return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode); + }); + app.decorate("requireEntityPermission", (_entity, action) => async (request) => { + await app.authenticate(request); + const permissionCode = action === "view_own" ? "leave.view_own" : action; + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { permission: permissionCode }, + }); + } + }); + + 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: "服务器内部错误", details: {} }, + }); + }); + + await app.register(leaveRoutes, { prefix: "/api" }); + return app; +} + +describe("leave list scope", () => { + it("allows manager to read team calendar window for own department", async () => { + const departmentId = "11111111-1111-4111-8111-111111111112"; + const prisma = buildPrisma([buildLeaveRow({ departmentId })]); + const app = await buildApp({ + employee: { + id: "manager-1", + department_id: departmentId, + permissionCodes: ["leave.view_own", "leave.view_all", "leave.approve"], + }, + prisma, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/leave-requests?scope=all&dateFrom=2026-04-28&dateTo=2026-05-05&departmentId=${departmentId}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.scope).toBe("all"); + expect(prisma.leaveRequest.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + department_id: departmentId, + }), + }), + ); + + await app.close(); + }); + + it("rejects employee when requesting all-scope leave records", async () => { + const departmentId = "11111111-1111-4111-8111-111111111112"; + const app = await buildApp({ + employee: { + id: "employee-1", + department_id: departmentId, + permissionCodes: ["leave.view_own"], + }, + prisma: buildPrisma(), + }); + + const response = await app.inject({ + method: "GET", + url: "/api/leave-requests?scope=all&dateFrom=2026-04-28&dateTo=2026-05-05", + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + + await app.close(); + }); + + it("rejects manager when querying another department without leave.manage", async () => { + const departmentId = "11111111-1111-4111-8111-111111111112"; + const otherDepartmentId = "22222222-2222-4222-8222-222222222222"; + const app = await buildApp({ + employee: { + id: "manager-1", + department_id: departmentId, + permissionCodes: ["leave.view_own", "leave.view_all", "leave.approve"], + }, + prisma: buildPrisma(), + }); + + const response = await app.inject({ + method: "GET", + url: `/api/leave-requests?scope=all&dateFrom=2026-04-28&dateTo=2026-05-05&departmentId=${otherDepartmentId}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + expect(response.json().error.details).toEqual(expect.objectContaining({ departmentId: "forbidden" })); + + await app.close(); + }); +});