feat(authz): 重写权限模板分配与班级授权边界

拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
LoveChengke committed 2026-10-31 09:40:23 +08:00
1 parent 682818dd4a
commit 66be3a63b1
115 files changed
+4463 -2747

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
/** 只创建独立临时班级;验证任课教师的只读详情不能显示班主任维护入口。 */
async function checkClassRelations(win, options, record) {
const base = `${new URL(options.url).origin}/api`;
async function request(route, token, method = 'GET', body) {
const response = await fetch(`${base}${route}`, {
method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
const result = await response.json();
return { status: response.status, data: result.data, message: result.message };
}
async function data(route, token, method, body) {
const result = await request(route, token, method, body);
if (result.status >= 400) throw new Error(`${route}: ${result.status} ${result.message}`);
return result.data;
}
async function wait(expression) {
const end = Date.now() + 8000;
while (Date.now() < end) {
if (await win.webContents.executeJavaScript(expression)) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('班级详情未就绪');
}
const teacher = await data('/auth/login', null, 'POST', {
username: options.username,
password: options.password,
});
const admin = await data('/auth/login', null, 'POST', {
username: options.adminUsername,
password: options.adminPassword,
});
let other, student;
try {
other = await data('/classes', admin.token, 'POST', {
grade: '验证',
enrollmentYear: 2098,
classIndex: 1 + Math.floor(Math.random() * 99),
teacherId: admin.user.id,
code: `REL${Date.now().toString(36).toUpperCase()}`,
});
await data(`/classes/${other.id}/subject-teachers`, admin.token, 'PUT', {
assignments: [{ subjectName: '数学', teacherId: teacher.user.id }],
});
student = await data('/students', admin.token, 'POST', {
classId: other.id,
studentNo: `REL${Date.now()}`,
name: '独立关系验证学生',
});
const detail = await data(`/classes/${other.id}`, teacher.token);
const statuses = await Promise.all([
request(`/students/${student.id}`, teacher.token, 'PATCH', { name: student.name }),
request(`/classes/${other.id}/settings`, teacher.token, 'PATCH', { termWeeks: detail.termWeeks }),
request(`/classes/${other.id}/student-grade-query`, teacher.token, 'PATCH', {
enabled: detail.studentGradeQueryEnabled,
}),
request(`/courses/${detail.courses[0].id}`, teacher.token, 'DELETE'),
]);
record(
'任课教师跨班修改学生、学期设置、成绩开关及删除课程均返回 403',
statuses.every((result) => result.status === 403),
statuses.map((result) => result.status).join('/'),
);
await win.loadURL(`${new URL(options.url).origin}/classes`);
await wait(
`Array.from(document.querySelectorAll('.el-table__row')).some(row => row.textContent.includes(${JSON.stringify(other.name)}))`,
);
await win.webContents.executeJavaScript(
`Array.from(document.querySelectorAll('.el-table__row')).find(row => row.textContent.includes(${JSON.stringify(other.name)})).querySelector('button').click()`,
);
await wait(
`Boolean(document.querySelector('.el-drawer .el-tabs')) && !document.querySelector('.el-drawer .el-loading-mask')`,
);
const peer = await win.webContents.executeJavaScript(`(() => {
const drawer = document.querySelector('.el-drawer');
const pane = name => drawer.querySelector('#pane-' + name);
const writes = Array.from(pane('students').querySelectorAll('button')).filter(button => ['添加学生', '移出'].includes(button.textContent.trim()));
return { studentsVisible: pane('students').textContent.includes('独立关系验证学生'), studentWrites: writes.length, courseDeletes: Array.from(pane('courses').querySelectorAll('button')).filter(button => button.textContent.trim() === '删除').length, courseCreate: Boolean(pane('courses').querySelector('input')), gradeQueryDisabled: pane('settings').querySelector('.el-switch')?.classList.contains('is-disabled') };
})()`);
record(
'任课教师可查看别班详情,名单/课程写入口隐藏且成绩开关只读',
peer.studentsVisible &&
peer.studentWrites === 0 &&
peer.courseDeletes === 0 &&
!peer.courseCreate &&
peer.gradeQueryDisabled,
JSON.stringify(peer),
);
const classes = await data('/classes', teacher.token);
const own = classes.find((row) => row.teacher?.id === teacher.user.id);
if (!own) throw new Error('缺少独立的班主任班级');
await win.loadURL(`${new URL(options.url).origin}/classes`);
await wait(
`Array.from(document.querySelectorAll('.el-table__row')).some(row => row.textContent.includes(${JSON.stringify(own.name)}))`,
);
await win.webContents.executeJavaScript(
`Array.from(document.querySelectorAll('.el-table__row')).find(row => row.textContent.includes(${JSON.stringify(own.name)})).querySelector('button').click()`,
);
await wait(
`Boolean(document.querySelector('.el-drawer .el-tabs')) && !document.querySelector('.el-drawer .el-loading-mask')`,
);
const owner = await win.webContents.executeJavaScript(`(() => {
const drawer = document.querySelector('.el-drawer');
return { studentWrite: Array.from(drawer.querySelectorAll('#pane-students button')).some(button => button.textContent.trim() === '添加学生'), courseCreate: Boolean(drawer.querySelector('#pane-courses input')), gradeQueryEnabled: !drawer.querySelector('#pane-settings .el-switch')?.classList.contains('is-disabled') };
})()`);
record(
'班主任本人班级仍可维护名单、课程和成绩查询开关',
owner.studentWrite && owner.courseCreate && owner.gradeQueryEnabled,
JSON.stringify(owner),
);
const members = await data('/permissions/users', admin.token);
const previous = members.find((member) => member.id === teacher.user.id).assignments;
const groups = [];
try {
const allClasses = await data('/classes', admin.token);
const unrelatedGrade = allClasses.find(
(row) => row.grade !== own.grade && row.teacher?.id !== teacher.user.id,
)?.grade;
if (!unrelatedGrade) throw new Error('缺少不担任班主任的隔离年级');
const reads = await data('/permissions/groups', admin.token, 'POST', {
name: `菜单只读${Date.now()}`,
scope: 'ALL',
keys: [
'dashboard:read',
'class:read',
'roster:read',
'schedule:read',
'term:read',
'homework:read',
'notification:read',
'grade:read',
'courseware:read',
'client:read',
],
});
groups.push(reads.id);
const limited = await data('/permissions/groups', admin.token, 'POST', {
name: `菜单空关系${Date.now()}`,
scope: 'GRADE',
scopeValue: unrelatedGrade,
keys: ['class:settings'],
});
groups.push(limited.id);
await data(`/permissions/users/${teacher.user.id}/groups`, admin.token, 'PUT', {
assignments: [
{ groupId: reads.id, scope: 'ALL', scopeValue: '' },
{ groupId: limited.id, scope: 'GRADE', scopeValue: unrelatedGrade },
],
});
const restricted = await data('/permissions/me', teacher.token);
await win.loadURL(`${new URL(options.url).origin}/dashboard`);
await wait(
`Boolean(document.querySelector('.page-title')) && document.querySelectorAll('.el-menu-item').length > 0`,
);
const labels = await win.webContents.executeJavaScript(
`Array.from(document.querySelectorAll('.el-menu-item')).map(node => node.textContent.trim())`,
);
const hidden = [
'班级管理',
'学生管理',
'课表档案',
'学期周次',
'作业发布',
'通知发布',
'成绩录入',
'课件',
'客户端',
];
record(
'只有读权限或写键无可操作班级时隐藏维护页面',
restricted.permissions.includes('class:settings') &&
restricted.actionScopes['class:settings'].length === 0 &&
hidden.every((label) => !labels.includes(label)),
labels.join(','),
);
} finally {
await data(`/permissions/users/${teacher.user.id}/groups`, admin.token, 'PUT', {
assignments: previous.map(({ groupId, scope, scopeValue }) => ({ groupId, scope, scopeValue })),
});
for (const id of groups) await data(`/permissions/groups/${id}`, admin.token, 'DELETE');
}
await win.loadURL(`${new URL(options.url).origin}/term-weeks`);
await wait(
`document.querySelector('.page-title')?.textContent.trim() === '学期周次' && document.querySelector('.page').textContent.includes('当前第') && !document.querySelector('.el-loading-mask')`,
);
const partial = await win.webContents.executeJavaScript(`(() => ({
menu: Array.from(document.querySelectorAll('.el-menu-item')).some(node => node.textContent.trim() === '学期周次'),
own: document.querySelector('.page').textContent.includes(${JSON.stringify(own.name)}),
editable: Array.from(document.querySelectorAll('.page button')).some(node => node.textContent.trim() === '保存'),
dateEnabled: !document.querySelector('.page .el-date-editor input')?.disabled,
}))()`);
record(
'部分班级可维护时保留周次入口,进入后默认选中本人可编辑班级',
partial.menu && partial.own && partial.editable && partial.dateEnabled,
JSON.stringify(partial),
);
} finally {
if (other && student) await data(`/classes/${other.id}/students/${student.id}`, admin.token, 'DELETE');
if (other) await data(`/classes/${other.id}`, admin.token, 'DELETE');
}
}
module.exports = { checkClassRelations };
+87 -20
View File
@@ -18,6 +18,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { app, BrowserWindow } = require('electron');
const { checkClassRelations } = require('./class-relations.cjs');
// 使用独立(每次清空)的 userData,避免 Service Worker 缓存影响验证结果
if (process.env.UI_SMOKE_PROFILE) {
@@ -30,9 +31,7 @@ const PASSWORD = process.env.UI_SMOKE_PASS ?? 'teacher123';
const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
/**
* 侧边栏菜单(教师账号 teacher1 可见的部分)。
* 按角色权限模型:班级管理 / 学生管理 / 教师管理 仅管理员可见,
* 教师端不应出现这些入口(后端同时强校验),因此这里的期望值就是教师可见的全部菜单。
* 教师默认能力下可见的教学与班级入口;组织管理入口另行断言隐藏。
*/
const MENU_ITEMS = [
{ label: '仪表盘', path: '/dashboard' },
@@ -44,12 +43,10 @@ const MENU_ITEMS = [
{ label: '通知发布', path: '/notifications' },
{ label: '课件', path: '/courseware' },
{ label: '成绩录入', path: '/grades' },
// 按 client:read 展示设备状态入口。
{ label: '客户端', path: '/clients' },
];
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
const HIDDEN_MENU_LABELS = ['教师管理', '数据库管理'];
const HIDDEN_MENU_LABELS = ['教师管理', '权限与范围', '数据库管理', '系统设置', '客户端'];
/** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */
const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin';
@@ -138,8 +135,9 @@ async function runAdminTeacherChecks(win) {
for (const item of [
{ label: '学期周次', path: '/term-weeks' },
{ label: '学年升级', path: '/promotion' },
{ label: '毕业归档', path: '/archives' },
{ label: '权限组', path: '/permissions' },
{ label: '权限', path: '/permissions' },
]) {
await win.webContents.executeJavaScript(
`Array.from(document.querySelectorAll('.el-menu-item')).find(node => node.textContent.trim().startsWith(${JSON.stringify(item.label)}))?.click()`,
@@ -150,7 +148,48 @@ async function runAdminTeacherChecks(win) {
8000,
);
record(`管理侧栏入口:${item.label}`, ready);
if (item.path === '/term-weeks') {
const isolated = await win.webContents.executeJavaScript(
`!document.querySelector('.page').textContent.includes('学年升级计划')`,
);
record('学期周次不再嵌入学年升级计划', isolated);
}
}
const permissionPreview = await win.webContents.executeJavaScript(`(async () => {
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
const until=async fn=>{const end=Date.now()+8000;while(Date.now()<end){if(fn())return true;await sleep(100);}return false;};
if(!await until(()=>Array.from(document.querySelectorAll('.el-table__row')).some(n=>n.textContent.includes('教师默认模板'))))return {reason:'成员表未就绪'};
const defaultTab=document.querySelector('.el-tabs__item.is-active')?.textContent.trim()==='成员与范围';
const rows=Array.from(document.querySelectorAll('.el-table__row'));
const admin=rows.find(n=>n.textContent.includes('固定全部权限,不可分配'));
const fixed=Boolean(admin?.querySelector('button:disabled'));
const teacher=rows.find(n=>n.textContent.includes('教师默认模板'));
teacher?.querySelector('button:not(:disabled)')?.click();
if(!await until(()=>Array.from(document.querySelectorAll('.el-dialog')).some(n=>n.textContent.includes('不选模板表示恢复默认模板'))))return {reason:'分配弹窗未打开'};
const dialog=Array.from(document.querySelectorAll('.el-dialog')).find(n=>n.textContent.includes('不选模板表示恢复默认模板'));
const noImplicitAssignment=dialog.querySelectorAll('.assignment').length===0;
const add=Array.from(dialog.querySelectorAll('button')).find(n=>n.textContent.trim()==='添加模板分配');add?.click();
await until(()=>dialog.querySelectorAll('.assignment').length===1);
dialog.querySelector('.assignment .el-select__wrapper')?.click();await sleep(150);
Array.from(document.querySelectorAll('.el-select-dropdown__item')).find(n=>n.textContent.trim()==='班主任')?.click();
const draft=await until(()=>dialog.querySelectorAll('.assignment').length===1 && dialog.querySelectorAll('.el-table__row').length>0 && dialog.textContent.includes('仅班主任班生效') && dialog.textContent.includes('高风险') && !dialog.querySelector('.el-loading-mask'));
const merged=dialog.textContent.includes('实际合并范围') && dialog.textContent.includes('可管理 / 可查看的班级');
const headHint=dialog.textContent.includes('仅班主任班生效');
const risk=dialog.textContent.includes('高风险');
Array.from(dialog.querySelectorAll('button')).find(n=>n.textContent.trim()==='取消')?.click();
return {defaultTab,fixed,noImplicitAssignment,draft,merged,headHint,risk};
})()`);
record(
'权限页展示成员独立范围、管理员固定权限及保存前生效预览',
permissionPreview?.defaultTab &&
permissionPreview?.fixed &&
permissionPreview?.noImplicitAssignment &&
permissionPreview?.draft &&
permissionPreview?.merged &&
permissionPreview?.headHint &&
permissionPreview?.risk,
JSON.stringify(permissionPreview),
);
// 客户端推送弹窗真实切换安装时机,只检查界面,不向设备发送更新。
await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/clients`);
await waitFor(
@@ -790,7 +829,7 @@ async function main() {
})()`);
record('Service Worker 已注册(浏览器可安装为应用)', Boolean(swInfo?.ok), String(swInfo?.detail ?? ''));
// 7.5 权限入口隐藏(教师端不应出现班级/学生/成绩录入入口)
// 7.5 教师端隐藏未获授权的组织管理入口。
const hiddenCheck = await win.webContents.executeJavaScript(`(() => {
const labels = Array.from(document.querySelectorAll('.el-menu-item')).map((node) =>
(node.textContent ?? '').trim(),
@@ -803,6 +842,29 @@ async function main() {
(hiddenCheck?.leaked ?? ['?']).length === 0,
`可见菜单=[${(hiddenCheck?.labels ?? []).join(',')}] 越权入口=[${(hiddenCheck?.leaked ?? []).join(',')}]`,
);
record(
'学生名单与学生管理合并为一个侧栏入口',
hiddenCheck.labels.filter((label) => label.startsWith('学生管理') || label.startsWith('学生名单'))
.length === 1,
hiddenCheck.labels.join(','),
);
await checkClassRelations(
win,
{
url: TARGET_URL,
username: USERNAME,
password: PASSWORD,
adminUsername: ADMIN_USERNAME,
adminPassword: ADMIN_PASSWORD,
},
record,
);
await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/roster?status=active`);
const rosterRedirected = await waitFor(
win,
`location.pathname === '/students' && location.search === '?status=active'`,
);
record('旧学生名单地址保留查询条件并转到学生管理', rosterRedirected);
// 7.51 仅管理员页面:教师**直接输网址**也必须被路由守卫挡回仪表盘
// (菜单隐藏只是"看不见",不等于没有权限 —— 这里验证真的进不去)
@@ -1096,6 +1158,8 @@ async function main() {
})()`);
await sleep(300);
// 管理员维护入口:教师录入与设备管理。
await runAdminTeacherChecks(win);
// 客户端页保留设备、远控与安装包入口;退役联动入口已移除。
const clientsUi = await win.webContents.executeJavaScript(`(async () => {
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
@@ -1106,10 +1170,8 @@ async function main() {
return location.pathname === '/clients' && document.body.innerText.includes('一键推送更新') &&
!document.body.innerText.includes('ClassIsland 联动');
})()`);
record('客户端设备页可以进入且已移除联动入口', clientsUi === true);
record('管理员可从侧栏进入客户端设备维护页且已移除联动入口', clientsUi === true);
// 7.9 管理员专属:教师录入(本轮新增「教师管理」页,仅管理员可见可用)
await runAdminTeacherChecks(win);
await runPlanEditorChecks(win);
// 8. 手机小屏适配(390×844,1Panel 风格:侧边栏收进抽屉 + 卡片内横向滚动)
@@ -1463,28 +1525,33 @@ async function runPlanEditorChecks(win) {
const pickedName = document.querySelector('.class-picker input')?.value ?? '';
const klass = (classes?.data ?? []).find(c => c.name === pickedName) ?? classes?.data?.[0];
let created = null;
const layoutName = 'UI 冒烟时间表 ' + Date.now();
if (klass) {
// 探针课表可能占满全天;用接口建一张空时间表作为可逆验收数据,随后由 UI 刷新选中。
created = await fetch('/api/time-layouts', {
method: 'POST',
headers,
body: JSON.stringify({ name: 'UI 冒烟时间表', classId: klass.id, points: [] }),
body: JSON.stringify({ name: layoutName, classId: klass.id, points: [] }),
}).then(r => r.json()).catch(() => null);
}
[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();
await wait(200);
if (document.querySelector('.el-message-box'))
document.querySelector('.el-message-box__btns .el-button--primary')?.click();
for (let i = 0; i < 30; i++) {
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
const item = [...document.querySelectorAll('.layout-explorer .plan-item')].find(b => b.querySelector('strong')?.textContent.trim() === layoutName);
if (document.querySelector('.el-message-box'))
document.querySelector('.el-message-box__btns .el-button--primary')?.click();
if (item && !document.querySelector('.el-loading-mask')) item.click();
if (document.querySelector('.layout-explorer .plan-item.active strong')?.textContent.trim() === layoutName) break;
await wait(100);
if ([...document.querySelectorAll('.layout-explorer .plan-item')].some(b => b.textContent.includes('UI 冒烟时间表'))) break;
}
[...document.querySelectorAll('.layout-explorer .plan-item')].find(b => b.textContent.includes('UI 冒烟时间表'))?.click();
await wait(250);
const before = document.querySelectorAll('.time-point-row').length;
const addButton = [...document.querySelectorAll('.layout-toolbar button')].find(b => b.textContent.trim() === '上课');
addButton?.click();
await wait(200);
const addDeadline = Date.now() + 3000;
while (Date.now() < addDeadline && document.querySelectorAll('.time-point-row').length !== before + 1) await wait(100);
const rows = document.querySelectorAll('.time-point-row');
const timeText = document.querySelector('.time-point-row.selected .point-time')?.textContent?.trim() ?? '';
const match = timeText.match(/(\\d{2}:\\d{2})\\s*[–-]\\s*(\\d{2}:\\d{2})/);
@@ -1592,12 +1659,12 @@ async function runPlanEditorChecks(win) {
`document.body.innerText.includes('开发环境') && Boolean(document.querySelector('.el-switch.is-disabled'))`,
),
);
await win.loadURL(new URL('/term-weeks', TARGET_URL).href);
await win.loadURL(new URL('/promotion', TARGET_URL).href);
record(
'学年升级设置与待补跑提示可见且不执行升级',
'学年升级独立页展示轨道、计划预览与执行记录且不执行升级',
await waitFor(
win,
`document.body.innerText.includes('学年升级计划') && document.body.innerText.includes('总周数')`,
`document.querySelector('.page-title')?.textContent.trim() === '学年升级' && document.body.innerText.includes('轨道名称') && document.body.innerText.includes('预览已保存计划') && document.body.innerText.includes('执行记录')`,
),
);
await win.loadURL(new URL('/archives', TARGET_URL).href);
+4 -2
View File
@@ -110,6 +110,7 @@ const timeout = setTimeout(() => {
child.on('exit', async (code) => {
clearTimeout(timeout);
let cleanupFailed = false;
if (probe) {
try {
const removed = await cleanupInClassProbe(probe, { notificationTitle: urgentTitle });
@@ -119,14 +120,15 @@ child.on('exit', async (code) => {
: '[ui-smoke] 探针清理完成(无残留数据)',
);
} catch (error) {
cleanupFailed = true;
console.error(`[ui-smoke] 探针清理失败(请手动检查演示数据):${error?.message ?? error}`);
}
}
await context.cleanup();
console.log(`[ui-smoke] 后台验收总耗时:${((Date.now() - startedAt) / 1000).toFixed(1)} 秒`);
if (code === 0) {
if (code === 0 && !cleanupFailed) {
console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`);
}
process.exit(code ?? 1);
process.exit(code === 0 && cleanupFailed ? 1 : (code ?? 1));
});