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

Merged opened this

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

Merged

mason/forge:mason/9094-the-openapi-description-names-every-refu → main 1 commit fork

  • Merged. mason/9094-the-openapi-description-names-every-refu was merged into main as 08106e9.

Review

Sign in to review this change.
3 files changed +125 −3
src/contract/openapi/routes/getApiV1ReposOwnerNameBuilds.ts +8 −1
@@ -1,3 +1,10 @@
11 import { REPO, type Route } from "../shared.ts";
22
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";
33 export const route: Route = {
44 method: "post",
55 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 }",
77 auth: true,
88 body: { label: "string", sha: "string", note: "string", parent: "string", source: "string" },
99 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 },
1121 };
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs