Files
railwaysystem/build_ui/tests/unit/selfServiceIdentityFlags.spec.mjs
T
wangzhibiaoandCursor b7eab13a83 修复权限:首页按真实业务身份选择工作台
菜单层与 API 层已按真实业务身份限制,但首页只看 role_key,导致有 train_teacher
角色却无教师档案绑定的账号先进入教师工作台再被 API 拒绝。

- 身份服务新增 hasActiveTeacherIdentity / hasActiveStudentIdentity,
  语义与原菜单过滤完全一致,异常吞掉返回 false
- SysLoginController 抽出 resolveSelfServiceIdentity 作为唯一事实源,
  自服务菜单过滤与 getInfo 新增的 selfService 标志共用该方法,不新增第三套规则
- 前端 store 新增 selfService 标志,缺失时默认全 false 并严格布尔化,登出时重置
- 自服务路由过滤与首页渲染改为按身份标志判断,且与角色授权取交集,与菜单层口径一致
- 有角色无业务档案的账号落到普通首页,Dashboard 组件根本不创建,
  不会发起任何自服务 API 请求,页面给出明确的未绑定提示

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 17:04:26 +08:00

178 lines
11 KiB
JavaScript

import assert from 'node:assert';
import { isSelfServiceRouteAllowed, filterSelfServiceRoutes } from '../../src/utils/selfServiceRoute.js';
console.log('=== Running Self-Service Identity Flag Tests (FIX-05) ===');
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: '系统管理' } };
// 1. 新签名:依据真实业务身份标志判定,而不是 role_key
{
const noIdentity = { teacherWorkbench: false, studentCenter: false };
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, noIdentity), false, '无教师业务身份时教师工作台必须隐藏');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, noIdentity), false, '无学员业务身份时学员中心必须隐藏');
assert.strictEqual(isSelfServiceRouteAllowed(teacherInfo, noIdentity), true, '人员管理下的教师信息不受自服务过滤影响');
assert.strictEqual(isSelfServiceRouteAllowed(studentInfo, noIdentity), true, '人员管理下的学员信息不受自服务过滤影响');
assert.strictEqual(isSelfServiceRouteAllowed(portrait, noIdentity), true, '学员画像不受自服务过滤影响');
assert.strictEqual(isSelfServiceRouteAllowed(system, noIdentity), true, '系统管理不受自服务过滤影响');
console.log('✔ PASS: no business identity hides both self-service entries while keeping management routes');
const teacherIdentity = { teacherWorkbench: true, studentCenter: false };
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, teacherIdentity), true, '有教师业务身份时教师工作台可见');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, teacherIdentity), false, '教师身份不得看到学员中心');
console.log('✔ PASS: teacher identity sees teacher workbench only');
const studentIdentity = { teacherWorkbench: false, studentCenter: true };
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, studentIdentity), false, '学员身份不得看到教师工作台');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, studentIdentity), true, '有学员业务身份时学员中心可见');
console.log('✔ PASS: student identity sees student center only');
}
// 2. Fail closed:标志缺失 / 非布尔真值一律按无身份处理
{
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench), false, '未传身份标志时必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter), false, '未传身份标志时必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, {}), false, '空标志对象必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, null), false, 'null 标志必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, { teacherWorkbench: undefined }), false, 'undefined 标志必须 fail closed');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, { teacherWorkbench: 'true' }), false, '非布尔真值不得放行');
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, { teacherWorkbench: 1 }), false, '数字 1 不得放行');
assert.strictEqual(isSelfServiceRouteAllowed(null, { teacherWorkbench: true }), false, '空路由必须返回 false');
console.log('✔ PASS: missing or non-boolean identity flags fail closed');
}
// 3. 关键回归:只有 role_key 没有业务档案绑定的账号(qa_role_nobody 场景)必须被拦截
{
// 旧实现传入 ['train_teacher'] 角色数组即可放行,新实现必须拒绝
assert.strictEqual(isSelfServiceRouteAllowed(teacherWorkbench, ['train_teacher']), false, '角色数组不得再被当作身份凭据');
assert.strictEqual(isSelfServiceRouteAllowed(studentCenter, ['train_student']), false, '角色数组不得再被当作身份凭据');
console.log('✔ PASS: role_key alone can no longer unlock self-service routes (qa_role_nobody regression guard)');
}
// 4. 菜单树深度过滤与空 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 titlesOf = (routes) => routes.map(r => (r.meta ? r.meta.title : (r.children && r.children[0] ? r.children[0].meta.title : '')));
// A. 管理员 / 有角色无绑定账号:两个自服务 wrapper 都被清理
const noIdentityFiltered = filterSelfServiceRoutes(mockMenuTree, { teacherWorkbench: false, studentCenter: false });
assert.strictEqual(noIdentityFiltered.length, 2, '无业务身份时仅保留人员管理与系统管理');
assert.strictEqual(noIdentityFiltered[0].meta.title, '人员管理');
assert.strictEqual(noIdentityFiltered[0].children.length, 2, '人员管理下的教师信息与学员信息完好');
assert.strictEqual(noIdentityFiltered[1].meta.title, '系统管理');
console.log('✔ PASS: identity-less user gets both self-service wrappers pruned');
// B. 教师身份
const teacherTitles = titlesOf(filterSelfServiceRoutes(mockMenuTree, { teacherWorkbench: true, studentCenter: false }));
assert.strictEqual(teacherTitles.includes('教师工作台'), true, '教师身份保留教师工作台');
assert.strictEqual(teacherTitles.includes('学员中心'), false, '教师身份不含学员中心');
// C. 学员身份
const studentTitles = titlesOf(filterSelfServiceRoutes(mockMenuTree, { teacherWorkbench: false, studentCenter: true }));
assert.strictEqual(studentTitles.includes('教师工作台'), false, '学员身份不含教师工作台');
assert.strictEqual(studentTitles.includes('学员中心'), true, '学员身份保留学员中心');
// D. 未传标志时整棵树的自服务入口都被清理
const defaultTitles = titlesOf(filterSelfServiceRoutes(mockMenuTree));
assert.strictEqual(defaultTitles.includes('教师工作台'), false, '默认参数下教师工作台必须隐藏');
assert.strictEqual(defaultTitles.includes('学员中心'), false, '默认参数下学员中心必须隐藏');
assert.strictEqual(filterSelfServiceRoutes(null).length, 0, '非数组输入返回空数组');
console.log('✔ PASS: menu tree filtering follows identity flags in all branches');
}
// 5. 首页面板渲染判定:角色授权 ∩ 真实业务身份,并在缺失绑定时给出提示
{
function resolveHomePanel(roles, selfService) {
const flags = selfService || {};
const isAdmin = roles.includes('admin') || roles.includes('train_admin');
const hasTeacherRole = roles.includes('train_teacher');
const hasStudentRole = roles.includes('train_student');
const hasTeacherIdentity = flags.teacherWorkbench === true;
const hasStudentIdentity = flags.studentCenter === true;
const hasRoleConflict = !isAdmin && hasTeacherRole && hasStudentRole;
if (isAdmin) return { panel: 'admin', missingBinding: false };
if (hasRoleConflict) return { panel: 'conflict', missingBinding: false };
if (hasTeacherRole && !hasStudentRole && hasTeacherIdentity) return { panel: 'teacher', missingBinding: false };
if (hasStudentRole && !hasTeacherRole && hasStudentIdentity) return { panel: 'student', missingBinding: false };
return {
panel: 'fallback',
missingBinding: (hasTeacherRole && !hasTeacherIdentity) || (hasStudentRole && !hasStudentIdentity)
};
}
const bound = { teacherWorkbench: true, studentCenter: false };
const boundStudent = { teacherWorkbench: false, studentCenter: true };
const none = { teacherWorkbench: false, studentCenter: false };
// 不得回归:admin / train_admin 行为不变
assert.deepStrictEqual(resolveHomePanel(['admin'], none), { panel: 'admin', missingBinding: false }, 'admin 仍进管理驾驶舱');
assert.deepStrictEqual(resolveHomePanel(['train_admin'], none), { panel: 'admin', missingBinding: false }, 'train_admin 仍进管理驾驶舱');
// 不得回归:正常绑定的教师 / 学员行为不变
assert.strictEqual(resolveHomePanel(['train_teacher'], bound).panel, 'teacher', '绑定教师仍进教师工作台');
assert.strictEqual(resolveHomePanel(['train_student'], boundStudent).panel, 'student', '绑定学员仍进学员中心');
// 核心缺陷修复:qa_role_nobody(有 train_teacher 角色、无教师档案)
const roleNobody = resolveHomePanel(['train_teacher'], none);
assert.strictEqual(roleNobody.panel, 'fallback', '有角色无绑定不得渲染教师工作台');
assert.strictEqual(roleNobody.missingBinding, true, '有角色无绑定必须提示未关联业务档案');
const studentRoleNobody = resolveHomePanel(['train_student'], none);
assert.strictEqual(studentRoleNobody.panel, 'fallback', '有角色无绑定不得渲染学员中心');
assert.strictEqual(studentRoleNobody.missingBinding, true, '有角色无绑定必须提示未关联业务档案');
// 无培训业务角色的普通账号不应看到绑定缺失提示
const plainUser = resolveHomePanel(['common'], none);
assert.strictEqual(plainUser.panel, 'fallback', '普通账号进入兜底首页');
assert.strictEqual(plainUser.missingBinding, false, '无培训角色账号不显示绑定缺失提示');
// 角色互斥冲突分支保持原有行为,且不显示绑定缺失提示
const conflict = resolveHomePanel(['train_teacher', 'train_student'], none);
assert.strictEqual(conflict.panel, 'conflict', '角色互斥冲突分支保持原有告警');
assert.strictEqual(conflict.missingBinding, false, '冲突告警场景不叠加绑定缺失提示');
console.log('✔ PASS: home panel selection = role grant ∩ business identity, with missing-binding hint');
}
console.log('=== All Self-Service Identity Flag Unit Tests Passed ===');