Files
STCourses/apps/server/test/e2e.test.ts
T
Lucas WintherandClaude Opus 5.5 8045c80d93 fix: final-review wave (CSRF guard, fail-closed owner bootstrap, streamed zips, e2e visibility suite)
- Critical 1 (CSRF): core wrap refuses every POST/PUT/PATCH/DELETE whose Origin
  (Referer as fallback) is not publicUrl's origin or STC_ALLOWED_ORIGINS, and
  any body that is not application/json (multipart only on declared upload
  routes). Better Auth routes go through the same guard; only declared
  identity-provider callbacks (SAML ACS/SLO, OAuth callbacks) are exempt.
  Better Auth trustedOrigins use the same list. Session cookie stays Lax
  (Strict breaks OAuth/OIDC/SAML returns); documented.
- Important 4 / learner minor 4: the first-owner claim and the role row are
  one transaction guarded by "no owner yet"; only a unique violation means
  learner; any other error fails the sign-up with 500 and removes the
  half-created user. Serialised per DB handle (Bun's SQLite connection).
- Important 2 / learner minor 12: code zips stream entry by entry (large
  files stored in 256 KB chunks with a data descriptor and incremental
  CRC-32), so memory stays at one chunk; the cap is now 1 GiB (ZIP32).
- Important 3 / learner minor 10: the e2e runs the production wiring
  (productFeatures order, AI drafts through the real sink) against a
  restricted course and asserts 404/401 for learner and anonymous on
  course, lesson, media, cover, code, zip, notes, progress, attempts, AI
  chat, library, search and AI status; a real Range request (206, 416);
  forged writes (foreign Origin, text/plain, no Origin) are 403 and the MCP
  test command never runs.
- Minors 7-9: compose passes every documented variable; the health check
  uses STC_PORT; productFeatures checks the studio-before-AI order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:42:17 +01:00

219 lines
15 KiB
TypeScript

// End-to-end (plan Task I3): the real server with every feature, wired exactly as in production
// (`productFeatures()` order and `packageMigrations`), against the fixtures plus a restricted copy, on SQLite and
// Postgres. It is also the visibility and cross-site regression suite for the composed server.
import { describe, expect, test } from "bun:test";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AiProvider } from "@stc/contracts";
import { loadConfig } from "@stc/core";
import { createBrowserRunner } from "../../../packages/feature-learner/src/web/runner/runner";
import { packageMigrations, productFeatures } from "../src/features";
import { startServer } from "../src/main";
import { PG, pgSchema } from "./pg";
const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses");
const SAMPLE = join(FIXTURES, "sample");
const ORIGIN = "http://localhost:3000";
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1, 0xff, 0xd9]);
const cookieFrom = (r: Response) => r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; ");
/** A canned AI provider: chat answers with text; generation answers with one file block. */
const fakeAi: AiProvider = {
id: "fake",
async *chat(messages) {
const sys = messages[0]?.content ?? "";
if (sys.includes("=== FILE:")) yield { text: "=== FILE: lessons/generated.md ===\n# Generated\n\nA draft.\n=== END FILE ===\n" };
else yield { text: "An answer." };
},
};
/** A restricted copy of the sample course ("secret", titled "Hidden Vault"). */
function restrictedCourseDir(): string {
const dir = mkdtempSync(join(tmpdir(), "stc-e2e-courses-"));
cpSync(SAMPLE, join(dir, "secret"), { recursive: true });
const m = readFileSync(join(SAMPLE, "stcourse.yaml"), "utf8");
writeFileSync(join(dir, "secret/stcourse.yaml"), m.replace("id: sample", "id: secret").replace("title: Sample course", "title: Hidden Vault").replace("visibility: public", "visibility: restricted"));
return dir;
}
async function run(databaseUrl: string) {
const dataDir = mkdtempSync(join(tmpdir(), "stc-e2e-"));
const config = loadConfig({
STC_PORT: "0", STC_DATABASE_URL: databaseUrl, STC_SECRET: "e2e-secret-0123456789abcdef0123456789",
STC_DATA_DIR: dataDir, STC_COURSE_DIRS: `${FIXTURES}:${restrictedCourseDir()}`, STC_PUBLIC_URL: ORIGIN,
});
// The importer's cover candidate for the restricted course.
mkdirSync(join(config.cacheDir, "covers", "secret"), { recursive: true });
writeFileSync(join(config.cacheDir, "covers", "secret", "frame-1.jpg"), JPEG);
const s = await startServer(config, productFeatures({ studioWatch: false, aiProviderFactory: () => fakeAi }), { migrations: packageMigrations });
const base = s.server.url.toString().replace(/\/$/, "");
type Opts = { cookie?: string; body?: unknown; headers?: Record<string, string>; origin?: string | null };
const call = async (method: string, path: string, opts: Opts = {}) => {
const headers: Record<string, string> = { ...opts.headers };
if (opts.origin !== null) headers.origin = opts.origin ?? ORIGIN;
if (opts.cookie) headers.cookie = opts.cookie;
if (opts.body !== undefined) headers["content-type"] ??= "application/json";
const body = opts.body === undefined ? undefined : typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
return fetch(base + path, { method, headers, body });
};
const json = async (method: string, path: string, opts: Opts = {}) => (await call(method, path, opts)).json() as Promise<any>;
const signUp = async (email: string, name: string) => {
const r = await call("POST", "/api/auth/sign-up/email", { body: { email, name, password: "correct horse battery" } });
expect(r.status).toBe(200);
return cookieFrom(r);
};
const until = async (f: () => Promise<boolean>) => { for (let i = 0; i < 400; i++) { if (await f()) return; await Bun.sleep(25); } throw new Error("timeout"); };
try {
// 1. the server is up with every feature, the fixture course and the restricted copy
expect(await json("GET", "/api/health")).toEqual({ ok: true, courses: 2, db: databaseUrl.startsWith("postgres") ? "postgres" : "sqlite" });
expect((await call("GET", "/c/sample")).headers.get("content-type")).toContain("text/html");
expect(s.features.map((f) => f.name)).toEqual(["learner", "import", "studio", "ai"]);
// 2. sign up: the first account is the owner
const owner = await signUp("[email protected]", "Ada Lovelace");
const me = (await json("GET", "/api/me", { cookie: owner })) as { user: { id: string; roles: string[] } };
expect(me.user.roles).toEqual(["owner"]);
// 3. mark a lesson done
expect((await call("PUT", "/api/courses/sample/progress/m1-intro", { cookie: owner, body: { state: "done" } })).status).toBe(200);
// 4. a slide note on the video lesson's deck, and a bodyless DELETE over the wire
const note = await call("POST", "/api/courses/sample/notes", { cookie: owner, body: { lessonId: "m1-video", anchorKind: "slide", anchorValue: 2, deckId: "d1", body: "Slide two matters" } });
expect(note.status).toBe(201);
const scratch = (await json("POST", "/api/courses/sample/notes", { cookie: owner, body: { lessonId: "m1-intro", body: "scratch" } })) as { id: string };
expect((await call("DELETE", `/api/notes/${scratch.id}`, { cookie: owner })).status).toBe(200);
// 5. run the sample exercise with the learner's runner, then save the passing attempt
const lesson = (await json("GET", "/api/courses/sample/lessons/m2-ex-add", { cookie: owner })) as { browser: { starter: string; tests: string } };
const runner = createBrowserRunner("js");
expect((await runner.run(lesson.browser.starter, lesson.browser.tests, 5000)).some((t) => !t.passed)).toBe(true);
const code = lesson.browser.starter.replace("a - b", "a + b");
const results = await runner.run(code, lesson.browser.tests, 5000);
expect(results.length).toBeGreaterThan(0);
expect(results.every((t) => t.passed)).toBe(true);
expect((await call("PUT", "/api/courses/sample/lessons/m2-ex-add/attempt", { cookie: owner, body: { code, passed: true, result: results, track: "js" } })).status).toBe(200);
// 6. read progress and notes back
const progress = (await json("GET", "/api/courses/sample/progress", { cookie: owner })) as Record<string, { state: string }>;
expect(progress["m1-intro"]?.state).toBe("done");
expect(progress["m2-ex-add"]?.state).toBe("done");
const notes = (await json("GET", "/api/courses/sample/notes", { cookie: owner })) as { body: string; anchorKind: string; deckId: string }[];
expect(notes.map((n) => [n.body, n.anchorKind, n.deckId])).toEqual([["Slide two matters", "slide", "d1"]]);
// 7. a second account is a plain learner: own data only, and the owner/author features refuse it
const lea = await signUp("[email protected]", "Lea");
expect(await json("GET", "/api/courses/sample/progress", { cookie: lea })).toEqual({});
expect((await call("GET", "/api/import/info", { cookie: lea })).status).toBe(403);
expect((await call("GET", "/api/studio/courses", { cookie: lea })).status).toBe(403);
expect((await call("GET", "/api/ai/admin/config", { cookie: lea })).status).toBe(403);
expect((await call("GET", "/api/import/info", { cookie: owner })).status).toBe(200);
expect((await call("GET", "/api/studio/courses", { cookie: owner })).status).toBe(200);
// 8. media: a real Range request gives 206 with the right bytes; nosniff everywhere
const whole = new Uint8Array(readFileSync(join(SAMPLE, "media/clip.mp4")));
const part = await call("GET", "/api/media/sample/media/clip.mp4", { cookie: lea, headers: { Range: "bytes=0-9" } });
expect(part.status).toBe(206);
expect(part.headers.get("content-range")).toBe(`bytes 0-9/${whole.length}`);
expect(new Uint8Array(await part.arrayBuffer())).toEqual(whole.slice(0, 10));
expect(part.headers.get("x-content-type-options")).toBe("nosniff");
expect((await call("GET", "/api/media/sample/media/clip.mp4", { headers: { Range: `bytes=${whole.length}-` } })).status).toBe(416);
// 9. AI on (canned provider), so chat and retrieval can be checked
expect((await call("PUT", "/api/ai/admin/config", { cookie: owner, body: { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [] } })).status).toBe(200);
// 10. the restricted course: the owner gets everything; a learner and anonymous users learn nothing
const restricted: [string, string, unknown?][] = [
["GET", "/api/courses/secret"],
["GET", "/api/courses/secret/lessons/m1-intro"],
["GET", "/api/media/secret/lessons/01-intro.md"],
["GET", "/api/courses/secret/cover"],
["GET", "/api/courses/secret/code/m1"],
["GET", "/api/courses/secret/code/m1/zip"],
["GET", "/api/courses/secret/notes"],
["GET", "/api/courses/secret/progress"],
["PUT", "/api/courses/secret/progress/m1-intro", { state: "done" }],
["POST", "/api/courses/secret/notes", { lessonId: "m1-intro", body: "x" }],
["PUT", "/api/courses/secret/lessons/m2-ex-add/attempt", { code: "x", passed: false }],
["POST", "/api/ai/courses/secret/chat", { message: "What is in this course?" }],
];
for (const [method, path, body] of restricted) {
const asOwner = await call(method, path, { cookie: owner, body });
expect([method, path, asOwner.status >= 200 && asOwner.status < 300]).toEqual([method, path, true]);
await asOwner.arrayBuffer();
const asLearner = await call(method, path, { cookie: lea, body });
expect([method, path, asLearner.status]).toEqual([method, path, 404]);
// Anonymous: 404 for content; 401 ("sign in first") on per-user routes, which answer the same for every course id.
const anon = await call(method, path, { body });
expect([method, path, [401, 404].includes(anon.status)]).toEqual([method, path, true]);
const text = await anon.text();
expect(text).not.toContain("Hidden Vault");
}
const ids = async (cookie?: string) => ((await json("GET", "/api/library", { cookie })) as { courses: { id: string }[] }).courses.map((c) => c.id).sort();
expect(await ids(owner)).toEqual(["sample", "secret"]);
expect(await ids(lea)).toEqual(["sample"]);
expect(await ids()).toEqual(["sample"]);
const search = async (cookie?: string) => JSON.stringify(await json("GET", "/api/library/search?q=vault", { cookie }));
expect(await search(owner)).toContain("secret");
expect(await search(lea)).toBe("[]");
expect(await search()).toBe("[]");
// AI retrieval: the course is unknown to the learner's assistant status, known to the owner's
expect((await json("GET", "/api/ai/status?courseId=secret", { cookie: owner })).course?.id).toBe("secret");
expect((await json("GET", "/api/ai/status?courseId=secret", { cookie: lea })).course).toBeUndefined();
expect((await json("GET", "/api/ai/status?courseId=secret")).course).toBeUndefined();
// 11. cross-site requests (CSRF): the owner's cookie is not enough
const pwned = join(dataDir, "PWNED");
const mcp = { transport: "stdio", command: "sh", args: ["-c", `touch ${pwned}`] };
const forged = await call("POST", "/api/ai/admin/mcp/test", { cookie: owner, origin: "http://evil.example", headers: { "content-type": "text/plain" }, body: JSON.stringify(mcp) });
expect(forged.status).toBe(403);
const forgedJson = await call("POST", "/api/ai/admin/mcp/test", { cookie: owner, origin: "http://evil.example", body: mcp });
expect(forgedJson.status).toBe(403);
const noOrigin = await call("POST", "/api/ai/admin/mcp/test", { cookie: owner, origin: null, body: mcp });
expect(noOrigin.status).toBe(403);
await Bun.sleep(200);
expect(existsSync(pwned)).toBe(false);
expect((await call("POST", "/api/courses/sample/notes", { cookie: owner, origin: "http://evil.example", headers: { "content-type": "text/plain" }, body: JSON.stringify({ lessonId: "m1-intro", body: "forged" }) })).status).toBe(403);
expect((await call("POST", "/api/courses/sample/notes", { cookie: owner, origin: null, headers: { referer: `${ORIGIN}/c/sample/m1-intro` }, body: { lessonId: "m1-intro", body: "via referer" } })).status).toBe(201);
expect((await call("POST", "/api/auth/sign-in/email", { origin: "http://evil.example", body: { email: "[email protected]", password: "correct horse battery" } })).status).toBe(403);
expect((await json("GET", "/api/courses/sample/notes", { cookie: owner })).map((n: { body: string }) => n.body)).not.toContain("forged");
// 12. AI drafts through the real wiring: a studio course gets a commit on the author's draft branch, as the
// author; a course the studio does not manage gets files under <data>/drafts
expect((await call("POST", "/api/studio/courses", { cookie: owner, body: { id: "gen-target", title: "Generated" } })).status).toBe(201);
const generate = async (courseId: string, lessonId: string) => {
const r = await call("POST", "/api/ai/studio/generate", { cookie: owner, body: { courseId, kind: "document", style: "pure-snake", topic: "Adding", sources: [{ kind: "lesson", lessonId }] } });
expect(r.status).toBe(202);
const { jobId } = (await r.json()) as { jobId: string };
let job: any = null;
await until(async () => { job = await json("GET", `/api/ai/studio/jobs/${jobId}`, { cookie: owner }); return job.status === "done" || job.status === "failed"; });
expect([job.status, job.error]).toEqual(["done", null]);
return job.result as { branch?: string; location: string; paths: string[] };
};
const onStudio = await generate("gen-target", "welcome");
expect(onStudio.branch).toMatch(/^draft\//);
const repo = join(config.studioReposDir, "gen-target.git");
expect((await Bun.$`git --git-dir=${repo} log -1 ${"--format=%an <%ae>"} ${onStudio.branch!}`.text()).trim()).toBe("Ada Lovelace <[email protected]>");
expect(await Bun.$`git --git-dir=${repo} show ${`${onStudio.branch}:lessons/generated.md`}`.text()).toContain("# Generated");
const onFiles = await generate("sample", "m1-intro");
expect(onFiles.branch).toBeUndefined();
expect(onFiles.location.startsWith(resolve(dataDir, "drafts", "sample"))).toBe(true);
expect(readFileSync(join(onFiles.location, "lessons/generated.md"), "utf8")).toContain("# Generated");
} finally {
await s.stop();
}
}
describe("end to end", () => {
test("sign up, mark done, slide note, exercise, progress, visibility, CSRF, AI drafts [sqlite]", () => run("sqlite://:memory:"), 90_000);
const pg = PG;
if (pg) {
test("sign up, mark done, slide note, exercise, progress, visibility, CSRF, AI drafts [postgres]", async () => {
const { url, drop } = await pgSchema(pg);
try { await run(url); } finally { await drop(); }
}, 90_000);
}
});