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

Roles, part 3a: a member is refused admin settings and releases, with a 403 on the API and a plain line on the page #2403

Merged opened this

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

Merged

scout/forge:scout/2384-roles-part-3a-a-member-is-refused-admin → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/2384-roles-part-3a-a-member-is-refused-admin was merged into main as 56395a2.

Review

Sign in to review this change.
16 files changed +248 −40
docs/ROLES.md +7
@@ -15,6 +15,13 @@ verify, a suspended one is refused, an agent and a stranger to a private
1515 repository meet their own reasons first. A comment from a guest is still a
1616 comment; it just starts no run. Agents and organizations are not guests.
1717
18 Part 3a (#2384) enforces "Administer the forge" for admin settings and
19 releases: every `/admin/*` page, `/api/v1/admin/*` call, and the `/releases`
20 page with its rollback refuse a member or guest with a 403 and "only an admin
21 can change the forge's settings or its releases" (`not_admin` in the API).
22 `src/http/admin_gate.ts` is the one gate, ahead of every handler;
23 `/releases.json` stays public. Token scopes are a later part.
24
1825 Nothing switches the refusal off: the test suite runs with it everywhere, and
1926 a suite whose people make things promotes them first (`promote` in
2027 `test/helpers/members.ts`). The crew's wake-up sweep, which starts a run on an
src/core/roles.ts +3
@@ -48,6 +48,9 @@ export function rolesTable(): string {
4848 /** The words a guest is refused with, on every surface. */
4949 export const NOT_A_MEMBER = "the owner has not made this account a member";
5050
51 /** The words a member (or guest) is refused admin settings and releases with. */
52 export const NOT_AN_ADMIN = "only an admin can change the forge's settings or its releases";
53
5154 /**
5255 * Whether the table withholds `ability` from this account. Only a person
5356 * can be a guest: agents and organizations are not accounts the owner
src/http/admin_gate.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * The one gate in front of admin settings and releases: `/admin/*`,
3 * `/api/v1/admin/*` and the `/releases` page with its rollback. A signed-in
4 * person whose role lacks `administer` (`core/roles.ts`) is refused with a
5 * 403 and one plain sentence, as JSON for the API and as a page for a
6 * browser, before any handler runs. Anyone else meets the handler's own
7 * rules, so an admin is unchanged. For releases the rule itself is
8 * `release/gate.ts`'s, which the terminal shares; this gate only supplies the
9 * 403 and its wording on the page. `/releases.json` stays public.
10 */
11
12 import type { App } from "../app.ts";
13 import { lacks, NOT_AN_ADMIN } from "../core/roles.ts";
14 import { empty } from "../web/ui.ts";
15 import { html } from "../web/html.ts";
16 import { page } from "../web/layout.ts";
17 import type { Auth, Ctx } from "./context.ts";
18 import { json } from "./respond.ts";
19
20 const under = (path: string, root: string): boolean => path === root || path.startsWith(`${root}/`);
21
22 /** Whether this path is one the `administer` ability guards. */
23 export function isAdminPath(path: string): boolean {
24 return under(path, "/admin") || under(path, "/api/v1/admin") || under(path, "/releases");
25 }
26
27 /** The refusal for a person who may not administer, or undefined to carry on. */
28 export function adminGate(app: App, request: Request, url: URL, auth: Auth): Response | undefined {
outdated

High: this is the only place the administer refusal is applied, and only HTTP reaches it. The terminal's Releases screen (src/tui/screens/releases.ts) goes through release/gate.ts mayRead/mayRollBack, which never ask lacks(..., "administer"). A member who writes FORGE_REPO can still roll back from SSH. Move the check into release/gate.ts so both doors share it.

29 if (auth.user === undefined || !isAdminPath(url.pathname)) return undefined;
30 if (!lacks(app.db, auth.user, "administer")) return undefined;
31 if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: NOT_AN_ADMIN }, 403);
32 const ctx: Ctx = { app, request, url, params: {}, auth, theme: undefined };
33 return page(
34 {
35 ctx,
36 title: "Not allowed",
37 body: html`<div class="page-head"><h1>403</h1></div>
38 ${empty({ icon: "lock", title: "Not allowed", note: NOT_AN_ADMIN })}`,
39 },
40 403,
41 );
42 }
src/http/server.ts +5
@@ -430,6 +430,7 @@ import { targetForShortCode } from "../origins/oauth.ts";
430430 import { apiRoutes } from "./api.ts";
431431 import { inNamespace } from "../core/proposal_token.ts";
432432 import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts";
433 import { adminGate } from "./admin_gate.ts";
433434 import { proposalGate } from "./proposal_gate.ts";
434435 import { feedRoutes } from "./feed.ts";
435436 import type { Database } from "bun:sqlite";
@@ -2302,6 +2303,10 @@ async function answerGet(
23022303 const proposalRefusal = proposalGate(app, request, url, auth);
23032304 if (proposalRefusal !== undefined) return proposalRefusal;
23042305
2306 // Admin settings and releases are an admin's; a member is told so.
2307 const adminRefusal = adminGate(app, request, url, auth);

This gate sits only on the HTTP path. Any rule it adds for releases has to live in release/gate.ts too, or the terminal disagrees with the page.

2308 if (adminRefusal !== undefined) return adminRefusal;
2309
23052310 // The two things standing between one runaway agent and everybody
23062311 // else's latency, applied before a handler runs and after auth is
23072312 // known — a limit needs an identity, and a replay needs a credential.
src/release/gate.ts +6 −2
@@ -3,11 +3,15 @@
33 * both clients (the /releases page and the SSH terminal) run: the same
44 * refusals, `beginSwap`/`endSwap`, the health-read swap, then this process
55 * drains. Plain data in, plain data out; neither client re-states a rule.
6 * Releases are an admin's to see and undo: a person whose role lacks
7 * `administer` (`core/roles.ts`) is refused here, whatever they may do to
8 * FORGE_REPO, so the page and the terminal agree.
69 */
710
811 import { existsSync } from "node:fs";
912 import type { App } from "../app.ts";
1013 import { canRead, canWrite, getRepo, type Repo } from "../core/repos.ts";
14 import { lacks } from "../core/roles.ts";
1115 import type { User } from "../core/users.ts";
1216 import { releaseDir } from "./checkout.ts";
1317 import type { Release } from "./record.ts";
@@ -27,7 +31,7 @@ function forgeRepoOf(app: App): Repo | undefined {
2731
2832 /** Who may see the release history: the forge repository's readers and a site admin. */
2933 export function mayRead(app: App, user: User | undefined): boolean {
30 if (user === undefined) return false;
34 if (user === undefined || lacks(app.db, user, "administer")) return false;
3135 if (user.isAdmin) return true;
3236 const repo = forgeRepoOf(app);
3337 return repo !== undefined && canRead(app.db, repo, user);
@@ -35,7 +39,7 @@ export function mayRead(app: App, user: User | undefined): boolean {
3539
3640 export function mayRollBack(app: App, user: User | undefined): boolean {
3741 // Undoing a deployment is a person's decision, as a revert is (#1824).
38 if (user === undefined || user.kind === "agent") return false;
42 if (user === undefined || user.kind === "agent" || lacks(app.db, user, "administer")) return false;
3943 const repo = forgeRepoOf(app);
4044 return repo !== undefined && canWrite(app.db, repo, user);
4145 }
test/accounts.test.ts +5 −5
@@ -148,7 +148,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
148148
149149 test("nobody but the owner sees the list, or can act on it", async () => {
150150 const { token } = await outsider("mallory");
151 expect((await api("/api/v1/admin/accounts", { token })).status).toBe(404);
151 expect((await api("/api/v1/admin/accounts", { token })).status).toBe(403);
152152 expect(
153153 (
154154 await api("/api/v1/admin/accounts/ada/suspend", {
@@ -156,11 +156,11 @@ describe("accounts: the owner sees, suspends and removes", () => {
156156 token,
157157 })
158158 ).status,
159 ).toBe(404);
159 ).toBe(403);
160160 expect(
161161 (await api("/api/v1/admin/accounts/ada", { method: "DELETE", token }))
162162 .status,
163 ).toBe(404);
163 ).toBe(403);
164164 expect((await api("/api/v1/admin/accounts")).status).toBe(404);
165165 const page = await fetch(`${base}/settings/accounts`, {
166166 headers: { authorization: `Bearer ${token}` },
@@ -498,7 +498,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
498498 // The plain path is as it was.
499499 expect((await del({})).status).toBe(409);
500500 // A non-admin is refused.
501 expect((await del({ retireOrgs: true, confirm: true }, bossToken)).status).toBe(404);
501 expect((await del({ retireOrgs: true, confirm: true }, bossToken)).status).toBe(403);
502502 // Without confirm: the list, and nothing changes.
503503 const preview = await del({ retireOrgs: true });
504504 expect(preview.status).toBe(200);
@@ -636,7 +636,7 @@ describe("accounts: the owner sees, suspends and removes", () => {
636636 // Nobody but the owner may say so.
637637 expect(
638638 (await api("/api/v1/admin/accounts/nadia/verify", { method: "POST", token: other })).status,
639 ).toBe(404);
639 ).toBe(403);
640640
641641 const listed = (await (await api("/api/v1/admin/accounts", { token: ownerToken })).json()) as {
642642 items: { username: string; verified: boolean }[];
test/admin_refused.test.ts added +127
@@ -0,0 +1,127 @@
1 /**
2 * Roles, part 3a (#2384): a member is refused admin settings and releases
3 * with a 403 and one plain sentence, on the API and on the page; an admin is
4 * unchanged.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { mayRead, mayRollBack } from "../src/release/gate.ts";
14 import { NOT_AN_ADMIN, setMember } from "../src/core/roles.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 describe("a member is refused admin settings and releases", () => {
24 let dataDir = "";
25 let app: App;
26 let servers: Servers;
27 let alice: User;
28 let carol: User; // member
29
30 const token = (u: User) => createToken(app.db, u.id, "admin", "admin").token;
31 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
32 const call = (method: string, path: string, u: User, body?: unknown) =>
33 fetch(`${servers.url}${path}`, {
34 method,
35 headers: { "content-type": "application/json", authorization: `Bearer ${token(u)}` },
36 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
37 });
38 const visit = (method: string, path: string, u: User) =>
39 fetch(`${servers.url}${path}`, {
40 method,
41 headers: { cookie: cookie(u), origin: servers.url, accept: "text/html" },
42 ...(method === "POST" ? { body: new URLSearchParams({}) } : {}),
43 redirect: "manual",
44 });
45 const mk = async (username: string, admin = false) => {
46 const r = await createUser(app.db, {
47 trusted: true,
48 username,
49 email: `${username}@forge.local`,
50 password: "a-good-passphrase",
51 ...(admin ? { adminIfFirst: true } : {}),
52 });
53 if (!r.ok) throw new Error(r.error);
54 return r.value;
55 };
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-admin-refused-"));
59 app = createApp({ dataDir, port: 0 });
60 servers = await serve(app);
61 alice = await mk("alice", true);
62 carol = await mk("carol");
63 setMember(app.db, carol.id, true, alice.id, new Date());
64 }, 60_000);
65
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 const apiActs: [string, string, unknown?][] = [
73 ["GET", "/api/v1/admin/capacity"],
74 ["PUT", "/api/v1/admin/capacity/anything", { value: "on" }],
75 ["GET", "/api/v1/admin/limits"],
76 ["GET", "/api/v1/admin/settings"],
77 ["GET", "/api/v1/admin/invites"],
78 ["POST", "/api/v1/admin/invites", {}],
79 ["GET", "/api/v1/admin/accounts"],
80 ["GET", "/api/v1/admin/people"],
81 ["POST", "/api/v1/admin/accounts/alice/suspend", {}],
82 ];
83
84 test("every admin API act: 403 and the sentence for a member, success for an admin", async () => {
85 for (const [method, path, body] of apiActs) {
86 const refused = await call(method, path, carol, body);
87 expect(refused.status, `${method} ${path}`).toBe(403);
88 expect(await refused.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN });
89 }
90 for (const path of ["/api/v1/admin/capacity", "/api/v1/admin/limits", "/api/v1/admin/people"])
91 expect((await call("GET", path, alice)).status, path).toBe(200);
92 });
93
94 test("admin pages and releases: a plain 403 line for a member, the page for an admin", async () => {
95 const pages: [string, string][] = [
96 ["GET", "/admin/limits"],
97 ["POST", "/admin/limits"],
98 ["POST", "/admin/quotas"],
99 ["POST", "/admin/signup"],
100 ["GET", "/releases"],
101 ["POST", `/releases/${"a".repeat(40)}/rollback`],
102 ];
103 for (const [method, path] of pages) {
104 const refused = await visit(method, path, carol);
105 expect(refused.status, `${method} ${path}`).toBe(403);
106 expect(await refused.text()).toContain(NOT_AN_ADMIN.replace("'", "&#39;"));
107 }
108 expect((await visit("GET", "/admin/limits", alice)).status).toBe(200);
109 expect((await visit("GET", "/releases", alice)).status).toBe(200);
110 });
111
112 test("a member who writes the forge's repository is still refused /releases and a rollback", async () => {
113 const repo = await createRepo(app, alice, { name: "forge", visibility: "private" });
114 if (!repo.ok) throw new Error(String(repo.error));
115 app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(carol.id, repo.value.id);
116 app.config.forgeRepo = "carol/forge";
117 expect(mayRollBack(app, carol)).toBe(false);
118 expect(mayRead(app, carol)).toBe(false);
119 for (const [m, p] of [["GET", "/releases"], ["POST", `/releases/${"a".repeat(40)}/rollback`]] as const)
120 expect((await visit(m, p, carol)).status, p).toBe(403);
121 });
122
123 test("the public release list stays public, and a signed-out caller meets the old rules", async () => {
124 expect((await fetch(`${servers.url}/releases.json`)).status).toBe(200);
125 expect((await fetch(`${servers.url}/api/v1/admin/capacity`)).status).not.toBe(403);
126 });
127 });
test/capacity.test.ts +4 −2
@@ -171,14 +171,16 @@ describe("capacity: only a forge admin reads or changes it", () => {
171171 expect(bobPost.status).toBe(404);
172172
173173 for (const token of [bobAdmin, agentRw]) {
174 expect((await api(token, "GET", "/admin/capacity")).status).toBe(404);
174 // A person who is not an admin is refused; an agent is told it is not there.
175 const refused = token === bobAdmin ? 403 : 404;
176 expect((await api(token, "GET", "/admin/capacity")).status).toBe(refused);
175177 expect(
176178 (
177179 await api(token, "PUT", "/admin/capacity/checks.concurrency", {
178180 value: "9",
179181 })
180182 ).status,
181 ).toBe(404);
183 ).toBe(refused);
182184 const tool = await mcp(token, {
183185 key: "checks.concurrency",
184186 value: "9",
test/invites.test.ts +4 −4
@@ -80,9 +80,9 @@ describe("invites: an admin mints, lists and revokes", () => {
8080 const made = mintInvite(app.db, "x");
8181 for (const token of [guest, "forge_nope"]) {
8282 const post = await api("/api/v1/admin/invites", token, { method: "POST", body: "{}" });
83 expect([401, 404]).toContain(post.status);
84 expect([401, 404]).toContain((await api("/api/v1/admin/invites", token)).status);
85 expect([401, 404]).toContain(
83 expect([401, 403, 404]).toContain(post.status);
84 expect([401, 403, 404]).toContain((await api("/api/v1/admin/invites", token)).status);
85 expect([401, 403, 404]).toContain(
8686 (await api(`/api/v1/admin/invites/${made.invite.id}`, token, { method: "DELETE" })).status,
8787 );
8888 }
@@ -155,7 +155,7 @@ describe("invites: the settings form", () => {
155155 test("a cross-origin post is refused and a non-admin gets the not-found page", async () => {
156156 const before = listInvites(app.db).length;
157157 expect((await post(alice, "/admin/invites", { days: "3" }, "https://evil.example")).status).toBe(403);
158 expect((await post(bob, "/admin/invites", { days: "3" })).status).toBe(404);
158 expect((await post(bob, "/admin/invites", { days: "3" })).status).toBe(403);
159159 expect(listInvites(app.db).length).toBe(before);
160160 });
161161 });
test/limits.test.ts +6 −5
@@ -80,12 +80,13 @@ describe("limits", () => {
8080 await rm(dataDir, { recursive: true, force: true });
8181 });
8282
83 test("a non-admin, signed in or not, gets a 404 and changes nothing", async () => {
83 test("a non-admin, signed in or not, is refused and changes nothing", async () => {
8484 const bob = (await login("bob")).cookie;
8585 for (const cookie of [bob, ""]) {
8686 const got = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } });
87 expect(got.status).toBe(404);
88 expect((await post(cookie, { key: "front.pinned_max", value: "50" })).status).toBe(404);
87 const refused = cookie === "" ? 404 : 403; // signed out: not there; signed in: not an admin
88 expect(got.status).toBe(refused);
89 expect((await post(cookie, { key: "front.pinned_max", value: "50" })).status).toBe(refused);
8990 }
9091 expect(limitValue(app.db, "front.pinned_max")).toBe(LIMITS["front.pinned_max"].default);
9192 expect(limitChanges(app.db)).toEqual([]);
@@ -177,8 +178,8 @@ describe("limits", () => {
177178 const listed = await api(adminToken, "GET", "/admin/limits");
178179 expect(listed.status).toBe(200);
179180 expect(((await listed.json()) as { limits: unknown[] }).limits).toHaveLength(LIMIT_KEYS.length);
180 expect((await api(bobToken, "GET", "/admin/limits")).status).toBe(404);
181 expect((await api(bobToken, "PUT", "/admin/limits/share.daily_max", { value: "9" })).status).toBe(404);
181 expect((await api(bobToken, "GET", "/admin/limits")).status).toBe(403);
182 expect((await api(bobToken, "PUT", "/admin/limits/share.daily_max", { value: "9" })).status).toBe(403);
182183 expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: "0" })).status).toBe(422);
183184 expect((await api(adminToken, "PUT", "/admin/limits/nothing.here", { value: "3" })).status).toBe(422);
184185 expect(limitValue(app.db, "share.daily_max")).toBe(LIMITS["share.daily_max"].default);
test/model-grant-pages.test.ts +2 −2
@@ -107,7 +107,7 @@ describe("a model grant, from the account's page and the API", () => {
107107 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
108108 body: JSON.stringify({ usdPerMonth: usd }),
109109 });
110 expect((await post(bobTok.token, 5)).status).toBe(404);
110 expect((await post(bobTok.token, 5)).status).toBe(403);
111111 expect((await post(tok.token, "5")).status).toBe(422);
112112 expect((await post(tok.token, -1)).status).toBe(422);
113113 expect((await post(tok.token, 7.5)).status).toBe(200);
@@ -174,7 +174,7 @@ describe("a model grant, from the account's page and the API", () => {
174174 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
175175 body: JSON.stringify(body),
176176 });
177 expect((await post(bobTok.token, { dailyUsd: 5, perRunUsd: 1 })).status).toBe(404);
177 expect((await post(bobTok.token, { dailyUsd: 5, perRunUsd: 1 })).status).toBe(403);
178178 expect((await post(tok.token, { dailyUsd: "5", perRunUsd: 1 })).status).toBe(422);
179179 expect((await post(tok.token, { dailyUsd: 0, perRunUsd: 1 })).status).toBe(422);
180180 expect((await post(tok.token, { dailyUsd: 5, perRunUsd: 1 }, "nobody")).status).toBe(404);
test/quotas.test.ts +2 −2
@@ -148,8 +148,8 @@ describe("quotas", () => {
148148
149149 test("a non-admin is refused", async () => {
150150 const t = createToken(app.db, bob.id, "t", "admin").token;
151 expect((await api(t, "GET")).status).toBe(404);
152 expect((await api(t, "PATCH", { quotas: { apps: 2 } })).status).toBe(404);
151 expect((await api(t, "GET")).status).toBe(403);
152 expect((await api(t, "PATCH", { quotas: { apps: 2 } })).status).toBe(403);
153153 expect(getQuotas(app.db).apps).toBeNull();
154154 });
155155
test/releases-page.test.ts +7 −5
@@ -106,22 +106,24 @@ describe("the releases page", () => {
106106 expect(body).not.toContain(`/releases/${PRUNED}/rollback`);
107107 });
108108
109 test("a non-reader gets a 404 and no live line; a reader who cannot write sees the list with no button", async () => {
109 test("a non-admin is refused with a 403 and gets no live line, even as a reader of the repository", async () => {
110110 const cookie = await login("bob", "another-good-passphrase");
111111 const get = (path: string) => fetch(`${servers.url}${path}`, { headers: { cookie } });
112 expect((await get("/releases")).status).toBe(404);
112 expect((await get("/releases")).status).toBe(403);
113113 const home = await (await get("/")).text();
114114 expect(home).not.toContain("live-release");
115115 expect(home).not.toContain("aaaaaaa");
116116 expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403);
117117
118118 app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run();
119 const body = await (await get("/releases")).text();
120 expect(body).toContain("bbbbbbb");
119 const refused = await get("/releases");
120 expect(refused.status).toBe(403);
121 const body = await refused.text();
122 expect(body).not.toContain("bbbbbbb");
121123 expect(body).not.toContain("/rollback");
122124 expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403);
123125 expect((await post(`/releases/${KEPT}/rollback`, "", servers.url)).status).toBe(403);
124 expect(await (await get("/")).text()).toContain("live-release");
126 expect(await (await get("/")).text()).not.toContain("live-release");
125127 app.db.query("UPDATE repo SET visibility = 'private' WHERE name = 'forge'").run();
126128 });
127129
test/roles.test.ts +1 −1
@@ -152,7 +152,7 @@ describe("roles on /settings/people", () => {
152152 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
153153 body: JSON.stringify({ role, usdPerMonth: usd }),
154154 });
155 expect((await send(bobTok, "member")).status).toBe(404);
155 expect((await send(bobTok, "member")).status).toBe(403);
156156 expect((await send(tok, "admin")).status).toBe(422);
157157 expect((await send(tok, "member", -1)).status).toBe(422);
158158 expect(roleNow("bob")).toBe("guest");
test/signup.test.ts +2 −2
@@ -103,7 +103,7 @@ describe("signup setting", () => {
103103 test("a non-admin is refused", async () => {
104104 const t = createToken(app.db, bob.id, "t", "admin").token;
105105 const res = await api(t, "PATCH", { signup: "open" });
106 expect(res.status).toBe(404);
106 expect(res.status).toBe(403);
107107 expect(getSignup(app.db)).toBe("closed");
108108 });
109109
@@ -133,7 +133,7 @@ describe("signup setting", () => {
133133 // Before: invite (set through MCP above).
134134 expect((await post(aliceCookie, "closed", "https://evil.example")).status).toBe(403);
135135 expect(getSignup(app.db)).toBe("invite");
136 expect((await post(bobCookie, "closed", servers.url)).status).toBe(404);
136 expect((await post(bobCookie, "closed", servers.url)).status).toBe(403);
137137 expect(getSignup(app.db)).toBe("invite");
138138 await post(aliceCookie, "nope", servers.url);
139139 expect(getSignup(app.db)).toBe("invite");
test/tui-releases.test.ts +25 −10
@@ -10,9 +10,10 @@ import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createRepo } from "../src/actions/repos.ts";
1212 import { createApp, type App } from "../src/app.ts";
13 import { setMember } from "../src/core/roles.ts";
1314 import { createUser, type User } from "../src/core/users.ts";
1415 import { releaseDir } from "../src/release/checkout.ts";
15 import { mayRollBack } from "../src/release/gate.ts";
16 import { mayRead, mayRollBack } from "../src/release/gate.ts";
1617 import { recordRelease } from "../src/release/record.ts";
1718 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1819 import type { Ctx } from "../src/tui/app/app.ts";
@@ -113,16 +114,11 @@ describe("the releases screen", () => {
113114 expect(text).toContain("healthy since");
114115 });
115116
116 test("a reader sees the releases but gets no rollback", async () => {
117 test("a non-admin reader of FORGE_REPO sees nothing and gets no rollback", async () => {
117118 app.db.run("UPDATE repo SET visibility='public' WHERE name='forge'");
118 expect(offered(carol)).toBe(true);
119 const lines = await readReleases(new Session(app, identity(carol)));
120 expect(lines.map((l) => l.release.sha.slice(0, 1))).toEqual(["b", "a"]);
121 expect(lines.every((l) => !l.rollable)).toBe(true);
122 // Enter on the kept release asks nothing.
123 const text = await open(carol, "j\r");
124 expect(text).not.toContain("Swap to");
125 expect(text).toContain("bbbbbbb");
119 expect(offered(carol)).toBe(false);
120 expect(await readReleases(new Session(app, identity(carol)))).toEqual([]);
121 expect(await open(carol, "j\r")).not.toContain("bbbbbbb");
126122 });
127123
128124 test("an agent is refused a rollback, whatever it can read", async () => {
@@ -142,4 +138,23 @@ describe("the releases screen", () => {
142138 const told = await open(alice, "\r\x1b[D\r");
143139 expect(told).toContain("FORGE_SELF_SWAP is off");
144140 });
141
142 test("a member who writes FORGE_REPO is refused releases and rollback (#2384), in the rule, the palette and the screen", async () => {
143 setMember(app.db, carol.id, true, alice.id, new Date());
144 app.db.query("UPDATE repo SET owner_id = ?1 WHERE name = 'forge'").run(carol.id);
145 app.config.forgeRepo = "carol/forge";
146 try {
147 expect(mayRead(app, carol)).toBe(false);
148 expect(mayRollBack(app, carol)).toBe(false);
149 expect(mayRead(app, alice) && mayRollBack(app, alice)).toBe(true);
150 expect(offered(carol)).toBe(false);
151 expect(await readReleases(new Session(app, identity(carol)))).toEqual([]);
152 const text = await open(carol, "\r");
153 expect(text).not.toContain("bbbbbbb");
154 expect(text).not.toContain("Swap to");
155 expect(offered(alice)).toBe(true);
156 } finally {
157 app.config.forgeRepo = "alice/forge";
158 }
159 });
145160 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs