// @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 { const html = '
player
'; 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 { 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) | 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('')); archive.addFile('modules/module-1/media/attack.html', Buffer.from('')); archive.addFile('modules/module-1/media/attack.htm', Buffer.from('')); archive.addFile('modules/module-1/media/attack.svg', Buffer.from('')); archive.addFile('modules/module-1/media/attack.xml', Buffer.from('')); archive.addFile('modules/module-1/media/attack.xhtml', Buffer.from('')); 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('