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.
| 25a91a6 | 1 | /** |
| 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 | ||
| 10 | import { describe, it, expect } from "bun:test"; | |
| 11 | import app from "../app"; | |
| 12 | import { | |
| 13 | parsePackageName, | |
| 14 | parseRepoUrl, | |
| 15 | computeShasum, | |
| 16 | computeIntegrity, | |
| 17 | buildPackument, | |
| 18 | resolveRepoFromPackageJson, | |
| 19 | tarballFilename, | |
| 20 | } from "../lib/packages"; | |
| 21 | ||
| 22 | describe("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 | ||
| 69 | describe("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 | ||
| 89 | describe("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 | ||
| 105 | describe("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 | ||
| 146 | describe("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 | ||
| 158 | describe("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 | ||
| 251 | describe("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 | ||
| 266 | describe("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 | }); |