cleanlib-cli 0.1.5

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! CLEANLIB-375 — `cleanlib audit --since` + `--decision` filter wiring.
//!
//! The transport-layer `audit()` method appends the three optional filters
//! (`since`, `decision`, `ecosystem`) as URL query pairs on the outgoing
//! `GET /v1/audit` request, but the existing `cli_matrix` integration test
//! for the audit verb matched `path_regex(r"^/v1/audit")` — it never
//! asserted that the query params reached the wire, so a regression where
//! any of the three was dropped between the CLI's clap parser and the
//! transport crate would still show every existing case green.
//!
//! These tests pin the wire-side behaviour: the mock rejects the request
//! unless every expected `?since=`, `?decision=`, `?ecosystem=` pair is
//! present, so a silent-drop of any flag surfaces here as an HTTP 404 that
//! propagates into a `CleanLibraryError` at the client boundary.
//!
//! Sister of the cycle-14 CLEANLIB-72 fix that corrected the audit URL
//! from `/v1/customer/audit` → `/v1/audit` — that fix landed the endpoint;
//! this fix locks the filter-parameter contract on top of it.

use cleanlib_client::transport;
use wiremock::matchers::{method, path_regex, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn empty_audit_response() -> serde_json::Value {
    serde_json::json!({ "records": [] })
}

fn audit_response_with_entry(decision: &str, ecosystem: &str) -> serde_json::Value {
    serde_json::json!({
        "records": [{
            "request_id": "req-cleanlib-375",
            "request_at": "2026-07-01T00:00:00Z",
            "ecosystem": ecosystem,
            "package_name": "cors",
            "package_version": "2.8.5",
            "policy_decision": decision,
            "reasoning": "audit synthetic"
        }]
    })
}

#[tokio::test]
async fn cleanlib_375_since_flag_reaches_wire_as_query_param() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .and(query_param("since", "2026-06-01T00:00:00Z"))
        .respond_with(ResponseTemplate::new(200).set_body_json(empty_audit_response()))
        .mount(&server)
        .await;

    let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
        .expect("client construct");
    // No expect_ok() convenience — assert the mock accepted the request
    // (any 404 would surface as CleanLibraryError::Http here). This IS the
    // wire-shape assertion: the mock only responds when `since=...` matches.
    client
        .audit(Some("2026-06-01T00:00:00Z"), None, None)
        .await
        .expect("since-only audit must reach the mock with the query param");
}

#[tokio::test]
async fn cleanlib_375_decision_flag_reaches_wire_as_query_param() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .and(query_param("decision", "DENY"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(audit_response_with_entry("DENY", "npm")),
        )
        .mount(&server)
        .await;

    let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
        .expect("client construct");
    let resp = client
        .audit(None, Some("DENY"), None)
        .await
        .expect("decision-only audit must reach the mock with the query param");
    assert_eq!(resp.records.len(), 1);
    assert_eq!(resp.records[0].policy_decision, "DENY");
}

#[tokio::test]
async fn cleanlib_375_since_and_decision_together_both_reach_wire() {
    // Guard against a regression where the second query-pair overwrites the
    // first (would happen if the transport layer accidentally shared a
    // `query_pairs_mut` borrow across if-arms — a class of bug that shows
    // green in single-flag tests). Both matchers must fire.
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .and(query_param("since", "2026-06-01T00:00:00Z"))
        .and(query_param("decision", "WARN"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(audit_response_with_entry("WARN", "pypi")),
        )
        .mount(&server)
        .await;

    let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
        .expect("client construct");
    let resp = client
        .audit(Some("2026-06-01T00:00:00Z"), Some("WARN"), None)
        .await
        .expect("combined since+decision audit must carry BOTH query params");
    assert_eq!(resp.records.len(), 1);
    assert_eq!(resp.records[0].policy_decision, "WARN");
}

#[tokio::test]
async fn cleanlib_375_all_three_filters_together_reach_wire() {
    // The full customer-facing combo: `cleanlib audit --since X --decision Y
    // --ecosystem Z`. Every flag must survive to the wire.
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .and(query_param("since", "2026-06-01T00:00:00Z"))
        .and(query_param("decision", "ALLOW"))
        .and(query_param("ecosystem", "npm"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(audit_response_with_entry("ALLOW", "npm")),
        )
        .mount(&server)
        .await;

    let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
        .expect("client construct");
    let resp = client
        .audit(Some("2026-06-01T00:00:00Z"), Some("ALLOW"), Some("npm"))
        .await
        .expect("since+decision+ecosystem audit must carry ALL query params");
    assert_eq!(resp.records[0].ecosystem, "npm");
}

#[tokio::test]
async fn cleanlib_375_none_filters_omit_query_params() {
    // Absence assertion — when the flag is None, the query pair must NOT be
    // appended. A mock that requires `since=` present would still pass the
    // matcher-based test above even if we appended `since=` always; this
    // catches the opposite regression by demanding zero query params.
    //
    // wiremock has no direct "no query" matcher, but the standard idiom is
    // to mount a matcher that DOES require the param and assert the outer
    // call errors out (mock returns default 404 when nothing matches). We
    // then mount a fallback matcher without the param and assert that IS
    // what the client hits.
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .and(query_param("since", "any-value"))
        .respond_with(ResponseTemplate::new(500).set_body_string("must not fire"))
        .up_to_n_times(1)
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .respond_with(ResponseTemplate::new(200).set_body_json(empty_audit_response()))
        .mount(&server)
        .await;

    let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
        .expect("client construct");
    // All filters None — must hit the fallback (200), never the
    // "since=any-value" matcher above.
    client
        .audit(None, None, None)
        .await
        .expect("None-filter audit must succeed via the fallback matcher");
}

// ── End-to-end via the compiled `cleanlib` binary ─────────────────────────
//
// The client-crate tests above pin the transport-layer wiring. The tests
// below drive the same behaviour through the compiled CLI binary — so a
// regression at the clap-arg → command::run boundary (e.g. `since` renamed
// but not threaded through) surfaces here too. `CLEANLIBRARY_APP_URL` is
// the env override the client honours in `config::load_with_env_overrides`
// — pointing it at wiremock lets the binary run without any config file.

fn cleanlib_bin() -> std::path::PathBuf {
    std::path::PathBuf::from(std::env!("CARGO_BIN_EXE_cleanlib"))
}

#[tokio::test]
async fn cleanlib_375_binary_threads_since_and_decision_to_query_params() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .and(query_param("since", "2026-06-01T00:00:00Z"))
        .and(query_param("decision", "DENY"))
        .respond_with(
            ResponseTemplate::new(200).set_body_json(audit_response_with_entry("DENY", "npm")),
        )
        .mount(&server)
        .await;

    // Isolate config discovery so the test doesn't pick up a real
    // ~/.config/cleanlib file — CLEANLIBRARY_ENDPOINT overrides the endpoint
    // regardless of what config::default_path() finds, but we set HOME to a
    // tempdir anyway to avoid touching any real API key file.
    let dir = tempfile::tempdir().expect("tempdir");
    let out = std::process::Command::new(cleanlib_bin())
        .env("CLEANLIBRARY_ENDPOINT", server.uri())
        .env("CLEANLIBRARY_API_KEY", "test-bearer")
        .env("HOME", dir.path())
        .args([
            "audit",
            "--since",
            "2026-06-01T00:00:00Z",
            "--decision",
            "DENY",
        ])
        .output()
        .expect("invoke cleanlib binary");

    assert!(
        out.status.success(),
        "audit --since --decision must exit 0; stderr: {}\nstdout: {}",
        String::from_utf8_lossy(&out.stderr),
        String::from_utf8_lossy(&out.stdout),
    );
    // Server received the request with both query params — otherwise the
    // wiremock matcher would have returned 404, propagating as an anyhow
    // error and a non-zero exit above.
}