mod common;
use common::TestSettings;
use rcman::{SettingsConfig, SettingsManager};
use serde_json::json;
use std::sync::atomic::{AtomicU32, Ordering};
use tempfile::TempDir;
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn unique_app_name() -> String {
let count = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
format!("rcman-test-{}-{}", std::process::id(), count)
}
fn create_manager_with_credentials() -> (TempDir, SettingsManager<rcman::JsonStorage, TestSettings>)
{
let temp_dir = TempDir::new().unwrap();
let app_name = unique_app_name();
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
(temp_dir, manager)
}
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_secret_not_in_json_file() {
let (temp_dir, manager) = create_manager_with_credentials();
let _ = manager.get_all().unwrap();
manager
.save_setting("api", "key", &json!("super_secret_api_key_123"))
.unwrap();
let settings_path = temp_dir.path().join("settings.json");
if settings_path.exists() {
let content = std::fs::read_to_string(&settings_path).unwrap();
assert!(!content.contains("super_secret_api_key_123"));
assert!(!content.contains("api.key"));
}
}
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_secret_retrieved_correctly() {
let (_temp_dir, manager) = create_manager_with_credentials();
let _ = manager.get_all().unwrap();
manager
.save_setting("api", "key", &json!("my_secret_value"))
.unwrap();
let metadata = manager.metadata().unwrap();
let api_key_meta = metadata.get("api.key").unwrap();
assert_eq!(api_key_meta.value, Some(json!("my_secret_value")));
}
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_reset_secret_clears_value() {
let (_temp_dir, manager) = create_manager_with_credentials();
let _ = manager.get_all().unwrap();
manager
.save_setting("api", "key", &json!("secret_to_reset"))
.unwrap();
let metadata = manager.metadata().unwrap();
assert_eq!(
metadata.get("api.key").unwrap().value,
Some(json!("secret_to_reset"))
);
let default_value = manager.reset_setting("api", "key").unwrap();
assert_eq!(default_value, json!(""));
let metadata = manager.metadata().unwrap();
assert_eq!(metadata.get("api.key").unwrap().value, Some(json!("")));
}
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_reset_all_clears_secrets() {
let (_temp_dir, manager) = create_manager_with_credentials();
let _ = manager.get_all().unwrap();
manager
.save_setting("api", "key", &json!("will_be_cleared"))
.unwrap();
manager.reset_all().unwrap();
let metadata = manager.metadata().unwrap();
let api_key_value = metadata.get("api.key").unwrap().value.clone();
assert_eq!(api_key_value, Some(json!("")));
}
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_secret_default_not_stored() {
let (_temp_dir, manager) = create_manager_with_credentials();
let _ = manager.get_all().unwrap();
manager
.save_setting("api", "key", &json!("temporary_key"))
.unwrap();
manager.save_setting("api", "key", &json!("")).unwrap();
let metadata = manager.metadata().unwrap();
assert_eq!(metadata.get("api.key").unwrap().value, Some(json!("")));
}
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_multiple_secrets() {
let (_temp_dir, manager) = create_manager_with_credentials();
let _ = manager.get_all().unwrap();
manager
.save_setting("api", "key", &json!("secret1"))
.unwrap();
manager
.save_setting("ui", "theme", &json!("light"))
.unwrap();
let metadata = manager.metadata().unwrap();
assert_eq!(
metadata.get("api.key").unwrap().value,
Some(json!("secret1"))
);
assert_eq!(
metadata.get("ui.theme").unwrap().value,
Some(json!("light"))
);
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
fn test_credentials_manager_available() {
let (_temp_dir, manager) = create_manager_with_credentials();
assert!(manager.credentials().is_some());
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
fn test_credentials_not_available_when_disabled() {
let temp_dir = TempDir::new().unwrap();
let app_name = unique_app_name();
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.build();
let manager = SettingsManager::new(config).unwrap();
assert!(manager.credentials().is_none());
}
#[cfg(all(feature = "keychain", any(target_os = "android", target_os = "ios")))]
#[test]
fn test_mobile_keychain_store_retrieve_remove() {
let (_temp_dir, manager) = create_manager_with_credentials();
manager
.save_setting("api", "key", &json!("mobile_secret_123"))
.unwrap();
let metadata = manager.metadata().unwrap();
assert_eq!(
metadata.get("api.key").unwrap().value,
Some(json!("mobile_secret_123"))
);
let reset_val = manager.reset_setting("api", "key").unwrap();
assert!(reset_val == json!("") || reset_val.is_null());
}
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_secret_persists_across_sessions() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().to_path_buf();
let app_name = unique_app_name();
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(&config_path)
.with_schema::<TestSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager.get_all().unwrap();
manager
.save_setting("api", "key", &json!("persistent_secret"))
.unwrap();
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(&config_path)
.with_schema::<TestSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
let metadata = manager.metadata().unwrap();
let value = metadata.get("api.key").unwrap().value.clone();
#[cfg(feature = "keychain")]
assert_eq!(value, Some(json!("persistent_secret")));
#[cfg(not(feature = "keychain"))]
let _ = value;
}
}
#[test]
fn test_secret_has_correct_metadata() {
let (_temp_dir, manager) = create_manager_with_credentials();
let metadata = manager.metadata().unwrap();
let api_key_meta = metadata.get("api.key").unwrap();
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
assert!(api_key_meta.is_secret());
#[cfg(not(any(feature = "keychain", feature = "encrypted-file")))]
assert!(!api_key_meta.is_secret());
let theme_meta = metadata.get("ui.theme").unwrap();
assert!(!theme_meta.is_secret());
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
#[test]
fn test_encrypted_fallback_with_env_password() {
use rcman::SecretPasswordSource;
let temp_dir = TempDir::new().unwrap();
let app_name = unique_app_name();
let fallback_path = temp_dir.path().join("secrets.enc.json");
let env_var = format!("PASS_{}", unique_app_name().replace("-", "_"));
unsafe { std::env::set_var(&env_var, "super-secure-password") };
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_encrypted_fallback(
&fallback_path,
SecretPasswordSource::Environment(env_var.clone()),
)
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.save_setting("api", "key", &json!("secret-value"))
.unwrap();
let val = manager.get_value("api.key").unwrap();
assert_eq!(val, json!("secret-value"));
unsafe { std::env::remove_var(&env_var) };
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
#[test]
fn test_encrypted_fallback_with_file_password() {
use rcman::SecretPasswordSource;
let temp_dir = TempDir::new().unwrap();
let app_name = unique_app_name();
let fallback_path = temp_dir.path().join("secrets.enc.json");
let password_path = temp_dir.path().join("password.txt");
std::fs::write(&password_path, "file-password-123").unwrap();
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<TestSettings>()
.with_encrypted_fallback(&fallback_path, SecretPasswordSource::File(password_path))
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.save_setting("api", "key", &json!("secret-value"))
.unwrap();
let val = manager.get_value("api.key").unwrap();
assert_eq!(val, json!("secret-value"));
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
struct MigrationNormalSettings {
pub api: MigrationApiSettings,
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
struct MigrationApiSettings {
pub key: String,
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
impl rcman::SettingsSchema for MigrationNormalSettings {
fn get_metadata() -> std::collections::HashMap<String, rcman::SettingMetadata> {
rcman::settings! {
"api.key" => rcman::SettingMetadata::text("")
.meta_str("label", "API Key")
}
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
struct MigrationSecretSettings {
pub api: MigrationApiSettings,
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
impl rcman::SettingsSchema for MigrationSecretSettings {
fn get_metadata() -> std::collections::HashMap<String, rcman::SettingMetadata> {
rcman::settings! {
"api.key" => {
let s = rcman::SettingMetadata::text("")
.meta_str("label", "API Key");
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
let s = s.secret();
s
}
}
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
fn test_migration_normal_to_secret() {
let temp_dir = tempfile::TempDir::new().unwrap();
let app_name = unique_app_name();
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<MigrationNormalSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.save_setting("api", "key", &json!("my-plain-text-key"))
.unwrap();
let settings_path = temp_dir.path().join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap();
assert!(content.contains("my-plain-text-key"));
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<MigrationSecretSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
let creds = manager.credentials().unwrap();
let stored_secret = creds.get("api.key").unwrap();
assert_eq!(stored_secret, Some("my-plain-text-key".to_string()));
let settings_path = temp_dir.path().join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap();
assert!(!content.contains("my-plain-text-key"));
assert!(!content.contains("api.key"));
let val: String = manager.get("api.key").unwrap();
assert_eq!(val, "my-plain-text-key");
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_migration_secret_to_normal() {
let temp_dir = tempfile::TempDir::new().unwrap();
let app_name = unique_app_name();
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<MigrationSecretSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.save_setting("api", "key", &json!("my-secret-key-value"))
.unwrap();
let settings_path = temp_dir.path().join("settings.json");
if settings_path.exists() {
let content = std::fs::read_to_string(&settings_path).unwrap();
assert!(!content.contains("my-secret-key-value"));
}
let creds = manager.credentials().unwrap();
assert_eq!(
creds.get("api.key").unwrap(),
Some("my-secret-key-value".to_string())
);
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<MigrationNormalSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
let settings_path = temp_dir.path().join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap();
assert!(content.contains("my-secret-key-value"));
let creds = manager.credentials().unwrap();
assert_eq!(creds.get("api.key").unwrap(), None);
let val: String = manager.get("api.key").unwrap();
assert_eq!(val, "my-secret-key-value");
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_migration_upgrade_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let app_name = unique_app_name();
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<MigrationSecretSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.save_setting("api", "key", &json!("old-version-secret"))
.unwrap();
let creds = manager.credentials().unwrap();
assert_eq!(
creds.get("api.key").unwrap(),
Some("old-version-secret".to_string())
);
creds.remove("__rcman_secrets__").unwrap();
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<MigrationSecretSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
let creds = manager.credentials().unwrap();
let list_str = creds.get("__rcman_secrets__").unwrap().unwrap();
let list: Vec<String> = serde_json::from_str(&list_str).unwrap();
assert!(list.contains(&"api.key".to_string()));
let val: String = manager.get("api.key").unwrap();
assert_eq!(val, "old-version-secret");
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_schema::<MigrationNormalSettings>()
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
let settings_path = temp_dir.path().join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap();
assert!(content.contains("old-version-secret"));
let creds = manager.credentials().unwrap();
assert_eq!(creds.get("api.key").unwrap(), None);
let list_str = creds.get("__rcman_secrets__").unwrap().unwrap();
let list: Vec<String> = serde_json::from_str(&list_str).unwrap();
assert!(list.is_empty());
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
struct SubMigrationNormalSettings {
pub host: String,
pub token: String,
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
impl rcman::SettingsSchema for SubMigrationNormalSettings {
fn get_metadata() -> std::collections::HashMap<String, rcman::SettingMetadata> {
rcman::settings! {
"host" => rcman::SettingMetadata::text(""),
"token" => rcman::SettingMetadata::text("")
}
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
struct SubMigrationSecretSettings {
pub host: String,
pub token: String,
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
impl rcman::SettingsSchema for SubMigrationSecretSettings {
fn get_metadata() -> std::collections::HashMap<String, rcman::SettingMetadata> {
rcman::settings! {
"host" => rcman::SettingMetadata::text(""),
"token" => {
let s = rcman::SettingMetadata::text("");
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
let s = s.secret();
s
}
}
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_subsettings_migration() {
let temp_dir = tempfile::TempDir::new().unwrap();
let app_name = unique_app_name();
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.register_sub_settings(
rcman::SubSettingsConfig::new("remotes")
.with_schema::<SubMigrationNormalSettings>(),
)
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
remotes
.set(
"gdrive",
&json!({
"host": "example.com",
"token": "plain_token_123"
}),
)
.unwrap();
let file_path = temp_dir.path().join("remotes").join("gdrive.json");
let content = std::fs::read_to_string(&file_path).unwrap();
assert!(content.contains("plain_token_123"));
assert!(content.contains("example.com"));
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.register_sub_settings(
rcman::SubSettingsConfig::new("remotes")
.with_schema::<SubMigrationSecretSettings>(),
)
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let file_path = temp_dir.path().join("remotes").join("gdrive.json");
let content = std::fs::read_to_string(&file_path).unwrap();
assert!(!content.contains("plain_token_123"));
assert!(content.contains("example.com"));
let creds = manager.credentials().unwrap();
let stored_secret = creds.get("sub.remotes.gdrive.token").unwrap();
assert_eq!(stored_secret, Some("plain_token_123".to_string()));
let val: serde_json::Value = remotes.get_value("gdrive").unwrap();
assert_eq!(val["token"], json!("plain_token_123"));
assert_eq!(val["host"], json!("example.com"));
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.register_sub_settings(
rcman::SubSettingsConfig::new("remotes")
.with_schema::<SubMigrationNormalSettings>(),
)
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let file_path = temp_dir.path().join("remotes").join("gdrive.json");
let content = std::fs::read_to_string(&file_path).unwrap();
assert!(content.contains("plain_token_123"));
assert!(content.contains("example.com"));
let creds = manager.credentials().unwrap();
assert_eq!(creds.get("sub.remotes.gdrive.token").unwrap(), None);
let val: serde_json::Value = remotes.get_value("gdrive").unwrap();
assert_eq!(val["token"], json!("plain_token_123"));
assert_eq!(val["host"], json!("example.com"));
}
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[test]
#[ignore = "Requires Secret Service daemon (not available in CI)"]
fn test_subsettings_migration_upgrade_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let app_name = unique_app_name();
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.register_sub_settings(
rcman::SubSettingsConfig::new("remotes")
.with_schema::<SubMigrationSecretSettings>(),
)
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
remotes
.set(
"gdrive",
&json!({
"host": "example.com",
"token": "upgrade-secret-token"
}),
)
.unwrap();
let creds = manager.credentials().unwrap();
assert_eq!(
creds.get("sub.remotes.gdrive.token").unwrap(),
Some("upgrade-secret-token".to_string())
);
creds.remove("__rcman_secrets__").unwrap();
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.register_sub_settings(
rcman::SubSettingsConfig::new("remotes")
.with_schema::<SubMigrationSecretSettings>(),
)
.unwrap();
let remotes = manager.sub_settings("remotes").unwrap();
let creds = manager.credentials().unwrap();
let list_str = creds.get("__rcman_secrets__").unwrap().unwrap();
let list: Vec<String> = serde_json::from_str(&list_str).unwrap();
assert!(list.contains(&"sub.remotes.gdrive.token".to_string()));
let val: serde_json::Value = remotes.get_value("gdrive").unwrap();
assert_eq!(val["token"], json!("upgrade-secret-token"));
}
{
let config = SettingsConfig::builder(&app_name, "1.0.0")
.with_config_dir(temp_dir.path())
.with_credentials()
.build();
let manager = SettingsManager::new(config).unwrap();
manager
.register_sub_settings(
rcman::SubSettingsConfig::new("remotes")
.with_schema::<SubMigrationNormalSettings>(),
)
.unwrap();
let _remotes = manager.sub_settings("remotes").unwrap();
let file_path = temp_dir.path().join("remotes").join("gdrive.json");
let content = std::fs::read_to_string(&file_path).unwrap();
assert!(content.contains("upgrade-secret-token"));
let creds = manager.credentials().unwrap();
assert_eq!(creds.get("sub.remotes.gdrive.token").unwrap(), None);
let list_str = creds.get("__rcman_secrets__").unwrap().unwrap();
let list: Vec<String> = serde_json::from_str(&list_str).unwrap();
assert!(!list.contains(&"sub.remotes.gdrive.token".to_string()));
}
}