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

A test pins reattaching a mirror: it refuses a different repository, or one already mirrored #7154

Merged opened this

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

Merged

scout/forge:scout/7089-a-test-pins-reattaching-a-mirror-it-refu → main 1 commit fork

  • Merged. scout/7089-a-test-pins-reattaching-a-mirror-it-refu was merged into main as 6a37a45.

Review

Sign in to review this change.
2 files changed +250 −1
test/fake-origin.ts +5 −1
@@ -36,6 +36,8 @@ export type FakeKind = "github" | "forgejo" | "gitea";
3636 export interface FakeRepo {
3737 id: number;
3838 fullName: string;
39 /** Names it answered to before a rename; a real forge redirects them. */
40 formerNames?: string[];
3941 description?: string;
4042 private?: boolean;
4143 fork?: boolean;
@@ -422,7 +424,9 @@ function route(
422424 if (!repoMatch) return new Response("no route", { status: 404 });
423425 const fullName = `${repoMatch[1]}/${repoMatch[2]}`;
424426 const tail = repoMatch[3] ?? "";
425 const repo = state.repos.find((r) => r.fullName === fullName);
427 const repo = state.repos.find(
428 (r) => r.fullName === fullName || r.formerNames?.includes(fullName),
429 );
426430 if (!repo) return jsonResponse({ message: "Not Found" }, 404);
427431
428432 // A fork the origin has accepted but not finished making is a 404 for as
test/mirror-reattach-refusals.test.ts added +245
@@ -0,0 +1,245 @@
1 import {
2 afterAll,
3 beforeAll,
4 describe,
5 expect,
6 setDefaultTimeout,
7 test,
8 } from "bun:test";
9 import { mkdtemp, readFile, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { reattachMirror } from "../src/actions/origins.ts";
14 import { createOrigin, getOriginById } from "../src/core/origins.ts";
15 import { getRepo, type Repo } from "../src/core/repos.ts";
16 import { getUserByName } from "../src/core/users.ts";
17 import { resolveOid } from "../src/git/merge.ts";
18 import { repoDir } from "../src/git/repos.ts";
19 import { syncOrigin, syncsSettled } from "../src/origins/sync.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import {
22 mirrorAndWait,
23 newFakeState,
24 serveFakeForge,
25 type FakeForge,
26 } from "./fake-origin.ts";
27 import { proveAll } from "./helpers/verified.ts";
28 import { seedAdmin } from "./helpers/admin.ts";
29
30 /**
31 * Reattaching a frozen mirror (#1090) has two refusals that keep it from
32 * syncing one repository's history into another: a different remote id
33 * (`not_the_same_repo`) and a remote another mirror already holds
34 * (`already_mirrored`). Both leave the mirror exactly as it was; a rename,
35 * which keeps the remote id, is no reason to refuse.
36 */
37
38 setDefaultTimeout(60_000);
39
40 const LOGIN = "octo";
41 const TOKEN = "octo-secret-7089";
42 const PASSWORD = "alice-secret-7089";
43
44 const basic = (user: string, secret: string): string =>
45 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
46
47 describe("reattaching a frozen mirror", () => {
48 let dataDir: string;
49 let gitDataDir: string;
50 let app: App;
51 let servers: Servers;
52 let gitApp: App;
53 let gitServers: Servers;
54 let fake: FakeForge;
55 let originId = "";
56
57 const alice = () => getUserByName(app.db, "alice")!;
58 const widgets = (): Repo => getRepo(app.db, "alice", "widgets")!;
59 const api = (path: string, init?: RequestInit) =>
60 fetch(`${servers.url}${path}`, {
61 ...init,
62 headers: {
63 "content-type": "application/json",
64 authorization: basic("alice", PASSWORD),
65 },
66 });
67
68 const mirrorMain = () =>
69 resolveOid(repoDir(app.reposRoot, "alice", "widgets"), "refs/heads/main");
70 const upstreamMain = () =>
71 resolveOid(repoDir(gitApp.reposRoot, LOGIN, "widgets"), "refs/heads/main");
72
73 /** An origin row made without a connect, so nothing is re-attached by it. */
74 const originRow = (): string => {
75 const made = createOrigin(app.db, app.config.dataDir, {
76 userId: alice().id,
77 kind: "forgejo",
78 apiUrl: fake.apiUrl,
79 webUrl: fake.url,
80 login: LOGIN,
81 token: TOKEN,
82 });
83 if (!made.ok) throw new Error(made.error);
84 return made.value.id;
85 };
86
87 const disconnect = async (id: string): Promise<void> => {
88 const gone = await api(`/api/v1/user/origins/${id}`, { method: "DELETE" });
89 expect(gone.status).toBe(200);
90 };
91
92 /** What a refusal must not touch: the git remote and the mirror's columns. */
93 async function snapshot(): Promise<{ config: string; row: unknown }> {
94 const repo = widgets();
95 return {
96 config: await readFile(
97 join(repoDir(app.reposRoot, "alice", "widgets"), "config"),
98 "utf8",
99 ),
100 row: app.db
101 .query(
102 `SELECT origin_id, origin_full_name, origin_remote_id, origin_html_url
103 FROM repo WHERE id = ?1`,
104 )
105 .get(repo.id),
106 };
107 }
108
109 beforeAll(async () => {
110 dataDir = await mkdtemp(join(tmpdir(), "forge-reattach-"));
111 gitDataDir = await mkdtemp(join(tmpdir(), "forge-reattach-git-"));
112
113 // A second forge is the git host the mirror clones from.
114 gitApp = createApp({ dataDir: gitDataDir, port: 0 });
115 gitServers = await serve(gitApp);
116 await seedAdmin(gitApp, {
117 username: LOGIN,
118 email: "[email protected]",
119 password: TOKEN,
120 });
121 proveAll(gitApp);
122 const auth = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
123 await fetch(`${gitServers.url}/api/v1/repos`, {
124 method: "POST",
125 headers: auth,
126 body: JSON.stringify({ name: "widgets", visibility: "public" }),
127 });
128 await commitUpstream("README.md");
129
130 const state = newFakeState("forgejo", LOGIN, TOKEN);
131 state.repos.push({
132 id: 41,
133 fullName: "octo/widgets",
134 defaultBranch: "main",
135 cloneUrl: `${gitServers.url}/octo/widgets.git`,
136 });
137 fake = serveFakeForge(state);
138 state.repos[0]!.htmlUrl = `${fake.url}/octo/widgets`;
139
140 app = createApp({ dataDir, port: 0 });
141 servers = await serve(app);
142 await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
143 proveAll(app);
144
145 originId = originRow();
146 const job = await mirrorAndWait(
147 await api(`/api/v1/user/origins/${originId}/mirror`, {
148 method: "POST",
149 body: JSON.stringify({ fullName: "octo/widgets" }),
150 }),
151 );
152 expect(job?.stage).toBe("done");
153 await disconnect(originId);
154 }, 120_000);
155
156 afterAll(async () => {
157 await syncsSettled();
158 fake.stop();
159 servers.stop();
160 gitServers.stop();
161 app.close();
162 gitApp.close();
163 await rm(dataDir, { recursive: true, force: true, maxRetries: 5 });
164 await rm(gitDataDir, { recursive: true, force: true, maxRetries: 5 });
165 }, 120_000);
166
167 async function commitUpstream(path: string): Promise<string> {
168 const made = await fetch(
169 `${gitServers.url}/api/v1/repos/octo/widgets/contents`,
170 {
171 method: "POST",
172 headers: { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) },
173 body: JSON.stringify({
174 branch: "main",
175 message: `upstream: ${path}`,
176 files: [{ path, content: `${path}\n` }],
177 }),
178 },
179 );
180 expect(made.status).toBeLessThan(300);
181 return (await upstreamMain())!;
182 }
183
184 test("a mirror is frozen once its origin is disconnected", () => {
185 const row = app.db
186 .query<{ origin_id: string | null; origin_remote_id: string }, [string]>(
187 "SELECT origin_id, origin_remote_id FROM repo WHERE id = ?1",
188 )
189 .get(widgets().id)!;
190 expect(row.origin_id).toBeNull();
191 expect(row.origin_remote_id).toBe("41");
192 });
193
194 test("a repository renamed at the origin is reattached by its remote id, and syncs", async () => {
195 const remote = fake.state.repos[0]!;
196 remote.formerNames = ["octo/widgets"];
197 remote.fullName = "octo/gears";
198
199 const fresh = originRow();
200 const done = await reattachMirror(app, alice(), widgets(), fresh);
201 expect(done.ok).toBe(true);
202 if (!done.ok) return;
203 expect(done.value.fullName).toBe("octo/gears");
204 await syncsSettled();
205
206 const tip = await commitUpstream("moved.txt");
207 expect(await mirrorMain()).not.toBe(tip);
208 await syncOrigin(app, getOriginById(app.db, fresh)!);
209 expect(await mirrorMain()).toBe(tip);
210 await disconnect(fresh);
211 });
212
213 test("another repository under the same name is not_the_same_repo, and nothing changes", async () => {
214 // Deleted at the origin and made again: a new id under the old name.
215 fake.state.repos[0]!.id = 99;
216 const before = await snapshot();
217
218 const other = originRow();
219 const refused = await reattachMirror(app, alice(), widgets(), other);
220 expect(refused).toEqual({ ok: false, error: "not_the_same_repo" });
221 expect(await snapshot()).toEqual(before);
222 expect(before.row).toMatchObject({ origin_id: null, origin_remote_id: "41" });
223 fake.state.repos[0]!.id = 41;
224 await disconnect(other);
225 });
226
227 test("a remote another mirror already holds is already_mirrored, and nothing changes", async () => {
228 // The same repository mirrored again, under another local name, through
229 // a fresh origin: it holds remote 41 while the frozen one waits.
230 const holder = originRow();
231 const job = await mirrorAndWait(
232 await api(`/api/v1/user/origins/${holder}/mirror`, {
233 method: "POST",
234 body: JSON.stringify({ fullName: "octo/gears", name: "gears" }),
235 }),
236 );
237 expect(job?.stage).toBe("done");
238 const before = await snapshot();
239
240 const refused = await reattachMirror(app, alice(), widgets(), holder);
241 expect(refused).toEqual({ ok: false, error: "already_mirrored" });
242 expect(await snapshot()).toEqual(before);
243 expect(before.row).toMatchObject({ origin_id: null });
244 });
245 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs