190 lines
8.7 KiB
TypeScript
190 lines
8.7 KiB
TypeScript
// @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<string, unknown>,
|
|
sceneContent: Record<string, unknown> = { 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<string, unknown> = { 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`,
|
|
},
|
|
});
|
|
});
|
|
});
|