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

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

453 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AddressInfo } from 'node:net';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { createApp } from '../src/app.js';
import { prisma } from '../src/lib/db.js';
import { signToken } from '../src/lib/jwt.js';
import { exportSnapshot, assertValidSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
import { ensureDefaultGroups } from '../src/lib/permissions.js';
import { registerEmitter } from '../src/realtime/bus.js';
import {
COURSEWARE_MAX_BYTES,
SOCKET_EVENTS,
coursewareCategory,
dayKeyLocal,
type CoursewareDto,
} from '@classhelper/shared';
if (
!process.env.DATABASE_URL?.includes('v14-verification') ||
!process.env.COURSEWARE_DIR?.includes('v14-verification')
)
throw new Error('课件验收只能使用临时库和文件目录');
let count = 0;
function check(name: string, value: unknown) {
assert.ok(value, name);
console.log(`通过 ${++count}:${name}`);
}
const emitted: { rooms: string[]; event: string; payload: unknown }[] = [];
registerEmitter((rooms, event, payload) => emitted.push({ rooms, event, payload }));
const staff = await Promise.all(
['课件教师甲', '课件教师乙', '仅班主任', '临时管理员'].map((name, i) =>
prisma.user.create({
data: {
name,
username: `cw-${Date.now()}-${i}`,
passwordHash: '仅签发临时令牌',
role: i === 3 ? 'ADMIN' : 'TEACHER',
},
}),
),
);
const tokens = staff.map((user) =>
signToken({
sub: user.id,
username: user.username,
name: user.name,
role: user.role as 'ADMIN' | 'TEACHER',
classId: null,
}),
);
const classes = await Promise.all(
[0, 1, 2].map((i) =>
prisma.class.create({
data: {
name: `课件验证班${i + 1}`,
grade: '验证',
code: `CW${Date.now()}${i}`,
teacherId: staff[2]!.id,
},
}),
),
);
await prisma.course.createMany({
data: classes.map((c, i) => ({ name: '语文', classId: c.id, teacherId: staff[i === 2 ? 1 : 0]!.id })),
});
const classTokens = classes.map((c) =>
signToken({
sub: c.id,
classId: c.id,
username: c.code,
name: c.name,
role: 'CLASS_DEVICE',
classSession: true,
}),
);
await ensureDefaultGroups();
let server = http.createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = () => `http://127.0.0.1:${(server.address() as AddressInfo).port}/api`;
async function request(route: string, token = tokens[0], body?: unknown, method = body ? 'POST' : 'GET') {
const res = await fetch(`${base()}${route}`, {
method,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
return { status: res.status, ...(await res.json()) };
}
const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
async function upload(
options: Partial<{ name: string; size: number; classIds: string[]; mode: string; folderPath: string }> = {},
token = tokens[0],
data = bytes,
) {
const meta = {
name: '第1课 演示.pptx',
size: data.length,
classIds: [classes[0]!.id],
mode: 'cloud',
...options,
};
const res = await fetch(
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(meta))}`,
{
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' },
body: data,
signal: AbortSignal.timeout(10000),
},
);
return { status: res.status, ...(await res.json()) };
}
const list = async (token: string) => (await request('/courseware', token)).data as CoursewareDto[];
const heartbeat = async (i: number, key: string) =>
request('/clients/heartbeat', classTokens[i], {
deviceKey: key,
clientVersion: '1.4.0',
platform: 'verification',
});
const pending = async (i: number, key: string) =>
request(`/courseware/transfers?deviceKey=${key}`, classTokens[i]);
async function files(dir = process.env.COURSEWARE_DIR!): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
return (
await Promise.all(
entries.map((entry) =>
entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
),
)
).flat();
}
try {
check('未登录不能列出课件', (await request('/courseware', '')).status === 401);
const ownClasses = (await request('/courseware/classes')).data;
check(
'只能选择自己的授课班级',
ownClasses.length === 2 && !ownClasses.some((c: { id: string }) => c.id === classes[2]!.id),
);
check(
'仅班主任而无任课关系不能选择班级',
(await request('/courseware/classes', tokens[2])).data.length === 0,
);
check('管理员上传同样只能选择本人任课班级', (await upload({}, tokens[3])).status === 403);
check('班级端不能上传', (await upload({}, classTokens[0])).status === 403);
check('拒绝非任课班级上传', (await upload({ classIds: [classes[2]!.id] })).status === 403);
check('拒绝目录穿越文件名', (await upload({ name: '../secret.pptx' })).status === 422);
check('拒绝空文件', (await upload({ size: 0 }, tokens[0], Buffer.alloc(0))).status === 422);
check('拒绝超过 512 MB 的课件', (await upload({ size: COURSEWARE_MAX_BYTES + 1 })).status === 422);
check('拒绝错误文件长度', (await upload({ size: 1 })).status === 400);
check('推送不能不选班级', (await upload({ mode: 'push', classIds: [] })).status === 422);
const created = await upload();
const item = created.data as CoursewareDto;
check(
'完整上传后创建课件元数据',
created.status === 201 && item.size === bytes.length && item.name === '第1课 演示.pptx',
);
check(
'摘要、日期与类型正确',
item.sha256 === createHash('sha256').update(bytes).digest('hex') &&
item.archiveDate === dayKeyLocal(new Date()) &&
item.category === 'presentation',
);
check('API 不暴露服务端路径', !('storagePath' in item));
const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
check(
'文件按教师、年、月、日、上传时间保存',
/^Teachers\/课件教师甲\/\d{4}\/\d{2}\/\d{2}\/\d{2}-\d{2}-\d{2}\/第1课 演示\.pptx$/.test(
item.archivePath || '',
) &&
stored.storagePath.endsWith(path.join(...item.archivePath!.split('/'))) &&
(await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
);
const folder = await request('/courseware/folders', tokens[0], { path: '地理/必修一' });
check('教师可创建多层分类文件夹', folder.status === 201 && folder.data.path === '地理/必修一');
check('文件夹按教师隔离', (await request('/courseware/folders', tokens[1])).data.length === 0);
check(
'拒绝目录穿越文件夹',
(await request('/courseware/folders', tokens[0], { path: '../越界' })).status === 422,
);
check(
'班级不能创建文件夹',
(await request('/courseware/folders', classTokens[0], { path: '越权' })).status === 403,
);
const moved = await request(
`/courseware/${item.id}/folder`,
tokens[0],
{ folderPath: folder.data.path },
'PATCH',
);
check(
'课件可分类到自建文件夹且日期归档保持稳定',
moved.data.folderPath === folder.data.path && moved.data.archivePath === item.archivePath,
);
check(
'不能借用其他教师文件夹上传',
(await upload({ folderPath: folder.data.path, classIds: [] }, tokens[1])).status === 400,
);
check(
'不能移动其他教师课件',
(await request(`/courseware/${item.id}/folder`, tokens[1], { folderPath: '' }, 'PATCH')).status === 403,
);
check(
'班级仅看到自己可见的课件',
(await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
);
check('其他教师不能访问本人文件', (await request(`/courseware/${item.id}`, tokens[1])).status === 403);
check(
'其他班级不能绕过列表下载',
(await request(`/courseware/${item.id}/download`, classTokens[1])).status === 403,
);
const downloaded = await fetch(`${base()}/courseware/${item.id}/download`, {
headers: { authorization: `Bearer ${classTokens[0]}` },
});
check(
'中文文件按附件下载且内容一致',
downloaded.headers.get('content-disposition')?.includes('attachment') &&
Buffer.from(await downloaded.arrayBuffer()).equals(bytes),
);
check('下载响应禁止共享缓存', downloaded.headers.get('cache-control')?.includes('no-store'));
await heartbeat(0, 'courseware-device-1');
await heartbeat(0, 'courseware-device-2');
await heartbeat(1, 'courseware-device-3');
check('仅存云端不会自动发送任务', (await pending(0, 'courseware-device-1')).data.length === 0);
check('未登记设备不能拉取传输', (await pending(0, 'unknown-device')).status === 403);
check('不能使用其他班级设备标识', (await pending(1, 'courseware-device-1')).status === 403);
const privateItem = (await upload({ classIds: [], name: '私有讲义.pdf' })).data as CoursewareDto;
check('仅自己可见文件不向班级披露', !(await list(classTokens[0]!)).some((c) => c.id === privateItem.id));
check(
'无法推送到课件可见范围以外',
(await request(`/courseware/${item.id}/push`, tokens[0], { classIds: [classes[1]!.id] })).status === 400,
);
check(
'他人不能改可见范围',
(await request(`/courseware/${item.id}/visibility`, tokens[1], { classIds: [] }, 'PATCH')).status === 403,
);
const pushed = (
await upload({ mode: 'push', name: '课堂练习.docx', classIds: classes.slice(0, 2).map((c) => c.id) })
).data as CoursewareDto;
check(
'推送模式先存云端实体',
!!(await prisma.courseware.findUnique({ where: { id: pushed.id } })) &&
(await list(tokens[0]!)).some((c) => c.id === pushed.id),
);
check(
'班级端不泄露其他可见班级',
(await list(classTokens[0]!)).every((c) => c.classes.every((v) => v.id === classes[0]!.id)),
);
const task = (await pending(0, 'courseware-device-1')).data[0];
check('推送任务可由离线班级下次上线拉取', task.courseware.id === pushed.id);
check(
'实时提示只发送到所选班级房间',
emitted
.filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
.every((e) =>
e.rooms.every((room) =>
[classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}:courseware`),
),
),
);
await new Promise<void>((resolve) => server.close(() => resolve()));
server = http.createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
check('重启服务端后仍保留未完成传输', (await pending(0, 'courseware-device-1')).data[0].id === task.id);
const ackRoute = `/courseware/transfers/${task.id}/ack`;
check(
'其他班级不能回执任务',
(await request(ackRoute, classTokens[1], { deviceKey: 'courseware-device-3' })).status === 404,
);
check(
'接收完成后可回执',
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
);
check(
'重复回执幂等',
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
);
check(
'回执仅清除本设备任务',
(await pending(0, 'courseware-device-1')).data.length === 0 &&
(await pending(0, 'courseware-device-2')).data.length === 1,
);
await request(`/courseware/${pushed.id}/visibility`, tokens[0], { classIds: [classes[0]!.id] }, 'PATCH');
check(
'撤回班级可见范围同时取消其待传任务',
(await pending(1, 'courseware-device-3')).data.length === 0 &&
!(await list(classTokens[1]!)).some((c) => c.id === pushed.id),
);
check(
'撤回后不能通过直链下载',
(await request(`/courseware/${pushed.id}/download`, classTokens[1])).status === 403,
);
await request(`/courseware/${pushed.id}/push`, tokens[0], { classIds: [classes[0]!.id] });
check('已归档课件可再次推送', (await pending(0, 'courseware-device-1')).data.length === 1);
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await prisma.permissionGroupItem.deleteMany({
where: { groupId: defaultGroup.id, key: 'courseware:write' },
});
check('收回写权限立即禁止上传', (await upload()).status === 403);
await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
const device = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: 'courseware-device-1' } });
await prisma.clientDevice.update({ where: { id: device.id }, data: { updateTargetVersion: '2.0.0' } });
const updateProgress = {
version: '2.0.0',
stage: 'downloading',
downloadedBytes: 50,
totalBytes: 100,
percent: 50,
error: '',
};
check(
'客户端可上报更新下载进度',
(
await request('/clients/update-progress', classTokens[0], {
deviceKey: device.deviceKey,
progress: updateProgress,
})
).status === 200,
);
check(
'其他班级不能冒报设备更新进度',
(
await request('/clients/update-progress', classTokens[1], {
deviceKey: device.deviceKey,
progress: updateProgress,
})
).status === 403,
);
await request('/clients/update-progress', classTokens[0], {
deviceKey: device.deviceKey,
progress: { ...updateProgress, stage: 'installing', percent: 100 },
});
await request('/clients/update-progress', classTokens[0], {
deviceKey: device.deviceKey,
progress: updateProgress,
});
let state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
check('迟到的下载状态不覆盖安装中状态', JSON.parse(state.updateProgress).stage === 'installing');
await request('/clients/heartbeat', classTokens[0], {
deviceKey: device.deviceKey,
clientVersion: '2.0.0',
});
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
check(
'新版上线心跳确认完成并记录实际版本',
JSON.parse(state.updateProgress).stage === 'completed' &&
JSON.parse(state.updateProgress).version === '2.0.0',
);
await prisma.clientDevice.update({
where: { id: device.id },
data: { updateTargetVersion: '', updateProgress: '' },
});
await request('/clients/update-progress', classTokens[0], {
deviceKey: device.deviceKey,
progress: updateProgress,
});
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
check('取消目标后忽略旧任务进度', state.updateProgress === '');
const teacherDeviceKey = 'courseware-teacher-device';
check(
'教师会话不能注册设备心跳',
(await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }))
.status === 403,
);
const teacherDevice = await prisma.clientDevice.create({
data: { deviceKey: teacherDeviceKey, updateTargetVersion: '2.0.0' },
});
check(
'旧未绑定设备也不能由教师冒报进度',
(
await request('/clients/update-progress', tokens[0], {
deviceKey: teacherDeviceKey,
progress: { ...updateProgress, version: '2.0.0' },
})
).status === 403,
);
check(
'拒绝请求不改变旧设备进度',
(await prisma.clientDevice.findUniqueOrThrow({ where: { id: teacherDevice.id } })).updateProgress === '',
);
const snapshot = await exportSnapshot(prisma, 'sqlite');
check(
'备份包含课件、可见范围、推送和回执',
snapshot.data.Courseware.length === 3 &&
snapshot.data.CoursewareReceipt.length === 1 &&
snapshot.data.CoursewarePush.length > 0 &&
snapshot.data.CoursewareVisibility.length > 0,
);
const oldSnapshot = structuredClone(snapshot);
for (const key of [
'Courseware',
'CoursewareFolder',
'CoursewarePush',
'CoursewareReceipt',
'CoursewareVisibility',
])
delete (oldSnapshot.data as Record<string, unknown>)[key];
assertValidSnapshot(oldSnapshot);
check('允许读取升级前的旧版快照', true);
await request(`/courseware/${pushed.id}`, tokens[0], undefined, 'DELETE');
check(
'删除课件级联清理推送任务',
(await pending(0, 'courseware-device-1')).data.length === 0 &&
(await pending(0, 'courseware-device-2')).data.length === 0,
);
check('删除实体后无残余文件', (await files()).length === 2);
const before = (await files()).length;
await new Promise<void>((resolve) => {
const metadata = { name: '中断上传.zip', size: 100000, classIds: [], mode: 'cloud' };
const req = http.request(
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(metadata))}`,
{
method: 'POST',
headers: {
authorization: `Bearer ${tokens[0]}`,
'content-type': 'application/octet-stream',
'content-length': '100000',
},
},
);
req.on('error', () => resolve());
req.write(Buffer.alloc(1000));
setTimeout(() => req.destroy(), 250);
});
await new Promise((resolve) => setTimeout(resolve, 250));
check(
'上传中断自动清理临时文件',
(await files()).length === before && !(await files()).some((file) => file.endsWith('.part')),
);
check(
'文件分类覆盖演示、文档、媒体与压缩包',
coursewareCategory('A.PPTX') === 'presentation' &&
coursewareCategory('a.pdf') === 'document' &&
coursewareCategory('v.mp4') === 'video' &&
coursewareCategory('a.7z') === 'archive',
);
await restoreSnapshot(prisma, 'sqlite', snapshot);
check(
'恢复数据库后保留课件共享和回执数据',
(await prisma.courseware.count()) === 3 &&
(await prisma.coursewareReceipt.count()) === 1 &&
(await prisma.coursewareFolder.count()) === 1,
);
console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
} finally {
server.closeAllConnections();
server.close();
await once(server, 'close').catch(() => undefined);
await prisma.$disconnect();
}