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

mikronova/modgunn

tests/cli_scorecard.rs

//! End-to-end CLI test for the `modgunn scorecard` verb — the flag-parsing +
//! `parse_scorecard` fold + findings-print + `append_scorecard` persistence path in
//! `src/main.rs` that the library-level `provenance`/`warehouse` unit tests never
//! touch as one flow.
//!
//! Writes a real-shaped `scorecard --format json` fixture to a scratch dir, then
//! DRIVES the compiled binary (`CARGO_BIN_EXE_modgunn`): first a pure parse+print
//! (no `--warehouse`), then a persisting run, then reads the persisted signals back
//! through `warehouse provenance-log` — the append→load round-trip THROUGH the CLI.
//! Fully offline, zero-shell.
//!
//! Gated on `warehouse` (the binary's `required-features`, without which
//! `CARGO_BIN_EXE_modgunn` is not built). Run:
//! `cargo test --features "warehouse manifests test-support" --test cli_scorecard`.
#![cfg(feature = "warehouse")]

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

/// A unique scratch directory under the system temp root.
fn temp_dir(tag: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("modgunn-scorecard-{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),
    )
}

/// A `scorecard --format json` report: low aggregate, a FOUND dangerous workflow
/// (score 0 on the inverted scale), a failing branch-protection, an unmaintained
/// project. Mirrors the shape `provenance::parse_scorecard` folds.
const REPORT: &str = r#"{
    "date": "2026-07-14",
    "repo": { "name": "github.com/acme/widget", "commit": "deadbeef" },
    "scorecard": { "version": "v5.0.0" },
    "score": 3.4,
    "checks": [
        { "name": "Maintained", "score": 1, "reason": "no commits in 180d" },
        { "name": "Branch-Protection", "score": 2, "reason": "no required reviews" },
        { "name": "Signed-Releases", "score": -1, "reason": "no releases" },
        { "name": "Dangerous-Workflow", "score": 0, "reason": "script injection in CI" }
    ]
}"#;

/// Without `--warehouse` the verb is a pure parse+print: it folds the report, prints
/// the signals line + every advisory finding, exit 0. RED-when-broken: the **High**
/// `MODGUNN-SCORECARD-DANGEROUS-WORKFLOW` finding must appear — if the inverted
/// dangerous-workflow scale or the severity fold breaks, this line is absent.
#[test]
fn scorecard_parses_and_prints_findings() {
    let dir = temp_dir("print");
    let report = dir.join("report.json");
    std::fs::write(&report, REPORT).unwrap();

    let (stdout, stderr, code) = run(&["scorecard", "--path", report.to_str().unwrap()]);
    assert_eq!(code, 0, "exit 0; stderr={stderr}");
    assert!(
        stdout.contains("github.com/acme/widget"),
        "subject line missing:\n{stdout}"
    );
    assert!(
        stdout.contains("dangerous_workflow=true"),
        "dangerous-workflow flag missing:\n{stdout}"
    );
    // The load-bearing High finding (RED-when-broken).
    assert!(
        stdout.contains("High") && stdout.contains("MODGUNN-SCORECARD-DANGEROUS-WORKFLOW"),
        "High dangerous-workflow finding missing:\n{stdout}"
    );
    assert!(
        stdout.contains("MODGUNN-SCORECARD-LOW"),
        "low-score finding missing:\n{stdout}"
    );
    assert!(
        stdout.contains("MODGUNN-SCORECARD-UNMAINTAINED"),
        "unmaintained finding missing:\n{stdout}"
    );
    assert!(
        stdout.contains("MODGUNN-SCORECARD-NO-BRANCH-PROTECTION"),
        "no-branch-protection finding missing:\n{stdout}"
    );
    assert!(
        stdout.contains("4 scorecard finding(s)"),
        "finding-count footer missing:\n{stdout}"
    );
    assert!(
        stdout.contains("worst High"),
        "worst-severity footer missing:\n{stdout}"
    );
    // Not persisted without --warehouse.
    assert!(
        !stdout.contains("persisted"),
        "no --warehouse ⇒ no persistence:\n{stdout}"
    );

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

/// `--warehouse <root>` PERSISTS the signals via `append_scorecard`; a subsequent
/// `warehouse provenance-log` reads them back (the append→load round-trip through the
/// CLI). `--subject` overrides the report's `repo.name`. RED-when-broken: the persisted
/// subject + its High/Medium/Low findings must reappear from the SECOND process, which
/// only sees them via the warehouse table — so a broken append or load fails here.
#[test]
fn scorecard_persists_and_round_trips_through_provenance_log() {
    let dir = temp_dir("persist");
    let report = dir.join("report.json");
    std::fs::write(&report, REPORT).unwrap();
    let root = dir.join("catalog");
    std::fs::create_dir_all(&root).unwrap();
    let root_s = root.to_str().unwrap().to_string();

    // Persist with a subject override.
    let (out, err, code) = run(&[
        "scorecard",
        "--path",
        report.to_str().unwrap(),
        "--subject",
        "acme/widget",
        "--warehouse",
        &root_s,
    ]);
    assert_eq!(code, 0, "persisting scorecard exits 0; stderr={err}");
    assert!(
        out.contains("acme/widget"),
        "subject override not applied:\n{out}"
    );
    assert!(
        out.contains("persisted into scorecard_signals"),
        "persist footer missing:\n{out}"
    );

    // Round-trip: a FRESH process reads the persisted signals back out of the table.
    let (log, log_err, log_code) = run(&["warehouse", "provenance-log", "--warehouse", &root_s]);
    assert_eq!(log_code, 0, "provenance-log exits 0; stderr={log_err}");
    assert!(
        log.contains("acme/widget"),
        "persisted subject not read back:\n{log}"
    );
    assert!(
        log.contains("MODGUNN-SCORECARD-DANGEROUS-WORKFLOW"),
        "persisted High finding not read back:\n{log}"
    );
    assert!(
        log.contains("MODGUNN-SCORECARD-UNMAINTAINED"),
        "persisted unmaintained finding not read back:\n{log}"
    );
    assert!(
        log.contains("1 scorecard subject(s)"),
        "provenance-log scorecard tally missing:\n{log}"
    );

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

/// A missing report file is a hard error (non-zero exit) — the negative,
/// RED-when-broken assertion that the verb doesn't silently succeed on bad input.
#[test]
fn scorecard_missing_file_errors() {
    let dir = temp_dir("missing");
    let missing = dir.join("nope.json");

    let (_stdout, stderr, code) = run(&["scorecard", "--path", missing.to_str().unwrap()]);
    assert_ne!(code, 0, "a missing report must fail");
    assert!(
        stderr.contains("read"),
        "expected a read error naming the file:\n{stderr}"
    );

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