363 lines
19 KiB
Rust
363 lines
19 KiB
Rust
// file: crates/ksp-config-lib/unit_tests/environment.rs
|
|
// version: 3
|
|
|
|
#[test]
|
|
fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
|
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
|
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
|
|
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
|
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
|
assert!(resolved.is_ok(), "process value should resolve");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.value(), "");
|
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
|
|
}
|
|
|
|
#[test]
|
|
fn dotenv_wins_over_fallback_when_process_value_is_absent() {
|
|
let process = std::collections::BTreeMap::<String, String>::new();
|
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
|
|
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
|
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
|
assert!(resolved.is_ok(), "dotenv value should resolve");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.value(), "dotenv-logs");
|
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_dotenv_value_is_defined_and_beats_fallback() {
|
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
|
assert!(resolved.is_ok(), "empty dotenv value should resolve");
|
|
if let std::result::Result::Ok(resolved) = resolved {
|
|
assert_eq!(resolved.value(), "");
|
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_is_used_only_when_external_sources_are_absent() {
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
|
|
assert!(resolved.is_ok(), "fallback should resolve");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.value(), "fallback-logs");
|
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Fallback);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_variable_without_fallback_is_a_distinct_error() {
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
|
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None);
|
|
let error = match resolved {
|
|
std::result::Result::Ok(_) => return,
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING);
|
|
assert!(!error.to_string().contains("fallback-logs"));
|
|
}
|
|
|
|
#[test]
|
|
fn ksp_and_kspb_namespaces_are_supported_but_external_names_are_rejected() {
|
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
|
process.insert("KSP_LOGS_DIRECTORY".to_owned(), "logs".to_owned());
|
|
let bot_variable = ["KSPB_", "TEST_KEY"].concat();
|
|
process.insert(bot_variable.clone(), "hidden".to_owned());
|
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
|
assert!(environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None).is_ok());
|
|
assert!(environment.resolve_variable(bot_variable.as_str(), std::option::Option::None).is_ok());
|
|
let external = environment.resolve_variable("OTHER_NETWORK", std::option::Option::None);
|
|
let error = match external {
|
|
std::result::Result::Ok(_) => return,
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID);
|
|
}
|
|
|
|
#[test]
|
|
fn text_resolver_supports_multiple_placeholders_and_literal_fallbacks() {
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
|
let resolved = environment.resolve_text("logs=${KSP_LOGS_DIRECTORY:-logs};second=${KSP_LOGS_DIRECTORY:-other}");
|
|
assert!(resolved.is_ok(), "multiple placeholders should resolve");
|
|
if let std::result::Result::Ok(resolved) = resolved {
|
|
assert_eq!(resolved, "logs=logs;second=other");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_or_nested_placeholders_are_rejected() {
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
|
let unclosed = environment.resolve_text("${KSP_LOGS_DIRECTORY");
|
|
let unclosed = match unclosed {
|
|
std::result::Result::Ok(_) => return,
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(unclosed.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
|
|
let nested = environment.resolve_text("${KSP_LOGS_DIRECTORY:-${KSP_LOGS_DIRECTORY}}}");
|
|
let nested = match nested {
|
|
std::result::Result::Ok(_) => return,
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(nested.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
|
|
}
|
|
|
|
#[test]
|
|
fn json_resolver_walks_objects_and_arrays_without_changing_keys() {
|
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
|
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "runtime-logs".to_owned());
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
|
let source = serde_json::json!({"path": "${KSP_LOGS_DIRECTORY}", "items": [1, "${KSP_LOGS_DIRECTORY}"], "enabled": true});
|
|
let resolved = environment.resolve_json(&source);
|
|
assert!(resolved.is_ok(), "recursive JSON resolution should succeed");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved["path"], serde_json::Value::String("runtime-logs".to_owned()));
|
|
assert_eq!(resolved["items"][1], serde_json::Value::String("runtime-logs".to_owned()));
|
|
assert_eq!(resolved["enabled"], serde_json::Value::Bool(true));
|
|
}
|
|
|
|
#[test]
|
|
fn dotenv_parser_supports_comments_export_quotes_empty_values_and_ignores_external_keys() {
|
|
let path = std::path::Path::new("fixture.env");
|
|
let content = "# comment\nexport KSP_LOGS_DIRECTORY = 'quoted logs'\nOTHER_TOOL=value\n";
|
|
let parsed = super::parse_dotenv_content(path, content);
|
|
assert!(parsed.is_ok(), "dotenv fixture should parse");
|
|
let parsed = match parsed {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(parsed.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("quoted logs"));
|
|
assert!(!parsed.contains_key("OTHER_TOOL"));
|
|
}
|
|
|
|
#[test]
|
|
fn dotenv_duplicate_ksp_key_is_rejected() {
|
|
let parsed = super::parse_dotenv_content(std::path::Path::new("fixture.env"), "KSP_LOGS_DIRECTORY=one\nKSP_LOGS_DIRECTORY=two\n");
|
|
let error = match parsed {
|
|
std::result::Result::Ok(_) => return,
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(error.code(), crate::ERROR_CODE_DOTENV_SYNTAX_INVALID);
|
|
}
|
|
|
|
#[test]
|
|
fn fake_process_collection_filters_unrelated_names_without_mutating_real_environment() {
|
|
let values = vec![
|
|
(std::ffi::OsString::from("KSP_LOGS_DIRECTORY"), std::ffi::OsString::from("process-logs")),
|
|
(std::ffi::OsString::from("OTHER_TOOL"), std::ffi::OsString::from("ignored")),
|
|
];
|
|
let collected = super::collect_process_environment(values);
|
|
assert!(collected.is_ok(), "fake process environment should collect");
|
|
let collected = match collected {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(collected.len(), 1);
|
|
assert_eq!(collected.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("process-logs"));
|
|
}
|
|
|
|
#[test]
|
|
fn committed_logging_profile_resolves_environment_fallback_without_changing_source_profile() {
|
|
let workspace = workspace_root();
|
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
|
assert!(bootstrap.is_ok(), "bootstrap should resolve committed roots");
|
|
let bootstrap = match bootstrap {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let registry = crate::ConfigFileRegistry::defaults();
|
|
assert!(registry.is_ok(), "default registry should build");
|
|
let registry = match registry {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
|
let file_id = match file_id {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
|
assert!(profile.is_ok(), "committed Logging profile should resolve");
|
|
let profile = match profile {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
|
let effective = profile.resolve_effective_environment(&environment);
|
|
assert!(effective.is_ok(), "committed Logging environment fallback should resolve");
|
|
let effective = match effective {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(profile.effective().get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("${KSP_LOGS_DIRECTORY:-logs}"));
|
|
assert_eq!(effective.get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("logs"));
|
|
}
|
|
|
|
#[test]
|
|
fn env_example_inventory_contains_current_runtime_variable_with_preceding_comment() {
|
|
let content = std::fs::read_to_string(workspace_root().join(crate::DEFAULT_DOTENV_EXAMPLE_PATH));
|
|
assert!(content.is_ok(), ".env.example must be committed at workspace root");
|
|
let content = match content {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let lines = content.lines().collect::<std::vec::Vec<&str>>();
|
|
let mut found = false;
|
|
for index in 0..lines.len() {
|
|
if lines[index].starts_with("KSP_LOGS_DIRECTORY=") {
|
|
found = true;
|
|
assert!(index > 0, "environment entry must have a preceding comment");
|
|
assert!(lines[index - 1].trim_start().starts_with('#'), "environment entry must be immediately preceded by a comment");
|
|
}
|
|
}
|
|
assert!(found, "current Config environment variable must appear in .env.example");
|
|
}
|
|
|
|
fn workspace_root() -> std::path::PathBuf {
|
|
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
}
|
|
|
|
#[test]
|
|
fn secret_environment_value_keeps_real_value_but_redacts_safe_and_debug_views() {
|
|
let canary = "KSP_SECRET_CANARY_91b7c6";
|
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
|
process.insert("KSP_SECRET_TEST_TOKEN".to_owned(), canary.to_owned());
|
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
|
let resolved = environment.resolve_variable("KSP_SECRET_TEST_TOKEN", std::option::Option::None);
|
|
assert!(resolved.is_ok(), "secret process value should resolve");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.value(), canary);
|
|
assert_eq!(resolved.safe_value(), crate::REDACTED_CONFIG_VALUE);
|
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
|
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
|
|
let debug = format!("{resolved:?}");
|
|
assert!(!debug.contains(canary), "Debug must not reveal the secret canary");
|
|
assert!(debug.contains(crate::REDACTED_CONFIG_VALUE));
|
|
}
|
|
|
|
#[test]
|
|
fn detailed_text_redacts_only_secret_segments_and_keeps_ordered_provenance() {
|
|
let secret = "secret-canary-4a62";
|
|
let mut process = std::collections::BTreeMap::<String, String>::new();
|
|
process.insert("KSP_PUBLIC_HOST".to_owned(), "rpc.example.test".to_owned());
|
|
process.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
|
|
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
|
|
let resolved = environment.resolve_text_detailed("https://${KSP_PUBLIC_HOST}/?token=${KSP_SECRET_TOKEN}");
|
|
assert!(resolved.is_ok(), "composed secret URL should resolve");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.value(), "https://rpc.example.test/?token=secret-canary-4a62");
|
|
assert_eq!(resolved.safe_value(), "https://rpc.example.test/?token=********");
|
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
|
assert_eq!(resolved.provenance().len(), 4);
|
|
assert_eq!(resolved.provenance()[0], crate::ConfigValueProvenance::DocumentLiteral);
|
|
assert_eq!(resolved.provenance()[1].variable_name(), std::option::Option::Some("KSP_PUBLIC_HOST"));
|
|
assert_eq!(resolved.provenance()[2], crate::ConfigValueProvenance::DocumentLiteral);
|
|
assert_eq!(resolved.provenance()[3].variable_name(), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
|
let debug = format!("{resolved:?}");
|
|
assert!(!debug.contains(secret), "resolved text Debug must not reveal a secret segment");
|
|
}
|
|
|
|
#[test]
|
|
fn secret_fallback_inherits_secret_sensitivity_and_is_redacted() {
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
|
let resolved = environment.resolve_text_detailed("token=${KSP_SECRET_TOKEN:-false-secret}");
|
|
assert!(resolved.is_ok(), "secret fallback should resolve");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.value(), "token=false-secret");
|
|
assert_eq!(resolved.safe_value(), "token=********");
|
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
|
assert_eq!(resolved.provenance()[1].environment_source(), std::option::Option::Some(super::ConfigEnvironmentSource::Fallback));
|
|
}
|
|
|
|
#[test]
|
|
fn detailed_json_preserves_safe_tree_sensitivity_and_pointer_provenance() {
|
|
let secret = "nested-secret-canary-2d11";
|
|
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
|
|
dotenv.insert("KSP_SECRET_TOKEN".to_owned(), secret.to_owned());
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
|
|
let source = serde_json::json!({"transport": {"url": "https://host/?token=${KSP_SECRET_TOKEN}"}, "items": ["plain", 7]});
|
|
let resolved = environment.resolve_json_detailed(&source);
|
|
assert!(resolved.is_ok(), "detailed JSON should resolve");
|
|
let resolved = match resolved {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.value()["transport"]["url"], serde_json::Value::String(format!("https://host/?token={secret}")));
|
|
assert_eq!(resolved.safe_value()["transport"]["url"], serde_json::Value::String("https://host/?token=********".to_owned()));
|
|
assert_eq!(resolved.sensitivity(), crate::ConfigSensitivity::Secret);
|
|
let provenance = resolved.provenance_at("/transport/url");
|
|
assert!(provenance.is_some(), "JSON pointer provenance should exist");
|
|
if let std::option::Option::Some(provenance) = provenance {
|
|
assert_eq!(provenance.last().and_then(crate::ConfigValueProvenance::variable_name), std::option::Option::Some("KSP_SECRET_TOKEN"));
|
|
}
|
|
let debug = format!("{resolved:?}");
|
|
assert!(!debug.contains(secret), "resolved JSON Debug must not reveal a secret canary");
|
|
}
|
|
|
|
#[test]
|
|
fn detailed_profile_environment_keeps_global_origin_and_adds_environment_provenance() {
|
|
let workspace = workspace_root();
|
|
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
|
let bootstrap = match bootstrap {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let registry = crate::ConfigFileRegistry::defaults();
|
|
let registry = match registry {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
|
|
let file_id = match file_id {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
|
|
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
|
let profile = match profile {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
|
|
let effective = profile.resolve_effective_environment_detailed(&environment);
|
|
assert!(effective.is_ok(), "detailed committed Logging profile should resolve");
|
|
let effective = match effective {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(profile.origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global));
|
|
assert_eq!(effective.value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
|
|
assert_eq!(effective.safe_value()["logs_directory"], serde_json::Value::String("logs".to_owned()));
|
|
assert_eq!(
|
|
effective.provenance_at("/logs_directory").and_then(|items| items.last()).and_then(crate::ConfigValueProvenance::variable_name),
|
|
std::option::Option::Some("KSP_LOGS_DIRECTORY"),
|
|
);
|
|
}
|