Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

packages.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.

packages.test.tsBlame297 lines · 1 contributor
25a91a6Claude1/**
2 * Tests for Block C2 — npm-compatible package registry.
3 *
4 * Covers the pure helpers in `src/lib/packages.ts` and a handful of
5 * route-level behaviour guarantees (401 without auth, 404 for unknown
6 * packages). The integration paths — actual publish → install cycles —
7 * are exercised by higher-level tests once a real test DB is wired.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import {
13 parsePackageName,
14 parseRepoUrl,
15 computeShasum,
16 computeIntegrity,
17 buildPackument,
18 resolveRepoFromPackageJson,
19 tarballFilename,
20} from "../lib/packages";
21
22describe("parsePackageName", () => {
23 it("parses a plain name", () => {
24 const r = parsePackageName("left-pad");
25 expect(r).not.toBeNull();
26 expect(r!.scope).toBeNull();
27 expect(r!.name).toBe("left-pad");
28 expect(r!.full).toBe("left-pad");
29 });
30
31 it("parses a scoped name", () => {
32 const r = parsePackageName("@acme/widgets");
33 expect(r).not.toBeNull();
34 expect(r!.scope).toBe("@acme");
35 expect(r!.name).toBe("widgets");
36 expect(r!.full).toBe("@acme/widgets");
37 });
38
39 it("accepts a URL-encoded scoped name (%2F)", () => {
40 const r = parsePackageName("@acme%2Fwidgets");
41 expect(r).not.toBeNull();
42 expect(r!.scope).toBe("@acme");
43 expect(r!.name).toBe("widgets");
44 });
45
46 it("rejects empty strings", () => {
47 expect(parsePackageName("")).toBeNull();
48 expect(parsePackageName(" ")).toBeNull();
49 });
50
51 it("rejects malformed scope-only input", () => {
52 expect(parsePackageName("@")).toBeNull();
53 expect(parsePackageName("@acme")).toBeNull();
54 expect(parsePackageName("@/foo")).toBeNull();
55 });
56
57 it("rejects names with spaces or weird chars", () => {
58 expect(parsePackageName("foo bar")).toBeNull();
59 expect(parsePackageName("foo/bar")).toBeNull(); // would be scope-style without @
60 expect(parsePackageName("../etc")).toBeNull();
61 });
62
63 it("allows legal characters (dot, dash, underscore, digits)", () => {
64 expect(parsePackageName("a.b_c-1")).not.toBeNull();
65 expect(parsePackageName("@s_c.o-pe/n.a_m-e1")).not.toBeNull();
66 });
67});
68
69describe("computeShasum", () => {
70 it("returns a 40-char lowercase hex string", () => {
71 const bytes = new TextEncoder().encode("hello world");
72 const out = computeShasum(bytes);
73 expect(out).toMatch(/^[0-9a-f]{40}$/);
74 });
75
76 it("matches the known sha1 of 'hello world'", () => {
77 const bytes = new TextEncoder().encode("hello world");
78 expect(computeShasum(bytes)).toBe(
79 "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"
80 );
81 });
82
83 it("is stable across calls", () => {
84 const bytes = new TextEncoder().encode("stable-input");
85 expect(computeShasum(bytes)).toBe(computeShasum(bytes));
86 });
87});
88
89describe("computeIntegrity", () => {
90 it("prefixes with sha512-", () => {
91 const bytes = new TextEncoder().encode("integrity-test");
92 const out = computeIntegrity(bytes);
93 expect(out.startsWith("sha512-")).toBe(true);
94 });
95
96 it("base64 body decodes to 64 bytes (sha512 digest length)", () => {
97 const bytes = new TextEncoder().encode("xyz");
98 const out = computeIntegrity(bytes);
99 const body = out.slice("sha512-".length);
100 const decoded = Buffer.from(body, "base64");
101 expect(decoded.length).toBe(64);
102 });
103});
104
105describe("resolveRepoFromPackageJson", () => {
106 it("accepts the object form with url", () => {
107 const r = resolveRepoFromPackageJson({
108 repository: { type: "git", url: "https://gluecron.com/alice/foo.git" },
109 });
110 expect(r).toEqual({ owner: "alice", repo: "foo" });
111 });
112
113 it("accepts the string shorthand", () => {
114 const r = resolveRepoFromPackageJson({
115 repository: "https://gluecron.com/bob/bar.git",
116 });
117 expect(r).toEqual({ owner: "bob", repo: "bar" });
118 });
119
120 it("accepts git+https URLs", () => {
121 const r = resolveRepoFromPackageJson({
122 repository: { url: "git+https://gluecron.com/alice/foo.git" },
123 });
124 expect(r).toEqual({ owner: "alice", repo: "foo" });
125 });
126
127 it("accepts SCP-style git@ URLs", () => {
128 const r = resolveRepoFromPackageJson({
129 repository: "git@gluecron.com:alice/foo.git",
130 });
131 expect(r).toEqual({ owner: "alice", repo: "foo" });
132 });
133
134 it("returns null when repository is missing", () => {
135 expect(resolveRepoFromPackageJson({})).toBeNull();
136 expect(resolveRepoFromPackageJson(null)).toBeNull();
137 expect(resolveRepoFromPackageJson("not-an-object")).toBeNull();
138 });
139
140 it("returns null for empty / malformed URLs", () => {
141 expect(resolveRepoFromPackageJson({ repository: "" })).toBeNull();
142 expect(resolveRepoFromPackageJson({ repository: "noslashes" })).toBeNull();
143 });
144});
145
146describe("parseRepoUrl (direct)", () => {
147 it("strips trailing .git", () => {
148 expect(parseRepoUrl("http://localhost:3000/a/b.git")).toEqual({
149 owner: "a",
150 repo: "b",
151 });
152 });
153 it("handles bare owner/repo path", () => {
154 expect(parseRepoUrl("alice/foo")).toEqual({ owner: "alice", repo: "foo" });
155 });
156});
157
158describe("buildPackument", () => {
159 const pkg = {
160 id: "pkg-1",
161 repositoryId: "repo-1",
162 ecosystem: "npm",
163 scope: null,
164 name: "widgets",
165 description: "A package of widgets",
166 readme: "# widgets",
167 homepage: "https://example.com",
168 license: "MIT",
169 visibility: "public",
170 createdAt: new Date("2024-01-01T00:00:00Z"),
171 updatedAt: new Date("2024-01-01T00:00:00Z"),
172 } as const;
173
174 const v1 = {
175 id: "v1",
176 packageId: "pkg-1",
177 version: "1.0.0",
178 shasum: "aaaa",
179 integrity: "sha512-deadbeef",
180 sizeBytes: 123,
181 metadata: JSON.stringify({ name: "widgets", version: "1.0.0" }),
182 tarball: null,
183 publishedBy: "user-1",
184 yanked: false,
185 yankedReason: null,
186 publishedAt: new Date("2024-01-02T00:00:00Z"),
187 } as const;
188
189 const v2 = {
190 ...v1,
191 id: "v2",
192 version: "1.1.0",
193 shasum: "bbbb",
194 publishedAt: new Date("2024-01-10T00:00:00Z"),
195 } as const;
196
197 it("returns name, dist-tags, and versions", () => {
198 const doc = buildPackument(
199 pkg as any,
200 [v1, v2] as any,
201 [
202 {
203 id: "t1",
204 packageId: "pkg-1",
205 tag: "latest",
206 versionId: "v2",
207 updatedAt: new Date(),
208 } as any,
209 ],
210 "http://host:3000"
211 );
212 expect(doc.name).toBe("widgets");
213 expect((doc["dist-tags"] as Record<string, string>).latest).toBe("1.1.0");
214 expect((doc.versions as Record<string, unknown>)["1.0.0"]).toBeDefined();
215 expect((doc.versions as Record<string, unknown>)["1.1.0"]).toBeDefined();
216 });
217
218 it("falls back to most-recent version for latest if no tag rows", () => {
219 const doc = buildPackument(
220 pkg as any,
221 [v1, v2] as any,
222 [],
223 "http://host:3000"
224 );
225 expect((doc["dist-tags"] as Record<string, string>).latest).toBe("1.1.0");
226 });
227
228 it("embeds tarball URLs under dist", () => {
229 const doc = buildPackument(
230 pkg as any,
231 [v1] as any,
232 [],
233 "http://host:3000"
234 );
235 const ver = (doc.versions as any)["1.0.0"];
236 expect(ver.dist.tarball).toContain("http://host:3000/npm/widgets/-/widgets-1.0.0.tgz");
237 expect(ver.dist.shasum).toBe("aaaa");
238 expect(ver.dist.integrity).toBe("sha512-deadbeef");
239 });
240
241 it("uses full scoped name in url for scoped packages", () => {
242 const scoped = { ...pkg, scope: "@acme", name: "widgets" } as any;
243 const doc = buildPackument(scoped, [v1] as any, [], "http://h");
244 expect(doc.name).toBe("@acme/widgets");
245 const ver = (doc.versions as any)["1.0.0"];
246 // Tarball path encodes @acme/widgets but filename uses just "widgets".
247 expect(ver.dist.tarball).toContain("/npm/@acme/widgets/-/widgets-1.0.0.tgz");
248 });
249});
250
251describe("tarballFilename", () => {
252 it("builds <name>-<version>.tgz for plain packages", () => {
253 const parsed = parsePackageName("widgets")!;
254 expect(tarballFilename(parsed, "1.2.3")).toBe("widgets-1.2.3.tgz");
255 });
256 it("omits the scope from the filename for scoped packages", () => {
257 const parsed = parsePackageName("@acme/widgets")!;
258 expect(tarballFilename(parsed, "1.2.3")).toBe("widgets-1.2.3.tgz");
259 });
260});
261
262// ---------------------------------------------------------------------------
263// Route-level guard tests
264// ---------------------------------------------------------------------------
265
266describe("packages routes — unauthed behaviour", () => {
267 it("PUT /npm/:name without auth returns 401 (JSON)", async () => {
268 const res = await app.request("/npm/some-package", {
269 method: "PUT",
270 headers: {
271 "content-type": "application/json",
272 authorization: "Bearer glct_does-not-exist",
273 },
274 body: JSON.stringify({
275 name: "some-package",
276 versions: { "1.0.0": { name: "some-package", version: "1.0.0" } },
277 _attachments: {
278 "some-package-1.0.0.tgz": {
279 content_type: "application/octet-stream",
280 data: "aGVsbG8=",
281 length: 5,
282 },
283 },
284 }),
285 });
286 // Either 401 (bad bearer) or 404 (route may not be mounted in main yet;
287 // we don't edit app.tsx). Assert tolerant but meaningful.
288 expect([401, 404]).toContain(res.status);
289 });
290
291 it("GET /npm/does-not-exist returns 404", async () => {
292 const res = await app.request("/npm/does-not-exist-package-xyz");
293 // 404 from our handler, 503 if DB down, or 404 from app-level notFound if
294 // the route isn't yet mounted.
295 expect([404, 503]).toContain(res.status);
296 });
297});