// @vitest-environment node import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AdmZip from 'adm-zip'; import { createLearningCourseLibrary, LEARNING_DOWNLOAD_MAX_REDIRECTS, } from '@electron/services/learning-course-library'; import { assertLearningCoursePackageRegistered, closeLearningPlayerServer, createLearningPlayerServer, evictLearningCoursePackagesForAccount, getLearningPlayerServer, } from '@electron/services/learning-player-server'; import { LEARNING_ARCHIVE_MAX_BYTES } from '../../shared/learning'; import type { WorksSquareAccountBinding } from '@electron/services/works-square-session'; describe('Learning course library', () => { let root: string | null = null; const fetchImpl = vi.fn(); const getAccessToken = vi.fn(); const accountA = 'a'.repeat(64); const accountB = 'b'.repeat(64); let currentBinding: WorksSquareAccountBinding | null; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'makelore-learning-library-')); fetchImpl.mockReset(); getAccessToken.mockReset(); getAccessToken.mockResolvedValue('works-token'); currentBinding = { accountKey: accountA, epoch: 1 }; }); afterEach(async () => { if (root) await rm(root, { recursive: true, force: true }); root = null; }); function course(bytes: Uint8Array, overrides: Record = {}) { return { id: 'course-1', origin: 'ops_large', status: 'published', title: 'Python 基础入门', summary: null, language: 'zh-CN', contentHash: 'a'.repeat(64), archiveSha256: createHash('sha256').update(bytes).digest('hex'), archiveBytes: bytes.byteLength, formatVersion: 1, minPlayerVersion: '1.0.0', sceneCount: 10, capabilities: { sceneKinds: ['slide', 'interactive', 'quiz'], hasAudio: false, hasWhiteboard: true, hasAgent: true }, createdAt: '2026-08-16T00:00:00.000Z', publishedAt: '2026-08-16T00:00:00.000Z', ...overrides, }; } function createLibrary(overrides: Partial[0]> = {}) { return createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example', getAccountBinding: () => currentBinding, isCurrentAccountBinding: (binding) => binding.accountKey === currentBinding?.accountKey && binding.epoch === currentBinding.epoch, ...overrides, }); } function archivePath(accountKey = accountA): string { return join( root!, 'learning', 'accounts', accountKey, 'courses', 'course-1', 'a'.repeat(64), 'course.makelore-course.zip', ); } it('streams and installs a package only after hash and size verification', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]); const metadata = course(archive); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200, headers: { 'Content-Type': 'application/zip' } })); const library = createLibrary({ now: () => new Date('2026-08-16T08:00:00.000Z'), }); const installed = await library.download('course-1'); expect(new Uint8Array(await readFile(archivePath()))).toEqual(archive); expect(installed).not.toHaveProperty('archivePath'); expect(installed).toMatchObject({ schemaVersion: 1, installedAt: '2026-08-16T08:00:00.000Z' }); expect(installed.course).not.toHaveProperty('version'); await expect(library.listInstalled()).resolves.toEqual([installed]); expect(fetchImpl).toHaveBeenNthCalledWith(2, 'https://square.example/api/learning/courses/course-1/download', expect.objectContaining({ headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' }, })); }); it('removes partial files when the archive digest does not match', async () => { const expected = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1]); const tampered = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 2]); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(expected)), { status: 200 })) .mockResolvedValueOnce(new Response(tampered, { status: 200 })); const library = createLibrary(); await expect(library.download('course-1')).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' }); await expect(library.listInstalled()).resolves.toEqual([]); }); it('rejects cross-origin download redirects', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 9]); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 307, headers: { Location: 'https://objects.example/course.zip' } })); const library = createLibrary(); await expect(library.download('course-1')).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_INVALID', message: '课程下载地址不安全', }); expect(fetchImpl).toHaveBeenCalledTimes(2); }); it('follows controlled same-origin redirects manually without forwarding Bearer', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 9]); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 307, headers: { Location: '/downloads/one' } })) .mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: '/downloads/two' } })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLibrary(); await library.download('course-1'); expect(fetchImpl).toHaveBeenNthCalledWith(3, new URL('https://square.example/downloads/one'), { headers: { Accept: 'application/zip' }, redirect: 'manual', }); expect(fetchImpl).toHaveBeenNthCalledWith(4, new URL('https://square.example/downloads/two'), { headers: { Accept: 'application/zip' }, redirect: 'manual', }); }); it('rejects oversized archive metadata before requesting download bytes', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify(course(archive, { archiveBytes: LEARNING_ARCHIVE_MAX_BYTES + 1, })), { status: 200 })); const library = createLibrary(); await expect(library.download('course-1')).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_TOO_LARGE', message: '课程包超过本机允许的大小', }); expect(fetchImpl).toHaveBeenCalledOnce(); }); it('rejects redirect loops', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: '/downloads/one' } })) .mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: '/downloads/two' } })) .mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: '/downloads/one' } })); await expect(createLibrary().download('course-1')).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_LOOP', }); }); it('rejects redirect chains beyond the bounded hop count', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })); for (let hop = 0; hop <= LEARNING_DOWNLOAD_MAX_REDIRECTS; hop += 1) { fetchImpl.mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: `/downloads/${hop}` }, })); } await expect(createLibrary().download('course-1')).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_LIMIT', }); }); it('keeps installed courses invisible across account partitions', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 4]); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLibrary(); const installedForA = await library.download('course-1'); expect(await library.listInstalled()).toEqual([installedForA]); currentBinding = { accountKey: accountB, epoch: 2 }; await expect(library.listInstalled()).resolves.toEqual([]); expect(await readFile(archivePath(accountA))).toEqual(Buffer.from(archive)); }); it('fails closed when the account changes during an in-flight download', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 4]); let resolveMetadata!: (response: Response) => void; fetchImpl.mockImplementationOnce(() => new Promise((resolve) => { resolveMetadata = resolve; })); const library = createLibrary(); const pending = library.download('course-1'); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledOnce()); currentBinding = { accountKey: accountB, epoch: 2 }; resolveMetadata(new Response(JSON.stringify(course(archive)), { status: 200 })); await expect(pending).rejects.toMatchObject({ code: 'LEARNING_ACCOUNT_CHANGED', message: '登录账号已更改,请重试', }); expect(fetchImpl).toHaveBeenCalledOnce(); }); it('does not refresh a 401 after the account changes while cancelling its response', async () => { let resolveCancel!: () => void; const cancel = vi.fn(() => new Promise((resolve) => { resolveCancel = resolve; })); fetchImpl.mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 401 })); const pending = createLibrary().download('course-1'); await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()); currentBinding = { accountKey: accountB, epoch: 2 }; resolveCancel(); await expect(pending).rejects.toMatchObject({ code: 'LEARNING_ACCOUNT_CHANGED', message: '登录账号已更改,请重试', }); expect(getAccessToken).toHaveBeenCalledOnce(); expect(fetchImpl).toHaveBeenCalledOnce(); }); it('fails closed when no stable account identity is available', async () => { currentBinding = null; await expect(createLibrary().listInstalled()).rejects.toMatchObject({ code: 'LEARNING_AUTH_REQUIRED', message: '请先登录', }); expect(fetchImpl).not.toHaveBeenCalled(); }); it('reads the frozen classroom payload from an installed package', async () => { const zip = new AdmZip(); zip.addFile('classroom.json', Buffer.from(JSON.stringify({ stage: { id: 'stage-local', name: 'Python 基础入门' }, scenes: Array.from({ length: 10 }, (_, index) => ({ id: `scene-${index}`, order: index, type: 'slide' })), }))); const archive = zip.toBuffer(); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLibrary(); await library.download('course-1'); expect(await readFile(archivePath())).toEqual(archive); expect(JSON.parse(new AdmZip(archivePath()).readAsText('classroom.json')).stage.id).toBe('stage-local'); await expect(library.readClassroom('course-1')).resolves.toMatchObject({ courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId: null, moduleContentHash: 'a'.repeat(64), modules: [{ moduleId: 'main', title: 'Python 基础入门', sceneCount: 10 }], classroom: { stage: { id: 'stage-local' }, scenes: expect.any(Array) }, }); }); it('keeps authoritative classroom resolution registration-free until the player path explicitly registers it', async () => { const zip = new AdmZip(); zip.addFile('classroom.json', Buffer.from(JSON.stringify({ stage: { id: 'stage-local', name: 'Python 基础入门' }, scenes: Array.from({ length: 10 }, (_, index) => ({ id: `scene-${index}`, order: index, type: 'slide' })), }))); const archive = zip.toBuffer(); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLibrary(); await library.download('course-1'); const artifactRoot = join(root!, 'registration-order-player'); const html = '
player
'; await mkdir(join(artifactRoot, '_next', 'static'), { recursive: true }); await mkdir(join(artifactRoot, 'avatars'), { recursive: true }); await mkdir(join(artifactRoot, 'public'), { recursive: true }); await writeFile(join(artifactRoot, 'index.html'), html); await writeFile(join(artifactRoot, 'artifact.json'), JSON.stringify({ schemaVersion: 1, entrypoint: 'index.html', htmlSha256: createHash('sha256').update(html).digest('hex'), })); const previousArtifactRoot = process.env.MAKELORE_LEARNING_PLAYER_ROOT; process.env.MAKELORE_LEARNING_PLAYER_ROOT = artifactRoot; evictLearningCoursePackagesForAccount(accountA); await closeLearningPlayerServer(); try { await getLearningPlayerServer(accountA); await expect(library.resolveClassroom('course-1')).resolves.toMatchObject({ courseId: 'course-1' }); expect(() => assertLearningCoursePackageRegistered(accountA, 'course-1', 'a'.repeat(64))).toThrow( 'Learning course package is not registered for the active account', ); await library.readClassroom('course-1'); expect(() => assertLearningCoursePackageRegistered(accountA, 'course-1', 'a'.repeat(64))).not.toThrow(); } finally { await closeLearningPlayerServer(); if (previousArtifactRoot === undefined) delete process.env.MAKELORE_LEARNING_PLAYER_ROOT; else process.env.MAKELORE_LEARNING_PLAYER_ROOT = previousArtifactRoot; } }); it('accepts large-course metadata and expands a selected frozen module for the production Stage', async () => { const moduleContentHash = 'b'.repeat(64); const moduleId = 'python-intro'; const moduleRoot = `modules/${moduleId}/`; const zip = new AdmZip(); zip.addFile('course.json', Buffer.from(JSON.stringify({ schemaVersion: 1, modules: [{ moduleId, title: 'Python 入门模块', summary: '变量、表达式与第一次练习', sceneCount: 1, contentHash: moduleContentHash, path: `modules/${moduleId}`, }], }))); zip.addFile(`${moduleRoot}manifest.json`, Buffer.from(JSON.stringify({ formatVersion: 1, exportedAt: '2026-08-16T00:00:00.000Z', appVersion: '1.0.0', stage: { name: 'Python 入门模块', language: 'zh-CN', interactiveMode: true, createdAt: 1, updatedAt: 2, }, agents: [{ name: '麦洛老师', role: 'teacher', persona: '循序渐进地讲解', avatar: '/avatars/teacher.png', color: '#3b82f6', priority: 0, }], scenes: [{ type: 'slide', title: '变量是什么', order: 0, content: { type: 'slide', imageId: 'diagram' }, actions: [{ type: 'speech', audioRef: 'audio/line.mp3', text: '变量用于保存数据。' }], }], mediaIndex: { 'audio/line.mp3': { type: 'audio', mimeType: 'audio/mpeg' }, 'media/diagram.png': { type: 'generated', mimeType: 'image/png' }, }, }))); zip.addFile(`${moduleRoot}bundle.json`, Buffer.from(JSON.stringify({ meta: { coursewareId: moduleId, version: 1, stageName: 'Python 入门模块', sceneCount: 1, contentHash: moduleContentHash, }, completeness: { complete: true }, }))); zip.addFile(`${moduleRoot}quiz/quiz.json`, Buffer.from(JSON.stringify({ quizzes: [] }))); zip.addFile(`${moduleRoot}knowledge/knowledge.json`, Buffer.from(JSON.stringify({ documents: [] }))); zip.addFile(`${moduleRoot}audio/line.mp3`, Buffer.from('audio-bytes')); zip.addFile(`${moduleRoot}media/diagram.png`, Buffer.from('image-bytes')); const archive = zip.toBuffer(); const metadata = course(archive, { sceneCount: 1, capabilities: { modular: true, orderedModules: true, productionStagePerModule: true }, modules: [{ moduleId, title: 'Python 入门模块', summary: '变量、表达式与第一次练习', sceneCount: 1, contentHash: moduleContentHash, }], }); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLibrary(); await library.download('course-1'); const payload = await library.readClassroom('course-1', moduleId); expect(payload).toMatchObject({ courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId, moduleContentHash, modules: [{ moduleId, title: 'Python 入门模块', sceneCount: 1, contentHash: moduleContentHash }], classroom: { stage: { id: `learning_course-1_${moduleId}`, name: 'Python 入门模块', interactiveMode: true, }, scenes: [{ title: '变量是什么', order: 0 }], }, }); expect(payload.classroom.scenes[0]).toMatchObject({ content: { imageId: `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}media/diagram.png`, }, actions: [{ type: 'speech', audioUrl: `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}audio/line.mp3`, }], }); // The same verified aggregate package registered by the consumer must be // able to serve the rewritten immutable media URL to the production Stage. const artifactRoot = join(root!, 'player-artifact'); const html = '
player
'; await mkdir(join(artifactRoot, '_next', 'static'), { recursive: true }); await mkdir(join(artifactRoot, 'avatars'), { recursive: true }); await mkdir(join(artifactRoot, 'public'), { recursive: true }); await writeFile(join(artifactRoot, 'index.html'), html); await writeFile(join(artifactRoot, 'artifact.json'), JSON.stringify({ schemaVersion: 1, entrypoint: 'index.html', htmlSha256: createHash('sha256').update(html).digest('hex'), })); await writeFile(join(artifactRoot, '_next', 'static', 'app.js'), 'window.player = true'); await writeFile(join(artifactRoot, 'avatars', 'teacher.png'), 'avatar'); await writeFile(join(artifactRoot, 'public', 'openmaic-mark.png'), 'mark'); const player = await createLearningPlayerServer({ artifactRoot, accountKey: accountA }); try { const index = await fetch(player.url); const cookie = index.headers.get('set-cookie')?.split(';', 1)[0]; const response = await fetch(new URL( `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}audio/line.mp3`, player.url, ), { headers: { Cookie: cookie! } }); expect(response.status).toBe(200); expect(await response.text()).toBe('audio-bytes'); } finally { await player.close(); } }); });