zitadel-tui 0.1.7

A terminal UI for managing Zitadel resources
use anyhow::{Context, Result};

use crate::{auth::resolve_access_token, cli::Cli, client::ZitadelClient, config::AppConfig};

pub async fn authenticated_client(args: &Cli, config: &AppConfig) -> Result<ZitadelClient> {
    let host = resolved_host(args, config)?;
    let http = reqwest::Client::new();
    let auth = resolve_access_token(
        &http,
        &host,
        args.token.clone(),
        args.service_account_file.clone(),
        config,
    )
    .await?;
    auth.ensure_api_credential()?;
    ZitadelClient::new(host, auth.token)
}

pub fn resolved_host(args: &Cli, config: &AppConfig) -> Result<String> {
    args.host
        .clone()
        .or_else(|| config.zitadel_url.clone())
        .context("Zitadel URL is required via --host, ZITADEL_URL, or config")
}

pub fn resolved_host_or_cache(args: &Cli, config: &AppConfig) -> Result<String> {
    resolved_host(args, config).or_else(|_| {
        crate::token_cache::TokenCache::load()
            .ok()
            .flatten()
            .map(|cache| cache.host)
            .context(
                "Zitadel URL is required via --host, ZITADEL_URL, config, or a cached login session",
            )
    })
}

pub async fn resolved_project_id(
    client: &ZitadelClient,
    args: &Cli,
    config: &AppConfig,
) -> Result<String> {
    if let Some(project_id) = args
        .project_id
        .clone()
        .or_else(|| config.project_id.clone())
    {
        return Ok(project_id);
    }

    client
        .get_default_project()
        .await?
        .get("id")
        .and_then(|value| value.as_str())
        .map(ToOwned::to_owned)
        .context("failed to determine default project id")
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;
    use std::{
        env, fs,
        time::{SystemTime, UNIX_EPOCH},
    };

    fn temp_cache_path() -> std::path::PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        env::temp_dir().join(format!("zitadel-tui-shared-test-tokens-{unique}.json"))
    }

    fn clear_host_env() -> Option<String> {
        let original = env::var("ZITADEL_URL").ok();
        env::remove_var("ZITADEL_URL");
        original
    }

    fn restore_host_env(original: Option<String>) {
        if let Some(value) = original {
            env::set_var("ZITADEL_URL", value);
        } else {
            env::remove_var("ZITADEL_URL");
        }
    }

    #[test]
    fn resolved_host_uses_cli_arg() {
        let _guard = crate::test_support::env_lock();
        let original = clear_host_env();
        let args = Cli::parse_from(["zitadel-tui", "--host", "https://cli.example.com"]);
        let config = AppConfig {
            zitadel_url: Some("https://config.example.com".to_string()),
            ..AppConfig::default()
        };

        let host = resolved_host(&args, &config).unwrap();
        restore_host_env(original);
        assert_eq!(host, "https://cli.example.com");
    }

    #[test]
    fn resolved_host_falls_back_to_config() {
        let _guard = crate::test_support::env_lock();
        let original = clear_host_env();
        let args = Cli::parse_from(["zitadel-tui"]);
        let config = AppConfig {
            zitadel_url: Some("https://config.example.com".to_string()),
            ..AppConfig::default()
        };

        let host = resolved_host(&args, &config).unwrap();
        restore_host_env(original);
        assert_eq!(host, "https://config.example.com");
    }

    #[test]
    fn resolved_host_cli_arg_takes_precedence_over_config() {
        let _guard = crate::test_support::env_lock();
        let original = clear_host_env();
        let args = Cli::parse_from(["zitadel-tui", "--host", "https://cli.example.com"]);
        let config = AppConfig {
            zitadel_url: Some("https://config.example.com".to_string()),
            ..AppConfig::default()
        };

        let host = resolved_host(&args, &config).unwrap();
        restore_host_env(original);
        assert_eq!(host, "https://cli.example.com");
    }

    #[test]
    fn resolved_host_errors_when_absent() {
        let _guard = crate::test_support::env_lock();
        let original = clear_host_env();
        let args = Cli::parse_from(["zitadel-tui"]);
        let error = resolved_host(&args, &AppConfig::default())
            .unwrap_err()
            .to_string();

        restore_host_env(original);
        assert!(error.contains("Zitadel URL is required"));
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn authenticated_client_rejects_oidc_session_tokens_for_api_commands() {
        let _guard = crate::test_support::env_lock();
        let original_host = clear_host_env();
        let original_token = env::var("ZITADEL_TOKEN").ok();
        let original_sa = env::var("ZITADEL_SERVICE_ACCOUNT_FILE").ok();
        env::remove_var("ZITADEL_TOKEN");
        env::remove_var("ZITADEL_SERVICE_ACCOUNT_FILE");
        let cache_path = temp_cache_path();
        env::set_var("ZITADEL_TUI_TOKEN_CACHE", &cache_path);

        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        crate::token_cache::TokenCache {
            access_token: "header.payload.signature".to_string(),
            refresh_token: Some("refresh-token".to_string()),
            expires_at: Some(now + 3600),
            client_id: "native-client".to_string(),
            host: "https://zitadel.example.com".to_string(),
        }
        .save()
        .unwrap();

        let args = Cli::parse_from(["zitadel-tui", "--host", "https://zitadel.example.com"]);
        let error = match authenticated_client(&args, &AppConfig::default()).await {
            Ok(_) => panic!("expected OIDC session token to be rejected for API commands"),
            Err(error) => error.to_string(),
        };

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = fs::remove_file(cache_path);
        restore_host_env(original_host);
        if let Some(token) = original_token {
            env::set_var("ZITADEL_TOKEN", token);
        }
        if let Some(path) = original_sa {
            env::set_var("ZITADEL_SERVICE_ACCOUNT_FILE", path);
        }

        assert!(error.contains("requires Zitadel API credentials"));
        assert!(error.contains("auth status"));
        assert!(error.contains("personal access token"));
    }
}