feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
1 parent
682818dd4a
commit
66be3a63b1
115 files changed
+4463
-2747
No files matched your search
@@ -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('授权静态门禁通过:无旧入口、无省略权限键的范围查询');
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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(
|
||||
|
||||
@@ -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} 项通过`);
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
Reference in new issue
Block a user