|
| 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 { |
| 6 | + getPrimaryParent, |
| 7 | + getRecommendedAction, |
| 8 | + logInfo, |
| 9 | + logWarn, |
| 10 | + printCacheSummary, |
| 11 | + serializeFinding, |
| 12 | + sortFindingsForOutput, |
| 13 | + summarizeNextAction, |
| 14 | + summarizeRisk, |
| 15 | +} from "../src/output/formatters.js"; |
| 16 | +import { |
| 17 | + printActionSummary, |
| 18 | + printCompactOutput, |
| 19 | + printFinalStatus, |
| 20 | + printSummary, |
| 21 | + printTable, |
| 22 | +} from "../src/output/printers.js"; |
| 23 | +import { stripAnsi } from "../src/utils/chalk.js"; |
| 24 | +import type { Finding, OsvVuln, ScanInput } from "../src/types.js"; |
| 25 | + |
| 26 | +function createFinding(overrides?: Partial<Finding>): Finding { |
| 27 | + const vuln: OsvVuln = { |
| 28 | + id: "OSV-123", |
| 29 | + aliases: ["CVE-2026-0001"], |
| 30 | + summary: "Prototype pollution", |
| 31 | + severity: [{ score: "9.8" }], |
| 32 | + }; |
| 33 | + |
| 34 | + return { |
| 35 | + pkg: { |
| 36 | + name: "lodash", |
| 37 | + version: "4.17.20", |
| 38 | + ecosystem: "npm", |
| 39 | + paths: [["project", "app", "lodash"]], |
| 40 | + }, |
| 41 | + vulnerabilities: [vuln], |
| 42 | + severity: "critical", |
| 43 | + cveAliases: ["CVE-2026-0001"], |
| 44 | + dependencyPaths: [["project", "app", "lodash"]], |
| 45 | + relationship: "transitive", |
| 46 | + firstFixedVersion: "4.17.21", |
| 47 | + recommendedParentUpgrade: { |
| 48 | + package: "app", |
| 49 | + currentVersion: "1.0.0", |
| 50 | + targetVersion: "1.1.0", |
| 51 | + viaPath: ["project", "app", "lodash"], |
| 52 | + vulnerablePackage: "lodash", |
| 53 | + confidence: "exact-direct-child", |
| 54 | + reason: "app@1.1.0 no longer allows lodash@4.17.20", |
| 55 | + }, |
| 56 | + ...overrides, |
| 57 | + }; |
| 58 | +} |
| 59 | + |
| 60 | +function createScanInput(mode: ScanInput["mode"] = "resolved-lockfile"): ScanInput { |
| 61 | + return { |
| 62 | + mode, |
| 63 | + source: mode === "manifest-fallback" ? "package-json" : "package-lock", |
| 64 | + filePath: "/tmp/package-lock.json", |
| 65 | + packages: [], |
| 66 | + notes: [], |
| 67 | + warnings: [], |
| 68 | + skippedDependencies: [], |
| 69 | + }; |
| 70 | +} |
| 71 | + |
| 72 | +function captureLogs(run: () => void): string[] { |
| 73 | + const logs: string[] = []; |
| 74 | + const spy = jest.spyOn(console, "log").mockImplementation((...args: unknown[]) => { |
| 75 | + logs.push(args.map(arg => String(arg)).join(" ")); |
| 76 | + }); |
| 77 | + |
| 78 | + try { |
| 79 | + run(); |
| 80 | + } finally { |
| 81 | + spy.mockRestore(); |
| 82 | + } |
| 83 | + |
| 84 | + return logs.map(line => stripAnsi(line)); |
| 85 | +} |
| 86 | + |
| 87 | +describe("output formatters", () => { |
| 88 | + it("derives the primary parent and recommendation text from findings", () => { |
| 89 | + const finding = createFinding(); |
| 90 | + |
| 91 | + expect(getPrimaryParent(finding)).toBe("app"); |
| 92 | + expect(getRecommendedAction(finding)).toContain("Upgrade app from 1.0.0 to 1.1.0"); |
| 93 | + expect(summarizeRisk(finding)).toContain("specific parent upgrade target"); |
| 94 | + expect(summarizeNextAction(finding)).toBe("Upgrade app 1.0.0 -> 1.1.0."); |
| 95 | + }); |
| 96 | + |
| 97 | + it("serializes findings with inferred vulnerability severity", () => { |
| 98 | + const finding = createFinding(); |
| 99 | + const serialized = serializeFinding(finding); |
| 100 | + |
| 101 | + expect(serialized).toMatchObject({ |
| 102 | + package: "lodash", |
| 103 | + version: "4.17.20", |
| 104 | + severity: "critical", |
| 105 | + relationship: "transitive", |
| 106 | + firstFixedVersion: "4.17.21", |
| 107 | + primaryParent: "app", |
| 108 | + cves: ["CVE-2026-0001"], |
| 109 | + }); |
| 110 | + expect(serialized.vulnerabilities[0]).toMatchObject({ |
| 111 | + id: "OSV-123", |
| 112 | + severity: "critical", |
| 113 | + }); |
| 114 | + }); |
| 115 | + |
| 116 | + it("sorts findings by severity and then package name", () => { |
| 117 | + const findings = [ |
| 118 | + createFinding({ pkg: { name: "zlib", version: "1.0.0", ecosystem: "npm" }, severity: "medium" }), |
| 119 | + createFinding({ pkg: { name: "axios", version: "1.0.0", ecosystem: "npm" }, severity: "medium" }), |
| 120 | + createFinding({ pkg: { name: "chalk", version: "1.0.0", ecosystem: "npm" }, severity: "high" }), |
| 121 | + ]; |
| 122 | + |
| 123 | + const sorted = sortFindingsForOutput(findings); |
| 124 | + expect(sorted.map(item => `${item.severity}:${item.pkg.name}`)).toEqual([ |
| 125 | + "high:chalk", |
| 126 | + "medium:axios", |
| 127 | + "medium:zlib", |
| 128 | + ]); |
| 129 | + }); |
| 130 | + |
| 131 | + it("prints cache and info/warn lines when output is not json", () => { |
| 132 | + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-output-cache-")); |
| 133 | + fs.writeFileSync( |
| 134 | + path.join(cacheDir, "osv-vulns.json"), |
| 135 | + JSON.stringify({ |
| 136 | + version: 2, |
| 137 | + createdAt: "2026-01-01T00:00:00.000Z", |
| 138 | + entries: { "OSV-123": { id: "OSV-123" }, "OSV-404": null }, |
| 139 | + queryEntries: { "npm:lodash@4.17.20": ["OSV-123"] }, |
| 140 | + }), |
| 141 | + "utf8", |
| 142 | + ); |
| 143 | + |
| 144 | + try { |
| 145 | + const lines = captureLogs(() => { |
| 146 | + printCacheSummary(cacheDir); |
| 147 | + logInfo("hello"); |
| 148 | + logWarn("careful"); |
| 149 | + }); |
| 150 | + |
| 151 | + expect(lines[0]).toContain("Cache: 1 package match record, 1 advisory detail record, 1 empty lookup"); |
| 152 | + expect(lines[1]).toBe("hello"); |
| 153 | + expect(lines[2]).toBe("careful"); |
| 154 | + } finally { |
| 155 | + fs.rmSync(cacheDir, { recursive: true, force: true }); |
| 156 | + } |
| 157 | + }); |
| 158 | +}); |
| 159 | + |
| 160 | +describe("output printers", () => { |
| 161 | + it("prints an empty summary for clean manifest fallback scans", () => { |
| 162 | + const lines = captureLogs(() => { |
| 163 | + printSummary([], 2, createScanInput("manifest-fallback")); |
| 164 | + }); |
| 165 | + |
| 166 | + expect(lines).toEqual([ |
| 167 | + "✓ No known OSV matches found for manifest fallback packages (2 exact direct dependencies checked)", |
| 168 | + ]); |
| 169 | + }); |
| 170 | + |
| 171 | + it("prints a finding summary and action summary for vulnerable packages", () => { |
| 172 | + const findings = [ |
| 173 | + createFinding(), |
| 174 | + createFinding({ |
| 175 | + pkg: { name: "minimist", version: "0.0.8", ecosystem: "npm", paths: [["project", "minimist"]] }, |
| 176 | + relationship: "direct", |
| 177 | + dependencyPaths: [["project", "minimist"]], |
| 178 | + severity: "high", |
| 179 | + firstFixedVersion: "1.2.8", |
| 180 | + recommendedParentUpgrade: undefined, |
| 181 | + vulnerabilities: [{ id: "OSV-456", severity: [{ score: "7.5" }] }], |
| 182 | + }), |
| 183 | + ]; |
| 184 | + |
| 185 | + const lines = captureLogs(() => { |
| 186 | + printSummary(findings, 25, createScanInput()); |
| 187 | + printActionSummary(findings); |
| 188 | + }); |
| 189 | + |
| 190 | + expect(lines[0]).toContain("✗ Found 2 package(s) with known OSV matches from package-lock"); |
| 191 | + expect(lines.join("\n")).toContain("Quick take"); |
| 192 | + expect(lines.join("\n")).toContain("1 vulnerable package look directly fixable in this project."); |
| 193 | + expect(lines.join("\n")).toContain("1 issue come through other dependencies."); |
| 194 | + }); |
| 195 | + |
| 196 | + it("prints a table and final status for findings", () => { |
| 197 | + const findings = [createFinding()]; |
| 198 | + |
| 199 | + const lines = captureLogs(() => { |
| 200 | + printTable(findings, "high"); |
| 201 | + printFinalStatus(findings); |
| 202 | + }); |
| 203 | + |
| 204 | + expect(lines.join("\n")).toContain("Showing high+ findings in the main table. Use --all to show everything."); |
| 205 | + expect(lines.join("\n")).toContain("Package"); |
| 206 | + expect(lines.join("\n")).toContain("lodash"); |
| 207 | + expect(lines.join("\n")).toContain("OSV-123"); |
| 208 | + expect(lines.join("\n")).toContain("✖ Scan complete. 1 issue found (1 critical, 0 high)."); |
| 209 | + }); |
| 210 | + |
| 211 | + it("prints compact output for urgent findings and a clean final line for empty scans", () => { |
| 212 | + const linesWithFinding = captureLogs(() => { |
| 213 | + printCompactOutput([createFinding()]); |
| 214 | + }); |
| 215 | + const emptyLines = captureLogs(() => { |
| 216 | + printCompactOutput([]); |
| 217 | + }); |
| 218 | + |
| 219 | + expect(linesWithFinding.join("\n")).toContain("📦 Vulnerabilities found"); |
| 220 | + expect(linesWithFinding.join("\n")).toContain("🚀 Top Priority Fix"); |
| 221 | + expect(linesWithFinding.join("\n")).toContain("Upgrade app 1.0.0 → 1.1.0"); |
| 222 | + expect(emptyLines).toContain("✔ Scan complete. No known vulnerabilities found."); |
| 223 | + }); |
| 224 | +}); |
0 commit comments