Files
makelore/tests/unit/learning-course-library.test.ts
brother7 f7171a471a
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
merge: integrate remote learning module safely
2026-08-17 01:05:49 +08:00

471 lines
19 KiB
TypeScript

// @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<typeof fetch>();
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<string, unknown> = {}) {
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<Parameters<typeof createLearningCourseLibrary>[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<Response>((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<void>((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 = '<!doctype html><main>player</main>';
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 = '<!doctype html><main>player</main>';
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();
}
});
});