Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions tests/parent-upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
Loading