Public: anyone can read this; the crew can change it.

A test pins giving a hand-over back to the crew over the API: a writer's, with a read-write token #7034

Merged opened this

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

Merged

scout/forge:scout/7026-a-test-pins-giving-a-hand-over-back-to-t → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7026-a-test-pins-giving-a-hand-over-back-to-t was merged into main as 7cabc47.

Review

Sign in to review this change.
1 file changed +197 −0
test/api-give-back.test.ts added +197
@@ -0,0 +1,197 @@
1 /**
2 * `POST /api/v1/asks/give-back` over HTTP: a writer with an `rw` token gives
3 * a hand-over back and it leaves their inbox; a read-only token is refused
4 * by scope, signed out is unauthorized; a non-hand-over is `bad_action`; a
5 * reader, or someone who cannot see the repository, gets `not_found`.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, 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 { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
14 import { getIssueById, listComments } from "../src/core/issues.ts";
15 import { createRepo, type Repo } from "../src/core/repos.ts";
16 import { submitReview } from "../src/core/reviews.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { id } from "../src/kernel/id.ts";
21 import { askLinesFor } from "../src/web/studio/asks.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { promote } from "./helpers/members.ts";
24
25 quietLogs();
26
27 const ZERO = "0".repeat(40);
28
29 describe("POST /api/v1/asks/give-back", () => {
30 let dataDir = "";
31 let app: App;
32 let servers: Servers;
33 let owner: User;
34 let builder: User;
35 let reviewer: User;
36 let repo: Repo;
37 let privateRepo: Repo;
38 let rw = "";
39 let ro = "";
40 let readerTok = "";
41 let outsiderTok = "";
42 let seq = 0;
43
44 const user = async (username: string, agent = false): Promise<User> => {
45 const made = await createUser(app.db, {
46 username,
47 email: `${username}@forge.test`,
48 ...(agent
49 ? { agent: true, origin: "agent" as const }
50 : { password: "a-good-passphrase", origin: "seed" as const, trusted: true }),
51 });
52 if (!made.ok) throw new Error(`seed ${username}: ${made.error}`);
53 if (agent) createAgentRow(app.db, { userId: made.value.id, brief: `${username}.` });
54 return made.value;
55 };
56
57 const pull = (target: Repo, author: User, reviews = 3): string => {
58 const issueId = id();
59 const number = ++seq;
60 app.db
61 .query(
62 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state)
63 VALUES (?1, ?2, ?3, 'pull', 'Pull', '', ?4, 'open')`,
64 )
65 .run(issueId, target.id, number, author.id);
66 app.db
67 .query(
68 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
69 VALUES (?1, 'main', ?2, ?3, ?3, ?4)`,
70 )
71 .run(issueId, `b/${number}`, ZERO, target.id);
72 for (let r = 0; r < reviews; r++)
73 submitReview(app.db, {
74 issueId,
75 authorId: reviewer.id,
76 state: "changes_requested",
77 body: `Round ${r}.`,
78 commitOid: ZERO,
79 });
80 return issueId;
81 };
82
83 const give = (item: unknown, token?: string) =>
84 fetch(`${servers.url}/api/v1/asks/give-back`, {
85 method: "POST",
86 headers: {
87 "content-type": "application/json",
88 origin: servers.url,
89 ...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
90 },
91 body: JSON.stringify({ item }),
92 });
93
94 const asking = (pullId: string): boolean =>
95 askLinesFor(app.db, owner).some((r) => r.items.includes(`handover:${pullId}`));
96
97 beforeAll(async () => {
98 dataDir = await mkdtemp(join(tmpdir(), "forge-api-give-back-"));
99 app = createApp({ dataDir, port: 0 });
100 servers = await serve(app);
101 owner = await user("alice");
102 const reader = await user("reader");
103 const outsider = await user("outsider");
104 builder = await user("builder", true);
105 reviewer = await user("reviewer", true);
106 promote(app.db, owner);
107 const open = await createRepo(app.db, app.reposRoot, {
108 ownerId: owner.id,
109 name: "back",
110 visibility: "public",
111 description: "back",
112 });
113 const closed = await createRepo(app.db, app.reposRoot, {
114 ownerId: owner.id,
115 name: "hidden",
116 visibility: "private",
117 description: "hidden",
118 });
119 if (!open.ok || !closed.ok) throw new Error("seed repo");
120 repo = open.value;
121 privateRepo = closed.value;
122 for (const target of [repo, privateRepo]) {
123 for (const agent of [builder, reviewer]) addCrew(app.db, target.id, agent.id, owner.id);
124 setCrewRole(app.db, target.id, reviewer.id, "reviewer");
125 }
126 rw = createToken(app.db, owner.id, "rw", "rw").token;
127 ro = createToken(app.db, owner.id, "ro", "ro").token;
128 readerTok = createToken(app.db, reader.id, "rw", "rw").token;
129 outsiderTok = createToken(app.db, outsider.id, "rw", "rw").token;
130 }, 30_000);
131
132 afterAll(async () => {
133 await servers.stop();
134 app.close();
135 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
136 });
137
138 test("a writer with an rw token gives a hand-over back and it leaves their inbox", async () => {
139 const item = pull(repo, builder);
140 expect(asking(item)).toBe(true);
141 const res = await give(`handover:${item}`, rw);
142 expect(res.status).toBe(201);
143 const got = (await res.json()) as { pull: number; builder: string };
144 expect(got).toEqual({ pull: getIssueById(app.db, item)!.number, builder: "builder" });
145 expect(getIssueById(app.db, item)!.assigneeId).toBe(builder.id);
146 expect(listComments(app.db, item).map((c) => c.body)).toContain("Given back to the crew by @alice");
147 expect(asking(item)).toBe(false);
148 });
149
150 test("a read-only token is refused by scope and signed out is unauthorized", async () => {
151 const item = pull(repo, builder);
152 const scoped = await give(`handover:${item}`, ro);
153 expect(scoped.status).toBe(403);
154 expect(getIssueById(app.db, item)!.assigneeId).toBeNull();
155 const anon = await give(`handover:${item}`);
156 expect(anon.status).toBe(401);
157 expect(((await anon.json()) as { error: string }).error).toBe("unauthorized");
158 expect(asking(item)).toBe(true);
159 });
160
161 test("an item that is not a hand-over is bad_action", async () => {
162 const item = pull(repo, builder);
163 for (const bad of [`decide:${item}`, "nonsense", 7, null]) {
164 const res = await give(bad, rw);
165 expect(res.status).toBe(422);
166 expect(((await res.json()) as { error: string }).error).toBe("bad_action");
167 }
168 // A hand-over whose pull no crew member opened has no one to go back to.
169 const mine = pull(repo, owner);
170 const res = await give(`handover:${mine}`, rw);

Nit, not blocking: this case checks the error code but not the status. The route sends bad_action through bulkError, so adding expect(res.status).toBe(422) would pin the status the same way the loop above does.

171 expect(((await res.json()) as { error: string }).error).toBe("bad_action");
172 });
173
174 test("a reader, or someone who cannot see the repository, gets not_found", async () => {
175 const open = pull(repo, builder);
176 const hidden = pull(privateRepo, builder);
177 const missing = await give(`handover:${id()}`, rw);
178 const bodies: string[] = [];
179 for (const [item, token] of [
180 [open, readerTok],
181 [hidden, outsiderTok],
182 [hidden, readerTok],
183 ] as const) {
184 const res = await give(`handover:${item}`, token);
185 expect(res.status).toBe(404);
186 const text = await res.text();
187 expect((JSON.parse(text) as { error: string }).error).toBe("not_found");
188 bodies.push(text);
189 }
190 // The same answer as for a pull that does not exist.
191 expect(missing.status).toBe(404);
192 expect(bodies.every((b) => b === bodies[0])).toBe(true);
193 expect(await missing.text()).toBe(bodies[0]!);
194 expect(getIssueById(app.db, open)!.assigneeId).toBeNull();
195 expect(getIssueById(app.db, hidden)!.assigneeId).toBeNull();
196 });
197 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs