use crate::config::{
Config, CredentialSource, GlobalConfig, GlobalDefaults, NativeAddress, ParseError, Profile,
Project, ProviderAlias, ProviderCache, RequireReason, 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(SecretSpecError::from)?;
Ok(config)
}
#[test]
fn test_new_with_project_config() {
let config = Config {
project: Project {
name: "test-project".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
};
let spec = Secrets::new(config, None, None, None);
assert_eq!(spec.config().project.name, "test-project");
}
#[test]
fn profile_supports_consuming_iteration() {
let profile = Profile {
defaults: None,
secrets: HashMap::from([("API_KEY".to_string(), Secret::default())]),
};
let secrets: HashMap<String, Secret> = profile.into_iter().collect();
assert!(secrets.contains_key("API_KEY"));
}
#[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 require_reason_always_blocks_access_without_reason() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path().join("secretspec.toml");
fs::write(
&project_path,
r#"
[project]
name = "policy-project"
revision = "1.0"
require_reason = true
[profiles.default]
"#,
)
.unwrap();
let project_config = Config::try_from(project_path.as_path()).unwrap();
assert_eq!(
project_config.project.require_reason,
Some(RequireReason::Always)
);
let build = || {
let mut spec = Secrets::new(project_config.clone(), None, None, None);
spec.set_require_reason(RequireReason::Always);
spec
};
assert!(matches!(
build().validate(),
Err(SecretSpecError::ReasonRequired)
));
assert!(!matches!(
build().with_reason("running migrations").validate(),
Err(SecretSpecError::ReasonRequired)
));
assert!(matches!(
build().with_reason(" ").validate(),
Err(SecretSpecError::ReasonRequired)
));
}
#[test]
fn require_reason_false_allows_access_without_reason() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path().join("secretspec.toml");
fs::write(
&project_path,
r#"
[project]
name = "open-project"
revision = "1.0"
require_reason = false
[profiles.default]
"#,
)
.unwrap();
let project_config = Config::try_from(project_path.as_path()).unwrap();
assert_eq!(
project_config.project.require_reason,
Some(RequireReason::Never)
);
let mut spec = Secrets::new(project_config, None, None, None);
spec.set_require_reason(RequireReason::Never);
assert!(!matches!(
spec.validate(),
Err(SecretSpecError::ReasonRequired)
));
}
#[test]
fn test_new_with_default_overrides() {
let config = Config {
project: Project {
name: "test-project".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("dotenv".to_string()),
profile: Some("production".to_string()),
providers: None,
},
audit: 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(),
resolution: 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_resolution_report_provenance() {
use crate::report::ResolutionStatus;
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "DATABASE_URL=postgres://localhost/db\n").unwrap();
let secret = |required: bool, default: Option<&str>| Secret {
description: Some("test".to_string()),
required: Some(required),
default: default.map(String::from),
..Default::default()
};
let mut secrets = HashMap::new();
secrets.insert("DATABASE_URL".to_string(), secret(true, None));
secrets.insert(
"DEV_SESSION_SECRET".to_string(),
secret(false, Some("development-only-secret")),
);
secrets.insert("SENTRY_DSN".to_string(), secret(false, None));
secrets.insert("STRIPE_KEY".to_string(), secret(true, None));
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let config = Config {
project: Project {
name: "report-test".to_string(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(config, None, Some(provider), None);
let report = match spec.validate().unwrap() {
Ok(validated) => validated.report(),
Err(errors) => errors.report(),
};
assert_eq!(report.schema_version, 1);
assert_eq!(report.profile, "default");
assert!(!report.all_required_present());
let names: Vec<&str> = report.secrets.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec![
"DATABASE_URL",
"DEV_SESSION_SECRET",
"SENTRY_DSN",
"STRIPE_KEY"
]
);
let by_name = |name: &str| {
report
.secrets
.iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("missing entry {name}"))
};
let db = by_name("DATABASE_URL");
assert_eq!(db.status, ResolutionStatus::Resolved);
assert!(db.required);
assert!(db.source_provider.is_some(), "provider hit is attributed");
assert!(!db.default_applied);
assert!(!db.generated);
let session = by_name("DEV_SESSION_SECRET");
assert_eq!(session.status, ResolutionStatus::Resolved);
assert!(session.default_applied);
assert!(
session.source_provider.is_none(),
"a default has no provider"
);
let sentry = by_name("SENTRY_DSN");
assert_eq!(sentry.status, ResolutionStatus::MissingOptional);
assert!(!sentry.required);
let stripe = by_name("STRIPE_KEY");
assert_eq!(stripe.status, ResolutionStatus::MissingRequired);
assert!(stripe.required);
}
#[test]
fn profile_presence_constraints_validate_resolved_values() {
use crate::validation::ConstraintKind;
fn app(env_contents: &str, kind: ConstraintKind, groups: &[&str]) -> (TempDir, Secrets) {
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, env_contents).unwrap();
let member = || Secret {
description: Some("alternative credential".to_string()),
at_least_one: (kind == ConstraintKind::AtLeastOne)
.then(|| groups.iter().map(|group| (*group).to_string()).collect()),
exactly_one: (kind == ConstraintKind::ExactlyOne)
.then(|| groups.iter().map(|group| (*group).to_string()).collect()),
..Default::default()
};
let config = Config {
project: Project {
name: "constraint-test".to_string(),
..Default::default()
},
profiles: HashMap::from([(
"default".to_string(),
Profile {
defaults: None,
secrets: HashMap::from([
("PASSWORD".to_string(), member()),
("ACCESS_TOKEN".to_string(), member()),
]),
},
)]),
providers: None,
scopes: None,
};
let provider = format!("dotenv://{}", env_path.display());
let app = Secrets::new(config, None, Some(provider), None);
(temp_dir, app)
}
fn validation_errors(spec: &Secrets) -> ValidationErrors {
match spec.validate().unwrap() {
Ok(_) => panic!("expected presence constraint to fail"),
Err(errors) => errors,
}
}
let (_temp_dir, spec) = app("", ConstraintKind::AtLeastOne, &["auth"]);
let errors = validation_errors(&spec);
assert!(errors.missing_required.is_empty());
assert_eq!(errors.constraint_violations.len(), 1);
assert_eq!(
errors.constraint_violations[0].kind,
ConstraintKind::AtLeastOne
);
assert_eq!(errors.constraint_violations[0].group, "auth");
assert!(errors.constraint_violations[0].present.is_empty());
let report = errors.report();
assert!(!report.all_required_present());
assert_eq!(
serde_json::to_value(&report).unwrap()["constraint_violations"][0]["kind"],
"at_least_one"
);
assert!(matches!(
spec.resolve(),
Err(SecretSpecError::ValidationFailed(_))
));
let (_temp_dir, spec) = app(
"ACCESS_TOKEN=token\n",
ConstraintKind::AtLeastOne,
&["auth"],
);
assert!(spec.validate().unwrap().is_ok());
let (_temp_dir, spec) = app("", ConstraintKind::AtLeastOne, &["auth", "deploy"]);
let errors = validation_errors(&spec);
assert_eq!(
errors
.constraint_violations
.iter()
.map(|violation| violation.group.as_str())
.collect::<Vec<_>>(),
vec!["auth", "deploy"]
);
let (_temp_dir, spec) = app("", ConstraintKind::ExactlyOne, &["auth"]);
let errors = validation_errors(&spec);
assert_eq!(
errors.constraint_violations[0].kind,
ConstraintKind::ExactlyOne
);
assert!(errors.constraint_violations[0].present.is_empty());
let (_temp_dir, spec) = app(
"PASSWORD=p\nACCESS_TOKEN=t\n",
ConstraintKind::ExactlyOne,
&["auth"],
);
let errors = validation_errors(&spec);
assert_eq!(
errors.constraint_violations[0].present,
vec!["ACCESS_TOKEN".to_string(), "PASSWORD".to_string()]
);
let (_temp_dir, spec) = app("PASSWORD=p\n", ConstraintKind::ExactlyOne, &["auth"]);
assert!(spec.validate().unwrap().is_ok());
}
pub(crate) fn resolve_test_config(secrets: HashMap<String, Secret>) -> Config {
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
Config {
project: Project {
name: "resolve-test".to_string(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
}
}
static RESOLUTION_ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) struct ResolutionEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
pub(crate) fn scrub_resolution_env() -> ResolutionEnvGuard {
let lock = RESOLUTION_ENV_GUARD
.lock()
.unwrap_or_else(|e| e.into_inner());
let saved = [
"SECRETSPEC_PROVIDER",
"SECRETSPEC_PROFILE",
"SECRETSPEC_SCOPE",
]
.into_iter()
.map(|key| {
let previous = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
(key, previous)
})
.collect();
ResolutionEnvGuard { _lock: lock, saved }
}
impl Drop for ResolutionEnvGuard {
fn drop(&mut self) {
for (key, previous) in self.saved.drain(..) {
if let Some(value) = previous {
unsafe { std::env::set_var(key, value) };
}
}
}
}
pub(crate) struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
pub(crate) fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
pub(crate) fn remove(key: &'static str) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => unsafe { std::env::set_var(self.key, value) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[test]
fn test_resolve_carries_values_and_provenance() {
use crate::resolve::ResolvedSource;
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "DATABASE_URL=postgres://localhost/db\n").unwrap();
let secret = |required: bool, default: Option<&str>| Secret {
description: Some("test".to_string()),
required: Some(required),
default: default.map(String::from),
..Default::default()
};
let mut secrets = HashMap::new();
secrets.insert("DATABASE_URL".to_string(), secret(true, None));
secrets.insert(
"DEV_SESSION_SECRET".to_string(),
secret(false, Some("development-only-secret")),
);
secrets.insert("SENTRY_DSN".to_string(), secret(false, None));
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(resolve_test_config(secrets), None, Some(provider), None);
let response = spec.resolve().unwrap();
assert_eq!(response.schema_version, 2);
assert_eq!(response.profile, "default");
assert!(response.is_ok());
assert_eq!(response.missing_optional, vec!["SENTRY_DSN".to_string()]);
let db = &response.secrets["DATABASE_URL"];
assert_eq!(db.value.as_deref(), Some("postgres://localhost/db"));
assert!(db.path.is_none());
assert!(!db.as_path);
assert_eq!(db.source, ResolvedSource::Provider);
assert!(db.source_provider.is_some());
let session = &response.secrets["DEV_SESSION_SECRET"];
assert_eq!(session.value.as_deref(), Some("development-only-secret"));
assert_eq!(session.source, ResolvedSource::Default);
assert!(session.source_provider.is_none());
assert!(!response.secrets.contains_key("SENTRY_DSN"));
let stripped = response.without_values();
assert!(stripped.secrets["DATABASE_URL"].value.is_none());
assert_eq!(
stripped.secrets["DATABASE_URL"].source,
ResolvedSource::Provider
);
}
#[test]
fn composed_secrets_resolve_in_dependency_order_without_reparsing_values() {
use crate::resolve::ResolvedSource;
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(
&env_path,
"DB_USER=alice\nDB_PASSWORD='${DB_HOST}'\nDB_HOST=db.example\n",
)
.unwrap();
let stored = |description: &str| Secret {
description: Some(description.to_string()),
..Default::default()
};
let mut secrets = HashMap::from([
("DB_USER".to_string(), stored("user")),
("DB_PASSWORD".to_string(), stored("password")),
("DB_HOST".to_string(), stored("host")),
]);
secrets.insert(
"AUTH".to_string(),
Secret {
description: Some("credentials".to_string()),
composed: Some("${DB_USER}:${DB_PASSWORD}".to_string()),
..Default::default()
},
);
secrets.insert(
"DATABASE_URL".to_string(),
Secret {
description: Some("dsn".to_string()),
composed: Some("postgres://${AUTH}@${DB_HOST}/app".to_string()),
..Default::default()
},
);
let config = resolve_test_config(secrets);
config.validate().unwrap();
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(config, None, Some(provider), None);
let response = spec.resolve().unwrap();
let auth = &response.secrets["AUTH"];
assert_eq!(auth.value.as_deref(), Some("alice:${DB_HOST}"));
assert_eq!(auth.source, ResolvedSource::Composed);
let dsn = &response.secrets["DATABASE_URL"];
assert_eq!(
dsn.value.as_deref(),
Some("postgres://alice:${DB_HOST}@db.example/app")
);
assert_eq!(dsn.source, ResolvedSource::Composed);
assert!(dsn.source_provider.is_none());
let report = spec.report().unwrap();
assert!(
report
.to_explain_string()
.contains("DATABASE_URL ok composed")
);
}
#[test]
fn composed_secrets_propagate_missingness_and_are_read_only() {
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "").unwrap();
let secrets = HashMap::from([
(
"OPTIONAL_PART".to_string(),
Secret {
description: Some("optional".to_string()),
required: Some(false),
..Default::default()
},
),
(
"OPTIONAL_RESULT".to_string(),
Secret {
description: Some("optional result".to_string()),
required: Some(false),
composed: Some("prefix-${OPTIONAL_PART}".to_string()),
..Default::default()
},
),
(
"REQUIRED_RESULT".to_string(),
Secret {
description: Some("required result".to_string()),
composed: Some("prefix-${OPTIONAL_PART}".to_string()),
..Default::default()
},
),
]);
let config = resolve_test_config(secrets);
config.validate().unwrap();
let spec = Secrets::new(
config,
None,
Some(format!("dotenv://{}", env_path.display())),
None,
);
let response = spec.resolve().unwrap();
assert_eq!(
response.missing_required,
vec!["REQUIRED_RESULT".to_string()]
);
assert!(
response
.missing_optional
.contains(&"OPTIONAL_RESULT".to_string())
);
let error = spec
.set("REQUIRED_RESULT", Some("override".to_string()))
.unwrap_err();
assert!(matches!(
error,
SecretSpecError::ComposedSecretReadOnly(ref name) if name == "REQUIRED_RESULT"
));
}
#[test]
fn composed_secrets_use_the_exported_path_of_as_path_dependencies() {
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "CERT=certificate-bytes\n").unwrap();
let secrets = HashMap::from([
(
"CERT".to_string(),
Secret {
description: Some("certificate".to_string()),
as_path: Some(true),
..Default::default()
},
),
(
"CERT_ARG".to_string(),
Secret {
description: Some("certificate argument".to_string()),
composed: Some("--cert=${CERT}".to_string()),
..Default::default()
},
),
]);
let config = resolve_test_config(secrets);
config.validate().unwrap();
let spec = Secrets::new(
config,
None,
Some(format!("dotenv://{}", env_path.display())),
None,
);
let response = spec.resolve().unwrap();
let cert_path = response.secrets["CERT"].path.as_deref().unwrap();
assert_eq!(
response.secrets["CERT_ARG"].value.as_deref(),
Some(format!("--cert={cert_path}").as_str())
);
}
#[test]
fn test_resolve_missing_required_is_empty_with_error_list() {
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"STRIPE_KEY".to_string(),
Secret {
description: Some("stripe".to_string()),
required: Some(true),
..Default::default()
},
);
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(resolve_test_config(secrets), None, Some(provider), None);
let response = spec.resolve().unwrap();
assert!(!response.is_ok());
assert_eq!(response.missing_required, vec!["STRIPE_KEY".to_string()]);
assert!(response.secrets.is_empty());
}
#[test]
fn test_resolve_as_path_returns_persisted_path() {
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "TLS_CERT=----cert-bytes----\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"TLS_CERT".to_string(),
Secret {
description: Some("cert".to_string()),
required: Some(true),
as_path: Some(true),
..Default::default()
},
);
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(resolve_test_config(secrets), None, Some(provider), None);
let response = spec.resolve().unwrap();
let cert = &response.secrets["TLS_CERT"];
assert!(cert.as_path);
assert!(cert.value.is_none());
let path = cert.path.as_deref().expect("as_path yields a path");
let contents = fs::read_to_string(path).unwrap();
assert_eq!(contents, "----cert-bytes----");
fs::remove_file(path).ok();
}
#[test]
fn test_resolve_without_values_keeps_structure_but_no_value_or_path() {
use crate::resolve::ResolvedSource;
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(
&env_path,
"DATABASE_URL=postgres://localhost/db\nTLS_CERT=----cert----\n",
)
.unwrap();
let secret = |as_path: bool| Secret {
description: Some("t".to_string()),
required: Some(true),
as_path: Some(as_path),
..Default::default()
};
let mut secrets = HashMap::new();
secrets.insert("DATABASE_URL".to_string(), secret(false));
secrets.insert("TLS_CERT".to_string(), secret(true));
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(resolve_test_config(secrets), None, Some(provider), None);
let response = spec.resolve_without_values().unwrap();
assert!(response.is_ok());
let db = &response.secrets["DATABASE_URL"];
assert!(db.value.is_none());
assert!(db.path.is_none());
assert!(!db.as_path);
assert_eq!(db.source, ResolvedSource::Provider);
let cert = &response.secrets["TLS_CERT"];
assert!(cert.value.is_none());
assert!(cert.path.is_none());
assert!(cert.as_path);
}
#[test]
fn test_report_lists_missing_required_without_failing() {
use crate::report::ResolutionStatus;
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "PRESENT=here\n").unwrap();
let secret = || Secret {
description: Some("t".to_string()),
required: Some(true),
..Default::default()
};
let mut secrets = HashMap::new();
secrets.insert("PRESENT".to_string(), secret());
secrets.insert("MISSING".to_string(), secret());
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(resolve_test_config(secrets), None, Some(provider), None);
assert!(!spec.resolve().unwrap().is_ok());
let report = spec.report().unwrap();
let status = |name: &str| {
report
.secrets
.iter()
.find(|s| s.name == name)
.map(|s| s.status.clone())
};
assert_eq!(status("PRESENT"), Some(ResolutionStatus::Resolved));
assert_eq!(status("MISSING"), Some(ResolutionStatus::MissingRequired));
}
#[test]
fn test_value_free_surfaces_do_not_generate_or_store() {
use crate::config::GenerateConfig;
use crate::report::ResolutionStatus;
use crate::resolve::ResolvedSource;
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"SESSION_KEY".to_string(),
Secret {
description: Some("generated".to_string()),
required: Some(true),
secret_type: Some("hex".to_string()),
generate: Some(GenerateConfig::Bool(true)),
..Default::default()
},
);
let provider = format!("dotenv://{}", env_path.display());
let spec = Secrets::new(resolve_test_config(secrets), None, Some(provider), None);
let report = spec.report().unwrap();
let entry = report
.secrets
.iter()
.find(|s| s.name == "SESSION_KEY")
.expect("SESSION_KEY in report");
assert_eq!(entry.status, ResolutionStatus::Resolved);
assert!(entry.generated);
assert_eq!(
fs::read_to_string(&env_path).unwrap(),
"",
"report() must not store a generated secret"
);
let response = spec.resolve_without_values().unwrap();
let resolved = &response.secrets["SESSION_KEY"];
assert_eq!(resolved.source, ResolvedSource::Generated);
assert!(resolved.value.is_none());
assert_eq!(
fs::read_to_string(&env_path).unwrap(),
"",
"resolve_without_values() must not store a generated secret"
);
let full = spec.resolve().unwrap();
assert!(full.is_ok());
assert!(full.secrets["SESSION_KEY"].value.is_some());
assert!(
fs::read_to_string(&env_path)
.unwrap()
.contains("SESSION_KEY"),
"resolve() generates and stores the secret"
);
}
#[test]
fn test_value_free_report_tolerates_read_only_provider() {
use crate::config::GenerateConfig;
use crate::report::ResolutionStatus;
let mut secrets = HashMap::new();
secrets.insert(
"SESSION_KEY".to_string(),
Secret {
description: Some("generated".to_string()),
required: Some(true),
secret_type: Some("hex".to_string()),
generate: Some(GenerateConfig::Bool(true)),
..Default::default()
},
);
let spec = Secrets::new(
resolve_test_config(secrets),
None,
Some("env://".to_string()),
None,
);
let report = spec.report().expect("report() must not fail on env://");
let entry = report
.secrets
.iter()
.find(|s| s.name == "SESSION_KEY")
.expect("SESSION_KEY in report");
assert_eq!(entry.status, ResolutionStatus::Resolved);
assert!(entry.generated);
}
#[test]
fn test_chain_primary_error_surfaces_instead_of_missing() {
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"DB_PASSWORD".to_string(),
Secret {
description: Some("db".to_string()),
required: Some(true),
providers: Some(vec!["primary".to_string(), "fallback".to_string()]),
..Default::default()
},
);
let mut provider_aliases = HashMap::new();
provider_aliases.insert(
"primary".to_string(),
ProviderAlias::from("bogus://unreachable"),
);
provider_aliases.insert(
"fallback".to_string(),
ProviderAlias::from(format!("dotenv://{}", env_path.display())),
);
let mut config = resolve_test_config(secrets);
config.providers = Some(provider_aliases);
let spec = Secrets::new(config, None, None, None);
assert!(
spec.resolve().is_err(),
"a primary provider outage with an empty fallback must surface the error"
);
assert!(spec.report().is_err());
}
#[test]
fn test_secretspec_new() {
let config = Config {
project: Project {
name: "test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: Some("dev".to_string()),
providers: None,
},
audit: 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,
},
audit: None,
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
},
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(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
},
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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
},
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(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
},
None,
None,
None,
);
let result = spec.get_provider(None, 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,
},
audit: None,
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
},
Some(global_config),
None,
None,
);
let result = spec.get_provider(None, 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_later_parent_wins_conflicts() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("parent-a")).unwrap();
fs::create_dir_all(base_path.join("parent-b")).unwrap();
fs::create_dir_all(base_path.join("root")).unwrap();
for (directory, description) in [("parent-a", "from A"), ("parent-b", "from B")] {
fs::write(
base_path.join(directory).join("secretspec.toml"),
format!(
r#"
[project]
name = "{directory}"
revision = "1.0"
[profiles.default]
SHARED = {{ description = "{description}", required = true }}
"#
),
)
.unwrap();
}
fs::write(
base_path.join("root/secretspec.toml"),
r#"
[project]
name = "root"
revision = "1.0"
extends = ["../parent-a", "../parent-b"]
[profiles.default]
ROOT_ONLY = { description = "root", required = true }
"#,
)
.unwrap();
let config = Config::try_from(base_path.join("root/secretspec.toml").as_path()).unwrap();
let shared = &config.profiles["default"].secrets["SHARED"];
assert_eq!(shared.description.as_deref(), Some("from B"));
}
#[test]
fn test_extends_allows_diamond_and_preserves_branch_override() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
for directory in ["base", "left", "right", "root"] {
fs::create_dir_all(base_path.join(directory)).unwrap();
}
fs::write(
base_path.join("base/secretspec.toml"),
r#"
[project]
name = "base"
revision = "1.0"
[profiles.default]
SHARED = { description = "from base", required = true }
"#,
)
.unwrap();
fs::write(
base_path.join("left/secretspec.toml"),
r#"
[project]
name = "left"
revision = "1.0"
extends = ["../base"]
[profiles.default]
SHARED = { description = "from left", required = true }
"#,
)
.unwrap();
fs::write(
base_path.join("right/secretspec.toml"),
r#"
[project]
name = "right"
revision = "1.0"
extends = ["../base"]
[profiles.default]
RIGHT_ONLY = { description = "right", required = true }
"#,
)
.unwrap();
fs::write(
base_path.join("root/secretspec.toml"),
r#"
[project]
name = "root"
revision = "1.0"
extends = ["../left", "../right"]
[profiles.default]
ROOT_ONLY = { description = "root", required = true }
"#,
)
.unwrap();
let config = Config::try_from(base_path.join("root/secretspec.toml").as_path())
.expect("a shared ancestor is not a dependency cycle");
let default = &config.profiles["default"].secrets;
assert_eq!(default["SHARED"].description.as_deref(), Some("from left"));
assert!(default.contains_key("RIGHT_ONLY"));
assert!(default.contains_key("ROOT_ONLY"));
}
#[test]
fn test_extends_inherits_profile_defaults() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::create_dir_all(base_path.join("parent")).unwrap();
fs::create_dir_all(base_path.join("child")).unwrap();
fs::write(
base_path.join("parent/secretspec.toml"),
r#"
[project]
name = "parent"
revision = "1.0"
[profiles.production]
PARENT_ONLY = { description = "parent", required = true }
[profiles.production.defaults]
required = false
providers = ["shared"]
"#,
)
.unwrap();
fs::write(
base_path.join("child/secretspec.toml"),
r#"
[project]
name = "child"
revision = "1.0"
extends = ["../parent"]
[profiles.production]
CHILD_ONLY = { description = "child", required = true }
"#,
)
.unwrap();
let config = Config::try_from(base_path.join("child/secretspec.toml").as_path()).unwrap();
let defaults = config.profiles["production"]
.defaults
.as_ref()
.expect("profile defaults should be inherited");
assert_eq!(defaults.required, Some(false));
assert_eq!(
defaults.providers.as_deref(),
Some(["shared".to_string()].as_slice())
);
}
#[test]
#[cfg(unix)]
fn test_extends_resolves_relative_to_symlink_location() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let link_dir = temp_dir.path().join("linkdir");
let real_dir = temp_dir.path().join("realdir");
fs::create_dir_all(link_dir.join("base")).unwrap();
fs::create_dir_all(&real_dir).unwrap();
fs::write(
link_dir.join("base/secretspec.toml"),
r#"
[project]
name = "base"
revision = "1.0"
[profiles.default]
SHARED = { description = "from base", required = true }
"#,
)
.unwrap();
fs::write(
real_dir.join("app.toml"),
r#"
[project]
name = "app"
revision = "1.0"
extends = ["base"]
[profiles.default]
APP_ONLY = { description = "app", required = true }
"#,
)
.unwrap();
let manifest = link_dir.join("secretspec.toml");
symlink(real_dir.join("app.toml"), &manifest).unwrap();
let config = Config::try_from(manifest.as_path())
.expect("extends should resolve relative to the symlink's directory");
let secrets = &config.profiles["default"].secrets;
assert!(secrets.contains_key("SHARED"), "inherited from ../base");
assert!(secrets.contains_key("APP_ONLY"));
}
#[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(),
..Default::default()
},
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
},
providers: None,
scopes: None,
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: None,
},
audit: 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 _cwd = crate::secrets::lock_cwd();
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(),
..Default::default()
},
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
},
providers: None,
scopes: None,
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("dotenv".to_string()),
profile: None,
providers: None,
},
audit: 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(),
..Default::default()
},
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
},
providers: None,
scopes: None,
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: None,
},
audit: 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(),
..Default::default()
},
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
},
providers: None,
scopes: None,
};
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,
},
audit: 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.contains_key("SECRET_THREE"),
"SECRET_THREE should not be imported (not in source)"
);
assert!(
!vars.contains_key("SECRET_FOUR"),
"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(),
..Default::default()
},
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
},
providers: None,
scopes: None,
};
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,
},
audit: 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,
},
audit: 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(),
..Default::default()
},
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
},
providers: None,
scopes: None,
};
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, },
audit: 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.contains_key("PROD_SECRET"),
"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(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
audit: 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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
audit: 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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
audit: 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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
audit: 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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
audit: 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, },
audit: 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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
let providers_map = aliases_map(&[("shared", "keyring://")]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: Some(providers_map),
},
audit: None,
};
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 providers_map = aliases_map(&[
("dev", "dotenv://.env.development"),
("prod", "onepassword://Production"),
]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
audit: None,
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
},
Some(global_config),
None,
None,
);
let dev_uri = spec
.resolve_one_provider("dev")
.expect("Should resolve dev alias");
assert_eq!(dev_uri, "dotenv://.env.development");
let prod_uri = spec
.resolve_one_provider("prod")
.expect("Should resolve prod alias");
assert_eq!(prod_uri, "onepassword://Production");
}
#[test]
fn test_provider_alias_not_found() {
let providers_map = aliases_map(&[("existing", "dotenv://.env")]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
audit: None,
};
let spec = Secrets::new(
Config {
project: Project {
name: "test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
},
Some(global_config),
None,
None,
);
let result = spec.resolve_one_provider("nonexistent");
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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
let providers_map = aliases_map(&[
("primary", &format!("dotenv://{}", env_file.display())),
("fallback", &format!("dotenv://{}", keyring_file.display())),
]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: None,
profile: None,
providers: Some(providers_map),
},
audit: None,
};
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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
let providers_map = aliases_map(&[
("primary", &format!("dotenv://{}", primary_file.display())),
("fallback", &format!("dotenv://{}", fallback_file.display())),
]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()), profile: None,
providers: Some(providers_map),
},
audit: None,
};
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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
let providers_map = aliases_map(&[
("primary", &format!("dotenv://{}", primary_dir.display())),
("fallback", &format!("dotenv://{}", fallback_file.display())),
]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
audit: None,
};
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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
let providers_map = aliases_map(&[
("a", &format!("dotenv://{}", broken_a.display())),
("b", &format!("dotenv://{}", broken_b.display())),
]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("keyring".to_string()),
profile: None,
providers: Some(providers_map),
},
audit: None,
};
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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
let providers_map = aliases_map(&[
("env_provider", &format!("dotenv://{}", env_file.display())),
(
"keyring_provider",
&format!("dotenv://{}", keyring_file.display()),
),
]);
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: Some(providers_map),
},
audit: None,
};
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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
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(),
ProviderAlias::from("onepassword://Shared"),
);
providers.insert(
"prod".to_string(),
ProviderAlias::from("onepassword://Production"),
);
}
assert_eq!(config.defaults.providers.as_ref().unwrap().len(), 2);
assert_eq!(
config.defaults.providers.as_ref().unwrap().get("shared"),
Some(&ProviderAlias::from("onepassword://Shared"))
);
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,
},
audit: 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,
},
audit: 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,
},
audit: 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,
},
audit: 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_generate_writes_through_ref_coordinates() {
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-ref"
revision = "1.0"
[profiles.default]
DB_PASSWORD = { description = "Database password", type = "password", generate = true, ref = { item = "MY_DB_SECRET" } }
"#;
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,
},
audit: None,
};
let spec = Secrets::new(config, Some(global_config), None, None);
let validated = spec.validate().unwrap().unwrap();
let generated = validated
.resolved
.secrets
.get("DB_PASSWORD")
.unwrap()
.expose_secret()
.to_string();
assert_eq!(generated.len(), 32);
let env_contents = fs::read_to_string(&env_file).unwrap();
assert!(
env_contents.contains("MY_DB_SECRET="),
"generated value should be stored under the ref key, got: {}",
env_contents
);
assert!(
env_contents.contains(&generated),
"the .env file should hold the generated value, got: {}",
env_contents
);
}
#[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,
},
audit: 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,
},
audit: 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,
},
audit: 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,
},
audit: 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,
at_least_one: None,
exactly_one: None,
default: None,
composed: None,
providers: None,
reference: 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(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
};
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(),
..Default::default()
},
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
},
providers: None,
scopes: None,
};
let providers_map = aliases_map(&[
("personal", &format!("dotenv://{}", personal_path.display())),
("team", &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),
},
audit: None,
};
(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)
}
fn chain_walk_spec(
temp_dir: &TempDir,
files: &[(&str, &str)],
chain: &[&str],
) -> (Secrets, Vec<std::path::PathBuf>) {
let mut paths = Vec::new();
let mut aliases = Vec::new();
for (alias, contents) in files {
let path = temp_dir.path().join(format!(".env.{alias}"));
fs::write(&path, contents).unwrap();
aliases.push((alias.to_string(), format!("dotenv://{}", path.display())));
paths.push(path);
}
let mut secrets = HashMap::new();
secrets.insert(
"MY_SECRET".to_string(),
Secret {
description: Some("test secret".to_string()),
required: Some(true),
providers: Some(chain.iter().map(|s| s.to_string()).collect()),
..Default::default()
},
);
let alias_refs: Vec<(&str, &str)> = aliases
.iter()
.map(|(alias, uri)| (alias.as_str(), uri.as_str()))
.collect();
let mut global_config = global_config_with_aliases(&alias_refs);
global_config.defaults.provider = Some("keyring".to_string());
let spec = Secrets::new(
resolve_test_config(secrets),
Some(global_config),
None,
None,
);
(spec, paths)
}
fn resolved_my_secret(spec: &Secrets) -> Option<String> {
spec.validate()
.expect("validation should not error")
.expect("required secret should resolve")
.resolved
.secrets
.get("MY_SECRET")
.map(|s| s.expose_secret().to_string())
}
#[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_undefined_fallback_alias_is_ignored_when_the_primary_answers() {
let _env = scrub_resolution_env();
let temp_dir = TempDir::new().unwrap();
let (spec, paths) = chain_walk_spec(
&temp_dir,
&[("personal", "MY_SECRET=already_here\n")],
&["personal", "ghost"],
);
assert_eq!(resolved_my_secret(&spec).as_deref(), Some("already_here"));
spec.get("MY_SECRET")
.expect("get reads the primary and ignores the undefined fallback");
spec.set("MY_SECRET", Some("updated".to_string()))
.expect("set writes to the primary and ignores the fallback");
assert_eq!(
read_env_var(&paths[0], "MY_SECRET").as_deref(),
Some("updated"),
"set must write through the primary"
);
}
#[test]
fn test_a_live_fallback_before_an_undefined_alias_still_wins() {
let _env = scrub_resolution_env();
let temp_dir = TempDir::new().unwrap();
let (spec, _paths) = chain_walk_spec(
&temp_dir,
&[("personal", ""), ("team", "MY_SECRET=from_team\n")],
&["personal", "team", "ghost"],
);
assert_eq!(
resolved_my_secret(&spec).as_deref(),
Some("from_team"),
"the live fallback must answer before the undefined link is reached",
);
}
#[test]
fn test_an_undefined_alias_mid_chain_does_not_block_a_later_provider() {
let _env = scrub_resolution_env();
let temp_dir = TempDir::new().unwrap();
let (spec, _paths) = chain_walk_spec(
&temp_dir,
&[("personal", ""), ("team", "MY_SECRET=from_team\n")],
&["personal", "ghost", "team"],
);
assert_eq!(
resolved_my_secret(&spec).as_deref(),
Some("from_team"),
"a broken link must be skipped, not abort the chain",
);
spec.get("MY_SECRET")
.expect("get walks past the broken link to the provider that answers");
}
#[test]
fn test_chain_entry_1password_gets_the_onepassword_hint() {
let _env = scrub_resolution_env();
let mut secrets = HashMap::new();
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("test secret".to_string()),
required: Some(true),
providers: Some(vec!["1password".to_string()]),
..Default::default()
},
);
let spec = Secrets::new(resolve_test_config(secrets), None, None, None);
let err = match spec.validate() {
Ok(_) => panic!("the misspelled provider cannot be constructed"),
Err(e) => e,
};
assert!(
err.to_string().contains("onepassword"),
"the error must point at the correct spelling: {err}"
);
}
#[test]
fn test_single_store_ref_rejects_unsupported_coordinate_up_front() {
let _env = scrub_resolution_env();
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "db=secret\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"DB_PASSWORD".to_string(),
Secret {
description: Some("db password".to_string()),
required: Some(true),
reference: Some(crate::config::NativeAddress {
item: "db".to_string(),
field: Some("password".to_string()),
..Default::default()
}),
providers: Some(vec![format!("dotenv://{}", env_path.display())]),
..Default::default()
},
);
let spec = Secrets::new(resolve_test_config(secrets), None, None, None);
let err = match spec.validate() {
Ok(_) => panic!("an unsupported ref coordinate must be rejected"),
Err(e) => e,
};
match err {
SecretSpecError::ProviderOperationFailed(msg) => {
assert!(
msg.contains("field"),
"message should name the coordinate: {msg}"
);
assert!(
msg.contains("dotenv"),
"message should name the store: {msg}"
);
}
other => panic!("expected ProviderOperationFailed, got {other:?}"),
}
}
#[test]
fn test_override_skips_read_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 override_spec = spec.explicit_provider_spec(None);
let route = spec
.route_for(&secret_config, &override_spec)
.expect("override resolution should succeed");
assert_eq!(
route.specs(),
Some(vec!["team".to_string()]),
"override must collapse the chain to the single override spec"
);
assert_eq!(
route.primary(),
Some(format!("dotenv://{}", team_path.display()).as_str())
);
}
#[test]
fn test_get_accepts_provider_shorthand_override() {
let _env = scrub_resolution_env();
let temp_dir = TempDir::new().unwrap();
let env_path = temp_dir.path().join(".env");
fs::write(&env_path, "MY_SECRET=hello\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"MY_SECRET".to_string(),
Secret {
description: Some("test secret".to_string()),
required: Some(true),
..Default::default()
},
);
let mut spec = Secrets::new(resolve_test_config(secrets), None, None, None);
spec.set_provider(format!("dotenv:{}", env_path.display()));
spec.get("MY_SECRET")
.expect("get must honor a scheme:path shorthand override");
}
#[test]
fn test_resolve_one_provider_accepts_bare_names_and_shorthand() {
let spec = Secrets::new(resolve_test_config(HashMap::new()), None, None, None);
assert_eq!(spec.resolve_one_provider("keyring").unwrap(), "keyring");
assert_eq!(
spec.resolve_one_provider("dotenv:.env.production").unwrap(),
"dotenv:.env.production"
);
assert!(matches!(
spec.resolve_one_provider("ghost"),
Err(SecretSpecError::ProviderNotFound(_))
));
}
fn strip_ansi(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
i += 2;
while i < bytes.len() && bytes[i] != b'm' {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
#[test]
fn test_format_summary_omits_optional_when_none_missing() {
let line = Secrets::format_summary(5, 0, 0);
assert_eq!(strip_ansi(&line), "Summary: 5 found, 0 missing");
}
#[test]
fn test_format_summary_appends_optional_when_some_missing() {
let line = Secrets::format_summary(4, 0, 1);
assert_eq!(strip_ansi(&line), "Summary: 4 found, 0 missing, 1 optional");
let mixed = Secrets::format_summary(2, 3, 4);
assert_eq!(
strip_ansi(&mixed),
"Summary: 2 found, 3 missing, 4 optional"
);
}
#[test]
fn test_validate_marks_unset_optional_secret_as_missing_optional() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, "REQUIRED_PRESENT=value\n").unwrap();
let config_file = temp_dir.path().join("secretspec.toml");
let toml_content = r#"[project]
name = "issue72"
revision = "1.0"
[profiles.default]
REQUIRED_PRESENT = { description = "required, present" }
OPTIONAL_MISSING = { description = "optional, not set", required = 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,
},
audit: None,
};
let spec = Secrets::new(config, Some(global_config), None, None);
let validated = spec
.validate()
.unwrap()
.expect("no required secrets are missing, so validation should succeed");
assert!(
validated.resolved.secrets.contains_key("REQUIRED_PRESENT"),
"required secret should be resolved"
);
assert!(
!validated.resolved.secrets.contains_key("OPTIONAL_MISSING"),
"unset optional secret must not appear in resolved secrets"
);
assert_eq!(
validated.missing_optional,
vec!["OPTIONAL_MISSING".to_string()],
"unset optional secret must be reported in missing_optional"
);
}
pub(crate) fn aliases_map(aliases: &[(&str, &str)]) -> HashMap<String, ProviderAlias> {
aliases
.iter()
.map(|(k, v)| (k.to_string(), ProviderAlias::from(*v)))
.collect()
}
fn config_with_project_aliases(aliases: &[(&str, &str)]) -> Config {
Config {
project: Project {
name: "alias-test".to_string(),
..Default::default()
},
profiles: HashMap::new(),
providers: Some(aliases_map(aliases)),
scopes: None,
}
}
pub(crate) fn global_config_with_aliases(aliases: &[(&str, &str)]) -> GlobalConfig {
GlobalConfig {
defaults: GlobalDefaults {
provider: None,
profile: None,
providers: Some(aliases_map(aliases)),
},
audit: None,
}
}
fn config_with_project_alias_secret(
alias: &str,
uri: &str,
secret_providers: Option<Vec<String>>,
) -> Config {
let mut secrets = HashMap::new();
secrets.insert(
"API_KEY".to_string(),
Secret {
description: Some("API key".to_string()),
required: Some(true),
providers: secret_providers,
..Default::default()
},
);
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
Config {
project: Project {
name: "alias-validation".to_string(),
..Default::default()
},
profiles,
providers: Some(aliases_map(&[(alias, uri)])),
scopes: None,
}
}
#[test]
fn test_project_providers_resolve_without_global_config() {
let config = config_with_project_aliases(&[("op_infra", "onepassword://Infra")]);
let spec = Secrets::new(config, None, None, None);
let resolved = spec
.resolve_one_provider("op_infra")
.expect("project alias should resolve");
assert_eq!(resolved, "onepassword://Infra");
}
#[test]
fn test_project_providers_take_precedence_over_global() {
let config = config_with_project_aliases(&[("shared", "dotenv://.env.team")]);
let global = global_config_with_aliases(&[("shared", "dotenv://.env.user")]);
let spec = Secrets::new(config, Some(global), None, None);
let resolved = spec
.resolve_one_provider("shared")
.expect("alias should resolve");
assert_eq!(
resolved, "dotenv://.env.team",
"project alias must win on conflict with global"
);
}
#[test]
fn test_unknown_alias_error_lists_both_sources() {
let config = config_with_project_aliases(&[("project_only", "dotenv://.env.team")]);
let global = global_config_with_aliases(&[("global_only", "dotenv://.env.user")]);
let spec = Secrets::new(config, Some(global), None, None);
let err = spec
.resolve_one_provider("does_not_exist")
.expect_err("missing alias must error");
let msg = err.to_string();
assert!(
msg.contains("project_only") && msg.contains("global_only"),
"error should list aliases from both project and global config, got: {}",
msg
);
}
#[test]
fn test_extends_carries_project_providers() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
fs::create_dir_all(base.join("shared")).unwrap();
fs::create_dir_all(base.join("app")).unwrap();
fs::write(
base.join("shared/secretspec.toml"),
r#"
[project]
name = "shared"
revision = "1.0"
[providers]
op_infra = "onepassword://Shared"
op_overridden = "onepassword://OldVault"
[profiles.default]
SHARED_SECRET = { description = "Shared", required = true }
"#,
)
.unwrap();
fs::write(
base.join("app/secretspec.toml"),
r#"
[project]
name = "app"
revision = "1.0"
extends = ["../shared"]
[providers]
op_overridden = "onepassword://NewVault"
[profiles.default]
APP_SECRET = { description = "App", required = true }
"#,
)
.unwrap();
let config = Config::try_from(base.join("app/secretspec.toml").as_path()).unwrap();
let providers = config
.providers
.as_ref()
.expect("merged config should carry [providers]");
assert_eq!(
providers.get("op_infra").map(|alias| alias.uri.as_str()),
Some("onepassword://Shared"),
"alias defined only in extended config should be inherited"
);
assert_eq!(
providers
.get("op_overridden")
.map(|alias| alias.uri.as_str()),
Some("onepassword://NewVault"),
"alias defined in both should resolve to the current (extending) config's value"
);
}
#[test]
fn test_provider_override_expands_project_alias() {
let config = config_with_project_aliases(&[("op_infra", "onepassword://Infra")]);
let spec = Secrets::new(config, None, None, Some("default".to_string()));
let mut spec = spec;
spec.set_provider("op_infra");
let resolved = spec
.explicit_provider_spec(None)
.map(|spec_str| spec.resolve_provider_spec(spec_str))
.expect("override should resolve to a URI");
assert_eq!(resolved, "onepassword://Infra");
}
#[test]
fn test_global_alias_still_resolves_when_project_providers_present() {
let config = config_with_project_aliases(&[("local", "dotenv://.env.local")]);
let global = global_config_with_aliases(&[("team", "onepassword://Team")]);
let spec = Secrets::new(config, Some(global), None, None);
let resolved = spec
.resolve_one_provider("team")
.expect("global alias should resolve when project map exists but doesn't define it");
assert_eq!(resolved, "onepassword://Team");
}
#[test]
fn test_fallback_chain_resolves_aliases_from_mixed_sources() {
let _env = scrub_resolution_env();
let temp_dir = TempDir::new().unwrap();
let team_path = temp_dir.path().join(".env.team");
let user_path = temp_dir.path().join(".env.user");
fs::write(&team_path, "").unwrap();
fs::write(&user_path, "MY_SECRET=from_user\n").unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"MY_SECRET".to_string(),
Secret {
description: Some("test secret".to_string()),
required: Some(true),
providers: Some(vec!["project_team".to_string(), "user_dotenv".to_string()]),
..Default::default()
},
);
let mut config = resolve_test_config(secrets);
let team_uri = format!("dotenv://{}", team_path.display());
let user_uri = format!("dotenv://{}", user_path.display());
config.providers = Some(aliases_map(&[("project_team", &team_uri)]));
let global = global_config_with_aliases(&[("user_dotenv", &user_uri)]);
let spec = Secrets::new(config, Some(global), None, None);
assert_eq!(
resolved_my_secret(&spec).as_deref(),
Some("from_user"),
"the fallback resolved from the global source must answer after the project-defined primary misses"
);
}
#[test]
fn test_provider_override_resolves_global_alias_when_project_providers_present() {
let config = config_with_project_aliases(&[("local", "dotenv://.env.local")]);
let global = global_config_with_aliases(&[("team", "onepassword://Team")]);
let mut spec = Secrets::new(config, Some(global), None, None);
spec.set_provider("team");
let resolved = spec
.explicit_provider_spec(None)
.map(|spec_str| spec.resolve_provider_spec(spec_str))
.expect("override should resolve to a URI");
assert_eq!(resolved, "onepassword://Team");
}
#[test]
fn test_import_source_expands_project_alias() {
let temp_dir = TempDir::new().unwrap();
let source_env_path = temp_dir.path().join(".env.source");
let target_env_path = temp_dir.path().join(".env.target");
fs::write(&source_env_path, "API_KEY=from-source\n").unwrap();
let source_uri = format!("dotenv://{}", source_env_path.display());
let target_uri = format!("dotenv://{}", target_env_path.display());
let config = config_with_project_alias_secret("source_env", &source_uri, None);
let global = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(target_uri),
..Default::default()
},
..Default::default()
};
let spec = Secrets::new(config, Some(global), None, None);
spec.import("source_env")
.expect("import source alias should resolve from project [providers]");
assert_eq!(
read_env_var(&target_env_path, "API_KEY").as_deref(),
Some("from-source")
);
}
#[test]
fn test_import_source_literal_uri_still_works() {
let temp_dir = TempDir::new().unwrap();
let source_env_path = temp_dir.path().join(".env.source");
let target_env_path = temp_dir.path().join(".env.target");
fs::write(&source_env_path, "API_KEY=from-source\n").unwrap();
let source_uri = format!("dotenv://{}", source_env_path.display());
let target_uri = format!("dotenv://{}", target_env_path.display());
let config = config_with_project_alias_secret("unused", "dotenv://.env.unused", None);
let global = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(target_uri),
..Default::default()
},
..Default::default()
};
let spec = Secrets::new(config, Some(global), None, None);
spec.import(&source_uri)
.expect("import from a literal provider URI should still work");
assert_eq!(
read_env_var(&target_env_path, "API_KEY").as_deref(),
Some("from-source")
);
}
#[test]
fn delete_removes_one_provider_value_and_is_idempotent() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let store = temp.path().join("store.env");
fs::write(&store, "API_KEY=secret\nOTHER=keep\n").unwrap();
let config: Config = toml::from_str(
r#"
[project]
name = "delete-test"
revision = "1.0"
[profiles.default]
API_KEY = { description = "API key" }
"#,
)
.unwrap();
let spec = Secrets::new(
config,
None,
Some(format!("dotenv://{}", store.display())),
None,
);
assert!(spec.delete("API_KEY").unwrap());
assert_eq!(read_env_var(&store, "API_KEY"), None);
assert_eq!(read_env_var(&store, "OTHER").as_deref(), Some("keep"));
assert!(!spec.delete("API_KEY").unwrap());
}
#[test]
fn delete_changes_only_the_primary_write_provider() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let primary = temp.path().join("primary.env");
let fallback = temp.path().join("fallback.env");
fs::write(&primary, "API_KEY=primary\n").unwrap();
fs::write(&fallback, "API_KEY=fallback\n").unwrap();
let primary_uri = format!("dotenv://{}", primary.display());
let fallback_uri = format!("dotenv://{}", fallback.display());
let config: Config = toml::from_str(&format!(
r#"
[project]
name = "delete-route-test"
revision = "1.0"
[providers]
primary = '{primary_uri}'
fallback = '{fallback_uri}'
[profiles.default]
API_KEY = {{ description = "API key", providers = ["primary", "fallback"] }}
"#
))
.unwrap();
let spec = Secrets::new(config, None, None, None);
assert!(spec.delete("API_KEY").unwrap());
assert_eq!(read_env_var(&primary, "API_KEY"), None);
assert_eq!(
read_env_var(&fallback, "API_KEY").as_deref(),
Some("fallback"),
"delete must not walk and destroy fallback copies"
);
assert_eq!(resolved_value(&spec, "API_KEY"), "fallback");
}
#[test]
fn import_with_delete_source_deletes_only_verified_values() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let target = temp.path().join("target.env");
fs::write(
&source,
"COPIED=from-source\nIDENTICAL=same\nCONFLICT=source-value\n",
)
.unwrap();
fs::write(&target, "IDENTICAL=same\nCONFLICT=target-value\n").unwrap();
let config: Config = toml::from_str(
r#"
[project]
name = "import-delete-source-test"
revision = "1.0"
[profiles.default]
COPIED = { description = "Copied" }
IDENTICAL = { description = "Identical" }
CONFLICT = { description = "Conflict" }
"#,
)
.unwrap();
let spec = Secrets::new(
config,
None,
Some(format!("dotenv://{}", target.display())),
None,
);
spec.import_with_delete_source(&format!("dotenv://{}", source.display()))
.unwrap();
assert_eq!(
read_env_var(&target, "COPIED").as_deref(),
Some("from-source")
);
assert_eq!(read_env_var(&target, "IDENTICAL").as_deref(), Some("same"));
assert_eq!(
read_env_var(&target, "CONFLICT").as_deref(),
Some("target-value"),
"import must not overwrite an existing target"
);
assert_eq!(read_env_var(&source, "COPIED"), None);
assert_eq!(read_env_var(&source, "IDENTICAL"), None);
assert_eq!(
read_env_var(&source, "CONFLICT").as_deref(),
Some("source-value"),
"a differing target must retain the source copy"
);
}
#[test]
fn import_with_delete_source_rejects_the_same_store() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let store = temp.path().join("store.env");
fs::write(&store, "API_KEY=keep\n").unwrap();
let store_uri = format!("dotenv://{}", store.display());
let config: Config = toml::from_str(
r#"
[project]
name = "same-import-store-test"
revision = "1.0"
[profiles.default]
API_KEY = { description = "API key" }
"#,
)
.unwrap();
let spec = Secrets::new(config, None, Some(store_uri.clone()), None);
let error = spec
.import_with_delete_source(&store_uri)
.expect_err("a move within one store would delete the destination");
assert!(error.to_string().contains("same provider"), "{error}");
assert_eq!(read_env_var(&store, "API_KEY").as_deref(), Some("keep"));
}
#[test]
fn import_with_delete_source_rejects_equivalent_pass_addresses() {
let _env = scrub_resolution_env();
let config: Config = toml::from_str(
r#"
[project]
name = "same-import-pass-test"
revision = "1.0"
[profiles.default]
API_KEY = { description = "API key" }
"#,
)
.unwrap();
let spec = Secrets::new(
config,
None,
Some("pass://secretspec/{project}/{profile}/{key}".to_string()),
None,
);
let error = spec
.import_with_delete_source("pass")
.expect_err("the explicit default pass path must resolve to the source entry");
assert!(error.to_string().contains("same provider"), "{error}");
}
#[test]
fn import_with_delete_source_rejects_non_deleting_source_before_target_writes() {
let _env = scrub_resolution_env();
let _source = EnvVarGuard::set("A_FIRST", "keep-at-source");
let temp = TempDir::new().unwrap();
let target = temp.path().join("target.env");
let config: Config = toml::from_str(
r#"
[project]
name = "unsupported-import-source-test"
revision = "1.0"
[profiles.default]
A_FIRST = { description = "Would otherwise be copied" }
"#,
)
.unwrap();
let spec = Secrets::new(
config,
None,
Some(format!("dotenv://{}", target.display())),
None,
);
let error = spec
.import_with_delete_source("env")
.expect_err("a non-deleting source must fail before importing any value");
assert!(
error
.to_string()
.contains("does not support deleting secrets"),
"{error}"
);
assert!(
!target.exists(),
"source capability preflight must happen before the target is created"
);
}
#[test]
fn import_with_delete_source_rejects_equivalent_dotenv_paths() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let store = temp.path().join("store.env");
fs::write(&store, "API_KEY=keep\n").unwrap();
let config: Config = toml::from_str(
r#"
[project]
name = "same-import-path-test"
revision = "1.0"
[profiles.default]
API_KEY = { description = "API key" }
"#,
)
.unwrap();
let mut spec = Secrets::new(config, None, Some("dotenv://store.env".to_string()), None);
spec.config_dir = temp.path().to_path_buf();
let error = spec
.import_with_delete_source("dotenv://./store.env")
.expect_err("equivalent paths must not bypass the same-store preflight");
assert!(error.to_string().contains("same provider"), "{error}");
assert_eq!(read_env_var(&store, "API_KEY").as_deref(), Some("keep"));
}
#[test]
fn import_with_delete_source_rejects_hard_linked_dotenv_paths() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let target = temp.path().join("target.env");
fs::write(&source, "API_KEY=keep\n").unwrap();
fs::hard_link(&source, &target).unwrap();
let config: Config = toml::from_str(
r#"
[project]
name = "same-import-file-test"
revision = "1.0"
[profiles.default]
API_KEY = { description = "API key" }
"#,
)
.unwrap();
let spec = Secrets::new(
config,
None,
Some(format!("dotenv://{}", target.display())),
None,
);
let error = spec
.import_with_delete_source(&format!("dotenv://{}", source.display()))
.expect_err("hard links to one file must be treated as the same store");
assert!(error.to_string().contains("same provider"), "{error}");
assert_eq!(read_env_var(&source, "API_KEY").as_deref(), Some("keep"));
}
#[test]
fn import_with_delete_source_preflights_every_destination_before_moving_values() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let target = temp.path().join("target.env");
fs::write(&source, "A_FIRST=keep-at-source\nZ_SAME=also-keep\n").unwrap();
let source_uri = format!("dotenv://{}", source.display());
let target_uri = format!("dotenv://{}", target.display());
let config: Config = toml::from_str(&format!(
r#"
[project]
name = "preflight-import-store-test"
revision = "1.0"
[providers]
target = '{target_uri}'
source = '{source_uri}'
[profiles.default]
A_FIRST = {{ description = "Would otherwise move first", providers = ["target"] }}
Z_SAME = {{ description = "Unsafe same-store route", providers = ["source"] }}
"#
))
.unwrap();
let spec = Secrets::new(config, None, None, None);
let error = spec
.import_with_delete_source(&source_uri)
.expect_err("all routes must be checked before the first source deletion");
assert!(error.to_string().contains("same provider"), "{error}");
assert_eq!(
read_env_var(&source, "A_FIRST").as_deref(),
Some("keep-at-source")
);
assert_eq!(
read_env_var(&target, "A_FIRST"),
None,
"preflight must happen before any earlier value is copied"
);
}
#[test]
fn test_import_unknown_source_lists_available_aliases() {
let temp_dir = TempDir::new().unwrap();
let source_uri = format!("dotenv://{}", temp_dir.path().join(".env.source").display());
let config = config_with_project_alias_secret("source_env", &source_uri, None);
let spec = Secrets::new(config, None, None, None);
let err = spec
.import("source_emv")
.expect_err("import from an unknown provider/alias must error");
let msg = err.to_string();
assert!(
msg.contains("source_env") && msg.contains("available aliases"),
"unknown import source should list the defined aliases, got: {}",
msg
);
}
#[test]
fn test_validate_project_provider_chain_without_global_default() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env.project");
fs::write(&env_file, "API_KEY=from-project\n").unwrap();
let uri = format!("dotenv://{}", env_file.display());
let config = config_with_project_alias_secret(
"project_env",
&uri,
Some(vec!["project_env".to_string()]),
);
let spec = Secrets::new(config, None, None, None);
let validated = spec
.validate()
.expect("project provider alias should not require a global provider")
.expect("secret should resolve from project provider alias");
assert_eq!(
validated
.resolved
.secrets
.get("API_KEY")
.unwrap()
.expose_secret(),
"from-project"
);
assert_eq!(
validated.resolved.provider, uri,
"validation metadata should report the resolved project provider URI"
);
}
#[test]
fn test_validate_provider_override_project_alias_without_global_default() {
let temp_dir = TempDir::new().unwrap();
let env_file = temp_dir.path().join(".env.override");
fs::write(&env_file, "API_KEY=from-override\n").unwrap();
let uri = format!("dotenv://{}", env_file.display());
let config = config_with_project_alias_secret("project_env", &uri, None);
let mut spec = Secrets::new(config, None, None, None);
spec.set_provider("project_env");
let validated = spec
.validate()
.expect("override alias should not be reparsed as a provider scheme")
.expect("secret should resolve from explicit project alias");
assert_eq!(
validated
.resolved
.secrets
.get("API_KEY")
.unwrap()
.expose_secret(),
"from-override"
);
assert_eq!(
validated.resolved.provider, uri,
"validation metadata should report the resolved override URI"
);
}
fn dotenv_spec(
env_contents: &str,
profiles: HashMap<String, Profile>,
temp_dir: &TempDir,
) -> Secrets {
let env_file = temp_dir.path().join(".env");
fs::write(&env_file, env_contents).unwrap();
Secrets::new(
Config {
project: Project {
name: "test".to_string(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
},
Some(GlobalConfig {
defaults: GlobalDefaults {
provider: Some(format!("dotenv://{}", env_file.display())),
profile: None,
providers: None,
},
audit: None,
}),
None,
None,
)
}
fn required_secret_profile(name: &str) -> HashMap<String, Profile> {
let mut secrets = HashMap::new();
secrets.insert(
name.to_string(),
Secret {
description: Some("A required secret".to_string()),
required: Some(true),
..Default::default()
},
);
HashMap::from([(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
)])
}
#[test]
fn test_check_returns_ok_when_required_present() {
let temp_dir = TempDir::new().unwrap();
let spec = dotenv_spec(
"REQUIRED=value\n",
required_secret_profile("REQUIRED"),
&temp_dir,
);
let validated = spec.check(true).expect("check should succeed");
assert!(validated.resolved.secrets.contains_key("REQUIRED"));
}
#[test]
fn test_check_no_prompt_errors_when_required_missing() {
let temp_dir = TempDir::new().unwrap();
let spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
assert!(
matches!(
spec.check(true),
Err(SecretSpecError::RequiredSecretMissing(_))
),
"expected RequiredSecretMissing when a required secret is absent"
);
}
#[test]
fn test_run_command_returns_child_exit_code() {
let temp_dir = TempDir::new().unwrap();
let empty_default = HashMap::from([(
"default".to_string(),
Profile {
defaults: None,
secrets: HashMap::new(),
},
)]);
let spec = dotenv_spec("", empty_default, &temp_dir);
assert_eq!(
spec.run_command(vec![
"sh".to_string(),
"-c".to_string(),
"exit 3".to_string()
])
.unwrap(),
3
);
assert_eq!(spec.run_command(vec!["true".to_string()]).unwrap(), 0);
assert_eq!(spec.run_command(vec!["false".to_string()]).unwrap(), 1);
}
fn audit_actions(lines: &std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Vec<String> {
lines
.lock()
.unwrap()
.iter()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["action"]
.as_str()
.unwrap()
.to_string()
})
.collect()
}
#[test]
fn audit_check_emits_single_check_event() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec(
"REQUIRED=value\n",
required_secret_profile("REQUIRED"),
&temp_dir,
);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.check(true).expect("check should succeed");
assert_eq!(audit_actions(&lines), vec!["check"]);
}
#[test]
fn audit_run_emits_run_not_check() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec(
"REQUIRED=value\n",
required_secret_profile("REQUIRED"),
&temp_dir,
);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.run_command(vec!["true".to_string()]).unwrap();
assert_eq!(audit_actions(&lines), vec!["run"]);
let event: serde_json::Value = serde_json::from_str(&lines.lock().unwrap()[0]).unwrap();
assert_eq!(event["outcome"], "started");
}
fn audit_events(lines: &std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Vec<serde_json::Value> {
lines
.lock()
.unwrap()
.iter()
.map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
.collect()
}
fn defaulted_secret_profile(name: &str, default: &str) -> HashMap<String, Profile> {
let mut secrets = HashMap::new();
secrets.insert(
name.to_string(),
Secret {
description: Some("with default".to_string()),
required: Some(false),
default: Some(default.to_string()),
..Default::default()
},
);
HashMap::from([(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
)])
}
#[test]
fn audit_get_present_records_found_without_value() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec(
"REQUIRED=hunter2\n",
required_secret_profile("REQUIRED"),
&temp_dir,
);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.get("REQUIRED").unwrap();
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "get");
assert_eq!(events[0]["outcome"], "found");
assert_eq!(events[0]["key"], "REQUIRED");
assert!(events[0]["provider"].as_str().unwrap().contains("dotenv"));
assert!(!lines.lock().unwrap()[0].contains("hunter2"));
}
#[test]
fn audit_get_missing_records_missing() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(matches!(
spec.get("REQUIRED"),
Err(SecretSpecError::SecretNotFound(_))
));
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "get");
assert_eq!(events[0]["outcome"], "missing");
assert_eq!(events[0]["key"], "REQUIRED");
}
#[test]
fn audit_get_default_records_default() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec(
"",
defaulted_secret_profile("OPTIONAL", "fallback"),
&temp_dir,
);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.get("OPTIONAL").unwrap();
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "get");
assert_eq!(events[0]["outcome"], "default");
assert_eq!(events[0]["key"], "OPTIONAL");
}
#[test]
fn audit_get_undefined_records_error() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(matches!(
spec.get("UNDEFINED"),
Err(SecretSpecError::SecretNotFound(_))
));
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "get");
assert_eq!(events[0]["outcome"], "error");
assert_eq!(events[0]["error_kind"], "secret_not_found");
assert!(events[0].get("provider").is_none());
}
#[test]
fn audit_set_records_written_without_value() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.set("REQUIRED", Some("secret_value".to_string()))
.unwrap();
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "set");
assert_eq!(events[0]["outcome"], "written");
assert_eq!(events[0]["key"], "REQUIRED");
assert!(events[0]["provider"].as_str().unwrap().contains("dotenv"));
assert!(!lines.lock().unwrap()[0].contains("secret_value"));
}
#[test]
fn audit_set_undefined_records_error() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(matches!(
spec.set("UNDEFINED", Some("v".to_string())),
Err(SecretSpecError::SecretNotFound(_))
));
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "set");
assert_eq!(events[0]["outcome"], "error");
assert_eq!(events[0]["error_kind"], "secret_not_found");
}
#[test]
fn audit_set_provider_construction_failure_records_error() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
spec.set_provider("ghost");
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(matches!(
spec.set("REQUIRED", Some("v".to_string())),
Err(SecretSpecError::ProviderNotFound(_))
));
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "set");
assert_eq!(events[0]["outcome"], "error");
assert_eq!(events[0]["error_kind"], "provider_not_found");
assert_eq!(events[0]["key"], "REQUIRED");
}
#[test]
fn audit_set_readonly_provider_records_error() {
let project_config = Config {
project: Project {
name: "test".to_string(),
..Default::default()
},
profiles: required_secret_profile("REQUIRED"),
providers: None,
scopes: None,
};
let global_config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some("env".to_string()),
profile: None,
providers: None,
},
audit: None,
};
let mut spec = Secrets::new(project_config, Some(global_config), None, None);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(matches!(
spec.set("REQUIRED", Some("v".to_string())),
Err(SecretSpecError::ProviderOperationFailed(_))
));
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "set");
assert_eq!(events[0]["outcome"], "error");
assert_eq!(events[0]["error_kind"], "provider_operation_failed");
}
#[test]
fn audit_policy_denied_still_records_blocked_attempt() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec(
"REQUIRED=value\n",
required_secret_profile("REQUIRED"),
&temp_dir,
);
spec.set_require_reason(RequireReason::Always);
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(matches!(
spec.get("REQUIRED"),
Err(SecretSpecError::ReasonRequired)
));
let events = audit_events(&lines);
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "get");
assert_eq!(events[0]["outcome"], "error");
assert_eq!(events[0]["error_kind"], "reason_required");
assert_eq!(events[0]["key"], "REQUIRED");
}
#[test]
fn audit_import_records_keys_and_per_secret_writes() {
let temp_dir = TempDir::new().unwrap();
let mut spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
let source = temp_dir.path().join("source.env");
fs::write(&source, "REQUIRED=from_source\n").unwrap();
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.import(&format!("dotenv://{}", source.display()))
.unwrap();
let events = audit_events(&lines);
let actions: Vec<&str> = events
.iter()
.map(|e| e["action"].as_str().unwrap())
.collect();
assert_eq!(actions, vec!["set", "import"]);
let set = &events[0];
assert_eq!(set["outcome"], "written");
assert_eq!(set["key"], "REQUIRED");
let import = &events[1];
assert_eq!(import["outcome"], "written");
assert_eq!(import["keys"][0], "REQUIRED");
assert!(
!lines
.lock()
.unwrap()
.iter()
.any(|l| l.contains("from_source"))
);
}
#[test]
fn test_resolve_profile_unknown_returns_invalid_profile() {
let temp_dir = TempDir::new().unwrap();
let spec = dotenv_spec("", required_secret_profile("REQUIRED"), &temp_dir);
let result = spec.resolve_profile_secret_names(Some("nonexistent"));
match result {
Err(SecretSpecError::InvalidProfile(msg)) => {
assert!(msg.contains("nonexistent"));
assert!(msg.contains("Available profiles"));
}
other => panic!("expected InvalidProfile, got {other:?}"),
}
}
fn secrets_with_credential_alias(
target_uri: &str,
credentials: HashMap<String, CredentialSource>,
) -> Secrets {
let mut config = resolve_test_config(HashMap::new());
config.providers = Some(HashMap::from([(
"target".to_string(),
ProviderAlias {
uri: target_uri.to_string(),
credentials,
..Default::default()
},
)]));
Secrets::new(config, None, None, None)
}
#[test]
fn provider_credentials_read_convention_credential_from_source() {
let _guard = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
std::fs::write(&source, "access_token=secret-abc\n").unwrap();
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource::from(format!("dotenv://{}", source.display())),
)]),
);
let credentials = secrets
.resolve_provider_credentials("target", "default")
.unwrap();
assert_eq!(
credentials
.get("access_token")
.map(|value| value.expose_secret().to_string()),
Some("secret-abc".to_string()),
);
}
#[test]
fn sourced_credential_reaches_target_provider_end_to_end() {
let _guard = scrub_resolution_env();
let _token = EnvVarGuard::remove("OP_SERVICE_ACCOUNT_TOKEN");
let temp = TempDir::new().unwrap();
let target_scope = |filename: &str, token: &str| {
let source = temp.path().join(filename);
std::fs::write(&source, format!("service_account_token={token}\n")).unwrap();
let secrets = secrets_with_credential_alias(
"onepassword://Private",
HashMap::from([(
"service_account_token".to_string(),
CredentialSource::from(format!("dotenv://{}", source.display())),
)]),
);
secrets
.get_provider(Some("target"), Some("default"))
.expect("the source credential should build the target provider")
.auth_scope_key()
.expect("onepassword should identify its effective authentication scope")
};
let first = target_scope("first.env", "source-token-a");
let same = target_scope("same.env", "source-token-a");
let different = target_scope("different.env", "source-token-b");
assert_eq!(
first, same,
"the same fetched credential yields the same scope"
);
assert_ne!(
first, different,
"changing the source credential must change the target's effective auth scope"
);
assert!(!first.contains("source-token-a"));
assert!(!different.contains("source-token-b"));
}
#[test]
fn provider_credentials_read_from_systemd_credential_source() {
let _guard = scrub_resolution_env();
let directory = TempDir::new().unwrap();
std::fs::write(
directory.path().join("test_token"),
"systemd-delivered-token",
)
.unwrap();
let _credentials_directory =
EnvVarGuard::set("CREDENTIALS_DIRECTORY", directory.path().to_str().unwrap());
let secrets = secrets_with_credential_alias(
"memtest://",
HashMap::from([(
"test_token".to_string(),
CredentialSource::from("systemd-credential"),
)]),
);
let credentials = secrets
.resolve_provider_credentials("target", "default")
.expect("the systemd credential source should resolve");
assert_eq!(
credentials
.get("test_token")
.map(|value| value.expose_secret().to_string()),
Some("systemd-delivered-token".to_string()),
);
}
#[test]
fn provider_credentials_read_ref_addressed_credential() {
let _guard = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
std::fs::write(&source, "SOURCE_KEY=secret-xyz\n").unwrap();
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource {
provider: format!("dotenv://{}", source.display()),
reference: Some(NativeAddress {
item: "SOURCE_KEY".to_string(),
..Default::default()
}),
},
)]),
);
let credentials = secrets
.resolve_provider_credentials("target", "default")
.unwrap();
assert_eq!(
credentials
.get("access_token")
.map(|value| value.expose_secret().to_string()),
Some("secret-xyz".to_string()),
);
}
#[test]
fn configured_credential_is_resolved_even_when_provider_env_is_set() {
let _guard = scrub_resolution_env();
let _var = EnvVarGuard::set("BWS_ACCESS_TOKEN", "from-env");
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
std::fs::write(&source, "access_token=from-source\n").unwrap();
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource::from(format!("dotenv://{}", source.display())),
)]),
);
let credentials = secrets
.resolve_provider_credentials("target", "default")
.unwrap();
assert_eq!(
credentials
.get("access_token")
.map(|value| value.expose_secret()),
Some("from-source")
);
}
#[test]
fn missing_provider_credential_is_an_actionable_error() {
let _guard = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
std::fs::write(&source, "").unwrap();
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource::from(format!("dotenv://{}", source.display())),
)]),
);
let error = secrets
.resolve_provider_credentials("target", "default")
.unwrap_err();
let message = error.to_string();
assert!(
message.contains("access_token") && message.contains("not found"),
"error should name the credential and say it was not found: {message}"
);
}
#[test]
fn credential_source_must_name_a_known_provider() {
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource::from("not_a_real_provider"),
)]),
);
let error = secrets.validate_credential_sources("target").unwrap_err();
assert!(
error.to_string().contains("not_a_real_provider"),
"error should name the unknown source: {error}"
);
}
#[test]
fn credential_name_must_be_supported_by_target_provider() {
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"BWS_ACCESS_TOKEN".to_string(),
CredentialSource::from("keyring"),
)]),
);
let error = secrets.validate_credential_sources("target").unwrap_err();
let message = error.to_string();
assert!(message.contains("BWS_ACCESS_TOKEN"), "{message}");
assert!(message.contains("access_token"), "{message}");
}
#[test]
fn declared_provider_credentials_validates_every_source_before_login() {
let secrets = secrets_with_credential_alias(
"vault://secret/app?auth=approle",
HashMap::from([
(
"role_id".to_string(),
CredentialSource::from("dotenv://source.env"),
),
(
"secret_id".to_string(),
CredentialSource::from("not_a_real_provider"),
),
]),
);
let error = secrets.declared_provider_credentials("target").unwrap_err();
assert!(error.to_string().contains("secret_id"));
}
#[test]
fn credential_source_display_redacts_inline_credentials() {
let source = CredentialSource::from("onepassword+token://ops_secret@Vault");
let displayed = source.display_provider();
assert_eq!(displayed, "onepassword+token://Vault");
assert!(!displayed.contains("ops_secret"));
}
#[test]
fn credential_chain_is_limited_to_one_hop() {
let mut config = resolve_test_config(HashMap::new());
config.providers = Some(HashMap::from([
(
"chained".to_string(),
ProviderAlias {
uri: "keyring://".to_string(),
credentials: HashMap::from([(
"access_token".to_string(),
CredentialSource::from("keyring"),
)]),
..Default::default()
},
),
(
"target".to_string(),
ProviderAlias {
uri: "bws://00000000-0000-0000-0000-000000000000".to_string(),
credentials: HashMap::from([(
"access_token".to_string(),
CredentialSource::from("chained"),
)]),
..Default::default()
},
),
]));
let secrets = Secrets::new(config, None, None, None);
let error = secrets.validate_credential_sources("target").unwrap_err();
assert!(
error.to_string().contains("one hop"),
"error should explain the one-hop limit: {error}"
);
}
#[test]
fn provider_credential_round_trips_through_its_source() {
let _guard = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
std::fs::write(&source, "").unwrap();
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource::from(format!("dotenv://{}", source.display())),
)]),
);
let credentials = secrets.declared_provider_credentials("target").unwrap();
assert_eq!(credentials.len(), 1);
let (var, source_spec) = &credentials[0];
secrets
.store_provider_credential(
source_spec,
var,
&secrecy::SecretString::new("stored-value".into()),
)
.unwrap();
let resolved = secrets
.resolve_provider_credentials("target", "default")
.unwrap();
assert_eq!(
resolved
.get("access_token")
.map(|value| value.expose_secret().to_string()),
Some("stored-value".to_string()),
);
}
#[test]
fn provider_credentials_memoize_per_profile() {
let _guard = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
std::fs::write(&source, "access_token=v1\n").unwrap();
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource::from(format!("dotenv://{}", source.display())),
)]),
);
secrets
.get_provider(Some("target"), Some("default"))
.expect("the source supplies the credential");
std::fs::write(&source, "").unwrap();
secrets
.get_provider(Some("target"), Some("default"))
.expect("the memoized credential must be reused for the same profile");
assert!(
secrets.get_provider(Some("target"), Some("other")).is_err(),
"another profile must re-fetch rather than reuse the memoized credential"
);
}
#[test]
fn storing_a_provider_credential_invalidates_the_memo() {
let _guard = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
std::fs::write(&source, "access_token=old\n").unwrap();
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([(
"access_token".to_string(),
CredentialSource::from(format!("dotenv://{}", source.display())),
)]),
);
secrets
.get_provider(Some("target"), Some("default"))
.expect("the source supplies the credential");
let credentials = secrets.declared_provider_credentials("target").unwrap();
let (var, source_spec) = &credentials[0];
secrets
.store_provider_credential(source_spec, var, &secrecy::SecretString::new("new".into()))
.unwrap();
std::fs::write(&source, "").unwrap();
assert!(
secrets
.get_provider(Some("target"), Some("default"))
.is_err(),
"the store must clear the memo so the credential is re-read"
);
}
#[test]
fn declared_provider_credentials_errors_for_an_unknown_alias() {
let secrets = Secrets::new(resolve_test_config(HashMap::new()), None, None, None);
assert!(secrets.declared_provider_credentials("nope").is_err());
}
#[test]
fn declared_provider_credentials_is_empty_for_an_alias_without_credentials() {
let mut config = resolve_test_config(HashMap::new());
config.providers = Some(HashMap::from([(
"plain".to_string(),
ProviderAlias::from("keyring://"),
)]));
let secrets = Secrets::new(config, None, None, None);
assert!(
secrets
.declared_provider_credentials("plain")
.unwrap()
.is_empty()
);
}
#[test]
fn store_provider_credential_rejects_a_read_only_source() {
let secrets = secrets_with_credential_alias(
"bws://00000000-0000-0000-0000-000000000000",
HashMap::from([("access_token".to_string(), CredentialSource::from("env://"))]),
);
let credentials = secrets.declared_provider_credentials("target").unwrap();
let (var, source_spec) = &credentials[0];
let result = secrets.store_provider_credential(
source_spec,
var,
&secrecy::SecretString::new("x".into()),
);
assert!(result.is_err(), "the env provider is read-only");
}
#[cfg(test)]
mod scopes {
use super::*;
const MANIFEST: &str = r#"
[project]
name = "scope-test"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
API_KEY = { description = "API key", required = true }
QUEUE_TOKEN = { description = "Queue token", required = true }
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]
[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]
"#;
fn config(toml: &str) -> Config {
toml::from_str(toml).expect("valid manifest")
}
#[test]
fn scope_narrows_resolution_to_the_intersection() {
let mut spec = Secrets::new(config(MANIFEST), None, None, None);
spec.set_scope("api");
assert_eq!(
spec.resolve_profile_secret_names(None).unwrap(),
vec!["API_KEY".to_string(), "DATABASE_URL".to_string()]
);
}
#[test]
fn no_scope_resolves_every_secret() {
let spec = Secrets::new(config(MANIFEST), None, None, None);
assert_eq!(
spec.resolve_profile_secret_names(None).unwrap(),
vec![
"API_KEY".to_string(),
"DATABASE_URL".to_string(),
"QUEUE_TOKEN".to_string()
]
);
}
#[test]
fn unknown_scope_errors_and_lists_the_defined_ones() {
let mut spec = Secrets::new(config(MANIFEST), None, None, None);
spec.set_scope("nope");
let err = spec
.resolve_profile_secret_names(None)
.expect_err("an undefined scope must fail resolution");
let SecretSpecError::InvalidScope(msg) = err else {
panic!("expected InvalidScope, got {err:?}");
};
assert!(msg.contains("nope"), "names the bad scope: {msg}");
assert!(
msg.contains("api") && msg.contains("worker"),
"lists the available scopes: {msg}"
);
}
#[test]
fn scope_membership_is_intersected_with_the_selected_profile() {
let manifest = r#"
[project]
name = "scope-intersect"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[profiles.production]
SENTRY_DSN = { description = "Sentry", required = true }
[scopes.api]
secrets = ["DATABASE_URL", "SENTRY_DSN"]
"#;
let mut spec = Secrets::new(config(manifest), None, None, None);
spec.set_scope("api");
assert_eq!(
spec.resolve_profile_secret_names(Some("default")).unwrap(),
vec!["DATABASE_URL".to_string()]
);
assert_eq!(
spec.resolve_profile_secret_names(Some("production"))
.unwrap(),
vec!["DATABASE_URL".to_string(), "SENTRY_DSN".to_string()]
);
}
#[test]
fn resolving_values_skips_required_secrets_outside_the_scope() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DATABASE_URL=postgres://localhost/db\nAPI_KEY=secret\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut scoped = Secrets::new(config(MANIFEST), None, Some(provider.clone()), None);
scoped.set_scope("api");
let response = scoped.resolve().unwrap();
assert!(
response.is_ok(),
"excluded required secret must not fail resolution"
);
assert!(response.secrets.contains_key("DATABASE_URL"));
assert!(response.secrets.contains_key("API_KEY"));
assert!(!response.secrets.contains_key("QUEUE_TOKEN"));
let unscoped = Secrets::new(config(MANIFEST), None, Some(provider), None);
let response = unscoped.resolve().unwrap();
assert!(!response.is_ok());
assert!(
response
.missing_required
.contains(&"QUEUE_TOKEN".to_string())
);
}
#[cfg(unix)]
#[test]
fn run_scope_scrubs_an_excluded_inherited_secret_from_the_child() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DATABASE_URL=postgres://localhost/db\nAPI_KEY=secret\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let _leaked = EnvVarGuard::set("QUEUE_TOKEN", "leaked-from-parent");
let mut spec = Secrets::new(config(MANIFEST), None, Some(provider), None);
spec.set_scope("api");
let excluded_file = temp.path().join("excluded");
let included_file = temp.path().join("included");
let exit = spec
.run_command(vec![
"sh".to_string(),
"-c".to_string(),
format!(
"printf '%s' \"$QUEUE_TOKEN\" > {}; printf '%s' \"$DATABASE_URL\" > {}",
excluded_file.display(),
included_file.display()
),
])
.unwrap();
assert_eq!(exit, 0);
assert_eq!(
fs::read_to_string(&excluded_file).unwrap(),
"",
"excluded QUEUE_TOKEN must not reach the child, even inherited from the parent"
);
assert_eq!(
fs::read_to_string(&included_file).unwrap(),
"postgres://localhost/db",
"the scoped DATABASE_URL is still injected"
);
}
#[test]
fn composed_scope_resolves_dependencies_without_exposing_them() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DB_USER=alice\nDB_PASSWORD=s3cret\nDB_HOST=db.example\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let manifest = r#"
[project]
name = "composed-scope"
revision = "1.0"
[profiles.default]
DB_USER = { description = "DB user" }
DB_PASSWORD = { description = "DB password" }
DB_HOST = { description = "DB host" }
DATABASE_URL = { description = "DSN", composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app" }
[scopes.api]
secrets = ["DATABASE_URL"]
"#;
let mut spec = Secrets::new(config(manifest), None, Some(provider), None);
spec.set_scope("api");
let response = spec.resolve().unwrap();
assert!(response.is_ok());
assert_eq!(
response
.secrets
.get("DATABASE_URL")
.and_then(|s| s.value.as_deref()),
Some("postgres://alice:s3cret@db.example/app")
);
assert!(!response.secrets.contains_key("DB_USER"));
assert!(!response.secrets.contains_key("DB_PASSWORD"));
assert!(!response.secrets.contains_key("DB_HOST"));
}
#[test]
fn nested_composition_resolves_under_scope_without_exposing_intermediates() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DB_USER=alice\nDB_PASSWORD=s3cret\nDB_HOST=db.example\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let manifest = r#"
[project]
name = "nested-composed-scope"
revision = "1.0"
[profiles.default]
DB_USER = { description = "DB user" }
DB_PASSWORD = { description = "DB password" }
DB_HOST = { description = "DB host" }
DATABASE_URL = { description = "DSN", composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app" }
CONN = { description = "Connection string", composed = "url=${DATABASE_URL}" }
[scopes.api]
secrets = ["CONN"]
"#;
let mut spec = Secrets::new(config(manifest), None, Some(provider), None);
spec.set_scope("api");
let response = spec.resolve().unwrap();
assert!(response.is_ok());
assert_eq!(
response
.secrets
.get("CONN")
.and_then(|s| s.value.as_deref()),
Some("url=postgres://alice:s3cret@db.example/app")
);
assert!(!response.secrets.contains_key("DATABASE_URL"));
assert!(!response.secrets.contains_key("DB_USER"));
assert!(!response.secrets.contains_key("DB_PASSWORD"));
assert!(!response.secrets.contains_key("DB_HOST"));
}
#[cfg(unix)]
#[test]
fn run_scope_scrubs_a_secret_declared_only_under_another_profile() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "DATABASE_URL=postgres://localhost/db\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let manifest = r#"
[project]
name = "cross-profile-scrub"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[profiles.production]
PROD_ONLY = { description = "prod secret", required = true }
[scopes.api]
secrets = ["DATABASE_URL"]
"#;
let _leaked = EnvVarGuard::set("PROD_ONLY", "leaked-from-parent");
let mut spec = Secrets::new(config(manifest), None, Some(provider), None);
spec.set_scope("api");
let leaked_file = temp.path().join("prod_only");
let included_file = temp.path().join("db");
let exit = spec
.run_command(vec![
"sh".to_string(),
"-c".to_string(),
format!(
"printf '%s' \"$PROD_ONLY\" > {}; printf '%s' \"$DATABASE_URL\" > {}",
leaked_file.display(),
included_file.display()
),
])
.unwrap();
assert_eq!(exit, 0);
assert_eq!(
fs::read_to_string(&leaked_file).unwrap(),
"",
"a secret from another profile must be scrubbed from the scoped child"
);
assert_eq!(
fs::read_to_string(&included_file).unwrap(),
"postgres://localhost/db",
"the scoped DATABASE_URL is still injected"
);
}
#[cfg(unix)]
#[test]
fn run_scope_keeps_an_admitted_secret_the_profile_does_not_declare() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "DATABASE_URL=postgres://localhost/db\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let manifest = r#"
[project]
name = "admitted-across-profiles"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[profiles.production]
SENTRY_DSN = { description = "error reporting", required = true }
PROD_ONLY = { description = "prod secret", required = true }
[scopes.api]
secrets = ["DATABASE_URL", "SENTRY_DSN"]
"#;
let _admitted = EnvVarGuard::set("SENTRY_DSN", "https://sentry.example/1");
let _leaked = EnvVarGuard::set("PROD_ONLY", "leaked-from-parent");
let mut spec = Secrets::new(config(manifest), None, Some(provider), None);
spec.set_scope("api");
let admitted_file = temp.path().join("sentry");
let leaked_file = temp.path().join("prod_only");
let exit = spec
.run_command(vec![
"sh".to_string(),
"-c".to_string(),
format!(
"printf '%s' \"$SENTRY_DSN\" > {}; printf '%s' \"$PROD_ONLY\" > {}",
admitted_file.display(),
leaked_file.display()
),
])
.unwrap();
assert_eq!(exit, 0);
assert_eq!(
fs::read_to_string(&admitted_file).unwrap(),
"https://sentry.example/1",
"a secret the scope admits must not be scrubbed, even when the \
selected profile does not declare it"
);
assert_eq!(
fs::read_to_string(&leaked_file).unwrap(),
"",
"a secret no scope admits is still scrubbed"
);
}
#[test]
fn provider_diagnostics_never_name_a_hidden_dependency() {
let visible: std::collections::HashSet<String> =
["DATABASE_URL".to_string()].into_iter().collect();
assert_eq!(
Secrets::diagnostic_secret_name("DB_PASSWORD", Some(&visible)),
crate::secrets::HIDDEN_SECRET_LABEL,
"an out-of-scope composition input is never named"
);
assert_eq!(
Secrets::diagnostic_secret_name("DATABASE_URL", Some(&visible)),
"DATABASE_URL",
"a secret the scope exposes keeps its own name"
);
assert_eq!(
Secrets::diagnostic_secret_name("DB_PASSWORD", None),
"DB_PASSWORD",
"unscoped, every name is its own label"
);
assert!(
!crate::secrets::HIDDEN_SECRET_LABEL.contains('_'),
"the placeholder must not look like a secret name"
);
}
#[test]
fn get_ignores_the_active_scope() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "QUEUE_TOKEN=tok\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut spec = Secrets::new(config(MANIFEST), None, Some(provider), None);
spec.set_scope("api");
assert!(
spec.get("QUEUE_TOKEN").is_ok(),
"an out-of-scope secret is still readable by name"
);
assert!(
spec.get("NOT_DECLARED").is_err(),
"an undeclared secret is still an error"
);
}
#[test]
fn set_lists_every_profile_secret_under_an_active_scope() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut spec = Secrets::new(config(MANIFEST), None, Some(provider), None);
spec.set_scope("api");
let err = spec
.set("UNDEFINED", Some("v".to_string()))
.expect_err("an undeclared secret cannot be written");
let SecretSpecError::SecretNotFound(msg) = err else {
panic!("expected SecretNotFound, got {err:?}");
};
assert!(
msg.contains("QUEUE_TOKEN"),
"the listing must not hide the out-of-scope QUEUE_TOKEN: {msg}"
);
}
#[test]
fn set_audits_an_undeclared_secret_even_under_an_undefined_scope() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut spec = Secrets::new(config(MANIFEST), None, Some(provider), None);
spec.set_scope("nope");
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(
matches!(
spec.set("UNDEFINED", Some("v".to_string())),
Err(SecretSpecError::SecretNotFound(_))
),
"the undefined scope must not mask the real error"
);
let events = audit_events(&lines);
assert_eq!(events.len(), 1, "the attempted write is still audited");
assert_eq!(events[0]["action"], "set");
assert_eq!(events[0]["outcome"], "error");
assert_eq!(events[0]["error_kind"], "secret_not_found");
}
#[test]
fn empty_scope_contacts_no_provider() {
let _env = scrub_resolution_env();
let manifest = r#"
[project]
name = "empty-scope"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[profiles.production]
PROD_ONLY = { description = "prod only", required = true }
[scopes.none]
secrets = ["PROD_ONLY"]
"#;
let mut spec = Secrets::new(config(manifest), None, Some("bogus://x".to_string()), None);
spec.set_scope("none");
let response = spec
.resolve()
.expect("an empty scope resolves without contacting a provider");
assert!(response.is_ok());
assert!(
response.secrets.is_empty(),
"an empty scope resolves to nothing"
);
assert!(response.missing_required.is_empty());
assert_eq!(
response.provider, "",
"a resolution that contacted no provider reports none"
);
assert_eq!(spec.report().unwrap().provider, "");
let unscoped = Secrets::new(config(manifest), None, Some("bogus://x".to_string()), None);
assert!(
unscoped.resolve().is_err(),
"a broken provider must fail an unscoped resolve"
);
}
#[test]
fn ignore_ambient_scope_still_honors_an_explicit_scope() {
let mut spec = Secrets::new(config(MANIFEST), None, None, None);
spec.set_ignore_ambient_scope(true);
spec.set_scope("api");
assert_eq!(
spec.resolve_profile_secret_names(None).unwrap(),
vec!["API_KEY".to_string(), "DATABASE_URL".to_string()],
"an explicit scope is honored even when the ambient fallback is suppressed"
);
}
const COMPOSED_MANIFEST: &str = r#"
[project]
name = "composed-scope"
revision = "1.0"
[profiles.default]
DB_USER = { description = "DB user" }
DB_PASSWORD = { description = "DB password" }
DB_HOST = { description = "DB host" }
DATABASE_URL = { description = "DSN", composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app" }
[scopes.api]
secrets = ["DATABASE_URL"]
"#;
#[test]
fn scoped_prompting_never_offers_out_of_scope_dependencies() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "DB_USER=alice\nDB_HOST=db.example\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let unscoped = Secrets::new(
config(COMPOSED_MANIFEST),
None,
Some(provider.clone()),
None,
);
let uerr = match unscoped.validate().unwrap() {
Ok(_) => panic!("DB_PASSWORD missing must fail resolution"),
Err(e) => e,
};
let uprompt = unscoped
.scoped_promptable_missing(&uerr, "default")
.unwrap();
assert!(
uprompt.contains(&"DB_PASSWORD".to_string()),
"unscoped prompting offers the missing leaf: {uprompt:?}"
);
let mut scoped = Secrets::new(config(COMPOSED_MANIFEST), None, Some(provider), None);
scoped.set_scope("api");
let serr = match scoped.validate().unwrap() {
Ok(_) => panic!("the visible composed secret must be unrenderable"),
Err(e) => e,
};
assert_eq!(serr.missing_required, vec!["DATABASE_URL".to_string()]);
let sprompt = scoped.scoped_promptable_missing(&serr, "default").unwrap();
assert!(
sprompt.is_empty(),
"a scope must not offer hidden dependencies for prompting: {sprompt:?}"
);
}
#[cfg(unix)]
#[test]
fn run_scope_scrubs_composed_dependencies_from_the_child() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DB_USER=alice\nDB_PASSWORD=s3cret\nDB_HOST=db.example\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let _u = EnvVarGuard::set("DB_USER", "leaked-user");
let _p = EnvVarGuard::set("DB_PASSWORD", "leaked-pass");
let mut spec = Secrets::new(config(COMPOSED_MANIFEST), None, Some(provider), None);
spec.set_scope("api");
let url_file = temp.path().join("url");
let user_file = temp.path().join("user");
let pass_file = temp.path().join("pass");
let exit = spec
.run_command(vec![
"sh".to_string(),
"-c".to_string(),
format!(
"printf '%s' \"$DATABASE_URL\" > {}; printf '%s' \"$DB_USER\" > {}; printf '%s' \"$DB_PASSWORD\" > {}",
url_file.display(),
user_file.display(),
pass_file.display()
),
])
.unwrap();
assert_eq!(exit, 0);
assert_eq!(
fs::read_to_string(&url_file).unwrap(),
"postgres://alice:s3cret@db.example/app"
);
assert_eq!(
fs::read_to_string(&user_file).unwrap(),
"",
"DB_USER must not reach the scoped child"
);
assert_eq!(
fs::read_to_string(&pass_file).unwrap(),
"",
"DB_PASSWORD must not reach the scoped child"
);
}
#[test]
fn export_scope_emits_only_visible_secrets() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "DATABASE_URL=db\nAPI_KEY=key\nQUEUE_TOKEN=tok\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut spec = Secrets::new(config(MANIFEST), None, Some(provider), None);
spec.set_scope("api");
let mut out = Vec::new();
spec.export(crate::ExportFormat::Dotenv, &mut out).unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("DATABASE_URL"));
assert!(rendered.contains("API_KEY"));
assert!(
!rendered.contains("QUEUE_TOKEN"),
"export --scope must not emit an out-of-scope secret: {rendered}"
);
}
#[test]
fn active_scope_is_surfaced_in_resolve_and_report_output() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "DATABASE_URL=db\nAPI_KEY=key\nQUEUE_TOKEN=tok\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut scoped = Secrets::new(config(MANIFEST), None, Some(provider.clone()), None);
scoped.set_scope("api");
assert_eq!(scoped.resolve().unwrap().scope.as_deref(), Some("api"));
let report = scoped.report().unwrap();
assert_eq!(report.scope.as_deref(), Some("api"));
let explained = report.to_explain_string();
assert!(
explained.contains("scope:") && explained.contains("api"),
"the explain output names the active scope: {explained}"
);
let unscoped = Secrets::new(config(MANIFEST), None, Some(provider), None);
assert_eq!(unscoped.resolve().unwrap().scope, None);
assert_eq!(unscoped.report().unwrap().scope, None);
}
const CONSTRAINT_MANIFEST: &str = r#"
[project]
name = "scoped-constraints"
revision = "1.0"
[profiles.default]
AWS_KEY = { description = "AWS credential", required = { at_least_one = "cloud" } }
GCP_KEY = { description = "GCP credential", required = { at_least_one = "cloud" } }
PRIMARY = { description = "Primary token", required = { exactly_one = "token" } }
FALLBACK = { description = "Fallback token", required = { exactly_one = "token" } }
UNRELATED = { description = "Unrelated" }
[scopes.aws]
secrets = ["AWS_KEY", "UNRELATED"]
[scopes.tokens]
secrets = ["PRIMARY", "FALLBACK"]
[scopes.plain]
secrets = ["UNRELATED"]
"#;
#[test]
fn a_presence_group_is_enforced_over_the_visible_members() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "GCP_KEY=g\nPRIMARY=p\nUNRELATED=u\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let unscoped = Secrets::new(
config(CONSTRAINT_MANIFEST),
None,
Some(provider.clone()),
None,
);
assert!(
unscoped.validate().unwrap().is_ok(),
"GCP_KEY satisfies at_least_one across the whole profile"
);
let mut scoped = Secrets::new(config(CONSTRAINT_MANIFEST), None, Some(provider), None);
scoped.set_scope("aws");
let errors = match scoped.validate().unwrap() {
Ok(_) => panic!("a scope whose only visible group member is missing must fail"),
Err(e) => e,
};
let violation = errors
.constraint_violations
.iter()
.find(|v| v.group == "cloud")
.expect("the cloud group is violated under this scope");
assert_eq!(violation.secrets, vec!["AWS_KEY".to_string()]);
assert!(violation.present.is_empty());
}
#[test]
fn scoped_constraint_violation_report_matches_the_schema() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "GCP_KEY=g\nPRIMARY=p\nUNRELATED=u\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut scoped = Secrets::new(config(CONSTRAINT_MANIFEST), None, Some(provider), None);
scoped.set_scope("aws");
let report = scoped.report().unwrap();
let violation = report
.constraint_violations
.iter()
.find(|v| v.group == "cloud")
.expect("the cloud group is violated under the aws scope");
assert_eq!(violation.secrets, vec!["AWS_KEY".to_string()]);
let instance = serde_json::to_value(&report).unwrap();
let schema: serde_json::Value =
serde_json::from_str(include_str!("fixtures/resolution-report.schema.json")).unwrap();
let validator = jsonschema::validator_for(&schema).expect("the committed schema compiles");
let errors: Vec<String> = validator
.iter_errors(&instance)
.map(|e| e.to_string())
.collect();
assert!(
errors.is_empty(),
"scoped report must validate against the schema: {errors:?}"
);
}
#[test]
fn import_ignores_the_active_scope() {
let temp = TempDir::new().unwrap();
let source = temp.path().join(".env.source");
let target = temp.path().join(".env.target");
fs::write(&source, "IN_SCOPE=a\nOUT_OF_SCOPE=b\n").unwrap();
fs::write(&target, "").unwrap();
const MANIFEST: &str = r#"
[project]
name = "scoped-import"
revision = "1.0"
[profiles.default]
IN_SCOPE = { description = "In scope" }
OUT_OF_SCOPE = { description = "Out of scope" }
[scopes.only_in]
secrets = ["IN_SCOPE"]
"#;
let mut spec = Secrets::new(
config(MANIFEST),
None,
Some(format!("dotenv://{}", target.display())),
None,
);
spec.set_scope("only_in");
spec.import(&format!("dotenv://{}", source.display()))
.unwrap();
let imported: HashMap<String, String> = dotenvy::from_path_iter(&target)
.unwrap()
.map(|item| item.unwrap())
.collect();
assert_eq!(imported.get("IN_SCOPE"), Some(&"a".to_string()));
assert_eq!(
imported.get("OUT_OF_SCOPE"),
Some(&"b".to_string()),
"import must copy the out-of-scope secret too"
);
}
#[test]
fn a_presence_group_with_no_visible_member_is_not_enforced() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "UNRELATED=u\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut scoped = Secrets::new(config(CONSTRAINT_MANIFEST), None, Some(provider), None);
scoped.set_scope("plain");
assert!(
scoped.validate().unwrap().is_ok(),
"a scope exposing no member of any group resolves cleanly"
);
}
#[test]
fn exactly_one_is_still_enforced_when_the_scope_shows_both_members() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "PRIMARY=p\nFALLBACK=f\nGCP_KEY=g\nUNRELATED=u\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut scoped = Secrets::new(config(CONSTRAINT_MANIFEST), None, Some(provider), None);
scoped.set_scope("tokens");
let errors = match scoped.validate().unwrap() {
Ok(_) => panic!("both members present must violate exactly_one under a scope"),
Err(e) => e,
};
let violation = errors
.constraint_violations
.iter()
.find(|v| v.group == "token")
.expect("the token group is violated under this scope");
assert_eq!(violation.present.len(), 2);
}
#[cfg(unix)]
#[test]
fn a_hidden_as_path_input_keeps_its_file_alive_for_the_visible_composition() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "DB_CERT=certificate-body\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let manifest = r#"
[project]
name = "as-path-scope"
revision = "1.0"
[profiles.default]
DB_CERT = { description = "Client certificate", as_path = true }
PG_ARGS = { description = "Connection args", composed = "sslcert=${DB_CERT}" }
[scopes.api]
secrets = ["PG_ARGS"]
"#;
let mut spec = Secrets::new(config(manifest), None, Some(provider), None);
spec.set_scope("api");
let args_file = temp.path().join("args");
let body_file = temp.path().join("body");
let cert_file = temp.path().join("cert");
let exit = spec
.run_command(vec![
"sh".to_string(),
"-c".to_string(),
format!(
"printf '%s' \"$PG_ARGS\" > {}; cat \"${{PG_ARGS#sslcert=}}\" > {} 2>/dev/null; printf '%s' \"$DB_CERT\" > {}",
args_file.display(),
body_file.display(),
cert_file.display()
),
])
.unwrap();
assert_eq!(exit, 0);
let args = fs::read_to_string(&args_file).unwrap();
assert!(
args.starts_with("sslcert=/"),
"the composition embeds the input's temp-file path: {args}"
);
assert_eq!(
fs::read_to_string(&body_file).unwrap(),
"certificate-body",
"a hidden as_path input's file must outlive scope filtering"
);
assert_eq!(
fs::read_to_string(&cert_file).unwrap(),
"",
"DB_CERT must not reach the scoped child as a variable"
);
}
#[test]
fn audit_records_accessed_secrets_for_a_scoped_check() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DB_USER=alice\nDB_PASSWORD=s3cret\nDB_HOST=db.example\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut spec = Secrets::new(config(COMPOSED_MANIFEST), None, Some(provider), None);
spec.set_scope("api");
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.check(true).expect("the scoped check resolves");
let events = super::audit_events(&lines);
let check = events
.iter()
.find(|e| e["action"] == "check")
.expect("a check event is recorded");
assert_eq!(check["scope"], "api");
let mut keys: Vec<String> = check["keys"]
.as_array()
.expect("the check event lists keys")
.iter()
.map(|k| k.as_str().unwrap().to_string())
.collect();
keys.sort();
assert_eq!(
keys,
vec![
"DATABASE_URL".to_string(),
"DB_HOST".to_string(),
"DB_PASSWORD".to_string(),
"DB_USER".to_string(),
],
"the audit records every secret actually read, not only the visible one"
);
}
#[cfg(unix)]
#[test]
fn audit_records_scope_and_visible_keys_for_run() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DATABASE_URL=db\nAPI_KEY=key\nQUEUE_TOKEN=token\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut spec = Secrets::new(config(MANIFEST), None, Some(provider), None);
spec.set_scope("api");
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert_eq!(spec.run_command(vec!["true".to_string()]).unwrap(), 0);
let events = super::audit_events(&lines);
let run = events
.iter()
.find(|e| e["action"] == "run")
.expect("a run event is recorded");
assert_eq!(run["scope"], "api");
assert_eq!(run["keys"], serde_json::json!(["API_KEY", "DATABASE_URL"]));
}
#[test]
fn audit_records_scope_and_visible_keys_for_export() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(
&env_path,
"DATABASE_URL=db\nAPI_KEY=key\nQUEUE_TOKEN=token\n",
)
.unwrap();
let provider = format!("dotenv://{}", env_path.display());
let mut spec = Secrets::new(config(MANIFEST), None, Some(provider), None);
spec.set_scope("worker");
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
spec.export(crate::ExportFormat::Dotenv, &mut Vec::new())
.unwrap();
let events = super::audit_events(&lines);
let export = events
.iter()
.find(|e| e["action"] == "export")
.expect("an export event is recorded");
assert_eq!(export["scope"], "worker");
assert_eq!(
export["keys"],
serde_json::json!(["DATABASE_URL", "QUEUE_TOKEN"])
);
}
#[test]
fn audit_records_an_invalid_scope_name_on_failure() {
let mut spec = Secrets::new(config(MANIFEST), None, Some("env://".to_string()), None);
spec.set_scope("does-not-exist");
let (logger, lines) = crate::audit::test_support::collecting_logger();
spec.set_audit_for_test(logger);
assert!(spec.check(true).is_err());
let events = super::audit_events(&lines);
let check = events
.iter()
.find(|e| e["action"] == "check")
.expect("the failed check is recorded");
assert_eq!(check["scope"], "does-not-exist");
assert_eq!(check["outcome"], "error");
}
#[test]
fn a_hidden_composition_input_does_not_satisfy_a_group() {
let temp = TempDir::new().unwrap();
let env_path = temp.path().join(".env");
fs::write(&env_path, "AWS_KEY=a\n").unwrap();
let provider = format!("dotenv://{}", env_path.display());
let manifest = r#"
[project]
name = "hidden-input-constraint"
revision = "1.0"
[profiles.default]
AWS_KEY = { description = "AWS credential", required = { at_least_one = "cloud" } }
GCP_KEY = { description = "GCP credential", required = { at_least_one = "cloud" } }
DERIVED = { description = "Derived", composed = "aws=${AWS_KEY}" }
[scopes.derived]
secrets = ["DERIVED"]
"#;
let mut scoped = Secrets::new(config(manifest), None, Some(provider), None);
scoped.set_scope("derived");
let validated = scoped.validate().unwrap();
assert!(validated.is_ok(), "the hidden input renders DERIVED");
let resolved = validated.unwrap();
assert!(!resolved.resolved.secrets.contains_key("AWS_KEY"));
}
}
fn cached_providers(
source_paths: &[&Path],
cache_uri: &str,
max_age: &str,
) -> HashMap<String, ProviderAlias> {
let mut providers = HashMap::new();
let mut fallback = Vec::new();
for (index, path) in source_paths.iter().enumerate() {
let alias = format!("source{index}");
fallback.push(alias.clone());
providers.insert(
alias,
ProviderAlias::from(format!("dotenv://{}", path.display())),
);
}
providers.insert("local".to_string(), ProviderAlias::from(cache_uri));
providers.insert(
"myprovider".to_string(),
ProviderAlias::cached(fallback, ProviderCache::new("local", max_age).unwrap()).unwrap(),
);
providers
}
fn cached_dotenv_providers(
source_paths: &[&Path],
cache_path: &Path,
max_age: &str,
) -> HashMap<String, ProviderAlias> {
cached_providers(
source_paths,
&format!("dotenv://{}", cache_path.display()),
max_age,
)
}
fn cached_secrets_with(project: &str, providers: HashMap<String, ProviderAlias>) -> Secrets {
let mut config = resolve_test_config(HashMap::from([(
"API_KEY".to_string(),
Secret {
providers: Some(vec!["myprovider".to_string()]),
..Default::default()
},
)]));
config.project.name = project.to_string();
config.providers = Some(providers);
Secrets::new(config, None, None, None)
}
fn cached_dotenv_secrets(source_paths: &[&Path], cache_path: &Path, max_age: &str) -> Secrets {
cached_secrets_with(
"resolve-test",
cached_dotenv_providers(source_paths, cache_path, max_age),
)
}
fn cached_memtest_providers(cache_path: &Path) -> HashMap<String, ProviderAlias> {
HashMap::from([
("source".to_string(), ProviderAlias::from("memtest://")),
(
"local".to_string(),
ProviderAlias::from(format!("dotenv://{}", cache_path.display())),
),
(
"myprovider".to_string(),
ProviderAlias::cached(
vec!["source".to_string()],
ProviderCache::new("local", "8h").unwrap(),
)
.unwrap(),
),
])
}
fn resolved_value(secrets: &Secrets, name: &str) -> String {
secrets.resolve().unwrap().secrets[name]
.value
.clone()
.expect("inline resolved value")
}
#[test]
fn a_cached_default_provider_reports_the_store_it_reads_first() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
let mut config =
resolve_test_config(HashMap::from([("API_KEY".to_string(), Secret::default())]));
config.providers = Some(cached_dotenv_providers(&[&source], &cache, "1h"));
let mut global = global_config_with_aliases(&[]);
global.defaults.provider = Some("myprovider".to_string());
let secrets = Secrets::new(config, Some(global), None, None);
let report = secrets.report().unwrap();
assert_eq!(report.provider, format!("dotenv://{}", source.display()));
}
#[test]
fn cached_route_hits_cache_refreshes_after_clear_and_survives_source_loss() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote-1\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "8h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-1");
assert!(cache.exists(), "the first source hit should populate cache");
fs::write(&source, "API_KEY=remote-2\n").unwrap();
assert_eq!(
resolved_value(&secrets, "API_KEY"),
"remote-1",
"a fresh cache hit wins over the changed source"
);
assert_eq!(secrets.clear_cache(Some("API_KEY")).unwrap(), 1);
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-2");
fs::remove_file(&source).unwrap();
fs::create_dir(&source).unwrap();
assert_eq!(
resolved_value(&secrets, "API_KEY"),
"remote-2",
"a fresh hit must not contact the now-broken authoritative provider"
);
}
#[test]
fn cached_route_walks_fallback_in_order_and_caches_the_answer() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let missing = temp.path().join("missing.env");
let fallback = temp.path().join("fallback.env");
let cache = temp.path().join("cache.env");
fs::write(&fallback, "API_KEY=from-fallback\n").unwrap();
let secrets = cached_dotenv_secrets(&[&missing, &fallback], &cache, "1h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "from-fallback");
fs::remove_file(&fallback).unwrap();
assert_eq!(resolved_value(&secrets, "API_KEY"), "from-fallback");
}
#[test]
fn expired_cache_entry_falls_back_and_refreshes() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote-1\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "8h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-1");
backdate_cache_entry(&cache, "resolve-test", "API_KEY");
fs::write(&source, "API_KEY=remote-2\n").unwrap();
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-2");
}
#[test]
fn value_free_report_does_not_populate_cache() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "1h");
let report = secrets.report().unwrap();
assert!(report.all_required_present());
assert!(
!cache.exists(),
"value-free resolution must not create a cache entry"
);
}
#[test]
fn set_writes_authoritative_provider_then_refreshes_cache() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
let secrets = cached_dotenv_secrets(&[&source], &cache, "1h");
secrets.set("API_KEY", Some("written".to_string())).unwrap();
let source_value = dotenvy::from_path_iter(&source)
.unwrap()
.next()
.unwrap()
.unwrap();
assert_eq!(source_value, ("API_KEY".to_string(), "written".to_string()));
fs::remove_file(&source).unwrap();
assert_eq!(resolved_value(&secrets, "API_KEY"), "written");
}
#[test]
fn delete_removes_the_authoritative_value_and_its_cache_entry() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "1h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote");
assert!(cache.exists(), "the first read should populate the cache");
assert!(secrets.delete("API_KEY").unwrap());
assert_eq!(read_env_var(&source, "API_KEY"), None);
assert_eq!(read_env_var(&cache, "API_KEY"), None);
let errors = match secrets.validate().unwrap() {
Ok(_) => panic!("a deleted required secret must no longer resolve from cache"),
Err(errors) => errors,
};
assert_eq!(errors.missing_required, vec!["API_KEY".to_string()]);
}
#[test]
fn a_damaged_entry_of_our_own_is_replaced() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote-1\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "1h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-1");
let marker = crate::cache::CACHE_ENVELOPE_MARKER;
write_cache_entry(
&cache,
"resolve-test",
"API_KEY",
&format!("{marker}{{trunc"),
);
fs::write(&source, "API_KEY=remote-2\n").unwrap();
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-2");
let refreshed = stored_cache_entry(&cache).expect("a replacement entry");
assert!(
refreshed
.strip_prefix(marker)
.is_some_and(|payload| { serde_json::from_str::<serde_json::Value>(payload).is_ok() }),
"{refreshed}"
);
}
#[test]
fn changed_authoritative_route_invalidates_existing_cache() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source_a = temp.path().join("source-a.env");
let source_b = temp.path().join("source-b.env");
let cache = temp.path().join("cache.env");
fs::write(&source_a, "API_KEY=from-a\n").unwrap();
fs::write(&source_b, "API_KEY=from-b\n").unwrap();
let first = cached_dotenv_secrets(&[&source_a], &cache, "1h");
assert_eq!(resolved_value(&first, "API_KEY"), "from-a");
let changed = cached_dotenv_secrets(&[&source_b], &cache, "1h");
assert_eq!(
resolved_value(&changed, "API_KEY"),
"from-b",
"the cache must not survive a change to its authoritative provider route"
);
}
#[test]
fn shared_flat_cache_does_not_cross_projects() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let cache = temp.path().join("cache.env");
let providers = cached_memtest_providers(&cache);
let project_a = cached_secrets_with("cache-project-a", providers.clone());
let project_b = cached_secrets_with("cache-project-b", providers);
project_a
.set("API_KEY", Some("from-project-a".to_string()))
.unwrap();
project_b
.set("API_KEY", Some("from-project-b".to_string()))
.unwrap();
assert_eq!(
resolved_value(&project_a, "API_KEY"),
"from-project-a",
"a shared flat cache must reject another project's envelope"
);
}
#[test]
fn shared_flat_cache_does_not_cross_profiles() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let cache = temp.path().join("cache.env");
let mut config = resolve_test_config(HashMap::from([(
"API_KEY".to_string(),
Secret {
providers: Some(vec!["myprovider".to_string()]),
..Default::default()
},
)]));
config.project.name = "cache-profile-test".to_string();
config
.profiles
.insert("production".to_string(), config.profiles["default"].clone());
config.providers = Some(cached_memtest_providers(&cache));
let mut secrets = Secrets::new(config, None, None, None);
secrets
.set("API_KEY", Some("from-default".to_string()))
.unwrap();
secrets.set_profile("production");
secrets
.set("API_KEY", Some("from-production".to_string()))
.unwrap();
secrets.set_profile("default");
assert_eq!(
resolved_value(&secrets, "API_KEY"),
"from-default",
"a shared flat cache must reject another profile's envelope"
);
}
#[test]
fn cache_write_failure_does_not_hide_authoritative_value() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let secrets = cached_secrets_with(
"cache-unwritable-test",
cached_providers(&[&source], "failwrite://", "1h"),
);
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote");
}
fn backdate_cache_entry(cache: &Path, project: &str, name: &str) {
let marker = crate::cache::CACHE_ENVELOPE_MARKER;
let (_, stored) = dotenvy::from_path_iter(cache)
.unwrap()
.next()
.unwrap()
.unwrap();
let payload = stored
.strip_prefix(marker)
.expect("a cache entry carries the ownership marker");
let mut envelope: serde_json::Value = serde_json::from_str(payload).unwrap();
envelope["cached_at"] = serde_json::json!(0);
write_cache_entry(
cache,
project,
name,
&format!("{marker}{}", serde_json::to_string(&envelope).unwrap()),
);
}
fn write_cache_entry(cache: &Path, project: &str, name: &str, value: &str) {
let provider = crate::provider::provider_from_spec(
&format!("dotenv://{}", cache.display()),
crate::provider::ProviderCredentials::new(),
)
.unwrap();
provider
.set(
crate::provider::Address::convention(project, "default", name),
&secrecy::SecretString::new(value.into()),
)
.unwrap();
}
#[test]
fn an_expired_entry_is_dropped_even_when_nothing_replaces_it() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "8h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote");
backdate_cache_entry(&cache, "resolve-test", "API_KEY");
fs::remove_file(&source).unwrap();
secrets.report().unwrap();
assert!(
!fs::read_to_string(&cache).unwrap().contains("API_KEY"),
"an entry no read can serve must not keep its plaintext"
);
}
#[test]
fn a_cache_asks_its_store_to_expire_the_entry() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let secrets = cached_secrets_with(
"cache-expiry-test",
cached_providers(&[&source], "expiring://", "8h"),
);
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote");
assert_eq!(
crate::provider::tests::recorded_expiry("cache-expiry-test/default/API_KEY"),
Some(std::time::Duration::from_secs(8 * 60 * 60))
);
}
#[test]
fn a_cache_that_cannot_delete_is_refused() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let secrets = cached_secrets_with(
"cache-undeletable-test",
cached_providers(&[&source], "env://", "1h"),
);
let message = secrets.resolve().unwrap_err().to_string();
assert!(message.contains("cannot delete secrets"), "{message}");
}
#[test]
fn a_write_that_bypasses_the_cache_invalidates_it() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote-1\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "8h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-1");
let mut direct = cached_dotenv_secrets(&[&source], &cache, "8h");
direct.set_provider("source0");
direct.set("API_KEY", Some("remote-2".to_string())).unwrap();
assert_eq!(
resolved_value(&secrets, "API_KEY"),
"remote-2",
"a write that bypassed the cache must invalidate what it superseded"
);
}
#[test]
fn a_failed_cache_refresh_drops_the_superseded_entry() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
fs::write(&source, "API_KEY=remote-1\n").unwrap();
let project = "cache-refresh-failure-test";
let cached = cached_secrets_with(project, cached_providers(&[&source], "memtest://", "8h"));
let unwritable_cache =
cached_secrets_with(project, cached_providers(&[&source], "failwrite://", "8h"));
assert_eq!(resolved_value(&cached, "API_KEY"), "remote-1");
unwritable_cache
.set("API_KEY", Some("remote-2".to_string()))
.unwrap();
assert_eq!(
resolved_value(&cached, "API_KEY"),
"remote-2",
"an entry the refresh could not replace must be dropped, not served"
);
}
#[test]
fn cache_construction_failure_drops_the_superseded_entry() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let credential = temp.path().join("cache-credential.env");
fs::write(&source, "API_KEY=remote-1\n").unwrap();
fs::write(&credential, "test_token=available\n").unwrap();
let project = "cache-construction-failure-test";
let providers = || {
let mut providers = cached_providers(&[&source], "memtest://", "8h");
providers.insert(
"local".to_string(),
ProviderAlias {
uri: "memtest://".to_string(),
credentials: HashMap::from([(
"test_token".to_string(),
CredentialSource::from(format!("dotenv://{}", credential.display())),
)]),
..Default::default()
},
);
providers
};
let populated = cached_secrets_with(project, providers());
assert_eq!(resolved_value(&populated, "API_KEY"), "remote-1");
fs::write(&source, "API_KEY=remote-2\n").unwrap();
fs::remove_file(&credential).unwrap();
fs::create_dir(&credential).unwrap();
let unavailable = cached_secrets_with(project, providers());
assert_eq!(resolved_value(&unavailable, "API_KEY"), "remote-2");
fs::remove_dir(&credential).unwrap();
fs::write(&credential, "test_token=available-again\n").unwrap();
let recovered = cached_secrets_with(project, providers());
assert_eq!(
resolved_value(&recovered, "API_KEY"),
"remote-2",
"credential recovery must not revive the superseded cache value"
);
}
fn stored_cache_entry(cache: &Path) -> Option<String> {
dotenvy::from_path_iter(cache).unwrap().find_map(|item| {
let (key, value) = item.unwrap();
(key == "API_KEY").then_some(value)
})
}
#[test]
fn a_value_secretspec_did_not_write_is_never_deleted() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
fs::write(&cache, "API_KEY=someone-elses-value\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "8h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote");
let error = secrets
.clear_cache(Some("API_KEY"))
.unwrap_err()
.to_string();
assert!(error.contains("not a SecretSpec cache entry"), "{error}");
assert_eq!(
stored_cache_entry(&cache).as_deref(),
Some("someone-elses-value"),
"a value SecretSpec did not write must survive both the read and the clear"
);
}
#[test]
fn another_projects_cache_entry_is_never_deleted() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let providers = cached_dotenv_providers(&[&source], &cache, "8h");
let theirs = cached_secrets_with("their-project", providers.clone());
assert_eq!(resolved_value(&theirs, "API_KEY"), "remote");
let entry = stored_cache_entry(&cache).expect("their cache entry");
let ours = cached_secrets_with("our-project", providers);
let error = ours.clear_cache(Some("API_KEY")).unwrap_err().to_string();
assert!(error.contains("their-project/default"), "{error}");
assert_eq!(
stored_cache_entry(&cache).as_deref(),
Some(entry.as_str()),
"another project's entry must survive our clear"
);
}
#[test]
fn cached_reads_serve_every_secret_across_cache_stores() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let shared_cache = temp.path().join("shared-cache.env");
let other_cache = temp.path().join("other-cache.env");
fs::write(&source, "A_KEY=a\nB_KEY=b\nC_KEY=c\n").unwrap();
let cached_secret = |alias: &str| Secret {
providers: Some(vec![alias.to_string()]),
..Default::default()
};
let mut config = resolve_test_config(HashMap::from([
("A_KEY".to_string(), cached_secret("myprovider")),
("B_KEY".to_string(), cached_secret("myprovider")),
("C_KEY".to_string(), cached_secret("otherprovider")),
]));
let mut providers = cached_dotenv_providers(&[&source], &shared_cache, "1h");
providers.insert(
"other_local".to_string(),
ProviderAlias::from(format!("dotenv://{}", other_cache.display())),
);
providers.insert(
"otherprovider".to_string(),
ProviderAlias::cached(
vec!["source0".to_string()],
ProviderCache::new("other_local", "1h").unwrap(),
)
.unwrap(),
);
config.providers = Some(providers);
let secrets = Secrets::new(config, None, None, None);
for (name, value) in [("A_KEY", "a"), ("B_KEY", "b"), ("C_KEY", "c")] {
assert_eq!(resolved_value(&secrets, name), value);
}
fs::remove_file(&source).unwrap();
for (name, value) in [("A_KEY", "a"), ("B_KEY", "b"), ("C_KEY", "c")] {
assert_eq!(resolved_value(&secrets, name), value, "{name} from cache");
}
}
#[test]
fn cache_clear_counts_only_the_entries_it_removed() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "1h");
assert_eq!(
secrets.clear_cache(None).unwrap(),
0,
"nothing is cached yet, so nothing was cleared"
);
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote");
assert_eq!(secrets.clear_cache(None).unwrap(), 1);
assert_eq!(
secrets.clear_cache(None).unwrap(),
0,
"clearing an already-cleared cache removes nothing"
);
}
#[test]
fn cache_clear_ignores_a_provider_override() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "API_KEY=remote-1\n").unwrap();
let secrets = cached_dotenv_secrets(&[&source], &cache, "8h");
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-1");
let mut overridden = cached_dotenv_secrets(&[&source], &cache, "8h");
overridden.set_provider("source0");
assert_eq!(overridden.clear_cache(Some("API_KEY")).unwrap(), 1);
fs::write(&source, "API_KEY=remote-2\n").unwrap();
assert_eq!(resolved_value(&secrets, "API_KEY"), "remote-2");
}
#[test]
fn cache_clear_clears_what_it_can_before_reporting_a_failure() {
let _env = scrub_resolution_env();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source.env");
let cache = temp.path().join("cache.env");
fs::write(&source, "A_KEY=a\nB_KEY=b\n").unwrap();
let mut config = resolve_test_config(HashMap::from([
(
"A_KEY".to_string(),
Secret {
providers: Some(vec!["unclearable".to_string()]),
..Default::default()
},
),
(
"B_KEY".to_string(),
Secret {
providers: Some(vec!["myprovider".to_string()]),
..Default::default()
},
),
]));
let mut providers = cached_dotenv_providers(&[&source], &cache, "1h");
providers.insert(
"unreachable".to_string(),
ProviderAlias::from("faildelete://"),
);
providers.insert(
"unclearable".to_string(),
ProviderAlias::cached(
vec!["source0".to_string()],
ProviderCache::new("unreachable", "1h").unwrap(),
)
.unwrap(),
);
config.providers = Some(providers);
let secrets = Secrets::new(config, None, None, None);
assert_eq!(resolved_value(&secrets, "B_KEY"), "b");
let message = secrets.clear_cache(None).unwrap_err().to_string();
assert!(message.contains("cleared 1 cache entry"), "{message}");
assert!(message.contains("A_KEY"), "{message}");
assert!(
!fs::read_to_string(&cache).unwrap().contains("B_KEY"),
"one unclearable cache must not leave the rest of the profile cached"
);
}