Files
STCourses/packages/feature-studio/test/feature.test.ts
T
Lucas WintherandClaude Opus 5.5 2d162a4282 fix(studio): review round 1 (symlink-safe writes, remote allowlist, upload cap/types/no-overwrite, owner rule at publish/sync/resolve, studio/repos layout and read-only imports, side-effect-free list)
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]>
2026-10-30 04:43:53 +01:00

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);
});