/**
* Tests the real, deep-scan `SbomGenerator` backend (#610,
* ./tool-sbom-generator.ts) against a `MockProcessRunner`
* (./__tests__/mock-process-runner.ts) — no live `syft`/`docker buildx`/
* `cyclonedx-maven`/`cdxgen` invoked. Asserts the command each artifact type
* dispatches to, that output is parsed (package count), and that a missing
* tool surfaces `ToolNotAvailableError` rather than a silent/empty SBOM.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createToolSbomGenerator } from "./tool-sbom-generator";
import { ToolNotAvailableError } from "./process-runner";
import { createMockProcessRunner } from "./__tests__/mock-process-runner";
const dirs: string[] = [];
function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "chant-tool-sbom-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
});
const CYCLONEDX_DOC = JSON.stringify({ bomFormat: "CycloneDX", components: [{ name: "left-pad" }, { name: "express" }] });
const SPDX_DOC = JSON.stringify({ spdxVersion: "SPDX-2.3", packages: [{ name: "left-pad" }] });
describe("createToolSbomGenerator — forDir (#610)", () => {
it("scans with syft when available, requesting the SPDX format by default", async () => {
const dir = tempDir();
const mock = createMockProcessRunner({ responses: { "cat ": SPDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forDir({ path: dir });
expect(doc.format).toBe("spdx");
expect(doc.mediaType).toBe("application/spdx+json");
expect(doc.generator).toBe("syft");
expect(doc.packageCount).toBe(1);
const syftCall = mock.calls.find((c) => c.command.startsWith("syft "))!;
expect(syftCall.command).toContain(`syft '${dir}' -o spdx-json=`);
});
it("requests CycloneDX output when format is cyclonedx", async () => {
const dir = tempDir();
const mock = createMockProcessRunner({ responses: { "cat ": CYCLONEDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forDir({ path: dir, format: "cyclonedx" });
expect(doc.mediaType).toBe("application/vnd.cyclonedx+json");
expect(doc.packageCount).toBe(2);
const syftCall = mock.calls.find((c) => c.command.startsWith("syft "))!;
expect(syftCall.command).toContain("-o cyclonedx-json=");
});
it("falls back to cdxgen when syft is unavailable", async () => {
const dir = tempDir();
const mock = createMockProcessRunner({
tools: { syft: false, cdxgen: true },
responses: { "cat ": CYCLONEDX_DOC },
});
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forDir({ path: dir });
expect(doc.generator).toBe("cdxgen");
expect(doc.format).toBe("cyclonedx"); // cdxgen is CycloneDX-only, even though SPDX is the project default.
expect(mock.calls.some((c) => c.command.startsWith("cdxgen "))).toBe(true);
expect(mock.calls.some((c) => c.command.startsWith("syft "))).toBe(false);
});
it("throws ToolNotAvailableError when neither syft nor cdxgen is installed", async () => {
const dir = tempDir();
const mock = createMockProcessRunner({ tools: { syft: false, cdxgen: false } });
const generator = createToolSbomGenerator({ runner: mock.runner });
await expect(generator.forDir({ path: dir })).rejects.toThrow(ToolNotAvailableError);
});
});
describe("createToolSbomGenerator — forZip (#610)", () => {
it("always scans with syft", async () => {
const mock = createMockProcessRunner({ responses: { "cat ": CYCLONEDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forZip({ zipPath: "archive/lambda.zip", digest: "sha256:aaa", format: "cyclonedx" });
expect(doc.generator).toBe("syft");
expect(doc.packageCount).toBe(2);
expect(mock.calls.some((c) => c.command.includes("archive/lambda.zip"))).toBe(true);
});
it("throws ToolNotAvailableError when syft is unavailable (no fallback for zip)", async () => {
const mock = createMockProcessRunner({ tools: { syft: false } });
const generator = createToolSbomGenerator({ runner: mock.runner });
await expect(generator.forZip({ zipPath: "archive/lambda.zip", digest: "sha256:aaa" })).rejects.toThrow(
ToolNotAvailableError,
);
});
});
describe("createToolSbomGenerator — forJar (#610)", () => {
it("scans with syft when no pom.xml sits alongside the jar", async () => {
const dir = tempDir();
const jarPath = join(dir, "app.jar");
const mock = createMockProcessRunner({ responses: { "cat ": CYCLONEDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forJar({ jarPath, digest: "sha256:bbb", format: "cyclonedx" });
expect(doc.generator).toBe("syft");
expect(mock.calls.some((c) => c.command.startsWith("mvn "))).toBe(false);
});
it("prefers cyclonedx-maven when a pom.xml sits alongside the jar and mvn is available", async () => {
const dir = tempDir();
writeFileSync(join(dir, "pom.xml"), "");
const jarPath = join(dir, "app.jar");
const mock = createMockProcessRunner({ tools: { mvn: true }, responses: { "cat ": CYCLONEDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forJar({ jarPath, digest: "sha256:bbb" });
expect(doc.generator).toBe("cyclonedx-maven");
expect(doc.format).toBe("cyclonedx"); // cyclonedx-maven always produces CycloneDX regardless of the requested default.
const mvnCall = mock.calls.find((c) => c.command.startsWith("mvn "))!;
expect(mvnCall.command).toContain("cyclonedx-maven-plugin");
});
it("falls back to syft when a pom.xml is present but mvn is not installed", async () => {
const dir = tempDir();
writeFileSync(join(dir, "pom.xml"), "");
const jarPath = join(dir, "app.jar");
const mock = createMockProcessRunner({ tools: { mvn: false, syft: true }, responses: { "cat ": CYCLONEDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forJar({ jarPath, digest: "sha256:bbb" });
expect(doc.generator).toBe("syft");
});
});
describe("createToolSbomGenerator — forImage (#610)", () => {
it("scans the saved tarball with syft when no Dockerfile sits next to it", async () => {
const dir = tempDir();
const imagePath = join(dir, "search.tar");
const mock = createMockProcessRunner({ responses: { "cat ": SPDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forImage({ imagePath, digest: "sha256:ccc" });
expect(doc.generator).toBe("syft");
expect(mock.calls.some((c) => c.command.startsWith("docker buildx"))).toBe(false);
});
it("prefers BuildKit's --sbom attestation when a Dockerfile sits next to the tarball and docker is available", async () => {
const dir = tempDir();
writeFileSync(join(dir, "Dockerfile"), "FROM scratch");
const imagePath = join(dir, "search.tar");
const mock = createMockProcessRunner({ tools: { docker: true }, responses: { "cat ": SPDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forImage({ imagePath, digest: "sha256:ccc" });
expect(doc.generator).toBe("buildkit");
expect(doc.format).toBe("spdx");
const buildxCall = mock.calls.find((c) => c.command.startsWith("docker buildx"))!;
expect(buildxCall.command).toContain("--sbom=true");
expect(buildxCall.command).toContain(dir);
});
it("falls back to syft when a Dockerfile is present but docker is unavailable", async () => {
const dir = tempDir();
writeFileSync(join(dir, "Dockerfile"), "FROM scratch");
const imagePath = join(dir, "search.tar");
const mock = createMockProcessRunner({ tools: { docker: false, syft: true }, responses: { "cat ": SPDX_DOC } });
const generator = createToolSbomGenerator({ runner: mock.runner });
const doc = await generator.forImage({ imagePath, digest: "sha256:ccc" });
expect(doc.generator).toBe("syft");
});
it("throws ToolNotAvailableError when falling back to syft and syft is also unavailable", async () => {
const dir = tempDir();
const imagePath = join(dir, "search.tar");
const mock = createMockProcessRunner({ tools: { syft: false } });
const generator = createToolSbomGenerator({ runner: mock.runner });
await expect(generator.forImage({ imagePath, digest: "sha256:ccc" })).rejects.toThrow(ToolNotAvailableError);
});
});