Public: anyone can read this; the crew can change it.
The OpenAPI description names every refusal of POST …/builds and the fields of a build pin, so an agent reads them before it is refused #9102
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9094-the-openapi-description-names-every-refu → main 1 commit fork
3 files
changed +125
−3
src/contract/openapi/routes/getApiV1ReposOwnerNameBuilds.ts +8 −1
| @@ -1,3 +1,10 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/builds`, summary: "List a repository's build pins (refs/builds/<label>), newest first; answers { items, next_cursor }", auth: false }; | |
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${REPO}/builds`, | |
| 6 | summary: "List a repository's build pins (refs/builds/<label>), newest first; each item is { label, oid, note, parent, source, pinnedBy, pinnedAt }; answers { items, next_cursor }", | |
| 7 | auth: false, | |
| 8 | reply: { items: "array", next_cursor: "null" }, | |
| 9 | refusals: { not_found: "404: no such repository, or it is not readable" }, | |
| 10 | }; |
src/contract/openapi/routes/postApiV1ReposOwnerNameBuilds.ts +12 −2
| @@ -3,9 +3,19 @@ import { REPO, type Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "post", |
| 5 | 5 | path: `${REPO}/builds`, |
| 6 | summary: "Pin a commit as a build (rw scope, a writer's) at refs/builds/<label>; answers 201 with the pin", | |
| 6 | summary: "Pin a commit as a build (rw scope, a person who writes) at refs/builds/<label>; answers 201 with the pin: { label, oid, note, parent, source, pinnedBy, pinnedAt }", | |
| 7 | 7 | auth: true, |
| 8 | 8 | body: { label: "string", sha: "string", note: "string", parent: "string", source: "string" }, |
| 9 | 9 | status: 201, |
| 10 | refusals: { exists: "409: a pin with that label already exists" }, | |
| 10 | reply: { label: "string", oid: "string", note: "string", parent: "string", source: "string", pinnedBy: "string", pinnedAt: "string" }, | |
| 11 | refusals: { | |
| 12 | forbidden: "403: not a person who writes the repository (an agent is refused)", | |
| 13 | not_found: "404: no such repository, or the caller cannot read it", | |
| 14 | invalid_label: "422: the label is not a valid pin name", | |
| 15 | invalid_parent: "422: `parent` is neither a label nor `owner/name:label`", | |
| 16 | not_a_commit: "422: `sha` names no commit of the repository", | |
| 17 | exists: "409: a pin with that label already exists", | |
| 18 | mirrored: "409: the repository mirrors an origin", | |
| 19 | git_failed: "422: git refused the change", | |
| 20 | }, | |
| 11 | 21 | }; |
test/build-pin-refusals.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * The OpenAPI document names every refusal of POST …/builds: each `error` | |
| 3 | * the handler answers is among the refusals the served document lists. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts"; | |
| 13 | import { getRepo } from "../src/core/repos.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let alice: User; | |
| 22 | let oid: string; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-pin-refusals-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | const made = await createUser(app.db, { | |
| 29 | origin: "seed", | |
| 30 | trusted: true, | |
| 31 | username: "alice", | |
| 32 | email: "[email protected]", | |
| 33 | password: "a-good-passphrase", | |
| 34 | adminIfFirst: true, | |
| 35 | }); | |
| 36 | if (!made.ok) throw new Error(String(made.error)); | |
| 37 | alice = made.value; | |
| 38 | for (const name of ["game", "lib"]) { | |
| 39 | const repo = await createRepo(app, alice, { name, visibility: "public" }); | |
| 40 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 41 | const c = await commitToBranch(app, alice, getRepo(app.db, "alice", name)!, { | |
| 42 | branch: "main", | |
| 43 | message: "one", | |
| 44 | files: [{ path: "one.txt", content: "one\n" }], | |
| 45 | }); | |
| 46 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 47 | if (name === "game") oid = c.value.commitOid; | |
| 48 | } | |
| 49 | const origin = createOrigin(app.db, app.config.dataDir, { | |
| 50 | userId: alice.id, | |
| 51 | kind: "forgejo", | |
| 52 | apiUrl: "http://127.0.0.1:9/api/v1", | |
| 53 | webUrl: "http://127.0.0.1:9", | |
| 54 | login: "alice", | |
| 55 | token: "t", | |
| 56 | }); | |
| 57 | if (!origin.ok) throw new Error(origin.error); | |
| 58 | linkRepoToOrigin(app.db, getRepo(app.db, "alice", "lib")!.id, { | |
| 59 | originId: origin.value.id, | |
| 60 | fullName: "octo/lib", | |
| 61 | remoteId: "1", | |
| 62 | htmlUrl: "http://127.0.0.1:9/octo/lib", | |
| 63 | }); | |
| 64 | }); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("every error POST …/builds answers is a refusal the OpenAPI document lists", async () => { | |
| 73 | const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any; | |
| 74 | const op = doc.paths["/api/v1/repos/{owner}/{name}/builds"].post; | |
| 75 | const refusals = String(op.responses.default.description).split("Refusals: ")[1] ?? ""; | |
| 76 | const codes = [...refusals.matchAll(/`([a-z_]+)` \(/g)].map((m) => m[1]!); | |
| 77 | ||
| 78 | const token = createToken(app.db, alice.id, "t", "rw").token; | |
| 79 | const agent = await createAgent(app, alice, { username: "bot" }); | |
| 80 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 81 | const agentToken = createToken(app.db, agent.value.user.id, "t", "rw").token; | |
| 82 | ||
| 83 | const pin = async (repo: string, as: string, body: Record<string, string>) => { | |
| 84 | const res = await fetch(`${servers.url}/api/v1/repos/alice/${repo}/builds`, { | |
| 85 | method: "POST", | |
| 86 | headers: { "content-type": "application/json", authorization: `Bearer ${as}` }, | |
| 87 | body: JSON.stringify(body), | |
| 88 | }); | |
| 89 | return { status: res.status, error: ((await res.json()) as { error?: string }).error }; | |
| 90 | }; | |
| 91 | const first = await pin("game", token, { label: "v1", sha: oid }); | |
| 92 | expect(first.status).toBe(201); | |
| 93 | ||
| 94 | const answers = [ | |
| 95 | await pin("game", token, { label: "-bad", sha: oid }), | |
| 96 | await pin("game", token, { label: "v2", sha: oid, parent: "no spaces/allowed" }), | |
| 97 | await pin("game", token, { label: "v2", sha: "f".repeat(40) }), | |
| 98 | await pin("game", token, { label: "v1", sha: oid }), | |
| 99 | await pin("game", agentToken, { label: "v3", sha: oid }), | |
| 100 | await pin("lib", token, { label: "v1", sha: oid }), | |
| 101 | ]; | |
| 102 | const wanted = ["invalid_label", "invalid_parent", "not_a_commit", "exists", "forbidden", "mirrored"]; | |
| 103 | expect(answers.map((a) => a.error)).toEqual(wanted); | |
| 104 | for (const a of answers) expect(codes).toContain(a.error!); | |
| 105 | }); |
new above
offline