use crate::config::{
Config, GlobalConfig, GlobalDefaults, ParseError, Profile, Project, Resolved, Secret,
};
use crate::error::{Result, SecretSpecError};
use crate::secrets::Secrets;
use crate::validation::{ValidatedSecrets, ValidationErrors};
use secrecy::ExposeSecret;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::path::Path;
use std::{fs, io};
use tempfile::TempDir;
fn parse_spec_from_str(content: &str, _base_path: Option<&Path>) -> Result<Config> {
let config: Config = toml::from_str(content).map_err(SecretSpecError::Toml)?;
if config.project.revision != "1.0" {
return Err(SecretSpecError::UnsupportedRevision(
config.project.revision,
));
}
config.validate().map_err(|e| SecretSpecError::from(e))?;
Ok(config)
}
#[test]
fn test_new_with_project_config() {
let config = Config {
project: Project {
name: "test-project".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
};
let spec = Secrets::new(config, None, None, None);
assert_eq!(spec.config().project.name, "test-project");
}
#[test]
fn test_new_with_custom_configs() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path().join("custom-secretspec.toml");
let global_path = temp_dir.path().join("custom-global.toml");
let project_config = r#"
[project]
name = "custom-project"
revision = "1.0"
[profiles.default]
API_KEY = { description = "API Key", required = true }
"#;
fs::write(&project_path, project_config).unwrap();
let global_config = r#"
[defaults]
provider = "keyring"
profile = "development"
"#;
fs::write(&global_path, global_config).unwrap();
let config = Config::try_from(project_path.as_path()).unwrap();
let global_config_content = fs::read_to_string(&global_path).unwrap();
let global_config: Option<GlobalConfig> = Some(toml::from_str(&global_config_content).unwrap());
let spec = Secrets::new(config, global_config, None, None);
assert_eq!(spec.config().project.name, "custom-project");
assert_eq!(
spec.global_config()
.as_ref()
.unwrap()
.defaults
.provider
.as_ref(),
Some(&"keyring".to_string())
);
}
#[test]
fn test_new_with_default_overrides() {
let config = Config {
project: Project {
name: "test-project".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("dotenv".to_string()),
profile: Some("production".to_string()),
providers: None,
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
assert_eq!(spec.config().project.name, "test-project");
}
#[test]
fn test_extends_functionality() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("common")).unwrap();
fs::create_dir_all(base_path.join("auth")).unwrap();
fs::create_dir_all(base_path.join("base")).unwrap();
let common_config = r#"
[project]
name = "common"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Database connection string", required = true }
REDIS_URL = { description = "Redis connection URL", required = false, default = "redis://localhost:6379" }
[profiles.development]
DATABASE_URL = { description = "Database connection string", required = false, default = "sqlite:///dev.db" }
REDIS_URL = { description = "Redis connection URL", required = false, default = "redis://localhost:6379" }
"#;
fs::write(base_path.join("common/secretspec.toml"), common_config).unwrap();
let auth_config = r#"
[project]
name = "auth"
revision = "1.0"
[profiles.default]
JWT_SECRET = { description = "Secret key for JWT token signing", required = true }
OAUTH_CLIENT_ID = { description = "OAuth client ID", required = false }
"#;
fs::write(base_path.join("auth/secretspec.toml"), auth_config).unwrap();
let base_config = r#"
[project]
name = "test_project"
revision = "1.0"
extends = ["../common", "../auth"]
[profiles.default]
API_KEY = { description = "API key for external service", required = true }
# This should override the common one
DATABASE_URL = { description = "Override database connection", required = true }
[profiles.development]
API_KEY = { description = "API key for external service", required = false, default = "dev-api-key" }
"#;
fs::write(base_path.join("base/secretspec.toml"), base_config).unwrap();
let config = Config::try_from(base_path.join("base/secretspec.toml").as_path()).unwrap();
assert_eq!(config.project.name, "test_project");
assert_eq!(config.project.revision, "1.0");
assert_eq!(
config.project.extends,
Some(vec!["../common".to_string(), "../auth".to_string()])
);
let default_profile = config.profiles.get("default").unwrap();
assert!(default_profile.secrets.contains_key("API_KEY"));
assert!(default_profile.secrets.contains_key("DATABASE_URL"));
assert!(default_profile.secrets.contains_key("REDIS_URL"));
assert!(default_profile.secrets.contains_key("JWT_SECRET"));
assert!(default_profile.secrets.contains_key("OAUTH_CLIENT_ID"));
let database_url_config = default_profile.secrets.get("DATABASE_URL").unwrap();
assert_eq!(
database_url_config.description,
Some("Override database connection".to_string())
);
let redis_config = default_profile.secrets.get("REDIS_URL").unwrap();
assert_eq!(
redis_config.description,
Some("Redis connection URL".to_string())
);
assert_eq!(redis_config.required, Some(false));
assert_eq!(
redis_config.default,
Some("redis://localhost:6379".to_string())
);
let jwt_config = default_profile.secrets.get("JWT_SECRET").unwrap();
assert_eq!(
jwt_config.description,
Some("Secret key for JWT token signing".to_string())
);
assert_eq!(jwt_config.required, Some(true));
}
#[test]
fn test_validation_result_structure() {
let valid_result = ValidatedSecrets {
resolved: Resolved::new(HashMap::new(), "keyring".to_string(), "default".to_string()),
missing_optional: vec!["optional_secret".to_string()],
with_defaults: Vec::new(),
temp_files: Vec::new(),
};
assert_eq!(valid_result.missing_optional.len(), 1);
assert_eq!(valid_result.with_defaults.len(), 0);
let validation_errors = ValidationErrors::new(
vec!["required_secret".to_string()],
vec!["optional_secret".to_string()],
vec![],
"keyring".to_string(),
"default".to_string(),
);
assert!(validation_errors.has_errors());
assert_eq!(validation_errors.missing_required.len(), 1);
}
#[test]
fn test_secretspec_new() {
let config = Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: Some("dev".to_string()),
providers: None,
},
};
let spec = Secrets::new(config.clone(), Some(global_config.clone()), None, None);
assert_eq!(spec.config().project.name, "test");
assert!(spec.global_config().is_some());
assert_eq!(
spec.global_config().as_ref().unwrap().defaults.provider,
Some("keyring".to_string())
);
let spec_without_global = Secrets::new(config, None, None, None);
assert!(spec_without_global.global_config().is_none());
}
#[test]
fn test_resolve_profile() {
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: Some("development".to_string()),
providers: None,
},
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
},
Some(global_config),
None,
None,
);
assert_eq!(spec.resolve_profile_name(Some("production")), "production");
assert_eq!(spec.resolve_profile_name(None), "development");
let spec_no_global = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
},
None,
None,
None,
);
assert_eq!(spec_no_global.resolve_profile_name(None), "default");
}
#[test]
fn test_resolve_secret_config() {
let mut default_secrets = HashMap::new();
default_secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
default_secrets.insert(
"DATABASE_URL".to_string(),
Secret {
description: Some("Database URL".to_string()),
required: Some(false),
default: Some("sqlite:///default.db".to_string()),
providers: None,
as_path: None,
..Default::default()
},
);
let mut dev_secrets = HashMap::new();
dev_secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("Dev API Key".to_string()),
required: Some(false),
default: Some("dev-key".to_string()),
providers: None,
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets: default_secrets,
},
);
profiles.insert(
"development".to_string(),
Profile {
defaults: None,
secrets: dev_secrets,
},
);
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
},
None,
None,
None,
);
let secret_config = spec
.resolve_secret_config("API_KEY", Some("development"))
.unwrap();
assert_eq!(secret_config.required, Some(false));
assert_eq!(secret_config.default, Some("dev-key".to_string()));
let secret_config = spec
.resolve_secret_config("DATABASE_URL", Some("development"))
.unwrap();
assert_eq!(secret_config.required, Some(false));
assert_eq!(
secret_config.default,
Some("sqlite:///default.db".to_string())
);
assert!(
spec.resolve_secret_config("NONEXISTENT", Some("development"))
.is_none()
);
}
#[test]
fn test_get_provider_error_cases() {
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
},
None,
None,
None,
);
let result = spec.get_provider(None);
assert!(matches!(result, Err(SecretSpecError::NoProviderConfigured)));
}
#[test]
fn test_get_provider_with_global_config() {
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: None,
},
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
},
Some(global_config),
None,
None,
);
let result = spec.get_provider(None);
assert!(result.is_ok());
}
#[test]
fn test_project_config_from_path_error_handling() {
let temp_dir = TempDir::new().unwrap();
let invalid_toml = temp_dir.path().join("invalid.toml");
fs::write(&invalid_toml, "[invalid toml content").unwrap();
let result = Config::try_from(invalid_toml.as_path()).map_err(Into::<SecretSpecError>::into);
assert!(matches!(result, Err(SecretSpecError::Toml(_))));
let nonexistent = temp_dir.path().join("nonexistent.toml");
let result = Config::try_from(nonexistent.as_path()).map_err(Into::<SecretSpecError>::into);
assert!(matches!(result, Err(SecretSpecError::NoManifest)));
}
#[test]
fn test_parse_spec_from_str() {
let valid_toml = r#"
[project]
name = "test"
revision = "1.0"
[profiles.default]
API_KEY = { description = "API Key", required = true }
"#;
let result = parse_spec_from_str(valid_toml, None);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.project.name, "test");
let invalid_toml = "[invalid";
let result = parse_spec_from_str(invalid_toml, None);
assert!(matches!(result, Err(SecretSpecError::Toml(_))));
}
#[test]
fn test_extends_with_real_world_example() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("common")).unwrap();
fs::create_dir_all(base_path.join("auth")).unwrap();
fs::create_dir_all(base_path.join("base")).unwrap();
let common_config = r#"
[project]
name = "common"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Main database connection string", required = true }
REDIS_URL = { description = "Redis cache connection", required = false, default = "redis://localhost:6379" }
[profiles.development]
DATABASE_URL = { description = "Development database", required = false, default = "sqlite:///dev.db" }
REDIS_URL = { description = "Redis cache connection", required = false, default = "redis://localhost:6379" }
[profiles.production]
DATABASE_URL = { description = "Production database", required = true }
REDIS_URL = { description = "Redis cache connection", required = true }
"#;
fs::write(base_path.join("common/secretspec.toml"), common_config).unwrap();
let auth_config = r#"
[project]
name = "auth"
revision = "1.0"
[profiles.default]
JWT_SECRET = { description = "Secret for JWT signing", required = true }
OAUTH_CLIENT_ID = { description = "OAuth client identifier", required = false }
OAUTH_CLIENT_SECRET = { description = "OAuth client secret", required = false }
[profiles.production]
JWT_SECRET = { description = "Secret for JWT signing", required = true }
OAUTH_CLIENT_ID = { description = "OAuth client identifier", required = true }
OAUTH_CLIENT_SECRET = { description = "OAuth client secret", required = true }
"#;
fs::write(base_path.join("auth/secretspec.toml"), auth_config).unwrap();
let base_config = r#"
[project]
name = "my_app"
revision = "1.0"
extends = ["../common", "../auth"]
[profiles.default]
API_KEY = { description = "External API key", required = true }
# Override the database description from common
DATABASE_URL = { description = "Custom database for my app", required = true }
[profiles.development]
API_KEY = { description = "External API key", required = false, default = "dev-key-123" }
[profiles.production]
API_KEY = { description = "External API key", required = true }
MONITORING_TOKEN = { description = "Token for monitoring service", required = true }
"#;
fs::write(base_path.join("base/secretspec.toml"), base_config).unwrap();
let config = Config::try_from(base_path.join("base/secretspec.toml").as_path()).unwrap();
assert_eq!(config.project.name, "my_app");
assert_eq!(config.project.revision, "1.0");
assert_eq!(
config.project.extends,
Some(vec!["../common".to_string(), "../auth".to_string()])
);
let default_profile = config.profiles.get("default").unwrap();
assert_eq!(default_profile.secrets.len(), 6);
let database_url = default_profile.secrets.get("DATABASE_URL").unwrap();
assert_eq!(
database_url.description,
Some("Custom database for my app".to_string())
);
assert_eq!(database_url.required, Some(true));
let redis_url = default_profile.secrets.get("REDIS_URL").unwrap();
assert_eq!(
redis_url.description,
Some("Redis cache connection".to_string())
);
assert_eq!(redis_url.required, Some(false));
assert_eq!(
redis_url.default,
Some("redis://localhost:6379".to_string())
);
let jwt_secret = default_profile.secrets.get("JWT_SECRET").unwrap();
assert_eq!(
jwt_secret.description,
Some("Secret for JWT signing".to_string())
);
assert_eq!(jwt_secret.required, Some(true));
let dev_profile = config.profiles.get("development").unwrap();
let dev_api_key = dev_profile.secrets.get("API_KEY").unwrap();
assert_eq!(dev_api_key.required, Some(false));
assert_eq!(dev_api_key.default, Some("dev-key-123".to_string()));
let dev_database_url = dev_profile.secrets.get("DATABASE_URL").unwrap();
assert_eq!(
dev_database_url.description,
Some("Development database".to_string())
);
assert_eq!(dev_database_url.required, Some(false));
assert_eq!(
dev_database_url.default,
Some("sqlite:///dev.db".to_string())
);
let prod_profile = config.profiles.get("production").unwrap();
assert_eq!(
prod_profile.secrets.get("API_KEY").unwrap().required,
Some(true)
);
assert_eq!(
prod_profile.secrets.get("DATABASE_URL").unwrap().required,
Some(true)
);
assert_eq!(
prod_profile.secrets.get("REDIS_URL").unwrap().required,
Some(true)
);
assert_eq!(
prod_profile.secrets.get("JWT_SECRET").unwrap().required,
Some(true)
);
assert_eq!(
prod_profile
.secrets
.get("OAUTH_CLIENT_ID")
.unwrap()
.required,
Some(true)
);
assert_eq!(
prod_profile
.secrets
.get("OAUTH_CLIENT_SECRET")
.unwrap()
.required,
Some(true)
);
assert_eq!(
prod_profile
.secrets
.get("MONITORING_TOKEN")
.unwrap()
.required,
Some(true)
);
}
#[test]
fn test_extends_with_direct_circular_dependency() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("a")).unwrap();
fs::create_dir_all(base_path.join("b")).unwrap();
let config_a = r#"
[project]
name = "config_a"
revision = "1.0"
extends = ["../b"]
[profiles.default]
SECRET_A = { description = "Secret A", required = true }
"#;
fs::write(base_path.join("a/secretspec.toml"), config_a).unwrap();
let config_b = r#"
[project]
name = "config_b"
revision = "1.0"
extends = ["../a"]
[profiles.default]
SECRET_B = { description = "Secret B", required = true }
"#;
fs::write(base_path.join("b/secretspec.toml"), config_b).unwrap();
let result = Config::try_from(base_path.join("a/secretspec.toml").as_path());
assert!(result.is_err());
match result {
Err(ParseError::CircularDependency(msg)) => {
assert!(msg.contains("circular dependency"));
}
_ => panic!("Expected CircularDependency error"),
}
}
#[test]
fn test_extends_with_indirect_circular_dependency() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("a")).unwrap();
fs::create_dir_all(base_path.join("b")).unwrap();
fs::create_dir_all(base_path.join("c")).unwrap();
let config_a = r#"
[project]
name = "config_a"
revision = "1.0"
extends = ["../b"]
[profiles.default]
SECRET_A = { description = "Secret A", required = true }
"#;
fs::write(base_path.join("a/secretspec.toml"), config_a).unwrap();
let config_b = r#"
[project]
name = "config_b"
revision = "1.0"
extends = ["../c"]
[profiles.default]
SECRET_B = { description = "Secret B", required = true }
"#;
fs::write(base_path.join("b/secretspec.toml"), config_b).unwrap();
let config_c = r#"
[project]
name = "config_c"
revision = "1.0"
extends = ["../a"]
[profiles.default]
SECRET_C = { description = "Secret C", required = true }
"#;
fs::write(base_path.join("c/secretspec.toml"), config_c).unwrap();
let result = Config::try_from(base_path.join("a/secretspec.toml").as_path());
assert!(result.is_err());
match result {
Err(ParseError::CircularDependency(msg)) => {
assert!(msg.contains("circular dependency"));
}
_ => panic!("Expected CircularDependency error"),
}
}
#[test]
fn test_nested_extends() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("a")).unwrap();
fs::create_dir_all(base_path.join("b")).unwrap();
fs::create_dir_all(base_path.join("c")).unwrap();
let config_c = r#"
[project]
name = "config_c"
revision = "1.0"
[profiles.default]
SECRET_C = { description = "Secret C from base", required = true }
COMMON_SECRET = { description = "Common secret from C", required = true }
[profiles.production]
SECRET_C = { description = "Secret C for production", required = true }
"#;
fs::write(base_path.join("c/secretspec.toml"), config_c).unwrap();
let config_b = r#"
[project]
name = "config_b"
revision = "1.0"
extends = ["../c"]
[profiles.default]
SECRET_B = { description = "Secret B", required = true }
COMMON_SECRET = { description = "Common secret overridden by B", required = false, default = "default-b" }
[profiles.staging]
SECRET_B = { description = "Secret B for staging", required = true }
"#;
fs::write(base_path.join("b/secretspec.toml"), config_b).unwrap();
let config_a = r#"
[project]
name = "config_a"
revision = "1.0"
extends = ["../b"]
[profiles.default]
SECRET_A = { description = "Secret A", required = true }
[profiles.staging]
SECRET_A = { description = "Secret A for staging", required = false, default = "staging-a" }
"#;
fs::write(base_path.join("a/secretspec.toml"), config_a).unwrap();
let config = Config::try_from(base_path.join("a/secretspec.toml").as_path()).unwrap();
assert_eq!(config.project.name, "config_a");
let default_profile = config.profiles.get("default").unwrap();
assert_eq!(default_profile.secrets.len(), 4);
assert!(default_profile.secrets.contains_key("SECRET_A"));
assert!(default_profile.secrets.contains_key("SECRET_B"));
assert!(default_profile.secrets.contains_key("SECRET_C"));
assert!(default_profile.secrets.contains_key("COMMON_SECRET"));
let common_secret = default_profile.secrets.get("COMMON_SECRET").unwrap();
assert_eq!(
common_secret.description,
Some("Common secret overridden by B".to_string())
);
assert_eq!(common_secret.required, Some(false));
assert_eq!(common_secret.default, Some("default-b".to_string()));
let staging_profile = config.profiles.get("staging").unwrap();
assert!(staging_profile.secrets.contains_key("SECRET_A"));
assert!(staging_profile.secrets.contains_key("SECRET_B"));
let prod_profile = config.profiles.get("production").unwrap();
assert!(prod_profile.secrets.contains_key("SECRET_C"));
assert!(!prod_profile.secrets.contains_key("SECRET_A")); assert!(!prod_profile.secrets.contains_key("SECRET_B")); }
#[test]
fn test_extends_with_path_resolution_edge_cases() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("project/src")).unwrap();
fs::create_dir_all(base_path.join("shared/common")).unwrap();
fs::create_dir_all(base_path.join("shared/auth")).unwrap();
let common_config = r#"
[project]
name = "common"
revision = "1.0"
[profiles.default]
COMMON_SECRET = { description = "Common secret", required = true }
"#;
fs::write(
base_path.join("shared/common/secretspec.toml"),
common_config,
)
.unwrap();
let auth_config = r#"
[project]
name = "auth"
revision = "1.0"
[profiles.default]
AUTH_SECRET = { description = "Auth secret", required = true }
"#;
fs::write(base_path.join("shared/auth/secretspec.toml"), auth_config).unwrap();
let config_relative = r#"
[project]
name = "project"
revision = "1.0"
extends = ["../../shared/common", "../../shared/auth"]
[profiles.default]
PROJECT_SECRET = { description = "Project secret", required = true }
"#;
fs::write(
base_path.join("project/src/secretspec.toml"),
config_relative,
)
.unwrap();
let config = Config::try_from(base_path.join("project/src/secretspec.toml").as_path()).unwrap();
let default_profile = config.profiles.get("default").unwrap();
assert_eq!(default_profile.secrets.len(), 3);
assert!(default_profile.secrets.contains_key("COMMON_SECRET"));
assert!(default_profile.secrets.contains_key("AUTH_SECRET"));
assert!(default_profile.secrets.contains_key("PROJECT_SECRET"));
let config_dot_slash = r#"
[project]
name = "project2"
revision = "1.0"
extends = ["./../../shared/common"]
[profiles.default]
PROJECT2_SECRET = { description = "Project2 secret", required = true }
"#;
fs::write(
base_path.join("project/src/secretspec2.toml"),
config_dot_slash,
)
.unwrap();
let config2 =
Config::try_from(base_path.join("project/src/secretspec2.toml").as_path()).unwrap();
let default_profile2 = config2.profiles.get("default").unwrap();
assert_eq!(default_profile2.secrets.len(), 2);
assert!(default_profile2.secrets.contains_key("COMMON_SECRET"));
assert!(default_profile2.secrets.contains_key("PROJECT2_SECRET"));
let dir_with_spaces = base_path.join("dir with spaces");
if fs::create_dir_all(&dir_with_spaces).is_ok() {
let config_spaces = r#"
[project]
name = "spaces"
revision = "1.0"
[profiles.default]
SPACE_SECRET = { description = "Secret in dir with spaces", required = true }
"#;
fs::write(dir_with_spaces.join("secretspec.toml"), config_spaces).unwrap();
let config_extends_spaces = r#"
[project]
name = "project3"
revision = "1.0"
extends = ["../dir with spaces"]
[profiles.default]
PROJECT3_SECRET = { description = "Project3 secret", required = true }
"#;
fs::write(
base_path.join("project/secretspec3.toml"),
config_extends_spaces,
)
.unwrap();
let config3 =
Config::try_from(base_path.join("project/secretspec3.toml").as_path()).unwrap();
let default_profile3 = config3.profiles.get("default").unwrap();
assert_eq!(default_profile3.secrets.len(), 2);
assert!(default_profile3.secrets.contains_key("SPACE_SECRET"));
assert!(default_profile3.secrets.contains_key("PROJECT3_SECRET"));
}
}
#[test]
fn test_empty_extends_array() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let config_empty_extends = r#"
[project]
name = "project"
revision = "1.0"
extends = []
[profiles.default]
SECRET_A = { description = "Secret A", required = true }
[profiles.production]
SECRET_B = { description = "Secret B", required = false, default = "prod-b" }
"#;
fs::write(base_path.join("secretspec.toml"), config_empty_extends).unwrap();
let config = Config::try_from(base_path.join("secretspec.toml").as_path()).unwrap();
assert_eq!(config.project.name, "project");
assert_eq!(config.project.extends, Some(vec![]));
let default_profile = config.profiles.get("default").unwrap();
assert_eq!(default_profile.secrets.len(), 1);
assert!(default_profile.secrets.contains_key("SECRET_A"));
let prod_profile = config.profiles.get("production").unwrap();
assert_eq!(prod_profile.secrets.len(), 1);
assert!(prod_profile.secrets.contains_key("SECRET_B"));
}
#[test]
fn test_extends_with_file_path() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("shared")).unwrap();
fs::create_dir_all(base_path.join("backend")).unwrap();
let shared_config = r#"
[project]
name = "shared"
revision = "1.0"
[profiles.default]
SHARED_SECRET = { description = "A shared secret", required = true }
"#;
fs::write(base_path.join("shared/secretspec.toml"), shared_config).unwrap();
let backend_config = r#"
[project]
name = "backend"
revision = "1.0"
extends = ["../shared/secretspec.toml"]
[profiles.default]
BACKEND_SECRET = { description = "Backend specific secret", required = true }
"#;
fs::write(base_path.join("backend/secretspec.toml"), backend_config).unwrap();
let config = Config::try_from(base_path.join("backend/secretspec.toml").as_path()).unwrap();
assert_eq!(config.project.name, "backend");
assert_eq!(
config.project.extends,
Some(vec!["../shared/secretspec.toml".to_string()])
);
let default_profile = config.profiles.get("default").unwrap();
assert!(default_profile.secrets.contains_key("BACKEND_SECRET"));
assert!(default_profile.secrets.contains_key("SHARED_SECRET"));
}
#[test]
fn test_self_extension() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let config_self_dot = r#"
[project]
name = "self_extend"
revision = "1.0"
extends = ["."]
[profiles.default]
SECRET_A = { description = "Secret A", required = true }
"#;
fs::write(base_path.join("secretspec.toml"), config_self_dot).unwrap();
let result = Config::try_from(base_path.join("secretspec.toml").as_path());
assert!(result.is_err());
match result {
Err(ParseError::CircularDependency(msg)) => {
assert!(msg.contains("circular dependency"));
}
_ => panic!("Expected CircularDependency error for self-extension"),
}
fs::create_dir_all(base_path.join("subdir")).unwrap();
let parent_config = r#"
[project]
name = "parent"
revision = "1.0"
extends = ["./subdir"]
[profiles.default]
PARENT_SECRET = { description = "Parent secret", required = true }
"#;
fs::write(base_path.join("secretspec.toml"), parent_config).unwrap();
let child_config = r#"
[project]
name = "child"
revision = "1.0"
extends = [".."]
[profiles.default]
CHILD_SECRET = { description = "Child secret", required = true }
"#;
fs::write(base_path.join("subdir/secretspec.toml"), child_config).unwrap();
let result2 = Config::try_from(base_path.join("secretspec.toml").as_path());
assert!(result2.is_err());
match result2 {
Err(ParseError::CircularDependency(msg)) => {
assert!(msg.contains("circular dependency"));
}
_ => panic!("Expected CircularDependency error for parent-child circular reference"),
}
}
#[test]
fn test_property_overrides() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("base")).unwrap();
fs::create_dir_all(base_path.join("override")).unwrap();
let base_config = r#"
[project]
name = "base"
revision = "1.0"
[profiles.default]
SECRET_A = { description = "Original description A", required = true }
SECRET_B = { description = "Original description B", required = true, default = "original-b" }
SECRET_C = { description = "Original description C", required = false }
SECRET_D = { description = "Original description D", required = false, default = "original-d" }
"#;
fs::write(base_path.join("base/secretspec.toml"), base_config).unwrap();
let override_config = r#"
[project]
name = "override"
revision = "1.0"
extends = ["../base"]
[profiles.default]
# Override just description
SECRET_A = { description = "New description A", required = true }
# Override just required flag
SECRET_B = { description = "Original description B", required = false, default = "original-b" }
# Override just default value
SECRET_C = { description = "Original description C", required = false, default = "new-c" }
# Override multiple properties
SECRET_D = { description = "New description D", required = true }
# Add new secret
SECRET_E = { description = "New secret E", required = true }
"#;
fs::write(base_path.join("override/secretspec.toml"), override_config).unwrap();
let config = Config::try_from(base_path.join("override/secretspec.toml").as_path()).unwrap();
let default_profile = config.profiles.get("default").unwrap();
let secret_a = default_profile.secrets.get("SECRET_A").unwrap();
assert_eq!(secret_a.description, Some("New description A".to_string()));
assert_eq!(secret_a.required, Some(true));
assert_eq!(secret_a.default, None);
let secret_b = default_profile.secrets.get("SECRET_B").unwrap();
assert_eq!(
secret_b.description,
Some("Original description B".to_string())
);
assert_eq!(secret_b.required, Some(false)); assert_eq!(secret_b.default, Some("original-b".to_string()));
let secret_c = default_profile.secrets.get("SECRET_C").unwrap();
assert_eq!(
secret_c.description,
Some("Original description C".to_string())
);
assert_eq!(secret_c.required, Some(false));
assert_eq!(secret_c.default, Some("new-c".to_string()));
let secret_d = default_profile.secrets.get("SECRET_D").unwrap();
assert_eq!(secret_d.description, Some("New description D".to_string()));
assert_eq!(secret_d.required, Some(true)); assert_eq!(secret_d.default, None);
let secret_e = default_profile.secrets.get("SECRET_E").unwrap();
assert_eq!(secret_e.description, Some("New secret E".to_string()));
assert_eq!(secret_e.required, Some(true));
assert_eq!(secret_e.default, None);
}
#[test]
fn test_extends_with_missing_file() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let base_config = r#"
[project]
name = "test_project"
revision = "1.0"
extends = ["../nonexistent"]
[profiles.default]
API_KEY = { description = "API key for external service", required = true }
"#;
fs::write(base_path.join("secretspec.toml"), base_config).unwrap();
let result = Config::try_from(base_path.join("secretspec.toml").as_path());
assert!(result.is_err());
match result {
Err(ParseError::ExtendedConfigNotFound(path)) => {
assert!(path.contains("nonexistent"));
}
_ => panic!("Expected ExtendedConfigNotFound error for missing file"),
}
}
#[test]
fn test_extends_with_invalid_inputs() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let some_file = base_path.join("notadir.txt");
fs::write(&some_file, "not a directory").unwrap();
let config_extend_file = r#"
[project]
name = "test"
revision = "1.0"
extends = ["./notadir.txt"]
[profiles.default]
SECRET_A = { description = "Secret A", required = true }
"#;
fs::write(base_path.join("secretspec.toml"), config_extend_file).unwrap();
let result = Config::try_from(base_path.join("secretspec.toml").as_path());
assert!(result.is_err());
match result {
Err(ParseError::ExtendedConfigNotFound(path)) => {
assert!(path.contains("notadir.txt"));
}
_ => panic!("Expected ExtendedConfigNotFound error for extending to file"),
}
let config_empty_string = r#"
[project]
name = "test2"
revision = "1.0"
extends = [""]
[profiles.default]
SECRET_B = { description = "Secret B", required = true }
"#;
fs::write(base_path.join("secretspec2.toml"), config_empty_string).unwrap();
let result2 = Config::try_from(base_path.join("secretspec2.toml").as_path());
assert!(result2.is_err());
let config_no_dir = r#"
[project]
name = "test3"
revision = "1.0"
extends = ["./does_not_exist"]
[profiles.default]
SECRET_C = { description = "Secret C", required = true }
"#;
fs::write(base_path.join("secretspec3.toml"), config_no_dir).unwrap();
let result3 = Config::try_from(base_path.join("secretspec3.toml").as_path());
assert!(result3.is_err());
match result3 {
Err(ParseError::ExtendedConfigNotFound(path)) => {
assert!(path.contains("does_not_exist"));
}
_ => panic!("Expected ExtendedConfigNotFound error for non-existent directory"),
}
}
#[test]
fn test_extends_with_different_revisions() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("old")).unwrap();
let old_config = r#"
[project]
name = "old"
revision = "0.9"
[profiles.default]
OLD_SECRET = { description = "Old secret", required = true }
"#;
fs::write(base_path.join("old/secretspec.toml"), old_config).unwrap();
let new_config = r#"
[project]
name = "new"
revision = "1.0"
extends = ["./old"]
[profiles.default]
NEW_SECRET = { description = "New secret", required = true }
"#;
fs::write(base_path.join("secretspec.toml"), new_config).unwrap();
let result = Config::try_from(base_path.join("secretspec.toml").as_path());
assert!(result.is_err());
match result {
Err(ParseError::UnsupportedRevision(rev)) => {
assert_eq!(rev, "0.9");
}
_ => panic!("Expected UnsupportedRevision error"),
}
}
#[test]
fn test_set_with_undefined_secret() {
let project_config = Config {
project: Project {
name: "test_project".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: {
let mut profiles = HashMap::new();
let mut secrets = HashMap::new();
secrets.insert(
"DEFINED_SECRET".to_string(),
Secret {
description: Some("A defined secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
profiles
},
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: None,
},
};
let spec = Secrets::new(project_config, Some(global_config), None, None);
let result = spec.set("UNDEFINED_SECRET", Some("test_value".to_string()));
assert!(result.is_err());
match result {
Err(SecretSpecError::SecretNotFound(msg)) => {
assert!(msg.contains("UNDEFINED_SECRET"));
assert!(msg.contains("not defined in profile"));
assert!(msg.contains("DEFINED_SECRET"));
}
_ => panic!("Expected SecretNotFound error"),
}
}
#[test]
fn test_set_with_defined_secret() {
use std::env;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(&temp_dir).unwrap();
let project_config = Config {
project: Project {
name: "test_project".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: {
let mut profiles = HashMap::new();
let mut secrets = HashMap::new();
secrets.insert(
"DEFINED_SECRET".to_string(),
Secret {
description: Some("A defined secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
profiles
},
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("dotenv".to_string()),
profile: None,
providers: None,
},
};
let spec = Secrets::new(project_config, Some(global_config), None, None);
let result = spec.set("DEFINED_SECRET", Some("test_value".to_string()));
env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok(), "Setting a defined secret should succeed");
}
#[test]
fn test_set_with_readonly_provider() {
let project_config = Config {
project: Project {
name: "test_project".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: {
let mut profiles = HashMap::new();
let mut secrets = HashMap::new();
secrets.insert(
"DEFINED_SECRET".to_string(),
Secret {
description: Some("A defined secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
profiles
},
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: None,
},
};
let spec = Secrets::new(project_config, Some(global_config), None, None);
let result = spec.set("DEFINED_SECRET", Some("test_value".to_string()));
assert!(result.is_err());
match result {
Err(SecretSpecError::ProviderOperationFailed(msg)) => {
assert!(msg.contains("read-only"));
}
_ => panic!("Expected ProviderOperationFailed error for read-only provider"),
}
}
#[test]
fn test_import_between_dotenv_files() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
let project_config = Config {
project: Project {
name: "test_import_project".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: {
let mut profiles = HashMap::new();
let mut secrets = HashMap::new();
secrets.insert(
"SECRET_ONE".to_string(),
Secret {
description: Some("First test secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
secrets.insert(
"SECRET_TWO".to_string(),
Secret {
description: Some("Second test secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
secrets.insert(
"SECRET_THREE".to_string(),
Secret {
description: Some("Third test secret".to_string()),
required: Some(false),
default: Some("default_value".to_string()),
providers: None,
as_path: None,
..Default::default()
},
);
secrets.insert(
"SECRET_FOUR".to_string(),
Secret {
description: Some("Fourth test secret (not in source)".to_string()),
required: Some(false),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
profiles
},
};
let source_env_path = project_path.join(".env.source");
fs::write(
&source_env_path,
"SECRET_ONE=value_one_from_source\nSECRET_TWO=value_two_from_source\n",
)
.unwrap();
let target_env_path = project_path.join(".env.target");
fs::write(&target_env_path, "SECRET_TWO=existing_value_in_target\n").unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", target_env_path.display())),
profile: Some("default".to_string()),
providers: None,
},
};
let spec = Secrets::new(project_config, Some(global_config), None, None);
let from_provider = format!("dotenv://{}", source_env_path.display());
let result = spec.import(&from_provider);
assert!(result.is_ok(), "Import should succeed: {:?}", result);
let vars: HashMap<String, String> = {
let mut result = HashMap::new();
let env_vars = dotenvy::from_path_iter(&target_env_path).unwrap();
for item in env_vars {
let (k, v) = item.unwrap();
result.insert(k, v);
}
result
};
assert_eq!(
vars.get("SECRET_ONE"),
Some(&"value_one_from_source".to_string()),
"SECRET_ONE should be imported from source"
);
assert_eq!(
vars.get("SECRET_TWO"),
Some(&"existing_value_in_target".to_string()),
"SECRET_TWO should not be overwritten"
);
assert!(
vars.get("SECRET_THREE").is_none(),
"SECRET_THREE should not be imported (not in source)"
);
assert!(
vars.get("SECRET_FOUR").is_none(),
"SECRET_FOUR should not be imported (not in source)"
);
}
#[test]
fn test_import_edge_cases() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
let project_config = Config {
project: Project {
name: "test_edge_cases".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: {
let mut profiles = HashMap::new();
let mut secrets = HashMap::new();
secrets.insert(
"EMPTY_VALUE".to_string(),
Secret {
description: Some("Secret with empty value".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
secrets.insert(
"SPECIAL_CHARS".to_string(),
Secret {
description: Some("Secret with special characters".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
secrets.insert(
"MULTILINE".to_string(),
Secret {
description: Some("Secret with multiline value".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
profiles
},
};
let source_env_path = project_path.join(".env.edge");
fs::write(
&source_env_path,
concat!(
"EMPTY_VALUE=\n",
"SPECIAL_CHARS=\"value with spaces and special chars!\"\n",
"MULTILINE=single_line_value_no_spaces\n"
),
)
.unwrap();
let target_env_path = project_path.join(".env.target");
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", target_env_path.display())),
profile: Some("default".to_string()),
providers: None,
},
};
let spec = Secrets::new(project_config, Some(global_config), None, None);
let from_provider = format!("dotenv://{}", source_env_path.display());
let result = spec.import(&from_provider);
assert!(
result.is_ok(),
"Import should handle edge cases: {:?}",
result
);
let vars: HashMap<String, String> = {
let mut result = HashMap::new();
let env_vars = dotenvy::from_path_iter(&target_env_path).unwrap();
for item in env_vars {
let (k, v) = item.unwrap();
result.insert(k, v);
}
result
};
assert_eq!(
vars.get("EMPTY_VALUE"),
Some(&"".to_string()),
"Empty value should be imported"
);
assert_eq!(
vars.get("SPECIAL_CHARS"),
Some(&"value with spaces and special chars!".to_string()),
"Special characters should be preserved"
);
assert_eq!(
vars.get("MULTILINE"),
Some(&"single_line_value_no_spaces".to_string()),
"Value should be imported"
);
}
#[test]
fn test_profiles_inherit_from_default() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path().join("secretspec.toml");
let config_content = r#"
[project]
name = "test-no-merge"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Default database connection", required = true, default = "postgres://localhost/default" }
API_KEY = { description = "API key for services", required = true }
CACHE_TTL = { description = "Cache time to live", required = false, default = "3600" }
[profiles.development]
DATABASE_URL = { description = "Dev database connection", required = true, default = "postgres://localhost/dev" }
API_KEY = { description = "Dev API key", required = true }
# Note: CACHE_TTL is NOT defined in development profile
"#;
fs::write(&project_path, config_content).unwrap();
let config = Config::try_from(project_path.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config.clone(), Some(global_config.clone()), None, None);
let secret_config = spec
.resolve_secret_config("DATABASE_URL", Some("default"))
.expect("DATABASE_URL should exist in default");
assert_eq!(secret_config.required, Some(true));
assert_eq!(
secret_config.default,
Some("postgres://localhost/default".to_string())
);
let secret_config = spec
.resolve_secret_config("DATABASE_URL", Some("development"))
.expect("DATABASE_URL should exist in development");
assert_eq!(secret_config.required, Some(true));
assert_eq!(
secret_config.default,
Some("postgres://localhost/dev".to_string())
);
assert!(
spec.resolve_secret_config("CACHE_TTL", Some("default"))
.is_some()
);
assert!(
spec.resolve_secret_config("CACHE_TTL", Some("development"))
.is_some(),
"CACHE_TTL should be inherited from default profile"
);
let spec_default = Secrets::new(
config.clone(),
Some(global_config.clone()),
None,
Some("default".to_string()),
);
let default_validation_result = spec_default.validate().unwrap();
let spec_dev = Secrets::new(
config,
Some(global_config),
None,
Some("development".to_string()),
);
let dev_validation_result = spec_dev.validate().unwrap();
let default_errors = default_validation_result
.err()
.expect("Should have validation errors");
let dev_errors = dev_validation_result
.err()
.expect("Should have validation errors");
assert_eq!(
default_errors.missing_required.len()
+ default_errors.missing_optional.len()
+ default_errors.with_defaults.len(),
3
);
assert_eq!(
dev_errors.missing_required.len()
+ dev_errors.missing_optional.len()
+ dev_errors.with_defaults.len(),
3,
"Development should see 3 secrets: DATABASE_URL, API_KEY, and inherited CACHE_TTL"
);
}
#[test]
fn test_import_with_profiles() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
let project_config = Config {
project: Project {
name: "test_profiles".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: {
let mut profiles = HashMap::new();
let mut dev_secrets = HashMap::new();
dev_secrets.insert(
"DEV_SECRET".to_string(),
Secret {
description: Some("Development secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
dev_secrets.insert(
"SHARED_SECRET".to_string(),
Secret {
description: Some("Shared secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"development".to_string(),
Profile {
defaults: None,
secrets: dev_secrets,
},
);
let mut prod_secrets = HashMap::new();
prod_secrets.insert(
"PROD_SECRET".to_string(),
Secret {
description: Some("Production secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
prod_secrets.insert(
"SHARED_SECRET".to_string(),
Secret {
description: Some("Shared secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"production".to_string(),
Profile {
defaults: None,
secrets: prod_secrets,
},
);
profiles
},
};
let source_env_path = project_path.join(".env.all");
fs::write(
&source_env_path,
concat!(
"DEV_SECRET=dev_value\n",
"PROD_SECRET=prod_value\n",
"SHARED_SECRET=shared_value\n"
),
)
.unwrap();
let target_env_path = project_path.join(".env.dev");
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", target_env_path.display())),
profile: Some("development".to_string()),
providers: None, },
};
let spec = Secrets::new(project_config, Some(global_config), None, None);
let from_provider = format!("dotenv://{}", source_env_path.display());
let result = spec.import(&from_provider);
assert!(result.is_ok());
let vars: HashMap<String, String> = {
let mut result = HashMap::new();
let env_vars = dotenvy::from_path_iter(&target_env_path).unwrap();
for item in env_vars {
let (k, v) = item.unwrap();
result.insert(k, v);
}
result
};
assert_eq!(
vars.get("DEV_SECRET"),
Some(&"dev_value".to_string()),
"Development secret should be imported"
);
assert_eq!(
vars.get("SHARED_SECRET"),
Some(&"shared_value".to_string()),
"Shared secret should be imported for development profile"
);
assert!(
vars.get("PROD_SECRET").is_none(),
"Production secret should not be imported when using development profile"
);
}
#[test]
fn test_run_with_empty_command() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "").unwrap();
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
}),
None,
None,
);
let result = spec.run(vec![]);
assert!(result.is_err());
match result {
Err(SecretSpecError::Io(e)) => {
assert_eq!(e.kind(), io::ErrorKind::InvalidInput);
assert!(e.to_string().contains("No command specified"));
}
_ => panic!("Expected IO InvalidInput error"),
}
}
#[test]
fn test_run_with_missing_required_secrets() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"REQUIRED_SECRET".to_string(),
Secret {
description: Some("A required secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
}),
None,
None,
);
let result = spec.run(vec!["echo".to_string(), "hello".to_string()]);
assert!(result.is_err());
match result {
Err(SecretSpecError::RequiredSecretMissing(msg)) => {
assert!(msg.contains("REQUIRED_SECRET"));
}
_ => panic!("Expected RequiredSecretMissing error"),
}
}
#[test]
fn test_get_existing_secret() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "TEST_SECRET=test_value\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"TEST_SECRET".to_string(),
Secret {
description: Some("Test secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
}),
None,
None,
);
let result = spec.get("TEST_SECRET");
assert!(result.is_ok(), "Failed to get secret: {:?}", result);
}
#[test]
fn test_get_secret_with_default() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"SECRET_WITH_DEFAULT".to_string(),
Secret {
description: Some("Secret with default value".to_string()),
required: Some(false),
default: Some("default_value".to_string()),
providers: None,
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
}),
None,
None,
);
let result = spec.get("SECRET_WITH_DEFAULT");
assert!(result.is_ok());
}
#[test]
fn test_get_nonexistent_secret() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "EXISTING_SECRET=exists\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"EXISTING_SECRET".to_string(),
Secret {
description: Some("Existing secret".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
}),
None,
None,
);
let result = spec.get("NONEXISTENT_SECRET");
assert!(result.is_err());
match result {
Err(SecretSpecError::SecretNotFound(msg)) => {
assert!(msg.contains("NONEXISTENT_SECRET"));
}
_ => panic!("Expected SecretNotFound error"),
}
}
#[test]
fn test_import_dotenv_profile_issue_36() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_path = Path::new(manifest_dir).join("src/fixtures/issue_36_secretspec.toml");
let project_config =
Config::try_from(fixture_path.as_path()).expect("Should load fixture config");
let source_env_path = project_path.join(".env");
fs::write(&source_env_path, "JWT_SECRET=super-secret-jwt-token\n").unwrap();
let target_env_path = project_path.join(".env.target");
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", target_env_path.display())),
profile: Some("development".to_string()),
providers: None, },
};
let spec = Secrets::new(project_config, Some(global_config), None, None);
let from_provider = format!("dotenv://{}", source_env_path.display());
println!("=== Testing Issue #36 Bug Reproduction ===");
println!("Source .env file: {}", source_env_path.display());
println!("Target provider: dotenv://{}", target_env_path.display());
println!("Profile: development");
println!("Source .env contents:");
println!("{}", fs::read_to_string(&source_env_path).unwrap());
let result = spec.import(&from_provider);
match result {
Ok(_) => {
if target_env_path.exists() {
let target_contents = fs::read_to_string(&target_env_path).unwrap();
println!("Target file after import:");
println!("{}", target_contents);
assert!(
target_contents.contains("JWT_SECRET=\"super-secret-jwt-token\""),
"JWT_SECRET should have been imported from source .env"
);
assert!(
!target_contents.contains("MONGODB_HOST"),
"MONGODB_HOST should not be in target - it has a default and isn't in source"
);
assert!(
!target_contents.contains("MONGODB_PORT"),
"MONGODB_PORT should not be in target - it has a default and isn't in source"
);
} else {
println!("Target file was not created - this might be part of the bug");
panic!("Target file should have been created after importing JWT_SECRET");
}
}
Err(e) => {
panic!("Import should not fail: {:?}", e);
}
}
println!("=== Issue #36 test completed ===");
}
#[test]
fn test_per_secret_provider_configuration() {
let mut secrets = HashMap::new();
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key from shared provider".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["shared".to_string()]),
as_path: None,
..Default::default()
},
);
secrets.insert(
"DATABASE_URL".to_string(),
Secret {
description: Some("Database URL from default provider".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let config = Config {
project: Project {
name: "test_per_secret_provider".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let mut providers_map = HashMap::new();
providers_map.insert("shared".to_string(), "keyring://".to_string());
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let api_key_config = spec
.resolve_secret_config("API_KEY", Some("default"))
.unwrap();
assert_eq!(api_key_config.providers, Some(vec!["shared".to_string()]));
let db_config = spec
.resolve_secret_config("DATABASE_URL", Some("default"))
.unwrap();
assert_eq!(db_config.providers, None);
}
#[test]
fn test_provider_alias_resolution() {
let mut providers_map = HashMap::new();
providers_map.insert("dev".to_string(), "dotenv://.env.development".to_string());
providers_map.insert(
"prod".to_string(),
"onepassword://vault/Production".to_string(),
);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
},
Some(global_config),
None,
None,
);
let dev_uris = spec
.resolve_provider_aliases(Some(&["dev".to_string()]))
.expect("Should resolve dev alias");
assert_eq!(
dev_uris,
Some(vec!["dotenv://.env.development".to_string()])
);
let prod_uris = spec
.resolve_provider_aliases(Some(&["prod".to_string()]))
.expect("Should resolve prod alias");
assert_eq!(
prod_uris,
Some(vec!["onepassword://vault/Production".to_string()])
);
let multi_uris = spec
.resolve_provider_aliases(Some(&["dev".to_string(), "prod".to_string()]))
.expect("Should resolve multiple aliases");
assert_eq!(
multi_uris,
Some(vec![
"dotenv://.env.development".to_string(),
"onepassword://vault/Production".to_string(),
])
);
let no_uris = spec
.resolve_provider_aliases(None)
.expect("Should handle no aliases");
assert_eq!(no_uris, None);
}
#[test]
fn test_provider_alias_not_found() {
let mut providers_map = HashMap::new();
providers_map.insert("existing".to_string(), "dotenv://.env".to_string());
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: HashMap::new(),
},
Some(global_config),
None,
None,
);
let result = spec.resolve_provider_aliases(Some(&["nonexistent".to_string()]));
assert!(result.is_err());
match result {
Err(SecretSpecError::ProviderNotFound(msg)) => {
assert!(msg.contains("nonexistent"));
}
_ => panic!("Expected ProviderNotFound error"),
}
}
#[test]
fn test_per_secret_provider_with_fallback_chain() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
let keyring_file = temp_dir.path().join(".env.keyring");
fs::write(&env_file, "DATABASE_URL=postgres://localhost\n").unwrap();
fs::write(&keyring_file, "API_KEY=secret-key\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"DATABASE_URL".to_string(),
Secret {
description: Some("Database URL".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["primary".to_string(), "fallback".to_string()]),
as_path: None,
..Default::default()
},
);
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["fallback".to_string(), "primary".to_string()]),
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let config = Config {
project: Project {
name: "test_fallback".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let mut providers_map = HashMap::new();
providers_map.insert(
"primary".to_string(),
format!("dotenv://{}", env_file.display()),
);
providers_map.insert(
"fallback".to_string(),
format!("dotenv://{}", keyring_file.display()),
);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: None,
profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let db_config = spec
.resolve_secret_config("DATABASE_URL", Some("default"))
.unwrap();
assert_eq!(
db_config.providers,
Some(vec!["primary".to_string(), "fallback".to_string()])
);
let api_config = spec
.resolve_secret_config("API_KEY", Some("default"))
.unwrap();
assert_eq!(
api_config.providers,
Some(vec!["fallback".to_string(), "primary".to_string()])
);
}
#[test]
fn test_get_secret_with_fallback_chain() {
let temp_dir = TempDir::new().unwrap();
let primary_file = temp_dir.path().join(".env.primary");
let fallback_file = temp_dir.path().join(".env.fallback");
fs::write(&primary_file, "DATABASE_URL=postgres://localhost\n").unwrap();
fs::write(&fallback_file, "API_KEY=secret-key\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key from fallback".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["primary".to_string(), "fallback".to_string()]),
as_path: None,
..Default::default()
},
);
secrets.insert(
"DATABASE_URL".to_string(),
Secret {
description: Some("Database URL from primary".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["primary".to_string(), "fallback".to_string()]),
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let config = Config {
project: Project {
name: "test_fallback_integration".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let mut providers_map = HashMap::new();
providers_map.insert(
"primary".to_string(),
format!("dotenv://{}", primary_file.display()),
);
providers_map.insert(
"fallback".to_string(),
format!("dotenv://{}", fallback_file.display()),
);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()), profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
match spec.validate().unwrap() {
Ok(valid) => {
assert!(valid.resolved.secrets.contains_key("API_KEY"));
assert!(valid.resolved.secrets.contains_key("DATABASE_URL"));
let api_key = valid.resolved.secrets.get("API_KEY").unwrap();
assert_eq!(api_key.expose_secret(), "secret-key");
let db_url = valid.resolved.secrets.get("DATABASE_URL").unwrap();
assert_eq!(db_url.expose_secret(), "postgres://localhost");
}
Err(e) => panic!("Validation should succeed: {:?}", e),
}
}
#[test]
fn test_validate_falls_back_on_primary_provider_error() {
let temp_dir = TempDir::new().unwrap();
let primary_dir = temp_dir.path().join("broken");
fs::create_dir(&primary_dir).unwrap();
let fallback_file = temp_dir.path().join(".env.fallback");
fs::write(&fallback_file, "API_KEY=from-fallback\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["primary".to_string(), "fallback".to_string()]),
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let config = Config {
project: Project {
name: "test_error_fallback".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let mut providers_map = HashMap::new();
providers_map.insert(
"primary".to_string(),
format!("dotenv://{}", primary_dir.display()),
);
providers_map.insert(
"fallback".to_string(),
format!("dotenv://{}", fallback_file.display()),
);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
match spec
.validate()
.expect("validate should not propagate primary failure")
{
Ok(valid) => {
let api_key = valid.resolved.secrets.get("API_KEY").unwrap();
assert_eq!(api_key.expose_secret(), "from-fallback");
}
Err(e) => panic!("Expected fallback to succeed, got: {:?}", e),
}
}
#[test]
fn test_validate_surfaces_error_when_all_providers_fail() {
let temp_dir = TempDir::new().unwrap();
let broken_a = temp_dir.path().join("broken-a");
let broken_b = temp_dir.path().join("broken-b");
fs::create_dir(&broken_a).unwrap();
fs::create_dir(&broken_b).unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["a".to_string(), "b".to_string()]),
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let config = Config {
project: Project {
name: "test_all_fail".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let mut providers_map = HashMap::new();
providers_map.insert("a".to_string(), format!("dotenv://{}", broken_a.display()));
providers_map.insert("b".to_string(), format!("dotenv://{}", broken_b.display()));
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let result = spec.validate();
assert!(
result.is_err(),
"Expected error when every provider in the chain fails"
);
}
#[test]
fn test_validate_with_per_secret_providers() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
let keyring_file = temp_dir.path().join(".env.keyring");
fs::write(&env_file, "API_KEY=from-env\n").unwrap();
fs::write(&keyring_file, "DATABASE_URL=from-keyring\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["env_provider".to_string()]),
as_path: None,
..Default::default()
},
);
secrets.insert(
"DATABASE_URL".to_string(),
Secret {
description: Some("Database URL".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["keyring_provider".to_string()]),
as_path: None,
..Default::default()
},
);
secrets.insert(
"OPTIONAL_CONFIG".to_string(),
Secret {
description: Some("Optional configuration".to_string()),
required: Some(false),
default: Some("default-config".to_string()),
providers: None,
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let config = Config {
project: Project {
name: "test_multi_provider".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let mut providers_map = HashMap::new();
providers_map.insert(
"env_provider".to_string(),
format!("dotenv://{}", env_file.display()),
);
providers_map.insert(
"keyring_provider".to_string(),
format!("dotenv://{}", keyring_file.display()),
);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: Some(providers_map),
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
match spec.validate().unwrap() {
Ok(valid) => {
assert_eq!(valid.resolved.secrets.len(), 3);
assert_eq!(
valid
.resolved
.secrets
.get("API_KEY")
.unwrap()
.expose_secret(),
"from-env"
);
assert_eq!(
valid
.resolved
.secrets
.get("DATABASE_URL")
.unwrap()
.expose_secret(),
"from-keyring"
);
assert_eq!(
valid
.resolved
.secrets
.get("OPTIONAL_CONFIG")
.unwrap()
.expose_secret(),
"default-config"
);
assert!(valid.missing_optional.is_empty());
}
Err(e) => panic!("Validation should succeed: {:?}", e),
}
}
#[test]
fn test_secret_config_merges_providers_from_default() {
let mut default_secrets = HashMap::new();
default_secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key from default".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["shared".to_string()]),
as_path: None,
..Default::default()
},
);
let mut current_secrets = HashMap::new();
current_secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API Key from current".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
current_secrets.insert(
"DATABASE_URL".to_string(),
Secret {
description: Some("Database URL".to_string()),
required: Some(true),
default: None,
providers: Some(vec!["prod".to_string()]),
as_path: None,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets: default_secrets,
},
);
profiles.insert(
"production".to_string(),
Profile {
defaults: None,
secrets: current_secrets,
},
);
let config = Config {
project: Project {
name: "test_merge".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let spec = Secrets::new(config, None, None, None);
let api_key_config = spec
.resolve_secret_config("API_KEY", Some("production"))
.unwrap();
assert_eq!(
api_key_config.providers,
Some(vec!["shared".to_string()]),
"API_KEY should inherit providers from default profile"
);
let db_config = spec
.resolve_secret_config("DATABASE_URL", Some("production"))
.unwrap();
assert_eq!(
db_config.providers,
Some(vec!["prod".to_string()]),
"DATABASE_URL should use its own providers"
);
}
#[test]
fn test_profile_defaults_from_toml() {
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test"
revision = "1.0"
[profiles.production.defaults]
providers = ["prod_vault", "keyring"]
[profiles.production]
DATABASE_URL = { description = "Production DB" }
API_KEY = { description = "API key" }
SECRET_KEY = { description = "Secret key", providers = ["env"] }
[profiles.development.defaults]
required = false
default = "dev-default"
[profiles.development]
DATABASE_URL = { description = "Dev DB" }
API_KEY = { description = "Dev API key" }
SPECIAL_SECRET = { description = "Special secret", required = true }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let spec = Secrets::new(config, None, None, None);
let db_prod = spec
.resolve_secret_config("DATABASE_URL", Some("production"))
.unwrap();
assert_eq!(
db_prod.providers,
Some(vec!["prod_vault".to_string(), "keyring".to_string()]),
"DATABASE_URL should inherit production profile defaults"
);
let api_prod = spec
.resolve_secret_config("API_KEY", Some("production"))
.unwrap();
assert_eq!(
api_prod.providers,
Some(vec!["prod_vault".to_string(), "keyring".to_string()]),
"API_KEY should inherit production profile defaults"
);
let secret_prod = spec
.resolve_secret_config("SECRET_KEY", Some("production"))
.unwrap();
assert_eq!(
secret_prod.providers,
Some(vec!["env".to_string()]),
"SECRET_KEY should override with its own providers"
);
let db_dev = spec
.resolve_secret_config("DATABASE_URL", Some("development"))
.unwrap();
assert_eq!(
db_dev.required,
Some(false),
"DATABASE_URL should inherit required=false from dev defaults"
);
assert_eq!(
db_dev.default,
Some("dev-default".to_string()),
"DATABASE_URL should inherit default value from dev defaults"
);
let api_dev = spec
.resolve_secret_config("API_KEY", Some("development"))
.unwrap();
assert_eq!(api_dev.required, Some(false));
assert_eq!(api_dev.default, Some("dev-default".to_string()));
let special_dev = spec
.resolve_secret_config("SPECIAL_SECRET", Some("development"))
.unwrap();
assert_eq!(
special_dev.required,
Some(true),
"SPECIAL_SECRET should override required setting"
);
assert_eq!(
special_dev.default,
Some("dev-default".to_string()),
"SPECIAL_SECRET should still inherit default value"
);
}
#[test]
fn test_cli_provider_alias_operations() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config");
fs::create_dir(&config_dir).unwrap();
let config_path = config_dir.join("secretspec_config.toml");
let initial_config = r#"
[defaults]
provider = "keyring"
[providers]
"#;
fs::write(&config_path, initial_config).unwrap();
let mut config: GlobalConfig = toml::from_str(initial_config).unwrap();
if config.defaults.providers.is_none() {
config.defaults.providers = Some(HashMap::new());
}
if let Some(providers) = &mut config.defaults.providers {
providers.insert(
"shared".to_string(),
"onepassword://vault/Shared".to_string(),
);
providers.insert(
"prod".to_string(),
"onepassword://vault/Production".to_string(),
);
}
assert_eq!(config.defaults.providers.as_ref().unwrap().len(), 2);
assert_eq!(
config.defaults.providers.as_ref().unwrap().get("shared"),
Some(&"onepassword://vault/Shared".to_string())
);
if let Some(providers) = &mut config.defaults.providers {
providers.remove("prod");
}
assert_eq!(config.defaults.providers.as_ref().unwrap().len(), 1);
let aliases: Vec<_> = config
.defaults
.providers
.as_ref()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
assert_eq!(aliases.len(), 1);
assert_eq!(aliases[0].0, "shared");
}
#[test]
fn test_as_path_secrets() {
use secrecy::ExposeSecret;
use std::fs;
let temp_dir = TempDir::new().unwrap();
let secret_value = "my-secret-certificate-content";
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, format!("CERT_DATA={}", secret_value)).unwrap();
fs::write(
&env_file,
format!("CERT_DATA={}\nREGULAR_SECRET=not-a-path", secret_value),
)
.unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test-as-path"
revision = "1.0"
[profiles.default]
CERT_DATA = { description = "Certificate data", as_path = true }
REGULAR_SECRET = { description = "Regular secret", as_path = false }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let validated = spec.validate().unwrap().unwrap();
let cert_path_str = validated
.resolved
.secrets
.get("CERT_DATA")
.unwrap()
.expose_secret();
let cert_path = std::path::PathBuf::from(cert_path_str);
assert!(cert_path.exists(), "Temporary file should exist");
let file_content = fs::read_to_string(&cert_path).unwrap();
assert_eq!(
file_content, secret_value,
"Temporary file should contain the secret value"
);
let regular_secret = validated
.resolved
.secrets
.get("REGULAR_SECRET")
.unwrap()
.expose_secret();
assert_eq!(regular_secret, "not-a-path");
assert!(
!validated.temp_files.is_empty(),
"temp_files should contain the temporary file"
);
drop(validated);
assert!(
!cert_path.exists(),
"Temporary file should be cleaned up after drop"
);
}
#[test]
fn test_as_path_secrets_keep_temp_files() {
use secrecy::ExposeSecret;
use std::fs;
let temp_dir = TempDir::new().unwrap();
let secret_value = "certificate-data-to-keep";
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, format!("CERT_DATA={}", secret_value)).unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test-keep-files"
revision = "1.0"
[profiles.default]
CERT_DATA = { description = "Certificate data", as_path = true }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let mut validated = spec.validate().unwrap().unwrap();
let cert_path_str = validated
.resolved
.secrets
.get("CERT_DATA")
.unwrap()
.expose_secret();
let cert_path = std::path::PathBuf::from(cert_path_str);
assert!(cert_path.exists(), "Temporary file should exist");
let kept_paths = validated.keep_temp_files().unwrap();
assert_eq!(kept_paths.len(), 1, "Should have kept one temp file");
drop(validated);
assert!(
cert_path.exists(),
"Temporary file should still exist after keep_temp_files()"
);
let file_content = fs::read_to_string(&cert_path).unwrap();
assert_eq!(file_content, secret_value);
fs::remove_file(&cert_path).unwrap();
}
#[cfg(unix)]
#[test]
fn test_run_cleans_up_as_path_temp_files() {
use std::fs;
let temp_dir = TempDir::new().unwrap();
let secret_value = "secret-cert-content";
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, format!("CERT_DATA={}", secret_value)).unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
fs::write(
&config_file,
r#"[project]
name = "test-run-cleanup"
revision = "1.0"
[profiles.default]
CERT_DATA = { description = "Certificate data", as_path = true }
"#,
)
.unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let captured_path_file = temp_dir.path().join("captured-path");
let exit_code = spec
.run_command(vec![
"sh".to_string(),
"-c".to_string(),
format!(
"printf '%s' \"$CERT_DATA\" > {}",
captured_path_file.display()
),
])
.unwrap();
assert_eq!(exit_code, 0);
let captured_path = fs::read_to_string(&captured_path_file).unwrap();
assert!(
!captured_path.is_empty(),
"child should have observed the temp file path via $CERT_DATA"
);
assert!(
!std::path::Path::new(&captured_path).exists(),
"as_path temp file at {} should be removed once `run` returns",
captured_path
);
}
#[test]
fn test_config_parse_generate_bool() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
DB_PASSWORD = { description = "Database password", type = "password", generate = true }
"#;
let config = parse_spec_from_str(toml_content, None).unwrap();
let profile = config.profiles.get("default").unwrap();
let secret = profile.secrets.get("DB_PASSWORD").unwrap();
assert_eq!(secret.secret_type.as_deref(), Some("password"));
assert!(matches!(
secret.generate,
Some(crate::config::GenerateConfig::Bool(true))
));
}
#[test]
fn test_config_parse_generate_options() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
API_TOKEN = { description = "API token", type = "hex", generate = { bytes = 32 } }
"#;
let config = parse_spec_from_str(toml_content, None).unwrap();
let profile = config.profiles.get("default").unwrap();
let secret = profile.secrets.get("API_TOKEN").unwrap();
assert_eq!(secret.secret_type.as_deref(), Some("hex"));
match &secret.generate {
Some(crate::config::GenerateConfig::Options(opts)) => {
assert_eq!(opts.bytes, Some(32));
}
other => panic!("Expected Options, got {:?}", other),
}
}
#[test]
fn test_config_parse_generate_command() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
MONGO_KEY = { description = "MongoDB keyfile", type = "command", generate = { command = "echo test" } }
"#;
let config = parse_spec_from_str(toml_content, None).unwrap();
let profile = config.profiles.get("default").unwrap();
let secret = profile.secrets.get("MONGO_KEY").unwrap();
assert_eq!(secret.secret_type.as_deref(), Some("command"));
match &secret.generate {
Some(crate::config::GenerateConfig::Options(opts)) => {
assert_eq!(opts.command.as_deref(), Some("echo test"));
}
other => panic!("Expected Options, got {:?}", other),
}
}
#[test]
fn test_config_type_without_generate_is_valid() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
STATIC_SECRET = { description = "Manually managed", type = "password" }
"#;
let config = parse_spec_from_str(toml_content, None).unwrap();
let profile = config.profiles.get("default").unwrap();
let secret = profile.secrets.get("STATIC_SECRET").unwrap();
assert_eq!(secret.secret_type.as_deref(), Some("password"));
assert!(secret.generate.is_none());
}
#[test]
fn test_config_generate_without_type_is_error() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
BAD_SECRET = { description = "Missing type", generate = true }
"#;
let result = parse_spec_from_str(toml_content, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("requires 'type'"),
"Expected error about missing type, got: {}",
err_msg
);
}
#[test]
fn test_config_generate_false_without_type_is_valid() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
MANUAL_SECRET = { description = "No gen", generate = false }
"#;
let config = parse_spec_from_str(toml_content, None).unwrap();
let profile = config.profiles.get("default").unwrap();
let secret = profile.secrets.get("MANUAL_SECRET").unwrap();
assert!(matches!(
secret.generate,
Some(crate::config::GenerateConfig::Bool(false))
));
}
#[test]
fn test_config_generate_and_default_is_error() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
CONFLICT = { description = "Both", type = "password", generate = true, default = "foo" }
"#;
let result = parse_spec_from_str(toml_content, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("cannot both be set"),
"Expected conflict error, got: {}",
err_msg
);
}
#[test]
fn test_config_command_type_generate_bool_is_error() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
CMD_SECRET = { description = "Cmd", type = "command", generate = true }
"#;
let result = parse_spec_from_str(toml_content, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("command"),
"Expected command requirement error, got: {}",
err_msg
);
}
#[test]
fn test_config_unknown_type_is_error() {
let toml_content = r#"
[project]
name = "test-gen"
revision = "1.0"
[profiles.default]
BAD_TYPE = { description = "Unknown type", type = "rsa_key", generate = true }
"#;
let result = parse_spec_from_str(toml_content, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("unknown secret type"),
"Expected unknown type error, got: {}",
err_msg
);
}
#[test]
fn test_validate_generates_missing_secret() {
use secrecy::ExposeSecret;
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "").unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test-gen-validate"
revision = "1.0"
[profiles.default]
DB_PASSWORD = { description = "Database password", type = "password", generate = true }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let result = spec.validate().unwrap();
let validated = result.unwrap();
let value = validated.resolved.secrets.get("DB_PASSWORD").unwrap();
let s = value.expose_secret();
assert_eq!(s.len(), 32, "Default password length should be 32");
assert!(
s.chars().all(|c| c.is_alphanumeric()),
"Default password should be alphanumeric"
);
}
#[test]
fn test_validate_does_not_regenerate_existing_secret() {
use secrecy::ExposeSecret;
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "DB_PASSWORD=existing_value").unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test-gen-existing"
revision = "1.0"
[profiles.default]
DB_PASSWORD = { description = "Database password", type = "password", generate = true }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let result = spec.validate().unwrap();
let validated = result.unwrap();
let value = validated
.resolved
.secrets
.get("DB_PASSWORD")
.unwrap()
.expose_secret();
assert_eq!(
value, "existing_value",
"Existing secret should not be regenerated"
);
}
#[test]
fn test_validate_idempotent_generation() {
use secrecy::ExposeSecret;
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "").unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test-gen-idempotent"
revision = "1.0"
[profiles.default]
DB_PASSWORD = { description = "Database password", type = "password", generate = true }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config.clone(), Some(global_config.clone()), None, None);
let result1 = spec.validate().unwrap().unwrap();
let v1 = result1
.resolved
.secrets
.get("DB_PASSWORD")
.unwrap()
.expose_secret()
.to_string();
let spec2 = Secrets::new(config, Some(global_config), None, None);
let result2 = spec2.validate().unwrap().unwrap();
let v2 = result2
.resolved
.secrets
.get("DB_PASSWORD")
.unwrap()
.expose_secret()
.to_string();
assert_eq!(v1, v2, "Second validate should return same generated value");
}
#[test]
fn test_validate_multiple_generate_types() {
use secrecy::ExposeSecret;
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "").unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test-gen-multi"
revision = "1.0"
[profiles.default]
DB_PASSWORD = { description = "Password", type = "password", generate = true }
API_TOKEN = { description = "Token", type = "hex", generate = { bytes = 16 } }
SESSION_KEY = { description = "Session", type = "base64", generate = { bytes = 24 } }
REQUEST_ID = { description = "ID", type = "uuid", generate = true }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(config, Some(global_config), None, None);
let validated = spec.validate().unwrap().unwrap();
assert!(validated.resolved.secrets.contains_key("DB_PASSWORD"));
assert!(validated.resolved.secrets.contains_key("API_TOKEN"));
assert!(validated.resolved.secrets.contains_key("SESSION_KEY"));
assert!(validated.resolved.secrets.contains_key("REQUEST_ID"));
let pw = validated
.resolved
.secrets
.get("DB_PASSWORD")
.unwrap()
.expose_secret();
assert_eq!(pw.len(), 32);
let hex = validated
.resolved
.secrets
.get("API_TOKEN")
.unwrap()
.expose_secret();
assert_eq!(hex.len(), 32);
let uuid = validated
.resolved
.secrets
.get("REQUEST_ID")
.unwrap()
.expose_secret();
assert_eq!(uuid.len(), 36);
assert!(uuid.contains('-'));
}
#[test]
fn test_validate_generate_with_profile() {
use secrecy::ExposeSecret;
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "").unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "test-gen-profile"
revision = "1.0"
[profiles.default]
SHARED_KEY = { description = "Shared", type = "password", generate = true }
[profiles.production]
PROD_KEY = { description = "Production key", type = "hex", generate = { bytes = 32 } }
"#;
fs::write(&config_file, toml_content).unwrap();
let config = Config::try_from(config_file.as_path()).unwrap();
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
};
let spec = Secrets::new(
config,
Some(global_config),
None,
Some("production".to_string()),
);
let validated = spec.validate().unwrap().unwrap();
assert!(validated.resolved.secrets.contains_key("SHARED_KEY"));
assert!(validated.resolved.secrets.contains_key("PROD_KEY"));
let hex = validated
.resolved
.secrets
.get("PROD_KEY")
.unwrap()
.expose_secret();
assert_eq!(hex.len(), 64); }
#[test]
fn test_resolve_secret_config_merges_type_and_generate() {
let mut profiles = HashMap::new();
let mut default_secrets = HashMap::new();
default_secrets.insert(
"DB_PASSWORD".to_string(),
Secret {
description: Some("Database password".to_string()),
required: None,
default: None,
providers: None,
as_path: None,
secret_type: Some("password".to_string()),
generate: Some(crate::config::GenerateConfig::Bool(true)),
},
);
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets: default_secrets,
},
);
let mut prod_secrets = HashMap::new();
prod_secrets.insert(
"DB_PASSWORD".to_string(),
Secret {
description: Some("Prod DB password".to_string()),
required: Some(true),
default: None,
providers: None,
as_path: None,
..Default::default()
},
);
profiles.insert(
"production".to_string(),
Profile {
defaults: None,
secrets: prod_secrets,
},
);
let config = Config {
project: Project {
name: "test".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let spec = Secrets::new(config, None, Some("production".to_string()), None);
let resolved = spec
.resolve_secret_config("DB_PASSWORD", Some("production"))
.unwrap();
assert_eq!(resolved.secret_type.as_deref(), Some("password"));
assert!(resolved.generate.is_some());
assert_eq!(resolved.description.as_deref(), Some("Prod DB password"));
}
fn build_chain_scenario(
temp_dir: &TempDir,
) -> (Config, GlobalConfig, std::path::PathBuf, std::path::PathBuf) {
let personal_path = temp_dir.path().join(".env.personal");
let team_path = temp_dir.path().join(".env.team");
fs::write(&personal_path, "").unwrap();
fs::write(&team_path, "").unwrap();
let config = Config {
project: Project {
name: "test_project".to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles: {
let mut profiles = HashMap::new();
let mut secrets = HashMap::new();
secrets.insert(
"MY_SECRET".to_string(),
Secret {
description: Some("test secret".to_string()),
required: Some(true),
..Default::default()
},
);
profiles.insert(
"development".to_string(),
Profile {
defaults: Some(crate::config::ProfileDefaults {
required: None,
default: None,
providers: Some(vec!["personal".to_string(), "team".to_string()]),
}),
secrets,
},
);
profiles
},
};
let mut providers_map = HashMap::new();
providers_map.insert(
"personal".to_string(),
format!("dotenv://{}", personal_path.display()),
);
providers_map.insert(
"team".to_string(),
format!("dotenv://{}", team_path.display()),
);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: Some("development".to_string()),
providers: Some(providers_map),
},
};
(config, global_config, personal_path, team_path)
}
fn read_env_var(path: &std::path::Path, key: &str) -> Option<String> {
dotenvy::from_path_iter(path)
.ok()?
.filter_map(|res| res.ok())
.find(|(k, _)| k == key)
.map(|(_, v)| v)
}
#[test]
fn test_set_provider_override_wins_over_chain() {
let temp_dir = TempDir::new().unwrap();
let (config, global_config, personal_path, team_path) = build_chain_scenario(&temp_dir);
let spec = Secrets::new(config, Some(global_config), Some("team".to_string()), None);
spec.set("MY_SECRET", Some("override_value".to_string()))
.expect("set should succeed");
assert_eq!(
read_env_var(&team_path, "MY_SECRET").as_deref(),
Some("override_value"),
"secret should be written to the overridden provider"
);
assert!(
read_env_var(&personal_path, "MY_SECRET").is_none(),
"secret must not leak into the first-in-chain provider when overridden"
);
}
#[test]
fn test_set_without_override_uses_chain_first() {
let temp_dir = TempDir::new().unwrap();
let (config, global_config, personal_path, team_path) = build_chain_scenario(&temp_dir);
let spec = Secrets::new(config, Some(global_config), None, None);
spec.set("MY_SECRET", Some("chain_value".to_string()))
.expect("set should succeed");
assert_eq!(
read_env_var(&personal_path, "MY_SECRET").as_deref(),
Some("chain_value"),
"without override, set writes to the first alias in the chain"
);
assert!(
read_env_var(&team_path, "MY_SECRET").is_none(),
"team provider must remain untouched"
);
}
#[test]
fn test_resolve_read_provider_uris_override_skips_chain() {
let temp_dir = TempDir::new().unwrap();
let (config, global_config, _, team_path) = build_chain_scenario(&temp_dir);
let spec = Secrets::new(config, Some(global_config), Some("team".to_string()), None);
let secret_config = spec.resolve_secret_config("MY_SECRET", None).unwrap();
let uris = spec
.resolve_read_provider_uris(&secret_config, None)
.expect("override resolution should succeed")
.expect("override should produce a URI list");
assert_eq!(
uris.len(),
1,
"override must collapse the chain to a single URI"
);
assert_eq!(uris[0], format!("dotenv://{}", team_path.display()));
}