merge: integrate remote learning module safely
This commit is contained in:
341
tests/unit/learning-player-server.test.ts
Normal file
341
tests/unit/learning-player-server.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
// @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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user