Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

tests/cli_warehouse.rs

//! End-to-end CLI glue tests for the `warehouse` verbs (`scan`, `rollup`,
//! `provenance-log`) — the flag-parsing + warehouse-open + print path in
//! `src/main.rs` that the library-level unit tests never touch.
//!
//! Each test seeds a temp warehouse through modgunn's PUBLIC writer API, drops the
//! writer (skade is single-writer per catalog, so the lock must be released before
//! the CLI subprocess opens the same root), then DRIVES the real compiled binary
//! (`CARGO_BIN_EXE_modgunn`) and asserts on its stdout / exit status. Fully offline.
//!
//! Gated on `warehouse` (the binary's `required-features`): without it the CLI is
//! not built and this crate compiles to nothing.
#![cfg(feature = "warehouse")]

use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

use modgunn::provenance::ScorecardSignals;
use modgunn::warehouse::{BuildProvenanceVerdict, Warehouse, Zone};
#[cfg(feature = "test-support")]
use modgunn::{core::Severity, scan::Advisory};

/// A unique scratch catalog root (single-writer per catalog).
fn temp_root(tag: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("modgunn-cli-{tag}-{nanos}"));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Run the compiled `modgunn` binary with `args`; return `(stdout, stderr, code)`.
fn run(args: &[&str]) -> (String, String, i32) {
    let out = Command::new(env!("CARGO_BIN_EXE_modgunn"))
        .args(args)
        .output()
        .expect("spawn modgunn binary");
    (
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
        out.status.code().unwrap_or(-1),
    )
}

/// `warehouse scan` and `warehouse rollup` on a warehouse with no vulnerable
/// components print the wired fleet rollup summary (the "fleet clean" line) and exit
/// 0 — proving the `cmd_scan` / `cmd_rollup` glue (open → scan → render) runs.
#[test]
fn scan_and_rollup_print_the_wired_fleet_summary() {
    let root = temp_root("scan");
    let root_s = root.to_str().unwrap().to_string();
    {
        let wh = Warehouse::open_in_zone(&root, Zone::Demo).unwrap();
        wh.ensure_modgunn_tables().unwrap();
        // No sbom_components / advisories seeded ⇒ a clean fleet.
    } // drop releases the writer lock before the subprocess opens the same root.

    let (out, err, code) = run(&["warehouse", "scan", "--warehouse", &root_s]);
    assert_eq!(code, 0, "clean scan exits 0 (stderr: {err})");
    assert!(
        out.contains("fleet clean"),
        "scan prints the rollup summary: {out}"
    );
    assert!(
        out.contains("0 finding(s)"),
        "scan prints the finding count: {out}"
    );

    let (out2, _err2, code2) = run(&["warehouse", "rollup", "--warehouse", &root_s]);
    assert_eq!(code2, 0, "rollup exits 0");
    assert!(
        out2.contains("fleet clean"),
        "rollup prints the summary: {out2}"
    );

    // An unknown --engine falls back with a warning rather than failing the scan.
    let (_o3, err3, code3) = run(&[
        "warehouse",
        "scan",
        "--warehouse",
        &root_s,
        "--engine",
        "bogus",
    ]);
    assert_eq!(code3, 0, "unknown engine still scans (fallback)");
    assert!(
        err3.contains("unknown --engine"),
        "fallback warns on stderr: {err3}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// `warehouse provenance-log` reads back the build-provenance VERIFY ledger AND
/// surfaces the OSSF Scorecard advisory findings — the `query_build_provenance` +
/// `load_scorecard` + `ScorecardSignals::findings` wiring. Asserts both a verified
/// and a rejected verdict print, `--id` scopes to one row, and the low-score /
/// unmaintained scorecard findings appear.
#[test]
fn provenance_log_prints_verdicts_and_scorecard_findings() {
    let root = temp_root("provlog");
    let root_s = root.to_str().unwrap().to_string();
    {
        let wh = Warehouse::open_in_zone(&root, Zone::Demo).unwrap();
        wh.ensure_modgunn_tables().unwrap();
        wh.append_build_provenance(&[
            BuildProvenanceVerdict::new(
                "id-ok",
                "holger",
                "aa".repeat(32),
                Some("nordisk-ci".to_string()),
                true,
                "verified: signed by `nordisk-ci`",
            ),
            BuildProvenanceVerdict::new(
                "id-bad",
                "skidbladnir",
                "bb".repeat(32),
                None,
                false,
                "attestation digest does not match its record (record tampered)",
            ),
        ])
        .unwrap();
        wh.append_scorecard(&[ScorecardSignals {
            subject: "left-pad".into(),
            overall: 2.5,
            maintained: false,
            branch_protection: false,
            signed_releases: false,
            dangerous_workflow: false,
        }])
        .unwrap();
    }

    // Full log: both verdicts + the scorecard findings the signals fold into.
    let (out, err, code) = run(&["warehouse", "provenance-log", "--warehouse", &root_s]);
    assert_eq!(code, 0, "provenance-log exits 0 (stderr: {err})");
    assert!(
        out.contains("VERIFIED") && out.contains("id-ok"),
        "verified verdict: {out}"
    );
    assert!(
        out.contains("REJECTED") && out.contains("id-bad"),
        "rejected verdict: {out}"
    );
    assert!(
        out.contains("MODGUNN-SCORECARD-LOW"),
        "low-score finding surfaced: {out}"
    );
    assert!(
        out.contains("MODGUNN-SCORECARD-UNMAINTAINED"),
        "unmaintained finding surfaced: {out}"
    );
    assert!(
        out.contains("2 provenance verdict(s) (1 verified, 1 rejected)"),
        "summary: {out}"
    );

    // --id scopes to a single verdict row.
    let (out_ok, _e, _c) = run(&[
        "warehouse",
        "provenance-log",
        "--warehouse",
        &root_s,
        "--id",
        "id-ok",
    ]);
    assert!(out_ok.contains("id-ok"), "scoped row present: {out_ok}");
    assert!(
        !out_ok.contains("id-bad"),
        "--id filters out the other verdict: {out_ok}"
    );
    assert!(
        out_ok.contains("1 provenance verdict(s)"),
        "one row: {out_ok}"
    );

    // --repo scopes by repo.
    let (out_repo, _e, _c) = run(&[
        "warehouse",
        "provenance-log",
        "--warehouse",
        &root_s,
        "--repo",
        "skidbladnir",
    ]);
    assert!(
        out_repo.contains("id-bad") && !out_repo.contains("id-ok"),
        "--repo filters: {out_repo}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// `warehouse provenance-diff` compares the build-provenance ledger at two Iceberg
/// snapshots and prints the supply-chain delta: added / removed / verdict-transition.
/// Seeds two snapshots via the public writer (capturing each `snapshot_id`), then
/// drives the binary and asserts the regression (VERIFIED → REJECTED) + the fresh add
/// are reported, `--fail-on-regression` exits 3, and a snapshot-vs-itself is clean.
#[test]
fn provenance_diff_reports_added_and_verdict_regression() {
    let root = temp_root("provdiff");
    let root_s = root.to_str().unwrap().to_string();

    let (s1, s2);
    {
        let wh = Warehouse::open_in_zone(&root, Zone::Demo).unwrap();
        wh.ensure_modgunn_tables().unwrap();
        // Snapshot 1: one verified artifact.
        wh.append_build_provenance(&[BuildProvenanceVerdict::new(
            "id-1",
            "holger",
            "aa".repeat(32),
            Some("nordisk-ci".to_string()),
            true,
            "verified",
        )])
        .unwrap();
        s1 = wh
            .current_snapshot_id("build_provenance")
            .unwrap()
            .expect("snap1");
        // Snapshot 2: id-1 now REJECTED (regression) + a fresh id-2.
        wh.append_build_provenance(&[
            BuildProvenanceVerdict::new(
                "id-1",
                "holger",
                "aa".repeat(32),
                None,
                false,
                "record tampered",
            ),
            BuildProvenanceVerdict::new(
                "id-2",
                "skidbladnir",
                "bb".repeat(32),
                Some("nordisk-ci".to_string()),
                true,
                "verified",
            ),
        ])
        .unwrap();
        s2 = wh
            .current_snapshot_id("build_provenance")
            .unwrap()
            .expect("snap2");
    }

    let (s1s, s2s) = (s1.to_string(), s2.to_string());

    // Diff s1 → s2: id-2 added, id-1 regressed.
    let (out, err, code) = run(&[
        "warehouse",
        "provenance-diff",
        "--warehouse",
        &root_s,
        "--from",
        &s1s,
        "--to",
        &s2s,
    ]);
    assert_eq!(
        code, 0,
        "provenance-diff exits 0 without --fail-on-regression (stderr: {err})"
    );
    assert!(
        out.contains("+ ") && out.contains("id-2"),
        "id-2 added row: {out}"
    );
    assert!(
        out.contains("~ ") && out.contains("id-1"),
        "id-1 transition row: {out}"
    );
    assert!(out.contains("REGRESSION"), "regression tagged: {out}");
    assert!(
        out.contains("VERIFIED → REJECTED"),
        "verdict transition printed: {out}"
    );
    assert!(
        out.contains("1 added, 0 removed, 1 changed (1 regression(s))"),
        "summary: {out}"
    );

    // --fail-on-regression turns the same regression into a non-zero gate exit.
    let (_o, _e, code_fail) = run(&[
        "warehouse",
        "provenance-diff",
        "--warehouse",
        &root_s,
        "--from",
        &s1s,
        "--to",
        &s2s,
        "--fail-on-regression",
    ]);
    assert_eq!(code_fail, 3, "regression + --fail-on-regression exits 3");

    // From the empty beginning to s1: id-1 is a pure add, no regression.
    let (out_first, _e, code_first) = run(&[
        "warehouse",
        "provenance-diff",
        "--warehouse",
        &root_s,
        "--to",
        &s1s,
    ]);
    assert_eq!(code_first, 0);
    assert!(
        out_first.contains("id-1") && out_first.contains("1 added, 0 removed, 0 changed"),
        "beginning→s1: {out_first}"
    );

    // A snapshot against itself is unchanged.
    let (out_same, _e, _c) = run(&[
        "warehouse",
        "provenance-diff",
        "--warehouse",
        &root_s,
        "--from",
        &s2s,
        "--to",
        &s2s,
    ]);
    assert!(
        out_same.contains("provenance unchanged"),
        "self-diff clean: {out_same}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// End-to-end CLI `warehouse scan` over a genuinely-seeded fleet (feature
/// `test-support` supplies the nornir-owned `sbom_components` writer): a full scan
/// flags the vulnerable component in every repo, `--since <snapshot>` does the CDC
/// delta (only the components added after the watermark) and prints the next
/// watermark, and `--vex <dir>` suppresses findings whose CVE a CSAF VEX asserts
/// not-affected — the `cmd_scan` `--since` + `--vex` glue the library tests never
/// reach through the real binary.
#[cfg(feature = "test-support")]
#[test]
fn scan_since_delta_and_vex_suppression_end_to_end() {
    let root = temp_root("scan-delta");
    let root_s = root.to_str().unwrap().to_string();

    let s1;
    {
        let wh = Warehouse::open_in_zone(&root, Zone::Demo).unwrap();
        wh.ensure_modgunn_tables().unwrap();
        // A single high advisory keyed by a CVE id (so a VEX can suppress it by id).
        wh.append_advisories(
            "rev1",
            &[Advisory::basic(
                "CVE-2023-1234",
                "cargo",
                "openssl",
                vec!["0.10.55".into()],
                Severity::High,
                "use-after-free",
            )],
        )
        .unwrap();
        // Snapshot 1: openssl vulnerable in repoA (+ a clean serde).
        wh.seed_sbom_components(&[("repoA", "openssl", "0.10.55"), ("repoA", "serde", "1.0.0")])
            .unwrap();
        s1 = wh
            .current_snapshot_id("sbom_components")
            .unwrap()
            .expect("snap1");
        // Snapshot 2: the SAME vulnerable openssl NEWLY appears in repoB.
        wh.seed_sbom_components(&[("repoB", "openssl", "0.10.55")])
            .unwrap();
    }

    // Full scan: openssl flagged in BOTH repos.
    let (out_full, err, code) = run(&["warehouse", "scan", "--warehouse", &root_s]);
    assert_eq!(code, 0, "full scan exits 0 (stderr: {err})");
    assert!(
        out_full.contains("repoA") && out_full.contains("repoB"),
        "both repos flagged: {out_full}"
    );
    assert!(
        out_full.contains("CVE-2023-1234"),
        "advisory id printed: {out_full}"
    );
    assert!(
        out_full.contains("2 finding(s)"),
        "two findings full: {out_full}"
    );

    // --since s1: the CDC delta scans only components ADDED after s1 → repoB only,
    // and prints the next watermark to poll from.
    let s1s = s1.to_string();
    let (out_since, _e, code_since) =
        run(&["warehouse", "scan", "--warehouse", &root_s, "--since", &s1s]);
    assert_eq!(code_since, 0);
    assert!(
        out_since.contains("repoB"),
        "delta flags the newly-added repoB: {out_since}"
    );
    assert!(
        !out_since.contains("repoA"),
        "delta excludes the pre-watermark repoA: {out_since}"
    );
    assert!(
        out_since.contains("1 finding(s)"),
        "one delta finding: {out_since}"
    );
    assert!(
        out_since.contains("next --since "),
        "prints the next watermark: {out_since}"
    );

    // --vex: a CSAF doc asserting CVE-2023-1234 not-affected suppresses BOTH findings.
    let vex_dir = root.join("vex");
    std::fs::create_dir_all(&vex_dir).unwrap();
    std::fs::write(
        vex_dir.join("suppress.json"),
        r#"{ "vulnerabilities": [
            { "cve": "CVE-2023-1234",
              "product_status": { "known_not_affected": ["CSAFPID-openssl"] } }
        ] }"#,
    )
    .unwrap();
    let vex_s = vex_dir.to_str().unwrap().to_string();
    let (out_vex, _e, code_vex) =
        run(&["warehouse", "scan", "--warehouse", &root_s, "--vex", &vex_s]);
    assert_eq!(code_vex, 0);
    assert!(
        out_vex.contains("2 VEX-suppressed"),
        "both findings suppressed: {out_vex}"
    );
    assert!(
        out_vex.contains("0 finding(s)"),
        "nothing left after suppression: {out_vex}"
    );
    assert!(
        !out_vex.contains("CVE-2023-1234"),
        "the suppressed finding row is gone: {out_vex}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// `warehouse import` — the widest flag surface (`--osv`/`--malicious`/`--kev`/
/// `--epss`) with ZERO prior e2e coverage. Drives the real binary: a valid + a
/// malformed OSV doc (the malformed one is NAMED + counted, never silently dropped
/// — the "reduced coverage" path), a KEV json + EPSS csv enrichment, then a
/// round-trip through `warehouse stats`. Also asserts the empty-source guard bails.
#[test]
fn import_verb_drives_osv_kev_epss_and_round_trips_through_stats() {
    let root = temp_root("import");
    let root_s = root.to_str().unwrap().to_string();

    // --osv dir: one valid minimal OSV (cargo pkg + version) + one malformed doc.
    let osv_dir = root.join("osv");
    std::fs::create_dir_all(&osv_dir).unwrap();
    std::fs::write(
        osv_dir.join("good.json"),
        r#"{ "id": "CVE-2021-44228", "summary": "rce",
             "affected": [ { "package": { "ecosystem": "crates.io", "name": "log4rs" },
                            "versions": ["1.0.0"] } ] }"#,
    )
    .unwrap();
    std::fs::write(osv_dir.join("bad.json"), b"{ not valid json").unwrap();
    let osv_s = osv_dir.to_str().unwrap().to_string();

    // --kev json + --epss csv keyed on the same CVE.
    let kev_path = root.join("kev.json");
    std::fs::write(
        &kev_path,
        r#"{ "vulnerabilities": [ { "cveID": "CVE-2021-44228" } ] }"#,
    )
    .unwrap();
    let epss_path = root.join("epss.csv");
    std::fs::write(
        &epss_path,
        "cve,epss,percentile\nCVE-2021-44228,0.97,0.99\n",
    )
    .unwrap();

    let (out, err, code) = run(&[
        "warehouse",
        "import",
        "--warehouse",
        &root_s,
        "--rev",
        "rev1",
        "--osv",
        &osv_s,
        "--kev",
        kev_path.to_str().unwrap(),
        "--epss",
        epss_path.to_str().unwrap(),
    ]);
    assert_eq!(code, 0, "import exits 0 (stderr: {err})");
    assert!(
        out.contains("imported 1 advisories"),
        "one advisory imported: {out}"
    );
    assert!(
        out.contains("1 source files skipped"),
        "the malformed doc is counted: {out}"
    );
    assert!(out.contains("1 KEV"), "KEV enrichment reported: {out}");
    assert!(
        out.contains("1 EPSS-scored"),
        "EPSS enrichment reported: {out}"
    );
    assert!(
        err.contains("CVE coverage may be reduced"),
        "reduced-coverage warning on stderr: {err}"
    );

    // Round-trip: stats reports rev1 with the advisory count (single-writer — the
    // import subprocess has already dropped its lock).
    let (stats, _e, scode) = run(&["warehouse", "stats", "--warehouse", &root_s]);
    assert_eq!(scode, 0, "stats exits 0");
    assert!(
        stats.contains("rev1"),
        "stats reports the imported rev: {stats}"
    );
    assert!(
        stats.contains("1 advisories"),
        "stats reports the advisory count: {stats}"
    );

    // Empty-source guard: no --osv/--malicious → non-zero exit + clear reason.
    let (_o, e2, c2) = run(&[
        "warehouse",
        "import",
        "--warehouse",
        &root_s,
        "--rev",
        "rev2",
    ]);
    assert_ne!(c2, 0, "empty import bails");
    assert!(e2.contains("nothing to import"), "clear bail reason: {e2}");

    std::fs::remove_dir_all(&root).ok();
}