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

155 lines
8.6 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ada, bob, extGit, newStore, tempDir } from "./helpers";
async function courseWithEdit(text = "# Draft text\n", opts: { allowLocalRemotes?: boolean } = {}) {
const s = await newStore(opts);
await s.store.createCourse({ id: "c1", title: "C" }, ada);
const cur = (await s.store.readFile("c1", ada.id, "lessons/welcome.md"))!;
await s.store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: text, baseHash: cur.hash }], "Draft edit");
return { ...s, pub: s.store.paths("c1").published };
}
describe("publish", () => {
test("merges the draft into main; the draft is level afterwards", async () => {
const { store, pub } = await courseWithEdit();
const r = await store.publish("c1", ada);
expect(r).toMatchObject({ ok: true, published: true });
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Draft text\n");
expect(await extGit(pub, "log", "-1", "--format=%an|%s")).toBe("Ada Lovelace|Publish draft/u-ada");
const st = await store.status("c1", ada.id);
expect([st.ahead, st.behind]).toEqual([0, 0]);
expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: false });
});
test("a conflicting external commit on main is reported and main keeps the external content", async () => {
const { store, pub } = await courseWithEdit("# Studio version\n");
writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n");
await extGit(pub, "commit", "-qam", "External edit");
const head = await extGit(pub, "rev-parse", "HEAD");
const r = await store.publish("c1", ada);
expect(r).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] });
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# External version\n");
expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head);
expect(await extGit(pub, "status", "--porcelain")).toBe("");
expect((await store.status("c1", ada.id)).behind).toBe(1);
});
test("uncommitted edits in the published worktree block publishing", async () => {
const { store, pub } = await courseWithEdit();
writeFileSync(join(pub, "lessons/welcome.md"), "half-typed in an editor");
const r = await store.publish("c1", ada);
expect(r).toEqual({ ok: false, reason: "dirty", files: ["lessons/welcome.md"] });
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("half-typed in an editor");
});
test("an invalid manifest blocks publishing with diagnostics", async () => {
const { store } = await courseWithEdit();
const m = (await store.readFile("c1", ada.id, "stcourse.yaml"))!;
await store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: m.content.replace("lessons/welcome.md", "lessons/missing.md"), baseHash: m.hash }], "break");
const r = await store.publish("c1", ada);
expect(r.ok).toBe(false);
if (r.ok || r.reason !== "invalid") throw new Error("expected invalid");
expect(r.diagnostics[0]!.message).toContain("lessons/missing.md");
});
});
describe("update draft and conflict resolution", () => {
test("conflicts are shown with both sides, resolved by the author, then publish succeeds", async () => {
const { store, pub } = await courseWithEdit("# Studio version\n");
writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n");
await extGit(pub, "commit", "-qam", "External edit");
const u = await store.updateDraft("c1", ada);
expect(u).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] });
const st = await store.status("c1", ada.id);
expect(st.merging).toBe(true);
expect(st.conflicts).toEqual(["lessons/welcome.md"]);
await expect(store.saveFiles("c1", ada, [{ path: "x.md", content: "x" }], "x")).rejects.toThrow(/merge/);
const [c] = await store.conflicts("c1", ada.id);
expect(c).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio version\n", theirs: "# External version\n" });
expect(c!.working).toContain("<<<<<<<");
const res = await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Both versions\n" }]);
expect(res.remaining).toEqual([]);
expect(res.commit).toBeTruthy();
expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: true });
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Both versions\n");
});
test("abort returns the draft to its pre-merge state", async () => {
const { store, pub } = await courseWithEdit("# Studio version\n");
writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n");
await extGit(pub, "commit", "-qam", "External edit");
await store.updateDraft("c1", ada);
await store.abortMerge("c1", ada.id);
const st = await store.status("c1", ada.id);
expect(st.merging).toBe(false);
expect((await store.readFile("c1", ada.id, "lessons/welcome.md"))!.content).toBe("# Studio version\n");
});
test("a non-conflicting external commit merges cleanly into the draft", async () => {
const { store, pub } = await courseWithEdit();
writeFileSync(join(pub, "extra.md"), "external\n");
await extGit(pub, "add", "extra.md");
await extGit(pub, "commit", "-qm", "External add");
expect((await store.updateDraft("c1", ada)).ok).toBe(true);
expect((await store.readFile("c1", ada.id, "extra.md"))!.content).toBe("external\n");
});
});
describe("sync", () => {
test("no remote: an error, nothing happens", async () => {
const { store } = await courseWithEdit();
expect(await store.sync("c1", ada)).toMatchObject({ ok: false, reason: "no-remote" });
});
test("push to an empty remote, pull a remote commit, report a conflicting divergence", async () => {
// local bare remotes need the test-only allowLocalRemotes switch; the HTTP feature never sets it
const { store, pub } = await courseWithEdit("# Draft text\n", { allowLocalRemotes: true });
await store.publish("c1", ada);
const remote = join(tempDir(), "remote.git");
await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote);
await store.setRemote("c1", remote);
expect(await store.remote("c1")).toBe(remote);
expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true, pulled: false });
expect(await extGit(remote, "rev-parse", "main")).toBe(await extGit(pub, "rev-parse", "HEAD"));
// someone else pushes to the remote
const other = join(tempDir(), "other");
await extGit(tempDir(), "clone", "-q", remote, other);
writeFileSync(join(other, "lessons/welcome.md"), "# Remote version\n");
await extGit(other, "commit", "-qam", "Remote edit");
await extGit(other, "push", "-q", "origin", "main");
expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pulled: true, pushed: false });
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Remote version\n");
// diverge with a conflict
writeFileSync(join(other, "lessons/welcome.md"), "# Remote again\n");
await extGit(other, "commit", "-qam", "Remote edit 2");
await extGit(other, "push", "-q", "origin", "main");
await store.updateDraft("c1", ada);
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Local again\n", baseHash: cur.hash }], "local");
expect((await store.publish("c1", ada)).ok).toBe(true);
const head = await extGit(pub, "rev-parse", "HEAD");
expect(await store.sync("c1", ada)).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] });
expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head);
expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Local again\n");
// the author resolves via the draft, then Sync goes through
expect(await store.updateDraft("c1", ada, "remote")).toMatchObject({ ok: false, reason: "conflict" });
await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Merged\n" }]);
expect((await store.publish("c1", ada)).ok).toBe(true);
expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true });
expect(await extGit(remote, "show", "main:lessons/welcome.md")).toBe("# Merged");
void bob;
});
});
test("ext:: remotes cannot run commands, even with local remotes allowed for tests", async () => {
const { store } = await courseWithEdit("# x\n", { allowLocalRemotes: true });
const marker = join(tempDir(), "pwned");
await expect(store.setRemote("c1", `ext::touch%s${marker}`)).rejects.toThrow(/remote/);
expect(await Bun.file(marker).exists()).toBe(false);
});