// 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; origin?: string | null }; const call = async (method: string, path: string, opts: Opts = {}) => { const headers: Record = { ...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; 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) => { 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("ada@example.org", "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; 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("lea@example.org", "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: "ada@example.org", 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 /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 "); 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); } });