Files
makelore/tests/unit/learning-player-server.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

342 lines
17 KiB
TypeScript

// @vitest-environment node
import { createHash } from 'node:crypto';
import { request as httpRequest } from 'node:http';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import AdmZip from 'adm-zip';
import { readLearningPackageClassroom } from '@electron/services/learning-package-consumer';
import {
assertLearningCoursePackageRegistered,
closeLearningPlayerServer,
createLearningPlayerServer,
evictLearningCoursePackagesForAccount,
getLearningPlayerServer,
registerLearningCoursePackage,
unregisterLearningCoursePackage,
} from '@electron/services/learning-player-server';
import type { LearningCourse } from '../../shared/learning';
const ACCOUNT_KEY = 'account-test';
async function writePlayerArtifact(root: string): Promise<void> {
const html = '<!doctype html><main>player</main>';
await mkdir(join(root, '_next', 'static', 'chunks'), { recursive: true });
await mkdir(join(root, 'avatars'), { recursive: true });
await mkdir(join(root, 'public'), { recursive: true });
await writeFile(join(root, 'index.html'), html);
await writeFile(join(root, 'artifact.json'), JSON.stringify({
schemaVersion: 1,
entrypoint: 'index.html',
htmlSha256: createHash('sha256').update(html).digest('hex'),
}));
await writeFile(join(root, '_next', 'static', 'chunks', 'app.js'), 'window.player = true');
await writeFile(join(root, 'avatars', 'teacher.png'), 'avatar');
await writeFile(join(root, 'public', 'openmaic-mark.png'), 'mark');
}
function requestStatus(url: URL, host: string): Promise<number> {
return new Promise((resolve, reject) => {
const request = httpRequest(url, { headers: { Host: host } }, (response) => {
response.resume();
response.once('end', () => resolve(response.statusCode || 0));
});
request.once('error', reject);
request.end();
});
}
describe('Learning player static server', () => {
let root: string | null = null;
let close: (() => Promise<void>) | null = null;
afterEach(async () => {
await closeLearningPlayerServer();
if (close) await close();
evictLearningCoursePackagesForAccount(ACCOUNT_KEY);
evictLearningCoursePackagesForAccount('other-account');
if (root) await rm(root, { recursive: true, force: true });
close = null;
root = null;
delete process.env.MAKELORE_LEARNING_PLAYER_ROOT;
});
it('serves only the embedded OpenMAIC player and immutable static assets', async () => {
root = await mkdtemp(join(tmpdir(), 'makelore-player-server-'));
await writePlayerArtifact(root);
const server = await createLearningPlayerServer({ accountKey: ACCOUNT_KEY, artifactRoot: root });
close = server.close;
const index = await fetch(server.url);
expect(index.status).toBe(200);
expect(await index.text()).toContain('player');
expect(index.headers.get('content-security-policy')).toContain("object-src 'none'");
const cookie = index.headers.get('set-cookie')?.split(';', 1)[0];
expect(cookie).toMatch(/^makelore_learning_player=/);
const asset = await fetch(new URL('/_next/static/chunks/app.js', server.url), {
headers: { Cookie: cookie! },
});
expect(asset.headers.get('cache-control')).toContain('immutable');
expect(await asset.text()).toContain('window.player');
const avatar = await fetch(new URL('/avatars/teacher.png', server.url), { headers: { Cookie: cookie! } });
expect(avatar.status).toBe(200);
expect(avatar.headers.get('content-type')).toBe('image/png');
const publicAsset = await fetch(new URL('/openmaic-mark.png', server.url), { headers: { Cookie: cookie! } });
expect(publicAsset.status).toBe(200);
expect(publicAsset.headers.get('cache-control')).toContain('immutable');
expect(await publicAsset.text()).toBe('mark');
expect((await fetch(new URL('/package.json', server.url))).status).toBe(404);
expect((await fetch(new URL('/%2e%2e/artifact.json', server.url))).status).toBe(404);
});
it('rejects missing or wrong nonces and a mismatched Host header', async () => {
root = await mkdtemp(join(tmpdir(), 'makelore-player-server-'));
await writePlayerArtifact(root);
const server = await createLearningPlayerServer({ accountKey: ACCOUNT_KEY, artifactRoot: root });
close = server.close;
const protectedUrl = new URL(server.url);
expect((await fetch(new URL('/makelore-player?embedded=1', server.url))).status).toBe(404);
expect((await fetch(new URL('/wrong-nonce/makelore-player?embedded=1', server.url))).status).toBe(404);
expect(await requestStatus(protectedUrl, `localhost:${protectedUrl.port}`)).toBe(404);
expect((await fetch(protectedUrl)).status).toBe(200);
});
it('serves only passive audio, media, and font entries from a verified registered course package', async () => {
root = await mkdtemp(join(tmpdir(), 'makelore-player-server-'));
await writePlayerArtifact(root);
const archivePath = join(root, 'course.zip');
const archive = new AdmZip();
archive.addFile('modules/module-1/audio/line.mp3', Buffer.from('audio-bytes'));
archive.addFile('modules/module-1/media/diagram.png', Buffer.from('image-bytes'));
archive.addFile('fonts/lesson.woff', Buffer.from('root-font-bytes'));
archive.addFile('modules/module-1/fonts/lesson.woff2', Buffer.from('module-font-bytes'));
archive.addFile('scripts/lesson.woff', Buffer.from('wrong-root-font'));
archive.addFile('modules/module-1/other/lesson.woff', Buffer.from('wrong-module-font'));
archive.addFile('modules/module-1/media/polyglot.png', Buffer.from('<script>alert(1)</script>'));
archive.addFile('modules/module-1/media/attack.html', Buffer.from('<script>parent.postMessage({ type: "makelore:player:ready" }, "*")</script>'));
archive.addFile('modules/module-1/media/attack.htm', Buffer.from('<script>alert(1)</script>'));
archive.addFile('modules/module-1/media/attack.svg', Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'));
archive.addFile('modules/module-1/media/attack.xml', Buffer.from('<?xml version="1.0"?><root/>'));
archive.addFile('modules/module-1/media/attack.xhtml', Buffer.from('<html xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></html>'));
archive.addFile('modules/module-1/media/attack.js', Buffer.from('parent.postMessage({ type: "makelore:player:ready" }, "*")'));
archive.addFile('modules/module-1/media/attack.mjs', Buffer.from('parent.postMessage({ type: "makelore:player:ready" }, "*")'));
archive.addFile('modules/module-1/media/attack.pdf', Buffer.from('%PDF-1.7'));
archive.addFile('modules/module-1/bundle.json', Buffer.from('{}'));
archive.writeZip(archivePath);
const contentHash = 'a'.repeat(64);
registerLearningCoursePackage(ACCOUNT_KEY, 'course-assets-test', contentHash, archivePath);
const otherAccountServer = await createLearningPlayerServer({ accountKey: 'other-account', artifactRoot: root });
const otherIndex = await fetch(otherAccountServer.url);
const otherCookie = otherIndex.headers.get('set-cookie')?.split(';', 1)[0];
expect((await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/modules/module-1/audio/line.mp3`,
otherAccountServer.url,
), { headers: { Cookie: otherCookie! } })).status).toBe(404);
await otherAccountServer.close();
const server = await createLearningPlayerServer({ accountKey: ACCOUNT_KEY, artifactRoot: root });
close = server.close;
const protectedPlayerUrl = new URL(server.url);
const noncePrefix = protectedPlayerUrl.pathname.slice(0, protectedPlayerUrl.pathname.lastIndexOf('/'));
for (const extension of ['html', 'htm', 'svg', 'xml', 'xhtml', 'js', 'mjs', 'pdf']) {
const response = await fetch(new URL(
`${noncePrefix}/course-assets/course-assets-test/${contentHash}/modules/module-1/media/attack.${extension}`,
protectedPlayerUrl.origin,
));
expect(response.status, extension).toBe(404);
expect(response.headers.get('x-content-type-options'), extension).toBe('nosniff');
expect(response.headers.get('content-security-policy'), extension).toContain("default-src 'none'");
expect(response.headers.get('content-security-policy'), extension).toContain('sandbox');
expect(response.headers.get('content-security-policy'), extension).toContain("frame-ancestors 'none'");
}
const polyglot = await fetch(new URL(
`${noncePrefix}/course-assets/course-assets-test/${contentHash}/modules/module-1/media/polyglot.png`,
protectedPlayerUrl.origin,
));
expect(polyglot.status).toBe(200);
expect(polyglot.headers.get('content-type')).toBe('image/png');
expect(polyglot.headers.get('x-content-type-options')).toBe('nosniff');
expect(polyglot.headers.get('content-security-policy')).toContain("default-src 'none'");
expect(await polyglot.text()).toContain('<script>');
const index = await fetch(server.url);
const cookie = index.headers.get('set-cookie')?.split(';', 1)[0];
expect(cookie).toBeTruthy();
const audio = await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/modules/module-1/audio/line.mp3`,
server.url,
), { headers: { Cookie: cookie! } });
expect(audio.status).toBe(200);
expect(audio.headers.get('cache-control')).toBe('private, no-store');
expect(audio.headers.get('content-type')).toBe('audio/mpeg');
expect(audio.headers.get('x-content-type-options')).toBe('nosniff');
expect(audio.headers.get('content-security-policy')).toContain("default-src 'none'");
expect(audio.headers.get('cross-origin-resource-policy')).toBe('same-origin');
expect(await audio.text()).toBe('audio-bytes');
const rootFont = await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/fonts/lesson.woff`,
server.url,
), { headers: { Cookie: cookie! } });
expect(rootFont.status).toBe(200);
expect(rootFont.headers.get('content-type')).toBe('font/woff');
expect(rootFont.headers.get('x-content-type-options')).toBe('nosniff');
expect(rootFont.headers.get('content-security-policy')).toContain("default-src 'none'");
expect(await rootFont.text()).toBe('root-font-bytes');
const moduleFont = await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/modules/module-1/fonts/lesson.woff2`,
server.url,
), { headers: { Cookie: cookie! } });
expect(moduleFont.status).toBe(200);
expect(moduleFont.headers.get('content-type')).toBe('font/woff2');
expect(moduleFont.headers.get('x-content-type-options')).toBe('nosniff');
expect(moduleFont.headers.get('content-security-policy')).toContain("default-src 'none'");
expect(await moduleFont.text()).toBe('module-font-bytes');
for (const entryName of ['scripts/lesson.woff', 'modules/module-1/other/lesson.woff']) {
expect((await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/${entryName}`,
server.url,
), { headers: { Cookie: cookie! } })).status).toBe(404);
}
expect((await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/modules/module-1/bundle.json`,
server.url,
), { headers: { Cookie: cookie! } })).status).toBe(404);
const unauthenticatedAudio = await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/modules/module-1/audio/line.mp3`,
server.url,
));
expect(unauthenticatedAudio.status).toBe(404);
expect(unauthenticatedAudio.headers.get('cache-control')).toBe('private, no-store');
expect(unauthenticatedAudio.headers.get('x-content-type-options')).toBe('nosniff');
expect(unauthenticatedAudio.headers.get('content-security-policy')).toContain("default-src 'none'");
expect(unregisterLearningCoursePackage(ACCOUNT_KEY, 'course-assets-test', contentHash)).toBe(true);
expect((await fetch(new URL(
`/course-assets/course-assets-test/${contentHash}/modules/module-1/audio/line.mp3`,
server.url,
), { headers: { Cookie: cookie! } })).status).toBe(404);
});
it('asserts only the active account aggregate and invalidates old sessions on switch, eviction, and close', async () => {
root = await mkdtemp(join(tmpdir(), 'makelore-player-server-'));
await writePlayerArtifact(root);
process.env.MAKELORE_LEARNING_PLAYER_ROOT = root;
const archivePath = join(root, 'course.zip');
const archive = new AdmZip();
archive.addFile('modules/module-1/audio/line.mp3', Buffer.from('audio-bytes'));
archive.writeZip(archivePath);
const courseId = 'course-assets-test';
const contentHash = 'a'.repeat(64);
registerLearningCoursePackage(ACCOUNT_KEY, courseId, contentHash, archivePath);
const accountServer = await getLearningPlayerServer(ACCOUNT_KEY);
expect(() => assertLearningCoursePackageRegistered(ACCOUNT_KEY, courseId, contentHash)).not.toThrow();
const accountIndex = await fetch(accountServer.url);
const accountCookie = accountIndex.headers.get('set-cookie')?.split(';', 1)[0];
expect(accountCookie).toBeTruthy();
registerLearningCoursePackage('other-account', courseId, contentHash, archivePath);
const otherServer = await getLearningPlayerServer('other-account');
expect(() => assertLearningCoursePackageRegistered(ACCOUNT_KEY, courseId, contentHash)).toThrow(/active account/);
expect(() => assertLearningCoursePackageRegistered('other-account', courseId, contentHash)).not.toThrow();
await expect(fetch(accountServer.url, { headers: { Cookie: accountCookie! } })).rejects.toThrow();
evictLearningCoursePackagesForAccount('other-account');
expect(() => assertLearningCoursePackageRegistered('other-account', courseId, contentHash)).toThrow(/not registered/);
registerLearningCoursePackage('other-account', courseId, contentHash, archivePath);
expect(() => assertLearningCoursePackageRegistered('other-account', courseId, contentHash)).not.toThrow();
await closeLearningPlayerServer();
expect(() => assertLearningCoursePackageRegistered('other-account', courseId, contentHash)).toThrow(/active account/);
await expect(fetch(otherServer.url)).rejects.toThrow();
});
it('serves the exact module font URL produced by the package consumer', async () => {
root = await mkdtemp(join(tmpdir(), 'makelore-player-server-'));
await writePlayerArtifact(root);
const archivePath = join(root, 'course.zip');
const courseContentHash = 'd'.repeat(64);
const moduleContentHash = 'e'.repeat(64);
const course: LearningCourse = {
id: 'course-font-contract',
origin: 'user_single',
status: 'published',
title: 'Font contract course',
summary: null,
language: 'en',
contentHash: courseContentHash,
archiveSha256: 'f'.repeat(64),
archiveBytes: 1,
formatVersion: 1,
minPlayerVersion: '1.0.0',
sceneCount: 1,
capabilities: { modular: true, orderedModules: true, productionStagePerModule: true },
modules: [{
moduleId: 'module-1',
title: 'Module 1',
summary: null,
sceneCount: 1,
contentHash: moduleContentHash,
}],
createdAt: '2026-08-17T00:00:00.000Z',
publishedAt: '2026-08-17T00:00:00.000Z',
};
const archive = new AdmZip();
archive.addFile('modules/module-1/manifest.json', Buffer.from(JSON.stringify({
stage: { name: 'Module 1' },
agents: [],
scenes: [{ type: 'slide', content: { fontUrl: 'fonts/lesson.woff2' } }],
mediaIndex: {
'fonts/lesson.woff2': { type: 'generated', mimeType: 'font/woff2' },
},
})));
archive.addFile('modules/module-1/bundle.json', Buffer.from(JSON.stringify({
meta: {
coursewareId: 'module-1',
sceneCount: 1,
contentHash: moduleContentHash,
stageName: 'Module 1',
},
completeness: { complete: true },
})));
archive.addFile('modules/module-1/quiz/quiz.json', Buffer.from('{}'));
archive.addFile('modules/module-1/knowledge/knowledge.json', Buffer.from('{}'));
archive.addFile('modules/module-1/fonts/lesson.woff2', Buffer.from('consumer-font-bytes'));
archive.writeZip(archivePath);
const classroom = await readLearningPackageClassroom(new AdmZip(archivePath), course, 'module-1');
const scene = classroom.classroom.scenes[0] as { content?: { fontUrl?: unknown } };
expect(scene.content?.fontUrl).toBeTypeOf('string');
const fontUrl = String(scene.content?.fontUrl);
expect(fontUrl.match(/modules\/module-1/g)).toHaveLength(1);
expect(fontUrl).toBe(
`/course-assets/${course.id}/${courseContentHash}/modules/module-1/fonts/lesson.woff2`,
);
registerLearningCoursePackage(ACCOUNT_KEY, course.id, courseContentHash, archivePath);
const server = await createLearningPlayerServer({ accountKey: ACCOUNT_KEY, artifactRoot: root });
close = server.close;
const index = await fetch(server.url);
const cookie = index.headers.get('set-cookie')?.split(';', 1)[0];
expect(cookie).toBeTruthy();
const response = await fetch(new URL(fontUrl, server.url), { headers: { Cookie: cookie! } });
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('font/woff2');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(response.headers.get('content-security-policy')).toContain("default-src 'none'");
expect(await response.text()).toBe('consumer-font-bytes');
});
});