cleanlib-cli 0.1.4

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! `cleanlib login` (cycle-7 Cli2). Migrates `cmd_login` from `main.rs` and
//! extends it with optional keyring-storage (Cli6) via `--keyring`.
//!
//! Default behavior remains the cycle-4 contract — writes the bearer to
//! `~/.cleanlibrary/config.toml` for backwards-compat with existing scripts
//! that read that file. The new `--keyring` flag opts into the cycle-7
//! 3-tier auth substrate (env → keyring → gcloud).

use anyhow::Result;
use cleanlib_client::config;
use cleanlib_client::Client;
use cleanlib_client::errors::{CleanLibraryError, TransportError}; 

use crate::auth;

/// CLEANLIB-129 / Jira CLEANLIB-27: reject empty / whitespace-only API key
/// at the CLI boundary BEFORE we write anything to disk or keyring. Sister
/// of `[[feedback_substrate_state_fresh_read_before_banking]]` at the
/// runtime-validation axis — fail loud, not silent fail-open.
///
/// Returned as a plain `anyhow::Error` so `main.rs` propagates it to the
/// process exit code (sister of CLEANLIB-130 exit-code semantics — auth
/// failures land on exit 1, the same code as DENY).
fn validate_api_key(api_key: &str) -> Result<()> {
    if api_key.is_empty() {
        anyhow::bail!(
            "CLIENT_BEARER_EMPTY — API key must not be empty.\n\
             Pass a non-empty value via `cleanlib login --api-key <KEY>` or\n\
             set CLEANLIB_ENRICH_BEARER in your environment."
        );
    }
    if api_key.trim().is_empty() {
        anyhow::bail!(
            "CLIENT_BEARER_WHITESPACE — API key must not be whitespace-only.\n\
             Pass the literal API key value to `cleanlib login --api-key <KEY>`."
        );
    }
    Ok(())
}

pub async fn run(api_key: String, use_keyring: bool) -> Result<()> {
    // CLEANLIB-27 close: reject empty bearer up-front, before any side
    // effect (no keyring write, no config write, no false-positive UX in
    // `cleanlib status`).
    validate_api_key(&api_key)?;
        {
        let mut probe_cfg = config::load_with_env_overrides(config::default_path().as_deref()).unwrap_or_default();
        probe_cfg.auth.api_key = Some(api_key.clone());
        let client = Client::from_config(&probe_cfg)?;
        match client.probe_auth().await {
            Ok(()) => {}
            Err(CleanLibraryError::Authentication { ref reason_code, .. })
                if reason_code == "EXPIRED_OR_INVALID_API_KEY" =>
            {
                anyhow::bail!(
                    "KEY_EXPIRED — Your API key has expired.\nGenerate a new key and run: cleanlib login --api-key <NEW_KEY>"
                );
            }
            Err(CleanLibraryError::Authentication { ref reason_code, .. })
                if reason_code == "INVALID_API_KEY" =>
            {
                anyhow::bail!(
                    "INVALID_API_KEY — Key not recognised by server.\nCheck your key and run: cleanlib login --api-key <KEY>"
                );
            }
            Err(CleanLibraryError::Authentication { ref reason_code, .. })
                if reason_code == "MISSING_OR_INVALID_AUTH_HEADER" =>
            {
                anyhow::bail!(
                    "BAD_KEY_FORMAT — Key format is invalid.\nRe-copy your key exactly and run: cleanlib login --api-key <KEY>"
                );
            }
            Err(CleanLibraryError::Authentication { ref reason_code, .. }) => {
                anyhow::bail!(
                    "AUTH_FAILED [{}] — Authentication rejected.\nIf this persists contact support with the reason code above.",
                    reason_code
                );
            }
            Err(CleanLibraryError::ServerError { status: 503, .. }) => {
                anyhow::bail!(
                    "CDP_UNAVAILABLE — Authentication service temporarily unavailable.\nYour key may be valid. Wait and try again: cleanlib login --api-key <KEY>"
                );
            }
            Err(CleanLibraryError::ServerError { status, .. }) => {
                anyhow::bail!(
                    "SERVER_ERROR [{}] — Unexpected server error.\nIf this persists contact support.",
                    status
                );
            }
            Err(CleanLibraryError::Transport(TransportError::Timeout)) => {
                anyhow::bail!(
                    "TIMEOUT — Server did not respond.\nCheck your network and try again."
                );
            }
            Err(CleanLibraryError::Transport(TransportError::Network(e))) => {
                anyhow::bail!(
                    "NETWORK_ERROR — Cannot reach server: {}\nCheck your internet connection.",
                    e
                );
            }
            Err(other) => {
                anyhow::bail!(
                    "LOGIN_ERROR — Unexpected error: {}",
                    other
                );
            }
        }
    }


    if use_keyring {
        auth::bearer::store_in_keyring(&api_key)?;
        eprintln!(
            "api key stored in keyring (service={}, user={})",
            auth::KEYRING_SERVICE,
            auth::KEYRING_USER
        );
        return Ok(());
    }

    let path = config::default_path()
        .ok_or_else(|| anyhow::anyhow!("home directory not discoverable; cannot write config"))?;

    let mut cfg = if path.exists() {
        config::load(&path)?
    } else {
        config::Config::default()
    };

    cfg.auth.api_key = Some(api_key);
    config::save(&cfg, &path)?;

    eprintln!("api key stored in {}", path.display());
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::validate_api_key;

    // ── validate_api_key tests (client-side) ──────────────────────────────

    #[test]
    fn rejects_empty_api_key() {
        let err = validate_api_key("").unwrap_err();
        assert!(err.to_string().contains("CLIENT_BEARER_EMPTY"));
    }

    #[test]
    fn rejects_whitespace_only_api_key() {
        let err = validate_api_key("   ").unwrap_err();
        assert!(err.to_string().contains("CLIENT_BEARER_WHITESPACE"));
    }

    #[test]
    fn rejects_tab_and_newline_only() {
        assert!(validate_api_key("\t").is_err());
        assert!(validate_api_key("\n").is_err());
        assert!(validate_api_key(" \t\n ").is_err());
    }

    #[test]
    fn accepts_valid_tier_keyed_bearer_shape() {
        // Per [[reference_cleanlib_internal_test_keys]] mock-cdp fixture
        // key names (e.g. std_001 / ent_001 / free_001).
        assert!(validate_api_key("std_001").is_ok());
        assert!(validate_api_key("clk_xyz_long_token_value_12345").is_ok());
    }

    // ── probe_auth tests (server-side via wiremock) ───────────────────────

    #[tokio::test]
    async fn probe_auth_accepts_valid_key() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};
        use cleanlib_client::transport;

        // Start mock server
        let server = MockServer::start().await;

        // Mock server returns 200 for /v1/audit
        Mock::given(method("GET"))
            .and(path("/v1/audit"))
            .respond_with(ResponseTemplate::new(200)
                .set_body_json(serde_json::json!({
                    "records": [],
                    "backend_status": "not_wired"
                })))
            .mount(&server)
            .await;

        // Build client pointing at mock server
        let client = transport::Client::new(
            &server.uri(),
            Some("clk_std_test_001".into()),
        ).unwrap();

        // probe_auth should return Ok(()) for 200 response
        assert!(client.probe_auth().await.is_ok());
    }

    #[tokio::test]
    async fn probe_auth_rejects_invalid_key() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};
        use cleanlib_client::transport;

        // Start mock server
        let server = MockServer::start().await;

        // Mock server returns 401 for invalid key
        Mock::given(method("GET"))
            .and(path("/v1/audit"))
            .respond_with(ResponseTemplate::new(401)
                .insert_header(
                    "x-cleanlibrary-reason",
                    "INVALID_API_KEY"
                ))
            .mount(&server)
            .await;

        // Build client with fake key
        let client = transport::Client::new(
            &server.uri(),
            Some("fake_invalid_key".into()),
        ).unwrap();

        // probe_auth should return Err for 401 response
        let result = client.probe_auth().await;
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("authentication failed") || err.contains("INVALID_API_KEY"));
    }

    #[tokio::test]
    async fn probe_auth_rejects_expired_key() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};
        use cleanlib_client::transport;

        // Start mock server
        let server = MockServer::start().await;

        // Mock server returns 401 for expired key
        Mock::given(method("GET"))
            .and(path("/v1/audit"))
            .respond_with(ResponseTemplate::new(401)
                .insert_header(
                    "x-cleanlibrary-reason",
                    "EXPIRED_OR_INVALID_API_KEY"
                ))
            .mount(&server)
            .await;

        // Build client with expired key
        let client = transport::Client::new(
            &server.uri(),
            Some("clk_expired_key".into()),
        ).unwrap();

        // probe_auth should return Err for expired key
        let result = client.probe_auth().await;
        assert!(result.is_err());
    }
}