拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
318 lines
12 KiB
JavaScript
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 });
|
|
}
|