Public: anyone can read this; the crew can change it.
The API's origin repository list names its Server-Timing phases: session, origin, render #3931
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3923-the-api-s-origin-repository-list-names-i → main 4 commits fork
2 files
changed +94
−6
src/http/api.ts +11 −6
| @@ -4421,19 +4421,24 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4421 | 4421 | }); |
| 4422 | 4422 | |
| 4423 | 4423 | router.add("GET", "/api/v1/user/origins/:id/repos", async (ctx) => { |
| 4424 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4424 | 4425 | const denied = requireAccount(ctx, { adminToken: true }); |
| 4425 | 4426 | if (denied) return denied; |
| 4426 | const listed = await listRemoteRepos( | |
| 4427 | ctx.app, | |
| 4428 | ctx.auth.user, | |
| 4429 | ctx.params.id!, | |
| 4430 | ctx.url.searchParams.get("refresh") === "1", | |
| 4427 | const listed = await timePhaseAsync(ctx.request, "origin", () => | |
| 4428 | listRemoteRepos( | |
| 4429 | ctx.app, | |
| 4430 | ctx.auth.user, | |
| 4431 | ctx.params.id!, | |
| 4432 | ctx.url.searchParams.get("refresh") === "1", | |
| 4433 | ), | |
| 4431 | 4434 | ); |
| 4432 | 4435 | if (!listed.ok) |
| 4433 | 4436 | return listed.error === "unreachable" |
| 4434 | 4437 | ? apiError("origin_unreachable", 502, "the origin could not be reached") |
| 4435 | 4438 | : respondAccess(listed.error, 404); |
| 4436 | return json(listed.value.map((row) => remoteRepoJson(ctx, row))); | |
| 4439 | return timePhase(ctx.request, "render", () => | |
| 4440 | json(listed.value.map((row) => remoteRepoJson(ctx, row))), | |
| 4441 | ); | |
| 4437 | 4442 | }); |
| 4438 | 4443 | |
| 4439 | 4444 | // ── browsing an origin, live ───────────────────────────────────────── |
test/api-origin-repos-timing.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** The API's origin repository list names its Server-Timing phases (#3923). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 9 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | import { proveAll } from "./helpers/verified.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | const PASSWORD = "alice-login-secret"; | |
| 16 | const TOKEN = "octo-origin-secret-9f2a"; | |
| 17 | const auth = `Basic ${Buffer.from(`alice:${PASSWORD}`).toString("base64")}`; | |
| 18 | ||
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let fake: FakeForge; | |
| 23 | let originId = ""; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-origin-repos-timing-")); | |
| 27 | const state = newFakeState("forgejo", "octo", TOKEN); | |
| 28 | state.repos.push({ | |
| 29 | id: 41, | |
| 30 | fullName: "octo/widgets", | |
| 31 | cloneUrl: "http://127.0.0.1:1/octo/widgets.git", | |
| 32 | defaultBranch: "main", | |
| 33 | }); | |
| 34 | fake = serveFakeForge(state); | |
| 35 | app = createApp({ dataDir, port: 0 }); | |
| 36 | servers = await serve(app); | |
| 37 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD }); | |
| 38 | proveAll(app); | |
| 39 | const connected = await fetch(`${servers.url}/api/v1/user/origins`, { | |
| 40 | method: "POST", | |
| 41 | headers: { authorization: auth, "content-type": "application/json" }, | |
| 42 | body: JSON.stringify({ host: fake.url, token: TOKEN }), | |
| 43 | }); | |
| 44 | originId = String(((await connected.json()) as { id: string }).id); | |
| 45 | }); | |
| 46 | ||
| 47 | afterAll(async () => { | |
| 48 | fake.stop(); | |
| 49 | await servers.stop(); | |
| 50 | app.close(); | |
| 51 | await rm(dataDir, { recursive: true, force: true }); | |
| 52 | }); | |
| 53 | ||
| 54 | async function get(path: string): Promise<{ status: number; names: string[]; body: string }> { | |
| 55 | const res = await fetch(`${servers.url}${path}`, { headers: { authorization: auth } }); | |
| 56 | const body = await res.text(); | |
| 57 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); | |
| 58 | return { status: res.status, names: names.slice(names.indexOf("app") + 1), body }; | |
| 59 | } | |
| 60 | ||
| 61 | test("listing an origin's repositories names session, origin and render", async () => { | |
| 62 | const got = await get(`/api/v1/user/origins/${originId}/repos?refresh=1`); | |
| 63 | expect(got.status).toBe(200); | |
| 64 | expect(got.names).toEqual(["session", "origin", "render"]); | |
| 65 | expect(JSON.parse(got.body).map((r: { fullName: string }) => r.fullName)).toEqual([ | |
| 66 | "octo/widgets", | |
| 67 | ]); | |
| 68 | }); | |
| 69 | ||
| 70 | test("an unknown origin still answers 404 and names session and origin", async () => { | |
| 71 | const got = await get("/api/v1/user/origins/nope/repos"); | |
| 72 | expect(got.status).toBe(404); | |
| 73 | expect(got.names).toEqual(["session", "origin"]); | |
| 74 | }); | |
| 75 | ||
| 76 | test("an unreachable origin answers 502 naming session and origin", async () => { | |
| 77 | app.db.query("DELETE FROM origin_repo WHERE origin_id = ?1").run(originId); | |
| 78 | fake.stop(); | |
| 79 | const got = await get(`/api/v1/user/origins/${originId}/repos?refresh=1`); | |
| 80 | expect(got.status).toBe(502); | |
| 81 | expect(JSON.parse(got.body).error).toBe("origin_unreachable"); | |
| 82 | expect(got.names).toEqual(["session", "origin"]); | |
| 83 | }); |
new above
offline