mecha10-auth 0.6.3

Authentication services for Mecha10 - shared between CLI and launcher
Documentation
//! Shared auth command orchestration
//!
//! Both `mecha10` (dev machine CLI) and `mecha10-launcher` (robot daemon) expose
//! `auth login` / `auth logout` / `auth whoami` subcommands. They share the exact same
//! underlying [`AuthService`] and [`CredentialsService`], so the orchestration logic
//! (check login state, run the device code flow, save/delete credentials, print a
//! formatted result) lives here once instead of being duplicated per-binary.
//!
//! Each binary keeps its own hint text (e.g. `mecha10 auth logout` vs
//! `mecha10-launcher auth logout`) by passing its own binary name in.

use crate::credentials::CredentialsService;
use crate::service::AuthService;
use crate::{display_device_code_instructions, get_auth_url, open_browser};
use anyhow::Result;

/// Run the login command.
///
/// 1. If already logged in, print current user and a hint to log out first.
/// 2. Otherwise run the device code flow, save the resulting credentials, and print a summary.
///
/// `binary_name` is used to render binary-specific hint text (e.g. `mecha10` vs `mecha10-launcher`).
pub async fn handle_login(auth_url: Option<String>, binary_name: &str) -> Result<()> {
    let credentials_service = CredentialsService::new();

    // Check if already logged in
    if credentials_service.is_logged_in() {
        if let Ok(Some((_, email, name))) = credentials_service.get_user_info() {
            let display_name = name.unwrap_or_else(|| email.clone());
            println!();
            println!("Already logged in as {}", display_name);
            println!();
            println!("Run `{} auth logout` to log out first.", binary_name);
            println!();
            return Ok(());
        }
    }

    // Create auth service (with custom URL if provided, or from env var)
    let auth_service = match auth_url {
        Some(url) => AuthService::with_auth_url(url),
        None => AuthService::with_auth_url(get_auth_url()),
    };

    println!();
    println!("Logging in to mecha10...");
    println!();

    // Run device code flow
    let credentials = auth_service
        .run_device_code_flow(|device_code| {
            display_device_code_instructions(device_code);
            open_browser(&device_code.verification_uri);
        })
        .await?;

    // Save credentials
    credentials_service.save(&credentials)?;

    let display_name = credentials.name.as_ref().unwrap_or(&credentials.email);

    println!();
    println!("Successfully logged in as {}", display_name);
    println!();
    println!(
        "Credentials saved to {}",
        credentials_service.credentials_path().display()
    );
    println!();

    Ok(())
}

/// Run the logout command.
///
/// Removes stored credentials from `~/.mecha10/credentials.json` and prints a summary.
pub fn handle_logout() -> Result<()> {
    let credentials_service = CredentialsService::new();

    if !credentials_service.is_logged_in() {
        println!();
        println!("Not currently logged in.");
        println!();
        return Ok(());
    }

    // Get user info for display before deleting
    let user_info = credentials_service.get_user_info()?;

    // Delete credentials
    credentials_service.delete()?;

    println!();
    if let Some((_, email, name)) = user_info {
        let display_name = name.unwrap_or(email);
        println!("Logged out from {}", display_name);
    } else {
        println!("Logged out successfully");
    }
    println!();

    Ok(())
}

/// Run the whoami command.
///
/// Displays current authentication status and user info.
///
/// `binary_name` is used to render binary-specific hint text (e.g. `mecha10` vs `mecha10-launcher`).
pub fn handle_whoami(verbose: bool, binary_name: &str) -> Result<()> {
    let credentials_service = CredentialsService::new();

    println!();

    match credentials_service.load()? {
        Some(creds) if creds.is_valid() => {
            let display_name = creds.name.as_ref().unwrap_or(&creds.email);

            println!("Logged in as: {}", display_name);
            println!("Email: {}", creds.email);
            println!("User ID: {}", creds.user_id);

            if verbose {
                println!("API Key: {}", creds.masked_api_key());
            }

            println!(
                "Authenticated: {}",
                creds.authenticated_at.format("%Y-%m-%d %H:%M:%S UTC")
            );
        }
        Some(_) => {
            println!("Credentials found but invalid");
            println!();
            println!("Run `{} auth login` to authenticate", binary_name);
        }
        None => {
            println!("Not logged in");
            println!();
            println!("Run `{} auth login` to authenticate", binary_name);
        }
    }

    println!();

    Ok(())
}

/// One-line authentication status summary, e.g. for use in `config show` output.
///
/// Reuses the same [`CredentialsService`] load/validity check as [`handle_whoami`], just
/// condensed to a single line rather than the full multi-line whoami printout.
pub fn auth_status_line() -> Result<String> {
    let credentials_service = CredentialsService::new();

    let line = match credentials_service.load()? {
        Some(creds) if creds.is_valid() => {
            let display_name = creds.name.as_ref().unwrap_or(&creds.email);
            format!("Logged in as {} ({})", display_name, creds.email)
        }
        Some(_) => "Credentials found but invalid".to_string(),
        None => "Not logged in".to_string(),
    };

    Ok(line)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::credentials::CredentialsService;
    use crate::types::Credentials;
    use crate::DEFAULT_AUTH_URL;
    use chrono::Utc;
    use serial_test::serial;
    use tempfile::TempDir;

    /// Point `dirs::home_dir()` (via `HOME`) at a throwaway temp directory so
    /// `CredentialsService::new()` (used internally by every `commands::` function)
    /// reads/writes credentials there instead of the real `~/.mecha10`.
    ///
    /// Tests using this must be `#[serial]` since `HOME` is process-global state.
    struct HomeGuard {
        _temp_dir: TempDir,
        original_home: Option<String>,
    }

    impl HomeGuard {
        fn new() -> Self {
            let temp_dir = TempDir::new().unwrap();
            let original_home = std::env::var("HOME").ok();
            std::env::set_var("HOME", temp_dir.path());
            Self {
                _temp_dir: temp_dir,
                original_home,
            }
        }
    }

    impl Drop for HomeGuard {
        fn drop(&mut self) {
            match &self.original_home {
                Some(home) => std::env::set_var("HOME", home),
                None => std::env::remove_var("HOME"),
            }
        }
    }

    fn valid_credentials() -> Credentials {
        Credentials {
            api_key: "mecha_test123abc456def".to_string(),
            user_id: "usr_test123".to_string(),
            email: "test@example.com".to_string(),
            name: Some("Test User".to_string()),
            authenticated_at: Utc::now(),
            auth_url: DEFAULT_AUTH_URL.to_string(),
        }
    }

    fn invalid_credentials() -> Credentials {
        Credentials {
            api_key: "".to_string(),
            user_id: "usr_test123".to_string(),
            email: "invalid@example.com".to_string(),
            name: None,
            authenticated_at: Utc::now(),
            auth_url: DEFAULT_AUTH_URL.to_string(),
        }
    }

    // ---- handle_login ----

    #[tokio::test]
    #[serial]
    async fn handle_login_short_circuits_when_already_logged_in() {
        let _guard = HomeGuard::new();
        CredentialsService::new().save(&valid_credentials()).unwrap();

        // Already logged in, so this must return Ok without attempting any network call.
        let result = handle_login(None, "mecha10").await;
        assert!(result.is_ok());

        // State is unchanged: still logged in with the same credentials.
        assert!(CredentialsService::new().is_logged_in());
    }

    #[tokio::test]
    #[serial]
    async fn handle_login_propagates_network_error_when_device_code_request_fails() {
        let _guard = HomeGuard::new();
        // No credentials saved -> not logged in, so handle_login proceeds to run the
        // device code flow. Point it at a port nothing is listening on so the
        // connection fails fast instead of hitting a real network.
        let unreachable_url = "http://127.0.0.1:1".to_string();

        let result = handle_login(Some(unreachable_url), "mecha10").await;
        assert!(result.is_err());

        // No credentials should have been saved on failure.
        assert!(!CredentialsService::new().is_logged_in());
    }

    #[tokio::test]
    #[serial]
    async fn handle_login_uses_env_var_auth_url_when_none_provided() {
        let _guard = HomeGuard::new();
        std::env::set_var(crate::AUTH_URL_ENV_VAR, "http://127.0.0.1:1");

        let result = handle_login(None, "mecha10").await;

        std::env::remove_var(crate::AUTH_URL_ENV_VAR);

        assert!(result.is_err());
    }

    // ---- handle_logout ----

    #[test]
    #[serial]
    fn handle_logout_is_noop_when_not_logged_in() {
        let _guard = HomeGuard::new();

        let result = handle_logout();
        assert!(result.is_ok());
    }

    #[test]
    #[serial]
    fn handle_logout_deletes_credentials_when_logged_in() {
        let _guard = HomeGuard::new();
        let service = CredentialsService::new();
        service.save(&valid_credentials()).unwrap();
        assert!(service.is_logged_in());

        let result = handle_logout();
        assert!(result.is_ok());

        // Credentials file should be gone and no longer considered logged in.
        assert!(!CredentialsService::new().is_logged_in());
        assert!(CredentialsService::new().load().unwrap().is_none());
    }

    #[test]
    #[serial]
    fn handle_logout_succeeds_even_with_invalid_stored_credentials() {
        let _guard = HomeGuard::new();
        let service = CredentialsService::new();
        service.save(&invalid_credentials()).unwrap();

        // is_logged_in() is false (invalid creds), so this takes the "not logged in" path
        // even though a (invalid) credentials file exists on disk.
        let result = handle_logout();
        assert!(result.is_ok());
    }

    // ---- handle_whoami ----

    #[test]
    #[serial]
    fn handle_whoami_succeeds_when_not_logged_in() {
        let _guard = HomeGuard::new();
        let result = handle_whoami(false, "mecha10");
        assert!(result.is_ok());
    }

    #[test]
    #[serial]
    fn handle_whoami_succeeds_with_valid_credentials_and_verbose() {
        let _guard = HomeGuard::new();
        CredentialsService::new().save(&valid_credentials()).unwrap();

        let result = handle_whoami(true, "mecha10");
        assert!(result.is_ok());
    }

    #[test]
    #[serial]
    fn handle_whoami_succeeds_with_invalid_credentials() {
        let _guard = HomeGuard::new();
        CredentialsService::new().save(&invalid_credentials()).unwrap();

        let result = handle_whoami(false, "mecha10");
        assert!(result.is_ok());
    }

    #[test]
    #[serial]
    fn handle_whoami_propagates_corrupt_credentials_error() {
        let _guard = HomeGuard::new();
        let service = CredentialsService::new();
        std::fs::create_dir_all(service.credentials_path().parent().unwrap()).unwrap();
        std::fs::write(service.credentials_path(), "not valid json").unwrap();

        let result = handle_whoami(false, "mecha10");
        assert!(result.is_err());
    }

    // ---- auth_status_line ----

    #[test]
    #[serial]
    fn auth_status_line_reports_not_logged_in() {
        let _guard = HomeGuard::new();
        let line = auth_status_line().unwrap();
        assert_eq!(line, "Not logged in");
    }

    #[test]
    #[serial]
    fn auth_status_line_reports_logged_in_user() {
        let _guard = HomeGuard::new();
        CredentialsService::new().save(&valid_credentials()).unwrap();

        let line = auth_status_line().unwrap();
        assert_eq!(line, "Logged in as Test User (test@example.com)");
    }

    #[test]
    #[serial]
    fn auth_status_line_falls_back_to_email_without_name() {
        let _guard = HomeGuard::new();
        let mut creds = valid_credentials();
        creds.name = None;
        CredentialsService::new().save(&creds).unwrap();

        let line = auth_status_line().unwrap();
        assert_eq!(line, "Logged in as test@example.com (test@example.com)");
    }

    #[test]
    #[serial]
    fn auth_status_line_reports_invalid_credentials() {
        let _guard = HomeGuard::new();
        CredentialsService::new().save(&invalid_credentials()).unwrap();

        let line = auth_status_line().unwrap();
        assert_eq!(line, "Credentials found but invalid");
    }
}