342 lines
17 KiB
TypeScript
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');
|
|
});
|
|
});
|