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]>
155 lines
8.6 KiB
TypeScript
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);
|
|
});
|