Blame · Line-by-line history
package-access.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.
| 88f5bd1 | 1 | /** |
| 2 | * Regression guard: the package registry must not serve private packages. | |
| 3 | * | |
| 4 | * The bug this locks down: `GET /npm/...` resolved a package by name and | |
| 5 | * returned its packument and tarball with no authorization check whatsoever. | |
| 6 | * The platform-wide private-repo gate in app.tsx keys on the owner/repo path | |
| 7 | * shape, which the npm protocol routes do not have (they address packages by | |
| 8 | * package name alone) and which the JSON helper routes under the api prefix | |
| 9 | * also miss. So `npm install` returned the FULL SOURCE of a private | |
| 10 | * repository's package to an anonymous caller. | |
| 11 | * | |
| 12 | * Two layers are asserted here: | |
| 13 | * 1. The decision rule in lib/package-access.ts, driven through its | |
| 14 | * injectable-deps seam so no database is required. | |
| 15 | * 2. That the read routes actually still CALL that rule — a rule nothing | |
| 16 | * invokes is worth nothing, and the original bug was a missing call. | |
| 17 | * | |
| 18 | * Deliberately no `mock.module`: bun loads every test module before running | |
| 19 | * any test, and mock.module swaps the registry entry rather than rebinding | |
| 20 | * namespaces an importer already holds, so mocking `../db` here would leak | |
| 21 | * into unrelated files in a full run. | |
| 22 | */ | |
| 23 | ||
| 24 | import { describe, it, expect, afterAll, beforeEach } from "bun:test"; | |
| 25 | import { readFileSync } from "node:fs"; | |
| 26 | import { join } from "node:path"; | |
| 27 | import type { Package } from "../db/schema"; | |
| 28 | import type { RepoAccessLevel } from "../middleware/repo-access"; | |
| 29 | import { | |
| 30 | canReadPackage, | |
| 31 | canReadRepoPackages, | |
| 32 | isRepoMember, | |
| 33 | __setPackageAccessDepsForTests, | |
| 34 | } from "../lib/package-access"; | |
| 35 | ||
| 36 | const PUBLIC_REPO = { id: "repo-public", isPrivate: false }; | |
| 37 | const PRIVATE_REPO = { id: "repo-private", isPrivate: true }; | |
| 38 | ||
| 39 | const OWNER_ID = "user-owner"; | |
| 40 | const COLLAB_ID = "user-collab"; | |
| 41 | const STRANGER_ID = "user-stranger"; | |
| 42 | ||
| 43 | /** | |
| 44 | * Stand-in for the real resolver. Mirrors its contract: the owner and an | |
| 45 | * accepted collaborator get a level regardless of visibility; everyone else | |
| 46 | * falls back to "read" for public repos and "none" for private ones. | |
| 47 | */ | |
| 48 | async function fakeResolve(args: { | |
| 49 | repoId: string; | |
| 50 | userId: string | null; | |
| 51 | isPublic: boolean; | |
| 52 | }): Promise<RepoAccessLevel> { | |
| 53 | if (args.userId === OWNER_ID) return "owner"; | |
| 54 | if (args.userId === COLLAB_ID) return "read"; | |
| 55 | return args.isPublic ? "read" : "none"; | |
| 56 | } | |
| 57 | ||
| 58 | const REPOS_BY_ID: Record<string, { id: string; isPrivate: boolean }> = { | |
| 59 | [PUBLIC_REPO.id]: PUBLIC_REPO, | |
| 60 | [PRIVATE_REPO.id]: PRIVATE_REPO, | |
| 61 | }; | |
| 62 | ||
| 63 | beforeEach(() => { | |
| 64 | __setPackageAccessDepsForTests({ | |
| 65 | resolveRepoAccess: fakeResolve as any, | |
| 66 | loadRepoById: async (id: string) => REPOS_BY_ID[id] ?? null, | |
| 67 | }); | |
| 68 | }); | |
| 69 | ||
| 70 | afterAll(() => { | |
| 71 | __setPackageAccessDepsForTests(null); | |
| 72 | }); | |
| 73 | ||
| 74 | function pkg(overrides: Partial<Package>): Package { | |
| 75 | return { | |
| 76 | id: "pkg-1", | |
| 77 | repositoryId: PUBLIC_REPO.id, | |
| 78 | ecosystem: "npm", | |
| 79 | scope: null, | |
| 80 | name: "widget", | |
| 81 | visibility: "public", | |
| 82 | ...overrides, | |
| 83 | } as Package; | |
| 84 | } | |
| 85 | ||
| 86 | describe("package read access — repository is private", () => { | |
| 87 | it("denies an anonymous caller", async () => { | |
| 88 | expect(await canReadRepoPackages(PRIVATE_REPO, null)).toBe(false); | |
| 89 | }); | |
| 90 | ||
| 91 | it("denies a signed-in stranger", async () => { | |
| 92 | expect(await canReadRepoPackages(PRIVATE_REPO, STRANGER_ID)).toBe(false); | |
| 93 | }); | |
| 94 | ||
| 95 | it("allows the repository owner", async () => { | |
| 96 | expect(await canReadRepoPackages(PRIVATE_REPO, OWNER_ID)).toBe(true); | |
| 97 | }); | |
| 98 | ||
| 99 | it("allows an accepted collaborator", async () => { | |
| 100 | expect(await canReadRepoPackages(PRIVATE_REPO, COLLAB_ID)).toBe(true); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("package read access — repository is public", () => { | |
| 105 | it("allows an anonymous caller", async () => { | |
| 106 | expect(await canReadRepoPackages(PUBLIC_REPO, null)).toBe(true); | |
| 107 | }); | |
| 108 | ||
| 109 | it("still denies a package explicitly marked private", async () => { | |
| 110 | // visibility is stamped at publish time and never rewritten, so a | |
| 111 | // package marked private must stay private even in a public repo. | |
| 112 | expect(await canReadRepoPackages(PUBLIC_REPO, null, "private")).toBe( | |
| 113 | false | |
| 114 | ); | |
| 115 | expect(await canReadRepoPackages(PUBLIC_REPO, STRANGER_ID, "private")).toBe( | |
| 116 | false | |
| 117 | ); | |
| 118 | }); | |
| 119 | ||
| 120 | it("allows members to see an explicitly private package", async () => { | |
| 121 | expect(await canReadRepoPackages(PUBLIC_REPO, OWNER_ID, "private")).toBe( | |
| 122 | true | |
| 123 | ); | |
| 124 | expect(await canReadRepoPackages(PUBLIC_REPO, COLLAB_ID, "private")).toBe( | |
| 125 | true | |
| 126 | ); | |
| 127 | }); | |
| 128 | }); | |
| 129 | ||
| 130 | describe("canReadPackage resolves the owning repository live", () => { | |
| 131 | it("denies an anonymous caller a package in a private repo", async () => { | |
| 132 | // The package row still says "public" because it was published while the | |
| 133 | // repo was public; the LIVE repo row is what must decide. | |
| 134 | const stale = pkg({ repositoryId: PRIVATE_REPO.id, visibility: "public" }); | |
| 135 | expect(await canReadPackage(stale, null)).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | it("allows the owner the same package", async () => { | |
| 139 | const stale = pkg({ repositoryId: PRIVATE_REPO.id, visibility: "public" }); | |
| 140 | expect(await canReadPackage(stale, OWNER_ID)).toBe(true); | |
| 141 | }); | |
| 142 | ||
| 143 | it("allows an anonymous caller a public package in a public repo", async () => { | |
| 144 | expect(await canReadPackage(pkg({}), null)).toBe(true); | |
| 145 | }); | |
| 146 | ||
| 147 | it("denies when the owning repository no longer exists", async () => { | |
| 148 | const dangling = pkg({ repositoryId: "repo-deleted" }); | |
| 149 | expect(await canReadPackage(dangling, OWNER_ID)).toBe(false); | |
| 150 | }); | |
| 151 | }); | |
| 152 | ||
| 153 | describe("isRepoMember", () => { | |
| 154 | it("is false for anonymous even on a public repo", async () => { | |
| 155 | // Membership is not the same as readability — a public reader is not a | |
| 156 | // member, which is what decides visibility of private packages. | |
| 157 | expect(await isRepoMember(PUBLIC_REPO.id, null)).toBe(false); | |
| 158 | }); | |
| 159 | ||
| 160 | it("is false for a signed-in stranger", async () => { | |
| 161 | expect(await isRepoMember(PUBLIC_REPO.id, STRANGER_ID)).toBe(false); | |
| 162 | }); | |
| 163 | ||
| 164 | it("is true for the owner and for a collaborator", async () => { | |
| 165 | expect(await isRepoMember(PUBLIC_REPO.id, OWNER_ID)).toBe(true); | |
| 166 | expect(await isRepoMember(PUBLIC_REPO.id, COLLAB_ID)).toBe(true); | |
| 167 | }); | |
| 168 | }); | |
| 169 | ||
| 170 | // --- call sites stay wired ------------------------------------------------- | |
| 171 | // | |
| 172 | // Strip ONLY line comments before asserting. A block-comment regex eats route | |
| 173 | // paths (they contain a slash followed by a star), and these files are full | |
| 174 | // of them. | |
| 175 | ||
| 176 | function sourceWithoutLineComments(relPath: string): string { | |
| 177 | const text = readFileSync(join(import.meta.dir, "..", relPath), "utf8"); | |
| 178 | const stripped = text | |
| 179 | .split("\n") | |
| 180 | .filter((l) => !l.trim().startsWith("//")) | |
| 181 | .join("\n"); | |
| 182 | expect(stripped.length).toBeGreaterThan(0); | |
| 183 | return stripped; | |
| 184 | } | |
| 185 | ||
| 186 | /** Slice by anchor, never by character count — handlers move. */ | |
| 187 | function handlerBody(src: string, startAnchor: string, endAnchor: string) { | |
| 188 | const start = src.indexOf(startAnchor); | |
| 189 | expect(start).toBeGreaterThan(-1); | |
| 190 | const end = src.indexOf(endAnchor, start + startAnchor.length); | |
| 191 | expect(end).toBeGreaterThan(start); | |
| 192 | const body = src.slice(start, end); | |
| 193 | expect(body.length).toBeGreaterThan(0); | |
| 194 | return body; | |
| 195 | } | |
| 196 | ||
| 197 | describe("registry read routes invoke the access gate", () => { | |
| 198 | it("GET /npm/ checks canReadPackage before serving", async () => { | |
| 199 | const src = sourceWithoutLineComments("routes/packages-api.ts"); | |
| 200 | const body = handlerBody(src, `api.get("/npm/*"`, `api.put("/npm/*"`); | |
| 201 | expect(body).toContain("canReadPackage"); | |
| 202 | // The tarball branch lives inside this handler, so gating the handler | |
| 203 | // gates the tarball too — assert the gate precedes the tarball read. | |
| 204 | const gateAt = body.indexOf("canReadPackage"); | |
| 205 | const tarballAt = body.indexOf("match.tarball"); | |
| 206 | expect(tarballAt).toBeGreaterThan(gateAt); | |
| 207 | }); | |
| 208 | ||
| 209 | it("the JSON package routes check the gate", async () => { | |
| 210 | const src = sourceWithoutLineComments("routes/packages-api.ts"); | |
| 211 | const listBody = handlerBody( | |
| 212 | src, | |
| 213 | `api.get("/api/packages/:owner/:repo"`, | |
| 214 | `api.get("/api/packages/:owner/:repo/:pkgName` | |
| 215 | ); | |
| 216 | expect(listBody).toContain("canReadRepoPackages"); | |
| 217 | expect(listBody).toContain("isRepoMember"); | |
| 218 | ||
| 219 | const detailBody = handlerBody( | |
| 220 | src, | |
| 221 | `api.get("/api/packages/:owner/:repo/:pkgName`, | |
| 222 | `api.get("/npm/*"` | |
| 223 | ); | |
| 224 | expect(detailBody).toContain("canReadRepoPackages"); | |
| 225 | }); | |
| 226 | ||
| 227 | it("the package UI routes filter private packages for non-members", async () => { | |
| 228 | const src = sourceWithoutLineComments("routes/packages.tsx"); | |
| 229 | const listBody = handlerBody( | |
| 230 | src, | |
| 231 | `ui.get("/:owner/:repo/packages"`, | |
| 232 | `ui.get("/:owner/:repo/packages/:pkgName` | |
| 233 | ); | |
| 234 | expect(listBody).toContain("isRepoMember"); | |
| 235 | // The rendered rows must come from the filtered list, not the raw query. | |
| 236 | expect(listBody).toContain("visiblePkgs.map"); | |
| 237 | expect(listBody).not.toContain("rows = pkgs.map"); | |
| 238 | ||
| 239 | const detailBody = handlerBody( | |
| 240 | src, | |
| 241 | `ui.get("/:owner/:repo/packages/:pkgName`, | |
| 242 | "export default" | |
| 243 | ); | |
| 244 | expect(detailBody).toContain("isRepoMember"); | |
| 245 | }); | |
| 246 | }); |