Blame · Line-by-line history
private-repo-subpage-gate.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 8ed1b31 | 1 | /** |
| 2 | * Platform-wide private-repo gate for /:owner/:repo/... subpages. | |
| 3 | * | |
| 4 | * Every repo subpage owned its own privacy check and it drifted. A live probe | |
| 5 | * of a private repo on 2026-07-28 found 24 subpages returning 200 to an | |
| 6 | * anonymous visitor while the repo root correctly 404'd: | |
| 7 | * | |
| 8 | * /actions /archaeology /cloud-deployments /contributors /coupling | |
| 9 | * /dependencies /deployments /discussions /explain /gates /health | |
| 10 | * /insights /packages /previews /projects /pushes /queue /wiki | |
| 11 | * /insights/engineering /ai/changelog /ai/tests /docs/tracking | |
| 12 | * /search/nl /search/semantic | |
| 13 | * | |
| 14 | * These leaked branch and workflow names, CI logs, deployment history, | |
| 15 | * dependency manifests and wiki content for repositories the caller could not | |
| 16 | * open. | |
| 17 | * | |
| 18 | * The gate is registered once in app.tsx. What must not regress: it runs | |
| 19 | * before the repo routers, after softAuth, denies with 404 rather than 403, | |
| 20 | * and falls through for anything that is not a resolvable private repo — so | |
| 21 | * /settings/tokens, /orgs/new and the git Smart HTTP paths are untouched. | |
| 22 | */ | |
| 23 | ||
| 24 | import { describe, expect, it } from "bun:test"; | |
| 25 | import { readFileSync } from "fs"; | |
| 26 | ||
| 27 | const SRC = readFileSync("src/app.tsx", "utf8"); | |
| 28 | const gate = SRC.slice( | |
| 29 | SRC.indexOf('app.use("/:owner/:repo/*"'), | |
| 30 | SRC.indexOf('app.route("/", gitRoutes)') | |
| 31 | ); | |
| 32 | ||
| 33 | describe("gate placement", () => { | |
| 34 | it("is registered before every repo router", () => { | |
| 35 | const gateIdx = SRC.indexOf('app.use("/:owner/:repo/*"'); | |
| 36 | expect(gateIdx).toBeGreaterThan(-1); | |
| 37 | // gitRoutes is the first router mounted; webRoutes the last. | |
| 38 | expect(gateIdx).toBeLessThan(SRC.indexOf('app.route("/", gitRoutes)')); | |
| 39 | expect(gateIdx).toBeLessThan(SRC.indexOf('app.route("/", webRoutes)')); | |
| 40 | }); | |
| 41 | ||
| 42 | it("runs after softAuth so the viewer is resolved", () => { | |
| 43 | // Registered earlier and c.get("user") would always be undefined, which | |
| 44 | // would 404 private repos even for their own owner. | |
| 45 | expect(SRC.indexOf('app.use("*", softAuth)')).toBeLessThan( | |
| 46 | SRC.indexOf('app.use("/:owner/:repo/*"') | |
| 47 | ); | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("deny behaviour", () => { | |
| 52 | it("denies with 404, never 403", () => { | |
| 53 | expect(gate).toContain('access !== "none"'); | |
| 54 | expect(gate).toContain("404"); | |
| 55 | expect(gate).not.toMatch(/,\s*403\s*\)/); | |
| 56 | }); | |
| 57 | ||
| 58 | it("uses resolveRepoAccess so collaborators and org members still pass", () => { | |
| 59 | expect(gate).toContain("resolveRepoAccess"); | |
| 60 | // An ownerId equality check would lock out collaborators. | |
| 61 | expect(gate).not.toMatch(/user\.id\s*!==/); | |
| 62 | }); | |
| 63 | ||
| 64 | it("answers JSON callers with JSON", () => { | |
| 65 | expect(gate).toContain("application/json"); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("false positives fall through", () => { | |
| 70 | it("passes through when the repo does not resolve", () => { | |
| 71 | // /settings/tokens, /orgs/new, /:owner/:repo.git/* all resolve to null. | |
| 72 | expect(gate).toContain("if (!row || !row.isPrivate) return next()"); | |
| 73 | }); | |
| 74 | ||
| 75 | it("passes through public repos untouched", () => { | |
| 76 | // Same line: a non-private row short-circuits before any access lookup, | |
| 77 | // so public browsing costs nothing extra beyond the namespace read. | |
| 78 | expect(gate).toContain("!row.isPrivate"); | |
| 79 | }); | |
| 80 | ||
| 81 | it("fails OPEN on a namespace lookup error", () => { | |
| 82 | // A DB blip must not 404 the entire public site. The handler downstream | |
| 83 | // surfaces its own error instead. | |
| 84 | const cat = gate.slice(gate.indexOf("} catch {"), gate.indexOf("if (!row")); | |
| 85 | expect(cat).toContain("return next()"); | |
| 86 | }); | |
| 87 | }); |