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();
}