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]>
120 lines
6.4 KiB
TypeScript
120 lines
6.4 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { loadCourse } from "@stc/course-format";
|
|
import { contentHash } from "../src/server/store";
|
|
import { ada, bob, extGit, newStore } from "./helpers";
|
|
|
|
describe("createCourse", () => {
|
|
test("makes a bare repo, a published worktree on main and a valid manifest", async () => {
|
|
const { dataDir, store } = await newStore();
|
|
await store.createCourse({ id: "intro-git", title: "Intro: Git & you" }, ada);
|
|
expect(existsSync(join(dataDir, "studio/repos/intro-git.git/HEAD"))).toBe(true);
|
|
expect(existsSync(join(dataDir, "studio/repos/intro-git.git/stcourse.yaml"))).toBe(false);
|
|
expect(existsSync(join(dataDir, "courses"))).toBe(false); // the importer's folder is left alone
|
|
const pub = store.paths("intro-git").published;
|
|
expect(pub).toBe(join(dataDir, "worktrees/intro-git/published"));
|
|
expect(await extGit(pub, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main");
|
|
const loaded = await loadCourse(join(pub, "stcourse.yaml"));
|
|
expect(loaded.status).toBe("ok");
|
|
expect(loaded.manifest?.title).toBe("Intro: Git & you");
|
|
expect(await extGit(pub, "log", "-1", "--format=%an <%ae>")).toBe("Ada Lovelace <[email protected]>");
|
|
expect(store.isManaged("intro-git")).toBe(true);
|
|
expect(await store.listManaged()).toEqual(["intro-git"]);
|
|
});
|
|
|
|
test("rejects bad and duplicate ids", async () => {
|
|
const { store } = await newStore();
|
|
await expect(store.createCourse({ id: "Bad Id", title: "x" }, ada)).rejects.toThrow(/id/);
|
|
await store.createCourse({ id: "c1", title: "x" }, ada);
|
|
await expect(store.createCourse({ id: "c1", title: "y" }, ada)).rejects.toThrow(/exists/);
|
|
});
|
|
});
|
|
|
|
describe("drafts and saving", () => {
|
|
test("a draft worktree per author on draft/<author>; save commits as the author; published untouched", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
const dir = await store.ensureDraft("c1", ada.id);
|
|
expect(dir).toBe(store.paths("c1").draft(ada.id));
|
|
expect(await extGit(dir, "rev-parse", "--abbrev-ref", "HEAD")).toBe("draft/u-ada");
|
|
const cur = await store.readFile("c1", ada.id, "lessons/welcome.md");
|
|
expect(cur).not.toBeNull();
|
|
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Hello draft\n", baseHash: cur!.hash }], "Edit welcome");
|
|
expect(r.ok).toBe(true);
|
|
expect(await extGit(dir, "log", "-1", "--format=%an <%ae>|%s")).toBe("Ada Lovelace <[email protected]>|Edit welcome");
|
|
expect(readFileSync(join(store.paths("c1").published, "lessons/welcome.md"), "utf8")).not.toContain("Hello draft");
|
|
const st = await store.status("c1", ada.id);
|
|
expect(st.ahead).toBe(1);
|
|
expect(st.behind).toBe(0);
|
|
expect(st.merging).toBe(false);
|
|
});
|
|
|
|
test("saving unchanged content makes no commit", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
|
|
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: cur.content, baseHash: cur.hash }], "noop");
|
|
expect(r).toEqual({ ok: true, commit: null });
|
|
});
|
|
|
|
test("an external edit between load and save is a conflict, not an overwrite", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
|
|
const file = join(store.paths("c1").draft(ada.id), "lessons/welcome.md");
|
|
writeFileSync(file, "edited in VS Code\n");
|
|
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "studio text\n", baseHash: cur.hash }], "x");
|
|
expect(r.ok).toBe(false);
|
|
if (r.ok) return;
|
|
expect(r.conflicts).toEqual([{ path: "lessons/welcome.md", current: "edited in VS Code\n", currentHash: contentHash("edited in VS Code\n") }]);
|
|
expect(readFileSync(file, "utf8")).toBe("edited in VS Code\n");
|
|
});
|
|
|
|
test("baseHash null means the file must not exist yet", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "x", baseHash: null }], "x");
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
test("paths with spaces and non-ASCII characters", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
const p = "S08 - Containerizing/Æble notes ü.md";
|
|
const r = await store.saveFiles("c1", ada, [{ path: p, content: "æøå\n", baseHash: null }], "Add notes");
|
|
expect(r.ok).toBe(true);
|
|
expect((await store.readFile("c1", ada.id, p))?.content).toBe("æøå\n");
|
|
expect(await store.listFiles("c1", ada.id)).toContain(p);
|
|
});
|
|
|
|
test("binary content and deletion", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
const bytes = new Uint8Array([0, 1, 2, 255]);
|
|
expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", content: bytes }], "bin")).ok).toBe(true);
|
|
expect(new Uint8Array(readFileSync(join(store.paths("c1").draft(ada.id), "media/a.bin")))).toEqual(bytes);
|
|
expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", delete: true }], "rm")).ok).toBe(true);
|
|
expect(await store.listFiles("c1", ada.id)).not.toContain("media/a.bin");
|
|
});
|
|
|
|
test("path traversal and .git are rejected", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
for (const bad of ["../x.md", "/etc/passwd", ".git/config", "a/../../x", "", "a\0b"]) {
|
|
await expect(store.saveFiles("c1", ada, [{ path: bad, content: "x" }], "x")).rejects.toThrow(/path/i);
|
|
}
|
|
expect(await store.readFile("c1", ada.id, "../../../etc/passwd")).toBeNull();
|
|
});
|
|
|
|
test("authors get separate drafts; odd author ids are made safe", async () => {
|
|
const { store } = await newStore();
|
|
await store.createCourse({ id: "c1", title: "C" }, ada);
|
|
const a = await store.ensureDraft("c1", ada.id);
|
|
const b = await store.ensureDraft("c1", "bob/../x y");
|
|
expect(a).not.toBe(b);
|
|
expect(await extGit(b, "rev-parse", "--abbrev-ref", "HEAD")).toMatch(/^draft\/[A-Za-z0-9_-]+$/);
|
|
void bob;
|
|
});
|
|
});
|