修复权限:隔离教师工作台与学员中心菜单自服务可见性
This commit is contained in:
1 parent
2b1fcdb260
commit
ecfd38be17
15 files changed
+929
-17
No files matched your search
+94
@@ -92,6 +92,9 @@ public class SysLoginController
|
||||
return ajax;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
private com.ruoyi.train.service.ITrainingIdentityService identityService;
|
||||
|
||||
/**
|
||||
* 获取路由信息
|
||||
*
|
||||
@@ -102,9 +105,100 @@ public class SysLoginController
|
||||
{
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
|
||||
if (menus != null && !menus.isEmpty())
|
||||
{
|
||||
menus = filterSelfServiceMenus(menus, userId);
|
||||
}
|
||||
return AjaxResult.success(menuService.buildMenus(menus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户真实业务身份过滤角色专属自服务菜单(教师工作台、学员中心)。
|
||||
* 即使是超级管理员,管理权限亦不等于业务主体身份;无真实档案绑定则不可见。
|
||||
*/
|
||||
private List<SysMenu> filterSelfServiceMenus(List<SysMenu> menus, Long userId)
|
||||
{
|
||||
boolean hasTeacherIdentity = false;
|
||||
boolean hasStudentIdentity = false;
|
||||
|
||||
if (identityService != null && userId != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
com.ruoyi.train.domain.TrainTeacher teacher = identityService.getTeacherByUserId(userId);
|
||||
hasTeacherIdentity = (teacher != null && (teacher.getStatus() == null || teacher.getStatus() != 0));
|
||||
}
|
||||
catch (Exception ignored) {}
|
||||
|
||||
try
|
||||
{
|
||||
com.ruoyi.train.domain.TrainStudent student = identityService.getStudentByUserId(userId);
|
||||
hasStudentIdentity = (student != null && (student.getStatus() == null || student.getStatus() != 0));
|
||||
}
|
||||
catch (Exception ignored) {}
|
||||
}
|
||||
else if (userId != null && !SecurityUtils.isAdmin(userId))
|
||||
{
|
||||
// 无身份服务依赖时,降级检查角色绑定,但管理角色绝不赋予自服务
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser != null && loginUser.getUser() != null && loginUser.getUser().getRoles() != null)
|
||||
{
|
||||
for (com.ruoyi.common.core.domain.entity.SysRole r : loginUser.getUser().getRoles())
|
||||
{
|
||||
if ("train_teacher".equals(r.getRoleKey()))
|
||||
{
|
||||
hasTeacherIdentity = true;
|
||||
}
|
||||
if ("train_student".equals(r.getRoleKey()))
|
||||
{
|
||||
hasStudentIdentity = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<SysMenu> result = new java.util.ArrayList<>();
|
||||
for (SysMenu m : menus)
|
||||
{
|
||||
if (isSelfServiceAllowed(m, hasTeacherIdentity, hasStudentIdentity))
|
||||
{
|
||||
if (m.getChildren() != null && !m.getChildren().isEmpty())
|
||||
{
|
||||
m.setChildren(filterSelfServiceMenus(m.getChildren(), userId));
|
||||
}
|
||||
result.add(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSelfServiceAllowed(SysMenu m, boolean hasTeacherIdentity, boolean hasStudentIdentity)
|
||||
{
|
||||
String component = m.getComponent();
|
||||
String path = m.getPath();
|
||||
String perms = m.getPerms();
|
||||
|
||||
boolean isTeacherWorkbench = "train/me/teacher/index".equals(component)
|
||||
|| "train:me:teacher".equals(perms)
|
||||
|| ("teacher".equals(path) && "train/me/teacher/index".equals(component));
|
||||
|
||||
if (isTeacherWorkbench && !hasTeacherIdentity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean isStudentCenter = "train/me/student/index".equals(component)
|
||||
|| "train:me:student".equals(perms)
|
||||
|| ("student".equals(path) && "train/me/student/index".equals(component));
|
||||
|
||||
if (isStudentCenter && !hasStudentIdentity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查初始密码是否提醒修改
|
||||
public boolean initPasswordIsModify(Date pwdUpdateDate)
|
||||
{
|
||||
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.core.domain.entity.SysMenu;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
import com.ruoyi.web.controller.system.SysLoginController;
|
||||
|
||||
/**
|
||||
* 角色专属自服务菜单可见性与未授权访问阻断契约测试。
|
||||
*
|
||||
* 核心规则:
|
||||
* 1. 管理权限 != 业务主体身份。
|
||||
* 2. admin / train_admin 无教师档案时,教师工作台菜单 HIDDEN。
|
||||
* 3. admin / train_admin 无学员档案时,学员中心菜单 HIDDEN。
|
||||
* 4. 具备真实教师身份的用户,教师工作台 VISIBLE,学员中心 HIDDEN。
|
||||
* 5. 具备真实学员身份的用户,学员中心 VISIBLE,教师工作台 HIDDEN。
|
||||
* 6. 未授权用户直接调用 /train/me/teacher 或 /train/me/student 仍然被 403 严格拦截。
|
||||
* 7. 前端请求层具备 403 防风暴机制,页面初始化在身份失败时截断后续请求。
|
||||
*/
|
||||
public class RoleNavigationVisibilityContractTest
|
||||
{
|
||||
private SysLoginController loginController;
|
||||
private ITrainingIdentityService mockIdentityService;
|
||||
|
||||
private Long currentAdminUserId = 1L;
|
||||
private Long currentTrainAdminUserId = 2L;
|
||||
private Long currentTeacherUserId = 101L;
|
||||
private Long currentStudentUserId = 201L;
|
||||
private Long currentDualUserId = 301L;
|
||||
|
||||
private static final String PROJECT_ROOT = findProjectRoot();
|
||||
|
||||
private static String findProjectRoot()
|
||||
{
|
||||
File cur = new File(".").getAbsoluteFile();
|
||||
while (cur != null)
|
||||
{
|
||||
if (new File(cur, "build_ui").isDirectory() && new File(cur, "RuoYi-Vue").isDirectory())
|
||||
{
|
||||
return cur.getAbsolutePath();
|
||||
}
|
||||
cur = cur.getParentFile();
|
||||
}
|
||||
return new File(".").getAbsolutePath();
|
||||
}
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
loginController = new SysLoginController();
|
||||
|
||||
mockIdentityService = (ITrainingIdentityService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{ITrainingIdentityService.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getTeacherByUserId".equals(name))
|
||||
{
|
||||
Long uid = (Long) args[0];
|
||||
if (currentTeacherUserId.equals(uid) || currentDualUserId.equals(uid))
|
||||
{
|
||||
TrainTeacher t = new TrainTeacher();
|
||||
t.settId("T_MOCK_101");
|
||||
t.settName("测试讲师");
|
||||
t.setStatus(1);
|
||||
return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ("getStudentByUserId".equals(name))
|
||||
{
|
||||
Long uid = (Long) args[0];
|
||||
if (currentStudentUserId.equals(uid) || currentDualUserId.equals(uid))
|
||||
{
|
||||
TrainStudent s = new TrainStudent();
|
||||
s.setsId("S_MOCK_201");
|
||||
s.setsName("测试学员");
|
||||
s.setStatus(1);
|
||||
return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ("assertTeacherIdentity".equals(name))
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403);
|
||||
}
|
||||
if ("assertStudentIdentity".equals(name))
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人中心", 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
Field field = SysLoginController.class.getDeclaredField("identityService");
|
||||
field.setAccessible(true);
|
||||
field.set(loginController, mockIdentityService);
|
||||
}
|
||||
|
||||
private List<SysMenu> createMockMenuTree()
|
||||
{
|
||||
List<SysMenu> list = new ArrayList<>();
|
||||
|
||||
// 1. 人员管理(包含教师信息与学员信息子菜单)
|
||||
SysMenu peopleMgmt = new SysMenu();
|
||||
peopleMgmt.setMenuId(2159L);
|
||||
peopleMgmt.setMenuName("人员管理");
|
||||
peopleMgmt.setPath("people");
|
||||
peopleMgmt.setComponent("Layout");
|
||||
|
||||
List<SysMenu> peopleChildren = new ArrayList<>();
|
||||
SysMenu teacherInfo = new SysMenu();
|
||||
teacherInfo.setMenuId(2155L);
|
||||
teacherInfo.setMenuName("教师信息");
|
||||
teacherInfo.setPath("teacher");
|
||||
teacherInfo.setComponent("train/teacher");
|
||||
peopleChildren.add(teacherInfo);
|
||||
|
||||
SysMenu studentInfo = new SysMenu();
|
||||
studentInfo.setMenuId(2156L);
|
||||
studentInfo.setMenuName("学员信息");
|
||||
studentInfo.setPath("student");
|
||||
studentInfo.setComponent("train/student");
|
||||
peopleChildren.add(studentInfo);
|
||||
|
||||
peopleMgmt.setChildren(peopleChildren);
|
||||
list.add(peopleMgmt);
|
||||
|
||||
// 2. 教师工作台(自服务顶级菜单)
|
||||
SysMenu teacherWorkbench = new SysMenu();
|
||||
teacherWorkbench.setMenuId(2239L);
|
||||
teacherWorkbench.setMenuName("教师工作台");
|
||||
teacherWorkbench.setPath("teacher");
|
||||
teacherWorkbench.setComponent("train/me/teacher/index");
|
||||
teacherWorkbench.setPerms("train:me:teacher");
|
||||
list.add(teacherWorkbench);
|
||||
|
||||
// 3. 学员中心(自服务顶级菜单)
|
||||
SysMenu studentCenter = new SysMenu();
|
||||
studentCenter.setMenuId(2240L);
|
||||
studentCenter.setMenuName("学员中心");
|
||||
studentCenter.setPath("student");
|
||||
studentCenter.setComponent("train/me/student/index");
|
||||
studentCenter.setPerms("train:me:student");
|
||||
list.add(studentCenter);
|
||||
|
||||
// 4. 学员画像
|
||||
SysMenu portrait = new SysMenu();
|
||||
portrait.setMenuId(2173L);
|
||||
portrait.setMenuName("学员画像");
|
||||
portrait.setPath("portrait");
|
||||
portrait.setComponent("train/portrait");
|
||||
portrait.setPerms("train:portrait:query");
|
||||
list.add(portrait);
|
||||
|
||||
// 5. 系统管理
|
||||
SysMenu sysMgmt = new SysMenu();
|
||||
sysMgmt.setMenuId(1L);
|
||||
sysMgmt.setMenuName("系统管理");
|
||||
sysMgmt.setPath("system");
|
||||
sysMgmt.setComponent("Layout");
|
||||
list.add(sysMgmt);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<SysMenu> invokeFilterSelfServiceMenus(List<SysMenu> menus, Long userId) throws Exception
|
||||
{
|
||||
Method method = SysLoginController.class.getDeclaredMethod("filterSelfServiceMenus", List.class, Long.class);
|
||||
method.setAccessible(true);
|
||||
return (List<SysMenu>) method.invoke(loginController, menus, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 验证 admin 登录时自服务菜单隐藏,且普通管理菜单完整保留
|
||||
*/
|
||||
@Test
|
||||
public void testAdminSelfServiceMenuHidden() throws Exception
|
||||
{
|
||||
List<SysMenu> menus = createMockMenuTree();
|
||||
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentAdminUserId);
|
||||
|
||||
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
|
||||
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
|
||||
boolean hasPeopleMgmt = filtered.stream().anyMatch(m -> "人员管理".equals(m.getMenuName()));
|
||||
boolean hasPortrait = filtered.stream().anyMatch(m -> "学员画像".equals(m.getMenuName()));
|
||||
boolean hasSystemMgmt = filtered.stream().anyMatch(m -> "系统管理".equals(m.getMenuName()));
|
||||
|
||||
assertFalse("admin 教师工作台必须 HIDDEN", hasTeacherWorkbench);
|
||||
assertFalse("admin 学员中心必须 HIDDEN", hasStudentCenter);
|
||||
assertTrue("admin 人员管理必须保留", hasPeopleMgmt);
|
||||
assertTrue("admin 学员画像必须保留", hasPortrait);
|
||||
assertTrue("admin 系统管理必须保留", hasSystemMgmt);
|
||||
|
||||
// 人员管理下属的教师信息与学员信息完好
|
||||
SysMenu people = filtered.stream().filter(m -> "人员管理".equals(m.getMenuName())).findFirst().orElse(null);
|
||||
assertNotNull(people);
|
||||
assertEquals(2, people.getChildren().size());
|
||||
assertEquals("教师信息", people.getChildren().get(0).getMenuName());
|
||||
assertEquals("学员信息", people.getChildren().get(1).getMenuName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 验证 train_admin 登录时自服务菜单隐藏,业务菜单完整保留
|
||||
*/
|
||||
@Test
|
||||
public void testTrainAdminSelfServiceMenuHidden() throws Exception
|
||||
{
|
||||
List<SysMenu> menus = createMockMenuTree();
|
||||
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentTrainAdminUserId);
|
||||
|
||||
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
|
||||
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
|
||||
boolean hasPeopleMgmt = filtered.stream().anyMatch(m -> "人员管理".equals(m.getMenuName()));
|
||||
|
||||
assertFalse("train_admin 教师工作台必须 HIDDEN", hasTeacherWorkbench);
|
||||
assertFalse("train_admin 学员中心必须 HIDDEN", hasStudentCenter);
|
||||
assertTrue("train_admin 人员管理必须保留", hasPeopleMgmt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 验证具备真实教师身份的用户:教师工作台 VISIBLE,学员中心 HIDDEN
|
||||
*/
|
||||
@Test
|
||||
public void testTeacherSelfServiceMenuVisible() throws Exception
|
||||
{
|
||||
List<SysMenu> menus = createMockMenuTree();
|
||||
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentTeacherUserId);
|
||||
|
||||
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
|
||||
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
|
||||
|
||||
assertTrue("teacher 用户教师工作台必须 VISIBLE", hasTeacherWorkbench);
|
||||
assertFalse("teacher 用户学员中心必须 HIDDEN", hasStudentCenter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. 验证具备真实学员身份的用户:学员中心 VISIBLE,教师工作台 HIDDEN
|
||||
*/
|
||||
@Test
|
||||
public void testStudentSelfServiceMenuVisible() throws Exception
|
||||
{
|
||||
List<SysMenu> menus = createMockMenuTree();
|
||||
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentStudentUserId);
|
||||
|
||||
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
|
||||
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
|
||||
|
||||
assertFalse("student 用户教师工作台必须 HIDDEN", hasTeacherWorkbench);
|
||||
assertTrue("student 用户学员中心必须 VISIBLE", hasStudentCenter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. 验证同时具备教师与学员真实身份的用户:分别按真实身份可见
|
||||
*/
|
||||
@Test
|
||||
public void testDualIdentityUserBothVisible() throws Exception
|
||||
{
|
||||
List<SysMenu> menus = createMockMenuTree();
|
||||
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentDualUserId);
|
||||
|
||||
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
|
||||
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
|
||||
|
||||
assertTrue("双重身份用户教师工作台 VISIBLE", hasTeacherWorkbench);
|
||||
assertTrue("双重身份用户学员中心 VISIBLE", hasStudentCenter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. 验证非法直接访问保护:即便直接调用接口,无身份档案仍被 403 拦截
|
||||
*/
|
||||
@Test
|
||||
public void testDirectUnauthorizedAccessStillBlocked()
|
||||
{
|
||||
// 模拟直接访问教师工作台接口
|
||||
try
|
||||
{
|
||||
mockIdentityService.assertTeacherIdentity();
|
||||
fail("未绑定教师档案的用户直接访问必须抛出 403 ServiceException");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals(403, e.getCode().intValue());
|
||||
assertTrue(e.getMessage().contains("教师档案"));
|
||||
}
|
||||
|
||||
// 模拟直接访问学员中心接口
|
||||
try
|
||||
{
|
||||
mockIdentityService.assertStudentIdentity();
|
||||
fail("未绑定学员档案的用户直接访问必须抛出 403 ServiceException");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals(403, e.getCode().intValue());
|
||||
assertTrue(e.getMessage().contains("学员档案"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. 验证前端静态契约:
|
||||
* - selfServiceRoute.js 存在且实现隔离过滤
|
||||
* - permission.js 正确引入并执行自服务路由过滤
|
||||
* - request.js 包含 403 防风暴机制
|
||||
* - 教师与学员页面具备加载守卫
|
||||
*/
|
||||
@Test
|
||||
public void testFrontendRoleNavigationContracts() throws Exception
|
||||
{
|
||||
File utilsFile = new File(PROJECT_ROOT, "build_ui/src/utils/selfServiceRoute.js");
|
||||
assertTrue("selfServiceRoute.js 必须存在", utilsFile.exists());
|
||||
String utilsContent = new String(Files.readAllBytes(utilsFile.toPath()), "UTF-8");
|
||||
assertTrue("必须定义 isSelfServiceRouteAllowed", utilsContent.contains("isSelfServiceRouteAllowed"));
|
||||
assertTrue("必须限制 train_teacher", utilsContent.contains("train_teacher"));
|
||||
assertTrue("必须限制 train_student", utilsContent.contains("train_student"));
|
||||
|
||||
File permFile = new File(PROJECT_ROOT, "build_ui/src/store/modules/permission.js");
|
||||
assertTrue("permission.js 必须存在", permFile.exists());
|
||||
String permContent = new String(Files.readAllBytes(permFile.toPath()), "UTF-8");
|
||||
assertTrue("permission.js 必须导入 selfServiceRoute", permContent.contains("selfServiceRoute"));
|
||||
assertTrue("permission.js 必须在 filterAsyncRouter 中检查 roles", permContent.contains("isSelfServiceRouteAllowed"));
|
||||
|
||||
File reqFile = new File(PROJECT_ROOT, "build_ui/src/utils/request.js");
|
||||
assertTrue("request.js 必须存在", reqFile.exists());
|
||||
String reqContent = new String(Files.readAllBytes(reqFile.toPath()), "UTF-8");
|
||||
assertTrue("request.js 必须包含 403 防抖", reqContent.contains("lastForbiddenNotificationTime") && reqContent.contains("FORBIDDEN_DEBOUNCE_MS"));
|
||||
|
||||
File teacherVue = new File(PROJECT_ROOT, "build_ui/src/views/train/me/teacher/index.vue");
|
||||
String teacherContent = new String(Files.readAllBytes(teacherVue.toPath()), "UTF-8");
|
||||
assertTrue("教师页面必须在身份验证成功后再加载排课数据", teacherContent.contains("loadMyPlans") && !teacherContent.contains("initTeacherData() {\n this.loadProfile()\n this.loadMyPlans()"));
|
||||
|
||||
File studentVue = new File(PROJECT_ROOT, "build_ui/src/views/train/me/student/index.vue");
|
||||
String studentContent = new String(Files.readAllBytes(studentVue.toPath()), "UTF-8");
|
||||
assertTrue("学员页面必须在身份验证成功后再加载Tab数据", studentContent.contains("loadEnrollments") && !studentContent.contains("initStudentData() {\n this.loadProfile()\n this.loadEnrollments()"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getRouters } from '@/api/menu'
|
||||
import Layout from '@/layout/index'
|
||||
import ParentView from '@/components/ParentView'
|
||||
import InnerLink from '@/layout/components/InnerLink'
|
||||
import { isSelfServiceRouteAllowed } from '@/utils/selfServiceRoute'
|
||||
|
||||
const permission = {
|
||||
state: {
|
||||
@@ -30,16 +31,15 @@ const permission = {
|
||||
},
|
||||
actions: {
|
||||
// 生成路由
|
||||
GenerateRoutes({ commit }) {
|
||||
GenerateRoutes({ commit, rootGetters }) {
|
||||
return new Promise(resolve => {
|
||||
// 向后端请求路由数据
|
||||
getRouters().then(res => {
|
||||
//console.log("getRouters_res==============");
|
||||
//console.log(res);
|
||||
const roles = (rootGetters && rootGetters.roles) || []
|
||||
const sdata = JSON.parse(JSON.stringify(res.data))
|
||||
const rdata = JSON.parse(JSON.stringify(res.data))
|
||||
const sidebarRoutes = filterAsyncRouter(sdata)
|
||||
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
|
||||
const sidebarRoutes = filterAsyncRouter(sdata, false, false, roles)
|
||||
const rewriteRoutes = filterAsyncRouter(rdata, false, true, roles)
|
||||
const asyncRoutes = filterDynamicRoutes(dynamicRoutes);
|
||||
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
|
||||
router.addRoutes(asyncRoutes);
|
||||
@@ -55,8 +55,11 @@ const permission = {
|
||||
}
|
||||
|
||||
// 遍历后台传来的路由字符串,转换为组件对象
|
||||
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
|
||||
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false, roles = []) {
|
||||
return asyncRouterMap.filter(route => {
|
||||
if (!isSelfServiceRouteAllowed(route, roles)) {
|
||||
return false
|
||||
}
|
||||
if (type && route.children) {
|
||||
route.children = filterChildren(route.children)
|
||||
}
|
||||
@@ -73,7 +76,11 @@ function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
|
||||
}
|
||||
}
|
||||
if (route.children != null && route.children && route.children.length) {
|
||||
route.children = filterAsyncRouter(route.children, route, type)
|
||||
route.children = filterAsyncRouter(route.children, route, type, roles)
|
||||
// 如果作为wrapper的父路由在过滤后子路由为空,则一并移除
|
||||
if (route.children.length === 0) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
delete route['children']
|
||||
delete route['redirect']
|
||||
|
||||
@@ -65,12 +65,15 @@ service.interceptors.request.use(config => {
|
||||
Promise.reject(error)
|
||||
})
|
||||
|
||||
let lastForbiddenNotificationTime = 0
|
||||
const FORBIDDEN_DEBOUNCE_MS = 2500 // 2.5秒内对403权限拒绝只提醒一次
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(res => {
|
||||
// 未设置状态码则默认成功状态
|
||||
const code = res.data.code || 200
|
||||
// 获取错误信息
|
||||
const msg = errorCode[code] || res.data.msg || errorCode['default']
|
||||
// 获取错误信息,优先使用服务端返回的明确业务说明
|
||||
const msg = res.data.msg || errorCode[code] || errorCode['default']
|
||||
// 二进制数据则直接返回
|
||||
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
|
||||
return res.data
|
||||
@@ -88,6 +91,18 @@ service.interceptors.response.use(res => {
|
||||
})
|
||||
}
|
||||
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
|
||||
} else if (code === 403) {
|
||||
const forbiddenMsg = res.data.msg || errorCode['403'] || '当前操作没有权限'
|
||||
const now = Date.now()
|
||||
if (now - lastForbiddenNotificationTime > FORBIDDEN_DEBOUNCE_MS) {
|
||||
lastForbiddenNotificationTime = now
|
||||
Notification.error({
|
||||
title: '访问受限',
|
||||
message: forbiddenMsg,
|
||||
duration: 3500
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error(forbiddenMsg))
|
||||
} else if (code === 500) {
|
||||
Message({ message: msg, type: 'error' })
|
||||
return Promise.reject(new Error(msg))
|
||||
@@ -104,6 +119,19 @@ service.interceptors.response.use(res => {
|
||||
error => {
|
||||
console.log('err' + error)
|
||||
let { message } = error
|
||||
if (error.response && (error.response.status === 403 || (error.response.data && error.response.data.code === 403))) {
|
||||
const forbiddenMsg = (error.response.data && error.response.data.msg) || '当前操作没有权限'
|
||||
const now = Date.now()
|
||||
if (now - lastForbiddenNotificationTime > FORBIDDEN_DEBOUNCE_MS) {
|
||||
lastForbiddenNotificationTime = now
|
||||
Notification.error({
|
||||
title: '访问受限',
|
||||
message: forbiddenMsg,
|
||||
duration: 3500
|
||||
})
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (message === 'Network Error') {
|
||||
message = '后端接口连接异常'
|
||||
} else if (message.includes('timeout')) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 角色专属自服务路由与菜单权限过滤工具
|
||||
*
|
||||
* 业务规则:
|
||||
* 1. 教师工作台 (train/me/teacher/index):仅对具备 train_teacher 角色的用户可见。
|
||||
* 2. 学员中心 (train/me/student/index):仅对具备 train_student 角色的用户可见。
|
||||
* 3. 即使拥有超级管理员 (admin) 或培训管理员 (train_admin) 角色,无真实身份/角色时自服务菜单一律隐藏。
|
||||
* 4. 普通管理与业务菜单(如人员管理下的教师信息、学员信息等)不受本规则过滤。
|
||||
*/
|
||||
|
||||
export function isSelfServiceRouteAllowed(route, roles = []) {
|
||||
if (!route) return false
|
||||
const comp = route.component
|
||||
// 教师工作台专属自服务组件
|
||||
if (comp === 'train/me/teacher/index') {
|
||||
return Array.isArray(roles) && roles.includes('train_teacher')
|
||||
}
|
||||
// 学员中心专属自服务组件
|
||||
if (comp === 'train/me/student/index') {
|
||||
return Array.isArray(roles) && roles.includes('train_student')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度过滤路由树中的自服务专属路由
|
||||
*/
|
||||
export function filterSelfServiceRoutes(routes, roles = []) {
|
||||
if (!Array.isArray(routes)) return []
|
||||
return routes.reduce((acc, route) => {
|
||||
if (!isSelfServiceRouteAllowed(route, roles)) {
|
||||
return acc
|
||||
}
|
||||
const item = { ...route }
|
||||
if (item.children && Array.isArray(item.children)) {
|
||||
item.children = filterSelfServiceRoutes(item.children, roles)
|
||||
// 如果作为父容器wrapper在子路由被完全过滤后变为空,则移除该父容器
|
||||
if (item.children.length === 0 && (item.component === 'Layout' || !item.component || item.path === '/')) {
|
||||
return acc
|
||||
}
|
||||
}
|
||||
acc.push(item)
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
@@ -374,12 +374,6 @@ export default {
|
||||
methods: {
|
||||
initStudentData() {
|
||||
this.loadProfile()
|
||||
this.loadEnrollments()
|
||||
this.loadTimetable()
|
||||
this.loadReport()
|
||||
this.loadAttendance()
|
||||
this.loadDuration()
|
||||
this.loadPortrait()
|
||||
},
|
||||
loadProfile() {
|
||||
this.profileLoading = true
|
||||
@@ -388,6 +382,13 @@ export default {
|
||||
if (res.code === 200) {
|
||||
this.studentProfile = res.data
|
||||
this.identityErrorMsg = ''
|
||||
// 学员身份校验通过后,再并发加载学业与选课各Tab数据
|
||||
this.loadEnrollments()
|
||||
this.loadTimetable()
|
||||
this.loadReport()
|
||||
this.loadAttendance()
|
||||
this.loadDuration()
|
||||
this.loadPortrait()
|
||||
} else if (res.code === 403) {
|
||||
this.identityErrorMsg = res.msg || '当前登录账号未绑定任何学员档案,请联系管理员核对分配。'
|
||||
}
|
||||
|
||||
@@ -309,8 +309,6 @@ export default {
|
||||
methods: {
|
||||
initTeacherData() {
|
||||
this.loadProfile()
|
||||
this.loadMyPlans()
|
||||
this.loadMyTimetable()
|
||||
},
|
||||
loadProfile() {
|
||||
this.profileLoading = true
|
||||
@@ -319,6 +317,9 @@ export default {
|
||||
if (res.code === 200) {
|
||||
this.teacherProfile = res.data
|
||||
this.identityErrorMsg = ''
|
||||
// 教师身份校验通过后,再加载任教班级与课表日程
|
||||
this.loadMyPlans()
|
||||
this.loadMyTimetable()
|
||||
} else if (res.code === 403) {
|
||||
this.identityErrorMsg = res.msg || '当前登录账号未绑定任何教师档案,请联系管理员分配绑定。'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert';
|
||||
import { isSelfServiceRouteAllowed, filterSelfServiceRoutes } from '../../src/utils/selfServiceRoute.js';
|
||||
|
||||
console.log('=== Running Role Navigation Visibility & Self-Service Isolation Tests ===');
|
||||
|
||||
// 1. 测试 isSelfServiceRouteAllowed 基础路由拦截
|
||||
{
|
||||
const teacherWorkbench = { path: 'teacher', component: 'train/me/teacher/index', meta: { title: '教师工作台' } };
|
||||
const studentCenter = { path: 'student', component: 'train/me/student/index', meta: { title: '学员中心' } };
|
||||
const teacherInfo = { path: 'teacher', component: 'train/teacher/index', meta: { title: '教师信息' } };
|
||||
const studentInfo = { path: 'student', component: 'train/student/index', meta: { title: '学员信息' } };
|
||||
const portrait = { path: 'portrait', component: 'train/portrait/index', meta: { title: '学员画像' } };
|
||||
const system = { path: 'system', component: 'Layout', meta: { title: '系统管理' } };
|
||||
|
||||
// A. 超级管理员 (admin)
|
||||
const adminRoles = ['admin'];
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, adminRoles), false, 'admin 不能看到教师工作台');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, adminRoles), false, 'admin 不能看到学员中心');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(teacherInfo, adminRoles), true, 'admin 必须保留人员管理下的教师信息');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(studentInfo, adminRoles), true, 'admin 必须保留人员管理下的学员信息');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(portrait, adminRoles), true, 'admin 必须保留学员画像');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(system, adminRoles), true, 'admin 必须保留系统管理');
|
||||
console.log('✔ PASS: admin correctly blocks self-service routes while preserving management routes');
|
||||
|
||||
// B. 培训管理员 (train_admin)
|
||||
const trainAdminRoles = ['train_admin'];
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, trainAdminRoles), false, 'train_admin 不能看到教师工作台');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, trainAdminRoles), false, 'train_admin 不能看到学员中心');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(teacherInfo, trainAdminRoles), true, 'train_admin 必须保留人员管理下的教师信息');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(studentInfo, trainAdminRoles), true, 'train_admin 必须保留人员管理下的学员信息');
|
||||
console.log('✔ PASS: train_admin correctly blocks self-service routes while preserving business routes');
|
||||
|
||||
// C. 教师 (train_teacher)
|
||||
const teacherRoles = ['train_teacher'];
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, teacherRoles), true, 'teacher 可以看到教师工作台');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, teacherRoles), false, 'teacher 不能看到学员中心');
|
||||
console.log('✔ PASS: teacher sees teacher workbench only');
|
||||
|
||||
// D. 学员 (train_student)
|
||||
const studentRoles = ['train_student'];
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, studentRoles), false, 'student 不能看到教师工作台');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, studentRoles), true, 'student 可以看到学员中心');
|
||||
console.log('✔ PASS: student sees student center only');
|
||||
|
||||
// E. 双重身份 (train_teacher + train_student)
|
||||
const dualRoles = ['train_teacher', 'train_student'];
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, dualRoles), true, '具备教师身份时教师工作台可见');
|
||||
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, dualRoles), true, '具备学员身份时学员中心可见');
|
||||
console.log('✔ PASS: dual roles allow both self-service routes based on respective identities');
|
||||
}
|
||||
|
||||
// 2. 测试完整的菜单树过滤与 Wrapper 清理
|
||||
{
|
||||
const mockMenuTree = [
|
||||
{
|
||||
path: '/train',
|
||||
component: 'Layout',
|
||||
meta: { title: '人员管理' },
|
||||
children: [
|
||||
{ path: 'teacher', component: 'train/teacher/index', meta: { title: '教师信息' } },
|
||||
{ path: 'student', component: 'train/student/index', meta: { title: '学员信息' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: 'Layout',
|
||||
meta: null,
|
||||
children: [
|
||||
{ path: 'teacher', component: 'train/me/teacher/index', meta: { title: '教师工作台' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: 'Layout',
|
||||
meta: null,
|
||||
children: [
|
||||
{ path: 'student', component: 'train/me/student/index', meta: { title: '学员中心' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
component: 'Layout',
|
||||
meta: { title: '系统管理' },
|
||||
children: [
|
||||
{ path: 'user', component: 'system/user/index', meta: { title: '用户管理' } }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 管理员视角下的路由过滤
|
||||
const adminFiltered = filterSelfServiceRoutes(mockMenuTree, ['admin']);
|
||||
assert.strictEqual(adminFiltered.length, 2, '管理员过滤后仅保留人员管理和系统管理两个根节点');
|
||||
assert.strictEqual(adminFiltered[0].meta.title, '人员管理');
|
||||
assert.strictEqual(adminFiltered[0].children.length, 2, '人员管理下的教师信息与学员信息完好');
|
||||
assert.strictEqual(adminFiltered[1].meta.title, '系统管理');
|
||||
console.log('✔ PASS: admin menu tree completely eliminates empty wrappers for self-service routes');
|
||||
|
||||
// 教师视角下的路由过滤
|
||||
const teacherFiltered = filterSelfServiceRoutes(mockMenuTree, ['train_teacher']);
|
||||
const teacherTitles = teacherFiltered.map(r => (r.meta ? r.meta.title : (r.children && r.children[0] ? r.children[0].meta.title : '')));
|
||||
assert.strictEqual(teacherTitles.includes('教师工作台'), true, '教师视角保留教师工作台');
|
||||
assert.strictEqual(teacherTitles.includes('学员中心'), false, '教师视角不包含学员中心');
|
||||
console.log('✔ PASS: teacher menu tree retains teacher workbench and excludes student center');
|
||||
|
||||
// 学员视角下的路由过滤
|
||||
const studentFiltered = filterSelfServiceRoutes(mockMenuTree, ['train_student']);
|
||||
const studentTitles = studentFiltered.map(r => (r.meta ? r.meta.title : (r.children && r.children[0] ? r.children[0].meta.title : '')));
|
||||
assert.strictEqual(studentTitles.includes('教师工作台'), false, '学员视角不包含教师工作台');
|
||||
assert.strictEqual(studentTitles.includes('学员中心'), true, '学员视角保留学员中心');
|
||||
console.log('✔ PASS: student menu tree retains student center and excludes teacher workbench');
|
||||
}
|
||||
|
||||
// 3. 测试 403 权限异常防抖逻辑
|
||||
{
|
||||
const FORBIDDEN_DEBOUNCE_MS = 2500;
|
||||
let lastNotificationTime = 0;
|
||||
let notificationCount = 0;
|
||||
|
||||
function simulate403(currentTime) {
|
||||
if (currentTime - lastNotificationTime > FORBIDDEN_DEBOUNCE_MS) {
|
||||
lastNotificationTime = currentTime;
|
||||
notificationCount++;
|
||||
return true; // 弹窗触发
|
||||
}
|
||||
return false; // 防抖拦截
|
||||
}
|
||||
|
||||
// 模拟并发调用 6 个接口(如页面初始化时的多 Tab 数据并发)
|
||||
const t0 = 100000;
|
||||
assert.strictEqual(simulate403(t0), true, '首个 403 必须正常弹窗通知');
|
||||
assert.strictEqual(simulate403(t0 + 10), false, '并发第2个请求防抖拦截');
|
||||
assert.strictEqual(simulate403(t0 + 50), false, '并发第3个请求防抖拦截');
|
||||
assert.strictEqual(simulate403(t0 + 100), false, '并发第4个请求防抖拦截');
|
||||
assert.strictEqual(simulate403(t0 + 500), false, '500ms后仍处于防抖窗口,拦截');
|
||||
assert.strictEqual(simulate403(t0 + 2400), false, '2400ms后仍处于防抖窗口,拦截');
|
||||
assert.strictEqual(notificationCount, 1, '风暴期间只弹窗一次');
|
||||
|
||||
// 超过 2500ms 后的新操作
|
||||
const t1 = t0 + 2600;
|
||||
assert.strictEqual(simulate403(t1), true, '超过 2500ms 后用户新操作被拒应再次弹窗');
|
||||
assert.strictEqual(notificationCount, 2);
|
||||
console.log('✔ PASS: 403 notification debouncing prevents toast storm while preserving subsequent alerts');
|
||||
}
|
||||
|
||||
console.log('=== All Role Navigation & Debounce Unit Tests Passed ===');
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
角色专属自服务菜单可见性与未授权访问浏览器矩阵验证
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
FRONTEND_URL = "http://127.0.0.1:1024"
|
||||
BACKEND_URL = "http://127.0.0.1:8096"
|
||||
SCREENSHOT_DIR = os.path.abspath("docs/reports/preview-role-navigation-20260922/screenshots")
|
||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
||||
|
||||
def load_qa_env():
|
||||
env = {}
|
||||
env_file = os.path.expanduser("~/.railwaysystem-qa.env")
|
||||
if os.path.exists(env_file):
|
||||
with open(env_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip().strip('"')
|
||||
return env
|
||||
|
||||
def get_captcha_code_from_redis(uuid):
|
||||
res = subprocess.check_output(['redis-cli', 'get', f'captcha_codes:{uuid}']).decode('utf-8').strip()
|
||||
return res.strip('"')
|
||||
|
||||
class RoleNavValidator:
|
||||
def __init__(self):
|
||||
self.env = load_qa_env()
|
||||
self.qa_password = self.env.get("QA_ADMIN_PASSWORD")
|
||||
if not self.qa_password:
|
||||
raise RuntimeError("QA_ADMIN_PASSWORD is required in ~/.railwaysystem-qa.env")
|
||||
self.results = {}
|
||||
|
||||
def log(self, msg):
|
||||
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
def browser_login(self, page, username):
|
||||
self.log(f"Logging in as {username}...")
|
||||
page.context.clear_cookies()
|
||||
try:
|
||||
page.goto(f"{FRONTEND_URL}/login", wait_until="domcontentloaded")
|
||||
page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
except Exception:
|
||||
page.goto(f"{FRONTEND_URL}/login", wait_until="domcontentloaded")
|
||||
|
||||
page.wait_for_selector('input[placeholder="账号"]', timeout=10000)
|
||||
|
||||
last_uuid = [None]
|
||||
def handle_res(res):
|
||||
if 'captchaImage' in res.url:
|
||||
try:
|
||||
last_uuid[0] = res.json().get('uuid')
|
||||
except Exception:
|
||||
pass
|
||||
page.on('response', handle_res)
|
||||
|
||||
for _ in range(50):
|
||||
if last_uuid[0]:
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
uuid = last_uuid[0]
|
||||
code = get_captcha_code_from_redis(uuid)
|
||||
page.fill('input[placeholder="账号"]', username)
|
||||
page.fill('input[placeholder="密码"]', self.qa_password)
|
||||
page.fill('input[placeholder="验证码"]', code)
|
||||
|
||||
page.click('button:has-text("登 录")')
|
||||
for _ in range(40):
|
||||
page.wait_for_timeout(250)
|
||||
if '/login' not in page.url:
|
||||
break
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
def get_sidebar_menu_titles(self, page):
|
||||
page.wait_for_selector(".sidebar-container", timeout=10000)
|
||||
# 获取全部可见或DOM中的菜单项标题
|
||||
items = page.evaluate("""() => {
|
||||
const elements = document.querySelectorAll('.sidebar-container .el-menu-item, .sidebar-container .el-submenu__title');
|
||||
return Array.from(elements).map(el => el.innerText.trim()).filter(t => t.length > 0);
|
||||
}""")
|
||||
return items
|
||||
|
||||
def run(self):
|
||||
self.log("==================================================")
|
||||
self.log("STARTING ROLE NAVIGATION BROWSER MATRIX VALIDATION")
|
||||
self.log("==================================================")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel='chrome', headless=True)
|
||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = context.new_page()
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 1. 验证 admin
|
||||
# ----------------------------------------------------
|
||||
self.log(">>> [1/4] Validating admin ...")
|
||||
self.browser_login(page, "admin")
|
||||
admin_menus = self.get_sidebar_menu_titles(page)
|
||||
self.log(f"Admin Sidebar Menus: {admin_menus}")
|
||||
|
||||
admin_teacher_hidden = "教师工作台" not in admin_menus
|
||||
admin_student_hidden = "学员中心" not in admin_menus
|
||||
self.log(f"Admin: 教师工作台 HIDDEN = {admin_teacher_hidden}")
|
||||
self.log(f"Admin: 学员中心 HIDDEN = {admin_student_hidden}")
|
||||
|
||||
# 验证管理类菜单回归
|
||||
admin_people_visible = any("人员管理" in m for m in admin_menus)
|
||||
admin_portrait_visible = any("学员画像" in m for m in admin_menus)
|
||||
admin_system_visible = any("系统管理" in m for m in admin_menus)
|
||||
self.log(f"Admin: 人员管理保留 = {admin_people_visible}, 学员画像保留 = {admin_portrait_visible}, 系统管理保留 = {admin_system_visible}")
|
||||
|
||||
shot_admin_sidebar = os.path.join(SCREENSHOT_DIR, "admin_sidebar_menu.png")
|
||||
page.screenshot(path=shot_admin_sidebar)
|
||||
|
||||
# 测试直接未授权访问 /teacher
|
||||
self.log("Admin direct navigation test to /teacher ...")
|
||||
page.goto(f"{FRONTEND_URL}/teacher", wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(2000)
|
||||
admin_direct_teacher_url = page.url
|
||||
admin_teacher_toast_count = page.evaluate("() => document.querySelectorAll('.el-notification').length")
|
||||
self.log(f"Admin /teacher URL: {admin_direct_teacher_url}, Notification Toast Count: {admin_teacher_toast_count}")
|
||||
shot_admin_direct_teacher = os.path.join(SCREENSHOT_DIR, "admin_direct_teacher_blocked.png")
|
||||
page.screenshot(path=shot_admin_direct_teacher)
|
||||
|
||||
# 测试直接未授权访问 /student
|
||||
self.log("Admin direct navigation test to /student ...")
|
||||
page.goto(f"{FRONTEND_URL}/student", wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(2000)
|
||||
admin_direct_student_url = page.url
|
||||
admin_student_toast_count = page.evaluate("() => document.querySelectorAll('.el-notification').length")
|
||||
self.log(f"Admin /student URL: {admin_direct_student_url}, Notification Toast Count: {admin_student_toast_count}")
|
||||
shot_admin_direct_student = os.path.join(SCREENSHOT_DIR, "admin_direct_student_blocked.png")
|
||||
page.screenshot(path=shot_admin_direct_student)
|
||||
|
||||
assert admin_teacher_hidden, "Admin 菜单中不得显示【教师工作台】"
|
||||
assert admin_student_hidden, "Admin 菜单中不得显示【学员中心】"
|
||||
assert admin_people_visible, "Admin 菜单必须保留【人员管理】"
|
||||
assert admin_portrait_visible, "Admin 菜单必须保留【学员画像】"
|
||||
assert admin_teacher_toast_count <= 1, f"Admin 直接访问 /teacher 产生的 Toast 必须 <= 1,当前为 {admin_teacher_toast_count}"
|
||||
assert admin_student_toast_count <= 1, f"Admin 直接访问 /student 产生的 Toast 必须 <= 1,当前为 {admin_student_toast_count}"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 2. 验证 qa_train_admin
|
||||
# ----------------------------------------------------
|
||||
self.log(">>> [2/4] Validating qa_train_admin ...")
|
||||
self.browser_login(page, "qa_train_admin")
|
||||
train_admin_menus = self.get_sidebar_menu_titles(page)
|
||||
self.log(f"Train Admin Sidebar Menus: {train_admin_menus}")
|
||||
|
||||
train_admin_teacher_hidden = "教师工作台" not in train_admin_menus
|
||||
train_admin_student_hidden = "学员中心" not in train_admin_menus
|
||||
train_admin_people_visible = any("人员管理" in m for m in train_admin_menus)
|
||||
self.log(f"Train Admin: 教师工作台 HIDDEN = {train_admin_teacher_hidden}")
|
||||
self.log(f"Train Admin: 学员中心 HIDDEN = {train_admin_student_hidden}")
|
||||
self.log(f"Train Admin: 人员管理保留 = {train_admin_people_visible}")
|
||||
|
||||
shot_train_admin_sidebar = os.path.join(SCREENSHOT_DIR, "train_admin_sidebar_menu.png")
|
||||
page.screenshot(path=shot_train_admin_sidebar)
|
||||
|
||||
assert train_admin_teacher_hidden, "Train Admin 菜单中不得显示【教师工作台】"
|
||||
assert train_admin_student_hidden, "Train Admin 菜单中不得显示【学员中心】"
|
||||
assert train_admin_people_visible, "Train Admin 菜单必须保留【人员管理】业务菜单"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 3. 验证 qa_teacher_a
|
||||
# ----------------------------------------------------
|
||||
self.log(">>> [3/4] Validating qa_teacher_a ...")
|
||||
self.browser_login(page, "qa_teacher_a")
|
||||
teacher_menus = self.get_sidebar_menu_titles(page)
|
||||
self.log(f"Teacher Sidebar Menus: {teacher_menus}")
|
||||
|
||||
teacher_workbench_visible = any("教师工作台" in m for m in teacher_menus)
|
||||
teacher_student_hidden = "学员中心" not in teacher_menus
|
||||
self.log(f"Teacher: 教师工作台 VISIBLE = {teacher_workbench_visible}")
|
||||
self.log(f"Teacher: 学员中心 HIDDEN = {teacher_student_hidden}")
|
||||
|
||||
assert teacher_workbench_visible, "Teacher 用户必须显示【教师工作台】"
|
||||
assert teacher_student_hidden, "Teacher 用户不得显示【学员中心】"
|
||||
|
||||
# 访问并验证教师工作台页面正常加载
|
||||
self.log("Visiting Teacher Workbench page ...")
|
||||
page.goto(f"{FRONTEND_URL}/teacher", wait_until="networkidle")
|
||||
page.wait_for_timeout(1500)
|
||||
teacher_page_content = page.content()
|
||||
teacher_page_loaded_ok = ("QA_R2_讲师_01" in teacher_page_content or "教师工作台" in teacher_page_content or "任教班级" in teacher_page_content)
|
||||
self.log(f"Teacher Workbench Page loaded OK: {teacher_page_loaded_ok}")
|
||||
shot_teacher_page = os.path.join(SCREENSHOT_DIR, "teacher_workbench_loaded.png")
|
||||
page.screenshot(path=shot_teacher_page)
|
||||
assert teacher_page_loaded_ok, "教师工作台页面内容必须正常加载"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 4. 验证 qa_student_a
|
||||
# ----------------------------------------------------
|
||||
self.log(">>> [4/4] Validating qa_student_a ...")
|
||||
self.browser_login(page, "qa_student_a")
|
||||
student_menus = self.get_sidebar_menu_titles(page)
|
||||
self.log(f"Student Sidebar Menus: {student_menus}")
|
||||
|
||||
student_center_visible = any("学员中心" in m for m in student_menus)
|
||||
student_teacher_hidden = "教师工作台" not in student_menus
|
||||
self.log(f"Student: 学员中心 VISIBLE = {student_center_visible}")
|
||||
self.log(f"Student: 教师工作台 HIDDEN = {student_teacher_hidden}")
|
||||
|
||||
assert student_center_visible, "Student 用户必须显示【学员中心】"
|
||||
assert student_teacher_hidden, "Student 用户不得显示【教师工作台】"
|
||||
|
||||
# 访问并验证学员中心页面正常加载
|
||||
self.log("Visiting Student Center page ...")
|
||||
page.goto(f"{FRONTEND_URL}/student", wait_until="networkidle")
|
||||
page.wait_for_timeout(1500)
|
||||
student_page_content = page.content()
|
||||
student_page_loaded_ok = ("QA_R2_学员_001" in student_page_content or "个人中心" in student_page_content or "参训记录" in student_page_content or "学员姓名" in student_page_content)
|
||||
self.log(f"Student Center Page loaded OK: {student_page_loaded_ok}")
|
||||
shot_student_page = os.path.join(SCREENSHOT_DIR, "student_center_loaded.png")
|
||||
page.screenshot(path=shot_student_page)
|
||||
assert student_page_loaded_ok, "学员中心页面内容必须正常加载"
|
||||
|
||||
browser.close()
|
||||
|
||||
self.log("==================================================")
|
||||
self.log("ALL 4 ROLES BROWSER MATRIX VALIDATIONS PASSED!")
|
||||
self.log("==================================================")
|
||||
|
||||
if __name__ == "__main__":
|
||||
validator = RoleNavValidator()
|
||||
validator.run()
|
||||
Reference in new issue
Block a user