Public: anyone can read this; the crew can change it.
Fast test/migrate.test.ts: chop and rewrite under the budget (1 s a test, 10 s a file) #1283
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1250-fast-test-migrate-test-ts-chop-and-rewri → main 2 commits fork
2 files
changed +177
−186
scripts/test-times.json +1 −1
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | "test/helpers.test.ts": 0.7, |
| 25 | 25 | "test/landing.test.ts": 9.4, |
| 26 | 26 | "test/ledger.test.ts": 12.8, |
| 27 | "test/migrate.test.ts": 41.9, | |
| 27 | "test/migrate.test.ts": 5.9, | |
| 28 | 28 | "test/offline.test.ts": 3.5, |
| 29 | 29 | "test/pulls.test.ts": 14.6, |
| 30 | 30 | "test/run-secrets.test.ts": 2.8, |
test/migrate.test.ts +176 −185
| @@ -17,7 +17,11 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 17 | 17 | import { tmpdir } from "node:os"; |
| 18 | 18 | import { join } from "node:path"; |
| 19 | 19 | import { createApp, type App } from "../src/app.ts"; |
| 20 | import { migrateRepo, migrateUrl } from "../src/actions/migrate.ts"; | |
| 20 | import { | |
| 21 | isPrivateHost, | |
| 22 | migrateRepo, | |
| 23 | migrateUrl, | |
| 24 | } from "../src/actions/migrate.ts"; | |
| 21 | 25 | import { createAgent, listCrew } from "../src/actions/crew.ts"; |
| 22 | 26 | import { |
| 23 | 27 | commitToBranch, |
| @@ -28,25 +32,19 @@ import { getAppByRepo } from "../src/core/apps.ts"; | ||
| 28 | 32 | import { listIssues } from "../src/core/issues.ts"; |
| 29 | 33 | import { listOrigins } from "../src/core/origins.ts"; |
| 30 | 34 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 31 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 35 | import { createUser, type User } from "../src/core/users.ts"; | |
| 32 | 36 | import { repoDir } from "../src/git/repos.ts"; |
| 33 | 37 | import { serve, type Servers } from "../src/http/server.ts"; |
| 38 | import { createToken } from "../src/core/tokens.ts"; | |
| 39 | import { createSession } from "../src/core/sessions.ts"; | |
| 34 | 40 | import { settled } from "../src/origins/jobs.ts"; |
| 35 | 41 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 36 | 42 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 37 | 43 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 38 | 44 | import { quietLogs } from "./helpers/globals.ts"; |
| 39 | import { proveAll } from "./helpers/verified.ts"; | |
| 40 | 45 | |
| 41 | 46 | quietLogs(); |
| 42 | 47 | |
| 43 | const PASSWORD = "a-long-migrate-passphrase"; | |
| 44 | const UPSTREAM_LOGIN = "octo"; | |
| 45 | const UPSTREAM_SECRET = "octo-upstream-secret-7c1d"; | |
| 46 | ||
| 47 | const basic = (user: string, secret: string): string => | |
| 48 | `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`; | |
| 49 | ||
| 50 | 48 | async function git(args: string[], cwd: string) { |
| 51 | 49 | const proc = Bun.spawn(["git", ...args], { |
| 52 | 50 | cwd, |
| @@ -86,34 +84,39 @@ async function allRefs(dir: string): Promise<string> { | ||
| 86 | 84 | |
| 87 | 85 | describe("migrate", () => { |
| 88 | 86 | let dataDir: string; |
| 89 | let upstreamDir: string; | |
| 90 | 87 | let work: string; |
| 91 | 88 | let app: App; |
| 92 | 89 | let servers: Servers; |
| 93 | let upstream: App; | |
| 94 | let upstreamServers: Servers; | |
| 95 | 90 | let forgejo: FakeForge; |
| 96 | 91 | let base: string; |
| 97 | 92 | let alice: User; |
| 98 | 93 | let bob: User; |
| 99 | 94 | let aliceCookie = ""; |
| 100 | 95 | let bobCookie = ""; |
| 101 | let fixture: Repo; | |
| 96 | let bobToken = ""; | |
| 102 | 97 | let fixtureUrl = ""; |
| 98 | let originId = ""; | |
| 99 | // octo's credential at the "upstream": a token, since a password would | |
| 100 | // cost an argon2 verify on every git request. | |
| 101 | let upstreamSecret = ""; | |
| 103 | 102 | |
| 104 | const signUp = async (username: string): Promise<string> => { | |
| 105 | const response = await fetch(`${base}/signup`, { | |
| 106 | method: "POST", | |
| 107 | body: new URLSearchParams({ | |
| 108 | username, | |
| 109 | email: `${username}@forge.test`, | |
| 110 | password: PASSWORD, | |
| 111 | }), | |
| 112 | redirect: "manual", | |
| 103 | const person = async ( | |
| 104 | username: string, | |
| 105 | adminIfFirst = false, | |
| 106 | ): Promise<User> => { | |
| 107 | const made = await createUser(app.db, { | |
| 108 | trusted: true, | |
| 109 | username, | |
| 110 | email: `${username}@forge.test`, | |
| 111 | adminIfFirst, | |
| 113 | 112 | }); |
| 114 | return (response.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 113 | if (!made.ok) throw new Error(String(made.error)); | |
| 114 | return made.value; | |
| 115 | 115 | }; |
| 116 | 116 | |
| 117 | const cookieOf = (user: User): string => | |
| 118 | `forge_session=${createSession(app.db, user.id).token}`; | |
| 119 | ||
| 117 | 120 | const browser = ( |
| 118 | 121 | cookie: string, |
| 119 | 122 | path: string, |
| @@ -132,34 +135,57 @@ describe("migrate", () => { | ||
| 132 | 135 | ...(form === undefined ? {} : { body: new URLSearchParams(form) }), |
| 133 | 136 | }); |
| 134 | 137 | |
| 135 | const api = ( | |
| 136 | user: string, | |
| 137 | path: string, | |
| 138 | payload: unknown, | |
| 139 | ): Promise<Response> => | |
| 140 | fetch(`${base}${path}`, { | |
| 138 | /** The same call with a bearer token: no cookie, no origin header. */ | |
| 139 | const apiToken = (token: string, payload: unknown): Promise<Response> => | |
| 140 | fetch(`${base}/api/v1/migrate`, { | |
| 141 | 141 | method: "POST", |
| 142 | 142 | headers: { |
| 143 | 143 | "content-type": "application/json", |
| 144 | authorization: basic(user, PASSWORD), | |
| 144 | authorization: `Bearer ${token}`, | |
| 145 | 145 | }, |
| 146 | 146 | body: JSON.stringify(payload), |
| 147 | 147 | }); |
| 148 | 148 | |
| 149 | const api = (cookie: string, payload: unknown): Promise<Response> => | |
| 150 | fetch(`${base}/api/v1/migrate`, { | |
| 151 | method: "POST", | |
| 152 | headers: { | |
| 153 | "content-type": "application/json", | |
| 154 | cookie, | |
| 155 | origin: new URL(base).origin, | |
| 156 | }, | |
| 157 | body: JSON.stringify(payload), | |
| 158 | }); | |
| 159 | ||
| 160 | /** Start a migration and wait for it; the job is returned as it ended. */ | |
| 161 | const migrate = async ( | |
| 162 | user: User, | |
| 163 | input: Parameters<typeof migrateRepo>[2], | |
| 164 | ) => { | |
| 165 | const started = await migrateRepo(app, user, input); | |
| 166 | if (!started.ok) throw new Error(started.error); | |
| 167 | return settled(started.value.job.id); | |
| 168 | }; | |
| 169 | ||
| 149 | 170 | beforeAll(async () => { |
| 150 | 171 | dataDir = await mkdtemp(join(tmpdir(), "forge-migrate-")); |
| 151 | upstreamDir = await mkdtemp(join(tmpdir(), "forge-migrate-up-")); | |
| 152 | 172 | work = await mkdtemp(join(tmpdir(), "forge-migrate-work-")); |
| 153 | 173 | // The fixture's source is the forge itself, on loopback: a private host, |
| 154 | // which a personal forge may say is fine. | |
| 174 | // which a personal forge may say is fine. Its hook socket is what a | |
| 175 | // real push needs; no argon2 per request (sessions and tokens, not | |
| 176 | // passwords). | |
| 155 | 177 | app = createApp({ dataDir, port: 0, migrateFromPrivateHosts: true }); |
| 156 | 178 | servers = await serve(app); |
| 157 | base = servers.url; | |
| 158 | aliceCookie = await signUp("alice"); | |
| 159 | bobCookie = await signUp("bob"); | |
| 160 | proveAll(app); | |
| 161 | alice = getUserByName(app.db, "alice")!; | |
| 162 | bob = getUserByName(app.db, "bob")!; | |
| 179 | base = `http://127.0.0.1:${servers.port}`; | |
| 180 | // No passwords: argon2 is the slow part of an account, and every | |
| 181 | // request here rides a session or a token. | |
| 182 | alice = await person("alice", true); | |
| 183 | bob = await person("bob"); | |
| 184 | const octo = await person("octo"); | |
| 185 | upstreamSecret = createToken(app.db, octo.id, "upstream", "rw").token; | |
| 186 | aliceCookie = cookieOf(alice); | |
| 187 | bobCookie = cookieOf(bob); | |
| 188 | bobToken = createToken(app.db, bob.id, "cli", "rw").token; | |
| 163 | 189 | |
| 164 | 190 | // The fixture: alice's public repository with three branches, two tags |
| 165 | 191 | // (one annotated), and `trunk` as its default — not the forge's `main`. |
| @@ -168,26 +194,30 @@ describe("migrate", () => { | ||
| 168 | 194 | visibility: "public", |
| 169 | 195 | }); |
| 170 | 196 | if (!made.ok) throw new Error(made.error); |
| 171 | fixture = made.value; | |
| 172 | const commit = async (branch: string, path: string, message: string) => { | |
| 173 | const done = await commitToBranch(app, alice, fixture, { | |
| 197 | const fixture = made.value; | |
| 198 | const commit = async ( | |
| 199 | user: User, | |
| 200 | repo: Repo, | |
| 201 | branch: string, | |
| 202 | path: string, | |
| 203 | message: string, | |
| 204 | ) => { | |
| 205 | const done = await commitToBranch(app, user, repo, { | |
| 174 | 206 | branch, |
| 175 | 207 | message, |
| 176 | 208 | files: [{ path, content: `${message}\n` }], |
| 177 | 209 | }); |
| 178 | 210 | if (!done.ok) throw new Error(String(done.error)); |
| 179 | return done.value; | |
| 180 | 211 | }; |
| 181 | await commit("main", "README.md", "first"); | |
| 182 | await commit("main", "a.txt", "second"); | |
| 183 | await commit("trunk", "trunk.txt", "on trunk"); | |
| 184 | await commit("feature", "feature.txt", "a feature"); | |
| 185 | await commit("feature", "feature2.txt", "more feature"); | |
| 212 | await commit(alice, fixture, "main", "README.md", "first"); | |
| 213 | await commit(alice, fixture, "main", "a.txt", "second"); | |
| 214 | await commit(alice, fixture, "trunk", "trunk.txt", "on trunk"); | |
| 215 | await commit(alice, fixture, "feature", "feature.txt", "a feature"); | |
| 216 | await commit(alice, fixture, "feature", "feature2.txt", "more feature"); | |
| 186 | 217 | const settings = await updateRepoSettings(app, alice, fixture, { |
| 187 | 218 | defaultBranch: "trunk", |
| 188 | 219 | }); |
| 189 | 220 | if (!settings.ok) throw new Error(settings.error); |
| 190 | fixture = settings.value; | |
| 191 | 221 | const dir = repoDir(app.reposRoot, "alice", "fixture"); |
| 192 | 222 | expect((await git(["tag", "v1", "main~1"], dir)).code).toBe(0); |
| 193 | 223 | expect( |
| @@ -195,59 +225,48 @@ describe("migrate", () => { | ||
| 195 | 225 | ).toBe(0); |
| 196 | 226 | fixtureUrl = `${base}/alice/fixture.git`; |
| 197 | 227 | |
| 198 | // A second forge holding a *private* repository, behind a fake origin | |
| 199 | // whose clone URL points at it: only the origin's credential opens it. | |
| 200 | upstream = createApp({ dataDir: upstreamDir, port: 0 }); | |
| 201 | upstreamServers = await serve(upstream); | |
| 202 | await fetch(`${upstreamServers.url}/api/v1/users`, { | |
| 203 | method: "POST", | |
| 204 | headers: { "content-type": "application/json" }, | |
| 205 | body: JSON.stringify({ | |
| 206 | username: UPSTREAM_LOGIN, | |
| 207 | email: "[email protected]", | |
| 208 | password: UPSTREAM_SECRET, | |
| 209 | }), | |
| 228 | // A repository alice keeps private: no anonymous clone reaches it. | |
| 229 | const hidden = await createRepo(app, alice, { | |
| 230 | name: "hidden", | |
| 231 | visibility: "private", | |
| 210 | 232 | }); |
| 211 | const auth = { authorization: basic(UPSTREAM_LOGIN, UPSTREAM_SECRET) }; | |
| 212 | await fetch(`${upstreamServers.url}/api/v1/repos`, { | |
| 213 | method: "POST", | |
| 214 | headers: { "content-type": "application/json", ...auth }, | |
| 215 | body: JSON.stringify({ name: "secret-widgets", visibility: "private" }), | |
| 233 | if (!hidden.ok) throw new Error(hidden.error); | |
| 234 | await commit(alice, hidden.value, "main", "README.md", "secret"); | |
| 235 | ||
| 236 | // A private repository behind a fake origin whose clone URL points at | |
| 237 | // it: only the origin's credential (octo's) opens it. | |
| 238 | const widgets = await createRepo(app, octo, { | |
| 239 | name: "secret-widgets", | |
| 240 | visibility: "private", | |
| 216 | 241 | }); |
| 217 | await fetch( | |
| 218 | `${upstreamServers.url}/api/v1/repos/octo/secret-widgets/contents`, | |
| 219 | { | |
| 220 | method: "POST", | |
| 221 | headers: { "content-type": "application/json", ...auth }, | |
| 222 | body: JSON.stringify({ | |
| 223 | branch: "main", | |
| 224 | message: "first", | |
| 225 | files: [{ path: "README.md", content: "# widgets\n" }], | |
| 226 | }), | |
| 227 | }, | |
| 228 | ); | |
| 229 | const state = newFakeState("forgejo", UPSTREAM_LOGIN, UPSTREAM_SECRET); | |
| 242 | if (!widgets.ok) throw new Error(widgets.error); | |
| 243 | await commit(octo, widgets.value, "main", "README.md", "first"); | |
| 244 | const state = newFakeState("forgejo", "octo", upstreamSecret); | |
| 230 | 245 | state.repos.push({ |
| 231 | 246 | id: 77, |
| 232 | 247 | fullName: "octo/secret-widgets", |
| 233 | 248 | description: "gears, privately", |
| 234 | cloneUrl: `${upstreamServers.url}/octo/secret-widgets.git`, | |
| 235 | htmlUrl: `${upstreamServers.url}/octo/secret-widgets`, | |
| 249 | cloneUrl: `${base}/octo/secret-widgets.git`, | |
| 250 | htmlUrl: `${base}/octo/secret-widgets`, | |
| 236 | 251 | defaultBranch: "main", |
| 237 | 252 | private: true, |
| 238 | 253 | }); |
| 239 | 254 | forgejo = serveFakeForge(state); |
| 240 | 255 | forgetProviders(dataDir); |
| 241 | }); | |
| 256 | const connected = await browser(aliceCookie, "/settings/origins/token", { | |
| 257 | host: forgejo.url, | |
| 258 | token: upstreamSecret, | |
| 259 | }); | |
| 260 | expect(connected.status).toBe(303); | |
| 261 | originId = listOrigins(app.db, alice.id)[0]!.id; | |
| 262 | }, 30_000); | |
| 242 | 263 | |
| 243 | 264 | afterAll(async () => { |
| 244 | 265 | forgejo.stop(); |
| 245 | 266 | servers.stop(); |
| 246 | upstreamServers.stop(); | |
| 247 | 267 | app.close(); |
| 248 | upstream.close(); | |
| 249 | 268 | forgetProviders(dataDir); |
| 250 | for (const dir of [dataDir, upstreamDir, work]) | |
| 269 | for (const dir of [dataDir, work]) | |
| 251 | 270 | await rm(dir, { recursive: true, force: true }); |
| 252 | 271 | }); |
| 253 | 272 | |
| @@ -255,13 +274,12 @@ describe("migrate", () => { | ||
| 255 | 274 | const sourceDir = repoDir(app.reposRoot, "alice", "fixture"); |
| 256 | 275 | const sourceRefs = await allRefs(sourceDir); |
| 257 | 276 | |
| 258 | const response = await api("bob", "/api/v1/migrate", { | |
| 259 | source: { url: fixtureUrl }, | |
| 260 | }); | |
| 277 | // By token, so the non-cookie door to the endpoint stays covered. | |
| 278 | const response = await apiToken(bobToken, { source: { url: fixtureUrl } }); | |
| 261 | 279 | expect(response.status).toBe(202); |
| 262 | 280 | const started = (await response.json()) as { |
| 263 | repo: { full_name?: string; name: string }; | |
| 264 | job: { id: string; stage: string }; | |
| 281 | repo: { name: string }; | |
| 282 | job: { id: string }; | |
| 265 | 283 | }; |
| 266 | 284 | expect(started.repo.name).toBe("fixture"); |
| 267 | 285 | const job = await settled(started.job.id); |
| @@ -282,7 +300,8 @@ describe("migrate", () => { | ||
| 282 | 300 | expect((await git(["symbolic-ref", "HEAD"], dir)).out).toBe( |
| 283 | 301 | "refs/heads/trunk", |
| 284 | 302 | ); |
| 285 | // Native: no origin row, no remote in its config — nothing to write through to. | |
| 303 | // Native: no origin row, no remote in its config — nothing a push could | |
| 304 | // write through to, so pushes land here and the source never hears. | |
| 286 | 305 | expect(migrated.origin).toBeNull(); |
| 287 | 306 | expect((await git(["config", "--get-regexp", "^remote\\."], dir)).out).toBe( |
| 288 | 307 | "", |
| @@ -292,7 +311,7 @@ describe("migrate", () => { | ||
| 292 | 311 | const clone = join(work, "bob-fixture"); |
| 293 | 312 | const url = new URL(`${base}/bob/fixture.git`); |
| 294 | 313 | url.username = "bob"; |
| 295 | url.password = PASSWORD; | |
| 314 | url.password = bobToken; | |
| 296 | 315 | expect( |
| 297 | 316 | (await git(["clone", "--quiet", url.toString(), clone], work)).code, |
| 298 | 317 | ).toBe(0); |
| @@ -309,9 +328,9 @@ describe("migrate", () => { | ||
| 309 | 328 | (await git(["rev-parse", "--verify", "refs/heads/bobs"], dir)).code, |
| 310 | 329 | ).toBe(0); |
| 311 | 330 | expect(await allRefs(sourceDir)).toBe(sourceRefs); |
| 312 | }, 60_000); | |
| 331 | }); | |
| 313 | 332 | |
| 314 | test("refusals are words, and nothing is made", async () => { | |
| 333 | test("a URL git must not run is refused in words before anything exists", async () => { | |
| 315 | 334 | const count = () => |
| 316 | 335 | (app.db.query("SELECT COUNT(*) AS n FROM repo").get() as { n: number }).n; |
| 317 | 336 | const before = count(); |
| @@ -326,52 +345,56 @@ describe("migrate", () => { | ||
| 326 | 345 | `--upload-pack=touch ${canary}`, |
| 327 | 346 | "https://user:[email protected]/a/b.git", |
| 328 | 347 | "", |
| 329 | ]) { | |
| 330 | const response = await api("bob", "/api/v1/migrate", { source: { url } }); | |
| 331 | expect(response.status).toBe(422); | |
| 332 | const body = (await response.json()) as { | |
| 333 | error: string; | |
| 334 | message: string; | |
| 335 | }; | |
| 336 | expect(body.error).toBe("invalid_url"); | |
| 337 | expect(body.message).toContain("not a URL this forge will clone from"); | |
| 338 | } | |
| 348 | ]) | |
| 349 | expect(await migrateRepo(app, bob, { source: { url } })).toEqual({ | |
| 350 | ok: false, | |
| 351 | error: "invalid_url", | |
| 352 | }); | |
| 339 | 353 | expect(existsSync(canary)).toBe(false); |
| 354 | // On the wire it is a 422 with words. | |
| 355 | const response = await api(bobCookie, { source: { url: "file:///etc" } }); | |
| 356 | expect(response.status).toBe(422); | |
| 357 | const body = (await response.json()) as { error: string; message: string }; | |
| 358 | expect(body.error).toBe("invalid_url"); | |
| 359 | expect(body.message).toContain("not a URL this forge will clone from"); | |
| 340 | 360 | // What a person meant is what they get. |
| 341 | 361 | expect(migrateUrl("github.com/a/b")).toBe("https://github.com/a/b"); |
| 342 | 362 | expect(migrateUrl("git://example.com/a/b.git")).toBe( |
| 343 | 363 | "git://example.com/a/b.git", |
| 344 | 364 | ); |
| 365 | expect(count()).toBe(before); | |
| 366 | }); | |
| 345 | 367 | |
| 346 | // bob already has a `fixture`. | |
| 347 | const taken = await api("bob", "/api/v1/migrate", { | |
| 348 | source: { url: fixtureUrl }, | |
| 349 | }); | |
| 350 | expect(taken.status).toBe(422); | |
| 351 | expect(((await taken.json()) as { error: string }).error).toBe( | |
| 368 | test("a name, an agent or a form that cannot work is refused before anything exists", async () => { | |
| 369 | const made = await createRepo(app, bob, { name: "taken" }); | |
| 370 | expect(made.ok).toBe(true); | |
| 371 | const count = () => | |
| 372 | (app.db.query("SELECT COUNT(*) AS n FROM repo").get() as { n: number }).n; | |
| 373 | const before = count(); | |
| 374 | const refused = (input: Parameters<typeof migrateRepo>[2]) => | |
| 375 | migrateRepo(app, bob, input).then((r) => (r.ok ? "ok" : r.error)); | |
| 376 | expect(await refused({ source: { url: fixtureUrl }, name: "taken" })).toBe( | |
| 352 | 377 | "name_taken", |
| 353 | 378 | ); |
| 354 | const invalid = await api("bob", "/api/v1/migrate", { | |
| 355 | source: { url: fixtureUrl }, | |
| 356 | name: "no spaces allowed", | |
| 357 | }); | |
| 358 | expect(((await invalid.json()) as { error: string }).error).toBe( | |
| 359 | "invalid_name", | |
| 360 | ); | |
| 361 | const agent = await api("bob", "/api/v1/migrate", { | |
| 362 | source: { url: fixtureUrl }, | |
| 363 | name: "other", | |
| 364 | crew: { agent: "nobody-here", autonomous: false }, | |
| 365 | }); | |
| 366 | expect(((await agent.json()) as { error: string }).error).toBe( | |
| 367 | "no_such_agent", | |
| 368 | ); | |
| 379 | expect( | |
| 380 | await refused({ | |
| 381 | source: { url: fixtureUrl }, | |
| 382 | name: "no spaces allowed", | |
| 383 | }), | |
| 384 | ).toBe("invalid_name"); | |
| 385 | expect( | |
| 386 | await refused({ | |
| 387 | source: { url: fixtureUrl }, | |
| 388 | name: "other", | |
| 389 | crew: { agent: "nobody-here", autonomous: false }, | |
| 390 | }), | |
| 391 | ).toBe("no_such_agent"); | |
| 369 | 392 | |
| 370 | 393 | // The same refusal on the web: the form again, with the reason in it. |
| 371 | 394 | const page = await browser(bobCookie, "/migrate", { |
| 372 | 395 | url: fixtureUrl, |
| 373 | 396 | owner: "bob", |
| 374 | name: "", | |
| 397 | name: "taken", | |
| 375 | 398 | }); |
| 376 | 399 | expect(page.status).toBe(422); |
| 377 | 400 | expect(await page.text()).toContain( |
| @@ -386,25 +409,16 @@ describe("migrate", () => { | ||
| 386 | 409 | }); |
| 387 | 410 | |
| 388 | 411 | test("a clone that fails leaves nothing behind", async () => { |
| 389 | // A private repository is not there for an anonymous clone. | |
| 390 | const made = await createRepo(app, alice, { | |
| 391 | name: "hidden", | |
| 392 | visibility: "private", | |
| 393 | }); | |
| 394 | expect(made.ok).toBe(true); | |
| 395 | const started = await migrateRepo(app, bob, { | |
| 412 | const job = await migrate(bob, { | |
| 396 | 413 | source: { url: `${base}/alice/hidden.git` }, |
| 397 | 414 | }); |
| 398 | expect(started.ok).toBe(true); | |
| 399 | if (!started.ok) return; | |
| 400 | const job = await settled(started.value.job.id); | |
| 401 | 415 | expect(job?.stage).toBe("failed"); |
| 402 | 416 | expect(job?.error).toContain("could not be read"); |
| 403 | 417 | expect(getRepo(app.db, "bob", "hidden")).toBeUndefined(); |
| 404 | 418 | expect(existsSync(repoDir(app.reposRoot, "bob", "hidden"))).toBe(false); |
| 405 | 419 | }); |
| 406 | 420 | |
| 407 | test("with a crew: on the crew, an app, the importer's brief, autonomy when asked", async () => { | |
| 421 | test("with a crew: on the crew, an app, the importer's brief, autonomy only when asked", async () => { | |
| 408 | 422 | const agent = await createAgent(app, alice, { |
| 409 | 423 | username: "porter", |
| 410 | 424 | model: "", |
| @@ -416,14 +430,11 @@ describe("migrate", () => { | ||
| 416 | 430 | ["crewed", true], |
| 417 | 431 | ["assisted", false], |
| 418 | 432 | ] as const) { |
| 419 | const started = await migrateRepo(app, bob, { | |
| 433 | const job = await migrate(bob, { | |
| 420 | 434 | source: { url: fixtureUrl }, |
| 421 | 435 | name, |
| 422 | 436 | crew: { agent: "porter", autonomous }, |
| 423 | 437 | }); |
| 424 | expect(started.ok).toBe(true); | |
| 425 | if (!started.ok) return; | |
| 426 | const job = await settled(started.value.job.id); | |
| 427 | 438 | expect(job?.error).toBeUndefined(); |
| 428 | 439 | expect(job?.stage).toBe("done"); |
| 429 | 440 | expect(job?.detail).toContain("porter is on it"); |
| @@ -444,6 +455,8 @@ describe("migrate", () => { | ||
| 444 | 455 | const issue = issues[0]!; |
| 445 | 456 | expect(issue.title).toBe(`Make ${name} run here`); |
| 446 | 457 | expect(issue.assigneeName).toBe("porter"); |
| 458 | // The importer's brief is the same whether or not the crew is | |
| 459 | // autonomous; autonomy is the repository's setting, asserted above. | |
| 447 | 460 | for (const words of [ |
| 448 | 461 | "Adapt, don't rewrite", |
| 449 | 462 | "forge.json", |
| @@ -457,29 +470,19 @@ describe("migrate", () => { | ||
| 457 | 470 | ]) |
| 458 | 471 | expect(issue.body).toContain(words); |
| 459 | 472 | } |
| 460 | }, 60_000); | |
| 473 | }); | |
| 461 | 474 | |
| 462 | 475 | test("from an origin: the origin's credential opens a private repository", async () => { |
| 463 | const connected = await browser(aliceCookie, "/settings/origins/token", { | |
| 464 | host: forgejo.url, | |
| 465 | token: UPSTREAM_SECRET, | |
| 466 | }); | |
| 467 | expect(connected.status).toBe(303); | |
| 468 | const origin = listOrigins(app.db, alice.id)[0]!; | |
| 469 | ||
| 470 | 476 | // Anonymous, by URL: refused by the upstream, and nothing left here. |
| 471 | const anon = await migrateRepo(app, alice, { | |
| 472 | source: { url: `${upstreamServers.url}/octo/secret-widgets.git` }, | |
| 477 | const anon = await migrate(alice, { | |
| 478 | source: { url: `${base}/octo/secret-widgets.git` }, | |
| 473 | 479 | }); |
| 474 | expect(anon.ok).toBe(true); | |
| 475 | if (!anon.ok) return; | |
| 476 | expect((await settled(anon.value.job.id))?.stage).toBe("failed"); | |
| 480 | expect(anon?.stage).toBe("failed"); | |
| 477 | 481 | |
| 478 | 482 | // Picked from the origin: the header opens it. |
| 479 | const response = await api("alice", "/api/v1/migrate", { | |
| 480 | source: { originId: origin.id, fullName: "octo/secret-widgets" }, | |
| 483 | const response = await api(aliceCookie, { | |
| 484 | source: { originId, fullName: "octo/secret-widgets" }, | |
| 481 | 485 | }); |
| 482 | // A password is the account itself; it clears the admin floor. | |
| 483 | 486 | expect(response.status).toBe(202); |
| 484 | 487 | const started = (await response.json()) as { job: { id: string } }; |
| 485 | 488 | const job = await settled(started.job.id); |
| @@ -492,17 +495,17 @@ describe("migrate", () => { | ||
| 492 | 495 | expect((await git(["log", "--format=%s", "main"], dir)).out).toBe("first"); |
| 493 | 496 | // The credential rode in the environment and stayed there. |
| 494 | 497 | const config = await Bun.file(join(dir, "config")).text(); |
| 495 | expect(config).not.toContain(UPSTREAM_SECRET); | |
| 498 | expect(config).not.toContain(upstreamSecret); | |
| 496 | 499 | expect(config).not.toContain("extraHeader"); |
| 497 | 500 | |
| 498 | 501 | // Someone else's origin is not a source. |
| 499 | const bobs = await api("bob", "/api/v1/migrate", { | |
| 500 | source: { originId: origin.id, fullName: "octo/secret-widgets" }, | |
| 502 | const bobs = await api(bobCookie, { | |
| 503 | source: { originId, fullName: "octo/secret-widgets" }, | |
| 501 | 504 | }); |
| 502 | 505 | expect(((await bobs.json()) as { error: string }).error).toBe( |
| 503 | 506 | "no_such_origin", |
| 504 | 507 | ); |
| 505 | }, 60_000); | |
| 508 | }); | |
| 506 | 509 | |
| 507 | 510 | test("the page: the form, the plus menu, the job, and who may see it", async () => { |
| 508 | 511 | const anonymous = await browser("", "/migrate"); |
| @@ -559,10 +562,9 @@ describe("migrate", () => { | ||
| 559 | 562 | ).text(); |
| 560 | 563 | expect(repoPage).toContain("Migrated as alice/copy"); |
| 561 | 564 | expect(getRepo(app.db, "alice", "copy")?.autonomy).toBe("autonomous"); |
| 562 | }, 60_000); | |
| 565 | }); | |
| 563 | 566 | |
| 564 | 567 | test("a pasted address inside the network is an admin's alone, unless the forge says otherwise", async () => { |
| 565 | const { isPrivateHost } = await import("../src/actions/migrate.ts"); | |
| 566 | 568 | for (const h of [ |
| 567 | 569 | "127.0.0.1", |
| 568 | 570 | "localhost", |
| @@ -593,33 +595,25 @@ describe("migrate", () => { | ||
| 593 | 595 | "[2001:db8::1]", |
| 594 | 596 | ]) |
| 595 | 597 | expect(isPrivateHost(h)).toBe(false); |
| 596 | const strictDir = await mkdtemp(join(tmpdir(), "forge-migrate-strict-")); | |
| 597 | const strict = createApp({ dataDir: strictDir, port: 0 }); | |
| 598 | // The forge without the personal-forge switch: bob is refused, alice | |
| 599 | // (the admin) is not. The flag is flipped in place: this only proves | |
| 600 | // anything because migrateRepo reads it on every call, which the | |
| 601 | // refusal below (the app was built with it true) fails without. | |
| 602 | const config = app.config as { migrateFromPrivateHosts?: boolean }; | |
| 603 | config.migrateFromPrivateHosts = false; | |
| 598 | 604 | try { |
| 599 | const a = await createUser(strict.db, { trusted: true, | |
| 600 | username: "ann", | |
| 601 | email: "[email protected]", | |
| 602 | password: "a-good-passphrase", | |
| 603 | adminIfFirst: true, | |
| 604 | }); | |
| 605 | const b = await createUser(strict.db, { trusted: true, | |
| 606 | username: "ben", | |
| 607 | email: "[email protected]", | |
| 608 | password: "a-good-passphrase", | |
| 609 | }); | |
| 610 | if (!a.ok || !b.ok) throw new Error("users"); | |
| 611 | const refused = await migrateRepo(strict, b.value, { | |
| 605 | const refused = await migrateRepo(app, bob, { | |
| 612 | 606 | source: { url: `${base}/alice/hidden.git` }, |
| 613 | 607 | }); |
| 614 | 608 | expect(refused).toEqual({ ok: false, error: "private_host" }); |
| 615 | const allowed = await migrateRepo(strict, a.value, { | |
| 609 | const allowed = await migrateRepo(app, alice, { | |
| 616 | 610 | source: { url: `${base}/alice/hidden.git` }, |
| 617 | 611 | name: "peek", |
| 618 | 612 | }); |
| 619 | 613 | expect(allowed.ok).toBe(true); |
| 614 | if (allowed.ok) await settled(allowed.value.job.id); | |
| 620 | 615 | } finally { |
| 621 | strict.close(); | |
| 622 | await rm(strictDir, { recursive: true, force: true }); | |
| 616 | config.migrateFromPrivateHosts = true; | |
| 623 | 617 | } |
| 624 | 618 | }); |
| 625 | 619 | |
| @@ -646,13 +640,10 @@ describe("migrate", () => { | ||
| 646 | 640 | }, |
| 647 | 641 | }); |
| 648 | 642 | try { |
| 649 | const started = await migrateRepo(app, alice, { | |
| 643 | const job = await migrate(alice, { | |
| 650 | 644 | source: { url: `http://127.0.0.1:${hop.port}/hop.git` }, |
| 651 | 645 | name: "hopped", |
| 652 | 646 | }); |
| 653 | expect(started.ok).toBe(true); | |
| 654 | if (!started.ok) return; | |
| 655 | const job = await settled(started.value.job.id); | |
| 656 | 647 | expect(job?.stage).toBe("failed"); |
| 657 | 648 | expect(hits).toBe(0); |
| 658 | 649 | } finally { |
new above
offline