Files
STCourses/packages/feature-studio/test/store.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

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