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]>
118 lines
5.9 KiB
TypeScript
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"/);
|
|
});
|