Files
QuiCommit/tests/credential_tests.rs

1125 lines
39 KiB
Rust

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 <subcommand>` 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");
}
}