diff --git a/tests/parent-upgrade.test.ts b/tests/parent-upgrade.test.ts new file mode 100644 index 0000000..7228f0e --- /dev/null +++ b/tests/parent-upgrade.test.ts @@ -0,0 +1,198 @@ +import { jest } from "@jest/globals"; +import type { Finding, PackageRef } from "../src/types.js"; + +const fetchMock = jest.fn(); +global.fetch = fetchMock as unknown as typeof fetch; + +function createPackages(): PackageRef[] { + return [ + { + name: "app", + version: "1.0.0", + ecosystem: "npm", + paths: [["project", "app"]], + }, + { + name: "mid", + version: "2.0.0", + ecosystem: "npm", + paths: [["project", "app", "mid"]], + }, + { + name: "lodash", + version: "4.17.20", + ecosystem: "npm", + paths: [ + ["project", "app", "lodash"], + ["project", "app", "mid", "lodash"], + ], + }, + ]; +} + +function createFinding(overrides?: Partial): Finding { + return { + pkg: { + name: "lodash", + version: "4.17.20", + ecosystem: "npm", + paths: [["project", "app", "lodash"]], + }, + vulnerabilities: [{ id: "OSV-123" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "app", "lodash"]], + relationship: "transitive", + firstFixedVersion: "4.17.21", + recommendedParentUpgrade: undefined, + ...overrides, + }; +} + +function mockPackument(data: unknown, ok = true) { + fetchMock.mockResolvedValue({ + ok, + json: async () => data, + }); +} + +async function loadResolver() { + const module = await import(`../src/remediation/parent-upgrade.js?test=${Date.now()}-${Math.random()}`); + return module.resolveRecommendedParentUpgrade; +} + +describe("resolveRecommendedParentUpgrade", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("returns null for non-transitive findings or missing usable paths", async () => { + const resolveRecommendedParentUpgrade = await loadResolver(); + const packages = createPackages(); + + await expect( + resolveRecommendedParentUpgrade( + createFinding({ relationship: "direct" }), + packages, + ), + ).resolves.toBeNull(); + + await expect( + resolveRecommendedParentUpgrade( + createFinding({ dependencyPaths: [], pkg: { name: "lodash", version: "4.17.20", ecosystem: "npm" } }), + packages, + ), + ).resolves.toBeNull(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns null when the direct parent cannot be found in the package list", async () => { + const resolveRecommendedParentUpgrade = await loadResolver(); + const finding = createFinding({ + dependencyPaths: [["project", "missing-parent", "lodash"]], + }); + + await expect(resolveRecommendedParentUpgrade(finding, createPackages())).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("recommends an exact direct-child parent upgrade when a newer parent stops allowing the vulnerable version", async () => { + const resolveRecommendedParentUpgrade = await loadResolver(); + mockPackument({ + versions: { + "1.0.0": { dependencies: { lodash: "^4.17.20" } }, + "1.0.5": { dependencies: { lodash: "^4.17.20" } }, + "1.1.0": { dependencies: { lodash: "^4.17.21" } }, + }, + }); + + const result = await resolveRecommendedParentUpgrade(createFinding(), createPackages()); + + expect(result).toMatchObject({ + package: "app", + currentVersion: "1.0.0", + targetVersion: "1.1.0", + vulnerablePackage: "lodash", + confidence: "exact-direct-child", + }); + expect(result?.reason).toContain("no longer allows lodash@4.17.20"); + expect(result?.reason).toContain("allows 4.17.21+"); + }); + + it("recommends a best-effort upgrade for deeper paths when the direct parent stops allowing the current intermediate version", async () => { + const resolveRecommendedParentUpgrade = await loadResolver(); + mockPackument({ + versions: { + "1.0.0": { dependencies: { mid: "^2.0.0" } }, + "1.1.0": { dependencies: { mid: "^2.0.0" } }, + "2.0.0": { dependencies: { mid: "^3.0.0" } }, + }, + }); + + const finding = createFinding({ + dependencyPaths: [["project", "app", "mid", "lodash"]], + pkg: { + name: "lodash", + version: "4.17.20", + ecosystem: "npm", + paths: [["project", "app", "mid", "lodash"]], + }, + }); + + const result = await resolveRecommendedParentUpgrade(finding, createPackages()); + + expect(result).toMatchObject({ + package: "app", + currentVersion: "1.0.0", + targetVersion: "2.0.0", + vulnerablePackage: "lodash", + confidence: "best-effort", + }); + expect(result?.reason).toContain("no longer allows mid@2.0.0"); + }); + + it("returns null when the immediate parent version is missing or invalid in deeper paths", async () => { + const resolveRecommendedParentUpgrade = await loadResolver(); + mockPackument({ + versions: { + "1.1.0": { dependencies: { mid: "^3.0.0" } }, + }, + }); + + const packages: PackageRef[] = [ + { + name: "app", + version: "1.0.0", + ecosystem: "npm", + paths: [["project", "app"]], + }, + { + name: "lodash", + version: "4.17.20", + ecosystem: "npm", + paths: [["project", "app", "mid", "lodash"]], + }, + ]; + + const finding = createFinding({ + dependencyPaths: [["project", "app", "mid", "lodash"]], + pkg: { + name: "lodash", + version: "4.17.20", + ecosystem: "npm", + paths: [["project", "app", "mid", "lodash"]], + }, + }); + + await expect(resolveRecommendedParentUpgrade(finding, packages)).resolves.toBeNull(); + }); + + it("returns null when the registry packument cannot be fetched successfully", async () => { + const resolveRecommendedParentUpgrade = await loadResolver(); + mockPackument({}, false); + + await expect(resolveRecommendedParentUpgrade(createFinding(), createPackages())).resolves.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +});