Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

package-access.test.tsBlame246 lines · 1 contributor
88f5bd1ccantynz-alt1/**
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
24import { describe, it, expect, afterAll, beforeEach } from "bun:test";
25import { readFileSync } from "node:fs";
26import { join } from "node:path";
27import type { Package } from "../db/schema";
28import type { RepoAccessLevel } from "../middleware/repo-access";
29import {
30 canReadPackage,
31 canReadRepoPackages,
32 isRepoMember,
33 __setPackageAccessDepsForTests,
34} from "../lib/package-access";
35
36const PUBLIC_REPO = { id: "repo-public", isPrivate: false };
37const PRIVATE_REPO = { id: "repo-private", isPrivate: true };
38
39const OWNER_ID = "user-owner";
40const COLLAB_ID = "user-collab";
41const 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 */
48async 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
58const REPOS_BY_ID: Record<string, { id: string; isPrivate: boolean }> = {
59 [PUBLIC_REPO.id]: PUBLIC_REPO,
60 [PRIVATE_REPO.id]: PRIVATE_REPO,
61};
62
63beforeEach(() => {
64 __setPackageAccessDepsForTests({
65 resolveRepoAccess: fakeResolve as any,
66 loadRepoById: async (id: string) => REPOS_BY_ID[id] ?? null,
67 });
68});
69
70afterAll(() => {
71 __setPackageAccessDepsForTests(null);
72});
73
74function 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
86describe("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
104describe("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
130describe("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
153describe("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
176function 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. */
187function 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
197describe("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});