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

+86
View File
@@ -0,0 +1,86 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const forbidden = new Set([
'assertStaffPermission',
'hasAllScope',
'teacherScopeWhere',
'currentRequestPermission',
'bindRequestPermission',
'bindRequestPermissions',
]);
const failures = [];
function scan(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'generated') continue;
const file = path.join(dir, entry.name);
if (entry.isDirectory()) {
scan(file);
continue;
}
if (!file.endsWith('.ts')) continue;
const src = fs.readFileSync(file, 'utf8'),
ast = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true);
function fail(node, message) {
failures.push(`${file}:${ast.getLineAndCharacterOfPosition(node.getStart(ast)).line + 1} ${message}`);
}
function visit(node) {
if (ts.isIdentifier(node) && forbidden.has(node.text)) fail(node, `禁止旧授权符号 ${node.text}`);
if (ts.isCallExpression(node)) {
const name = node.expression.getText(ast);
const position = ['resolveClassScope', 'assertClassAccess', 'canAccessClass'].includes(name)
? 2
: ['classIdsFor', 'classWhere', 'getAccessibleClassIds'].includes(name)
? 1
: -1;
if (
position >= 0 &&
(!node.arguments[position] || node.arguments[position].getText(ast) === 'undefined')
)
fail(node, `${name} 必须显式传权限键`);
}
ts.forEachChild(node, visit);
}
visit(ast);
}
}
scan('packages/server/src');
// 菜单页必须声明能力键,防止移除旧 roles 后出现无门控入口。
const routerFile = 'packages/web-admin/src/router/index.ts';
const routerAst = ts.createSourceFile(
routerFile,
fs.readFileSync(routerFile, 'utf8'),
ts.ScriptTarget.Latest,
true,
);
function property(object, name) {
return object.properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(routerAst) === name)
?.initializer;
}
function checkRoutes(node) {
if (ts.isObjectLiteralExpression(node)) {
const name = property(node, 'name'),
component = property(node, 'component'),
meta = property(node, 'meta');
if (meta && ts.isObjectLiteralExpression(meta) && property(meta, 'roles'))
failures.push(`${routerFile} 禁止 meta.roles,统一使用能力键`);
if (
name &&
component &&
name.getText(routerAst) !== "'no-access'" &&
(!meta ||
!ts.isObjectLiteralExpression(meta) ||
(!property(meta, 'public') && !property(meta, 'permission')))
)
failures.push(`${routerFile} 页面 ${name.getText(routerAst)} 缺少 meta.permission`);
}
ts.forEachChild(node, checkRoutes);
}
checkRoutes(routerAst);
if (fs.existsSync('packages/server/src/lib/permission-scope.ts'))
failures.push('旧 permission-scope.ts 必须删除');
if (failures.length) {
console.error(failures.join('\n'));
process.exitCode = 1;
} else console.log('授权静态门禁通过:无旧入口、无省略权限键的范围查询');
+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));
});
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dir = path.join(root, '.cache', 'authz-verification', `run-${process.pid}-${Date.now()}`);
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
NODE_ENV: 'test',
DATABASE_PROVIDER: 'sqlite',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
JWT_SECRET: 'authz-isolated-verification-secret-2026',
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',
LOG_LEVEL: 'warn',
};
const require = createRequire(path.join(root, 'packages/server/package.json'));
const database = require('@libsql/client').createClient({ url: env.DATABASE_URL });
await database.execute('PRAGMA user_version=0');
database.close();
async function run(args) {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, { cwd: root, env, stdio: 'inherit', windowsHide: true });
child.once('error', reject);
child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`隔离验收失败:${code}`))));
});
}
await run([
'packages/server/node_modules/prisma/build/index.js',
'migrate',
'deploy',
'--config',
'packages/server/prisma.config.ts',
]);
await run(['packages/server/node_modules/tsx/dist/cli.mjs', 'packages/server/scripts/verify-authz.ts']);
console.log(`授权隔离数据库:${dir}`);
+44 -1
View File
@@ -50,7 +50,12 @@ try {
const script = `const {ensureSchema}=await import('./packages/server/src/lib/db-bootstrap.ts');
const result=await ensureSchema(); if(result.applied!==${expected})throw new Error(JSON.stringify(result));
const {ensureDefaultGroups}=await import('./packages/server/src/lib/permissions.ts');
await ensureDefaultGroups();process.exit(0);`;
await ensureDefaultGroups();
const {loadAuthPrincipal}=await import('./packages/server/src/lib/authz/index.ts');
const {PERMISSION_KEYS}=await import('./packages/shared/dist/index.js');
const admin=await loadAuthPrincipal({sub:'legacy-admin-user',role:'ADMIN',name:'测试',username:'测试',classId:null});
if(admin.permissions.size!==PERMISSION_KEYS.length||admin.fromGroups)throw new Error('管理员不应受默认组限制');
process.exit(0);`;
const result = spawnSync(
process.execPath,
['--import', './packages/server/node_modules/tsx/dist/loader.mjs', '--input-type=module', '-e', script],
@@ -125,6 +130,18 @@ try {
sql: "INSERT INTO \"StudentClassTransfer\" (id,studentId,studentNo,studentName,mode) VALUES (?,'legacy-student','LEGACY','旧学生',?)",
args: [id, mode],
});
await legacy.execute(
`INSERT INTO "User" (id,username,passwordHash,name,role,updatedAt) VALUES ('legacy-teacher','legacy-teacher','unused','旧教师','TEACHER',CURRENT_TIMESTAMP),('legacy-admin-user','legacy-admin-user','unused','旧管理员','ADMIN',CURRENT_TIMESTAMP)`,
);
await legacy.execute(
`INSERT INTO "PermissionGroup" (id,name,scope,scopeValue,updatedAt) VALUES ('old-self','旧任教模板','SELF_CLASS','',CURRENT_TIMESTAMP),('old-head','旧班主任模板','SELF_CLASS','HEAD',CURRENT_TIMESTAMP)`,
);
await legacy.execute(
`INSERT INTO "PermissionGroup" (id,name,defaultRole,scope,scopeValue,updatedAt) VALUES ('old-teacher-default','旧教师默认模板','TEACHER','ALL','',CURRENT_TIMESTAMP)`,
);
await legacy.execute(
`INSERT INTO "UserPermissionGroup" (id,userId,groupId) VALUES ('old-self-link','legacy-teacher','old-self'),('old-head-link','legacy-teacher','old-head'),('old-admin-link','legacy-admin-user','legacy-admin')`,
);
legacy.close();
run(names.length - split, legacyUrl);
const upgraded = createClient({ url: legacyUrl });
@@ -133,6 +150,32 @@ try {
await upgraded.execute(`SELECT key FROM "PermissionGroupItem" WHERE groupId='legacy-admin'`)
).rows.map((row) => row.key);
assert(keys.includes('roster:read') && keys.includes('promotion:manage'));
const assignments = (
await upgraded.execute(
`SELECT scope,scopeValue FROM "UserPermissionGroup" WHERE userId='legacy-teacher' ORDER BY scope`,
)
).rows;
assert.deepEqual(
assignments.map((a) => [a.scope, a.scopeValue]),
[
['HEAD', ''],
['TEACHING', ''],
],
);
assert.equal(
(await upgraded.execute(`SELECT scope FROM "PermissionGroup" WHERE defaultRole='TEACHER'`)).rows[0]
.scope,
'TEACHING',
);
assert.equal(
(await upgraded.execute(`SELECT scope FROM "PermissionGroup" WHERE id='old-head'`)).rows[0].scope,
'HEAD',
);
assert.equal(
(await upgraded.execute(`SELECT count(*) AS n FROM "PermissionGroup" WHERE defaultRole='ADMIN'`))
.rows[0].n,
1,
);
const kinds = (await upgraded.execute('SELECT id,kind,batchId FROM "StudentClassTransfer" ORDER BY id'))
.rows;
assert.deepEqual(
+20 -19
View File
@@ -28,12 +28,12 @@ const groups = (await api('/permissions/groups', admin)).data;
const teacherDefault = groups.find((g) => g.defaultRole === 'TEACHER');
const adminDefault = groups.find((g) => g.defaultRole === 'ADMIN');
const key = `api-regression-${Date.now()}`;
let deviceId, scheduleId, staffDeviceId;
let deviceId, scheduleId, staffDeviceId, foreignClassId;
const version = `99.0.0-regression-${Date.now()}`;
const releaseDir = path.join(root, 'packages/data/client-releases', version);
const releaseName = `ClassHelper-${version}-x64-client-setup.exe`;
try {
check('角色默认组已创建且可以识别对应角色', teacherDefault && adminDefault);
check('只有教师默认组,管理员没有可编辑默认组', teacherDefault && !adminDefault);
await api(`/permissions/groups/${teacherDefault.id}`, admin, { name: '回归教师默认组', keys: [] }, 'PATCH');
let mine = await api('/permissions/me', teacher);
check('未分组教师使用数据库默认组,空组真正收回权限', mine.data.permissions.length === 0);
@@ -47,15 +47,11 @@ try {
mine = await api('/permissions/me', teacher);
check('默认组改名后继续生效,可赋予远控与推送权限', mine.data.permissions.includes('remote:control'));
check('拥有推送权限的教师可读取安装包列表', (await api('/update/client/releases', teacher)).status === 200);
await api(`/permissions/groups/${adminDefault.id}`, admin, { keys: [] }, 'PATCH');
mine = await api('/permissions/me', admin);
check(
'管理员默认组可收权,保留两个防锁死入口',
mine.data.permissions.length === 2 &&
mine.data.permissions.includes('permission:manage') &&
mine.data.permissions.includes('system:manage'),
'管理员固定全集,不受教师模板修改影响',
mine.data.permissions.includes('teacher:manage') && mine.data.permissions.includes('client:update'),
);
await api(`/permissions/groups/${adminDefault.id}`, admin, { keys: adminDefault.keys }, 'PATCH');
const beat = await api('/clients/heartbeat', classToken, {
agentProtocol: 2,
@@ -112,13 +108,21 @@ try {
fs.writeFileSync(path.join(releaseDir, releaseName), '用于下载校验的测试载荷,不是安装器');
const registered = await api('/update/client/releases', admin, { version, fileName: releaseName });
check('本地缓存安装包可登记', registered.status === 201);
staffDeviceId = (await api('/clients/heartbeat', admin, { agentProtocol: 2, deviceKey: `${key}-staff` }))
.data.deviceId;
const deniedCommand = await api('/clients/commands', teacher, { deviceIds: [staffDeviceId], kind: 'lock' });
const foreignClass = (await api('/classes', admin, { grade: '高一', enrollmentYear: 2035, classIndex: 99 }))
.data;
foreignClassId = foreignClass.id;
const otherClassToken = (
await api('/auth/class-login', null, { code: foreignClass.code, password: '123456' })
).data.token;
staffDeviceId = (
await api('/clients/heartbeat', otherClassToken, { agentProtocol: 2, deviceKey: `${key}-other-class` })
).data.deviceId;
check(
'教师获远控权限后仍不能操作班级范围外设备',
deniedCommand.data.failed === 1 && deniedCommand.data.succeeded === 0,
'管理员个人会话不能冒充设备心跳',
(await api('/clients/heartbeat', admin, { agentProtocol: 2, deviceKey: `${key}-staff` })).status === 403,
);
const deniedCommand = await api('/clients/commands', teacher, { deviceIds: [staffDeviceId], kind: 'lock' });
check('教师获远控权限后仍不能操作班级范围外设备', deniedCommand.status === 403);
check(
'教师获更新权限后不能越过班级范围推送',
(await api('/clients/updates', teacher, { deviceIds: [staffDeviceId], version, mode: 'immediate' }))
@@ -212,12 +216,6 @@ try {
(await api(`/classes/${classLogin.data.user.classId}/notification-channel`, classToken)).status === 404,
);
} finally {
await api(
`/permissions/groups/${adminDefault.id}`,
admin,
{ name: adminDefault.name, keys: adminDefault.keys },
'PATCH',
);
await api(
`/permissions/groups/${teacherDefault.id}`,
admin,
@@ -226,8 +224,11 @@ try {
);
if (deviceId) await api(`/clients/devices/${deviceId}`, admin, undefined, 'DELETE');
if (staffDeviceId) await api(`/clients/devices/${staffDeviceId}`, admin, undefined, 'DELETE');
if (foreignClassId) await api(`/classes/${foreignClassId}`, admin, undefined, 'DELETE');
if (scheduleId) await api(`/schedules/${scheduleId}`, admin, undefined, 'DELETE');
await api(`/update/client/releases/${version}`, admin, undefined, 'DELETE');
if (!path.resolve(releaseDir).startsWith(path.resolve(root, 'packages/data/client-releases') + path.sep))
throw new Error('拒绝清理交付目录外的路径');
fs.rmSync(releaseDir, { recursive: true, force: true });
}
console.log(`客户端 API 回归:${passed}/${passed} 项通过`);
+18 -2
View File
@@ -46,8 +46,15 @@ function run(args, logName) {
});
});
}
const suite = process.argv.includes('--review-api')
? 'review-api'
: process.argv.includes('--clients-api')
? 'clients-api'
: null;
const seedFile = path.join(root, 'packages/server/prisma', `.verification-seed-${process.pid}.ts`),
testFile = path.join(root, 'packages/server/scripts', `.verification-e2e-${process.pid}.mjs`);
testFile = suite
? path.join(root, 'scripts', `.verification-${suite}-${process.pid}.mjs`)
: path.join(root, 'packages/server/scripts', `.verification-e2e-${process.pid}.mjs`);
const prefix = `temporary${process.pid}_`;
function namespace(code) {
for (const name of ['admin', 'teacher1', 'teacher2'])
@@ -101,7 +108,12 @@ try {
if (!ready) throw new Error('临时服务未就绪');
fs.writeFileSync(
testFile,
namespace(fs.readFileSync(path.join(root, 'packages/server/scripts/verify-e2e.mjs'), 'utf8')),
namespace(
fs.readFileSync(
path.join(root, suite ? `scripts/verify-${suite}.mjs` : 'packages/server/scripts/verify-e2e.mjs'),
'utf8',
),
),
{ flag: 'wx' },
);
if (process.argv.includes('--web')) {
@@ -110,6 +122,10 @@ try {
env.UI_SMOKE_ADMIN_PASS = 'admin123';
await run(['scripts/ui-smoke/run.mjs'], 'web.log');
} else await run([testFile], 'e2e.log');
if (server.exitCode !== null || server.signalCode !== null)
throw new Error(`临时服务在验收收尾前退出:code=${server.exitCode} signal=${server.signalCode}`);
const readyAtEnd = await fetch(`${env.VERIFY_BASE_URL}/readyz`, { signal: AbortSignal.timeout(3000) });
if (!readyAtEnd.ok) throw new Error(`临时服务收尾检查失败:HTTP ${readyAtEnd.status}`);
} finally {
if (server && server.exitCode === null) {
server.kill();
+19 -9
View File
@@ -140,7 +140,9 @@ try {
const payload = await ready;
check(
'班级端连接后自动加入自己的班级房间',
payload.rooms.some((room) => room === SOCKET_ROOMS.class(token === ownLogin.token ? a : b)),
payload.rooms.some(
(room) => room === SOCKET_ROOMS.classChannel(token === ownLogin.token ? a : b, 'notification'),
),
);
}
const join = await new Promise((resolve) =>
@@ -174,7 +176,7 @@ try {
weekStart: 15,
});
check('按班级学期兜底后仍检查起始周不能越过结束周', badSchedule.status === 400);
await api(`/classes/${a}`, admin, { termWeeks: 30 }, 'PATCH');
await api(`/classes/${a}/settings`, admin, { termWeeks: 30 }, 'PATCH');
const imported = await api('/imports/class-plan', admin, {
classId: a,
mode: 'merge',
@@ -289,8 +291,8 @@ try {
});
assert.ok(roleImport.status < 300, JSON.stringify(roleImport));
check(
'角色导入夹具确实更新了目标账号',
roleImport.data?.updated === 1 && roleImport.data.errors.length === 0,
'教师批量导入不会更新管理员账号,须使用管理员账号维护入口',
roleImport.data?.updated === 0 && roleImport.data.errors.length === 1,
);
const teachers = (await api('/teachers', admin)).data;
check(
@@ -311,20 +313,28 @@ try {
'未授予强制检查时教师的 force 参数不能绕过缓存',
(await api('/update/check?force=1', staffToken)).data.checkedAt === initialUpdate.checkedAt,
);
await api(`/permissions/groups/${groupId}`, admin, { keys: [...baseKeys, 'update:force'] }, 'PATCH');
check(
'教师模板不能保存管理员强制更新键',
(await api(`/permissions/groups/${groupId}`, admin, { keys: [...baseKeys, 'update:force'] }, 'PATCH'))
.status === 400,
);
const forcedUpdate = (await api('/update/check?force=1', staffToken)).data;
check('授予强制检查权限后教师也能按权限绕过缓存', forcedUpdate.checkedAt !== initialUpdate.checkedAt);
check('教师即使尝试授予管理员能力也不能绕过更新缓存', forcedUpdate.checkedAt === initialUpdate.checkedAt);
const readUpdateGroup = (
await api('/permissions/groups', admin, { name: `审查管理员只读更新${stamp}`, keys: ['update:check'] })
).data;
extraGroups.push(readUpdateGroup.id);
await api(`/permissions/users/${roleUser.id}/groups`, admin, { groupIds: [readUpdateGroup.id] }, 'PUT');
check(
'管理员不接受模板分配',
(await api(`/permissions/users/${roleUser.id}/groups`, admin, { groupIds: [readUpdateGroup.id] }, 'PUT'))
.status === 400,
);
const limitedAdmin = (
await api('/auth/login', null, { username: roleUser.username, password: 'review-test-123' })
).data.token;
check(
'管理员没有强制检查权限时同样不能绕过缓存',
(await api('/update/check?force=1', limitedAdmin)).data.checkedAt === forcedUpdate.checkedAt,
'管理员固定拥有强制检查能力',
(await api('/update/check?force=1', limitedAdmin)).data.checkedAt !== forcedUpdate.checkedAt,
);
previousAi = (await api('/ai/settings', admin)).data;
await api('/ai/settings', admin, { enabled: true, provider: 'mock' }, 'PUT');
+14 -6
View File
@@ -5,6 +5,7 @@ import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { EventEmitter } from 'node:events';
import { PERMISSION_KEYS } from '../packages/shared/dist/index.js';
const root = path.resolve('.');
const { build } = createRequire(path.join(root, 'packages/desktop-client/package.json'))('esbuild');
fs.mkdirSync(path.join(root, '.cache/tmp'), { recursive: true });
@@ -55,7 +56,7 @@ try {
const previousStorage = globalThis.localStorage;
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
try {
h.webPermissions = ['permission:manage', 'system:manage'];
h.webPermissions = [];
const webAuth = await bundle('web-auth', 'packages/web-admin/src/stores/auth.ts', {
vue: refMock,
pinia: 'export const defineStore=(_name, setup)=>setup;',
@@ -67,12 +68,15 @@ try {
auth.token.value = 'admin-test';
await auth.ensurePermissions();
check(
'管理员界面不会绕过已收回的默认组权限',
!auth.can('remote:control') && !auth.can('ai:use') && auth.can('permission:manage'),
'界面只采用服务端返回能力,不硬编码管理员入口',
!auth.can('remote:control') && !auth.can('ai:use') && !auth.can('permission:manage'),
);
h.webPermissions.push('remote:control');
h.webPermissions = [...PERMISSION_KEYS];
await auth.ensurePermissions(true);
check('刷新权限后管理员界面采用当前生效集合', auth.can('remote:control'));
check(
'刷新后界面镜像服务端管理员固定全集',
PERMISSION_KEYS.every((key) => auth.can(key)),
);
} finally {
globalThis.localStorage = previousStorage;
}
@@ -216,6 +220,8 @@ try {
vue: 'export const watch=()=>()=>{};',
'../api/index.js':
'const h=globalThis.__reviewClientTest; export const scheduleApi={list:(params)=>h.scheduleLoader(params)}; export const notificationApi={list:async()=>[]};',
'../api/http.js':
'export const api={get:async(_path,params)=>({week:params.week,days:[{weekday:new Date().getDay()||7,schedules:await globalThis.__reviewClientTest.scheduleLoader(params)}]})};',
'./db.js': 'export const cacheGet=async()=>null; export const cacheSet=async()=>{};',
'../stores/app.js':
'export const useAppStore=()=>globalThis.__reviewClientTest.appStore; export const readOfflineSince=()=>null; export const clearOfflineSince=()=>{};',
@@ -227,9 +233,10 @@ try {
'const Date=globalThis.__reviewClientTest.FixedDate;\n',
);
try {
await h.appStore.refreshTerm();
await bridge.refreshIslandSchedules();
check(
'先解析真实教学周再请求课表,不会误取第 1 周',
'课表请求使用已解析的真实教学周,不会误取第 1 周',
h.requestedWeek === 9 && bridge.getIslandClassContext().inClass,
);
const loader = h.scheduleLoader;
@@ -243,6 +250,7 @@ try {
);
h.scheduleLoader = loader;
h.termWeek = 10;
await h.appStore.refreshTerm();
await bridge.refreshIslandSchedules();
check('教学周变更后按新周请求并计算', h.requestedWeek === 10 && bridge.getIslandClassContext().inClass);
h.scheduleLoader = () =>