Skip to content

Commit a3d782e

Browse files
authored
Merge pull request #52 from sonukapoor/codex/issue-50-add-scanner-cache-tests
test: add scanner and cache unit tests
2 parents aca6dc7 + 7f2db0c commit a3d782e

3 files changed

Lines changed: 230 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,8 @@ jobs:
2222
- name: Install dependencies
2323
run: npm ci
2424

25+
- name: Test
26+
run: npm test
27+
2528
- name: Build
26-
run: npm run build
29+
run: npm run build

tests/osv-cache.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { loadCache, saveCache } from "../src/osv/cache.js";
5+
import type { CacheFile } from "../src/types.js";
6+
7+
function createTempCacheDir(): string {
8+
return fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-cache-test-"));
9+
}
10+
11+
function removeDir(dirPath: string) {
12+
fs.rmSync(dirPath, { recursive: true, force: true });
13+
}
14+
15+
describe("OSV cache", () => {
16+
it("returns an empty version 2 cache when the file does not exist", () => {
17+
const cacheDir = createTempCacheDir();
18+
19+
try {
20+
const cache = loadCache(cacheDir);
21+
22+
expect(cache.version).toBe(2);
23+
expect(cache.entries).toEqual({});
24+
expect(cache.queryEntries).toEqual({});
25+
} finally {
26+
removeDir(cacheDir);
27+
}
28+
});
29+
30+
it("upgrades an older cache file by defaulting queryEntries", () => {
31+
const cacheDir = createTempCacheDir();
32+
const cacheFile = path.join(cacheDir, "osv-vulns.json");
33+
34+
fs.writeFileSync(
35+
cacheFile,
36+
JSON.stringify({
37+
version: 1,
38+
createdAt: "2026-01-01T00:00:00.000Z",
39+
entries: {
40+
"OSV-123": { id: "OSV-123", aliases: ["CVE-2026-0001"] },
41+
},
42+
}),
43+
"utf8",
44+
);
45+
46+
try {
47+
const cache = loadCache(cacheDir);
48+
49+
expect(cache.version).toBe(2);
50+
expect(cache.entries["OSV-123"]).toMatchObject({ id: "OSV-123" });
51+
expect(cache.queryEntries).toEqual({});
52+
} finally {
53+
removeDir(cacheDir);
54+
}
55+
});
56+
57+
it("persists queryEntries and advisory entries to the JSON cache file", () => {
58+
const cacheDir = createTempCacheDir();
59+
const cache: CacheFile = {
60+
version: 2,
61+
createdAt: "2026-01-01T00:00:00.000Z",
62+
entries: {
63+
"OSV-123": { id: "OSV-123", aliases: ["CVE-2026-0001"] },
64+
},
65+
queryEntries: {
66+
"npm:left-pad@1.0.0": ["OSV-123"],
67+
},
68+
};
69+
70+
try {
71+
saveCache(cache, cacheDir);
72+
73+
const reloaded = loadCache(cacheDir);
74+
expect(reloaded.entries["OSV-123"]).toMatchObject({ id: "OSV-123" });
75+
expect(reloaded.queryEntries["npm:left-pad@1.0.0"]).toEqual(["OSV-123"]);
76+
} finally {
77+
removeDir(cacheDir);
78+
}
79+
});
80+
});

tests/scanner-cache.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { jest } from "@jest/globals";
5+
import type { OsvVuln, PackageRef, ParsedOptions } from "../src/types.js";
6+
7+
const queryBatchMock = jest.fn();
8+
const getVulnMock = jest.fn();
9+
10+
jest.unstable_mockModule("../src/advisory/osv-advisory-source.js", () => ({
11+
OsvAdvisorySource: jest.fn().mockImplementation(() => ({
12+
queryBatch: queryBatchMock,
13+
getVuln: getVulnMock,
14+
})),
15+
}));
16+
17+
const { scanPackages } = await import("../src/scanner.js");
18+
const { loadCache } = await import("../src/osv/cache.js");
19+
20+
function createTempCacheDir(): string {
21+
return fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-scanner-test-"));
22+
}
23+
24+
function removeDir(dirPath: string) {
25+
fs.rmSync(dirPath, { recursive: true, force: true });
26+
}
27+
28+
function createOptions(cacheDir: string): ParsedOptions {
29+
return {
30+
batchSize: "100",
31+
failOn: "critical",
32+
cacheDir,
33+
json: true,
34+
};
35+
}
36+
37+
function createPackage(name: string, version: string): PackageRef {
38+
return {
39+
name,
40+
version,
41+
ecosystem: "npm",
42+
paths: [["root", name]],
43+
};
44+
}
45+
46+
describe("scanPackages cache behavior", () => {
47+
beforeEach(() => {
48+
queryBatchMock.mockReset();
49+
getVulnMock.mockReset();
50+
});
51+
52+
it("uses cached package matches and advisory details on repeat scans", async () => {
53+
const cacheDir = createTempCacheDir();
54+
const pkg = createPackage("left-pad", "1.0.0");
55+
const detail: OsvVuln = {
56+
id: "OSV-123",
57+
aliases: ["CVE-2026-0001"],
58+
affected: [{ ranges: [{ events: [{ fixed: "1.0.1" }] }] }],
59+
};
60+
61+
queryBatchMock.mockResolvedValue([
62+
{
63+
package: pkg.name,
64+
version: pkg.version,
65+
vulnerabilities: [{ id: "OSV-123" }],
66+
},
67+
]);
68+
getVulnMock.mockResolvedValue(detail);
69+
70+
try {
71+
const firstFindings = await scanPackages([pkg], 100, createOptions(cacheDir));
72+
expect(firstFindings).toHaveLength(1);
73+
expect(firstFindings[0]?.vulnerabilities).toEqual([detail]);
74+
expect(queryBatchMock).toHaveBeenCalledTimes(1);
75+
expect(getVulnMock).toHaveBeenCalledTimes(1);
76+
77+
queryBatchMock.mockClear();
78+
getVulnMock.mockClear();
79+
80+
const secondFindings = await scanPackages([pkg], 100, createOptions(cacheDir));
81+
expect(secondFindings).toHaveLength(1);
82+
expect(secondFindings[0]?.vulnerabilities).toEqual([detail]);
83+
expect(queryBatchMock).not.toHaveBeenCalled();
84+
expect(getVulnMock).not.toHaveBeenCalled();
85+
} finally {
86+
removeDir(cacheDir);
87+
}
88+
});
89+
90+
it("does not retry advisory detail fetches for cached null entries", async () => {
91+
const cacheDir = createTempCacheDir();
92+
const pkg = createPackage("minimist", "0.0.8");
93+
const cacheFile = path.join(cacheDir, "osv-vulns.json");
94+
95+
fs.writeFileSync(
96+
cacheFile,
97+
JSON.stringify({
98+
version: 2,
99+
createdAt: "2026-01-01T00:00:00.000Z",
100+
entries: {
101+
"OSV-NULL": null,
102+
},
103+
queryEntries: {
104+
"npm:minimist@0.0.8": ["OSV-NULL"],
105+
},
106+
}),
107+
"utf8",
108+
);
109+
110+
try {
111+
const findings = await scanPackages([pkg], 100, createOptions(cacheDir));
112+
113+
expect(findings).toHaveLength(1);
114+
expect(findings[0]?.vulnerabilities).toEqual([]);
115+
expect(queryBatchMock).not.toHaveBeenCalled();
116+
expect(getVulnMock).not.toHaveBeenCalled();
117+
} finally {
118+
removeDir(cacheDir);
119+
}
120+
});
121+
122+
it("stores package match results in the JSON cache after an uncached scan", async () => {
123+
const cacheDir = createTempCacheDir();
124+
const pkg = createPackage("debug", "4.0.0");
125+
const detail: OsvVuln = { id: "OSV-999" };
126+
127+
queryBatchMock.mockResolvedValue([
128+
{
129+
package: pkg.name,
130+
version: pkg.version,
131+
vulnerabilities: [{ id: "OSV-999" }],
132+
},
133+
]);
134+
getVulnMock.mockResolvedValue(detail);
135+
136+
try {
137+
await scanPackages([pkg], 100, createOptions(cacheDir));
138+
139+
const cache = loadCache(cacheDir);
140+
expect(cache.queryEntries["npm:debug@4.0.0"]).toEqual(["OSV-999"]);
141+
expect(cache.entries["OSV-999"]).toMatchObject({ id: "OSV-999" });
142+
} finally {
143+
removeDir(cacheDir);
144+
}
145+
});
146+
});

0 commit comments

Comments
 (0)