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((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 { 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}`)), ), ); await new Promise((resolve) => server.close(() => resolve())); server = http.createServer(createApp()); await new Promise((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'; await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }); let teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } }); check('教师电脑设备不绑定班级', teacherDevice.classId === null); await prisma.clientDevice.update({ where: { id: teacherDevice.id }, data: { updateTargetVersion: '2.0.0' }, }); check( '教师电脑可上报自己的更新进度', ( await request('/clients/update-progress', tokens[0], { deviceKey: teacherDeviceKey, progress: { ...updateProgress, version: '2.0.0' }, }) ).status === 200, ); teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } }); check('教师电脑更新进度已记录', JSON.parse(teacherDevice.updateProgress).stage === 'downloading'); 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)[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((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(); }