// @vitest-environment node import AdmZip from 'adm-zip'; import { describe, expect, it, vi } from 'vitest'; import { readLearningPackageClassroom } from '@electron/services/learning-package-consumer'; import type { LearningCourse } from '../../shared/learning'; const course: LearningCourse = { id: 'course-1', origin: 'user_single', status: 'published', title: '课程', summary: null, language: 'zh-CN', contentHash: 'a'.repeat(64), archiveSha256: 'b'.repeat(64), archiveBytes: 1, formatVersion: 1, minPlayerVersion: '1.0.0', sceneCount: 1, capabilities: { sceneKinds: ['slide'], hasAudio: false, hasWhiteboard: false, hasAgent: false }, createdAt: '2026-08-16T00:00:00.000Z', publishedAt: '2026-08-16T00:00:00.000Z', }; const modularCourse: LearningCourse = { ...course, capabilities: { modular: true, orderedModules: true, productionStagePerModule: true }, modules: [{ moduleId: 'module-1', title: '模块 1', summary: null, sceneCount: 1, contentHash: 'c'.repeat(64), }], }; function frozenArchive( mediaIndex: Record, sceneContent: Record = { type: 'slide' }, ): AdmZip { const root = 'modules/module-1/'; const zip = new AdmZip(); zip.addFile(`${root}manifest.json`, Buffer.from(JSON.stringify({ stage: { name: '模块 1' }, agents: [], scenes: [{ type: 'slide', content: sceneContent }], mediaIndex, }))); zip.addFile(`${root}bundle.json`, Buffer.from(JSON.stringify({ meta: { coursewareId: 'module-1', sceneCount: 1, contentHash: 'c'.repeat(64), stageName: '模块 1', }, completeness: { complete: true }, }))); zip.addFile(`${root}quiz/quiz.json`, Buffer.from('{}')); zip.addFile(`${root}knowledge/knowledge.json`, Buffer.from('{}')); for (const path of Object.keys(mediaIndex)) { if (/^[A-Za-z0-9_/-]+\.[A-Za-z0-9]+$/u.test(path)) { zip.addFile(`${root}${path}`, Buffer.from('safe media')); } } return new AdmZip(zip.toBuffer()); } describe('Learning package consumer bounds', () => { const metadataEntry = (entryName: string, size = 1, compressedSize = size) => ({ entryName, isDirectory: false, header: { size, compressedSize }, getDataAsync: vi.fn(), }); it('rejects excessive ZIP entries from metadata before decompressing data', async () => { const getDataAsync = vi.fn(); const entries = Array.from({ length: 4_097 }, (_, index) => ({ entryName: `entry-${index}.json`, isDirectory: false, header: { size: 1, compressedSize: 1 }, getDataAsync, })); const zip = { getEntries: () => entries } as unknown as AdmZip; await expect(readLearningPackageClassroom(zip, course)).rejects.toThrow('课程包结构过于复杂'); expect(getDataAsync).not.toHaveBeenCalled(); }); it.each([ ['single-entry size', [metadataEntry('classroom.json', 64 * 1024 * 1024 + 1)], '课程包资源超出限制'], ['total uncompressed size', Array.from({ length: 9 }, (_, index) => metadataEntry(`asset-${index}`, 64 * 1024 * 1024)), '课程包资源超出限制'], ['module count', Array.from({ length: 129 }, (_, index) => metadataEntry(`modules/${index}/manifest.json`)), '课程包模块数量超出限制'], ])('rejects excessive %s from ZIP metadata', async (_label, entries, message) => { const zip = { getEntries: () => entries, getEntry: () => null } as unknown as AdmZip; await expect(readLearningPackageClassroom(zip, course)).rejects.toThrow(message); expect(entries.every((entry) => !vi.mocked(entry.getDataAsync).mock.calls.length)).toBe(true); }); it('rejects deeply nested JSON before recursive playback rewriting', async () => { let nested: Record = { leaf: true }; for (let index = 0; index < 70; index += 1) nested = { child: nested }; const archive = new AdmZip(); archive.addFile('classroom.json', Buffer.from(JSON.stringify({ stage: { id: 'stage-1', nested }, scenes: [{ id: 'scene-1' }], }))); const zip = new AdmZip(archive.toBuffer()); await expect(readLearningPackageClassroom(zip, course)).rejects.toThrow('classroom.json 无效'); }); it('rejects suspicious high-ratio entries using ZIP metadata', async () => { const zip = { getEntries: () => [{ entryName: 'classroom.json', isDirectory: false, header: { size: 2 * 1024 * 1024, compressedSize: 1 }, getDataAsync: vi.fn(), }], } as unknown as AdmZip; await expect(readLearningPackageClassroom(zip, course)).rejects.toThrow('课程包压缩数据异常'); }); it('rejects excessive legacy scene count before returning playback data', async () => { const data = Buffer.from(JSON.stringify({ stage: { id: 'stage-1' }, scenes: Array.from({ length: 10_001 }, (_, index) => ({ id: `scene-${index}` })), })); const entry = { ...metadataEntry('classroom.json', data.byteLength), getDataAsync: (callback: (value: Buffer, error: string) => void) => callback(data, ''), }; const zip = { getEntries: () => [entry], getEntry: (name: string) => name === 'classroom.json' ? entry : null, } as unknown as AdmZip; await expect(readLearningPackageClassroom(zip, { ...course, sceneCount: 10_001 })) .rejects.toThrow('课程包场景数量超出限制'); }); it.each([ ['HTML', 'media/lesson.html', 'text/html'], ['HTM', 'media/lesson.htm', 'text/html'], ['SVG', 'media/icon.svg', 'image/svg+xml'], ['XML', 'media/data.xml', 'application/xml'], ['XHTML', 'media/page.xhtml', 'application/xhtml+xml'], ['JavaScript', 'media/lesson.js', 'text/javascript'], ['module JavaScript', 'media/lesson.mjs', 'text/javascript'], ['PDF', 'media/handout.pdf', 'application/pdf'], ['unknown extension', 'media/blob.bin', 'application/octet-stream'], ['unsupported BMP image', 'media/diagram.bmp', 'image/bmp'], ['unsupported AVIF image', 'media/diagram.avif', 'image/avif'], ['unsupported FLAC audio', 'audio/line.flac', 'audio/flac'], ['unsupported OGV video', 'media/clip.ogv', 'video/ogg'], ['unsupported MOV video', 'media/clip.mov', 'video/quicktime'], ['unsupported JSON data', 'media/data.json', 'application/json'], ['arbitrary asset directory', 'assets/diagram.png', 'image/png'], ['arbitrary nested directory', 'media-assets/lesson.mp3', 'audio/mpeg'], ['module-prefixed relative path', 'modules/module-1/fonts/lesson.woff2', 'font/woff2'], ['extension mismatch', 'media/diagram.png', 'image/jpeg'], ['missing declaration', 'media/diagram.png', undefined], ['double extension', 'media/lesson.html.png', 'image/png'], ['versioned double extension', 'media/lesson.v1.png', 'image/png'], ['control character', 'media/lesson\u0000.png', 'image/png'], ['path traversal', 'media/../lesson.png', 'image/png'], ])('rejects unsafe %s media in a frozen manifest', async (_label, path, mimeType) => { const archive = frozenArchive({ [path]: { type: 'image', mimeType } }); await expect(readLearningPackageClassroom(archive, modularCourse)) .rejects.toThrow('课程包包含不安全媒体资源'); }); it.each([ ['AAC audio', 'audio/line.aac', 'audio/aac'], ['GIF image', 'media/diagram.gif', 'image/gif'], ['JPEG image', 'media/diagram.jpeg', 'image/jpeg'], ['JPG image', 'media/diagram.jpg', 'image/jpeg'], ['M4A audio', 'audio/line.m4a', 'audio/mp4'], ['MP3 audio', 'audio/line.mp3', 'audio/mpeg'], ['MP4 video', 'media/clip.mp4', 'video/mp4'], ['OGG audio', 'audio/line.ogg', 'audio/ogg'], ['OTF font', 'fonts/lesson.otf', 'font/otf'], ['PNG image', 'media/diagram.png', 'image/png'], ['nested media', 'media/icons/diagram.webp', 'image/webp'], ['TTF font', 'fonts/lesson.ttf', 'font/ttf'], ['WAV audio', 'audio/line.wav', 'audio/wav'], ['WebM video', 'media/clip.webm', 'video/webm'], ['WebP image', 'media/diagram.webp', 'image/webp'], ['WOFF font', 'fonts/lesson.woff', 'font/woff'], ['WOFF2 font', 'fonts/lesson.woff2', 'font/woff2'], ])('keeps passive %s media playable', async (_label, path, mimeType) => { const archive = frozenArchive({ [path]: { type: 'generated', mimeType } }); await expect(readLearningPackageClassroom(archive, modularCourse)).resolves.toMatchObject({ courseId: modularCourse.id, moduleId: 'module-1', }); }); it('prepends the authoritative module root exactly once to media URLs', async () => { const archive = frozenArchive( { 'fonts/lesson.woff2': { type: 'font', mimeType: 'font/woff2' } }, { type: 'slide', fontUrl: 'fonts/lesson.woff2' }, ); const payload = await readLearningPackageClassroom(archive, modularCourse); expect(payload.classroom.scenes[0]).toMatchObject({ content: { fontUrl: `/course-assets/course-1/${'a'.repeat(64)}/modules/module-1/fonts/lesson.woff2`, }, }); }); });