PA6 补齐权限与审计回归
这个提交包含在:
@@ -155,6 +155,17 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
admin: ["*"],
|
admin: ["*"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BUSINESS_APPROVAL_PERMISSIONS = [
|
||||||
|
"expense.approve",
|
||||||
|
"leave.approve",
|
||||||
|
"procurement.approve",
|
||||||
|
"travel.approve",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BUSINESS_APPROVER_ROLE_CODES = ["manager", "ceo", "finance", "finance_mgr", "hr"] as const;
|
||||||
|
const FINANCE_ROLE_CODES = ["finance", "finance_mgr"] as const;
|
||||||
|
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"] as const;
|
||||||
|
|
||||||
function rolePermissions(roleCode: string): readonly string[] {
|
function rolePermissions(roleCode: string): readonly string[] {
|
||||||
return ROLE_PERMISSIONS[roleCode] ?? [];
|
return ROLE_PERMISSIONS[roleCode] ?? [];
|
||||||
}
|
}
|
||||||
@@ -183,6 +194,19 @@ export function hasAnyPermission(permissionCodes: string[], candidates: string[]
|
|||||||
return candidates.some((candidate) => hasPermission(permissionCodes, candidate));
|
return candidates.some((candidate) => hasPermission(permissionCodes, candidate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasBusinessApproverRole(roleCodes: string[] | undefined, permissionCodes: string[]) {
|
||||||
|
const hasBusinessRole = (roleCodes ?? []).some((roleCode) =>
|
||||||
|
(BUSINESS_APPROVER_ROLE_CODES as readonly string[]).includes(roleCode),
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasBusinessRole && hasAnyPermission(permissionCodes, [...BUSINESS_APPROVAL_PERMISSIONS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFinanceRole(roleCodes: string[] | undefined, permissionCodes: string[]) {
|
||||||
|
const hasRole = (roleCodes ?? []).some((roleCode) => (FINANCE_ROLE_CODES as readonly string[]).includes(roleCode));
|
||||||
|
return hasRole && hasAnyPermission(permissionCodes, [...FINANCE_PORTAL_PERMISSIONS]);
|
||||||
|
}
|
||||||
|
|
||||||
export function isManagerRole(permissionCodes: string[]) {
|
export function isManagerRole(permissionCodes: string[]) {
|
||||||
if (hasPermission(permissionCodes, "*") || hasPermission(permissionCodes, "system.users")) {
|
if (hasPermission(permissionCodes, "*") || hasPermission(permissionCodes, "system.users")) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ import { NavLink } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchWorkflowTasks } from "../../api/workflow";
|
import { fetchWorkflowTasks } from "../../api/workflow";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../../auth/permissions";
|
import { hasAnyPermission, hasBusinessApproverRole } from "../../auth/permissions";
|
||||||
import {
|
import { MOBILE_NAV_ITEMS } from "../../features/mobile/navigation";
|
||||||
MOBILE_APPROVAL_PERMISSIONS,
|
|
||||||
MOBILE_NAV_ITEMS,
|
|
||||||
} from "../../features/mobile/navigation";
|
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { MobileIcon } from "./MobileIcon";
|
import { MobileIcon } from "./MobileIcon";
|
||||||
|
|
||||||
@@ -18,8 +15,8 @@ function resolveBadgeLabel(count: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MobileBottomNav() {
|
export function MobileBottomNav() {
|
||||||
const { permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||||
|
|
||||||
const pendingCountQuery = useQuery({
|
const pendingCountQuery = useQuery({
|
||||||
enabled: canAccessApprovals,
|
enabled: canAccessApprovals,
|
||||||
@@ -40,7 +37,11 @@ export function MobileBottomNav() {
|
|||||||
>
|
>
|
||||||
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
||||||
{MOBILE_NAV_ITEMS.map((item) => {
|
{MOBILE_NAV_ITEMS.map((item) => {
|
||||||
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
|
const disabled = item.key === "approvals"
|
||||||
|
? !canAccessApprovals
|
||||||
|
: item.anyOf
|
||||||
|
? !hasAnyPermission(permissions, [...item.anyOf])
|
||||||
|
: false;
|
||||||
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NavLink } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
|
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../../auth/permissions";
|
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../../auth/permissions";
|
||||||
import {
|
import {
|
||||||
ADMIN_NAV_ORDER,
|
ADMIN_NAV_ORDER,
|
||||||
BACKUP_NAV_ORDER,
|
BACKUP_NAV_ORDER,
|
||||||
@@ -32,7 +32,7 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
||||||
{ to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] },
|
{ to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] },
|
||||||
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
||||||
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||||
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
|
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
|
||||||
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
|
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
|
||||||
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
||||||
@@ -136,9 +136,12 @@ function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFallbackItems(permissions: string[]) {
|
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
|
||||||
|
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
|
||||||
const mapItems = (paths: string[]) =>
|
const mapItems = (paths: string[]) =>
|
||||||
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
||||||
|
.filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions))
|
||||||
|
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
|
||||||
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
|
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
|
||||||
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
||||||
|
|
||||||
@@ -184,7 +187,7 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const { permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
const navigationQuery = useQuery({
|
const navigationQuery = useQuery({
|
||||||
queryKey: ["system-navigation"],
|
queryKey: ["system-navigation"],
|
||||||
queryFn: fetchSystemNavigation,
|
queryFn: fetchSystemNavigation,
|
||||||
@@ -193,7 +196,7 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
|
|
||||||
const sections = navigationQuery.data?.sections
|
const sections = navigationQuery.data?.sections
|
||||||
? normalizeSections(navigationQuery.data.sections)
|
? normalizeSections(navigationQuery.data.sections)
|
||||||
: buildFallbackItems(permissions);
|
: buildFallbackItems(permissions, me?.roleCodes ?? []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="space-y-5">
|
<nav className="space-y-5">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { fetchEmployeeProfile } from "../api/employees";
|
|||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../auth/permissions";
|
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||||
@@ -52,8 +52,15 @@ function MobileActionLink({
|
|||||||
|
|
||||||
export function MobileMePage() {
|
export function MobileMePage() {
|
||||||
const { me, permissions, refreshMe, logout } = useAuth();
|
const { me, permissions, refreshMe, logout } = useAuth();
|
||||||
const canAccessApprovals = hasAnyPermission(permissions, ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]);
|
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||||
const canAccessAdmin = hasAnyPermission(permissions, ["*", "system.settings", "system.users", "report.dashboard.view"]);
|
const canAccessAdmin = hasAnyPermission(permissions, [
|
||||||
|
"*",
|
||||||
|
"system.settings",
|
||||||
|
"system.users",
|
||||||
|
"masterdata.manage",
|
||||||
|
"notification.channel.manage",
|
||||||
|
"integration.manage",
|
||||||
|
]);
|
||||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||||
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ import { fetchNotifications } from "../api/notifications";
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { fetchWorkflowTasks } from "../api/workflow";
|
import { fetchWorkflowTasks } from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../auth/permissions";
|
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
MOBILE_APPROVAL_PERMISSIONS,
|
|
||||||
MOBILE_CAPTURE_PERMISSIONS,
|
MOBILE_CAPTURE_PERMISSIONS,
|
||||||
MOBILE_LEAVE_PERMISSIONS,
|
MOBILE_LEAVE_PERMISSIONS,
|
||||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||||
@@ -155,8 +154,8 @@ function ActionTile({ action }: { action: MobileAction }) {
|
|||||||
|
|
||||||
export function MobilePortalPage() {
|
export function MobilePortalPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||||
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||||||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||||
@@ -217,14 +216,16 @@ export function MobilePortalPage() {
|
|||||||
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
||||||
|
|
||||||
const actions: MobileAction[] = [
|
const actions: MobileAction[] = [
|
||||||
{
|
canAccessApprovals
|
||||||
key: "approvals",
|
? {
|
||||||
label: "审批",
|
key: "approvals",
|
||||||
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
|
label: "审批",
|
||||||
icon: "fact_check",
|
meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办",
|
||||||
to: "/mobile/approvals",
|
icon: "fact_check",
|
||||||
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
to: "/mobile/approvals",
|
||||||
},
|
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
canAccessCapture
|
canAccessCapture
|
||||||
? {
|
? {
|
||||||
key: "capture",
|
key: "capture",
|
||||||
|
|||||||
+15
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../auth/permissions";
|
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||||
import { resolveDefaultWorkspacePath } from "../lib/runtimeContext";
|
import { resolveDefaultWorkspacePath } from "../lib/runtimeContext";
|
||||||
import { isOfflineSnapshotAllowedPath } from "../pwa";
|
import { isOfflineSnapshotAllowedPath } from "../pwa";
|
||||||
|
|
||||||
@@ -78,3 +78,17 @@ export function RequirePermission({
|
|||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RequireBusinessApprover({ children }: PropsWithChildren) {
|
||||||
|
const { me, permissions, ready } = useAuth();
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return <FullPageLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBusinessApproverRole(me?.roleCodes, permissions)) {
|
||||||
|
return <Navigate to="/403" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|||||||
+11
-7
@@ -68,7 +68,7 @@ import {
|
|||||||
WorkflowTaskDetailPage,
|
WorkflowTaskDetailPage,
|
||||||
WorkflowTasksPage,
|
WorkflowTasksPage,
|
||||||
} from "./lazy";
|
} from "./lazy";
|
||||||
import { GuestOnly, RequireAuth, RequirePermission } from "./guards";
|
import { GuestOnly, RequireAuth, RequireBusinessApprover, RequirePermission } from "./guards";
|
||||||
|
|
||||||
function withRouteFallback(node: ReactNode) {
|
function withRouteFallback(node: ReactNode) {
|
||||||
return <Suspense fallback={<RoutePageFallback />}>{node}</Suspense>;
|
return <Suspense fallback={<RoutePageFallback />}>{node}</Suspense>;
|
||||||
@@ -277,18 +277,18 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="workflow/tasks"
|
path="workflow/tasks"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
<RequireBusinessApprover>
|
||||||
{withRouteFallback(<WorkflowTasksPage />)}
|
{withRouteFallback(<WorkflowTasksPage />)}
|
||||||
</RequirePermission>
|
</RequireBusinessApprover>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="workflow/tasks/:taskId"
|
path="workflow/tasks/:taskId"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["expense.approve", "leave.approve", "procurement.approve", "travel.approve"]}>
|
<RequireBusinessApprover>
|
||||||
{withRouteFallback(<WorkflowTaskDetailPage />)}
|
{withRouteFallback(<WorkflowTaskDetailPage />)}
|
||||||
</RequirePermission>
|
</RequireBusinessApprover>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -358,7 +358,11 @@ export function AppRouter() {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="mobile/approvals"
|
path="mobile/approvals"
|
||||||
element={withRouteFallback(<MobileApprovalsPage />)}
|
element={
|
||||||
|
<RequireBusinessApprover>
|
||||||
|
{withRouteFallback(<MobileApprovalsPage />)}
|
||||||
|
</RequireBusinessApprover>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="notifications" element={withRouteFallback(<NotificationsPage />)} />
|
<Route path="notifications" element={withRouteFallback(<NotificationsPage />)} />
|
||||||
@@ -485,7 +489,7 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="system/settings"
|
path="system/settings"
|
||||||
element={
|
element={
|
||||||
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
|
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
|
||||||
{withRouteFallback(<SystemSettingsPage />)}
|
{withRouteFallback(<SystemSettingsPage />)}
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
FINANCE,
|
FINANCE,
|
||||||
FINANCE_MGR,
|
FINANCE_MGR,
|
||||||
MANAGER,
|
MANAGER,
|
||||||
|
apiGetData,
|
||||||
|
apiPostData,
|
||||||
createAuthenticatedApiClient,
|
createAuthenticatedApiClient,
|
||||||
login,
|
login,
|
||||||
type AuthenticatedApiClient,
|
type AuthenticatedApiClient,
|
||||||
@@ -23,6 +25,81 @@ type ApiExpectation = {
|
|||||||
note: string;
|
note: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApiMutationExpectation = ApiExpectation & {
|
||||||
|
method: "post" | "patch" | "delete";
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PortalHomeResponse = {
|
||||||
|
roleCapabilities?: {
|
||||||
|
canApproveTasks: boolean;
|
||||||
|
canUseFinance: boolean;
|
||||||
|
canManageSystem: boolean;
|
||||||
|
isAdminOnly: boolean;
|
||||||
|
};
|
||||||
|
financeSummary?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowSubmitResponse = {
|
||||||
|
currentTaskId: string;
|
||||||
|
workflowInstanceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowTaskDetailResponse = {
|
||||||
|
history: Array<{
|
||||||
|
action: string;
|
||||||
|
actorId: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuditLogListResponse = {
|
||||||
|
items: Array<{
|
||||||
|
action: string;
|
||||||
|
targetType: string | null;
|
||||||
|
targetId: string | null;
|
||||||
|
actorId: string | null;
|
||||||
|
extra: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationDeliveryListResponse = {
|
||||||
|
items: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type LeaveTypeListResponse = {
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
requiresAttachment?: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReimbursementCreatedResponse = {
|
||||||
|
id: string;
|
||||||
|
formNumber?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReimbursementAttachmentUploadResponse = {
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MasterDataListResponse = {
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
legalEntityId?: string | null;
|
||||||
|
entityType?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PurchaseCreateResponse = {
|
||||||
|
purchaseRequest: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const SENSITIVE_KEY_PATTERN = /(secret|password|token)/i;
|
const SENSITIVE_KEY_PATTERN = /(secret|password|token)/i;
|
||||||
|
|
||||||
test.use({ serviceWorkers: "block" });
|
test.use({ serviceWorkers: "block" });
|
||||||
@@ -50,6 +127,20 @@ async function expectApiStatus({ role, api, path, statuses, note }: ApiExpectati
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectApiMutationStatus({ role, api, path, statuses, note, method, data }: ApiMutationExpectation) {
|
||||||
|
const response =
|
||||||
|
method === "post"
|
||||||
|
? await api.post(path, data === undefined ? undefined : { data })
|
||||||
|
: method === "patch"
|
||||||
|
? await api.patch(path, data === undefined ? undefined : { data })
|
||||||
|
: await api.delete(path);
|
||||||
|
expectStatus(response.status(), statuses, `${role} ${method.toUpperCase()} ${path} (${note})`);
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
body: await parseJson(response),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function openAs(browser: Browser, user: E2EUser) {
|
async function openAs(browser: Browser, user: E2EUser) {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -100,6 +191,172 @@ function expectArray(value: unknown, context: string) {
|
|||||||
return value as JsonObject[];
|
return value as JsonObject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectPcNavMatrix(browser: Browser, user: E2EUser, input: {
|
||||||
|
visible?: string[];
|
||||||
|
hidden?: string[];
|
||||||
|
}) {
|
||||||
|
const { context, page } = await openAs(browser, user);
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
|
await page.goto("/portal");
|
||||||
|
for (const href of input.visible ?? []) {
|
||||||
|
await expect
|
||||||
|
.poll(() => page.locator(`nav a[href="${href}"]`).count(), {
|
||||||
|
message: `${user.name} should have entry ${href}`,
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
for (const href of input.hidden ?? []) {
|
||||||
|
await expect(page.locator(`nav a[href="${href}"]`), `${user.name} should not see ${href}`).toHaveCount(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApprove: boolean) {
|
||||||
|
const { context, page } = await openAs(browser, user);
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/mobile");
|
||||||
|
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
|
||||||
|
const approvalTab = page.getByTestId("mobile-nav-approvals");
|
||||||
|
if (canApprove) {
|
||||||
|
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
|
||||||
|
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]').first()).toBeVisible();
|
||||||
|
await page.goto("/mobile/approvals");
|
||||||
|
await expect(page).not.toHaveURL(/\/403/);
|
||||||
|
} else {
|
||||||
|
await expect(approvalTab).toBeDisabled();
|
||||||
|
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
|
||||||
|
await page.goto("/mobile/approvals");
|
||||||
|
await assertForbiddenPage(page);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(offsetDays: number) {
|
||||||
|
const value = new Date();
|
||||||
|
value.setUTCDate(value.getUTCDate() + offsetDays);
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSubmittedLeave(api: APIRequestContext, label: string): Promise<WorkflowSubmitResponse & { leaveRequestId: string }> {
|
||||||
|
const leaveTypes = await apiGetData<LeaveTypeListResponse>(api, "/api/leave-types?enabled=true");
|
||||||
|
const leaveType = leaveTypes.items.find((item) => !item.requiresAttachment) ?? leaveTypes.items[0];
|
||||||
|
if (!leaveType) {
|
||||||
|
throw new Error("未找到可用于 PA6 回归的请假类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = formatDate(3600 + (Date.now() % 1200));
|
||||||
|
const created = await apiPostData<{ id: string }>(api, "/api/leave-requests", {
|
||||||
|
leaveTypeId: leaveType.id,
|
||||||
|
startDate,
|
||||||
|
endDate: startDate,
|
||||||
|
startSession: "am",
|
||||||
|
endSession: "pm",
|
||||||
|
reason: `PA6 权限回归 ${label}`,
|
||||||
|
});
|
||||||
|
const submitted = await apiPostData<WorkflowSubmitResponse>(api, `/api/leave-requests/${created.id}/submit`, {});
|
||||||
|
return {
|
||||||
|
...submitted,
|
||||||
|
leaveRequestId: created.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function firstItem<T extends { id: string }>(items: T[], context: string) {
|
||||||
|
const item = items[0];
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`未找到可用于 PA6 回归的${context}`);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSubmittedPurchase(api: APIRequestContext, label: string): Promise<WorkflowSubmitResponse & { purchaseRequestId: string }> {
|
||||||
|
const legalEntities = await apiGetData<MasterDataListResponse>(api, "/api/master-data/legal-entities?enabled=true");
|
||||||
|
const legalEntity = await firstItem(legalEntities.items, "法人主体");
|
||||||
|
const costCenters = await apiGetData<MasterDataListResponse>(
|
||||||
|
api,
|
||||||
|
`/api/master-data/cost-centers?enabled=true&legalEntityId=${legalEntity.id}`,
|
||||||
|
);
|
||||||
|
const costCenter = await firstItem(costCenters.items, "成本中心");
|
||||||
|
const suppliers = await apiGetData<MasterDataListResponse>(
|
||||||
|
api,
|
||||||
|
`/api/master-data/suppliers?enabled=true&legalEntityId=${legalEntity.id}`,
|
||||||
|
);
|
||||||
|
const supplier = await firstItem(suppliers.items, "供应商");
|
||||||
|
const documentCategories = await apiGetData<MasterDataListResponse>(
|
||||||
|
api,
|
||||||
|
"/api/master-data/document-categories?enabled=true&entityType=purchase_request",
|
||||||
|
);
|
||||||
|
const documentCategory = await firstItem(documentCategories.items, "采购单据类型");
|
||||||
|
|
||||||
|
const created = await apiPostData<PurchaseCreateResponse>(api, "/api/purchase-requests", {});
|
||||||
|
const purchaseRequestId = created.purchaseRequest.id;
|
||||||
|
const updateResponse = await api.patch(`/api/purchase-requests/${purchaseRequestId}`, {
|
||||||
|
data: {
|
||||||
|
title: `PA6 采购权限回归 ${label}`,
|
||||||
|
reason: "PA6 验证采购自审和 admin-only 审批拒绝",
|
||||||
|
legalEntityId: legalEntity.id,
|
||||||
|
costCenterId: costCenter.id,
|
||||||
|
supplierId: supplier.id,
|
||||||
|
documentCategoryId: documentCategory.id,
|
||||||
|
expectedDeliveryDate: formatDate(45),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: `PA6 采购项 ${label}`,
|
||||||
|
quantity: "1",
|
||||||
|
unitPrice: "88.00",
|
||||||
|
isAssetCandidate: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateResponse.ok(), `purchase update failed: ${updateResponse.status()} ${await updateResponse.text()}`).toBe(true);
|
||||||
|
|
||||||
|
const submitted = await apiPostData<WorkflowSubmitResponse>(api, `/api/purchase-requests/${purchaseRequestId}/submit`, {});
|
||||||
|
return {
|
||||||
|
...submitted,
|
||||||
|
purchaseRequestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createReimbursementAttachment(api: APIRequestContext) {
|
||||||
|
const reimbursementDate = formatDate(30 + (Date.now() % 90));
|
||||||
|
const reimbursement = await apiPostData<ReimbursementCreatedResponse>(api, "/api/reimbursements", {
|
||||||
|
reimbursementDate,
|
||||||
|
description: `PA6 附件审计 ${Date.now()}`,
|
||||||
|
});
|
||||||
|
const uploadResponse = await api.post(`/api/reimbursements/${reimbursement.id}/attachments`, {
|
||||||
|
multipart: {
|
||||||
|
file: {
|
||||||
|
name: `pa6-attachment-${Date.now()}.pdf`,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
buffer: Buffer.from(`%PDF-1.4\nPA6_ATTACHMENT_AUDIT_${Date.now()}\n%%EOF\n`, "utf8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(uploadResponse.ok(), `upload attachment failed: ${uploadResponse.status()} ${await uploadResponse.text()}`).toBe(true);
|
||||||
|
const payload = await parseJson(uploadResponse) as { data?: ReimbursementAttachmentUploadResponse } | null;
|
||||||
|
const attachmentId = payload?.data?.items?.[0]?.id;
|
||||||
|
if (!attachmentId) {
|
||||||
|
throw new Error(`上传附件后未返回 attachmentId: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reimbursementId: reimbursement.id,
|
||||||
|
attachmentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAuditAction(api: APIRequestContext, action: string, predicate: (item: AuditLogListResponse["items"][number]) => boolean) {
|
||||||
|
const encoded = encodeURIComponent(action);
|
||||||
|
const data = await apiGetData<AuditLogListResponse>(api, `/api/audit-logs?action=${encoded}&page=1&pageSize=50`);
|
||||||
|
expectNoSensitiveKeys(data);
|
||||||
|
expect(data.items.some(predicate), `audit action ${action} should be traceable`).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("R3 权限直连审计", () => {
|
test.describe("R3 权限直连审计", () => {
|
||||||
test("前端直连备用路径按角色拦截", async ({ browser }) => {
|
test("前端直连备用路径按角色拦截", async ({ browser }) => {
|
||||||
await expectForbiddenRoutes(browser, EMPLOYEE, [
|
await expectForbiddenRoutes(browser, EMPLOYEE, [
|
||||||
@@ -118,6 +375,93 @@ test.describe("R3 权限直连审计", () => {
|
|||||||
await expectForbiddenRoutes(browser, FINANCE, [
|
await expectForbiddenRoutes(browser, FINANCE, [
|
||||||
"/system/integrations/erp",
|
"/system/integrations/erp",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await expectForbiddenRoutes(browser, ADMIN, [
|
||||||
|
"/workflow/tasks",
|
||||||
|
"/mobile/approvals",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PA6 角色入口矩阵:PC 与移动入口按正式业务角色裁剪", async ({ browser }) => {
|
||||||
|
await expectPcNavMatrix(browser, EMPLOYEE, {
|
||||||
|
visible: ["/portal", "/reimbursements"],
|
||||||
|
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"],
|
||||||
|
});
|
||||||
|
await expectPcNavMatrix(browser, MANAGER, {
|
||||||
|
visible: ["/workflow/tasks"],
|
||||||
|
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/settings"],
|
||||||
|
});
|
||||||
|
await expectPcNavMatrix(browser, FINANCE, {
|
||||||
|
visible: ["/invoice-pool"],
|
||||||
|
hidden: ["/audit-logs", "/system/settings", "/system/integrations/erp"],
|
||||||
|
});
|
||||||
|
await expectPcNavMatrix(browser, ADMIN, {
|
||||||
|
visible: ["/audit-logs", "/system/settings"],
|
||||||
|
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectMobileApprovalEntry(browser, EMPLOYEE, false);
|
||||||
|
await expectMobileApprovalEntry(browser, MANAGER, true);
|
||||||
|
await expectMobileApprovalEntry(browser, ADMIN, false);
|
||||||
|
|
||||||
|
const { context, page } = await openAs(browser, FINANCE);
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/mobile/me");
|
||||||
|
await expect(page.getByText("管理入口", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("mobile-me-action-link").filter({ hasText: "审批" }).first()).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PA6 portal 角色能力字段不把 admin-only 当业务审批人", async () => {
|
||||||
|
const clients = await Promise.all([
|
||||||
|
createAuthenticatedApiClient(EMPLOYEE),
|
||||||
|
createAuthenticatedApiClient(MANAGER),
|
||||||
|
createAuthenticatedApiClient(FINANCE),
|
||||||
|
createAuthenticatedApiClient(ADMIN),
|
||||||
|
]);
|
||||||
|
const [employee, manager, finance, admin] = clients;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const employeePortal = await apiGetData<PortalHomeResponse>(employee.api, "/api/portal/home");
|
||||||
|
expect(employeePortal.roleCapabilities).toMatchObject({
|
||||||
|
canApproveTasks: false,
|
||||||
|
canUseFinance: false,
|
||||||
|
canManageSystem: false,
|
||||||
|
isAdminOnly: false,
|
||||||
|
});
|
||||||
|
expect(employeePortal.financeSummary).toBeNull();
|
||||||
|
|
||||||
|
const managerPortal = await apiGetData<PortalHomeResponse>(manager.api, "/api/portal/home");
|
||||||
|
expect(managerPortal.roleCapabilities).toMatchObject({
|
||||||
|
canApproveTasks: true,
|
||||||
|
canUseFinance: false,
|
||||||
|
canManageSystem: false,
|
||||||
|
isAdminOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const financePortal = await apiGetData<PortalHomeResponse>(finance.api, "/api/portal/home");
|
||||||
|
expect(financePortal.roleCapabilities).toMatchObject({
|
||||||
|
canApproveTasks: true,
|
||||||
|
canUseFinance: true,
|
||||||
|
canManageSystem: false,
|
||||||
|
isAdminOnly: false,
|
||||||
|
});
|
||||||
|
expect(financePortal.financeSummary).toBeTruthy();
|
||||||
|
|
||||||
|
const adminPortal = await apiGetData<PortalHomeResponse>(admin.api, "/api/portal/home");
|
||||||
|
expect(adminPortal.roleCapabilities).toMatchObject({
|
||||||
|
canApproveTasks: false,
|
||||||
|
canUseFinance: false,
|
||||||
|
canManageSystem: true,
|
||||||
|
isAdminOnly: true,
|
||||||
|
});
|
||||||
|
expect(adminPortal.financeSummary).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await Promise.all(clients.map((client) => client.dispose()));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("员工自助直连页面不进入管理态 403", async ({ browser }) => {
|
test("员工自助直连页面不进入管理态 403", async ({ browser }) => {
|
||||||
@@ -143,6 +487,9 @@ test.describe("R3 权限直连审计", () => {
|
|||||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/payment-batches", statuses: [403, 409], note: "员工不得直连付款批次;409 表示功能门禁关闭且未泄露数据" });
|
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/payment-batches", statuses: [403, 409], note: "员工不得直连付款批次;409 表示功能门禁关闭且未泄露数据" });
|
||||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "员工不得直连 ERP 管理" });
|
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "员工不得直连 ERP 管理" });
|
||||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
|
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
|
||||||
|
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/audit-logs", statuses: [403], note: "员工不得查看审计日志" });
|
||||||
|
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" });
|
||||||
|
await expectApiMutationStatus({ role: "employee", api: employee.api, path: "/api/exports/ap-operations", method: "post", data: { format: "csv" }, statuses: [403, 409], note: "员工不得导出 AP 运营数据;409 表示功能门禁关闭且未泄露数据" });
|
||||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同台账只能走范围过滤或功能门禁" });
|
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同台账只能走范围过滤或功能门禁" });
|
||||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [200], note: "员工差旅列表强制本人范围" });
|
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [200], note: "员工差旅列表强制本人范围" });
|
||||||
|
|
||||||
@@ -175,9 +522,13 @@ test.describe("R3 权限直连审计", () => {
|
|||||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
|
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
|
||||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [403, 409], note: "主管不得直连付款批次" });
|
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [403, 409], note: "主管不得直连付款批次" });
|
||||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
|
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
|
||||||
|
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/audit-logs", statuses: [403], note: "主管不得查看审计日志" });
|
||||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
|
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
|
||||||
|
|
||||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "财务不得直连 ERP 管理" });
|
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "财务不得直连 ERP 管理" });
|
||||||
|
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/audit-logs", statuses: [403], note: "普通财务不得查看审计日志" });
|
||||||
|
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/system/feature-flags", statuses: [403], note: "普通财务不得管理系统开关" });
|
||||||
|
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/notifications/deliveries", statuses: [403], note: "普通财务不得查看通知渠道投递审计" });
|
||||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/invoice-pool?ownerScope=all&pageSize=20", statuses: [200], note: "财务可看票据财务范围" });
|
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/invoice-pool?ownerScope=all&pageSize=20", statuses: [200], note: "财务可看票据财务范围" });
|
||||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/payment-batches", statuses: [200, 409], note: "财务可看付款批次或受功能门禁限制" });
|
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/payment-batches", statuses: [200, 409], note: "财务可看付款批次或受功能门禁限制" });
|
||||||
|
|
||||||
@@ -190,6 +541,24 @@ test.describe("R3 权限直连审计", () => {
|
|||||||
});
|
});
|
||||||
expectNoSensitiveKeys(erpStatus.body?.data);
|
expectNoSensitiveKeys(erpStatus.body?.data);
|
||||||
|
|
||||||
|
const adminAudit = await expectApiStatus({
|
||||||
|
role: "admin",
|
||||||
|
api: admin.api,
|
||||||
|
path: "/api/audit-logs?page=1&pageSize=5",
|
||||||
|
statuses: [200],
|
||||||
|
note: "管理员可查看审计日志",
|
||||||
|
});
|
||||||
|
expectNoSensitiveKeys(adminAudit.body?.data);
|
||||||
|
|
||||||
|
const deliveryAudit = await expectApiStatus({
|
||||||
|
role: "admin",
|
||||||
|
api: admin.api,
|
||||||
|
path: "/api/notifications/deliveries?limit=5",
|
||||||
|
statuses: [200],
|
||||||
|
note: "管理员可查看通知投递审计",
|
||||||
|
});
|
||||||
|
expectNoSensitiveKeys(deliveryAudit.body?.data);
|
||||||
|
|
||||||
const wecomConfig = await expectApiStatus({
|
const wecomConfig = await expectApiStatus({
|
||||||
role: "admin",
|
role: "admin",
|
||||||
api: admin.api,
|
api: admin.api,
|
||||||
@@ -202,4 +571,111 @@ test.describe("R3 权限直连审计", () => {
|
|||||||
await Promise.all(clients.map((client) => client.dispose()));
|
await Promise.all(clients.map((client) => client.dispose()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("PA6 请假和采购自审、admin-only 业务审批被服务端拒绝", async () => {
|
||||||
|
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
|
const manager = await createAuthenticatedApiClient(MANAGER);
|
||||||
|
const admin = await createAuthenticatedApiClient(ADMIN);
|
||||||
|
const clients = [employee, manager, admin];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leave = await createSubmittedLeave(employee.api, `admin-only-${Date.now()}`);
|
||||||
|
const purchase = await createSubmittedPurchase(employee.api, `admin-only-${Date.now()}`);
|
||||||
|
|
||||||
|
await expectApiMutationStatus({
|
||||||
|
role: "employee",
|
||||||
|
api: employee.api,
|
||||||
|
path: `/api/workflow/tasks/${leave.currentTaskId}/approve`,
|
||||||
|
method: "post",
|
||||||
|
statuses: [403, 409],
|
||||||
|
note: "提交人本人不能审批自己的请假单",
|
||||||
|
});
|
||||||
|
await expectApiMutationStatus({
|
||||||
|
role: "admin",
|
||||||
|
api: admin.api,
|
||||||
|
path: `/api/workflow/tasks/${leave.currentTaskId}/approve`,
|
||||||
|
method: "post",
|
||||||
|
statuses: [403, 409],
|
||||||
|
note: "admin-only 不获得业务审批权",
|
||||||
|
});
|
||||||
|
await expectApiMutationStatus({
|
||||||
|
role: "employee",
|
||||||
|
api: employee.api,
|
||||||
|
path: `/api/workflow/tasks/${purchase.currentTaskId}/approve`,
|
||||||
|
method: "post",
|
||||||
|
statuses: [403, 409],
|
||||||
|
note: "提交人本人不能审批自己的采购单",
|
||||||
|
});
|
||||||
|
await expectApiMutationStatus({
|
||||||
|
role: "admin",
|
||||||
|
api: admin.api,
|
||||||
|
path: `/api/workflow/tasks/${purchase.currentTaskId}/approve`,
|
||||||
|
method: "post",
|
||||||
|
statuses: [403, 409],
|
||||||
|
note: "admin-only 不获得采购业务审批权",
|
||||||
|
});
|
||||||
|
|
||||||
|
const approved = await apiPostData<{ outcome: string; workflowInstanceId: string }>(
|
||||||
|
manager.api,
|
||||||
|
`/api/workflow/tasks/${leave.currentTaskId}/approve`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(approved.outcome).toBe("approved");
|
||||||
|
|
||||||
|
const detail = await apiGetData<WorkflowTaskDetailResponse>(manager.api, `/api/workflow/tasks/${leave.currentTaskId}`);
|
||||||
|
expect(detail.history.map((item) => item.action)).toEqual(expect.arrayContaining(["start", "approve"]));
|
||||||
|
expectNoSensitiveKeys(detail.history);
|
||||||
|
|
||||||
|
const purchaseApproved = await apiPostData<{ outcome: string }>(
|
||||||
|
manager.api,
|
||||||
|
`/api/workflow/tasks/${purchase.currentTaskId}/approve`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(purchaseApproved.outcome).toBe("approved");
|
||||||
|
} finally {
|
||||||
|
await Promise.all(clients.map((client) => client.dispose()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PA6 审计留痕:登录、审批历史、附件下载、导出与通知投递可追溯且脱敏", async () => {
|
||||||
|
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
|
const manager = await createAuthenticatedApiClient(MANAGER);
|
||||||
|
const finance = await createAuthenticatedApiClient(FINANCE);
|
||||||
|
const admin = await createAuthenticatedApiClient(ADMIN);
|
||||||
|
const clients = [employee, manager, finance, admin];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expectAuditAction(admin.api, "AUTH_LOGIN_SUCCESS", (item) => item.actorId === employee.me.id);
|
||||||
|
|
||||||
|
const leave = await createSubmittedLeave(employee.api, `audit-${Date.now()}`);
|
||||||
|
await apiPostData<{ outcome: string }>(manager.api, `/api/workflow/tasks/${leave.currentTaskId}/approve`, {});
|
||||||
|
const workflowDetail = await apiGetData<WorkflowTaskDetailResponse>(manager.api, `/api/workflow/tasks/${leave.currentTaskId}`);
|
||||||
|
expect(workflowDetail.history.some((item) => item.action === "approve" && item.actorId === manager.me.id)).toBe(true);
|
||||||
|
expectNoSensitiveKeys(workflowDetail.history);
|
||||||
|
|
||||||
|
const attachment = await createReimbursementAttachment(employee.api);
|
||||||
|
const downloadResponse = await employee.api.get(
|
||||||
|
`/api/reimbursements/${attachment.reimbursementId}/attachments/${attachment.attachmentId}/file`,
|
||||||
|
);
|
||||||
|
expect(downloadResponse.ok(), `download attachment failed: ${downloadResponse.status()}`).toBe(true);
|
||||||
|
await expectAuditAction(
|
||||||
|
admin.api,
|
||||||
|
"REIMBURSEMENT_ATTACHMENT_DOWNLOADED",
|
||||||
|
(item) => item.targetId === attachment.reimbursementId &&
|
||||||
|
(item.extra as JsonObject | null)?.attachmentId === attachment.attachmentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportResponse = await finance.api.post("/api/exports/ap-operations", { data: { format: "csv" } });
|
||||||
|
expectStatus(exportResponse.status(), [200, 409], "finance POST /api/exports/ap-operations");
|
||||||
|
if (exportResponse.status() === 200) {
|
||||||
|
await expectAuditAction(admin.api, "EXPORT_AP_OPERATIONS", (item) => item.actorId === finance.me.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveryData = await apiGetData<NotificationDeliveryListResponse>(admin.api, "/api/notifications/deliveries?limit=10");
|
||||||
|
expect(Array.isArray(deliveryData.items), "notification delivery audit items should be an array").toBe(true);
|
||||||
|
expectNoSensitiveKeys(deliveryData);
|
||||||
|
} finally {
|
||||||
|
await Promise.all(clients.map((client) => client.dispose()));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
PA6-1,权限审计回归合同建档,规划 R6 后角色矩阵、服务端权限、自审规则和审计留痕回归。,"1) 本 CSV 校验通过;2) 覆盖员工、审批人、财务、管理员、负责人;3) 前端隐藏不作为权限边界;4) 不改组织规则。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-46-00-r6-permission-audit-regression.csv。",python3,DONE,DONE,DONE,issues/2026-05-12_01-46-00-r6-permission-audit-regression.csv,[],权限问题发现后单独修复,不顺手扩功能。
|
PA6-1,权限审计回归合同建档,规划 R6 后角色矩阵、服务端权限、自审规则和审计留痕回归。,"1) 本 CSV 校验通过;2) 覆盖员工、审批人、财务、管理员、负责人;3) 前端隐藏不作为权限边界;4) 不改组织规则。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-46-00-r6-permission-audit-regression.csv。",python3,DONE,DONE,DONE,issues/2026-05-12_01-46-00-r6-permission-audit-regression.csv,[],权限问题发现后单独修复,不顺手扩功能。
|
||||||
PA6-2,角色入口矩阵回归,检查 PC 和移动入口按员工、审批人、财务、管理员、负责人裁剪。,"1) 员工不见财务和管理入口;2) 财务不见系统管理入口;3) 管理员不见业务审批按钮;4) 移动入口与 PC 权限一致。","流程级验证:各角色登录 PC 和移动 -> 记录入口矩阵 -> 与 docs/需求-统一审批中心规约.md 对齐。",playwright|manual,TODO,TODO,TODO,client/src/components/navigation|client/src/features/mobile/navigation.ts,"[""PA6-1""]",角色显示差异采用同框架裁剪。
|
PA6-2,角色入口矩阵回归,检查 PC 和移动入口按员工、审批人、财务、管理员、负责人裁剪。,"1) 员工不见财务和管理入口;2) 财务不见系统管理入口;3) 管理员不见业务审批按钮;4) 移动入口与 PC 权限一致。","流程级验证:各角色登录 PC 和移动 -> 记录入口矩阵 -> 与 docs/需求-统一审批中心规约.md 对齐。",playwright|manual,DONE,DONE,DONE,client/src/components/navigation|client/src/features/mobile/navigation.ts,"[""PA6-1""]",角色显示差异采用同框架裁剪。
|
||||||
PA6-3,服务端直连越权回归,对备用模块和业务动作做 API 直连拒绝验证。,"1) 普通员工直连预算、AP、ERP、票据中心等备用路径不得泄露数据;2) 财务和管理员边界正确;3) 无权动作返回 403 或可解释 4xx。","流程级验证:用员工、财务、管理员 token 直连关键 API -> 记录 HTTP 状态和是否泄露数据。",curl|playwright,TODO,TODO,TODO,server/src/routes|client/tests/e2e/permission-direct-access.spec.ts,"[""PA6-2""]",不得打印 token。
|
PA6-3,服务端直连越权回归,对备用模块和业务动作做 API 直连拒绝验证。,"1) 普通员工直连预算、AP、ERP、票据中心等备用路径不得泄露数据;2) 财务和管理员边界正确;3) 无权动作返回 403 或可解释 4xx。","流程级验证:用员工、财务、管理员 token 直连关键 API -> 记录 HTTP 状态和是否泄露数据。",curl|playwright,DONE,DONE,DONE,server/src/routes|client/tests/e2e/permission-direct-access.spec.ts,"[""PA6-2""]",不得打印 token。
|
||||||
PA6-4,自审与 admin-only 回归,覆盖报销、请假、采购的自审阻断和 admin-only 禁止业务审批。,"1) 提交人本人不能审批本人单据;2) admin-only 不能获得业务审批权;3) 正式审批人可以处理自己的待办;4) 服务端强制拒绝。","流程级验证:构造三类单据 -> 本人尝试审批被拒绝 -> admin-only 尝试审批被拒绝 -> 正式审批人通过。",playwright|node,TODO,TODO,TODO,server/src/routes/workflow.js|client/tests/e2e,"[""PA6-3""]",涉及 workflow 需 reviewer 复核。
|
PA6-4,自审与 admin-only 回归,覆盖报销、请假、采购的自审阻断和 admin-only 禁止业务审批。,"1) 提交人本人不能审批本人单据;2) admin-only 不能获得业务审批权;3) 正式审批人可以处理自己的待办;4) 服务端强制拒绝。","流程级验证:构造三类单据 -> 本人尝试审批被拒绝 -> admin-only 尝试审批被拒绝 -> 正式审批人通过。",playwright|node,DONE,DONE,DONE,server/src/routes/workflow.js|client/tests/e2e,"[""PA6-3""]",涉及 workflow 需 reviewer 复核。
|
||||||
PA6-5,审计留痕回归,核对登录、审批、付款、导出、附件下载和通知投递的审计可追溯。,"1) 关键动作有审计或 delivery;2) 审计不含密码和 secret;3) 查询权限正确;4) 失败动作也有可解释记录。","流程级验证:执行关键动作 -> 管理员查询审计 -> 检查字段和脱敏;通知投递查询 delivery。",playwright|manual,TODO,TODO,TODO,server/src/routes/audit*|server/src/services/notificationDispatcher.js,"[""PA6-4""]",敏感数据不得入库日志。
|
PA6-5,审计留痕回归,核对登录、审批、付款、导出、附件下载和通知投递的审计可追溯。,"1) 关键动作有审计或 delivery;2) 审计不含密码和 secret;3) 查询权限正确;4) 失败动作也有可解释记录。","流程级验证:执行关键动作 -> 管理员查询审计 -> 检查字段和脱敏;通知投递查询 delivery。",playwright|manual,DONE,DONE,DONE,server/src/routes/audit*|server/src/services/notificationDispatcher.js,"[""PA6-4""]",敏感数据不得入库日志。
|
||||||
PA6-6,权限审计回归部署,完成后执行权限 E2E、后端单测、部署和公网复检。,"1) permission-direct-access 通过;2) workflow 权限回归通过;3) 部署后 live smoke 通过;4) 问题全部登记。","流程级验证:cd client && npm run test:e2e -- permission-direct-access.spec.ts settings-governance.spec.ts;server 相关单测;deploy;live smoke readiness。",npm|node|bash,TODO,TODO,TODO,client/tests/e2e|server/tests|deploy/trialrun,"[""PA6-5""]",权限变更必须单独 commit。
|
PA6-6,权限审计回归部署,完成后执行权限 E2E、后端单测、部署和公网复检。,"1) permission-direct-access 通过;2) workflow 权限回归通过;3) 部署后 live smoke 通过;4) 问题全部登记。","流程级验证:cd client && npm run test:e2e -- permission-direct-access.spec.ts settings-governance.spec.ts;server 相关单测;deploy;live smoke readiness。",npm|node|bash,DONE,DONE,DONE,client/tests/e2e|server/tests|deploy/trialrun,"[""PA6-5""]",权限变更必须单独 commit。
|
||||||
|
|||||||
|
@@ -96,6 +96,7 @@ specs_for_group() {
|
|||||||
;;
|
;;
|
||||||
governance)
|
governance)
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
|
tests/e2e/permission-direct-access.spec.ts \
|
||||||
tests/e2e/settings-governance.spec.ts
|
tests/e2e/settings-governance.spec.ts
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -2645,6 +2645,18 @@ async function reimbursementRoutes(fastify) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "REIMBURSEMENT_ATTACHMENT_DOWNLOADED",
|
||||||
|
targetType: "reimbursement",
|
||||||
|
targetId: reimbursementId,
|
||||||
|
extra: {
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
reply.type(attachment.mime_type || "application/octet-stream");
|
reply.type(attachment.mime_type || "application/octet-stream");
|
||||||
reply.header(
|
reply.header(
|
||||||
"content-disposition",
|
"content-disposition",
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ const WORKSPACE_PROFILES = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const MODULE_VISIBILITY_MODES = Object.freeze(["main", "admin", "hidden"]);
|
const MODULE_VISIBILITY_MODES = Object.freeze(["main", "admin", "hidden"]);
|
||||||
|
const BUSINESS_APPROVE_PERMISSIONS = Object.freeze([
|
||||||
|
"expense.approve",
|
||||||
|
"leave.approve",
|
||||||
|
"procurement.approve",
|
||||||
|
"travel.approve",
|
||||||
|
]);
|
||||||
|
const BUSINESS_APPROVER_ROLE_CODES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr"]);
|
||||||
|
const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]);
|
||||||
|
const FINANCE_PERMISSION_CODES = Object.freeze(["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
|
||||||
|
const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "invoicePool"]);
|
||||||
|
|
||||||
const NAVIGATION_REGISTRY = Object.freeze({
|
const NAVIGATION_REGISTRY = Object.freeze({
|
||||||
portal: { key: "portal", label: "Portal", icon: "home_app_logo", href: "/portal", anyOf: [] },
|
portal: { key: "portal", label: "Portal", icon: "home_app_logo", href: "/portal", anyOf: [] },
|
||||||
@@ -23,7 +33,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
|||||||
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
|
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
|
||||||
contractsRegistry: { key: "contractsRegistry", label: "合同台账", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
|
contractsRegistry: { key: "contractsRegistry", label: "合同台账", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
|
||||||
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
|
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
|
||||||
systemSettings: { key: "systemSettings", label: "系统设置", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
systemSettings: { key: "systemSettings", label: "系统设置", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||||
featureFlags: { key: "featureFlags", label: "功能开关", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
|
featureFlags: { key: "featureFlags", label: "功能开关", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
|
||||||
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
|
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
|
||||||
|
|
||||||
@@ -176,6 +186,22 @@ function hasAnyPermission(hasPermission, employee, anyOf = []) {
|
|||||||
return anyOf.some((permissionCode) => hasPermission(employee, permissionCode));
|
return anyOf.some((permissionCode) => hasPermission(employee, permissionCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasBusinessApproverRole(hasPermission, employee) {
|
||||||
|
const roleCodes = employee?.roleCodes ?? [];
|
||||||
|
return (
|
||||||
|
roleCodes.some((roleCode) => BUSINESS_APPROVER_ROLE_CODES.has(roleCode)) &&
|
||||||
|
hasAnyPermission(hasPermission, employee, BUSINESS_APPROVE_PERMISSIONS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFinanceRole(hasPermission, employee) {
|
||||||
|
const roleCodes = employee?.roleCodes ?? [];
|
||||||
|
return (
|
||||||
|
roleCodes.some((roleCode) => FINANCE_ROLE_CODES.has(roleCode)) &&
|
||||||
|
hasAnyPermission(hasPermission, employee, FINANCE_PERMISSION_CODES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveModuleGate({ prisma, config, employee, moduleKey, registryItem }) {
|
async function resolveModuleGate({ prisma, config, employee, moduleKey, registryItem }) {
|
||||||
if (!registryItem?.featureFlagKey) {
|
if (!registryItem?.featureFlagKey) {
|
||||||
return { enabled: true, reason: "no_feature_gate" };
|
return { enabled: true, reason: "no_feature_gate" };
|
||||||
@@ -204,6 +230,14 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (moduleKey === "workflowTasks" && !hasBusinessApproverRole(hasPermission, employee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FINANCE_NAVIGATION_MODULES.has(moduleKey) && !hasFinanceRole(hasPermission, employee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const gate = await resolveModuleGate({ prisma, config, employee, moduleKey, registryItem });
|
const gate = await resolveModuleGate({ prisma, config, employee, moduleKey, registryItem });
|
||||||
if (!gate.enabled) {
|
if (!gate.enabled) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
const employee = {
|
const employee = {
|
||||||
id: "employee-1",
|
id: "employee-1",
|
||||||
department_id: "dept-1",
|
department_id: "dept-1",
|
||||||
|
roleCodes: ["employee"],
|
||||||
permissionCodes: resolvePermissionsForRoles(["employee"]),
|
permissionCodes: resolvePermissionsForRoles(["employee"]),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,4 +103,29 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
expect(backupKeys).not.toContain("erpIntegrations");
|
expect(backupKeys).not.toContain("erpIntegrations");
|
||||||
expect(backupKeys).not.toContain("dashboard");
|
expect(backupKeys).not.toContain("dashboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not expose workflow task entry to admin-only navigation", async () => {
|
||||||
|
const adminOnly = {
|
||||||
|
id: "admin-1",
|
||||||
|
department_id: "dept-1",
|
||||||
|
roleCodes: ["admin"],
|
||||||
|
permissionCodes: resolvePermissionsForRoles(["admin"]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigation = await getSystemNavigation({
|
||||||
|
prisma: buildPrisma(),
|
||||||
|
config: { ...buildConfig(), workflowDefinitionAdminEnabled: true },
|
||||||
|
employee: adminOnly,
|
||||||
|
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allKeys = [
|
||||||
|
...navigation.sections.main,
|
||||||
|
...navigation.sections.admin,
|
||||||
|
...navigation.sections.backup,
|
||||||
|
].map((item) => item.key);
|
||||||
|
|
||||||
|
expect(allKeys).toContain("auditLogs");
|
||||||
|
expect(allKeys).not.toContain("workflowTasks");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户