1125 lines
39 KiB
Rust
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");
|
|
}
|
|
}
|