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

dependency-scanner.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.

dependency-scanner.test.tsBlame401 lines · 1 contributor
da3fc18Claude1/**
2 * Dependency CVE Scanner — unit tests.
3 *
4 * Tests the pure parsing helpers, the OSV result mapper, and issue-body
5 * renderers without touching the DB or network.
6 */
7
8import { describe, it, expect } from "bun:test";
9import { __internal } from "../lib/dependency-scanner";
10import app from "../app";
11
12const {
13 parsePackageJson,
14 parseRequirementsTxt,
15 parseCargoToml,
16 parseGoMod,
17 parseGemfile,
18 mapSeverity,
19 extractFixVersion,
20 osvResultsToFindings,
21 renderVulnIssueBody,
22 renderDigestBody,
23 WEEKLY_DIGEST_MARKER,
24 FILE_ECOSYSTEM,
25 DEPENDENCY_FILES,
26} = __internal;
27
28// ---------------------------------------------------------------------------
29// parsePackageJson
30// ---------------------------------------------------------------------------
31
32describe("parsePackageJson", () => {
33 it("parses dependencies and devDependencies", () => {
34 const json = JSON.stringify({
35 dependencies: { lodash: "^4.17.11", express: "^4.18.0" },
36 devDependencies: { jest: "^29.0.0" },
37 });
38 const result = parsePackageJson(json, "npm");
39 expect(result.length).toBe(3);
40 expect(result.map((p) => p.name)).toContain("lodash");
41 expect(result.map((p) => p.name)).toContain("express");
42 expect(result.map((p) => p.name)).toContain("jest");
43 });
44
45 it("strips leading ^ ~ from version specs", () => {
46 const json = JSON.stringify({ dependencies: { react: "^18.2.0" } });
47 const result = parsePackageJson(json, "npm");
48 expect(result[0].version).toBe("18.2.0");
49 });
50
51 it("returns [] on invalid JSON", () => {
52 expect(parsePackageJson("not json", "npm")).toEqual([]);
53 });
54
55 it("sets correct ecosystem", () => {
56 const json = JSON.stringify({ dependencies: { foo: "1.0.0" } });
57 const result = parsePackageJson(json, "npm");
58 expect(result[0].ecosystem).toBe("npm");
59 });
60
61 it("handles empty dependency sections", () => {
62 const json = JSON.stringify({ name: "my-app" });
63 expect(parsePackageJson(json, "npm")).toEqual([]);
64 });
65});
66
67// ---------------------------------------------------------------------------
68// parseRequirementsTxt
69// ---------------------------------------------------------------------------
70
71describe("parseRequirementsTxt", () => {
72 it("parses pinned versions", () => {
73 const content = "requests==2.28.0\nDjango>=4.1\nflask\n";
74 const result = parseRequirementsTxt(content, "PyPI");
75 expect(result.map((p) => p.name)).toContain("requests");
76 expect(result.find((p) => p.name === "requests")?.version).toBe("2.28.0");
77 expect(result.map((p) => p.name)).toContain("Django");
78 expect(result.map((p) => p.name)).toContain("flask");
79 });
80
81 it("skips comments and -r flags", () => {
82 const content = "# comment\n-r base.txt\nnumpy==1.24.0\n";
83 const result = parseRequirementsTxt(content, "PyPI");
84 expect(result.length).toBe(1);
85 expect(result[0].name).toBe("numpy");
86 });
87
88 it("returns [] for empty content", () => {
89 expect(parseRequirementsTxt("", "PyPI")).toEqual([]);
90 });
91});
92
93// ---------------------------------------------------------------------------
94// parseCargoToml
95// ---------------------------------------------------------------------------
96
97describe("parseCargoToml", () => {
98 it("parses simple string versions", () => {
99 const content = `[dependencies]\nserde = "1.0.163"\ntokio = "1.28.0"\n`;
100 const result = parseCargoToml(content, "crates.io");
101 expect(result.map((p) => p.name)).toContain("serde");
102 expect(result.find((p) => p.name === "serde")?.version).toBe("1.0.163");
103 expect(result.find((p) => p.name === "tokio")?.version).toBe("1.28.0");
104 });
105
106 it("parses table-style versions", () => {
107 const content = `[dependencies]\nactix-web = { version = "4.3.0", features = ["macros"] }\n`;
108 const result = parseCargoToml(content, "crates.io");
109 expect(result.find((p) => p.name === "actix-web")?.version).toBe("4.3.0");
110 });
111
112 it("skips [package] and other sections", () => {
113 const content = `[package]\nname = "my-crate"\n\n[dependencies]\nhyper = "0.14.27"\n`;
114 const result = parseCargoToml(content, "crates.io");
115 expect(result.length).toBe(1);
116 expect(result[0].name).toBe("hyper");
117 });
118
119 it("returns [] for empty content", () => {
120 expect(parseCargoToml("", "crates.io")).toEqual([]);
121 });
122});
123
124// ---------------------------------------------------------------------------
125// parseGoMod
126// ---------------------------------------------------------------------------
127
128describe("parseGoMod", () => {
129 it("parses require lines", () => {
130 const content = `module example.com/myapp\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.0\n\tgolang.org/x/net v0.10.0\n)\n`;
131 const result = parseGoMod(content, "Go");
132 expect(result.map((p) => p.name)).toContain("github.com/gin-gonic/gin");
133 expect(result.find((p) => p.name === "github.com/gin-gonic/gin")?.version).toBe("1.9.0");
134 });
135
136 it("strips leading v from version", () => {
137 const content = `require github.com/foo/bar v2.1.0\n`;
138 const result = parseGoMod(content, "Go");
139 expect(result[0].version).toBe("2.1.0");
140 });
141
142 it("returns [] for content with no require lines", () => {
143 expect(parseGoMod("module foo\n\ngo 1.21\n", "Go")).toEqual([]);
144 });
145});
146
147// ---------------------------------------------------------------------------
148// parseGemfile
149// ---------------------------------------------------------------------------
150
151describe("parseGemfile", () => {
152 it("parses gem declarations", () => {
153 const content = `source 'https://rubygems.org'\ngem 'rails', '~> 7.0'\ngem 'pg'\n`;
154 const result = parseGemfile(content, "RubyGems");
155 expect(result.map((p) => p.name)).toContain("rails");
156 expect(result.map((p) => p.name)).toContain("pg");
157 });
158
159 it("returns [] for non-gem lines", () => {
160 expect(parseGemfile("source 'https://rubygems.org'\n", "RubyGems")).toEqual([]);
161 });
162});
163
164// ---------------------------------------------------------------------------
165// mapSeverity
166// ---------------------------------------------------------------------------
167
168describe("mapSeverity", () => {
169 it("returns 'critical' for CVSS score >= 9.0", () => {
170 expect(
171 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "9.5" }] })
172 ).toBe("critical");
173 });
174
175 it("returns 'high' for CVSS score >= 7.0", () => {
176 expect(
177 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "8.1" }] })
178 ).toBe("high");
179 });
180
181 it("returns 'medium' for CVSS score >= 4.0", () => {
182 expect(
183 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "5.3" }] })
184 ).toBe("medium");
185 });
186
187 it("returns 'low' for CVSS score < 4.0", () => {
188 expect(
189 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "2.1" }] })
190 ).toBe("low");
191 });
192
193 it("handles string CRITICAL severity", () => {
194 expect(
195 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "CRITICAL" }] })
196 ).toBe("critical");
197 });
198
199 it("handles string HIGH severity", () => {
200 expect(
201 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "HIGH" }] })
202 ).toBe("high");
203 });
204
205 it("returns 'medium' when no severity data", () => {
206 expect(mapSeverity({ id: "x" })).toBe("medium");
207 expect(mapSeverity({ id: "x", severity: [] })).toBe("medium");
208 });
209});
210
211// ---------------------------------------------------------------------------
212// extractFixVersion
213// ---------------------------------------------------------------------------
214
215describe("extractFixVersion", () => {
216 it("extracts fix version from affected ranges", () => {
217 const vuln = {
218 id: "CVE-2021-44228",
219 affected: [
220 {
221 ranges: [
222 {
223 type: "SEMVER",
224 events: [{ introduced: "2.0.0" }, { fixed: "2.15.0" }],
225 },
226 ],
227 },
228 ],
229 };
230 expect(extractFixVersion(vuln)).toBe("2.15.0");
231 });
232
233 it("returns undefined when no fix is available", () => {
234 const vuln = {
235 id: "CVE-XXXX",
236 affected: [{ ranges: [{ type: "SEMVER", events: [{ introduced: "0" }] }] }],
237 };
238 expect(extractFixVersion(vuln)).toBeUndefined();
239 });
240
241 it("returns undefined for vulns with no affected data", () => {
242 expect(extractFixVersion({ id: "x" })).toBeUndefined();
243 });
244});
245
246// ---------------------------------------------------------------------------
247// osvResultsToFindings
248// ---------------------------------------------------------------------------
249
250describe("osvResultsToFindings", () => {
251 it("converts OSV results to VulnFindings", () => {
252 const packages = [{ name: "lodash", version: "4.17.11", ecosystem: "npm" }];
253 const results = [
254 {
255 vulns: [
256 {
257 id: "GHSA-p6mc-m468-83gw",
258 summary: "Prototype pollution in lodash",
259 severity: [{ type: "CVSS_V3", score: "9.8" }],
260 affected: [
261 {
262 ranges: [
263 { type: "SEMVER", events: [{ introduced: "0" }, { fixed: "4.17.12" }] },
264 ],
265 },
266 ],
267 },
268 ],
269 },
270 ];
271 const findings = osvResultsToFindings(packages, results);
272 expect(findings).toHaveLength(1);
273 expect(findings[0].packageName).toBe("lodash");
274 expect(findings[0].installedVersion).toBe("4.17.11");
275 expect(findings[0].severity).toBe("critical");
276 expect(findings[0].cveId).toBe("GHSA-p6mc-m468-83gw");
277 expect(findings[0].fixVersion).toBe("4.17.12");
278 });
279
280 it("returns [] when no vulns in results", () => {
281 const packages = [{ name: "safe-pkg", version: "1.0.0", ecosystem: "npm" }];
282 const results = [{ vulns: [] }];
283 expect(osvResultsToFindings(packages, results)).toEqual([]);
284 });
285
286 it("handles mismatched results length gracefully", () => {
287 const packages = [{ name: "pkg-a", version: "1.0.0", ecosystem: "npm" }];
288 const findings = osvResultsToFindings(packages, []);
289 expect(findings).toEqual([]);
290 });
291});
292
293// ---------------------------------------------------------------------------
294// renderVulnIssueBody
295// ---------------------------------------------------------------------------
296
297describe("renderVulnIssueBody", () => {
298 const finding = {
299 packageName: "lodash",
300 installedVersion: "4.17.11",
301 severity: "critical" as const,
302 cveId: "GHSA-p6mc-m468-83gw",
303 description: "Prototype pollution.",
304 fixVersion: "4.17.12",
305 };
306
307 it("includes package name and version", () => {
308 const body = renderVulnIssueBody(finding);
309 expect(body).toContain("lodash");
310 expect(body).toContain("4.17.11");
311 });
312
313 it("includes CVE ID with OSV link", () => {
314 const body = renderVulnIssueBody(finding);
315 expect(body).toContain("GHSA-p6mc-m468-83gw");
316 expect(body).toContain("https://osv.dev/vulnerability/GHSA-p6mc-m468-83gw");
317 });
318
319 it("includes fix version in remediation", () => {
320 const body = renderVulnIssueBody(finding);
321 expect(body).toContain("4.17.12");
322 });
323
324 it("mentions 'No fix' when fixVersion is absent", () => {
325 const body = renderVulnIssueBody({ ...finding, fixVersion: undefined });
326 expect(body).toContain("No fix version");
327 });
328
329 it("includes CRITICAL urgency text for critical severity", () => {
330 const body = renderVulnIssueBody(finding);
331 expect(body).toContain("CRITICAL");
332 });
333});
334
335// ---------------------------------------------------------------------------
336// renderDigestBody
337// ---------------------------------------------------------------------------
338
339describe("renderDigestBody", () => {
340 const findings = [
341 {
342 packageName: "minimist",
343 installedVersion: "1.2.0",
344 severity: "medium" as const,
345 cveId: "CVE-2020-7598",
346 description: "Prototype pollution.",
347 fixVersion: "1.2.2",
348 },
349 ];
350
351 it("includes the weekly digest marker", () => {
352 expect(renderDigestBody(findings)).toContain(WEEKLY_DIGEST_MARKER);
353 });
354
355 it("includes package name and CVE in the table", () => {
356 const body = renderDigestBody(findings);
357 expect(body).toContain("minimist");
358 expect(body).toContain("CVE-2020-7598");
359 });
360
361 it("returns valid markdown table", () => {
362 const body = renderDigestBody(findings);
363 expect(body).toContain("| Package |");
364 expect(body).toContain("|---|");
365 });
366});
367
368// ---------------------------------------------------------------------------
369// Constants
370// ---------------------------------------------------------------------------
371
372describe("constants", () => {
373 it("DEPENDENCY_FILES contains the five expected files", () => {
374 expect(DEPENDENCY_FILES).toContain("package.json");
375 expect(DEPENDENCY_FILES).toContain("requirements.txt");
376 expect(DEPENDENCY_FILES).toContain("Cargo.toml");
377 expect(DEPENDENCY_FILES).toContain("go.mod");
378 expect(DEPENDENCY_FILES).toContain("Gemfile");
379 });
380
381 it("FILE_ECOSYSTEM maps each file to an ecosystem", () => {
382 expect(FILE_ECOSYSTEM["package.json"]).toBe("npm");
383 expect(FILE_ECOSYSTEM["requirements.txt"]).toBe("PyPI");
384 expect(FILE_ECOSYSTEM["Cargo.toml"]).toBe("crates.io");
385 expect(FILE_ECOSYSTEM["go.mod"]).toBe("Go");
386 expect(FILE_ECOSYSTEM["Gemfile"]).toBe("RubyGems");
387 });
388});
389
390// ---------------------------------------------------------------------------
391// Route auth smoke tests
392// ---------------------------------------------------------------------------
393
394describe("GET /:owner/:repo/security/vulnerabilities — route auth", () => {
395 it("returns 404 for non-existent owner", async () => {
396 const res = await app.request(
397 "/nonexistent-owner-99999/my-repo/security/vulnerabilities"
398 );
399 expect(res.status).toBe(404);
400 });
401});