use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; use quicommit::commands::credential::{host_to_service, CredentialAttributes}; use quicommit::config::manager::ConfigManager; use quicommit::config::{GitProfile, TokenConfig, TokenType}; use quicommit::utils::keyring::KeyringManager; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use tempfile::TempDir; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Global counter to generate unique profile names/emails per test invocation, /// preventing parallel keyring interference (the system keyring is a shared /// resource and tests run in parallel by default). static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); fn unique_id() -> u64 { UNIQUE_COUNTER.fetch_add(1, Ordering::SeqCst) } /// Generate a unique profile name for a test. fn unique_profile_name(prefix: &str) -> String { format!("{}_{}", prefix, unique_id()) } /// Generate a unique user email for a test. fn unique_email(prefix: &str) -> String { format!("{}@test.local", unique_profile_name(prefix)) } /// Write a minimal config file with one profile so the credential helper has /// something to work with. Returns the profile name and user email so tests /// can use them for keyring cleanup. fn write_config_with_profile( config_path: &PathBuf, profile_name: &str, user_name: &str, user_email: &str, ) { let mut manager = ConfigManager::with_path_fresh(config_path).unwrap(); let profile = GitProfile::new( profile_name.to_string(), user_name.to_string(), user_email.to_string(), ); manager .add_profile(profile_name.to_string(), profile) .unwrap(); manager .set_default_profile(Some(profile_name.to_string())) .unwrap(); manager.save().unwrap(); } /// Like `write_config_with_profile` but auto-generates a unique profile name /// and email. Returns `(profile_name, user_email)` for later cleanup. fn write_config_with_unique_profile( config_path: &PathBuf, prefix: &str, ) -> (String, String) { let profile_name = unique_profile_name(prefix); let user_email = unique_email(prefix); write_config_with_profile( config_path, &profile_name, "Test User", &user_email, ); (profile_name, user_email) } /// Run `quicommit credential ` with the given stdin and config. fn run_credential( subcommand: &str, stdin_input: &str, config_path: &PathBuf, ) -> std::process::Output { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(["credential", subcommand, "--config", config_path.to_str().unwrap()]) .write_stdin(stdin_input.to_string()); cmd.output().expect("Failed to execute quicommit credential") } /// Check whether the system keyring is available so we can skip tests that /// require it. fn keyring_available() -> bool { KeyringManager::new().is_available() } /// Remove a PAT from the keyring if it exists (cleanup helper). fn cleanup_pat(profile_name: &str, user_email: &str, service: &str) { let km = KeyringManager::new(); if km.is_available() { let _ = km.delete_pat(profile_name, user_email, service); } } // --------------------------------------------------------------------------- // Unit tests for host_to_service // --------------------------------------------------------------------------- mod host_to_service_tests { use super::*; #[test] fn test_known_hosts() { assert_eq!(host_to_service("github.com"), "github"); assert_eq!(host_to_service("gitlab.com"), "gitlab"); assert_eq!(host_to_service("bitbucket.org"), "bitbucket"); assert_eq!(host_to_service("codeberg.org"), "codeberg"); assert_eq!(host_to_service("gitea.com"), "gitea"); assert_eq!(host_to_service("gitee.com"), "gitee"); } #[test] fn test_www_prefix() { assert_eq!(host_to_service("www.github.com"), "github"); assert_eq!(host_to_service("www.gitlab.com"), "gitlab"); } #[test] fn test_case_insensitive() { assert_eq!(host_to_service("GitHub.Com"), "github"); assert_eq!(host_to_service("GITLAB.COM"), "gitlab"); assert_eq!(host_to_service("BitBucket.Org"), "bitbucket"); } #[test] fn test_trailing_slash() { assert_eq!(host_to_service("github.com/"), "github"); assert_eq!(host_to_service("gitlab.com//"), "gitlab"); } #[test] fn test_unknown_host_passthrough() { assert_eq!(host_to_service("example.com"), "example.com"); assert_eq!( host_to_service("git.internal.corp"), "git.internal.corp" ); assert_eq!(host_to_service("my-server.local"), "my-server.local"); } #[test] fn test_empty_host() { assert_eq!(host_to_service(""), ""); } } // --------------------------------------------------------------------------- // Unit tests for CredentialAttributes // --------------------------------------------------------------------------- mod credential_attributes_tests { use super::*; #[test] fn test_parse_basic() { let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.protocol.as_deref(), Some("https")); assert_eq!(attrs.host.as_deref(), Some("github.com")); assert_eq!(attrs.username.as_deref(), Some("octocat")); assert_eq!(attrs.password.as_deref(), Some("ghp_token123")); assert!(attrs.path.is_none()); } #[test] fn test_parse_with_path() { let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.path.as_deref(), Some("owner/repo.git")); } #[test] fn test_parse_empty_input() { let attrs = CredentialAttributes::parse_str(""); assert!(attrs.protocol.is_none()); assert!(attrs.host.is_none()); assert!(attrs.username.is_none()); assert!(attrs.password.is_none()); assert!(attrs.path.is_none()); } #[test] fn test_parse_ignores_unknown_keys() { let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.protocol.as_deref(), Some("https")); assert_eq!(attrs.host.as_deref(), Some("github.com")); } #[test] fn test_parse_stops_at_blank_line() { let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.protocol.as_deref(), Some("https")); assert_eq!(attrs.host.as_deref(), Some("github.com")); assert!(attrs.password.is_none()); } #[test] fn test_parse_value_containing_equals() { let input = "protocol=https\nhost=github.com\npassword=token=with=equals\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.password.as_deref(), Some("token=with=equals")); } #[test] fn test_parse_empty_values() { let input = "protocol=\nhost=github.com\nusername=\n\n"; let attrs = CredentialAttributes::parse_str(input); assert_eq!(attrs.protocol.as_deref(), Some("")); assert_eq!(attrs.username.as_deref(), Some("")); } #[test] fn test_serialize_roundtrip() { let attrs = CredentialAttributes { protocol: Some("https".to_string()), host: Some("github.com".to_string()), path: Some("owner/repo.git".to_string()), username: Some("octocat".to_string()), password: Some("ghp_token".to_string()), }; let serialized = attrs.serialize(); let reparsed = CredentialAttributes::parse_str(&serialized); assert_eq!(attrs, reparsed); } #[test] fn test_serialize_partial() { let attrs = CredentialAttributes { protocol: Some("https".to_string()), host: Some("github.com".to_string()), path: None, username: None, password: None, }; let serialized = attrs.serialize(); assert!(serialized.contains("protocol=https\n")); assert!(serialized.contains("host=github.com\n")); assert!(!serialized.contains("path=")); assert!(!serialized.contains("username=")); assert!(!serialized.contains("password=")); assert!(serialized.ends_with("\n\n")); } #[test] fn test_serialize_empty() { let attrs = CredentialAttributes::default(); let serialized = attrs.serialize(); // Should just be the terminating blank line assert_eq!(serialized, "\n"); } #[test] fn test_default_is_all_none() { let attrs = CredentialAttributes::default(); assert!(attrs.protocol.is_none()); assert!(attrs.host.is_none()); assert!(attrs.path.is_none()); assert!(attrs.username.is_none()); assert!(attrs.password.is_none()); } #[test] fn test_equality() { let a = CredentialAttributes { protocol: Some("https".to_string()), host: Some("github.com".to_string()), path: None, username: Some("user".to_string()), password: Some("pass".to_string()), }; let b = a.clone(); assert_eq!(a, b); let c = CredentialAttributes { protocol: Some("http".to_string()), ..a.clone() }; assert_ne!(a, c); } } // --------------------------------------------------------------------------- // CLI integration tests: help visibility // --------------------------------------------------------------------------- mod help_visibility_tests { use super::*; #[test] fn test_credential_hidden_from_main_help() { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.arg("--help"); cmd.assert() .success() .stdout(predicate::str::contains("credential").not()); } #[test] fn test_credential_hidden_from_no_args_help() { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.assert() .failure() .stderr(predicate::str::contains("credential").not()); } #[test] fn test_credential_get_subcommand_hidden() { let mut cmd = cargo_bin_cmd!("quicommit"); cmd.args(["credential", "--help"]); // The credential command exists but is hidden; --help should still work // for the command itself (clap shows help for explicitly invoked hidden // commands), but get/store/erase should be hidden from its listing. cmd.assert().success(); } #[test] fn test_credential_command_still_executable() { // Even though hidden, the command should be parseable and executable. let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "exec"); let input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", input, &config_path); assert!(output.status.success(), "credential get should succeed"); } } // --------------------------------------------------------------------------- // CLI integration tests: credential get (no keyring required) // --------------------------------------------------------------------------- mod credential_get_tests { use super::*; #[test] fn test_get_with_no_config_outputs_nothing() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("nonexistent.toml"); let input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.is_empty(), "get with no config should produce no output, got: {}", stdout ); } #[test] fn test_get_with_config_but_no_profiles_outputs_nothing() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); // Create an empty config file (default config, no profiles) ConfigManager::with_path_fresh(&config_path) .unwrap() .save() .unwrap(); let input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.is_empty(), "get with no profiles should produce no output" ); } #[test] fn test_get_with_profile_but_no_pat_outputs_nothing() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); // Use a unique profile so parallel tests storing PATs don't interfere. write_config_with_unique_profile(&config_path, "getnpat"); let input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.is_empty(), "get with no stored PAT should produce no output, got: {}", stdout ); } #[test] fn test_get_without_host_outputs_nothing() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "getnohost"); let input = "protocol=https\n\n"; let output = run_credential("get", input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.is_empty()); } #[test] fn test_get_with_empty_host_outputs_nothing() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "getempty"); let input = "protocol=https\nhost=\n\n"; let output = run_credential("get", input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.is_empty()); } #[test] fn test_get_with_empty_stdin_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "getemptyin"); let output = run_credential("get", "", &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.is_empty()); } } // --------------------------------------------------------------------------- // CLI integration tests: credential store (no keyring required for no-op cases) // --------------------------------------------------------------------------- mod credential_store_tests { use super::*; #[test] fn test_store_with_no_config_succeeds_silently() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("nonexistent.toml"); let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token\n\n"; let output = run_credential("store", input, &config_path); assert!( output.status.success(), "store with no config should succeed" ); } #[test] fn test_store_with_no_profiles_succeeds_silently() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); ConfigManager::with_path_fresh(&config_path) .unwrap() .save() .unwrap(); let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token\n\n"; let output = run_credential("store", input, &config_path); assert!(output.status.success()); } #[test] fn test_store_without_host_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "storenohost"); let input = "protocol=https\nusername=octocat\npassword=ghp_token\n\n"; let output = run_credential("store", input, &config_path); assert!(output.status.success()); } #[test] fn test_store_without_password_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "storenopass"); let input = "protocol=https\nhost=github.com\nusername=octocat\n\n"; let output = run_credential("store", input, &config_path); assert!(output.status.success()); } #[test] fn test_store_with_empty_stdin_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "storeempty"); let output = run_credential("store", "", &config_path); assert!(output.status.success()); } } // --------------------------------------------------------------------------- // CLI integration tests: credential erase (no keyring required for no-op cases) // --------------------------------------------------------------------------- mod credential_erase_tests { use super::*; #[test] fn test_erase_with_no_config_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("nonexistent.toml"); let input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("erase", input, &config_path); assert!(output.status.success()); } #[test] fn test_erase_with_no_profiles_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); ConfigManager::with_path_fresh(&config_path) .unwrap() .save() .unwrap(); let input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("erase", input, &config_path); assert!(output.status.success()); } #[test] fn test_erase_without_host_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "erasenohost"); let input = "protocol=https\n\n"; let output = run_credential("erase", input, &config_path); assert!(output.status.success()); } #[test] fn test_erase_with_profile_but_no_token_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "erasenotoken"); let input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("erase", input, &config_path); assert!(output.status.success()); } #[test] fn test_erase_with_empty_stdin_succeeds() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "eraseempty"); let output = run_credential("erase", "", &config_path); assert!(output.status.success()); } } // --------------------------------------------------------------------------- // Full store/get/erase cycle (requires keyring) // --------------------------------------------------------------------------- mod credential_full_cycle_tests { use super::*; #[test] fn test_store_then_get_returns_credentials() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "cycle_sg"); // Clean up any leftover PAT from a previous run cleanup_pat(&profile_name, &user_email, "github"); // Store a credential let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_test_token_123\n\n"; let output = run_credential("store", store_input, &config_path); assert!(output.status.success(), "store should succeed"); // Get the credential back let get_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success(), "get should succeed"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("protocol=https"), "output should contain protocol, got: {}", stdout ); assert!( stdout.contains("host=github.com"), "output should contain host, got: {}", stdout ); assert!( stdout.contains("password=ghp_test_token_123"), "output should contain the stored password, got: {}", stdout ); // Cleanup cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_erase_after_store_removes_credentials() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "cycle_es"); cleanup_pat(&profile_name, &user_email, "github"); // Store a credential let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_erase_test_token\n\n"; let output = run_credential("store", store_input, &config_path); assert!(output.status.success()); // Erase it let erase_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("erase", erase_input, &config_path); assert!(output.status.success()); // Get should now return nothing let get_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.is_empty(), "get after erase should produce no output, got: {}", stdout ); // Cleanup just in case cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_get_returns_profile_username_as_fallback() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "cycle_un"); cleanup_pat(&profile_name, &user_email, "github"); // Store a credential (git provides a username) let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_username_test\n\n"; let output = run_credential("store", store_input, &config_path); assert!(output.status.success()); // Get without providing a username — should fall back to profile user_name let get_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); // Should contain either the git-supplied username or the profile's user_name assert!( stdout.contains("username="), "output should contain a username, got: {}", stdout ); cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_store_with_gitlab_host() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "cycle_gl"); cleanup_pat(&profile_name, &user_email, "gitlab"); // Store a credential for gitlab let store_input = "protocol=https\nhost=gitlab.com\nusername=testuser\npassword=glpat-token\n\n"; let output = run_credential("store", store_input, &config_path); assert!(output.status.success()); // Get it back let get_input = "protocol=https\nhost=gitlab.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("password=glpat-token"), "output should contain gitlab token, got: {}", stdout ); cleanup_pat(&profile_name, &user_email, "gitlab"); } #[test] fn test_store_with_unknown_host() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "cycle_uh"); let unknown_host = "custom-git.example.com"; cleanup_pat(&profile_name, &user_email, unknown_host); // Store a credential for an unknown host let store_input = format!( "protocol=https\nhost={}\nusername=user\npassword=custom_token\n\n", unknown_host ); let output = run_credential("store", &store_input, &config_path); assert!(output.status.success()); // Get it back let get_input = format!("protocol=https\nhost={}\n\n", unknown_host); let output = run_credential("get", &get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("password=custom_token"), "output should contain custom token, got: {}", stdout ); cleanup_pat(&profile_name, &user_email, unknown_host); } #[test] fn test_erase_nonexistent_pat_succeeds() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); write_config_with_unique_profile(&config_path, "cycle_en"); // Erasing a PAT that was never stored should not fail let erase_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("erase", erase_input, &config_path); assert!(output.status.success()); } } // --------------------------------------------------------------------------- // Config integration: verify store updates the profile config // --------------------------------------------------------------------------- mod credential_config_integration_tests { use super::*; #[test] fn test_store_adds_token_to_profile_config() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "cfg_add"); cleanup_pat(&profile_name, &user_email, "github"); // Store a credential let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_config_test\n\n"; let output = run_credential("store", store_input, &config_path); assert!(output.status.success()); // Read the config back and check that the token was registered let manager = ConfigManager::with_path(&config_path).unwrap(); let profile = manager.get_profile(&profile_name).unwrap(); assert!( profile.tokens.contains_key("github"), "profile should have a 'github' token entry after store" ); cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_erase_removes_token_from_profile_config() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "cfg_rm"); cleanup_pat(&profile_name, &user_email, "github"); // Store a credential let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_erase_config\n\n"; run_credential("store", store_input, &config_path); // Verify it was added let manager = ConfigManager::with_path(&config_path).unwrap(); assert!(manager .get_profile(&profile_name) .unwrap() .tokens .contains_key("github")); // Erase it let erase_input = "protocol=https\nhost=github.com\n\n"; run_credential("erase", erase_input, &config_path); // Verify it was removed from config let manager = ConfigManager::with_path(&config_path).unwrap(); assert!( !manager .get_profile(&profile_name) .unwrap() .tokens .contains_key("github"), "token entry should be removed from config after erase" ); cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_store_does_not_duplicate_token_entry() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); // Manually add a token entry to the profile let (profile_name, user_email) = { let pname = unique_profile_name("cfg_dedup"); let uemail = unique_email("cfg_dedup"); let mut manager = ConfigManager::with_path_fresh(&config_path).unwrap(); let mut profile = GitProfile::new( pname.clone(), "Test User".to_string(), uemail.clone(), ); profile.add_token( "github".to_string(), TokenConfig::new(TokenType::Personal), ); manager.add_profile(pname.clone(), profile).unwrap(); manager.set_default_profile(Some(pname.clone())).unwrap(); manager.save().unwrap(); (pname, uemail) }; cleanup_pat(&profile_name, &user_email, "github"); // Store a credential let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_dedup_test\n\n"; let output = run_credential("store", store_input, &config_path); assert!(output.status.success()); // Verify there's still only one token entry let manager = ConfigManager::with_path(&config_path).unwrap(); let profile = manager.get_profile(&profile_name).unwrap(); assert_eq!( profile.tokens.len(), 1, "should not duplicate token entry" ); cleanup_pat(&profile_name, &user_email, "github"); } } // --------------------------------------------------------------------------- // get_pat_for_host public API test // --------------------------------------------------------------------------- mod get_pat_for_host_tests { #[test] fn test_get_pat_for_host_returns_none_without_config() { // This test uses the default config path which may or may not exist. // We just verify the function doesn't panic. let result = quicommit::commands::credential::get_pat_for_host("github.com"); assert!(result.is_ok(), "get_pat_for_host should not error"); } } // --------------------------------------------------------------------------- // Edge cases and protocol compliance // --------------------------------------------------------------------------- mod protocol_compliance_tests { use super::*; #[test] fn test_get_output_ends_with_blank_line() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "proto_blank"); cleanup_pat(&profile_name, &user_email, "github"); // Store let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_protocol_test\n\n"; run_credential("store", store_input, &config_path); // Get let get_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); // Git expects the output to end with a blank line assert!( stdout.ends_with("\n\n"), "credential output should end with a blank line, got: {:?}", stdout ); cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_get_output_has_key_value_format() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "proto_kv"); cleanup_pat(&profile_name, &user_email, "github"); // Store let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_format_test\n\n"; run_credential("store", store_input, &config_path); // Get let get_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); // Every non-blank line should be in key=value format for line in stdout.lines() { if !line.is_empty() { assert!( line.contains('='), "line should be in key=value format: {:?}", line ); } } cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_get_with_password_containing_special_chars() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "proto_sc"); cleanup_pat(&profile_name, &user_email, "github"); // Store a password with special characters let special_token = "ghp_t0k3n!@#$%^&*()_+-=[]{}|;:,.<>?"; let store_input = format!( "protocol=https\nhost=github.com\nusername=octocat\npassword={}\n\n", special_token ); let output = run_credential("store", &store_input, &config_path); assert!(output.status.success()); // Get it back let get_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains(&format!("password={}", special_token)), "output should contain the special-char password, got: {}", stdout ); cleanup_pat(&profile_name, &user_email, "github"); } #[test] fn test_multiple_hosts_use_different_services() { if !keyring_available() { eprintln!("[skip] keyring not available"); return; } let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let (profile_name, user_email) = write_config_with_unique_profile(&config_path, "proto_mh"); cleanup_pat(&profile_name, &user_email, "github"); cleanup_pat(&profile_name, &user_email, "gitlab"); // Store GitHub credential let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_multi_test\n\n"; run_credential("store", store_input, &config_path); // Store GitLab credential let store_input = "protocol=https\nhost=gitlab.com\nusername=testuser\npassword=glpat-multi\n\n"; run_credential("store", store_input, &config_path); // Get GitHub — should return GitHub token, not GitLab let get_input = "protocol=https\nhost=github.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("password=ghp_multi_test"), "should contain github token, got: {}", stdout ); assert!(!stdout.contains("glpat-multi")); // Get GitLab — should return GitLab token, not GitHub let get_input = "protocol=https\nhost=gitlab.com\n\n"; let output = run_credential("get", get_input, &config_path); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("password=glpat-multi"), "should contain gitlab token, got: {}", stdout ); assert!(!stdout.contains("ghp_multi_test")); // Cleanup cleanup_pat(&profile_name, &user_email, "github"); cleanup_pat(&profile_name, &user_email, "gitlab"); } }