Public: anyone can read this; the crew can change it.
A private repository is absent from every public door, proven by one sweep #12413
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12405-a-private-repository-is-absent-from-ever → main 2 commits fork 3 threads · 3 unresolved
1 file
changed +286
−0
test/private-sweep.test.ts added +286
| @@ -0,0 +1,286 @@ | ||
| 1 | /** | |
| 2 | * #12405: a private repository is absent from every public door. One private | |
| 3 | * repository carries a distinctive word in its name, description, issue and | |
| 4 | * commit message; every door in `DOORS` is fetched as a stranger and as a | |
| 5 | * signed-in person who is not a member, and the word is in none of the bodies | |
| 6 | * or headers. A door that knows who is asking (`roleAware`) shows it to the | |
| 7 | * owner. A new public door is one more line in `DOORS`. | |
| 8 | * | |
| 9 | * The doors that list repositories page on with a cursor; those pages are | |
| 10 | * followed to the end, so a private row cannot hide on page two. | |
| 11 | * | |
| 12 | * A private repository has no per-person collaborator here: a person who is | |
| 13 | * not the owner reads one through a team of the org that owns it (`canRead` | |
| 14 | * in `core/repos.ts`). So the member is carol, on a read team of `acme`, | |
| 15 | * which owns a second private repository (`TEAM`); she sees it where the | |
| 16 | * door knows who is asking, and bob and a stranger never do. | |
| 17 | */ | |
| 18 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 19 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 20 | import { tmpdir } from "node:os"; | |
| 21 | import { join } from "node:path"; | |
| 22 | import { createApp, type App } from "../src/app.ts"; | |
| 23 | import { openIssue } from "../src/actions/issues.ts"; | |
| 24 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 25 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 26 | import { createRepo as createCoreRepo, getRepo } from "../src/core/repos.ts"; | |
| 27 | import { createSession } from "../src/core/sessions.ts"; | |
| 28 | import { createToken } from "../src/core/tokens.ts"; | |
| 29 | import { createUser, type User } from "../src/core/users.ts"; | |
| 30 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 31 | import { promote } from "./helpers/members.ts"; | |
| 32 | ||
| 33 | /** Lower-case, so a door that lower-cases or slugs it is still caught. */ | |
| 34 | const NAME = "zqxsecretvault"; | |
| 35 | const DESCRIPTION = "zqxdescword"; | |
| 36 | const ISSUE = "zqxissueword"; | |
| 37 | const COMMIT = "zqxcommitword"; | |
| 38 | /** The org's private repository, read by its team and nobody else. */ | |
| 39 | const TEAM = "zqxteamvault"; | |
| 40 | const WORDS = [NAME, DESCRIPTION, ISSUE, COMMIT, TEAM]; | |
| 41 | /** A word nothing ever had, one per word: the control a door asked by a word is held to. */ | |
| 42 | const never = (word: string) => `zqxneverwas${WORDS.indexOf(word)}`; | |
| 43 | ||
| 44 | interface Door { | |
| 45 | readonly path: string; | |
| 46 | /** POST, with this JSON body; a GET when absent. */ | |
| 47 | readonly post?: unknown; | |
| 48 | /** Signed in by a bearer token rather than a cookie (MCP takes no cookie). */ | |
| 49 | readonly bearer?: boolean; | |
| 50 | /** The owner sees the private repository's name here. */ | |
| 51 | readonly roleAware?: boolean; | |
| 52 | } | |
| 53 | ||
| 54 | const rpc = (method: string, params?: unknown) => ({ jsonrpc: "2.0", id: 1, method, ...(params === undefined ? {} : { params }) }); | |
| 55 | const tool = (name: string, args: Record<string, unknown>) => rpc("tools/call", { name, arguments: args }); | |
| 56 | ||
| 57 | /** Every public door, in one place: add a new one here. */ | |
| 58 | const DOORS: readonly Door[] = [ | |
| 59 | { path: "/explore" }, | |
| 60 | { path: "/explore.atom" }, | |
| 61 | { path: `/explore?q=${NAME}` }, | |
| 62 | { path: "/explore?owner=alice" }, | |
| 63 | { path: "/explore?sort=new" }, | |
| 64 | { path: "/explore.atom?owner=alice" }, | |
| 65 | { path: "/api/v1/explore" }, | |
| 66 | { path: "/api/v1/explore?owner=alice" }, | |
| 67 | { path: "/api/v1/explore?sort=new" }, | |
| 68 | { path: `/api/v1/search?q=${NAME}`, roleAware: true }, | |
| 69 | { path: `/api/v1/search?q=${NAME}&kind=repos`, roleAware: true }, | |
| 70 | { path: `/api/v1/search?q=${ISSUE}&kind=issues` }, | |
| 71 | { path: `/search?q=${NAME}`, roleAware: true }, | |
| 72 | { path: `/search?q=${ISSUE}&kind=issues` }, | |
| 73 | { path: "/api/v1/repos", roleAware: true }, | |
outdated
|
||
| 74 | { path: "/api/v1/users/alice" }, | |
| 75 | { path: "/api/v1/users/alice/repos", roleAware: true }, | |
| 76 | { path: "/api/v1/users/acme/repos" }, | |
| 77 | { path: "/api/v1/repos/alice/open/issues" }, | |
| 78 | { path: `/api/v1/repos/alice/${NAME}/issues` }, | |
| 79 | { path: "/mcp", post: rpc("tools/list") }, | |
| 80 | { path: "/mcp", post: rpc("tools/list"), bearer: true }, | |
| 81 | { path: "/mcp", post: tool("explore", {}), bearer: true }, | |
| 82 | { path: "/mcp", post: tool("explore", { owner: "alice" }), bearer: true }, | |
| 83 | { path: "/mcp", post: tool("search", { q: NAME }), bearer: true, roleAware: true }, | |
| 84 | { path: "/mcp", post: tool("search", { q: ISSUE, kind: "issues" }), bearer: true }, | |
| 85 | { path: "/mcp", post: tool("issues", { repo: `alice/${NAME}` }), bearer: true }, | |
| 86 | { path: "/sitemap.xml" }, | |
| 87 | { path: "/robots.txt" }, | |
| 88 | { path: "/llms.txt" }, | |
| 89 | { path: "/.well-known/agent-card.json" }, | |
| 90 | { path: "/.well-known/mcp.json" }, | |
| 91 | { path: "/.well-known/api-catalog" }, | |
| 92 | { path: "/.well-known/forge" }, | |
| 93 | { path: "/feed" }, | |
| 94 | { path: "/api/v1/feed" }, | |
| 95 | { path: "/changelog.atom" }, | |
| 96 | { path: "/alice" }, | |
| 97 | { path: "/acme" }, | |
| 98 | { path: "/alice/open/issues" }, | |
| 99 | { path: "/alice/open/forks" }, | |
| 100 | { path: "/alice/open/activity.atom" }, | |
| 101 | { path: `/alice/${NAME}/activity.atom` }, | |
| 102 | { path: "/og/site.svg" }, | |
| 103 | { path: `/og/repo/alice~${NAME}.svg` }, | |
| 104 | { path: `/og/issue/alice~${NAME}~1.svg` }, | |
| 105 | { path: `/alice/${NAME}` }, | |
| 106 | { path: `/alice/${NAME}/issues` }, | |
| 107 | { path: `/alice/${NAME}/issues/1` }, | |
| 108 | { path: `/acme/${TEAM}` }, | |
| 109 | { path: `/api/v1/repos/alice/${NAME}` }, | |
| 110 | { path: `/api/v1/repos/acme/${TEAM}` }, | |
| 111 | ]; | |
| 112 | ||
| 113 | /** The doors that page on with `next_cursor`, at one row a page where they take a limit. */ | |
| 114 | const PAGED = ["/api/v1/repos?limit=1", "/api/v1/users/alice/repos?limit=1", "/api/v1/explore"]; | |
| 115 | ||
| 116 | /** Where carol, on acme's read team, sees the org's private repository. */ | |
| 117 | const MEMBER_DOORS: readonly Door[] = [ | |
| 118 | { path: "/api/v1/repos" }, | |
| 119 | { path: "/api/v1/users/acme/repos" }, | |
| 120 | { path: `/api/v1/search?q=${TEAM}` }, | |
| 121 | { path: `/search?q=${TEAM}` }, | |
| 122 | { path: `/acme/${TEAM}` }, | |
| 123 | { path: `/api/v1/repos/acme/${TEAM}` }, | |
| 124 | { path: "/mcp", post: tool("search", { q: TEAM }), bearer: true }, | |
| 125 | ]; | |
| 126 | ||
| 127 | let dataDir: string; | |
| 128 | let app: App; | |
| 129 | let servers: Servers; | |
| 130 | let alice: User; | |
| 131 | let bob: User; | |
| 132 | let carol: User; | |
| 133 | ||
| 134 | interface Visitor { | |
outdated
Only the owner is tried as a member. Add a collaborator who is not the owner, or document why that is out of scope. |
||
| 135 | readonly cookie: string; | |
| 136 | readonly token: string; | |
| 137 | } | |
| 138 | ||
| 139 | /** Status, headers and body together (lower-cased), and the body alone. */ | |
| 140 | async function body(door: Door, who?: Visitor): Promise<{ status: number; text: string; content: string }> { | |
| 141 | const headers: Record<string, string> = {}; | |
| 142 | if (who !== undefined) { | |
| 143 | if (door.bearer) headers["authorization"] = `Bearer ${who.token}`; | |
| 144 | else headers["cookie"] = who.cookie; | |
| 145 | } | |
| 146 | if (door.post !== undefined) { | |
| 147 | headers["content-type"] = "application/json"; | |
| 148 | headers["accept"] = "application/json, text/event-stream"; | |
| 149 | } | |
| 150 | const res = await fetch(`${servers.url}${door.path}`, { | |
| 151 | method: door.post === undefined ? "GET" : "POST", | |
| 152 | headers, | |
| 153 | redirect: "manual", | |
| 154 | ...(door.post === undefined ? {} : { body: JSON.stringify(door.post) }), | |
| 155 | }); | |
| 156 | const head = [...res.headers].map(([k, v]) => `${k}: ${v}`).join("\n"); | |
| 157 | const content = (await res.text()).toLowerCase(); | |
| 158 | return { status: res.status, text: `${head.toLowerCase()}\n\n${content}`, content }; | |
| 159 | } | |
| 160 | ||
| 161 | const label = (door: Door) => (door.post === undefined ? door.path : `${door.path} ${JSON.stringify(door.post)}`); | |
| 162 | const visitor = (u: User): Visitor => ({ | |
| 163 | cookie: `forge_session=${createSession(app.db, u.id).token}`, | |
| 164 | token: createToken(app.db, u.id, "sweep", "rw").token, | |
| 165 | }); | |
| 166 | ||
| 167 | /** The door with every word it was asked by swapped for a word nothing ever had. */ | |
| 168 | function neverDoor(door: Door, asked: readonly string[]): Door { | |
| 169 | const swap = (s: string) => asked.reduce((t, w) => t.replaceAll(w, never(w)), s); | |
| 170 | return { | |
| 171 | ...door, | |
| 172 | path: swap(door.path), | |
| 173 | ...(door.post === undefined ? {} : { post: JSON.parse(swap(JSON.stringify(door.post))) }), | |
| 174 | }; | |
| 175 | } | |
| 176 | ||
| 177 | beforeAll(async () => { | |
| 178 | dataDir = await mkdtemp(join(tmpdir(), "forge-private-sweep-")); | |
| 179 | app = createApp({ dataDir, port: 0 }); | |
| 180 | servers = await serve(app); | |
| 181 | const made: User[] = []; | |
| 182 | for (const username of ["alice", "bob", "carol"]) { | |
| 183 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" }); | |
| 184 | if (!u.ok) throw new Error(u.error); | |
| 185 | made.push(u.value); | |
| 186 | } | |
| 187 | [alice, bob, carol] = made as [User, User, User]; | |
| 188 | promote(app.db, alice, bob, carol); | |
| 189 | ||
| 190 | const open = await createRepo(app, alice, { name: "open", visibility: "public", description: "A public neighbour" }); | |
| 191 | if (!open.ok) throw new Error(String(open.error)); | |
| 192 | const o = await commitToBranch(app, alice, getRepo(app.db, "alice", "open")!, { message: "Start open", files: [{ path: "README.md", content: "# Open\n" }] }); | |
| 193 | if (!o.ok) throw new Error(String(o.error)); | |
| 194 | ||
| 195 | const r = await createRepo(app, alice, { name: NAME, visibility: "private", description: `The ${DESCRIPTION} plans` }); | |
| 196 | if (!r.ok) throw new Error(String(r.error)); | |
| 197 | const repo = getRepo(app.db, "alice", NAME)!; | |
| 198 | const c = await commitToBranch(app, alice, repo, { message: `Add the ${COMMIT}`, files: [{ path: "README.md", content: `# ${NAME}\n` }] }); | |
| 199 | if (!c.ok) throw new Error(String(c.error)); | |
| 200 | const i = openIssue(app, alice, repo, { title: `Fix the ${ISSUE}`, body: `About ${ISSUE}` }); | |
| 201 | if (!i.ok) throw new Error(String(i.error)); | |
| 202 | ||
| 203 | const org = createOrg(app.db, "acme", alice.id); | |
| 204 | if (!org.ok) throw new Error(org.error); | |
| 205 | const readers = createTeam(app.db, org.value.id, "Readers", "read"); | |
| 206 | if (!readers.ok) throw new Error(readers.error); | |
| 207 | addTeamMember(app.db, readers.value, carol.id); | |
| 208 | const t = await createCoreRepo(app.db, app.reposRoot, { ownerId: org.value.id, name: TEAM, visibility: "private" }); | |
| 209 | if (!t.ok) throw new Error(String(t.error)); | |
| 210 | }); | |
| 211 | ||
| 212 | afterAll(async () => { | |
| 213 | servers.stop(); | |
| 214 | app.close(); | |
| 215 | await rm(dataDir, { recursive: true, force: true }); | |
| 216 | }); | |
| 217 | ||
| 218 | for (const who of ["a stranger", "a signed-in non-member"] as const) { | |
| 219 | test(`no public door shows the private repository to ${who}`, async () => { | |
| 220 | const as = who === "a stranger" ? undefined : visitor(bob); | |
| 221 | const leaks: string[] = []; | |
| 222 | for (const door of DOORS) { | |
| 223 | const { status, text, content } = await body(door, as); | |
| 224 | if (status >= 500) leaks.push(`${label(door)} answers ${status}`); | |
| 225 | // A door asked by a word may echo the word it was asked; it must answer | |
| 226 | // as it does for a word that never existed, and show nothing else. | |
| 227 | const asked = WORDS.filter((w) => label(door).includes(w)); | |
| 228 | for (const word of WORDS.filter((w) => !asked.includes(w))) | |
| 229 | if (text.includes(word)) leaks.push(`${label(door)} (${status}) shows ${word}`); | |
| 230 | if (asked.length > 0) { | |
| 231 | const other = await body(neverDoor(door, asked), as); | |
| 232 | if (other.status !== status) leaks.push(`${label(door)} answers ${status}, a word that never was ${other.status}`); | |
| 233 | const back = asked.reduce((t, w) => t.replaceAll(never(w), w), other.content); | |
| 234 | if (back !== content) leaks.push(`${label(door)} answers differently from a word that never was`); | |
| 235 | } | |
| 236 | } | |
| 237 | expect(leaks).toEqual([]); | |
| 238 | }); | |
| 239 | ||
| 240 | test(`every page of a paged list leaves the private repositories out for ${who}`, async () => { | |
| 241 | const as = who === "a stranger" ? undefined : visitor(bob); | |
| 242 | const leaks: string[] = []; | |
| 243 | for (const first of PAGED) { | |
| 244 | let path: string | undefined = first; | |
| 245 | let pages = 0; | |
| 246 | while (path !== undefined && pages < 20) { | |
| 247 | const { status, text, content } = await body({ path }, as); | |
| 248 | expect({ path, status }).toEqual({ path, status: 200 }); | |
| 249 | for (const word of WORDS) if (text.includes(word)) leaks.push(`${path} shows ${word}`); | |
| 250 | const next = (JSON.parse(content) as { next_cursor?: string | null }).next_cursor; | |
| 251 | path = next ? `${first}${first.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(next)}` : undefined; | |
| 252 | pages++; | |
| 253 | } | |
| 254 | } | |
| 255 | expect(leaks).toEqual([]); | |
| 256 | }); | |
| 257 | } | |
| 258 | ||
| 259 | test("the owner sees the private repository where the door knows who is asking", async () => { | |
| 260 | const as = visitor(alice); | |
| 261 | const roleAware = DOORS.filter((d) => d.roleAware); | |
| 262 | expect(roleAware.length).toBeGreaterThan(0); | |
| 263 | for (const door of roleAware) { | |
| 264 | const { status, text } = await body(door, as); | |
| 265 | expect({ door: label(door), status, seen: text.includes(NAME) }).toEqual({ door: label(door), status: 200, seen: true }); | |
| 266 | } | |
| 267 | }); | |
| 268 | ||
| 269 | test("the owner reaches the private repository's own pages", async () => { | |
| 270 | const as = visitor(alice); | |
| 271 | for (const path of [`/alice/${NAME}`, `/api/v1/repos/alice/${NAME}`]) { | |
| 272 | const { status, text } = await body({ path }, as); | |
| 273 | expect({ path, status, seen: text.includes(NAME) }).toEqual({ path, status: 200, seen: true }); | |
| 274 | } | |
| 275 | }); | |
| 276 | ||
| 277 | test("a member who is not the owner sees the org's private repository where the door knows who is asking", async () => { | |
| 278 | const as = visitor(carol); | |
| 279 | for (const door of MEMBER_DOORS) { | |
| 280 | const { status, text } = await body(door, as); | |
| 281 | expect({ door: label(door), status, seen: text.includes(TEAM) }).toEqual({ door: label(door), status: 200, seen: true }); | |
| 282 | } | |
| 283 | // and not alice's own private repository, which no team of hers reads | |
| 284 | const { text } = await body({ path: "/api/v1/repos" }, as); | |
| 285 | expect(text.includes(NAME)).toBe(false); | |
| 286 | }); |
new above
offline
DOORS is missing
/api/v1/repos,/api/v1/users/alice,/api/v1/users/alice/reposand/mcp(tools/list and a repo list or search tool). Add them here, with roleAware on the listing doors.