import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { IMPORTED_MESSAGE } from "../src/server/feature"; import { isAllowedRemote, ownerRequiredFor } from "../src/server/store"; import { ada, extGit, newStore, tempDir } from "./helpers"; import { setupApp } from "./app-helpers"; describe("C1: writes never follow symlinks", () => { test("a dangling symlink is refused for save and resolve; the outside target is never created", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const draft = await store.ensureDraft("c1", ada.id); const outside = join(tempDir(), "pwned.txt"); symlinkSync(outside, join(draft, "evil.md")); await expect(store.saveFiles("c1", ada, [{ path: "evil.md", content: "x" }], "x")).rejects.toThrow(/symlink/); await expect(store.saveFiles("c1", ada, [{ path: "evil.md", content: "x", baseHash: null }], "x")).rejects.toThrow(/symlink/); expect(existsSync(outside)).toBe(false); }); test("a symlinked parent directory is refused", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const draft = await store.ensureDraft("c1", ada.id); const outsideDir = tempDir(); symlinkSync(outsideDir, join(draft, "sub")); await expect(store.saveFiles("c1", ada, [{ path: "sub/x.md", content: "x" }], "x")).rejects.toThrow(/symlink/); await expect(store.saveFiles("c1", ada, [{ path: "sub/deeper/x.md", content: "x" }], "x")).rejects.toThrow(/symlink/); expect(existsSync(join(outsideDir, "x.md"))).toBe(false); expect(existsSync(join(outsideDir, "deeper"))).toBe(false); }); test("a symlink pulled in from a remote merge cannot be written through; deleting the link leaves the target", async () => { const { store } = await newStore({ allowLocalRemotes: true }); await store.createCourse({ id: "c1", title: "C" }, ada); const remote = join(tempDir(), "remote.git"); await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote); await store.setRemote("c1", remote); await store.sync("c1", ada); // push main const outsideDir = tempDir(); const target = join(outsideDir, "target.md"); writeFileSync(target, "outside\n"); const other = join(tempDir(), "other"); await extGit(tempDir(), "clone", "-q", remote, other); symlinkSync(target, join(other, "notes.md")); symlinkSync(join(outsideDir, "new.md"), join(other, "dangling.md")); await extGit(other, "add", "notes.md", "dangling.md"); await extGit(other, "commit", "-qm", "plant symlinks"); await extGit(other, "push", "-q", "origin", "main"); expect((await store.updateDraft("c1", ada, "remote")).ok).toBe(true); await expect(store.saveFiles("c1", ada, [{ path: "notes.md", content: "pwned" }], "x")).rejects.toThrow(/symlink/); await expect(store.saveFiles("c1", ada, [{ path: "dangling.md", content: "pwned" }], "x")).rejects.toThrow(/symlink/); expect(readFileSync(target, "utf8")).toBe("outside\n"); expect(existsSync(join(outsideDir, "new.md"))).toBe(false); expect((await store.saveFiles("c1", ada, [{ path: "notes.md", delete: true }], "rm link")).ok).toBe(true); expect(readFileSync(target, "utf8")).toBe("outside\n"); }); test("resolve refuses to write through a symlink", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const pub = store.paths("c1").published; const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# mine\n", baseHash: cur.hash }], "mine"); writeFileSync(join(pub, "lessons/welcome.md"), "# theirs\n"); await extGit(pub, "commit", "-qam", "theirs"); await store.updateDraft("c1", ada); const outside = join(tempDir(), "p.txt"); symlinkSync(outside, join(store.paths("c1").draft(ada.id), "link.md")); await expect(store.resolve("c1", ada, [{ path: "link.md", content: "x" }])).rejects.toThrow(/symlink/); expect(existsSync(outside)).toBe(false); }); }); describe("I1: remotes", () => { test.each([ "/tmp/x.git", "../x.git", "x.git", "file:///tmp/x.git", "ext::sh -c touch% /tmp/p", "fd::3", "http://host/x.git", "git://host/x.git", "-uoption", "https://", "https:///x", "git@:x", "git@host:-x", "ssh://host", "ssh://-oProxyCommand=x/y", "https://host/x y", ])("rejects %p", async (url) => { expect(isAllowedRemote(url)).toBe(false); const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); await expect(store.setRemote("c1", url)).rejects.toThrow(); expect(await store.remote("c1")).toBeNull(); }); test.each(["https://github.com/a/b.git", "https://user:tok@git.example.org:8443/a/b.git", "ssh://git@host.example:22/a/b.git", "git@github.com:a/b.git"])("accepts %p", (url) => { expect(isAllowedRemote(url)).toBe(true); }); test("a remote configured outside the studio is re-checked before sync and update", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const remote = join(tempDir(), "remote.git"); await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote); await extGit(store.paths("c1").bare, "remote", "add", "origin", remote); expect(await store.sync("c1", ada)).toMatchObject({ ok: false, reason: "no-remote" }); expect(await store.updateDraft("c1", ada, "remote")).toMatchObject({ ok: false, reason: "no-remote" }); expect(await extGit(remote, "for-each-ref")).toBe(""); }); }); describe("I3: owner-only visibility", () => { const imported = "schema: 1\nid: c1\ntitle: C\nvisibility: restricted\nsource: { kind: packt-video }\nmodules: []\n"; test("rule", () => { expect(ownerRequiredFor(imported, imported)).toBeNull(); expect(ownerRequiredFor(imported, imported.replace("restricted", "public"))).toMatch(/owner/); expect(ownerRequiredFor(imported, imported.replace("restricted", "signed-in").replace("packt-video", "authored"))).toMatch(/owner/); expect(ownerRequiredFor(imported, imported.replace("source: { kind: packt-video }\n", ""))).toMatch(/source.kind/); const authored = imported.replace("packt-video", "authored"); expect(ownerRequiredFor(authored, authored.replace("restricted", "public"))).toBeNull(); expect(ownerRequiredFor(authored, authored.replace("authored", "pdf-deck"))).toMatch(/source.kind/); }); let env: Awaited>; afterEach(() => env?.stop()); test("publish by a non-owner is 403 when the merged manifest makes an imported course visible; the owner may", async () => { env = await setupApp(); const { call, feature } = env; await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); // the published course is an import (as an importer-adopted course would be) const pub = feature.store.paths("c1").published; const m0 = readFileSync(join(pub, "stcourse.yaml"), "utf8").replace("kind: authored", "kind: packt-video"); writeFileSync(join(pub, "stcourse.yaml"), m0); await extGit(pub, "commit", "-qam", "imported"); await feature.watcher.check(); const ada = { id: "u-ada", name: "Ada", email: "ada@example.org" }; // save-time check stops the plain attempt const st = (await call("ada", "GET", "/api/studio/courses/c1")).json; expect((await call("ada", "POST", "/api/studio/courses/c1/update-draft")).status).toBe(200); const st2 = (await call("ada", "GET", "/api/studio/courses/c1")).json; expect((await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", manifest: { ...st2.manifestRaw, visibility: "public" }, manifestBaseHash: st2.manifestHash, files: [] })).status).toBe(403); void st; // a draft that got there another way (AI draft writer, merge) is stopped at publish for (const edit of [(t: string) => t.replace("visibility: restricted", "visibility: public"), (t: string) => t.replace("visibility: restricted", "visibility: public").replace("kind: packt-video", "kind: authored")]) { const cur = (await feature.store.readFile("c1", ada.id, "stcourse.yaml"))!; await feature.store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: edit(cur.content), baseHash: cur.hash }], "sneak"); const r = await call("ada", "POST", "/api/studio/courses/c1/publish"); expect(r.status).toBe(403); expect(r.json.reason).toBe("forbidden"); expect(readFileSync(join(pub, "stcourse.yaml"), "utf8")).toBe(m0); expect(await extGit(pub, "status", "--porcelain")).toBe(""); await feature.store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: cur.content }], "undo"); } // the owner may const own = (await call("owner", "GET", "/api/studio/courses/c1")).json; expect((await call("owner", "PUT", "/api/studio/courses/c1/files", { message: "public", manifest: { ...own.manifestRaw, visibility: "public" }, manifestBaseHash: own.manifestHash, files: [] })).status).toBe(200); expect((await call("owner", "POST", "/api/studio/courses/c1/publish")).status).toBe(200); expect(readFileSync(join(pub, "stcourse.yaml"), "utf8")).toContain("visibility: public"); }); test("resolve of the manifest by a non-owner is checked too", async () => { const { store } = await newStore(); await store.createCourse({ id: "c1", title: "C" }, ada); const pub = store.paths("c1").published; const cur = (await store.readFile("c1", ada.id, "stcourse.yaml"))!; await store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: cur.content.replace("title: C", "title: Mine"), baseHash: cur.hash }], "mine"); writeFileSync(join(pub, "stcourse.yaml"), cur.content.replace("title: C", "title: Theirs").replace("kind: authored", "kind: packt-video")); await extGit(pub, "commit", "-qam", "theirs"); expect((await store.updateDraft("c1", ada)).ok).toBe(false); const bad = cur.content.replace("kind: authored", "kind: packt-video").replace("visibility: restricted", "visibility: public"); await expect(store.resolve("c1", ada, [{ path: "stcourse.yaml", content: bad }])).rejects.toThrow(/owner/); expect((await store.resolve("c1", { ...ada, owner: true }, [{ path: "stcourse.yaml", content: bad }])).remaining).toEqual([]); }); }); describe("I2: uploads", () => { let env: Awaited>; afterEach(() => env?.stop()); const upload = (name: string, bytes: Uint8Array, extra: Record = {}) => { const fd = new FormData(); fd.set("file", new File([bytes], name)); fd.set("dir", "media"); for (const [k, v] of Object.entries(extra)) fd.set(k, v); return env.call("ada", "POST", "/api/studio/courses/c1/upload", fd); }; test("type allowlist, manifest refused, no silent overwrite", async () => { env = await setupApp(); await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); expect((await upload("tool.exe", new Uint8Array([1]))).status).toBe(415); expect((await upload("page.HTM", new Uint8Array([1]))).status).toBe(415); expect((await upload("x", new Uint8Array([1]), { path: "stcourse.yaml" })).status).toBe(400); expect((await upload("slides.pdf", new Uint8Array([1]))).status).toBe(200); const again = await upload("slides.pdf", new Uint8Array([2])); expect(again.status).toBe(409); expect(readFileSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/media/slides.pdf"))[0]).toBe(1); expect((await upload("slides.pdf", new Uint8Array([2]), { overwrite: "true" })).status).toBe(200); expect(readFileSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/media/slides.pdf"))[0]).toBe(2); }); test("size cap from AppConfig.uploadMaxMb (STC_UPLOAD_MAX_MB), by Content-Length and while streaming", async () => { env = await setupApp([], "sqlite://:memory:", undefined, { uploadMaxMb: 1 }); await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); expect((await upload("big.zip", new Uint8Array(1024 * 1024 + 10))).status).toBe(413); // no Content-Length: a streamed body is cut off once it passes the cap const fd = new FormData(); fd.set("file", new File([new Uint8Array(2 * 1024 * 1024)], "big.zip")); const ct = new Response(fd).headers.get("content-type")!; const bytes = new Uint8Array(await new Response(fd).arrayBuffer()); const stream = new ReadableStream({ start(c) { for (let i = 0; i < bytes.length; i += 65536) c.enqueue(bytes.slice(i, i + 65536)); c.close(); } }); const r = await env.app.fetch(new Request("http://x/api/studio/courses/c1/upload", { method: "POST", headers: { "x-user": "ada", origin: "http://x", "content-type": ct }, body: stream, duplex: "half" } as RequestInit)); expect(r.status).toBe(413); expect(existsSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/big.zip"))).toBe(false); expect((await upload("small.zip", new Uint8Array(1000))).status).toBe(200); }); test("raw draft files are served with a sandboxing CSP", async () => { env = await setupApp(); await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); await upload("x.svg", new TextEncoder().encode("")); const r = await env.app.fetch(new Request("http://x/api/studio/courses/c1/raw/media/x.svg", { headers: { "x-user": "ada" } })); expect(r.headers.get("content-security-policy")).toContain("sandbox"); }); }); describe("I4: layout and imported courses", () => { let env: Awaited>; afterEach(() => env?.stop()); test("imported courses in /courses are read-only with their own message; ids cannot collide", async () => { env = await setupApp([], undefined, (dataDir) => { mkdirSync(join(dataDir, "courses/imp/lessons"), { recursive: true }); writeFileSync(join(dataDir, "courses/imp/lessons/a.md"), "# A\n"); writeFileSync(join(dataDir, "courses/imp/stcourse.yaml"), "schema: 1\nid: imp\ntitle: Imported\nsource: { kind: packt-video }\nmodules:\n - { id: m, title: M, lessons: [{ id: a, title: A, type: text, file: lessons/a.md }] }\n"); }); expect(env.ctx.courses.get("imp")?.status).toBe("ok"); const list = (await env.call("ada", "GET", "/api/studio/courses")).json; expect(list.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: false, message: IMPORTED_MESSAGE })); expect((await env.call("ada", "GET", "/api/studio/courses/imp")).json.message).toBe(IMPORTED_MESSAGE); const w = await env.call("ada", "PUT", "/api/studio/courses/imp/files", { message: "x", files: [{ path: "a.md", content: "x" }] }); expect(w.status).toBe(403); expect(w.json.error).toBe(IMPORTED_MESSAGE); expect((await env.call("ada", "POST", "/api/studio/courses", { id: "imp", title: "Dup" })).status).toBe(409); // studio repos are not in the importer's folder expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201); expect(existsSync(join(env.dataDir, "studio/repos/c1.git/HEAD"))).toBe(true); expect(existsSync(join(env.dataDir, "courses/c1"))).toBe(false); }); test("store refuses an id already used by an importer folder", async () => { const { store, dataDir } = await newStore(); mkdirSync(join(dataDir, "courses/imp"), { recursive: true }); await expect(store.createCourse({ id: "imp", title: "x" }, ada)).rejects.toThrow(/exists/); }); }); describe("I5: listing has no side effects", () => { let env: Awaited>; afterEach(() => env?.stop()); test("GET /api/studio/courses creates no draft worktree or branch; opening the editor does", async () => { env = await setupApp(); await env.call("owner", "POST", "/api/studio/courses", { id: "c1", title: "C" }); const bare = env.feature.store.paths("c1").bare; const draft = env.feature.store.paths("c1").draft("u-ada"); const list = await env.call("ada", "GET", "/api/studio/courses"); expect(list.json.courses[0]).toMatchObject({ id: "c1", ahead: 0, behind: 0, merging: false }); expect(existsSync(draft)).toBe(false); expect(await extGit(bare, "for-each-ref", "--format=%(refname)", "refs/heads/draft")).toBe(""); await env.call("ada", "GET", "/api/studio/courses/c1"); expect(existsSync(draft)).toBe(true); }); });