forked from wangziqi/gongxue-base
fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal file
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildMenu, collectMenuPaths, findRoleAwareLandingPath } from './menu-policy';
|
||||
|
||||
const teacherPermissions = [
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'notification:view',
|
||||
];
|
||||
const academicPermissions = [
|
||||
'dashboard:view',
|
||||
'student:view',
|
||||
'class:view',
|
||||
'teacher:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'classroom:view',
|
||||
'notification:view',
|
||||
];
|
||||
const accommodationPermissions = [
|
||||
'dashboard:view',
|
||||
'room:view',
|
||||
'occupancy:view',
|
||||
'expense:view',
|
||||
'bill:view',
|
||||
'deposit:view',
|
||||
'notification:view',
|
||||
];
|
||||
const systemPermissions = [
|
||||
'user:view',
|
||||
'role:view',
|
||||
'log:view',
|
||||
'integration:read',
|
||||
'ai:config:read',
|
||||
'notification:view',
|
||||
];
|
||||
|
||||
describe('role-aware menu policy', () => {
|
||||
it('builds a teacher flow without global student or class management', () => {
|
||||
const menu = buildMenu(['任课老师'], teacherPermissions);
|
||||
expect(menu.map((item) => item.label)).toEqual(['教学工作', '通知中心']);
|
||||
expect(collectMenuPaths(menu)).toEqual([
|
||||
'/teacher-workspace',
|
||||
'/schedules',
|
||||
'/attendance',
|
||||
'/notifications',
|
||||
]);
|
||||
expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe(
|
||||
'/teacher-workspace',
|
||||
);
|
||||
});
|
||||
|
||||
it('places schedules and attendance only once in academic management', () => {
|
||||
const menu = buildMenu(['教务管理员'], academicPermissions);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(menu.map((item) => item.label)).toEqual(['数据面板', '教务管理', '通知中心']);
|
||||
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
|
||||
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
|
||||
expect(paths).not.toContain('/teacher-workspace');
|
||||
});
|
||||
|
||||
it('keeps accommodation billing in one business workspace', () => {
|
||||
const menu = buildMenu(['住宿运营管理员'], accommodationPermissions);
|
||||
expect(menu.map((item) => item.label)).toEqual(['数据面板', '住宿运营', '通知中心']);
|
||||
expect(collectMenuPaths(menu)).toEqual([
|
||||
'/dashboard',
|
||||
'/room-visual',
|
||||
'/rooms',
|
||||
'/occupancies',
|
||||
'/expenses',
|
||||
'/bills',
|
||||
'/deposits',
|
||||
'/notifications',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lands system administrators on account management rather than notifications', () => {
|
||||
expect(findRoleAwareLandingPath(['系统管理员'], systemPermissions)).toBe('/users');
|
||||
});
|
||||
|
||||
it('builds a super-admin menu with unique routes and no teacher workspace', () => {
|
||||
const allPermissions = [
|
||||
...academicPermissions,
|
||||
...accommodationPermissions,
|
||||
...systemPermissions,
|
||||
'rental:view',
|
||||
'organization:view',
|
||||
];
|
||||
const menu = buildMenu(['超级管理员'], [...new Set(allPermissions)]);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
expect(paths).not.toContain('/teacher-workspace');
|
||||
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
|
||||
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deduplicates routes when an account has multiple business roles', () => {
|
||||
const menu = buildMenu(
|
||||
['教务管理员', '住宿运营管理员'],
|
||||
[...academicPermissions, ...accommodationPermissions],
|
||||
);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
});
|
||||
});
|
||||
186
apps/admin/src/auth/menu-policy.ts
Normal file
186
apps/admin/src/auth/menu-policy.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
export interface AppMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
children?: AppMenuItem[];
|
||||
}
|
||||
|
||||
interface MenuEntry extends AppMenuItem {
|
||||
permission: string;
|
||||
}
|
||||
|
||||
interface MenuSection extends AppMenuItem {
|
||||
roles: string[];
|
||||
children: MenuEntry[];
|
||||
}
|
||||
|
||||
const ROLE_ALIASES: Record<string, string> = {
|
||||
老师: 'teacher',
|
||||
任课老师: 'teacher',
|
||||
teacher: 'teacher',
|
||||
教务: 'academic',
|
||||
教务管理员: 'academic',
|
||||
academic: 'academic',
|
||||
宿管老师: 'accommodation',
|
||||
宿管: 'accommodation',
|
||||
财务: 'accommodation',
|
||||
住宿运营管理员: 'accommodation',
|
||||
accommodation_operations: 'accommodation',
|
||||
机构负责人: 'classroom',
|
||||
教室运营管理员: 'classroom',
|
||||
classroom_operations: 'classroom',
|
||||
系统管理员: 'system',
|
||||
system_admin: 'system',
|
||||
超管: 'super',
|
||||
超级管理员: 'super',
|
||||
super_admin: 'super',
|
||||
};
|
||||
|
||||
const SECTIONS: MenuSection[] = [
|
||||
{
|
||||
key: 'teaching-group',
|
||||
label: '教学工作',
|
||||
icon: 'calendar',
|
||||
roles: ['teacher'],
|
||||
children: [
|
||||
{ key: '/teacher-workspace', label: '今日教学', icon: 'workspace', permission: 'teacher-workspace:view' },
|
||||
{ key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' },
|
||||
{ key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'academic-group',
|
||||
label: '教务管理',
|
||||
icon: 'academic',
|
||||
roles: ['academic', 'super'],
|
||||
children: [
|
||||
{ key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' },
|
||||
{ key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' },
|
||||
{ key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' },
|
||||
{ key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' },
|
||||
{ key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' },
|
||||
{ key: '/classrooms', label: '教室查看', icon: 'classroom', permission: 'classroom:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'accommodation-group',
|
||||
label: '住宿运营',
|
||||
icon: 'home',
|
||||
roles: ['accommodation', 'super'],
|
||||
children: [
|
||||
{ key: '/room-visual', label: '住宿总览', icon: 'overview', permission: 'room:view' },
|
||||
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'classroom-group',
|
||||
label: '教室运营',
|
||||
icon: 'classroom',
|
||||
roles: ['classroom', 'super'],
|
||||
children: [
|
||||
{ key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' },
|
||||
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
|
||||
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system-group',
|
||||
label: '系统管理',
|
||||
icon: 'settings',
|
||||
roles: ['system', 'super'],
|
||||
children: [
|
||||
{ key: '/users', label: '账号管理', icon: 'users', permission: 'user:view' },
|
||||
{ key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' },
|
||||
{ key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' },
|
||||
{ key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' },
|
||||
{ key: '/integration-config', label: '钉钉集成', icon: 'integration', permission: 'integration:read' },
|
||||
{ key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getRoleDomains(roles: readonly string[], permissions: readonly string[]): Set<string> {
|
||||
const normalized = new Set(roles.map((role) => ROLE_ALIASES[role]).filter(Boolean));
|
||||
// 权限可以来自多个叠加角色,因此业务域按能力累加,而不是只选择一个。
|
||||
if (permissions.includes('student:view') || permissions.includes('class:view')) {
|
||||
normalized.add('academic');
|
||||
}
|
||||
if (
|
||||
permissions.includes('room:view') &&
|
||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
|
||||
) {
|
||||
normalized.add('accommodation');
|
||||
}
|
||||
if (permissions.includes('rental:view') || permissions.includes('organization:view')) {
|
||||
normalized.add('classroom');
|
||||
}
|
||||
if (permissions.includes('user:view') || permissions.includes('role:view')) {
|
||||
normalized.add('system');
|
||||
}
|
||||
const hasAdministrativeDomain = [...normalized].some((role) => role !== 'teacher');
|
||||
if (!hasAdministrativeDomain && permissions.includes('teacher-workspace:view')) {
|
||||
normalized.add('teacher');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function dedupeMenu(items: AppMenuItem[]): AppMenuItem[] {
|
||||
const usedPaths = new Set<string>();
|
||||
const result: AppMenuItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
const children = item.children.filter((child) => {
|
||||
if (usedPaths.has(child.key)) return false;
|
||||
usedPaths.add(child.key);
|
||||
return true;
|
||||
});
|
||||
if (children.length > 0) result.push({ ...item, children });
|
||||
continue;
|
||||
}
|
||||
if (!usedPaths.has(item.key)) {
|
||||
usedPaths.add(item.key);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildMenu(roles: readonly string[], permissions: readonly string[]): AppMenuItem[] {
|
||||
const roleSet = getRoleDomains(roles, permissions);
|
||||
const permissionSet = new Set(permissions);
|
||||
const sections: AppMenuItem[] = [];
|
||||
|
||||
if (permissionSet.has('dashboard:view') && !roleSet.has('teacher')) {
|
||||
sections.push({ key: '/dashboard', label: '数据面板', icon: 'dashboard' });
|
||||
}
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
if (!section.roles.some((role) => roleSet.has(role))) continue;
|
||||
const children = section.children
|
||||
.filter((child) => permissionSet.has(child.permission))
|
||||
.map(({ permission: _, ...child }) => child);
|
||||
if (children.length > 0) sections.push({ ...section, children, roles: undefined } as AppMenuItem);
|
||||
}
|
||||
|
||||
if (permissionSet.has('notification:view')) {
|
||||
sections.push({ key: '/notifications', label: '通知中心', icon: 'notification' });
|
||||
}
|
||||
|
||||
return dedupeMenu(sections);
|
||||
}
|
||||
|
||||
export function collectMenuPaths(items: readonly AppMenuItem[]): string[] {
|
||||
return items.flatMap((item) => (item.children ? collectMenuPaths(item.children) : [item.key]));
|
||||
}
|
||||
|
||||
export function findRoleAwareLandingPath(
|
||||
roles: readonly string[],
|
||||
permissions: readonly string[],
|
||||
): string | null {
|
||||
return collectMenuPaths(buildMenu(roles, permissions))[0] ?? null;
|
||||
}
|
||||
@@ -11,6 +11,18 @@ describe('permission navigation', () => {
|
||||
expect(findFirstAccessiblePath(permissions)).toBe('/classes');
|
||||
});
|
||||
|
||||
it('lands teachers on the teacher workspace without global student or class access', () => {
|
||||
expect(
|
||||
findFirstAccessiblePath([
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
]),
|
||||
).toBe('/teacher-workspace');
|
||||
expect(canAccessPath('/students', ['teacher-workspace:view'])).toBe(false);
|
||||
expect(canAccessPath('/classes', ['teacher-workspace:view'])).toBe(false);
|
||||
});
|
||||
|
||||
it('uses dashboard when it is the first accessible page', () => {
|
||||
expect(findFirstAccessiblePath(['dashboard:view', 'student:view'])).toBe('/dashboard');
|
||||
});
|
||||
|
||||
@@ -13,11 +13,11 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/room-visual', permission: 'room:view' },
|
||||
{ path: '/rooms', permission: 'room:view' },
|
||||
{ path: '/occupancies', permission: 'occupancy:view' },
|
||||
{ path: '/teacher-workspace', permission: 'teacher-workspace:view' },
|
||||
{ path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) },
|
||||
{ path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) },
|
||||
{ path: '/attendance', permission: 'attendance:view' },
|
||||
{ path: '/schedules', permission: 'schedule:view' },
|
||||
{ path: '/teacher-workspace', permission: 'class:view' },
|
||||
{ path: '/classroom-schedule', permission: 'rental:view' },
|
||||
{ path: '/classrooms', permission: 'classroom:view' },
|
||||
{ path: '/classroom-rentals', permission: 'rental:view' },
|
||||
@@ -32,7 +32,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/integration-config', permission: 'integration:read' },
|
||||
{ path: '/ai-config', permission: 'ai:config:read' },
|
||||
{ path: '/users', permission: 'user:view' },
|
||||
{ path: '/teachers', permission: 'user:view' },
|
||||
{ path: '/teachers', permission: 'teacher:view' },
|
||||
] as const;
|
||||
|
||||
function matchesPage(page: PermissionPage, pathname: string): boolean {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterTabsByPermission } from './permission-tabs';
|
||||
|
||||
describe('permission-aware tabs', () => {
|
||||
const tabs = [
|
||||
{ key: 'all', requiredPermission: 'deposit:view' },
|
||||
{ key: 'pending', requiredPermission: 'deposit:approve' },
|
||||
];
|
||||
const tabs = [
|
||||
{ key: 'records', requiredPermission: 'deposit:view' },
|
||||
{ key: 'refund', requiredPermission: 'deposit:refund' },
|
||||
];
|
||||
|
||||
it('hides tabs whose backing API permission is missing', () => {
|
||||
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('shows a privileged tab only when its permission is present', () => {
|
||||
describe('permission tabs', () => {
|
||||
it('shows only tabs allowed by exact permissions', () => {
|
||||
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual([
|
||||
'records',
|
||||
]);
|
||||
expect(
|
||||
filterTabsByPermission(tabs, ['deposit:view', 'deposit:approve']).map((tab) => tab.key),
|
||||
).toEqual(['all', 'pending']);
|
||||
filterTabsByPermission(tabs, ['deposit:view', 'deposit:refund']).map((tab) => tab.key),
|
||||
).toEqual(['records', 'refund']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user