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

A table test walks every door a crew-run token must not open and finds each a 404 #6865

Merged opened this

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

Merged

scout/forge:scout/6853-a-table-test-walks-every-door-a-crew-run → main 2 commits fork

  • Merged. scout/6853-a-table-test-walks-every-door-a-crew-run was merged into main as f8db2ec.

Review

Sign in to review this change.
1 file changed +173 −0
test/run-gate-doors.test.ts added +173
@@ -0,0 +1,173 @@
1 /**
2 * The closed side of the run gate as one table: every door a crew-run token
3 * must not open answers 404 `not_found`, the same as a repository it cannot
4 * see, and the parent repository is read-only (#6853).
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 { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { mintRunToken } from "../src/core/run_token.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const EXPIRES = "2999-01-01T00:00:00.000Z";
23
24 describe("the run gate's closed doors", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let alice: User;
29 let parent: Repo;
30 let fork: Repo;
31 let run: string;
32 let personal: string;
33
34 const call = (path: string, method = "GET", token = run, body?: unknown) =>
35 fetch(`${servers.url}${path}`, {
36 method,
37 redirect: "manual",
38 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
39 body: method === "GET" || method === "HEAD" ? undefined : JSON.stringify(body ?? {}),
40 });
41
42 const make = async (name: string, visibility: "public" | "private") => {
43 const made = await createRepo(app, alice, { name, visibility });
44 if (!made.ok) throw new Error(String(made.error));
45 const seeded = await commitToBranch(app, alice, made.value, {
46 branch: "main",
47 message: "Start",
48 files: [{ path: "README.md", content: `${name} unique-${name}\n` }],
49 });
50 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
51 return made.value;
52 };
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-run-doors-"));
56 app = createApp({ dataDir, port: 0, host: "forge.test" });
57 servers = await serve(app);
58 const made = await createUser(app.db, {
59 origin: "seed",
60 trusted: true,
61 username: "alice",
62 email: "[email protected]",
63 password: "a-good-passphrase",
64 adminIfFirst: true,
65 });
66 if (!made.ok) throw new Error(made.error);
67 alice = made.value;
68 parent = await make("upstream", "public");
69 await make("vault", "private");
70 await make("mine", "public");
71 const forked = await forkRepo(app, alice, parent, "upstream-work");
72 if (!forked.ok) throw new Error(String(forked.error));
73 fork = forked.value;
74 personal = createToken(app.db, alice.id, "personal", "rw").token;
75 // A run on the fork: its parent is `upstream`, read-only.
76 run = mintRunToken(app.db, alice.id, "doors", { repoId: fork.id, forkId: fork.id }, EXPIRES).token;
77 });
78
79 afterAll(async () => {
80 await servers.stop();
81 await rm(dataDir, { recursive: true, force: true });
82 });
83
84 const closed: [string, string][] = [
85 ["GET", "/api/v1/user"],
86 ["GET", "/api/v1/user/keys"],
87 ["GET", "/api/v1/tokens"],
88 ["POST", "/api/v1/tokens"],
89 ["DELETE", "/api/v1/tokens/1"],
90 ["GET", "/api/v1/secrets/alice"],
91 ["PUT", "/api/v1/secrets/alice/KEY"],
92 ["DELETE", "/api/v1/secrets/alice/KEY"],
93 ["GET", "/api/v1/admin/settings"],
94 ["GET", "/api/v1/admin/accounts"],
95 ["POST", "/api/v1/admin/accounts/alice/suspend"],
96 ["GET", "/api/v1/admin/limits"],
97 ["POST", "/mcp"],
98 ["GET", "/mcp"],
99 ["GET", "/"],
100 ["GET", "/explore"],
101 ["GET", "/alice/upstream"],
102 ["GET", "/alice/vault"],
103 ["GET", "/api/v1/repos/alice/vault"],
104 ["GET", "/api/v1/repos/alice/vault/issues"],
105 ["POST", "/api/v1/repos/alice/vault/issues"],
106 ["GET", "/api/v1/repos/alice/mine"],
107 ["POST", "/api/v1/repos/alice/mine/issues"],
108 ["GET", "/api/v1/repos/alice/nobody-here"],
109 ["POST", "/api/v1/repos"],
110 ];
111
112 test("each closed door answers 404 not_found, as an unseen repository does", async () => {
113 const unseen = await (await call("/api/v1/repos/alice/nobody-here")).text();
114 expect(JSON.parse(unseen).error).toBe("not_found");
115 for (const [method, path] of closed) {
116 const res = await call(path, method);
117 const body = await res.text();
118 expect([method, path, res.status]).toEqual([method, path, 404]);
119 expect([method, path, JSON.parse(body).error]).toEqual([method, path, "not_found"]);
120 }
121 // The doors exist for the crew member's own token.
122 expect((await call("/api/v1/user", "GET", personal)).status).toBe(200);
123 expect((await call("/api/v1/repos/alice/vault", "GET", personal)).status).toBe(200);
124 });
125
126 test("the parent reads; writing under it is 404 and changes nothing", async () => {
127 expect((await call("/api/v1/repos/alice/upstream")).status).toBe(200);
128 expect((await call("/api/v1/repos/alice/upstream/issues")).status).toBe(200);
129 const base = "/api/v1/repos/alice/upstream";
130 const writes: [string, string, unknown][] = [
131 ["POST", `${base}/issues`, { title: "from the run" }],
132 ["PATCH", `${base}/issues/1`, { title: "renamed by the run" }],
133 ["POST", `${base}/issues/1/comments`, { body: "from the run" }],
134 ["PATCH", base, { description: "changed by the run" }],
135 ["DELETE", base, undefined],
136 ];
137 // The doors are real routes: the crew member's own token opens them.
138 const made = await call(`${base}/issues`, "POST", personal, { title: "Seed" });
139 expect(made.status).toBe(201);
140 const state = () => [
141 app.db.query("SELECT COUNT(*) AS n, MAX(title) AS t FROM issue").get(),
142 app.db.query("SELECT COUNT(*) AS n FROM comment").get(),
143 app.db.query("SELECT COUNT(*) AS n, MAX(description) AS d FROM repo").get(),
144 ];
145 const before = state();
146 for (const [method, path, body] of writes) {
147 const res = await call(path, method, run, body);
148 expect([method, path, res.status, ((await res.json()) as { error: string }).error]).toEqual([method, path, 404, "not_found"]);
149 }
150 expect(state()).toEqual(before);
151 expect((await call(`${base}/issues/1`, "PATCH", personal, { title: "Renamed" })).status).toBe(200);
152 expect((await call(`${base}/issues/1/comments`, "POST", personal, { body: "noted" })).status).toBe(201);
153 // The repository itself answers its owner, not 404 (what it says to a change is its own business).
154 expect((await call(base, "PATCH", personal, { description: "Changed" })).status).not.toBe(404);
155 expect(state()).not.toEqual(before);
156 expect((await call(base)).status).toBe(200);
157 });
158
159 test("here, search, explore and openapi.json answer, and search names nothing outside the two repositories", async () => {
160 for (const path of ["/api/v1/here?path=/alice/upstream-work", "/api/v1/search?q=upstream", "/api/v1/explore"])
161 expect((await call(path)).status).toBe(200);
162 expect((await call("/api/v1/openapi.json")).status).toBe(308);
163 for (const q of ["vault", "mine", "unique-vault", "unique-mine"]) {
164 const res = await call(`/api/v1/search?q=${q}`);
165 expect(res.status).toBe(200);
166 const body = await res.text();
167 expect(body).not.toContain("vault");
168 expect(body).not.toContain("alice/mine");
169 }
170 const hit = await (await call("/api/v1/search?q=unique-upstream")).text();
171 expect(hit).toContain("upstream");
172 });
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs