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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6853-a-table-test-walks-every-door-a-crew-run → main 2 commits fork
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 | }); |
new above
offline