- 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]>
219 lines
15 KiB
TypeScript
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);
|
|
}
|
|
});
|