C1: writes lstat every path component and refuse symlinks (dangling included), check the parent's real path after mkdir, and open with O_NOFOLLOW. I1: only https://, ssh:// and git@host: remotes; protocol.file/ext/fd=never on every fetch and push; global/system git config ignored. I2: STC_UPLOAD_MAX_MB cap (default 100) by Content-Length and while streaming; extension allowlist (415); stcourse.yaml refused; 409 unless overwrite=true; sandboxing CSP on draft raw files. I3: owner-only visibility/source.kind rule checked on the merged result at publish and sync, and at resolve; publish also validates the merged tree. I4: bare repos in <dataDir>/studio/repos/<id>.git; imported courses read-only. I5: list status comes from the bare repo; drafts are created lazily. Co-Authored-By: Claude Opus 5.5 <[email protected]>
82 lines
5.3 KiB
TypeScript
82 lines
5.3 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { setupApp } from "./app-helpers";
|
|
|
|
let env: Awaited<ReturnType<typeof setupApp>>;
|
|
afterEach(() => env?.stop());
|
|
|
|
test("anonymous gets 401, learners 403", async () => {
|
|
env = await setupApp();
|
|
expect((await env.call(null, "GET", "/api/studio/courses")).status).toBe(401);
|
|
expect((await env.call("lea", "GET", "/api/studio/courses")).status).toBe(403);
|
|
expect((await env.call("lea", "POST", "/api/studio/courses", { id: "x", title: "X" })).status).toBe(403);
|
|
});
|
|
|
|
const DBS: [string, string][] = [["sqlite", "sqlite://:memory:"], ...(process.env.STC_TEST_PG_URL ? [["postgres", process.env.STC_TEST_PG_URL] as [string, string]] : [])];
|
|
|
|
test.each(DBS)("creating a course records the creator in course_authors; a per-course author without the role can edit it [%s]", async (_name, url) => {
|
|
env = await setupApp([], url);
|
|
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201);
|
|
const rows = await env.db.sql`SELECT course_id, user_id FROM course_authors`;
|
|
expect(rows.map((r: any) => [r.course_id, r.user_id])).toEqual([["c1", "u-ada"]]);
|
|
await env.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${"c1"}, ${"u-lea"})`;
|
|
const list = (await env.call("lea", "GET", "/api/studio/courses")).json;
|
|
expect(list.courses.map((c: any) => c.id)).toEqual(["c1"]);
|
|
expect((await env.call("lea", "GET", "/api/studio/courses/c1")).status).toBe(200);
|
|
expect((await env.call("lea", "POST", "/api/studio/courses", { id: "c2", title: "C2" })).status).toBe(403);
|
|
});
|
|
|
|
test("bad input: invalid id, missing title, bad JSON, bad paths, stale base hash", async () => {
|
|
env = await setupApp();
|
|
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "Bad Id", title: "x" })).status).toBe(400);
|
|
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1" })).status).toBe(400);
|
|
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
|
expect((await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "../x", content: "x" }] })).status).toBe(400);
|
|
const r = await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "lessons/welcome.md", content: "x", baseHash: "stale" }] });
|
|
expect(r.status).toBe(409);
|
|
expect(r.json.conflicts[0].path).toBe("lessons/welcome.md");
|
|
expect((await env.call("ada", "GET", "/api/studio/courses/nope")).status).toBe(404);
|
|
expect((await env.call("ada", "GET", "/api/studio/courses/c1/file?path=../../etc/passwd")).status).toBe(404);
|
|
});
|
|
|
|
test("manifest validation errors come from @stc/course-format", async () => {
|
|
env = await setupApp();
|
|
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
|
const st = (await env.call("ada", "GET", "/api/studio/courses/c1")).json;
|
|
expect(st.manifestErrors).toEqual([]);
|
|
const bad = { ...st.manifestRaw, accent: "orange", modules: [{ id: "m", title: "M", lessons: [{ id: "x", title: "X", type: "text" }] }] };
|
|
const v = (await env.call("ada", "POST", "/api/studio/courses/c1/validate", { manifest: bad })).json;
|
|
expect(v.errors).toEqual(expect.arrayContaining([expect.stringContaining("accent"), expect.stringContaining("file is required")]));
|
|
// saving an invalid draft is allowed (it is a draft) but publishing is refused with diagnostics
|
|
await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "wip", manifest: bad, manifestBaseHash: st.manifestHash, files: [] });
|
|
const st2 = (await env.call("ada", "GET", "/api/studio/courses/c1")).json;
|
|
expect(st2.manifestErrors.length).toBeGreaterThan(0);
|
|
const p = await env.call("ada", "POST", "/api/studio/courses/c1/publish");
|
|
expect(p.status).toBe(422);
|
|
expect(p.json.reason).toBe("invalid");
|
|
});
|
|
|
|
test("upload an attachment and serve it back from the draft", async () => {
|
|
env = await setupApp();
|
|
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
|
const fd = new FormData();
|
|
fd.set("file", new File([new Uint8Array([37, 80, 68, 70])], "Æble slides.pdf", { type: "application/pdf" }));
|
|
fd.set("dir", "media");
|
|
const r = await env.call("ada", "POST", "/api/studio/courses/c1/upload", fd);
|
|
expect(r.status).toBe(200);
|
|
expect(r.json.path).toBe("media/Æble slides.pdf");
|
|
const raw = await env.app.fetch(new Request(`http://x/api/studio/courses/c1/raw/${encodeURIComponent("media/Æble slides.pdf")}`, { headers: { "x-user": "ada" } }));
|
|
expect(raw.status).toBe(200);
|
|
expect(raw.headers.get("content-type")).toBe("application/pdf");
|
|
expect((await env.call("lea", "GET", `/api/studio/courses/c1/raw/media/x`)).status).toBe(403);
|
|
});
|
|
|
|
test("remote config and sync without a remote", async () => {
|
|
env = await setupApp();
|
|
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
|
|
expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(400);
|
|
expect((await env.call("ada", "PUT", "/api/studio/courses/c1/remote", { url: "/tmp/nowhere.git" })).status).toBe(400);
|
|
expect((await env.call("ada", "PUT", "/api/studio/courses/c1/remote", { url: "https://nowhere.invalid/x.git" })).status).toBe(200);
|
|
expect((await env.call("ada", "GET", "/api/studio/courses/c1")).json.status.remote).toBe("https://nowhere.invalid/x.git");
|
|
expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(502);
|
|
});
|