Files
ClassHelper/scripts/verify-review-client.mjs
T
LoveChengke 66be3a63b1 feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
2026-10-31 09:40:23 +08:00

318 lines
12 KiB
JavaScript

/** 隔离验证缓存故障恢复、实时通知竞态和崩溃恢复,不创建任何桌面窗口。 */
import assert from 'node:assert/strict';
import fs from 'node:fs';
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 });
const temp = fs.mkdtempSync(path.join(root, '.cache/tmp/review-client-'));
let passed = 0;
function check(name, value) {
assert.ok(value, name);
passed++;
console.log(`✓ ${name}`);
}
const h = { writes: [], pending: [], app: new EventEmitter(), handlers: {}, restarts: 0, recoveries: 0 };
globalThis.__reviewClientTest = h;
async function bundle(name, entry, mocks = {}, append = '', prefix = '') {
const target = path.join(temp, `${name}.mjs`);
await build({
entryPoints: [path.join(root, entry)],
outfile: target,
platform: 'node',
format: 'esm',
bundle: true,
logLevel: 'silent',
plugins: [
{
name: '隔离依赖',
setup(b) {
b.onResolve({ filter: /.*/ }, (args) => {
const key = Object.keys(mocks).find((key) => args.path === key);
if (key) return { path: key, namespace: 'review-mock' };
});
b.onLoad({ filter: /.*/, namespace: 'review-mock' }, (args) => ({
contents: mocks[args.path],
loader: 'js',
}));
b.onLoad({ filter: /\.(ts)$/ }, (args) =>
args.path === path.join(root, entry)
? { contents: prefix + fs.readFileSync(args.path, 'utf8') + append, loader: 'ts' }
: null,
);
},
},
],
});
return import(pathToFileURL(target).href);
}
const refMock =
'export const ref=(value)=>({value}); export const computed=(fn)=>({get value(){return fn()}});';
try {
const previousStorage = globalThis.localStorage;
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
try {
h.webPermissions = [];
const webAuth = await bundle('web-auth', 'packages/web-admin/src/stores/auth.ts', {
vue: refMock,
pinia: 'export const defineStore=(_name, setup)=>setup;',
'@/api':
'export const authApi={}; export const permissionApi={me:async()=>({permissions:globalThis.__reviewClientTest.webPermissions,fromGroups:false})};',
});
const auth = webAuth.useAuthStore();
auth.user.value = { role: 'ADMIN' };
auth.token.value = 'admin-test';
await auth.ensurePermissions();
check(
'界面只采用服务端返回能力,不硬编码管理员入口',
!auth.can('remote:control') && !auth.can('ai:use') && !auth.can('permission:manage'),
);
h.webPermissions = [...PERMISSION_KEYS];
await auth.ensurePermissions(true);
check(
'刷新后界面镜像服务端管理员固定全集',
PERMISSION_KEYS.every((key) => auth.can(key)),
);
} finally {
globalThis.localStorage = previousStorage;
}
const requests = [];
const originalIndexedDB = globalThis.indexedDB;
globalThis.indexedDB = {
open() {
const request = {};
requests.push(request);
return request;
},
};
try {
const db = await bundle('database', 'packages/desktop-client/src/renderer/cache/db.ts');
const first = db.cacheGet('grades', 'key');
requests[0].error = new Error('首次打开失败');
requests[0].onerror();
await assert.rejects(first, /首次打开失败/);
const second = db.cacheGet('grades', 'key');
check('IndexedDB 打开失败后会重新发起请求', requests.length === 2);
let closed = false;
const connection = {
close() {
closed = true;
},
transaction() {
const transaction = {
objectStore: () => ({
get() {
const request = { result: undefined };
queueMicrotask(() => transaction.oncomplete());
return request;
},
}),
};
return transaction;
},
};
requests[1].result = connection;
requests[1].onsuccess();
check('重试成功后可以正常读取缓存', (await second) === null);
connection.onversionchange();
check('版本变化会关闭旧连接', closed);
const blocked = db.cacheGet('grades', 'key');
requests[2].onblocked();
await assert.rejects(blocked, /其它窗口/);
const retry = db.cacheGet('grades', 'key');
requests[3].result = connection;
requests[3].onsuccess();
check('数据库被占用后也可以恢复', (await retry) === null && requests.length === 4);
} finally {
globalThis.indexedDB = originalIndexedDB;
}
const notifications = await bundle(
'notifications',
'packages/desktop-client/src/renderer/stores/notifications.ts',
{
vue: refMock,
pinia: 'export const defineStore=(_name, setup)=>setup;',
'../api/index.js':
'const h=globalThis.__reviewClientTest; export const notificationApi={list:()=>new Promise(resolve=>h.pending.push(resolve)),markRead:async()=>({}),markAllRead:async()=>({marked:1}),unreadCount:async()=>({count:0})};',
'../cache/db.js':
'const h=globalThis.__reviewClientTest; export const cacheGet=async()=>null; export const cacheSet=async(...args)=>{h.writes.push(args)};',
'./db.js':
'const h=globalThis.__reviewClientTest; export const cacheGet=async()=>null; export const cacheSet=async(...args)=>{h.writes.push(args)};',
'./app.js': 'export const useAppStore=()=>({markSynced(){}});',
},
`\nexport {setCacheScope} from '../cache/scope.js';`,
);
const store = notifications.useNotificationStore();
const item = {
id: 'n',
title: '实时通知',
content: '内容',
classId: 'c',
priority: 'NORMAL',
read: false,
createdAt: new Date().toISOString(),
};
let loading = store.load();
await store.pushRealtime(item);
h.pending.shift()([]);
await loading;
check('列表请求中的实时新通知不会丢失', store.items.value[0]?.id === 'n' && store.unreadCount.value === 1);
loading = store.load();
await store.markRead('n');
h.pending.shift()([{ ...item, read: false }]);
await loading;
check('迟到的列表快照不会撤销本地已读', store.items.value[0]?.read === true);
loading = store.load();
await store.removeRealtime('n');
h.pending.shift()([item]);
await loading;
check('已删除通知不会被迟到快照复活', store.items.value.length === 0);
loading = store.load();
notifications.setCacheScope('http://other.invalid', 'other-class');
h.pending.shift()([item]);
await loading;
check('旧会话迟到响应不会污染换班后的列表', store.items.value.length === 0);
const previousWindow = globalThis.window;
const noon = new Date();
noon.setHours(10, 0, 0, 0);
h.FixedDate = class extends Date {
constructor(...args) {
super(...(args.length ? args : [noon.getTime()]));
}
};
h.classStates = [];
globalThis.window = { desktop: { islandSetClassState: (state) => h.classStates.push(state) } };
h.auth = { token: 'session', classId: 'c' };
h.termWeek = 9;
h.appStore = {
currentWeek: 1,
async refreshTerm() {
this.currentWeek = h.termWeek;
},
};
h.scheduleLoader = async (params) => {
h.requestedWeek = params.week;
return [
{
id: 's',
classId: 'c',
courseId: 'course',
course: { name: '语文' },
dayOfWeek: noon.getDay() || 7,
startTime: '08:00',
endTime: '11:00',
weekStart: params.week,
weekEnd: params.week,
weekParity: 'ALL',
},
];
};
const bridge = await bundle(
'bridge',
'packages/desktop-client/src/renderer/island/bridge.ts',
{
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=()=>{};',
'../stores/auth.js': 'export const useAuthStore=()=>globalThis.__reviewClientTest.auth;',
'../stores/notifications.js': 'export const useNotificationStore=()=>({});',
'../stores/realtime.js': 'export const useRealtimeStore=()=>({on(){},off(){}});',
},
`\nexport {setCacheScope} from '../cache/scope.js';`,
'const Date=globalThis.__reviewClientTest.FixedDate;\n',
);
try {
await h.appStore.refreshTerm();
await bridge.refreshIslandSchedules();
check(
'课表请求使用已解析的真实教学周,不会误取第 1 周',
h.requestedWeek === 9 && bridge.getIslandClassContext().inClass,
);
const loader = h.scheduleLoader;
h.scheduleLoader = async () => {
throw new Error('短暂断网');
};
await bridge.refreshIslandSchedules();
check(
'无缓存的失败刷新不会清掉已知课表或上课状态',
bridge.getIslandSchedules().length === 1 && bridge.getIslandClassContext().inClass,
);
h.scheduleLoader = loader;
h.termWeek = 10;
await h.appStore.refreshTerm();
await bridge.refreshIslandSchedules();
check('教学周变更后按新周请求并计算', h.requestedWeek === 10 && bridge.getIslandClassContext().inClass);
h.scheduleLoader = () =>
new Promise((resolve) => {
h.finishSchedule = resolve;
});
const pending = bridge.refreshIslandSchedules();
await Promise.resolve();
await Promise.resolve();
bridge.stopIslandBridge();
bridge.setCacheScope('http://changed.invalid', 'class-b');
h.finishSchedule([]);
await pending;
check('停止桥接后旧班迟到课表不能恢复旧会话', bridge.getIslandSchedules().length === 0);
} finally {
globalThis.window = previousWindow;
}
h.app.relaunch = () => h.restarts++;
h.process = {
env: {},
on: (event, handler) => {
h.handlers[event] = handler;
},
};
const guard = await bundle(
'guard',
'packages/desktop-client/src/main/crash-guard.ts',
{
electron: 'export const app=globalThis.__reviewClientTest.app;',
'./logger.js': 'export const logger={info(){},warn(){},error(){}};',
},
'',
'const process=globalThis.__reviewClientTest.process;\n',
);
guard.installCrashGuard({
recoverMainWindow: async () => {
h.recoveries++;
await new Promise((resolve) => {
h.finishRecovery = resolve;
});
},
isIdle: () => true,
quitApp() {},
});
h.app.emit('render-process-gone', {}, { id: 99 }, { reason: 'crashed', exitCode: 2 });
check('没有主窗口时不会把灵动岛崩溃当成主窗口崩溃', h.recoveries === 0);
const win = new EventEmitter();
Object.assign(win, { webContents: { id: 10 }, isDestroyed: () => false, hide() {} });
guard.watchMainWindow(win);
h.app.emit('render-process-gone', {}, { id: 10 }, { reason: 'crashed', exitCode: 2 });
h.app.emit('render-process-gone', {}, { id: 10 }, { reason: 'crashed', exitCode: 2 });
check('异步窗口恢复完成前不重复重建', h.recoveries === 1);
h.finishRecovery();
await Promise.resolve();
h.handlers.unhandledRejection(new Error('普通请求失败'));
check('普通 Promise 拒绝不会触发应用重启', h.restarts === 0);
h.handlers.uncaughtException(new Error('致命异常'));
check('空闲时真正致命的异常仍可重启恢复', h.restarts === 1);
console.log(`审查客户端逻辑回归:${passed}/${passed} 项通过`);
} finally {
delete globalThis.__reviewClientTest;
fs.rmSync(temp, { recursive: true, force: true });
}