From 24f845fb8f02a67d328d6d75e3f75029ee0badbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:31:28 +0000 Subject: [PATCH 1/2] Add CI workflow and basic fixture unit tests Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- .github/dependabot.yml | 9 +++ .github/workflows/ci.yml | 39 ++++++++++ package.json | 2 +- src/cli.test.ts | 57 +++++++++++++++ src/fixtures.test.ts | 153 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/cli.test.ts create mode 100644 src/fixtures.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d53cf03..50fcd7f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,3 +20,12 @@ updates: dependency-type: "production" development-dependencies: dependency-type: "development" +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + assignees: + - "aegilops" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8e20444 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-test: + name: Build and test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: ['22.x', '24.x'] + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Restore packages + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/package.json b/package.json index dba786c..b9859c5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "start": "node dist/cli.js", "dev": "tsx src/cli.ts", "lint": "eslint . --ext .ts --max-warnings=0", - "test": "node dist/test-fixture-match.js && node dist/test-branch-search.js" + "test": "node --test dist/fixtures.test.js dist/cli.test.js && node dist/test-fixture-match.js && node dist/test-branch-search.js" }, "engines": { "node": ">=18.0.0" diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..94fdc66 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +// Tests run from the compiled output in `dist`, so the repository root is one level up +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const cli = fileURLToPath(new URL("./cli.js", import.meta.url)); + +const FIXTURE_REPO = "advanced-security/test-sbom-repo"; + +/** Run the built CLI offline against the fixtures, from the repository root */ +function runCli(args: string[]) { + const result = spawnSync(process.execPath, [cli, ...args], { + cwd: repoRoot, + encoding: "utf8", + // No token is provided: these invocations must work entirely offline + env: { ...process.env, GITHUB_TOKEN: "", GH_TOKEN: "" } + }); + assert.equal(result.status, 0, `CLI exited with ${result.status}\n${result.stderr}`); + return result; +} + +describe("CLI against the fixtures", () => { + it("matches malware advisories offline, as documented in the README", () => { + const { stdout } = runCli([ + "--sbom-cache", path.join("fixtures", "sboms"), + "--malware-cache", path.join("fixtures", "malware-cache"), + "--match-malware" + ]); + const matchLines = stdout.split("\n").filter(line => line.includes("GHSA-test-chalk-561")); + assert.equal(matchLines.length, 2); + assert.match(matchLines[0], /^advanced-security\/test-sbom-repo :: pkg:npm\/chalk@5\.6\.1 => GHSA-test-chalk-561 \(=5\.6\.1\)/); + assert.match(matchLines[1], /\[branch: test\]/); + }); + + it("searches by PURL and emits JSON", () => { + const { stdout } = runCli([ + "--sbom-cache", path.join("fixtures", "sboms"), + "--purl", "pkg:npm/chalk@5.6.1", + "--json" + ]); + const payload = JSON.parse(stdout) as { search: { repo: string; matches: { purl: string; reason: string }[] }[] }; + assert.equal(payload.search.length, 1); + assert.equal(payload.search[0].repo, FIXTURE_REPO); + assert.deepEqual(payload.search[0].matches.map(m => m.purl), ["pkg:npm/chalk@5.6.1", "pkg:npm/chalk@5.6.1@test"]); + }); + + it("reports no matches for a package that is not in the SBOM", () => { + const { stdout } = runCli([ + "--sbom-cache", path.join("fixtures", "sboms"), + "--purl", "pkg:npm/not-in-the-sbom@1.0.0" + ]); + assert.match(stdout, /No matches\./); + }); +}); diff --git a/src/fixtures.test.ts b/src/fixtures.test.ts new file mode 100644 index 0000000..d182e99 --- /dev/null +++ b/src/fixtures.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { IgnoreMatcher } from "./ignore.js"; +import { MalwareAdvisoryNode, MalwareAdvisorySync } from "./malwareAdvisories.js"; +import { matchMalware } from "./malwareMatcher.js"; +import { SbomCollector } from "./sbomCollector.js"; +import { readAll } from "./serialization.js"; +import { RepositorySbom } from "./types.js"; + +// Tests run from the compiled output in `dist`, so the repository root is one level up +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const sbomFixtureDir = path.join(repoRoot, "fixtures", "sboms"); +const malwareFixtureDir = path.join(repoRoot, "fixtures", "malware-cache"); + +const FIXTURE_REPO = "advanced-security/test-sbom-repo"; +const FIXTURE_GHSA = "GHSA-test-chalk-561"; + +/** Load the advisory cache the same way the CLI does (offline, no token required) */ +function loadFixtureAdvisories(): MalwareAdvisoryNode[] { + return new MalwareAdvisorySync({ token: "", cacheDir: malwareFixtureDir, quiet: true }).getAdvisories(); +} + +function fixtureMatches() { + return matchMalware(loadFixtureAdvisories(), readAll(sbomFixtureDir)); +} + +describe("SBOM fixture", () => { + it("loads the shipped SBOM from the fixture cache", () => { + const sboms = readAll(sbomFixtureDir); + assert.equal(sboms.length, 1); + assert.equal(sboms[0].repo, FIXTURE_REPO); + assert.equal(sboms[0].org, "advanced-security"); + assert.deepEqual(sboms[0].packages.map(p => p.name).sort(), ["chalk", "left-pad"]); + }); + + it("revives branch diffs as a Map keyed by head branch", () => { + const diffs = readAll(sbomFixtureDir)[0].branchDiffs; + if (!(diffs instanceof Map)) throw new Error("branchDiffs should be revived as a Map"); + const diff = diffs.get("test"); + if (!diff) throw new Error("expected a diff for the 'test' branch"); + assert.equal(diff.base, "main"); + assert.deepEqual(diff.changes.map(c => `${c.changeType}:${c.packageURL}`), ["added:pkg:npm/chalk@5.6.1"]); + }); +}); + +describe("malware advisory fixture", () => { + it("loads the shipped advisory from the fixture cache", () => { + const advisories = loadFixtureAdvisories(); + assert.equal(advisories.length, 1); + assert.equal(advisories[0].ghsaId, FIXTURE_GHSA); + assert.deepEqual( + advisories[0].vulnerabilities.map(v => `${v.ecosystem}:${v.name}:${v.vulnerableVersionRange}`), + ["NPM:chalk:=5.6.1"] + ); + }); +}); + +describe("malware matching", () => { + it("matches chalk 5.6.1 on the default branch and on the 'test' branch", () => { + const matches = fixtureMatches(); + assert.equal(matches.length, 2); + for (const match of matches) { + assert.equal(match.repo, FIXTURE_REPO); + assert.equal(match.purl, "pkg:npm/chalk@5.6.1"); + assert.equal(match.advisoryGhsaId, FIXTURE_GHSA); + assert.equal(match.vulnerableVersionRange, "=5.6.1"); + } + assert.deepEqual(matches.map(m => m.branch), [undefined, "test"]); + }); + + it("does not match a package outside the vulnerable version range", () => { + const sboms: RepositorySbom[] = [{ + repo: FIXTURE_REPO, + org: "advanced-security", + retrievedAt: new Date().toISOString(), + packages: [{ name: "chalk", version: "5.6.2", purl: "pkg:npm/chalk@5.6.2" }] + }]; + assert.deepEqual(matchMalware(loadFixtureAdvisories(), sboms), []); + }); + + it("honours the advisory date cutoff", () => { + const advisories = loadFixtureAdvisories(); + const sboms = readAll(sbomFixtureDir); + // The fixture advisory was both published and updated on 2025-09-29 + assert.equal(matchMalware(advisories, sboms, { advisoryDateCutoff: "2025-09-29" }).length, 2); + assert.equal(matchMalware(advisories, sboms, { advisoryDateCutoff: "2025-09-30" }).length, 0); + }); +}); + +describe("ignore file", () => { + it("parses the shipped ignore.example.yml", () => { + const matcher = IgnoreMatcher.load("ignore.example.yml", { cwd: repoRoot }); + if (!matcher) throw new Error("expected ignore.example.yml to parse"); + // The example file does not reference the fixture advisory, so nothing is filtered out + const { kept, ignored } = matcher.filter(fixtureMatches()); + assert.equal(kept.length, 2); + assert.equal(ignored.length, 0); + }); + + it("filters matches for an ignored advisory", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sbom-toolkit-ignore-")); + try { + const ignoreFile = path.join(dir, "ignore.yml"); + fs.writeFileSync(ignoreFile, `advisories:\n - ${FIXTURE_GHSA}\n`, "utf8"); + const matcher = IgnoreMatcher.load(ignoreFile); + if (!matcher) throw new Error("expected the ignore file to parse"); + const { kept, ignored } = matcher.filter(fixtureMatches()); + assert.equal(kept.length, 0); + assert.equal(ignored.length, 2); + assert.deepEqual(new Set(ignored.map(i => i.ignoreReason)), new Set([`advisory:${FIXTURE_GHSA}`])); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("PURL search", () => { + let collector: SbomCollector; + + before(async () => { + collector = new SbomCollector({ token: undefined, loadFromDir: sbomFixtureDir, syncSboms: false, quiet: true }); + await collector.collect(); + }); + + it("loads the fixture SBOM offline", () => { + assert.deepEqual(collector.getAllSboms().map(s => s.repo), [FIXTURE_REPO]); + }); + + it("matches an exact PURL on the default branch and on branch diffs", () => { + const results = collector.searchByPurlsWithReasons(["pkg:npm/chalk@5.6.1"]); + assert.deepEqual(results.get(FIXTURE_REPO)?.map(e => e.purl), ["pkg:npm/chalk@5.6.1", "pkg:npm/chalk@5.6.1@test"]); + }); + + it("matches a semver range, adding the 'pkg:' prefix when it is omitted", () => { + const entries = collector.searchByPurlsWithReasons(["npm/chalk@>=5.0.0 <6.0.0"]).get(FIXTURE_REPO); + assert.deepEqual(entries?.map(e => e.purl), ["pkg:npm/chalk@5.6.1", "pkg:npm/chalk@5.6.1@test"]); + assert.deepEqual(new Set(entries?.map(e => e.reason)), new Set(["pkg:npm/chalk@>=5.0.0 <6.0.0"])); + }); + + it("matches a wildcard query", () => { + const results = collector.searchByPurlsWithReasons(["pkg:npm/left-pad@*"]); + assert.deepEqual(results.get(FIXTURE_REPO)?.map(e => e.purl), ["pkg:npm/left-pad@1.3.0"]); + }); + + it("returns no results for a package that is not in the SBOM", () => { + assert.equal(collector.searchByPurlsWithReasons(["pkg:npm/not-in-the-sbom@1.0.0"]).size, 0); + }); +}); From 5186c96a5d21a6ed264f2a021911b8596f3af360 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:32:29 +0000 Subject: [PATCH 2/2] Document tests in README, TESTING.md and CHANGELOG Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ README.md | 15 ++++++++++++--- TESTING.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 TESTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cda4a1..9b447a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +Added: + +- Continuous integration: GitHub Actions workflow that restores packages, builds and tests on every push to `main` and every pull request. +- Unit tests using Node's built-in test runner (no new dependencies) covering the shipped fixtures: SBOM and advisory cache loading, branch diffs, malware matching and the advisory date cutoff, the ignore file, PURL search, and the documented offline CLI invocations. +- [TESTING.md](TESTING.md) documenting how to run the tests and what they cover. + +Fixed: + +- Corrected the example test output in the README, which named a repository that is not in the fixtures and omitted the branch match. + ## [2025-12-09] โ€“ 0.2.0 - Branch scanning and dependency submission Added: diff --git a/README.md b/README.md index 52e5bea..8896a37 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ npm run build ## ๐Ÿงช Test -The repo ships with a minimal test fixture to validate end-to-end malware matching without making network calls. +The repo ships with minimal test fixtures (`fixtures/`) that validate end-to-end malware matching and PURL search without making network calls. 1. Build the project: @@ -442,7 +442,13 @@ npm install npm run build ``` -2. Run the test harness script: +2. Run the tests: + +```bash +npm test +``` + +That runs the unit tests (Node's built-in test runner) plus the two fixture harness scripts. To run a single harness on its own: ```bash node dist/test-fixture-match.js @@ -452,7 +458,8 @@ You should see output similar to: ```text Matches: -chalk-org/chalk-repo => pkg:npm/chalk@5.6.1 matched advisory GHSA-test-chalk-561 range =5.6.1 +advanced-security/test-sbom-repo => pkg:npm/chalk@5.6.1 matched advisory GHSA-test-chalk-561 range =5.6.1 +advanced-security/test-sbom-repo => pkg:npm/chalk@5.6.1 matched advisory GHSA-test-chalk-561 range =5.6.1 on branch test ``` Alternatively, you can exercise the CLI purely offline using the fixtures (no token required): @@ -461,6 +468,8 @@ Alternatively, you can exercise the CLI purely offline using the fixtures (no to npm run start -- --sbom-cache fixtures/sboms --malware-cache fixtures/malware-cache --match-malware ``` +See [TESTING.md](TESTING.md) for more detail. Tests also run in CI on every push and pull request. + ## ๐Ÿšฆ Rate Limiting Standard & secondary rate limits trigger an automatic retry (up to 2 times). diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..8990c2e --- /dev/null +++ b/TESTING.md @@ -0,0 +1,54 @@ +# Testing + +All tests run offline against the fixtures in `fixtures/`, so no GitHub token or network access is required. + +## Running the tests + +```bash +npm install +npm run build +npm test +``` + +`npm test` runs against the compiled output in `dist/`, so `npm run build` must be run first (or again, after changing anything in `src/`). + +The same three steps run in CI (`.github/workflows/ci.yml`) on every push to `main` and on every pull request. + +## What is covered + +`npm test` runs two kinds of test: + +### Unit tests + +Node's built-in test runner (`node --test`) runs the `src/*.test.ts` files, so no test framework dependency is needed: + +| File | Covers | +| ---- | ------ | +| `src/fixtures.test.ts` | Loading the SBOM and malware advisory fixtures, branch diff revival, malware matching (including the advisory date cutoff), the `ignore.example.yml` ignore file, and PURL search (exact, semver range and wildcard queries) | +| `src/cli.test.ts` | Running the built CLI offline against the fixtures, as documented in the [README](README.md): malware matching, JSON search output, and the no-match case | + +### Fixture harness scripts + +These are also runnable on their own, and print their results for inspection: + +| Script | Covers | +| ------ | ------ | +| `node dist/test-fixture-match.js` | End-to-end malware matching against `fixtures/` | +| `node dist/test-branch-search.js` | PURL search over a synthetic repository SBOM with branch dependency diffs | + +## Adding tests + +Add a new `src/.test.ts` file using `node:test` and `node:assert/strict`, then add the compiled path (`dist/.test.js`) to the `test` script in `package.json`. Test files are listed explicitly rather than globbed so the runner behaves the same on every supported Node version and platform. + +## Fixtures + +| Fixture | Contents | +| ------- | -------- | +| `fixtures/sboms/advanced-security/test-sbom-repo/sbom.json` | An SPDX SBOM with `chalk@5.6.1` and `left-pad@1.3.0`, plus a dependency review diff adding `chalk@5.6.1` on a `test` branch | +| `fixtures/malware-cache/malware-advisories.json` | A single fake malware advisory, `GHSA-test-chalk-561`, covering npm `chalk` version `=5.6.1` | + +Matching these two fixtures produces two matches: one on the default branch and one on the `test` branch. + +## End-to-end testing + +Unit tests do not cover the GitHub API paths. Before submitting changes that touch collection, sync or upload, also run the CLI against a test organization and Enterprise and check the results are as expected, as described in [CONTRIBUTING.md](CONTRIBUTING.md).