OAPERM1 收紧启用前权限冻结
这个提交包含在:
@@ -44,7 +44,6 @@ const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
|
||||
...TRAVEL_SELF_SERVICE_PERMISSIONS,
|
||||
...PROJECT_SELF_SERVICE_PERMISSIONS,
|
||||
];
|
||||
|
||||
@@ -154,8 +153,6 @@ const ROLE_PERMISSIONS = {
|
||||
"attendance.view_all",
|
||||
"procurement.approve",
|
||||
"procurement.view_all",
|
||||
"travel.approve",
|
||||
"travel.view_all",
|
||||
"asset.view_all",
|
||||
"report.dashboard.view",
|
||||
"project.view_all",
|
||||
@@ -176,8 +173,6 @@ const ROLE_PERMISSIONS = {
|
||||
"attendance.view_all",
|
||||
"procurement.approve",
|
||||
"procurement.view_all",
|
||||
"travel.approve",
|
||||
"travel.view_all",
|
||||
"asset.view_all",
|
||||
"report.dashboard.view",
|
||||
"budget.view",
|
||||
@@ -194,8 +189,6 @@ const ROLE_PERMISSIONS = {
|
||||
"payment.batch.manage",
|
||||
"expense.view_all",
|
||||
"procurement.view_all",
|
||||
"travel.view_all",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
"attendance.view_all",
|
||||
],
|
||||
@@ -217,9 +210,6 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.finance.override_duplicate",
|
||||
"procurement.view_all",
|
||||
"procurement.manage",
|
||||
"travel.view_all",
|
||||
"travel.manage",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
"asset.manage",
|
||||
"asset.inventory",
|
||||
@@ -237,9 +227,6 @@ const ROLE_PERMISSIONS = {
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
"procurement.view_all",
|
||||
"procurement.manage",
|
||||
"travel.view_all",
|
||||
"travel.manage",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
"asset.manage",
|
||||
"asset.transfer",
|
||||
|
||||
@@ -844,7 +844,8 @@ async function employeesRoutes(fastify) {
|
||||
|
||||
const stats = await buildEmployeeStats(fastify.prisma, {
|
||||
timeZone: fastify.config.appTimezone,
|
||||
includeSalarySummary: fastify.hasPermission(request.employee, "system.users"),
|
||||
includeSalarySummary: fastify.hasPermission(request.employee, "system.settings"),
|
||||
includePerformanceSummary: fastify.hasPermission(request.employee, "system.settings"),
|
||||
});
|
||||
return sendData(reply, request, stats);
|
||||
});
|
||||
|
||||
@@ -1762,7 +1762,7 @@ async function exportsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/exports/payslips",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query);
|
||||
|
||||
+12
-11
@@ -21,6 +21,7 @@ const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/);
|
||||
const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]);
|
||||
const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]);
|
||||
const PAYROLL_MANAGE_PERMISSION = "system.settings";
|
||||
|
||||
const SALARY_ITEM_CREATE_SCHEMA = z
|
||||
.object({
|
||||
@@ -232,14 +233,14 @@ async function ensurePayslipExists(prisma, payslipId) {
|
||||
}
|
||||
|
||||
function canViewEmployeePayslips(fastify, request, employeeId) {
|
||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
|
||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, PAYROLL_MANAGE_PERMISSION);
|
||||
}
|
||||
|
||||
async function payrollRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/payroll/items",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const items = await fastify.prisma.salaryItem.findMany({
|
||||
@@ -255,7 +256,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/payroll/items",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body);
|
||||
@@ -300,7 +301,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.patch(
|
||||
"/payroll/items/:itemId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const itemId = parseId(request.params?.itemId);
|
||||
@@ -347,7 +348,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/payroll/employees/:employeeId/profile",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const employeeId = parseId(request.params?.employeeId);
|
||||
@@ -375,7 +376,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.put(
|
||||
"/payroll/employees/:employeeId/profile",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const employeeId = parseId(request.params?.employeeId);
|
||||
@@ -461,7 +462,7 @@ async function payrollRoutes(fastify) {
|
||||
}
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
|
||||
const isAdmin = fastify.hasPermission(request.employee, "system.users");
|
||||
const isAdmin = fastify.hasPermission(request.employee, PAYROLL_MANAGE_PERMISSION);
|
||||
const payslips = await fastify.prisma.payslip.findMany({
|
||||
where: {
|
||||
employee_id: employeeId,
|
||||
@@ -496,7 +497,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/payroll/payslips",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query);
|
||||
@@ -519,7 +520,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/payroll/payslips/generate",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body);
|
||||
@@ -679,7 +680,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.patch(
|
||||
"/payroll/payslips/:payslipId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const payslipId = parseId(request.params?.payslipId);
|
||||
@@ -753,7 +754,7 @@ async function payrollRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/payroll/payslips/issue",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body);
|
||||
|
||||
+10
-15
@@ -25,6 +25,7 @@ const SCORE_SCHEMA = z
|
||||
.refine((value) => Number.isFinite(value) && Math.abs(value * 2 - Math.round(value * 2)) < 1e-9, {
|
||||
message: "invalid_score_step",
|
||||
});
|
||||
const PERFORMANCE_MANAGE_PERMISSION = "system.settings";
|
||||
|
||||
const CYCLE_CREATE_SCHEMA = z
|
||||
.object({
|
||||
@@ -212,15 +213,9 @@ function mapReview(review) {
|
||||
}
|
||||
|
||||
function resolveViewerRole(fastify, request, employee) {
|
||||
if (fastify.hasPermission(request.employee, "system.users")) {
|
||||
if (fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
|
||||
return "admin";
|
||||
}
|
||||
if (request.employee.id === employee.id) {
|
||||
return "self";
|
||||
}
|
||||
if (employee.direct_manager_id && employee.direct_manager_id === request.employee.id) {
|
||||
return "manager";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -386,8 +381,8 @@ async function buildEmployeeTimeline(prisma, employeeId, viewerRole) {
|
||||
async function performanceRoutes(fastify) {
|
||||
fastify.get("/performance/cycles", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
|
||||
}
|
||||
|
||||
const cycles = await fastify.prisma.performanceReviewCycle.findMany();
|
||||
@@ -398,8 +393,8 @@ async function performanceRoutes(fastify) {
|
||||
|
||||
fastify.post("/performance/cycles", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
|
||||
}
|
||||
|
||||
const body = parseBody(CYCLE_CREATE_SCHEMA, request.body);
|
||||
@@ -431,8 +426,8 @@ async function performanceRoutes(fastify) {
|
||||
|
||||
fastify.patch("/performance/cycles/:cycleId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
|
||||
}
|
||||
|
||||
const cycleId = parseId(request.params?.cycleId);
|
||||
@@ -508,8 +503,8 @@ async function performanceRoutes(fastify) {
|
||||
|
||||
fastify.get("/performance/cycles/:cycleId/reviews", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
if (!fastify.hasPermission(request.employee, PERFORMANCE_MANAGE_PERMISSION)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } });
|
||||
}
|
||||
|
||||
const cycleId = parseId(request.params?.cycleId);
|
||||
|
||||
+11
-3
@@ -65,6 +65,14 @@ async function isFlagEnabled(fastify, request, flagKey) {
|
||||
return gate.enabled;
|
||||
}
|
||||
|
||||
function canSearchSensitiveRegistry(fastify, request, isAdmin) {
|
||||
return isAdmin || fastify.hasPermission(request.employee, "system.settings");
|
||||
}
|
||||
|
||||
function canSearchFrozenTravel(fastify, request, isAdmin) {
|
||||
return isAdmin || fastify.hasPermission(request.employee, "system.settings");
|
||||
}
|
||||
|
||||
async function searchRoutes(fastify) {
|
||||
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
@@ -307,7 +315,7 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (featureEnabled.projectLinking && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) {
|
||||
if (featureEnabled.projectLinking && canSearchSensitiveRegistry(fastify, request, isAdmin)) {
|
||||
tasks.push(
|
||||
fastify.prisma.contractRegistry.findMany({
|
||||
where: {
|
||||
@@ -348,7 +356,7 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (featureEnabled.confidentialDocRefs && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) {
|
||||
if (featureEnabled.confidentialDocRefs && canSearchSensitiveRegistry(fastify, request, isAdmin)) {
|
||||
tasks.push(
|
||||
fastify.prisma.confidentialDocumentRef.findMany({
|
||||
where: {
|
||||
@@ -449,7 +457,7 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "travel.view_own") || fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage")) {
|
||||
if (canSearchFrozenTravel(fastify, request, isAdmin)) {
|
||||
tasks.push(
|
||||
fastify.prisma.travelRequest.findMany({
|
||||
where: {
|
||||
|
||||
@@ -561,6 +561,29 @@ async function buildTaskVisibilityFilters(tx, request) {
|
||||
return assigneeFilters;
|
||||
}
|
||||
|
||||
function canViewWorkflowEntityType(request, entityType) {
|
||||
const normalizedEntityType = normalizeWorkflowEntityType(entityType);
|
||||
if (normalizedEntityType === "travel_request") {
|
||||
return hasPermission(request.employee.permissionCodes ?? [], "*")
|
||||
|| hasPermission(request.employee.permissionCodes ?? [], "system.settings");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildWorkflowEntityVisibilityWhere(request, entityType = null) {
|
||||
if (entityType) {
|
||||
return canViewWorkflowEntityType(request, entityType)
|
||||
? { entity_type: normalizeWorkflowEntityType(entityType) }
|
||||
: { entity_type: "__forbidden__" };
|
||||
}
|
||||
|
||||
const hiddenEntityTypes = [];
|
||||
if (!canViewWorkflowEntityType(request, "travel_request")) {
|
||||
hiddenEntityTypes.push("travel_request");
|
||||
}
|
||||
return hiddenEntityTypes.length ? { entity_type: { notIn: hiddenEntityTypes } } : {};
|
||||
}
|
||||
|
||||
function listAllowedActionsForTask(request, task, actionable) {
|
||||
if (task.status !== "pending" || !actionable) {
|
||||
return [];
|
||||
@@ -1957,6 +1980,7 @@ async function workflowRoutes(fastify) {
|
||||
const where = {
|
||||
status: "pending",
|
||||
...(visibilityFilters ? { OR: visibilityFilters } : {}),
|
||||
workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) },
|
||||
};
|
||||
|
||||
const tasks = await fastify.prisma.workflowTask.findMany({
|
||||
@@ -1998,9 +2022,7 @@ async function workflowRoutes(fastify) {
|
||||
const where = {
|
||||
status: query.status,
|
||||
OR: assigneeFilters,
|
||||
...(query.entityType
|
||||
? { workflow_instance: { is: { entity_type: query.entityType } } }
|
||||
: {}),
|
||||
workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request, query.entityType ?? null) },
|
||||
};
|
||||
|
||||
const [tasks, total] = await Promise.all([
|
||||
@@ -2151,6 +2173,7 @@ async function workflowRoutes(fastify) {
|
||||
where: {
|
||||
id: taskId,
|
||||
OR: assigneeFilters,
|
||||
workflow_instance: { is: buildWorkflowEntityVisibilityWhere(request) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -3199,7 +3222,9 @@ module.exports.__testables = {
|
||||
buildReturnedCopy,
|
||||
buildWorkflowTaskSummaryPayload,
|
||||
buildWorkflowEntityMeta,
|
||||
buildWorkflowEntityVisibilityWhere,
|
||||
calculateWorkflowTaskDueAt,
|
||||
canViewWorkflowEntityType,
|
||||
handlePurchaseApprovalErpSync,
|
||||
isWorkflowTaskOverdue,
|
||||
listAllowedActionsForTask,
|
||||
|
||||
@@ -29,6 +29,7 @@ function buildPerformanceDistribution(rows) {
|
||||
async function buildEmployeeStats(prisma, options = {}) {
|
||||
const timeZone = options.timeZone ?? "Asia/Shanghai";
|
||||
const includeSalarySummary = options.includeSalarySummary ?? true;
|
||||
const includePerformanceSummary = options.includePerformanceSummary ?? true;
|
||||
const todayString = currentDateString(timeZone);
|
||||
const today = parseDateOnlyString(todayString);
|
||||
const last30DateFrom = addDays(today, -29);
|
||||
@@ -114,14 +115,18 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.performanceReviewCycle.findFirst({
|
||||
where: { status: "active" },
|
||||
orderBy: [{ activated_at: "desc" }, { start_date: "desc" }],
|
||||
}),
|
||||
prisma.performanceReviewCycle.findFirst({
|
||||
where: { status: "completed" },
|
||||
orderBy: [{ completed_at: "desc" }, { end_date: "desc" }],
|
||||
}),
|
||||
includePerformanceSummary
|
||||
? prisma.performanceReviewCycle.findFirst({
|
||||
where: { status: "active" },
|
||||
orderBy: [{ activated_at: "desc" }, { start_date: "desc" }],
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
includePerformanceSummary
|
||||
? prisma.performanceReviewCycle.findFirst({
|
||||
where: { status: "completed" },
|
||||
orderBy: [{ completed_at: "desc" }, { end_date: "desc" }],
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
includeSalarySummary
|
||||
? prisma.payslip.aggregate({
|
||||
where: {
|
||||
@@ -155,7 +160,7 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
distribution: [],
|
||||
};
|
||||
|
||||
if (performanceCycle) {
|
||||
if (includePerformanceSummary && performanceCycle) {
|
||||
const [reviewAggregate, reviewDistribution] = await Promise.all([
|
||||
prisma.performanceReview.aggregate({
|
||||
where: { cycle_id: performanceCycle.id },
|
||||
|
||||
@@ -46,7 +46,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
||||
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||
dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] },
|
||||
purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
|
||||
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
|
||||
budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] },
|
||||
paymentBatches: { key: "paymentBatches", label: "支付批次", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
|
||||
apOperations: { key: "apOperations", label: "AP 运营", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
|
||||
|
||||
@@ -51,21 +51,23 @@ describe("Permission Constants", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "procurement.manage")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants travel permissions to configured roles", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.create")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.submit")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.approve")).toBe(true);
|
||||
it("freezes travel permissions for non-admin launch roles", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.create")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.submit")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.approve")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.approve")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["admin"]), "travel.manage")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants travel.payment.manage only to configured finance and administration roles", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(true);
|
||||
it("freezes travel.payment.manage before travel launch", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.payment.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.payment.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.payment.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.payment.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.payment.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["admin"]), "travel.payment.manage")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants ai.assist only to finance manager and admin", () => {
|
||||
|
||||
@@ -143,7 +143,7 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
|
||||
}
|
||||
|
||||
describe("employees stats route", () => {
|
||||
it("returns aggregated employee stats for read-only viewers without salary data", async () => {
|
||||
it("returns aggregated employee stats for read-only viewers without salary or performance data", async () => {
|
||||
const {
|
||||
app,
|
||||
employeeGroupBy,
|
||||
@@ -182,27 +182,10 @@ describe("employees stats route", () => {
|
||||
expiringSoonCount: 4,
|
||||
});
|
||||
expect(payload.data.performanceSummary).toEqual({
|
||||
cycle: {
|
||||
id: "cycle-active",
|
||||
name: "2026 Q2",
|
||||
cycleType: "quarterly",
|
||||
status: "active",
|
||||
startDate: "2026-04-01",
|
||||
endDate: "2026-06-30",
|
||||
},
|
||||
averageScore: 4.2,
|
||||
reviewedCount: 5,
|
||||
distribution: [
|
||||
{ score: 1, count: 0 },
|
||||
{ score: 1.5, count: 0 },
|
||||
{ score: 2, count: 0 },
|
||||
{ score: 2.5, count: 0 },
|
||||
{ score: 3, count: 0 },
|
||||
{ score: 3.5, count: 0 },
|
||||
{ score: 4, count: 3 },
|
||||
{ score: 4.5, count: 2 },
|
||||
{ score: 5, count: 0 },
|
||||
],
|
||||
cycle: null,
|
||||
averageScore: null,
|
||||
reviewedCount: 0,
|
||||
distribution: [],
|
||||
});
|
||||
expect(payload.data.salarySummary).toBeNull();
|
||||
expect(employeeGroupBy).toHaveBeenCalledTimes(2);
|
||||
@@ -211,17 +194,17 @@ describe("employees stats route", () => {
|
||||
expect(contractCount).toHaveBeenCalledTimes(1);
|
||||
expect(certificateGroupBy).toHaveBeenCalledTimes(1);
|
||||
expect(certificateCount).toHaveBeenCalledTimes(1);
|
||||
expect(performanceCycleFindFirst).toHaveBeenCalledTimes(2);
|
||||
expect(performanceReviewAggregate).toHaveBeenCalledTimes(1);
|
||||
expect(performanceReviewGroupBy).toHaveBeenCalledTimes(1);
|
||||
expect(performanceCycleFindFirst).not.toHaveBeenCalled();
|
||||
expect(performanceReviewAggregate).not.toHaveBeenCalled();
|
||||
expect(performanceReviewGroupBy).not.toHaveBeenCalled();
|
||||
expect(payslipAggregate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns salary summary for system.users viewers", async () => {
|
||||
it("returns salary and performance summary for system settings viewers", async () => {
|
||||
const { app, payslipAggregate } = await buildApp({
|
||||
permissionCodes: ["system.users"],
|
||||
permissionCodes: ["system.users", "system.settings"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -244,6 +227,10 @@ describe("employees stats route", () => {
|
||||
issuedCount: 2,
|
||||
},
|
||||
});
|
||||
expect(response.json().data.performanceSummary.cycle).toMatchObject({
|
||||
id: "cycle-active",
|
||||
name: "2026 Q2",
|
||||
});
|
||||
expect(payslipAggregate).toHaveBeenCalledTimes(2);
|
||||
|
||||
await app.close();
|
||||
|
||||
@@ -24,7 +24,7 @@ function buildPayslipRow() {
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
const salaryItems = [
|
||||
|
||||
@@ -21,7 +21,7 @@ function buildSalaryItem(overrides = {}) {
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
const state = {
|
||||
|
||||
@@ -48,7 +48,7 @@ const PAYSLIP_IDS = [
|
||||
];
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
|
||||
omitProfileFor = [],
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
|
||||
@@ -50,7 +50,7 @@ function buildSalaryRow(overrides = {}) {
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.settings"] },
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
const state = {
|
||||
|
||||
@@ -41,7 +41,7 @@ function buildCycleRow(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({ actor = { id: ADMIN_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] } } = {}) {
|
||||
async function buildApp({ actor = { id: ADMIN_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.settings"] } } = {}) {
|
||||
const app = Fastify();
|
||||
const state = {
|
||||
cycles: [],
|
||||
|
||||
@@ -276,7 +276,7 @@ async function buildApp({
|
||||
}
|
||||
|
||||
describe("performance review routes", () => {
|
||||
it("allows direct managers to score direct reports in active cycles", async () => {
|
||||
it("rejects direct managers while performance management is frozen before launch", async () => {
|
||||
const { app, state, performanceReviewDimension } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -292,23 +292,14 @@ describe("performance review routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toMatchObject({
|
||||
employeeId: EMPLOYEE_ID,
|
||||
overallScore: 4.5,
|
||||
comment: "表现稳定",
|
||||
dimensions: [
|
||||
{ dimensionType: "work_quality", score: 4.5 },
|
||||
{ dimensionType: "teamwork", score: 4 },
|
||||
],
|
||||
});
|
||||
expect(state.reviews.find((item) => item.cycle_id === ACTIVE_CYCLE_ID && item.employee_id === EMPLOYEE_ID)).toBeTruthy();
|
||||
expect(performanceReviewDimension.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(state.reviews.find((item) => item.cycle_id === ACTIVE_CYCLE_ID && item.employee_id === EMPLOYEE_ID)).toBeFalsy();
|
||||
expect(performanceReviewDimension.createMany).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns completed results only for self viewers", async () => {
|
||||
it("rejects self viewers while performance management is frozen before launch", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: EMPLOYEE_ID,
|
||||
@@ -322,11 +313,7 @@ describe("performance review routes", () => {
|
||||
url: `/api/performance/employees/${EMPLOYEE_ID}/reviews`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.viewerRole).toBe("self");
|
||||
expect(response.json().data.items).toHaveLength(1);
|
||||
expect(response.json().data.items[0].cycle.status).toBe("completed");
|
||||
expect(response.json().data.items[0].review.overallScore).toBe(4);
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -359,7 +346,7 @@ describe("performance review routes", () => {
|
||||
actor: {
|
||||
id: ADMIN_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["system.users"],
|
||||
permissionCodes: ["system.settings"],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,27 @@ async function buildApp({ employee, prisma }) {
|
||||
}
|
||||
|
||||
describe("global search R23 confidential boundary", () => {
|
||||
it("does not expose contract registry or confidential document refs to project managers through search", async () => {
|
||||
const prisma = createPrisma();
|
||||
const app = await buildApp({
|
||||
employee: { id: "manager-1", department_id: null, permissionCodes: ["project.view_all", "project.manage"] },
|
||||
prisma,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/search?q=HT-001",
|
||||
headers: { authorization: "Bearer test-token" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items).toEqual([]);
|
||||
expect(prisma.contractRegistry.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.confidentialDocumentRef.findMany).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not match sensitive summary text but still matches minimal identifiers", async () => {
|
||||
const prisma = createPrisma();
|
||||
const app = await buildApp({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const workflowRoutes = require("../../../src/routes/workflow");
|
||||
|
||||
describe("Workflow Route Permissions", () => {
|
||||
const { listAllowedActionsForTask, parseTaskListQuery } = workflowRoutes.__testables;
|
||||
const {
|
||||
buildWorkflowEntityVisibilityWhere,
|
||||
canViewWorkflowEntityType,
|
||||
listAllowedActionsForTask,
|
||||
parseTaskListQuery,
|
||||
} = workflowRoutes.__testables;
|
||||
|
||||
it("allows leave approvers to act on leave tasks", () => {
|
||||
const actions = listAllowedActionsForTask(
|
||||
@@ -133,4 +138,26 @@ describe("Workflow Route Permissions", () => {
|
||||
it("rejects unsupported workflow entityType filters", () => {
|
||||
expect(() => parseTaskListQuery({ entityType: "invoice_pool" })).toThrow("参数校验失败");
|
||||
});
|
||||
|
||||
it("hides travel workflow entities when travel is frozen for the actor", () => {
|
||||
const request = { employee: { permissionCodes: [] } };
|
||||
|
||||
expect(canViewWorkflowEntityType(request, "travel_request")).toBe(false);
|
||||
expect(buildWorkflowEntityVisibilityWhere(request)).toEqual({
|
||||
entity_type: { notIn: ["travel_request"] },
|
||||
});
|
||||
expect(buildWorkflowEntityVisibilityWhere(request, "travel_request")).toEqual({
|
||||
entity_type: "__forbidden__",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps travel workflow entities visible only for system settings holders", () => {
|
||||
const request = { employee: { permissionCodes: ["system.settings"] } };
|
||||
|
||||
expect(canViewWorkflowEntityType(request, "travel_request")).toBe(true);
|
||||
expect(buildWorkflowEntityVisibilityWhere(request)).toEqual({});
|
||||
expect(buildWorkflowEntityVisibilityWhere(request, "travel_request")).toEqual({
|
||||
entity_type: "travel_request",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ async function buildApp({ prisma, employee }) {
|
||||
}
|
||||
|
||||
describe("workflow transfer route", () => {
|
||||
it.each(["leave", "purchase_request", "travel_request"])(
|
||||
it.each(["leave", "purchase_request"])(
|
||||
"allows transfer for %s when target has matching approve permission",
|
||||
async (entityType) => {
|
||||
const tx = buildTx({ entityType });
|
||||
@@ -217,6 +217,33 @@ describe("workflow transfer route", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects transfer for frozen travel_request tasks", async () => {
|
||||
const tx = buildTx({ entityType: "travel_request" });
|
||||
const app = await buildApp({
|
||||
prisma: buildPrisma(tx),
|
||||
employee: {
|
||||
id: "actor-1",
|
||||
roleCodes: ["manager"],
|
||||
permissionCodes: ["expense.transfer"],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/workflow/tasks/${buildTaskRow().id}/transfer`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
toApproverId: "44444444-4444-4444-8444-444444444444",
|
||||
reason: "当前事项需转交对应审批人继续处理,请及时跟进。",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(tx.workflowTask.update).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects target approver without matching entity approve permission", async () => {
|
||||
const tx = buildTx({ entityType: "leave", targetRoleCode: "finance" });
|
||||
const app = await buildApp({
|
||||
|
||||
在新工单中引用
屏蔽一个用户