esdiag 0.16.4

Elastic Stack diagnostic collector and processor
use esdiag::data::{Auth, KnownHost, authenticate, get_secret, get_unlock_status};
use std::{
    path::PathBuf,
    process::{Command, Output},
    sync::{Mutex, OnceLock},
};
use tempfile::TempDir;

fn env_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn setup_env() -> (TempDir, PathBuf, PathBuf) {
    let tmp = TempDir::new().expect("temp dir");
    let config_dir = tmp.path().join(".esdiag");
    std::fs::create_dir_all(&config_dir).expect("create config dir");
    let hosts_path = config_dir.join("hosts.yml");
    let keystore_path = config_dir.join("secrets.yml");
    unsafe {
        std::env::set_var("HOME", tmp.path());
        std::env::set_var("USERPROFILE", tmp.path());
        std::env::set_var("ESDIAG_HOSTS", &hosts_path);
        std::env::set_var("ESDIAG_KEYSTORE", &keystore_path);
    }
    (tmp, hosts_path, keystore_path)
}

fn run_esdiag(args: &[&str], home: &TempDir, extra_env: &[(&str, &str)]) -> Output {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_esdiag"));
    cmd.args(args)
        .env("HOME", home.path())
        .env("USERPROFILE", home.path())
        .env("ESDIAG_HOSTS", home.path().join(".esdiag").join("hosts.yml"))
        .env("ESDIAG_KEYSTORE", home.path().join(".esdiag").join("secrets.yml"))
        .env("LOG_LEVEL", "debug");
    for (key, value) in extra_env {
        cmd.env(key, value);
    }
    cmd.output().expect("run esdiag")
}

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context} failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn keystore_add_command_stores_secret_that_reads_back_with_values() {
    let _guard = env_lock().lock().expect("env lock");
    let (home, _hosts_path, keystore_path) = setup_env();

    let add_output = run_esdiag(
        &[
            "keystore",
            "add",
            "cli-basic",
            "--user",
            "elastic",
            "--password",
            "pass-1",
        ],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert_success(&add_output, "keystore add");

    let secret = get_secret("cli-basic", "pw")
        .expect("read secret")
        .expect("secret should exist");
    let raw_keystore = std::fs::read_to_string(&keystore_path).expect("read keystore");

    assert_eq!(secret.basic.as_ref().map(|b| b.username.as_str()), Some("elastic"));
    assert_eq!(secret.basic.as_ref().map(|b| b.password.as_str()), Some("pass-1"));
    assert!(secret.apikey.is_none());
    assert!(!raw_keystore.contains("elastic"));
    assert!(!raw_keystore.contains("pass-1"));
}

#[test]
fn keystore_migrate_command_scrubs_plaintext_hosts_and_preserves_reads() {
    let _guard = env_lock().lock().expect("env lock");
    let (home, hosts_path, _keystore_path) = setup_env();
    unsafe {
        std::env::set_var("ESDIAG_KEYSTORE_PASSWORD", "pw");
    }

    let plaintext_hosts = concat!(
        "es-prod:\n",
        "  auth: ApiKey\n",
        "  app: Elasticsearch\n",
        "  apikey: apikey-1\n",
        "  roles:\n",
        "    - collect\n",
        "  url: http://localhost:9200/\n",
        "kb-prod:\n",
        "  auth: Basic\n",
        "  app: Kibana\n",
        "  username: elastic\n",
        "  password: pass-1\n",
        "  roles:\n",
        "    - collect\n",
        "  url: http://localhost:5601/\n",
    );
    std::fs::write(&hosts_path, plaintext_hosts).expect("write plaintext hosts");

    let migrate_output = run_esdiag(&["keystore", "migrate"], &home, &[("ESDIAG_KEYSTORE_PASSWORD", "pw")]);
    assert_success(&migrate_output, "keystore migrate");

    let migrated_hosts = KnownHost::parse_hosts_yml().expect("read migrated hosts");
    let raw_hosts = std::fs::read_to_string(&hosts_path).expect("read hosts");

    let es_host = migrated_hosts.get("es-prod").expect("es host exists");
    assert!(es_host.legacy_apikey.is_none(), "plaintext api key should be removed");
    assert_eq!(es_host.secret.as_deref(), Some("es-prod"));

    let kb_host = migrated_hosts.get("kb-prod").expect("kb host exists");
    assert!(
        kb_host.legacy_username.is_none(),
        "plaintext username should be removed"
    );
    assert!(
        kb_host.legacy_password.is_none(),
        "plaintext password should be removed"
    );
    assert_eq!(kb_host.secret.as_deref(), Some("kb-prod"));

    assert!(!raw_hosts.contains("apikey: apikey-1"));
    assert!(!raw_hosts.contains("username: elastic"));
    assert!(!raw_hosts.contains("password: pass-1"));

    let es_secret = get_secret("es-prod", "pw")
        .expect("read es secret")
        .expect("es secret exists");
    assert_eq!(es_secret.apikey.as_deref(), Some("apikey-1"));

    let kb_secret = get_secret("kb-prod", "pw")
        .expect("read kb secret")
        .expect("kb secret exists");
    assert_eq!(kb_secret.basic.as_ref().map(|b| b.username.as_str()), Some("elastic"));
    assert_eq!(kb_secret.basic.as_ref().map(|b| b.password.as_str()), Some("pass-1"));

    let es_auth = migrated_hosts
        .get("es-prod")
        .expect("es host exists")
        .get_auth()
        .expect("resolve es auth");
    assert!(matches!(es_auth, Auth::Apikey(key) if key == "apikey-1"));

    let kb_auth = migrated_hosts
        .get("kb-prod")
        .expect("kb host exists")
        .get_auth()
        .expect("resolve kb auth");
    assert!(matches!(kb_auth, Auth::Basic(user, pass) if user == "elastic" && pass == "pass-1"));
}

#[test]
fn keystore_add_rejects_duplicate_secret() {
    let _guard = env_lock().lock().expect("env lock");
    let (home, _hosts_path, _keystore_path) = setup_env();

    let first_add = run_esdiag(
        &["keystore", "add", "prod-es", "--apikey", "key-1"],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert_success(&first_add, "first keystore add");

    let second_add = run_esdiag(
        &["keystore", "add", "prod-es", "--apikey", "key-2"],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert!(
        !second_add.status.success(),
        "duplicate add should fail\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&second_add.stdout),
        String::from_utf8_lossy(&second_add.stderr)
    );
    assert!(
        String::from_utf8_lossy(&second_add.stderr).contains("already exists"),
        "expected duplicate-secret error"
    );

    let secret = get_secret("prod-es", "pw")
        .expect("read secret")
        .expect("secret should still exist");
    assert_eq!(secret.apikey.as_deref(), Some("key-1"));
}

#[test]
fn keystore_update_updates_existing_secret_and_rejects_missing_secret() {
    let _guard = env_lock().lock().expect("env lock");
    let (home, _hosts_path, _keystore_path) = setup_env();

    let missing_update = run_esdiag(
        &["keystore", "update", "missing", "--apikey", "key-1"],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert!(
        !missing_update.status.success(),
        "missing update should fail\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&missing_update.stdout),
        String::from_utf8_lossy(&missing_update.stderr)
    );
    assert!(
        String::from_utf8_lossy(&missing_update.stderr).contains("not found"),
        "expected missing-secret error"
    );

    let add_output = run_esdiag(
        &["keystore", "add", "prod-es", "--apikey", "key-1"],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert_success(&add_output, "keystore add before update");

    let update_output = run_esdiag(
        &[
            "keystore",
            "update",
            "prod-es",
            "--user",
            "elastic",
            "--password",
            "pass-2",
        ],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert_success(&update_output, "keystore update");

    let secret = get_secret("prod-es", "pw")
        .expect("read updated secret")
        .expect("updated secret exists");
    assert_eq!(
        secret.basic.as_ref().map(|basic| basic.username.as_str()),
        Some("elastic")
    );
    assert_eq!(
        secret.basic.as_ref().map(|basic| basic.password.as_str()),
        Some("pass-2")
    );
    assert!(secret.apikey.is_none(), "update should replace previous auth type");
}

#[test]
fn keystore_unlock_status_and_lock_commands_manage_unlock_file() {
    let _guard = env_lock().lock().expect("env lock");
    let (home, _hosts_path, _keystore_path) = setup_env();
    authenticate("pw").expect("create keystore");

    let unlock_output = run_esdiag(
        &["keystore", "unlock", "--ttl", "7d"],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert_success(&unlock_output, "keystore unlock");

    let unlock_path = home.path().join(".esdiag").join("keystore.unlock");
    assert!(unlock_path.exists(), "unlock file should exist");

    let status_output = run_esdiag(&["keystore", "status"], &home, &[]);
    assert_success(&status_output, "keystore status while unlocked");
    let status = get_unlock_status().expect("unlock status while unlocked");
    assert!(status.unlock_active, "status helper should report active unlock");
    assert!(status.expires_at_epoch.is_some(), "status should include expiry");

    let lock_output = run_esdiag(&["keystore", "lock"], &home, &[]);
    assert_success(&lock_output, "keystore lock");
    assert!(!unlock_path.exists(), "unlock file should be removed");

    let status_after_lock = run_esdiag(&["keystore", "status"], &home, &[]);
    assert_success(&status_after_lock, "keystore status after lock");
    let status_after_lock = get_unlock_status().expect("unlock status after lock");
    assert!(
        !status_after_lock.unlock_active,
        "status helper should report inactive unlock"
    );
}

#[test]
fn keystore_unlock_refuses_noninteractive_bootstrap_without_keystore() {
    let _guard = env_lock().lock().expect("env lock");
    let (home, _hosts_path, _keystore_path) = setup_env();

    let output = run_esdiag(&["keystore", "unlock"], &home, &[]);
    assert!(
        !output.status.success(),
        "unlock without keystore should fail non-interactively\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("No keystore exists"),
        "expected missing-keystore error"
    );
}

#[test]
fn keystore_add_missing_secret_material_fails_noninteractive() {
    let _guard = env_lock().lock().expect("env lock");
    let (home, _hosts_path, _keystore_path) = setup_env();

    let output = run_esdiag(
        &["keystore", "add", "prod-es", "--apikey"],
        &home,
        &[("ESDIAG_KEYSTORE_PASSWORD", "pw")],
    );
    assert!(
        !output.status.success(),
        "missing secret material should fail without tty\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("Required secret value was not provided"),
        "expected missing secret value error"
    );
}