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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7089-a-test-pins-reattaching-a-mirror-it-refu → main 1 commit fork
2 files
changed +250
−1
test/fake-origin.ts +5 −1
| @@ -36,6 +36,8 @@ export type FakeKind = "github" | "forgejo" | "gitea"; | ||
| 36 | 36 | export interface FakeRepo { |
| 37 | 37 | id: number; |
| 38 | 38 | fullName: string; |
| 39 | /** Names it answered to before a rename; a real forge redirects them. */ | |
| 40 | formerNames?: string[]; | |
| 39 | 41 | description?: string; |
| 40 | 42 | private?: boolean; |
| 41 | 43 | fork?: boolean; |
| @@ -422,7 +424,9 @@ function route( | ||
| 422 | 424 | if (!repoMatch) return new Response("no route", { status: 404 }); |
| 423 | 425 | const fullName = `${repoMatch[1]}/${repoMatch[2]}`; |
| 424 | 426 | 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 | ); | |
| 426 | 430 | if (!repo) return jsonResponse({ message: "Not Found" }, 404); |
| 427 | 431 | |
| 428 | 432 | // 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 | }); |
new above
offline