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

A test pins Settings › Crew: only the repository's admin changes its crew, and a refused change leaves it as it was #6881

Merged opened this

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

Merged

scout/forge:scout/6854-a-test-pins-settings-crew-only-the-repos → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/6854-a-test-pins-settings-crew-only-the-repos was merged into main as 790b695.

Review

Sign in to review this change.
1 file changed +232 −0
test/crew-settings-doors.test.ts added +232
@@ -0,0 +1,232 @@
1 /**
2 * Settings › Crew: only the repository's admin changes its crew. The four
3 * forms (add, role, subscriptions, remove) land back on `#crew` with a
4 * flash for the admin; a signed-in non-admin and a guest get the not-found
5 * page and nothing moves; a person or an unknown name is refused in words.
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 { createAgent } from "../src/actions/crew.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { crewRole, isCrew } from "../src/core/agents.ts";
16 import {
17 addSubscription,
18 listGrants,
19 setGrants,
20 } from "../src/core/subscriptions.ts";
21 import { createUser } from "../src/core/users.ts";
22 import { createRepo as initRepo, type Repo } from "../src/core/repos.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 const PASSWORD = "a-long-crew-passphrase";
29
30 describe("settings › crew doors", () => {
31 let dataDir = "";
32 let app: App;
33 let servers: Servers;
34 let repo: Repo;
35 let botId = "";
36 let otherId = "";
37 let carolRepo: Repo;
38 let carol = "";
39 let subId = "";
40 let admin = "";
41 let bob = "";
42
43 const post = (
44 path: string,
45 body: Record<string, string>,
46 cookie?: string,
47 ): Promise<Response> =>
48 fetch(`${servers.url}/alice/yard/settings/crew${path}`, {
49 method: "POST",
50 headers: { origin: servers.url, ...(cookie ? { cookie } : {}) },
51 body: new URLSearchParams(body),
52 redirect: "manual",
53 });
54
55 const signIn = async (username: string): Promise<string> => {
56 const res = await fetch(`${servers.url}/signin`, {
57 method: "POST",
58 body: new URLSearchParams({ username, password: PASSWORD }),
59 redirect: "manual",
60 });
61 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
62 };
63
64 const flash = (res: Response): string =>
65 decodeURIComponent(
66 `${res.headers.get("location") ?? ""} ${res.headers.get("set-cookie") ?? ""}`.replace(
67 /\+/g,
68 " ",
69 ),
70 );
71
72 const grants = () => listGrants(app.db, repo.id);
73
74 beforeAll(async () => {
75 dataDir = await mkdtemp(join(tmpdir(), "forge-crew-settings-"));
76 app = createApp({ dataDir, port: 0 });
77 servers = await serve(app);
78 const a = await createUser(app.db, {
79 origin: "seed",
80 username: "alice",
81 email: "[email protected]",
82 password: PASSWORD,
83 adminIfFirst: true,
84 trusted: true,
85 });
86 const b = await createUser(app.db, {
87 origin: "seed",
88 username: "bob",
89 email: "[email protected]",
90 password: PASSWORD,
91 trusted: true,
92 });
93 if (!a.ok || !b.ok) throw new Error("users");
94 const made = await createRepo(app, a.value, {
95 name: "yard",
96 visibility: "public",
97 });
98 if (!made.ok) throw new Error(String(made.error));
99 repo = made.value;
100 const bot = await createAgent(app, a.value, { username: "yardbot" });
101 if (!bot.ok) throw new Error("agent");
102 botId = bot.value.user.id;
103 const sub = addSubscription(app.db, dataDir, {
104 name: "pool",
105 token: "sk-ant-oat01-test-token-value",
106 });
107 if (!sub.ok) throw new Error("subscription");
108 subId = sub.value.id;
109 const other = await createAgent(app, a.value, { username: "otherbot" });
110 if (!other.ok) throw new Error("agent");
111 otherId = other.value.user.id;
112 const c = await createUser(app.db, {
113 origin: "seed",
114 username: "carol",
115 email: "[email protected]",
116 password: PASSWORD,
117 trusted: true,
118 });
119 if (!c.ok) throw new Error("user");
120 const cr = await initRepo(app.db, app.reposRoot, {
outdated

The add form posts yardbot, who is already on the crew from the setup above. An ungated add is a no-op here, so these assertions still pass. Use a different agent not on the crew for the add case and assert it stays off.

121 ownerId: c.value.id,
122 name: "carolyard",
123 visibility: "public",
124 });
125 if (!cr.ok) throw new Error(String(cr.error));
126 carolRepo = cr.value;
127 carol = await signIn("carol");
128 admin = await signIn("alice");
129 bob = await signIn("bob");
130 }, 30_000);
131
132 afterAll(async () => {
133 await servers.stop();
134 await rm(dataDir, { recursive: true, force: true });
135 });
136
137 test("a non-admin and a guest get not-found and the crew stays as it was", async () => {
138 // Set up a crew member with a role and a grant to watch.
139 const added = await post("", { agent: "yardbot" }, admin);
140 expect(added.status).toBe(303);
141 await post("/yardbot/role", { role: "reviewer" }, admin);
142 setGrants(app.db, repo.id, botId, [subId]);
143 const before = JSON.stringify(grants());
144
145 for (const cookie of [bob, undefined]) {
146 for (const [path, body] of [
147 ["", { agent: "otherbot" }],
148 ["/yardbot/role", { role: "planner" }],
149 ["/yardbot/remove", {}],
150 ["/subscriptions", {}],
151 ] as const) {
152 const res = await post(path, { ...body }, cookie);
153 expect(res.status).toBe(404);
154 expect(isCrew(app.db, repo.id, otherId)).toBe(false);
155 expect(isCrew(app.db, repo.id, botId)).toBe(true);
156 expect(crewRole(app.db, repo.id, botId)).toBe("reviewer");
157 expect(JSON.stringify(grants())).toBe(before);
158 }
159 }
160 // Clean up for the admin's walk.
161 expect((await post("/yardbot/remove", {}, admin)).status).toBe(303);
162 setGrants(app.db, repo.id, botId, []);
163 });
164
165 test("the admin adds, re-roles, saves subscriptions and removes, each back on #crew", async () => {
166 const add = await post("", { agent: "yardbot" }, admin);
167 expect(add.status).toBe(303);
168 expect(add.headers.get("location")).toContain("/alice/yard/settings#crew");
169 expect(flash(add)).toContain("yardbot is on the crew");
170 expect(isCrew(app.db, repo.id, botId)).toBe(true);
171
172 const role = await post("/yardbot/role", { role: "planner" }, admin);
173 expect(role.headers.get("location")).toContain("#crew");
174 expect(flash(role)).toContain("yardbot is a planner here");
175 expect(crewRole(app.db, repo.id, botId)).toBe("planner");
176
177 const bad = await post("/yardbot/role", { role: "emperor" }, admin);
178 expect(flash(bad)).toContain("That role could not be set");
179 expect(crewRole(app.db, repo.id, botId)).toBe("planner");
180
181 const save = await post(
182 "/subscriptions",
183 { grant: `${botId}:${subId}` },
184 admin,
185 );
186 expect(save.status).toBe(303);
outdated

Not part of this fix: the subscriptions route also requires a forge admin (crew-settings.ts:29), and alice is both here. Add a repo admin who is not a forge admin and assert 404 so that line is pinned.

187 expect(save.headers.get("location")).toContain("#crew");
188 expect(flash(save)).toContain("Saved");
189 expect(grants().map((g) => [g.agentId, g.subscriptionId])).toEqual([
190 [botId, subId],
191 ]);
192
193 const gone = await post("/yardbot/remove", {}, admin);
194 expect(gone.headers.get("location")).toContain("#crew");
195 expect(flash(gone)).toContain("yardbot is off the crew");
196 expect(isCrew(app.db, repo.id, botId)).toBe(false);
197 });
198
199 test("a person or an unknown name is refused with a sentence, nothing written", async () => {
200 const person = await post("", { agent: "bob" }, admin);
201 expect(person.status).toBe(303);
202 expect(flash(person)).toContain("Only an agent can be on a crew");
203 const nobody = await post("", { agent: "ghost" }, admin);
204 expect(flash(nobody)).toContain("No such agent on this forge");
205 const rows = app.db
206 .query<{ n: number }, [string]>(
207 "SELECT COUNT(*) AS n FROM repo_crew WHERE repo_id = ?1",
208 )
209 .get(repo.id)!;
210 expect(rows.n).toBe(0);
211 });
212
213 test("a repository admin who is not a forge admin cannot save subscriptions", async () => {
214 const own = (path: string, body: Record<string, string>) =>
215 fetch(`${servers.url}/carol/carolyard/settings/crew${path}`, {
216 method: "POST",
217 headers: { origin: servers.url, cookie: carol },
218 body: new URLSearchParams(body),
219 redirect: "manual",
220 });
221 // She does administer her own repository: the add form answers her.
222 expect((await own("", { agent: "otherbot" })).status).toBe(303);
223 expect(isCrew(app.db, carolRepo.id, otherId)).toBe(true);
224 setGrants(app.db, carolRepo.id, otherId, []);
225
226 const res = await own("/subscriptions", {
227 grant: `${otherId}:${subId}`,
228 });
229 expect(res.status).toBe(404);
230 expect(listGrants(app.db, carolRepo.id)).toEqual([]);
231 });
232 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs