use dirigent_fermata::core::secrets::config::{ EnforcementMode, HeuristicMode, ParseErrorAction, RedactionStyle, SecretsConfig, BUILTIN_KEY_PATTERNS, }; #[test] fn parse_minimal_files_only() { let cfg = SecretsConfig::from_toml( r#" [files] patterns = [".env", ".env.*"] "#, ) .unwrap(); assert_eq!(cfg.files.patterns, vec![".env", ".env.*"]); // Other sections use defaults assert_eq!(cfg.redaction.style, RedactionStyle::Masked); assert_eq!(cfg.enforcement.mode, EnforcementMode::Permissive); } #[test] fn parse_full_config() { let cfg = SecretsConfig::from_toml( r#" [files] patterns = [".env", "secrets.*"] [keys] include = ["STRIPE_*", "TWILIO_*"] exclude = ["PUBLIC_KEY", "SSH_KEY_PATH"] [redaction] style = "typed" [heuristic] enabled = false mode = "report" patterns = ['AKIA[A-Z2-7]{16}'] [enforcement] mode = "strict" on_parse_error = "deny" [[file]] path = "settings.py" format = "python-assignments" keys = ["SECRET_KEY", "DATABASES.*.PASSWORD"] "#, ) .unwrap(); assert_eq!(cfg.files.patterns, vec![".env", "secrets.*"]); assert_eq!(cfg.keys.include, vec!["STRIPE_*", "TWILIO_*"]); assert_eq!(cfg.keys.exclude, vec!["PUBLIC_KEY", "SSH_KEY_PATH"]); assert_eq!(cfg.redaction.style, RedactionStyle::Typed); assert!(!cfg.heuristic.enabled); assert_eq!(cfg.heuristic.mode, HeuristicMode::Report); assert_eq!(cfg.heuristic.patterns, vec!["AKIA[A-Z2-7]{16}"]); assert_eq!(cfg.enforcement.mode, EnforcementMode::Strict); assert_eq!(cfg.enforcement.on_parse_error, ParseErrorAction::Deny); assert_eq!(cfg.file_overrides.len(), 1); assert_eq!(cfg.file_overrides[0].path, "settings.py"); assert_eq!( cfg.file_overrides[0].format.as_deref(), Some("python-assignments") ); assert_eq!( cfg.file_overrides[0].keys, vec!["SECRET_KEY", "DATABASES.*.PASSWORD"] ); } #[test] fn empty_toml_returns_defaults() { let cfg = SecretsConfig::from_toml("").unwrap(); assert!(!cfg.files.patterns.is_empty()); assert!(cfg.files.patterns.contains(&".env".to_string())); assert_eq!(cfg.redaction.style, RedactionStyle::Masked); assert!(cfg.heuristic.enabled); assert_eq!(cfg.heuristic.mode, HeuristicMode::Enforce); assert_eq!(cfg.enforcement.mode, EnforcementMode::Permissive); assert_eq!( cfg.enforcement.on_parse_error, ParseErrorAction::MaskEntireFile ); assert!(cfg.file_overrides.is_empty()); } #[test] fn invalid_toml_produces_error() { let result = SecretsConfig::from_toml("this is not valid {{ toml"); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!( err_msg.contains("expected"), "error should describe parse issue: {err_msg}" ); } #[test] fn effective_key_includes_has_builtins() { let cfg = SecretsConfig::default(); let effective = cfg.effective_key_includes(); for builtin in BUILTIN_KEY_PATTERNS { assert!( effective.contains(&builtin.to_string()), "missing builtin: {builtin}" ); } } #[test] fn effective_key_includes_adds_user_patterns() { let cfg = SecretsConfig::from_toml( r#" [keys] include = ["MY_CUSTOM_SECRET_*"] "#, ) .unwrap(); let effective = cfg.effective_key_includes(); assert!(effective.contains(&"MY_CUSTOM_SECRET_*".to_string())); // Builtins still present assert!(effective.contains(&"*PASSWORD*".to_string())); } #[test] fn effective_key_includes_removes_excluded() { let cfg = SecretsConfig::from_toml( r#" [keys] exclude = ["*TOKEN*", "SENTRY_DSN"] "#, ) .unwrap(); let effective = cfg.effective_key_includes(); assert!( !effective.contains(&"*TOKEN*".to_string()), "excluded pattern should be removed" ); assert!( !effective.contains(&"SENTRY_DSN".to_string()), "excluded pattern should be removed" ); // Other builtins still present assert!(effective.contains(&"*PASSWORD*".to_string())); } #[test] fn key_matches_glob_case_insensitive() { let cfg = SecretsConfig::default(); assert!(cfg.key_matches("DATABASE_URL")); assert!(cfg.key_matches("database_url")); assert!(cfg.key_matches("my_password_here")); assert!(cfg.key_matches("MY_PASSWORD_HERE")); assert!(cfg.key_matches("STRIPE_SECRET_KEY")); assert!(cfg.key_matches("AWS_ACCESS_KEY_ID")); } #[test] fn key_matches_non_secret_keys() { let cfg = SecretsConfig::default(); assert!(!cfg.key_matches("DEBUG")); assert!(!cfg.key_matches("LOG_LEVEL")); assert!(!cfg.key_matches("PORT")); assert!(!cfg.key_matches("HOST")); } #[test] fn key_matches_respects_user_include() { let cfg = SecretsConfig::from_toml( r#" [keys] include = ["MY_APP_*"] "#, ) .unwrap(); assert!(cfg.key_matches("MY_APP_SETTING")); assert!(cfg.key_matches("my_app_setting")); } #[test] fn key_matches_respects_user_exclude() { let cfg = SecretsConfig::from_toml( r#" [keys] exclude = ["*TOKEN*"] "#, ) .unwrap(); // TOKEN patterns were excluded, so GITHUB_TOKEN should no longer match // via the *TOKEN* pattern. But it might match via GITHUB_TOKEN literal. // Let's check something that only matched *TOKEN*. assert!(!cfg.key_matches("MY_TOKEN")); // PASSWORD still matches assert!(cfg.key_matches("MY_PASSWORD")); } #[test] fn builtin_file_patterns_present() { let cfg = SecretsConfig::default(); let patterns = &cfg.files.patterns; assert!(patterns.contains(&".env".to_string())); assert!(patterns.contains(&"*.pem".to_string())); assert!(patterns.contains(&".aws/credentials".to_string())); assert!(patterns.contains(&"terraform.tfvars".to_string())); } #[test] fn load_missing_files_returns_defaults() { let tmp = tempfile::tempdir().unwrap(); let cfg = SecretsConfig::load(tmp.path()).unwrap(); assert_eq!(cfg.files.patterns, SecretsConfig::default().files.patterns); assert_eq!(cfg.redaction.style, RedactionStyle::Masked); } #[test] fn load_project_botsecrets() { let tmp = tempfile::tempdir().unwrap(); std::fs::write( tmp.path().join(".botsecrets"), r#" [redaction] style = "named" [keys] include = ["CUSTOM_*"] "#, ) .unwrap(); let cfg = SecretsConfig::load(tmp.path()).unwrap(); assert_eq!(cfg.redaction.style, RedactionStyle::Named); assert!(cfg.effective_key_includes().contains(&"CUSTOM_*".to_string())); // File patterns remain at defaults (not overridden) assert!(cfg.files.patterns.contains(&".env".to_string())); } #[test] fn load_local_overrides_project() { let tmp = tempfile::tempdir().unwrap(); std::fs::write( tmp.path().join(".botsecrets"), r#" [redaction] style = "named" [enforcement] mode = "strict" "#, ) .unwrap(); std::fs::write( tmp.path().join(".botsecrets.local"), r#" [redaction] style = "absent" "#, ) .unwrap(); let cfg = SecretsConfig::load(tmp.path()).unwrap(); // .local overrides .botsecrets for redaction style assert_eq!(cfg.redaction.style, RedactionStyle::Absent); // enforcement from .botsecrets is preserved (not in .local) assert_eq!(cfg.enforcement.mode, EnforcementMode::Strict); } #[test] fn load_invalid_botsecrets_returns_error() { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join(".botsecrets"), "invalid {{ toml").unwrap(); let result = SecretsConfig::load(tmp.path()); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains(".botsecrets"), "error should mention file: {err}"); } #[test] fn merge_keys_accumulate() { let tmp = tempfile::tempdir().unwrap(); std::fs::write( tmp.path().join(".botsecrets"), r#" [keys] include = ["FROM_PROJECT"] exclude = ["EXCLUDE_PROJECT"] "#, ) .unwrap(); std::fs::write( tmp.path().join(".botsecrets.local"), r#" [keys] include = ["FROM_LOCAL"] exclude = ["EXCLUDE_LOCAL"] "#, ) .unwrap(); let cfg = SecretsConfig::load(tmp.path()).unwrap(); assert!(cfg.keys.include.contains(&"FROM_PROJECT".to_string())); assert!(cfg.keys.include.contains(&"FROM_LOCAL".to_string())); assert!(cfg.keys.exclude.contains(&"EXCLUDE_PROJECT".to_string())); assert!(cfg.keys.exclude.contains(&"EXCLUDE_LOCAL".to_string())); } #[test] fn merge_file_patterns_replaced_not_appended() { let tmp = tempfile::tempdir().unwrap(); std::fs::write( tmp.path().join(".botsecrets"), r#" [files] patterns = ["only-this.env"] "#, ) .unwrap(); let cfg = SecretsConfig::load(tmp.path()).unwrap(); assert_eq!(cfg.files.patterns, vec!["only-this.env"]); // Defaults should be gone, replaced by the project's list assert!(!cfg.files.patterns.contains(&".env".to_string())); } #[test] fn all_redaction_styles_parse() { for (input, expected) in [ ("masked", RedactionStyle::Masked), ("typed", RedactionStyle::Typed), ("named", RedactionStyle::Named), ("absent", RedactionStyle::Absent), ] { let toml_str = format!("[redaction]\nstyle = \"{input}\""); let cfg = SecretsConfig::from_toml(&toml_str).unwrap(); assert_eq!(cfg.redaction.style, expected, "failed for: {input}"); } } #[test] fn all_enforcement_modes_parse() { for (input, expected) in [ ("strict", EnforcementMode::Strict), ("permissive", EnforcementMode::Permissive), ("audit", EnforcementMode::Audit), ] { let toml_str = format!("[enforcement]\nmode = \"{input}\""); let cfg = SecretsConfig::from_toml(&toml_str).unwrap(); assert_eq!(cfg.enforcement.mode, expected, "failed for: {input}"); } } #[test] fn all_heuristic_modes_parse() { for (input, expected) in [ ("enforce", HeuristicMode::Enforce), ("report", HeuristicMode::Report), ("disabled", HeuristicMode::Disabled), ] { let toml_str = format!("[heuristic]\nmode = \"{input}\""); let cfg = SecretsConfig::from_toml(&toml_str).unwrap(); assert_eq!(cfg.heuristic.mode, expected, "failed for: {input}"); } } #[test] fn serialization_roundtrip() { let cfg = SecretsConfig::from_toml( r#" [files] patterns = [".env"] [redaction] style = "typed" [enforcement] mode = "audit" on_parse_error = "allow" "#, ) .unwrap(); let serialized = toml::to_string(&cfg).unwrap(); let deserialized: SecretsConfig = toml::from_str(&serialized).unwrap(); assert_eq!(deserialized.redaction.style, RedactionStyle::Typed); assert_eq!(deserialized.enforcement.mode, EnforcementMode::Audit); assert_eq!( deserialized.enforcement.on_parse_error, ParseErrorAction::Allow ); }