Added support for multiple providers and wrote additional regression tests. Also fixed a bug with local synchronization with remote Git repositories when the CLI was just installed but the remote repo already exists with stuff in it.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use predicates::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_env() -> (TempDir, PathBuf, PathBuf) {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let cfg_home = td.path().join("config");
|
||||
let cache_home = td.path().join("cache");
|
||||
let data_home = td.path().join("data");
|
||||
fs::create_dir_all(&cfg_home).unwrap();
|
||||
fs::create_dir_all(&cache_home).unwrap();
|
||||
fs::create_dir_all(&data_home).unwrap();
|
||||
(td, cfg_home, cache_home)
|
||||
}
|
||||
|
||||
fn write_yaml_config(xdg_config_home: &Path, password_file: &Path, run_profile: Option<&str>) {
|
||||
let app_dir = xdg_config_home.join("gman");
|
||||
fs::create_dir_all(&app_dir).unwrap();
|
||||
let cfg = if let Some(profile) = run_profile {
|
||||
format!(
|
||||
r#"default_provider: local
|
||||
providers:
|
||||
- name: local
|
||||
provider: local
|
||||
password_file: {}
|
||||
run_configs:
|
||||
- name: {}
|
||||
secrets: ["api_key"]
|
||||
"#,
|
||||
password_file.display(),
|
||||
profile
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"default_provider: local
|
||||
providers:
|
||||
- name: local
|
||||
provider: local
|
||||
password_file: {}
|
||||
"#,
|
||||
password_file.display()
|
||||
)
|
||||
};
|
||||
// Confy with yaml feature typically uses .yml; write both to be safe.
|
||||
fs::write(app_dir.join("config.yml"), &cfg).unwrap();
|
||||
fs::write(app_dir.join("config.yaml"), &cfg).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_shows_help() {
|
||||
let (_td, cfg, cache) = setup_env();
|
||||
let mut cmd = Command::cargo_bin("gman").unwrap();
|
||||
cmd.env("XDG_CACHE_HOME", &cache)
|
||||
.env("XDG_CONFIG_HOME", &cfg)
|
||||
.arg("--help");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Usage").or(predicate::str::contains("Add")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_completions_bash() {
|
||||
let (_td, cfg, cache) = setup_env();
|
||||
let mut cmd = Command::cargo_bin("gman").unwrap();
|
||||
cmd.env("XDG_CACHE_HOME", &cache)
|
||||
.env("XDG_CONFIG_HOME", &cfg)
|
||||
.args(["completions", "bash"]);
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("_gman").or(predicate::str::contains("complete -F")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_get_list_update_delete_roundtrip() {
|
||||
let (td, xdg_cfg, xdg_cache) = setup_env();
|
||||
let pw_file = td.path().join("pw.txt");
|
||||
fs::write(&pw_file, b"testpw\n").unwrap();
|
||||
write_yaml_config(&xdg_cfg, &pw_file, None);
|
||||
|
||||
// add
|
||||
let mut add = Command::cargo_bin("gman").unwrap();
|
||||
add.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.stdin(Stdio::piped())
|
||||
.args(["add", "my_api_key"]);
|
||||
let mut child = add.spawn().unwrap();
|
||||
use std::io::Write as _;
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(b"super_secret\n")
|
||||
.unwrap();
|
||||
let add_out = child.wait_with_output().unwrap();
|
||||
assert!(add_out.status.success());
|
||||
|
||||
// get (text)
|
||||
let mut get = Command::cargo_bin("gman").unwrap();
|
||||
get.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.args(["get", "my_api_key"]);
|
||||
get.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("super_secret"));
|
||||
|
||||
// get as JSON
|
||||
let mut get_json = Command::cargo_bin("gman").unwrap();
|
||||
get_json
|
||||
.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.args(["--output", "json", "get", "my_api_key"]);
|
||||
get_json.assert().success().stdout(
|
||||
predicate::str::contains("MY_API_KEY").and(predicate::str::contains("super_secret")),
|
||||
);
|
||||
|
||||
// list
|
||||
let mut list = Command::cargo_bin("gman").unwrap();
|
||||
list.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.arg("list");
|
||||
list.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("MY_API_KEY"));
|
||||
|
||||
// update
|
||||
let mut update = Command::cargo_bin("gman").unwrap();
|
||||
update
|
||||
.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.stdin(Stdio::piped())
|
||||
.args(["update", "my_api_key"]);
|
||||
let mut child = update.spawn().unwrap();
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(b"new_val\n")
|
||||
.unwrap();
|
||||
let upd_out = child.wait_with_output().unwrap();
|
||||
assert!(upd_out.status.success());
|
||||
|
||||
// get again
|
||||
let mut get2 = Command::cargo_bin("gman").unwrap();
|
||||
get2.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.args(["get", "my_api_key"]);
|
||||
get2.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("new_val"));
|
||||
|
||||
// delete
|
||||
let mut del = Command::cargo_bin("gman").unwrap();
|
||||
del.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.args(["delete", "my_api_key"]);
|
||||
del.assert().success();
|
||||
|
||||
// get should now fail
|
||||
let mut get_missing = Command::cargo_bin("gman").unwrap();
|
||||
get_missing
|
||||
.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.args(["get", "my_api_key"]);
|
||||
get_missing.assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_wrap_dry_run_env_injection() {
|
||||
let (td, xdg_cfg, xdg_cache) = setup_env();
|
||||
let pw_file = td.path().join("pw.txt");
|
||||
fs::write(&pw_file, b"pw\n").unwrap();
|
||||
write_yaml_config(&xdg_cfg, &pw_file, Some("echo"));
|
||||
|
||||
// Add the secret so the profile can read it
|
||||
let mut add = Command::cargo_bin("gman").unwrap();
|
||||
add.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.stdin(Stdio::piped())
|
||||
.args(["add", "api_key"]);
|
||||
let mut child = add.spawn().unwrap();
|
||||
use std::io::Write as _;
|
||||
child.stdin.as_mut().unwrap().write_all(b"value\n").unwrap();
|
||||
let add_out = child.wait_with_output().unwrap();
|
||||
assert!(add_out.status.success());
|
||||
|
||||
// Dry-run wrapping: prints preview command
|
||||
let mut wrap = Command::cargo_bin("gman").unwrap();
|
||||
wrap.env("XDG_CONFIG_HOME", &xdg_cfg)
|
||||
.env("XDG_CACHE_HOME", &xdg_cache)
|
||||
.arg("--dry-run")
|
||||
.args(["echo", "hello"]);
|
||||
wrap.assert().success().stdout(
|
||||
predicate::str::contains("Command to be executed:").or(predicate::str::contains("echo")),
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_provider_kind_from() {
|
||||
enum ProviderKind {
|
||||
Local,
|
||||
}
|
||||
|
||||
impl From<ProviderKind> for SupportedProvider {
|
||||
fn from(k: ProviderKind) -> Self {
|
||||
match k {
|
||||
ProviderKind::Local => SupportedProvider::Local(LocalProvider),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let provider_kind = ProviderKind::Local;
|
||||
let supported_provider: SupportedProvider = provider_kind.into();
|
||||
assert_eq!(supported_provider, SupportedProvider::Local(LocalProvider));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
mod main_tests;
|
||||
+1
-1
@@ -1 +1 @@
|
||||
mod gman;
|
||||
mod cli_tests;
|
||||
+111
-11
@@ -1,9 +1,9 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gman::config::{Config, RunConfig};
|
||||
use gman::config::{Config, ProviderConfig, RunConfig};
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use validator::Validate;
|
||||
|
||||
@@ -17,6 +17,7 @@ mod tests {
|
||||
arg_format: None,
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_ok());
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ mod tests {
|
||||
arg_format: None,
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ mod tests {
|
||||
arg_format: None,
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -56,6 +59,7 @@ mod tests {
|
||||
arg_format: Some("{{key}}={{value}}".to_string()),
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ mod tests {
|
||||
arg_format: Some("{{key}}={{value}}".to_string()),
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_ok());
|
||||
}
|
||||
|
||||
@@ -82,6 +87,7 @@ mod tests {
|
||||
arg_format: None,
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_ok());
|
||||
}
|
||||
|
||||
@@ -95,6 +101,7 @@ mod tests {
|
||||
arg_format: None,
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -108,6 +115,7 @@ mod tests {
|
||||
arg_format: Some("key=value".to_string()),
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -121,6 +129,7 @@ mod tests {
|
||||
arg_format: None,
|
||||
files: None,
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_ok());
|
||||
}
|
||||
|
||||
@@ -134,6 +143,7 @@ mod tests {
|
||||
arg_format: None,
|
||||
files: Some(Vec::new()),
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_ok());
|
||||
}
|
||||
|
||||
@@ -147,12 +157,14 @@ mod tests {
|
||||
arg_format: Some("{{key}}={{value}}".to_string()),
|
||||
files: Some(Vec::new()),
|
||||
};
|
||||
|
||||
assert!(run_config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_valid() {
|
||||
let config = Config {
|
||||
fn test_provider_config_valid() {
|
||||
let config = ProviderConfig {
|
||||
name: Some("local-test".to_string()),
|
||||
provider: SupportedProvider::Local(LocalProvider),
|
||||
password_file: None,
|
||||
git_branch: None,
|
||||
@@ -160,14 +172,15 @@ mod tests {
|
||||
git_user_name: None,
|
||||
git_user_email: Some("test@example.com".to_string()),
|
||||
git_executable: None,
|
||||
run_configs: None,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_invalid_email() {
|
||||
let config = Config {
|
||||
fn test_provider_config_invalid_email() {
|
||||
let config = ProviderConfig {
|
||||
name: Some("local-test".to_string()),
|
||||
provider: SupportedProvider::Local(LocalProvider),
|
||||
password_file: None,
|
||||
git_branch: None,
|
||||
@@ -175,23 +188,110 @@ mod tests {
|
||||
git_user_name: None,
|
||||
git_user_email: Some("test".to_string()),
|
||||
git_executable: None,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_config_missing_name() {
|
||||
let config = ProviderConfig {
|
||||
name: None,
|
||||
provider: SupportedProvider::Local(LocalProvider),
|
||||
password_file: None,
|
||||
git_branch: None,
|
||||
git_remote_url: None,
|
||||
git_user_name: None,
|
||||
git_user_email: None,
|
||||
git_executable: None,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_config_default() {
|
||||
let config = ProviderConfig::default();
|
||||
|
||||
assert_eq!(config.name, Some("local".to_string()));
|
||||
assert_eq!(config.git_user_email, None);
|
||||
assert_eq!(config.password_file, Config::local_provider_password_file());
|
||||
assert_eq!(config.git_branch, Some("main".into()));
|
||||
assert_eq!(config.git_remote_url, None);
|
||||
assert_eq!(config.git_user_name, None);
|
||||
assert_eq!(config.git_executable, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_valid() {
|
||||
let config = Config {
|
||||
default_provider: Some("local".into()),
|
||||
providers: vec![ProviderConfig::default()],
|
||||
run_configs: None,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_invalid_default_provider() {
|
||||
let config = Config {
|
||||
default_provider: Some("nonexistent".into()),
|
||||
providers: vec![ProviderConfig::default()],
|
||||
run_configs: None,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_invalid_no_providers() {
|
||||
let config = Config {
|
||||
default_provider: Some("local".into()),
|
||||
providers: vec![],
|
||||
run_configs: None,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.provider, SupportedProvider::Local(LocalProvider));
|
||||
assert_eq!(config.git_branch, Some("main".to_string()));
|
||||
|
||||
assert_eq!(config.default_provider, Some("local".to_string()));
|
||||
assert_eq!(config.providers, vec![ProviderConfig::default()]);
|
||||
assert_eq!(config.run_configs, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_extract_provider() {
|
||||
let config = Config::default();
|
||||
let provider = config.extract_provider();
|
||||
assert_str_eq!(provider.name(), "LocalProvider");
|
||||
let provider = config.extract_provider_config(None).unwrap();
|
||||
|
||||
assert_eq!(provider.name, Some("local".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_extract_provider_with_name() {
|
||||
let mut config = Config::default();
|
||||
config.providers.push(ProviderConfig {
|
||||
name: Some("custom".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
let provider = config
|
||||
.extract_provider_config(Some("custom".into()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(provider.name, Some("custom".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_extract_provider_not_found() {
|
||||
let config = Config::default();
|
||||
let result = config.extract_provider_config(Some("nonexistent".into()));
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use base64::Engine;
|
||||
use gman::{decrypt_string, encrypt_string};
|
||||
use proptest::prelude::*;
|
||||
use secrecy::SecretString;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,2048}") {
|
||||
let pw = SecretString::new(password.into());
|
||||
let env = encrypt_string(pw.clone(), &msg).unwrap();
|
||||
let out = decrypt_string(pw, &env).unwrap();
|
||||
|
||||
prop_assert_eq!(out, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,256}") {
|
||||
let pw = SecretString::new(password.into());
|
||||
let env = encrypt_string(pw.clone(), &msg).unwrap();
|
||||
// Flip a bit in the ct payload segment
|
||||
let mut parts: Vec<&str> = env.split(';').collect();
|
||||
let ct_b64 = parts[6].strip_prefix("ct=").unwrap();
|
||||
let mut ct = base64::engine::general_purpose::STANDARD.decode(ct_b64).unwrap();
|
||||
ct[0] ^= 0x1;
|
||||
let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
|
||||
let new_ct = format!("ct={}", new_ct_b64);
|
||||
parts[6] = Box::leak(new_ct.into_boxed_str());
|
||||
let tampered = parts.join(";");
|
||||
|
||||
prop_assert!(decrypt_string(pw, &tampered).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use validator::Validate;
|
||||
|
||||
// Redefining the struct here for testing purposes
|
||||
pub struct SyncOpts<'a> {
|
||||
pub remote_url: &'a Option<String>,
|
||||
pub branch: &'a Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> Validate for SyncOpts<'a> {
|
||||
fn validate(&self) -> Result<(), validator::ValidationErrors> {
|
||||
if self.remote_url.is_none() {
|
||||
return Err(validator::ValidationErrors::new());
|
||||
}
|
||||
if self.branch.is_none() {
|
||||
return Err(validator::ValidationErrors::new());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_opts_validation_valid() {
|
||||
let remote_url = Some("https://github.com/user/repo.git".to_string());
|
||||
let branch = Some("main".to_string());
|
||||
let opts = SyncOpts {
|
||||
remote_url: &remote_url,
|
||||
branch: &branch,
|
||||
};
|
||||
assert!(opts.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_opts_validation_missing_remote_url() {
|
||||
let remote_url = None;
|
||||
let branch = Some("main".to_string());
|
||||
let opts = SyncOpts {
|
||||
remote_url: &remote_url,
|
||||
branch: &branch,
|
||||
};
|
||||
assert!(opts.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_opts_validation_missing_branch() {
|
||||
let remote_url = Some("https://github.com/user/repo.git".to_string());
|
||||
let branch = None;
|
||||
let opts = SyncOpts {
|
||||
remote_url: &remote_url,
|
||||
branch: &branch,
|
||||
};
|
||||
assert!(opts.validate().is_err());
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
mod git_sync_tests;
|
||||
mod local_tests;
|
||||
mod provider_tests;
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
mod bin;
|
||||
mod config_tests;
|
||||
mod providers;
|
||||
mod bin;
|
||||
mod prop_crypto;
|
||||
Reference in New Issue
Block a user