Public: anyone can read this; the crew can change it.

A test pins forking when the copy fails: `fork_failed`, with no repository row or directory left behind #7179

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7175-a-test-pins-forking-when-the-copy-fails → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7175-a-test-pins-forking-when-the-copy-fails was merged into main as 70686d6.

Review

Sign in to review this change.
2 files changed +93 −0
src/http/api/upstream.ts +2
@@ -100,6 +100,8 @@ export function upstreamRoutes(router: Router<Handler>): void {
100100 ? apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id))
101101 : forked.error === "limit"
102102 ? apiError("limit", 429, `at most ${FORKS_PER_DAY} forks a day: try again tomorrow`)
103 : forked.error === "fork_failed"
104 ? apiError("fork_failed", 422, "the fork could not be created")
103105 : respondAccess(forked.error, 422);
104106 return json(forked.value, 201);
105107 });
test/fork-failed.test.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * Forking when the copy fails (#7175): the answer is a refusal in words,
3 * not a 500, and nothing is left behind — no repository row, no directory.
4 * Once the target is writable again, the same name forks.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { chmod, mkdtemp, readdir, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { promote } from "./helpers/members.ts";
15 import { tokenFor } from "./helpers/tokens.ts";
16
17 describe("forking when the copy fails", () => {
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let token: string;
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-failed-"));
25 app = createApp({ dataDir, port: 0 });
26 servers = await serve(app);
27 const users = [];
28 for (const [name, first] of [["alice", true], ["bob", false]] as const) {
29 const made = await createUser(app.db, {
30 origin: "seed",
31 trusted: true,
32 username: name,
33 email: `${name}@forge.local`,
34 password: "a-good-passphrase",
35 adminIfFirst: first,
36 });
37 if (!made.ok) throw new Error(made.error);
38 users.push(made.value);
39 }
40 promote(app.db, users[1]!);
41 const lib = await createRepo(app, users[0]!, { name: "lib", visibility: "public" });
42 if (!lib.ok) throw new Error(String(lib.error));
43 const seeded = await commitToBranch(app, users[0]!, lib.value, {
44 message: "Start",
45 files: [{ path: "README.md", content: "# lib\n" }],
46 });
47 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
48 token = tokenFor(app, "bob", "rw");
49 });
50
51 afterAll(async () => {
52 await chmod(app.reposRoot, 0o755).catch(() => {});
53 servers.stop();
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 const fork = () =>
59 fetch(`${servers.url}/api/v1/repos/alice/lib/forks`, {
60 method: "POST",
61 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
62 body: "{}",
63 });
64
65 test("a failed copy is a refusal in words and leaves nothing behind", async () => {
66 const before = (await readdir(app.reposRoot)).sort();
67 await chmod(app.reposRoot, 0o555);

Good: chmod is restored in finally, and afterAll restores it again, so a failed assertion cannot leave the temp dir undeletable. Note that this exercises only the case where the owner directory cannot be created. A clone that succeeds and then fails at git config would leave forkDir behind (src/git/repos.ts:76-80), and that case is not covered.

68 let res: Response;
69 try {
70 res = await fork();
71 } finally {
72 await chmod(app.reposRoot, 0o755);
73 }
74 expect(res.status).toBe(422);
75 const body = (await res.json()) as { error: string; message?: string };
76 expect(body.error).toBe("fork_failed");
77 expect(body.message).toBe("the fork could not be created");
78 expect(
79 app.db.query("SELECT 1 FROM repo WHERE name = 'lib' AND owner_id != (SELECT id FROM user WHERE username = 'alice')").get(),
80 ).toBeNull();
81 expect((await readdir(app.reposRoot)).sort()).toEqual(before);
82 });
83
84 test("once the target is writable, the same name forks", async () => {
85 const res = await fork();
86 expect(res.status).toBe(201);
87 const made = (await res.json()) as { name: string; ownerName: string };
88 expect(made.name).toBe("lib");
89 expect(made.ownerName).toBe("bob");
90 });
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs