Files
STCourses/packages/feature-learner/test/lesson-view.test.tsx
T
Lucas WintherandClaude Opus 5.5 c7b9e1f98e fix(learner): review round 1: sanitize-html, sandboxed runner frame, one visibility rule, no Tab capture
C1: lesson HTML goes through a sanitize-html allowlist on the fully rendered output (no on* attributes, no
style, no svg/math/iframe/object/embed/form; URLs limited to http(s), mailto, relative and anchors). Raw tags
outside the allowlist are escaped as text. The SPA document is served with a CSP (script-src 'self', no inline
script) through spaWithCsp in main.ts; contract request §5 asks core to own it.

C2: exercises run in <iframe sandbox="allow-scripts"> (opaque origin, no cookies) loaded from
/api/learner/runner-frame, whose CSP allows only its own script (by hash) and blob: workers, and connect-src
'none'. postMessage only, both sides check event.source; the timeout removes the frame. The worker stubs now
also cover Worker, SharedWorker and caches.

I1: learner visibility calls core canView with per-course authorship only, like /api/media (contract request §6).
I2: Tab and Shift+Tab move focus; Enter keeps the previous line's indentation.

Tests: XSS unit cases, headless-Chrome checks for the exploit lesson and for fetch, import, Cache.add, nested
Worker, img, EventSource, WebSocket, XHR and sendBeacon from learner and test code (plus a negative control),
visibility on both dialects, SPA and runner-frame CSP headers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:20:43 +01:00

118 lines
5.9 KiB
TypeScript

import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { connect } from "@stc/db";
import { LessonView } from "../src/web";
import { LessonView as FromIndex } from "../src/index";
import type { LessonPayload } from "../src/shared/types";
import { localCommandFor } from "../src/web/lesson/Exercise";
import { firstStamp } from "../src/web/lesson/notes";
import { buildTree } from "../src/web/lesson/CodeFiles";
import { runMigrations } from "../src/server/migrate";
import { makeApp } from "./app";
async function sampleLessons(ids: string[]): Promise<Record<string, LessonPayload>> {
const db = await connect("sqlite://:memory:");
await runMigrations(db);
const { call } = await makeApp(db);
const out: Record<string, LessonPayload> = {};
for (const id of ids) out[id] = (await (await call("GET", `/api/courses/sample/lessons/${id}`)).json()) as LessonPayload;
await db.close();
return out;
}
const html = (el: React.ReactElement) => renderToString(<MemoryRouter>{el}</MemoryRouter>);
test("LessonView is exported from the package index and the web entry", () => {
expect(FromIndex).toBe(LessonView);
});
test("LessonView renders every sample lesson type", async () => {
const l = await sampleLessons(["m1-intro", "m1-video", "m2-ex-add"]);
const text = html(<LessonView lesson={l["m1-intro"]!} />);
expect(text).toContain('data-type="text"');
expect(text).toContain('data-look="reading"');
expect(text).toContain("<h1 class=\"title\">Introduction</h1>");
expect(text).toContain("Welcome to the sample course");
expect(text).toContain("Files for this section"); // module m1 has companion code
expect(text).toContain("/api/courses/sample/code/m1/zip");
const video = html(<LessonView lesson={l["m1-video"]!} signedIn={false} />);
expect(video).toContain('data-type="video"');
expect(video).toMatch(/<video[^>]+src="\/api\/media\/sample\/media\/clip\.mp4"/);
expect(video).toContain('data-deck="d1"'); // companion deck
expect(video).toContain("Transcript");
expect(video).toContain("Sign in"); // notes need an account
const ex = html(<LessonView lesson={l["m2-ex-add"]!} look="reading" signedIn />);
expect(ex).toContain('data-look="work"'); // exercises always use Work
expect(ex).toContain("Run tests");
expect(ex).toContain("return a - b;"); // the starter is in the editor
expect(ex).toContain('data-tier="1"');
expect(ex).toContain('data-tiers="2"');
expect(ex).toContain("node --test");
expect(ex).not.toContain("return a + b"); // no solution for learners
});
test("preview mode renders without notes or saved-attempt UI", async () => {
const l = await sampleLessons(["m1-video"]);
const out = html(<LessonView lesson={l["m1-video"]!} preview />);
expect(out).not.toContain("Your notes");
expect(out).not.toContain("Insert time");
});
test("a locked lesson shows the lock notice and no content", () => {
const locked: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "l2", title: "Later", type: "text", minutes: null },
module: { id: "m2", title: "Two", index: 1 }, locked: true, markdown: null, baseFile: null, trackMarkdown: {}, tracks: [],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null,
prev: { id: "l1", title: "Earlier" }, next: null, lessonFiles: {},
};
const out = html(<LessonView lesson={locked} signedIn />);
expect(out).toContain("This module is locked.");
expect(out).toMatch(/Back to (<!-- -->)?Earlier/);
});
test("track variants: the chosen track's markdown and code are shown", () => {
const lesson: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "t", title: "Tracks `x`", type: "text", minutes: 5 },
module: { id: "m", title: "M", index: 0 }, locked: false,
markdown: "# T\n\n**JavaScript**\n\n```js\nconst a = 1\n```\n\n**Scala 3**\n\n```scala\nval a = 1\n```\n",
baseFile: "course/t.md", trackMarkdown: {}, tracks: [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null,
localCommand: { js: "npm test", scala: "sbt test" }, prev: null, next: null, lessonFiles: {},
};
const js = html(<LessonView lesson={lesson} track="js" />);
expect(js).toContain("const a = 1");
expect(js).not.toContain("val a = 1");
expect(js).toContain("<code>x</code>"); // inline code in the title
expect(js).toContain('aria-label="Track"');
expect(js).toContain("npm test");
const scala = html(<LessonView lesson={lesson} track="scala" />);
expect(scala).toContain("val a = 1");
expect(scala).toContain("sbt test");
});
test("helpers: local command per track, note timestamps, code tree", () => {
expect(localCommandFor({ js: "a", scala: "b" }, "scala")).toBe("b");
expect(localCommandFor({ js: "a" }, "fsharp")).toBe("a");
expect(localCommandFor("x", null)).toBe("x");
expect(firstStamp("see [1:05] and [0:10]")).toBe(65);
expect(firstStamp("[1:02:03] x")).toBe(3723);
expect(firstStamp("no stamp")).toBeNull();
const t = buildTree([{ path: "a.txt", size: 1 }, { path: "src/b.ts", size: 2 }, { path: "src/x/c.ts", size: 3 }]);
expect(t.files.map((f) => f.name)).toEqual(["a.txt"]);
expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]);
expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts");
});
test("the exercise editor: no Tab capture, Enter keeps the indentation", async () => {
const { newlineWithIndent } = await import("../src/web/lesson/Exercise");
expect(newlineWithIndent("function f() {\n return 1", 26)).toBe("\n ");
expect(newlineWithIndent("x", 1)).toBe("\n");
expect(newlineWithIndent("\t\tx", 3)).toBe("\n\t\t");
const src = await Bun.file(new URL("../src/web/lesson/Exercise.tsx", import.meta.url)).text();
expect(src).not.toMatch(/e\.key === "Tab"/);
});