Public: anyone can read this; the crew can change it.
Fast test/forks.test.ts: chop and rewrite under the budget (1 s a test, 10 s a file) #1290
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1251-fast-test-forks-test-ts-chop-and-rewrite → main 2 commits fork
1 file
changed +164
−398
test/forks.test.ts +164 −398
| @@ -2,473 +2,239 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtemp, rm } from "node:fs/promises"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { | |
| 6 | commitToBranch, | |
| 7 | createRepo, | |
| 8 | deleteRepo, | |
| 9 | forkRepo, | |
| 10 | updateRepoSettings, | |
| 11 | } from "../src/actions/repos.ts"; | |
| 12 | import { syncFork } from "../src/actions/forks.ts"; | |
| 5 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 6 | import { deleteRepo, getRepo } from "../src/core/repos.ts"; | |
| 14 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { commitFiles } from "../src/git/write.ts"; | |
| 18 | import { resolveOid } from "../src/git/merge.ts"; | |
| 7 | 19 | import { repoDir } from "../src/git/repos.ts"; |
| 8 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 10 | import { proveAll } from "./helpers/verified.ts"; | |
| 11 | 21 | |
| 12 | const gitEnv = gitClientEnv({ | |
| 13 | GIT_AUTHOR_NAME: "F", | |
| 14 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 15 | GIT_COMMITTER_NAME: "F", | |
| 16 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 17 | }); | |
| 22 | /** | |
| 23 | * The invariants, one line each (the old file proved them through git over | |
| 24 | * HTTP, a token and a sign-in per person, and a 50 ms poll): | |
| 25 | * - a fork borrows its parent's objects, carries its history, and a push to | |
| 26 | * it never reaches the parent; | |
| 27 | * - a fork follows its parent's default branch on its own, fast-forward | |
| 28 | * only; one with work of its own is refused and told so; | |
| 29 | * - the About rail counts how far a fork is behind or ahead, and offers an | |
| 30 | * update only when one can be taken; | |
| 31 | * - the fork count and the list hold only forks the viewer may read; | |
| 32 | * - a private repo cannot be forked by a stranger, nor forked twice; | |
| 33 | * - a public fork never names a private parent to a stranger; | |
| 34 | * - deleting the parent detaches the fork whole. | |
| 35 | * Dropped as duplicates of what git.test.ts and the API suites prove: the | |
| 36 | * clone/push wire itself, and the sync route's JSON shape. | |
| 37 | */ | |
| 38 | ||
| 39 | const PASSWORD = "a-good-passphrase"; | |
| 18 | 40 | |
| 19 | 41 | describe("forks", () => { |
| 20 | 42 | let dataDir: string; |
| 21 | let work: string; | |
| 22 | 43 | let app: App; |
| 23 | 44 | let servers: Servers; |
| 24 | 45 | let base: string; |
| 25 | let aliceToken = ""; | |
| 26 | let bobToken = ""; | |
| 46 | const users: Record<string, User> = {}; | |
| 47 | const cookies: Record<string, string> = { "": "forge_mode=dev" }; | |
| 48 | let upstream: Repo; | |
| 27 | 49 | |
| 28 | async function gitOk(args: string[], cwd: string): Promise<string> { | |
| 29 | const p = Bun.spawn(["git", ...args], { | |
| 30 | cwd, | |
| 31 | env: gitEnv, | |
| 32 | stdout: "pipe", | |
| 33 | stderr: "pipe", | |
| 34 | }); | |
| 35 | const [out, err, code] = await Promise.all([ | |
| 36 | new Response(p.stdout).text(), | |
| 37 | new Response(p.stderr).text(), | |
| 38 | p.exited, | |
| 39 | ]); | |
| 40 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`); | |
| 41 | return out.trim(); | |
| 42 | } | |
| 43 | async function gitCode(args: string[], cwd: string): Promise<number> { | |
| 44 | const p = Bun.spawn(["git", ...args], { | |
| 45 | cwd, | |
| 46 | env: gitEnv, | |
| 47 | stdout: "ignore", | |
| 48 | stderr: "ignore", | |
| 49 | }); | |
| 50 | return await p.exited; | |
| 51 | } | |
| 52 | ||
| 53 | async function mkUser(username: string): Promise<string> { | |
| 54 | await fetch(`${base}/api/v1/users`, { | |
| 55 | method: "POST", | |
| 56 | headers: { "content-type": "application/json" }, | |
| 57 | body: JSON.stringify({ | |
| 58 | username, | |
| 59 | email: `${username}@f.dev`, | |
| 60 | password: "fork-me-please", | |
| 50 | const dirOf = (repo: Repo) => repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 51 | const tip = (repo: Repo) => resolveOid(dirOf(repo), `refs/heads/${repo.defaultBranch}`); | |
| 52 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 53 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 54 | return r.value; | |
| 55 | }; | |
| 56 | const forkAs = async (who: string, parent: Repo, name?: string) => | |
| 57 | must(await forkRepo(app, users[who]!, parent, name)); | |
| 58 | const commitAs = async (who: string, repo: Repo, file: string, message: string) => | |
| 59 | must( | |
| 60 | await commitToBranch(app, users[who]!, repo, { | |
| 61 | message, | |
| 62 | files: [{ path: file, content: `${message}\n` }], | |
| 61 | 63 | }), |
| 62 | }); | |
| 63 | proveAll(app); | |
| 64 | return fetch(`${base}/api/v1/tokens`, { | |
| 65 | method: "POST", | |
| 66 | headers: { "content-type": "application/json" }, | |
| 67 | body: JSON.stringify({ adminConfirm: true, | |
| 68 | username, | |
| 69 | password: "fork-me-please", | |
| 70 | name: "c", | |
| 71 | scope: "admin", | |
| 72 | }), | |
| 73 | }) | |
| 74 | .then((r) => r.json()) | |
| 75 | .then((j) => (j as { token: string }).token); | |
| 76 | } | |
| 77 | ||
| 78 | /** Sign in through the web form; the cookie is what a browser would carry. */ | |
| 79 | async function signIn(username: string): Promise<string> { | |
| 80 | const res = await fetch(`${base}/signin`, { | |
| 81 | method: "POST", | |
| 82 | body: new URLSearchParams({ username, password: "fork-me-please" }), | |
| 83 | redirect: "manual", | |
| 84 | }); | |
| 85 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 86 | } | |
| 87 | ||
| 88 | /** The HTML of a page as somebody — or, with no cookie, as a stranger. */ | |
| 89 | async function pageAs(path: string, cookie?: string): Promise<string> { | |
| 90 | const res = await fetch( | |
| 91 | `${base}${path}`, | |
| 92 | cookie === undefined ? {} : { headers: { cookie } }, | |
| 93 | 64 | ); |
| 65 | /** Pushes are followed in the background; give it turns of the event loop, not a clock. */ | |
| 66 | const follows = async (fork: Repo, want: string | undefined) => { | |
| 67 | for (let turns = 0; turns < 5_000; turns++) { | |
| 68 | if ((await tip(fork)) === want) return true; | |
| 69 | await new Promise((resolve) => setImmediate(resolve)); | |
| 70 | } | |
| 71 | return false; | |
| 72 | }; | |
| 73 | const page = async (path: string, who = "") => { | |
| 74 | const res = await fetch(`${base}${path}`, { headers: { cookie: cookies[who]! } }); | |
| 94 | 75 | expect(res.status).toBe(200); |
| 95 | 76 | return await res.text(); |
| 96 | } | |
| 77 | }; | |
| 97 | 78 | |
| 98 | 79 | beforeAll(async () => { |
| 99 | 80 | dataDir = await mkdtemp(join(tmpdir(), "forge-fork-")); |
| 100 | work = await mkdtemp(join(tmpdir(), "forge-fork-work-")); | |
| 101 | 81 | app = createApp({ dataDir, port: 0 }); |
| 102 | 82 | servers = await serve(app); |
| 103 | 83 | base = servers.url; |
| 104 | aliceToken = await mkUser("alice"); | |
| 105 | bobToken = await mkUser("bob"); | |
| 106 | ||
| 107 | // Alice makes a public repo with a commit. | |
| 108 | await fetch(`${base}/api/v1/repos`, { | |
| 109 | method: "POST", | |
| 110 | headers: { | |
| 111 | authorization: `Bearer ${aliceToken}`, | |
| 112 | "content-type": "application/json", | |
| 113 | }, | |
| 114 | body: JSON.stringify({ name: "app", visibility: "public" }), | |
| 115 | }); | |
| 116 | const remote = `${base.replace("http://", `http://alice:${aliceToken}@`)}/alice/app.git`; | |
| 117 | await gitOk(["clone", remote, "app"], work); | |
| 118 | const dir = join(work, "app"); | |
| 119 | await Bun.write(join(dir, "README.md"), "# original\n"); | |
| 120 | await gitOk(["add", "."], dir); | |
| 121 | await gitOk(["commit", "-m", "seed"], dir); | |
| 122 | await gitOk(["push", "origin", "main"], dir); | |
| 123 | }); | |
| 84 | for (const name of ["alice", "bob", "carol", "dan"]) { | |
| 85 | const made = await createUser(app.db, { | |
| 86 | trusted: true, | |
| 87 | username: name, | |
| 88 | email: `${name}@f.dev`, | |
| 89 | password: PASSWORD, | |
| 90 | adminIfFirst: name === "alice", | |
| 91 | }); | |
| 92 | users[name] = must(made); | |
| 93 | cookies[name] = | |
| 94 | `forge_session=${createSession(app.db, users[name]!.id).token}; forge_mode=dev`; | |
| 95 | } | |
| 96 | upstream = must( | |
| 97 | await createRepo(app, users["alice"]!, { name: "app", visibility: "public" }), | |
| 98 | ); | |
| 99 | await commitAs("alice", upstream, "README.md", "seed"); | |
| 100 | }, 60_000); | |
| 124 | 101 | |
| 125 | 102 | afterAll(async () => { |
| 126 | 103 | servers.stop(); |
| 127 | 104 | app.close(); |
| 128 | 105 | await rm(dataDir, { recursive: true, force: true }); |
| 129 | await rm(work, { recursive: true, force: true }); | |
| 130 | 106 | }); |
| 131 | 107 | |
| 132 | test("bob forks alice's repo; it shares objects via alternates", async () => { | |
| 133 | const res = await fetch(`${base}/api/v1/repos/alice/app/forks`, { | |
| 134 | method: "POST", | |
| 135 | headers: { | |
| 136 | authorization: `Bearer ${bobToken}`, | |
| 137 | "content-type": "application/json", | |
| 138 | }, | |
| 139 | body: JSON.stringify({}), | |
| 140 | }); | |
| 141 | expect(res.status).toBe(201); | |
| 142 | const fork = (await res.json()) as { | |
| 143 | ownerName: string; | |
| 144 | name: string; | |
| 145 | forkParentId: string | null; | |
| 146 | }; | |
| 147 | expect(fork.ownerName).toBe("bob"); | |
| 148 | expect(fork.forkParentId).not.toBeNull(); | |
| 149 | ||
| 150 | const forkDir = repoDir(join(dataDir, "repos"), "bob", "app"); | |
| 151 | // Alternates present → objects are shared, not copied. | |
| 108 | test("a fork borrows its parent's objects and a push to it never reaches the parent", async () => { | |
| 109 | const bob = await forkAs("bob", upstream); | |
| 110 | expect(bob.ownerName).toBe("bob"); | |
| 111 | expect(bob.forkParentId).toBe(upstream.id); | |
| 152 | 112 | expect( |
| 153 | await Bun.file(join(forkDir, "objects", "info", "alternates")).exists(), | |
| 113 | await Bun.file(join(dirOf(bob), "objects", "info", "alternates")).exists(), | |
| 154 | 114 | ).toBe(true); |
| 155 | // The fork carries the parent's history. | |
| 156 | const log = await gitOk(["-C", forkDir, "log", "--oneline"], work); | |
| 157 | expect(log).toContain("seed"); | |
| 115 | const seed = await tip(upstream); | |
| 116 | expect(await tip(bob)).toBe(seed); | |
| 117 | ||
| 118 | await commitAs("bob", bob, "feature.txt", "bob feature"); | |
| 119 | expect(await tip(bob)).not.toBe(seed); | |
| 120 | expect(await tip(upstream)).toBe(seed); | |
| 158 | 121 | }); |
| 159 | 122 | |
| 160 | test("bob pushes to his fork; alice's repo is untouched", async () => { | |
| 161 | const forkRemote = `${base.replace("http://", `http://bob:${bobToken}@`)}/bob/app.git`; | |
| 162 | await gitOk(["clone", forkRemote, "bob-app"], work); | |
| 163 | const dir = join(work, "bob-app"); | |
| 164 | await Bun.write(join(dir, "feature.txt"), "bob's work\n"); | |
| 165 | await gitOk(["add", "."], dir); | |
| 166 | await gitOk(["commit", "-m", "bob feature"], dir); | |
| 167 | await gitOk(["push", "origin", "main"], dir); | |
| 123 | test("a fork follows its parent fast-forward only; one with work of its own is refused", async () => { | |
| 124 | const carol = await forkAs("carol", upstream); | |
| 125 | await commitAs("alice", upstream, "NEWS.md", "alice moves on"); | |
| 126 | const moved = await tip(upstream); | |
| 127 | expect(await follows(carol, moved)).toBe(true); | |
| 168 | 128 | |
| 169 | // The fork has bob's commit… | |
| 170 | const forkLog = await gitOk( | |
| 171 | ["-C", repoDir(join(dataDir, "repos"), "bob", "app"), "log", "--oneline"], | |
| 172 | work, | |
| 173 | ); | |
| 174 | expect(forkLog).toContain("bob feature"); | |
| 175 | // …but alice's repo does not. | |
| 176 | const aliceLog = await gitOk( | |
| 177 | [ | |
| 178 | "-C", | |
| 179 | repoDir(join(dataDir, "repos"), "alice", "app"), | |
| 180 | "log", | |
| 181 | "--oneline", | |
| 182 | ], | |
| 183 | work, | |
| 184 | ); | |
| 185 | expect(aliceLog).not.toContain("bob feature"); | |
| 186 | }); | |
| 187 | ||
| 188 | test("a fork follows its parent's default branch, fast-forward only", async () => { | |
| 189 | const carolToken = await mkUser("carol"); | |
| 190 | const as = (token: string) => ({ | |
| 191 | authorization: `Bearer ${token}`, | |
| 192 | "content-type": "application/json", | |
| 193 | }); | |
| 194 | const forked = await fetch(`${base}/api/v1/repos/alice/app/forks`, { | |
| 195 | method: "POST", | |
| 196 | headers: as(carolToken), | |
| 197 | body: "{}", | |
| 198 | }); | |
| 199 | expect(forked.status).toBe(201); | |
| 200 | const repos = join(dataDir, "repos"); | |
| 201 | ||
| 202 | // Alice moves main; carol's untouched fork follows on its own. | |
| 203 | const dir = join(work, "app"); | |
| 204 | await Bun.write(join(dir, "NEWS.md"), "moved\n"); | |
| 205 | await gitOk(["add", "."], dir); | |
| 206 | await gitOk(["commit", "-m", "alice moves on"], dir); | |
| 207 | await gitOk(["push", "origin", "main"], dir); | |
| 208 | const aliceTip = ( | |
| 209 | await gitOk( | |
| 210 | ["-C", repoDir(repos, "alice", "app"), "rev-parse", "main"], | |
| 211 | work, | |
| 212 | ) | |
| 213 | ).trim(); | |
| 214 | const deadline = Date.now() + 5_000; | |
| 215 | let carolTip = ""; | |
| 216 | while (Date.now() < deadline) { | |
| 217 | carolTip = ( | |
| 218 | await gitOk( | |
| 219 | ["-C", repoDir(repos, "carol", "app"), "rev-parse", "main"], | |
| 220 | work, | |
| 221 | ) | |
| 222 | ).trim(); | |
| 223 | if (carolTip === aliceTip) break; | |
| 224 | await Bun.sleep(50); | |
| 225 | } | |
| 226 | expect(carolTip).toBe(aliceTip); | |
| 227 | 129 | // Asked by hand, there is nothing left to do. |
| 228 | const again = await fetch(`${base}/api/v1/repos/carol/app/sync`, { | |
| 229 | method: "POST", | |
| 230 | headers: as(carolToken), | |
| 231 | body: "{}", | |
| 232 | }); | |
| 233 | expect(again.status).toBe(200); | |
| 234 | const answer = (await again.json()) as { updated: boolean; to: string }; | |
| 235 | expect(answer.updated).toBe(false); | |
| 236 | expect(answer.to).toBe(aliceTip); | |
| 130 | const again = must(await syncFork(app, users["carol"]!, carol)); | |
| 131 | expect(again.updated).toBe(false); | |
| 132 | expect(again.to).toBe(moved!); | |
| 237 | 133 | |
| 238 | // Bob committed on his fork's main: it is his to reconcile, and he is told. | |
| 239 | const bobBefore = ( | |
| 240 | await gitOk( | |
| 241 | ["-C", repoDir(repos, "bob", "app"), "rev-parse", "main"], | |
| 242 | work, | |
| 243 | ) | |
| 244 | ).trim(); | |
| 245 | const refused = await fetch(`${base}/api/v1/repos/bob/app/sync`, { | |
| 246 | method: "POST", | |
| 247 | headers: as(bobToken), | |
| 248 | body: "{}", | |
| 249 | }); | |
| 250 | expect(refused.status).toBe(409); | |
| 251 | expect(((await refused.json()) as { error: string }).error).toBe( | |
| 252 | "diverged", | |
| 253 | ); | |
| 254 | expect( | |
| 255 | ( | |
| 256 | await gitOk( | |
| 257 | ["-C", repoDir(repos, "bob", "app"), "rev-parse", "main"], | |
| 258 | work, | |
| 259 | ) | |
| 260 | ).trim(), | |
| 261 | ).toBe(bobBefore); | |
| 134 | const bob = getRepo(app.db, "bob", "app")!; | |
| 135 | const before = await tip(bob); | |
| 136 | const refused = await syncFork(app, users["bob"]!, bob); | |
| 137 | expect(refused.ok).toBe(false); | |
| 138 | if (!refused.ok) expect(refused.error).toBe("diverged"); | |
| 139 | expect(await tip(bob)).toBe(before); | |
| 262 | 140 | }); |
| 263 | 141 | |
| 264 | test("the About rail says how far a fork is from its parent", async () => { | |
| 265 | const as = (token: string) => ({ | |
| 266 | authorization: `Bearer ${token}`, | |
| 267 | "content-type": "application/json", | |
| 268 | }); | |
| 269 | ||
| 270 | // Dan forks alice/app as it stands, so his copy starts level with it: | |
| 271 | // nothing to take, and nothing offered. | |
| 272 | const danToken = await mkUser("dan"); | |
| 273 | const danCookie = await signIn("dan"); | |
| 274 | expect( | |
| 275 | ( | |
| 276 | await fetch(`${base}/api/v1/repos/alice/app/forks`, { | |
| 277 | method: "POST", | |
| 278 | headers: as(danToken), | |
| 279 | body: "{}", | |
| 280 | }) | |
| 281 | ).status, | |
| 282 | ).toBe(201); | |
| 283 | const current = await pageAs("/dan/app", danCookie); | |
| 142 | test("the About rail says how far a fork is from its parent, and offers an update only when one can be taken", async () => { | |
| 143 | // Dan forks as upstream stands: level, nothing offered. | |
| 144 | await forkAs("dan", upstream); | |
| 145 | const current = await page("/dan/app", "dan"); | |
| 284 | 146 | expect(current).toContain("up to date"); |
| 285 | 147 | expect(current).not.toContain("Update from parent"); |
| 286 | 148 | |
| 287 | // Alice's main moves without a push the forge saw — a follow that failed, | |
| 288 | // or has not happened yet — which is how a fork ends up plainly behind: | |
| 289 | // the page counts the commit it is missing and offers the update. | |
| 290 | const dir = join(work, "app"); | |
| 291 | await Bun.write(join(dir, "LATER.md"), "later\n"); | |
| 292 | await gitOk(["add", "."], dir); | |
| 293 | await gitOk(["commit", "-m", "alice again"], dir); | |
| 294 | await gitOk( | |
| 295 | [ | |
| 296 | "-C", | |
| 297 | repoDir(join(dataDir, "repos"), "alice", "app"), | |
| 298 | "fetch", | |
| 299 | "-q", | |
| 300 | dir, | |
| 301 | "main:main", | |
| 302 | ], | |
| 303 | work, | |
| 149 | // Upstream's main moves with no push the forge saw (a follow that failed): | |
| 150 | // the fork is plainly behind and the page offers the update. | |
| 151 | must( | |
| 152 | await commitFiles(dirOf(upstream), { | |
| 153 | branch: "main", | |
| 154 | message: "alice again", | |
| 155 | files: [{ path: "LATER.md", content: "later\n" }], | |
| 156 | author: { name: "A", email: "[email protected]" }, | |
| 157 | }), | |
| 304 | 158 | ); |
| 305 | ||
| 306 | const behind = await pageAs("/dan/app", danCookie); | |
| 159 | const behind = await page("/dan/app", "dan"); | |
| 307 | 160 | expect(behind).toContain("1 commit behind"); |
| 308 | 161 | expect(behind).toContain("Take updates"); |
| 309 | 162 | |
| 310 | // Bob committed on his fork and alice moved on twice since: an update | |
| 311 | // would be refused, so the page says why instead of offering one. | |
| 312 | const diverged = await pageAs("/bob/app", await signIn("bob")); | |
| 163 | // Bob has work of his own and is two behind: an update would be refused, | |
| 164 | // so the page says why instead of offering one. | |
| 165 | const diverged = await page("/bob/app", "bob"); | |
| 313 | 166 | expect(diverged).toContain("1 of your own"); |
| 314 | 167 | expect(diverged).toContain("2 behind"); |
| 315 | 168 | expect(diverged).not.toContain("Update from parent"); |
| 316 | 169 | }); |
| 317 | 170 | |
| 318 | test("the fork count on the repository page opens the list of forks", async () => { | |
| 319 | const home = await (await fetch(`${base}/alice/app`)).text(); | |
| 171 | test("the fork count opens the list, and counts only forks the viewer may read", async () => { | |
| 172 | const home = await page("/alice/app"); | |
| 320 | 173 | expect(home).toContain('href="/alice/app/forks"'); |
| 321 | 174 | expect(home).toContain("3 forks"); |
| 322 | const listed = await (await fetch(`${base}/alice/app/forks`)).text(); | |
| 175 | const listed = await page("/alice/app/forks"); | |
| 323 | 176 | expect(listed).toContain("3 forks"); |
| 324 | expect(listed).toContain('href="/bob/app"'); | |
| 325 | expect(listed).toContain('href="/carol/app"'); | |
| 326 | expect(listed).toContain('href="/dan/app"'); | |
| 177 | for (const who of ["bob", "carol", "dan"]) | |
| 178 | expect(listed).toContain(`href="/${who}/app"`); | |
| 179 | ||
| 180 | const quiet = await forkAs("alice", upstream, "app-quiet"); | |
| 181 | must(await updateRepoSettings(app, users["alice"]!, quiet, { visibility: "private" })); | |
| 182 | expect(await page("/alice/app")).toContain("3 forks"); | |
| 183 | expect(await page("/alice/app/forks")).not.toContain('href="/alice/app-quiet"'); | |
| 184 | expect(await page("/alice/app", "alice")).toContain("4 forks"); | |
| 327 | 185 | }); |
| 328 | 186 | |
| 329 | test("a stranger cannot fork a private repo", async () => { | |
| 330 | await fetch(`${base}/api/v1/repos`, { | |
| 331 | method: "POST", | |
| 332 | headers: { | |
| 333 | authorization: `Bearer ${aliceToken}`, | |
| 334 | "content-type": "application/json", | |
| 335 | }, | |
| 336 | body: JSON.stringify({ name: "secret", visibility: "private" }), | |
| 337 | }); | |
| 338 | const res = await fetch(`${base}/api/v1/repos/alice/secret/forks`, { | |
| 339 | method: "POST", | |
| 340 | headers: { | |
| 341 | authorization: `Bearer ${bobToken}`, | |
| 342 | "content-type": "application/json", | |
| 343 | }, | |
| 344 | body: JSON.stringify({}), | |
| 345 | }); | |
| 346 | expect(res.status).toBe(404); | |
| 347 | }); | |
| 348 | ||
| 349 | test("double-forking is refused", async () => { | |
| 350 | const res = await fetch(`${base}/api/v1/repos/alice/app/forks`, { | |
| 351 | method: "POST", | |
| 352 | headers: { | |
| 353 | authorization: `Bearer ${bobToken}`, | |
| 354 | "content-type": "application/json", | |
| 355 | }, | |
| 356 | body: JSON.stringify({}), | |
| 357 | }); | |
| 358 | expect(res.status).toBe(422); | |
| 359 | expect(((await res.json()) as { error: string }).error).toBe( | |
| 360 | "already_forked", | |
| 187 | test("a stranger cannot fork a private repo, and nobody forks a parent twice", async () => { | |
| 188 | const secret = must( | |
| 189 | await createRepo(app, users["alice"]!, { name: "secret", visibility: "private" }), | |
| 361 | 190 | ); |
| 191 | const refused = await forkRepo(app, users["bob"]!, secret); | |
| 192 | expect(refused.ok).toBe(false); | |
| 193 | if (!refused.ok) expect(refused.error).toBe("not_found"); | |
| 194 | ||
| 195 | const twice = await forkRepo(app, users["bob"]!, upstream); | |
| 196 | expect(twice.ok).toBe(false); | |
| 197 | if (!twice.ok) expect(twice.error).toBe("already_forked"); | |
| 362 | 198 | }); |
| 363 | 199 | |
| 364 | test("a fork does not name a parent the viewer cannot read", async () => { | |
| 365 | const asAlice = { | |
| 366 | authorization: `Bearer ${aliceToken}`, | |
| 367 | "content-type": "application/json", | |
| 368 | }; | |
| 369 | // A private repo, forked by its own owner and then opened: the copy is | |
| 370 | // public, the parent is not. The lineage row is the one place a public | |
| 371 | // page could name a repository the visitor cannot see — and the drift | |
| 372 | // beside it would say how busy that repository has been. | |
| 373 | await fetch(`${base}/api/v1/repos`, { | |
| 374 | method: "POST", | |
| 375 | headers: asAlice, | |
| 376 | body: JSON.stringify({ name: "vault", visibility: "private" }), | |
| 377 | }); | |
| 378 | const remote = `${base.replace("http://", `http://alice:${aliceToken}@`)}/alice/vault.git`; | |
| 379 | await gitOk(["clone", remote, "vault"], work); | |
| 380 | const dir = join(work, "vault"); | |
| 381 | await Bun.write(join(dir, "README.md"), "# vault\n"); | |
| 382 | await gitOk(["add", "."], dir); | |
| 383 | await gitOk(["commit", "-m", "vault seed"], dir); | |
| 384 | await gitOk(["push", "origin", "main"], dir); | |
| 385 | expect( | |
| 386 | ( | |
| 387 | await fetch(`${base}/api/v1/repos/alice/vault/forks`, { | |
| 388 | method: "POST", | |
| 389 | headers: asAlice, | |
| 390 | body: JSON.stringify({ name: "vault-open" }), | |
| 391 | }) | |
| 392 | ).status, | |
| 393 | ).toBe(201); | |
| 394 | expect( | |
| 395 | ( | |
| 396 | await fetch(`${base}/api/v1/repos/alice/vault-open`, { | |
| 397 | method: "PATCH", | |
| 398 | headers: asAlice, | |
| 399 | body: JSON.stringify({ visibility: "public" }), | |
| 400 | }) | |
| 401 | ).status, | |
| 402 | ).toBe(200); | |
| 200 | test("a public fork does not name a private parent to a stranger", async () => { | |
| 201 | const vault = must( | |
| 202 | await createRepo(app, users["alice"]!, { name: "vault", visibility: "private" }), | |
| 203 | ); | |
| 204 | await commitAs("alice", vault, "README.md", "vault seed"); | |
| 205 | const open = await forkAs("alice", vault, "vault-open"); | |
| 206 | must(await updateRepoSettings(app, users["alice"]!, open, { visibility: "public" })); | |
| 403 | 207 | |
| 404 | const stranger = await pageAs("/alice/vault-open"); | |
| 208 | const stranger = await page("/alice/vault-open"); | |
| 405 | 209 | expect(stranger).not.toContain("Fork of"); |
| 406 | 210 | expect(stranger).not.toContain('href="/alice/vault"'); |
| 407 | 211 | expect(stranger).not.toContain("up to date"); |
| 408 | 212 | |
| 409 | // Alice reads the same page and the row is all there. | |
| 410 | const owner = await pageAs("/alice/vault-open", await signIn("alice")); | |
| 213 | const owner = await page("/alice/vault-open", "alice"); | |
| 411 | 214 | expect(owner).toContain("Fork of"); |
| 412 | 215 | expect(owner).toContain('href="/alice/vault"'); |
| 413 | 216 | expect(owner).toContain("up to date"); |
| 414 | 217 | }); |
| 415 | 218 | |
| 416 | test("a fork the viewer cannot read is not counted on the parent", async () => { | |
| 417 | const asAlice = { | |
| 418 | authorization: `Bearer ${aliceToken}`, | |
| 419 | "content-type": "application/json", | |
| 420 | }; | |
| 219 | test("deleting the parent detaches the fork, which survives whole", async () => { | |
| 220 | const bob = getRepo(app.db, "bob", "app")!; | |
| 221 | must(await deleteRepo(app, users["alice"]!, upstream)); | |
| 222 | ||
| 421 | 223 | expect( |
| 422 | ( | |
| 423 | await fetch(`${base}/api/v1/repos/alice/app/forks`, { | |
| 424 | method: "POST", | |
| 425 | headers: asAlice, | |
| 426 | body: JSON.stringify({ name: "app-quiet" }), | |
| 427 | }) | |
| 428 | ).status, | |
| 429 | ).toBe(201); | |
| 430 | expect( | |
| 431 | ( | |
| 432 | await fetch(`${base}/api/v1/repos/alice/app-quiet`, { | |
| 433 | method: "PATCH", | |
| 434 | headers: asAlice, | |
| 435 | body: JSON.stringify({ visibility: "private" }), | |
| 436 | }) | |
| 437 | ).status, | |
| 438 | ).toBe(200); | |
| 439 | ||
| 440 | // The count is a link to the list, so it counts what the list would show. | |
| 441 | const stranger = await pageAs("/alice/app"); | |
| 442 | expect(stranger).toContain("3 forks"); | |
| 443 | expect(await pageAs("/alice/app/forks")).not.toContain( | |
| 444 | 'href="/alice/app-quiet"', | |
| 445 | ); | |
| 446 | expect(await pageAs("/alice/app", await signIn("alice"))).toContain( | |
| 447 | "4 forks", | |
| 448 | ); | |
| 449 | }); | |
| 450 | ||
| 451 | test("deleting the parent detaches the fork, which survives standalone", async () => { | |
| 452 | const forkDir = repoDir(join(dataDir, "repos"), "bob", "app"); | |
| 453 | const parent = getRepo(app.db, "alice", "app")!; | |
| 454 | ||
| 455 | // Delete the parent whose objects the fork borrows. | |
| 456 | const deleted = await deleteRepo(app.db, join(dataDir, "repos"), parent); | |
| 457 | expect(deleted.ok).toBe(true); | |
| 458 | ||
| 459 | // The fork's alternates link is gone (objects were absorbed)… | |
| 460 | expect( | |
| 461 | await Bun.file(join(forkDir, "objects", "info", "alternates")).exists(), | |
| 224 | await Bun.file(join(dirOf(bob), "objects", "info", "alternates")).exists(), | |
| 462 | 225 | ).toBe(false); |
| 463 | // …its parent link is cleared… | |
| 464 | 226 | expect(getRepo(app.db, "bob", "app")!.forkParentId).toBeNull(); |
| 465 | // …and it is fully intact: git can still walk the original + bob's history | |
| 466 | // with strict connectivity (would fail if borrowed objects were lost). | |
| 467 | expect( | |
| 468 | await gitCode(["-C", forkDir, "fsck", "--connectivity-only"], work), | |
| 469 | ).toBe(0); | |
| 470 | const log = await gitOk(["-C", forkDir, "log", "--oneline"], work); | |
| 471 | expect(log).toContain("seed"); | |
| 472 | expect(log).toContain("bob feature"); | |
| 227 | // Strict connectivity fails if any borrowed object was not absorbed. | |
| 228 | const fsck = Bun.spawn(["git", "-C", dirOf(bob), "fsck", "--connectivity-only"], { | |
| 229 | stdout: "ignore", | |
| 230 | stderr: "ignore", | |
| 231 | }); | |
| 232 | expect(await fsck.exited).toBe(0); | |
| 233 | const log = Bun.spawn(["git", "-C", dirOf(bob), "log", "--format=%s"], { | |
| 234 | stdout: "pipe", | |
| 235 | }); | |
| 236 | const subjects = await new Response(log.stdout).text(); | |
| 237 | expect(subjects).toContain("seed"); | |
| 238 | expect(subjects).toContain("bob feature"); | |
| 473 | 239 | }); |
| 474 | 240 | }); |
new above
offline