lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit de8ed19babdd7a49ff25993cca6f4e97d8d1a6f8
parent 7fe21a8408db16e3074f7b3fa456497c804d2f29
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Feb 2026 05:26:58 +0000

coverage: raise `radroots-nostr-accounts` to strict 100 gates

Diffstat:
Mcrates/nostr-accounts/src/error.rs | 22++++++++++++++++++++++
Mcrates/nostr-accounts/src/manager.rs | 540++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr-accounts/src/store.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr-accounts/src/vault.rs | 26++++++++++++++++++++++++++
4 files changed, 631 insertions(+), 1 deletion(-)

diff --git a/crates/nostr-accounts/src/error.rs b/crates/nostr-accounts/src/error.rs @@ -37,3 +37,25 @@ impl From<radroots_runtime::RuntimeJsonError> for RadrootsNostrAccountsError { Self::Store(value.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + use radroots_identity::IdentityError; + use radroots_runtime::RuntimeJsonError; + use std::path::PathBuf; + + #[test] + fn converts_identity_error() { + let source = IdentityError::PublicKeyMismatch; + let converted: RadrootsNostrAccountsError = source.into(); + assert!(matches!(converted, RadrootsNostrAccountsError::Identity(_))); + } + + #[test] + fn converts_runtime_json_error() { + let source = RuntimeJsonError::NotFound(PathBuf::from("accounts.json")); + let converted: RadrootsNostrAccountsError = source.into(); + assert!(matches!(converted, RadrootsNostrAccountsError::Store(_))); + } +} diff --git a/crates/nostr-accounts/src/manager.rs b/crates/nostr-accounts/src/manager.rs @@ -289,9 +289,153 @@ fn now_unix_secs() -> u64 { #[cfg(test)] mod tests { use super::*; - use crate::store::RadrootsNostrFileAccountStore; + use crate::store::{ + RadrootsNostrAccountStore, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore, + }; + use crate::vault::RadrootsNostrSecretVault; use crate::vault::RadrootsNostrSecretVaultMemory; + use radroots_identity::RadrootsIdentityProfile; use std::sync::Arc; + use std::sync::RwLock; + use std::thread; + + struct LoadErrorStore; + + impl RadrootsNostrAccountStore for LoadErrorStore { + fn load(&self) -> Result<RadrootsNostrAccountStoreState, RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Store( + "store load failed".into(), + )) + } + + fn save( + &self, + _state: &RadrootsNostrAccountStoreState, + ) -> Result<(), RadrootsNostrAccountsError> { + Ok(()) + } + } + + struct SaveErrorStore { + state: RwLock<RadrootsNostrAccountStoreState>, + } + + impl SaveErrorStore { + fn new(state: RadrootsNostrAccountStoreState) -> Self { + Self { + state: RwLock::new(state), + } + } + } + + impl RadrootsNostrAccountStore for SaveErrorStore { + fn load(&self) -> Result<RadrootsNostrAccountStoreState, RadrootsNostrAccountsError> { + let guard = self.state.read().map_err(|_| { + RadrootsNostrAccountsError::Store("save error store poisoned".into()) + })?; + Ok(guard.clone()) + } + + fn save( + &self, + _state: &RadrootsNostrAccountStoreState, + ) -> Result<(), RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Store( + "store save failed".into(), + )) + } + } + + struct VaultStoreError; + + impl RadrootsNostrSecretVault for VaultStoreError { + fn store_secret_hex( + &self, + _account_id: &RadrootsIdentityId, + _secret_key_hex: &str, + ) -> Result<(), RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Vault( + "vault store failed".into(), + )) + } + + fn load_secret_hex( + &self, + _account_id: &RadrootsIdentityId, + ) -> Result<Option<String>, RadrootsNostrAccountsError> { + Ok(None) + } + + fn remove_secret( + &self, + _account_id: &RadrootsIdentityId, + ) -> Result<(), RadrootsNostrAccountsError> { + Ok(()) + } + } + + struct VaultLoadError; + + impl RadrootsNostrSecretVault for VaultLoadError { + fn store_secret_hex( + &self, + _account_id: &RadrootsIdentityId, + _secret_key_hex: &str, + ) -> Result<(), RadrootsNostrAccountsError> { + Ok(()) + } + + fn load_secret_hex( + &self, + _account_id: &RadrootsIdentityId, + ) -> Result<Option<String>, RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Vault( + "vault load failed".into(), + )) + } + + fn remove_secret( + &self, + _account_id: &RadrootsIdentityId, + ) -> Result<(), RadrootsNostrAccountsError> { + Ok(()) + } + } + + struct VaultInvalidSecret; + + impl RadrootsNostrSecretVault for VaultInvalidSecret { + fn store_secret_hex( + &self, + _account_id: &RadrootsIdentityId, + _secret_key_hex: &str, + ) -> Result<(), RadrootsNostrAccountsError> { + Ok(()) + } + + fn load_secret_hex( + &self, + _account_id: &RadrootsIdentityId, + ) -> Result<Option<String>, RadrootsNostrAccountsError> { + Ok(Some("invalid-secret".to_string())) + } + + fn remove_secret( + &self, + _account_id: &RadrootsIdentityId, + ) -> Result<(), RadrootsNostrAccountsError> { + Ok(()) + } + } + + fn poison_manager_state(manager: &RadrootsNostrAccountsManager) { + let state = manager.state.clone(); + let _ = thread::spawn(move || { + let _guard = state.write().expect("write"); + panic!("poison manager state"); + }) + .join(); + } #[test] fn manager_persists_selection_and_restores_signing_identity() { @@ -397,4 +541,398 @@ mod tests { .expect("account"); assert_eq!(record.label.as_deref(), Some("primary")); } + + #[test] + fn new_rejects_unsupported_schema_version() { + let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); + let mut state = RadrootsNostrAccountStoreState::default(); + state.version = crate::model::RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION + 1; + store.save(&state).expect("save"); + + let result = RadrootsNostrAccountsManager::new(store, vault); + assert!(matches!( + result, + Err(RadrootsNostrAccountsError::InvalidState(_)) + )); + } + + #[test] + fn new_clears_orphaned_selected_account() { + let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); + let mut state = RadrootsNostrAccountStoreState::default(); + state.selected_account_id = Some(RadrootsIdentity::generate().id()); + store.save(&state).expect("save"); + + let manager = RadrootsNostrAccountsManager::new(store, vault).expect("manager"); + assert!(manager.selected_account_id().expect("selected").is_none()); + } + + #[test] + fn selected_methods_return_none_when_state_is_empty() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + assert!( + manager + .selected_account() + .expect("selected account") + .is_none() + ); + assert!( + manager + .selected_public_identity() + .expect("selected public") + .is_none() + ); + assert!( + manager + .selected_signing_identity() + .expect("selected signing") + .is_none() + ); + + let missing_id = RadrootsIdentity::generate().id(); + assert!( + manager + .get_signing_identity(&missing_id) + .expect("signing") + .is_none() + ); + } + + #[test] + fn select_remove_export_and_lookup_paths() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let first_id = manager + .generate_identity(Some("first".into()), true) + .expect("first"); + let second_id = manager + .generate_identity(Some("second".into()), false) + .expect("second"); + + manager.select_account(&second_id).expect("select second"); + assert_eq!( + manager.selected_account_id().expect("selected"), + Some(second_id.clone()) + ); + assert!( + manager + .export_secret_hex(&second_id) + .expect("export") + .is_some() + ); + assert!( + manager + .get_signing_identity(&second_id) + .expect("signing") + .is_some() + ); + + manager.remove_account(&second_id).expect("remove second"); + assert_eq!( + manager.selected_account_id().expect("selected"), + Some(first_id) + ); + assert!( + manager + .export_secret_hex(&second_id) + .expect("export after remove") + .is_none() + ); + + let select_missing = manager.select_account(&second_id); + assert!(matches!( + select_missing, + Err(RadrootsNostrAccountsError::AccountNotFound(_)) + )); + let remove_missing = manager.remove_account(&second_id); + assert!(matches!( + remove_missing, + Err(RadrootsNostrAccountsError::AccountNotFound(_)) + )); + } + + #[test] + fn upsert_public_identity_updates_label_and_respects_selection_flag() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + manager + .generate_identity(Some("primary".into()), true) + .expect("generate"); + + let existing = manager + .selected_public_identity() + .expect("selected public") + .expect("public"); + manager + .upsert_public_identity(existing.clone(), Some("renamed".into()), false) + .expect("upsert existing"); + + let renamed = manager + .list_accounts() + .expect("list") + .into_iter() + .find(|record| record.account_id == existing.id) + .expect("record"); + assert_eq!(renamed.label.as_deref(), Some("renamed")); + + let watch_only = RadrootsIdentity::generate().to_public(); + let watch_id = watch_only.id.clone(); + let make_selected = manager.selected_account_id().expect("selected").is_some(); + manager + .upsert_public_identity(watch_only, Some("watch".into()), make_selected) + .expect("upsert watch"); + assert_eq!( + manager.selected_account_id().expect("selected"), + Some(watch_id) + ); + } + + #[test] + fn remove_non_selected_account_keeps_current_selection() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let selected_id = manager + .generate_identity(Some("selected".into()), true) + .expect("selected"); + let removable_id = manager + .generate_identity(Some("removable".into()), false) + .expect("removable"); + + manager.remove_account(&removable_id).expect("remove"); + assert_eq!( + manager.selected_account_id().expect("selected"), + Some(selected_id) + ); + } + + #[test] + fn resolve_signing_identity_mismatch_and_profile_paths() { + let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); + let manager = RadrootsNostrAccountsManager::new(store, vault.clone()).expect("manager"); + + let mismatch_public = RadrootsIdentity::generate().to_public(); + let mismatch_id = mismatch_public.id.clone(); + manager + .upsert_public_identity(mismatch_public, Some("mismatch".into()), true) + .expect("upsert mismatch"); + + let wrong_identity = RadrootsIdentity::generate(); + vault + .store_secret_hex(&mismatch_id, wrong_identity.secret_key_hex().as_str()) + .expect("vault store"); + + let mismatch = manager.selected_signing_identity(); + assert!(matches!( + mismatch, + Err(RadrootsNostrAccountsError::PublicKeyMismatch) + )); + + let mut with_profile = RadrootsIdentity::generate(); + let profile = RadrootsIdentityProfile { + identifier: Some("profile-id".to_string()), + ..RadrootsIdentityProfile::default() + }; + with_profile.set_profile(profile); + let profile_id = manager + .upsert_identity(&with_profile, Some("profile".into()), true) + .expect("upsert profile"); + let resolved = manager + .get_signing_identity(&profile_id) + .expect("resolve") + .expect("identity"); + assert_eq!( + resolved + .profile() + .and_then(|value| value.identifier.clone()) + .as_deref(), + Some("profile-id") + ); + } + + #[test] + fn manager_propagates_store_and_vault_errors() { + let load_error = RadrootsNostrAccountsManager::new( + Arc::new(LoadErrorStore), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ); + assert!(matches!( + load_error, + Err(RadrootsNostrAccountsError::Store(_)) + )); + + let save_error_store = Arc::new(SaveErrorStore::new( + RadrootsNostrAccountStoreState::default(), + )); + let save_error_manager = RadrootsNostrAccountsManager::new( + save_error_store, + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("manager"); + let save_error = save_error_manager.upsert_public_identity( + RadrootsIdentity::generate().to_public(), + None, + true, + ); + assert!(matches!( + save_error, + Err(RadrootsNostrAccountsError::Store(_)) + )); + + let vault_store_error_manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(VaultStoreError), + ) + .expect("manager"); + let identity = RadrootsIdentity::generate(); + let vault_store_error = vault_store_error_manager.upsert_identity(&identity, None, true); + assert!(matches!( + vault_store_error, + Err(RadrootsNostrAccountsError::Vault(_)) + )); + + let mut load_error_state = RadrootsNostrAccountStoreState::default(); + let load_error_public = RadrootsIdentity::generate().to_public(); + load_error_state + .accounts + .push(RadrootsNostrAccountRecord::new( + load_error_public.clone(), + Some("watch".into()), + 1, + )); + load_error_state.selected_account_id = Some(load_error_public.id.clone()); + let load_error_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + load_error_store + .save(&load_error_state) + .expect("save state"); + let vault_load_error_manager = + RadrootsNostrAccountsManager::new(load_error_store, Arc::new(VaultLoadError)) + .expect("manager"); + let vault_load_error = vault_load_error_manager.selected_signing_identity(); + assert!(matches!( + vault_load_error, + Err(RadrootsNostrAccountsError::Vault(_)) + )); + + let mut invalid_secret_state = RadrootsNostrAccountStoreState::default(); + let invalid_secret_public = RadrootsIdentity::generate().to_public(); + invalid_secret_state + .accounts + .push(RadrootsNostrAccountRecord::new( + invalid_secret_public.clone(), + Some("invalid".into()), + 1, + )); + invalid_secret_state.selected_account_id = Some(invalid_secret_public.id.clone()); + let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + invalid_secret_store + .save(&invalid_secret_state) + .expect("save state"); + let invalid_secret_manager = + RadrootsNostrAccountsManager::new(invalid_secret_store, Arc::new(VaultInvalidSecret)) + .expect("manager"); + let invalid_secret = invalid_secret_manager.selected_signing_identity(); + assert!(matches!( + invalid_secret, + Err(RadrootsNostrAccountsError::Identity(_)) + )); + } + + #[test] + fn migrate_legacy_identity_file_returns_error_for_missing_path() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let temp = tempfile::tempdir().expect("tempdir"); + let missing = temp.path().join("missing_legacy.json"); + let migrated = manager.migrate_legacy_identity_file(&missing, None, false); + assert!(matches!( + migrated, + Err(RadrootsNostrAccountsError::Identity(_)) + )); + } + + #[test] + fn manager_reports_poisoned_state_locks() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + poison_manager_state(&manager); + + assert!(matches!( + manager.list_accounts(), + Err(RadrootsNostrAccountsError::Store(_)) + )); + assert!(matches!( + manager.selected_account_id(), + Err(RadrootsNostrAccountsError::Store(_)) + )); + assert!(matches!( + manager.selected_account(), + Err(RadrootsNostrAccountsError::Store(_)) + )); + + let account_id = RadrootsIdentity::generate().id(); + assert!(matches!( + manager.get_signing_identity(&account_id), + Err(RadrootsNostrAccountsError::Store(_)) + )); + assert!(matches!( + manager.select_account(&account_id), + Err(RadrootsNostrAccountsError::Store(_)) + )); + assert!(matches!( + manager.remove_account(&account_id), + Err(RadrootsNostrAccountsError::Store(_)) + )); + assert!(matches!( + manager.upsert_public_identity(RadrootsIdentity::generate().to_public(), None, false), + Err(RadrootsNostrAccountsError::Store(_)) + )); + } + + #[test] + fn stub_store_and_vault_methods_are_exercised() { + let load_error_store = LoadErrorStore; + let load_error_store_result = + load_error_store.save(&RadrootsNostrAccountStoreState::default()); + assert!(load_error_store_result.is_ok()); + + let save_error_store = SaveErrorStore::new(RadrootsNostrAccountStoreState::default()); + let loaded = save_error_store.load().expect("load"); + assert_eq!( + loaded.version, + RadrootsNostrAccountStoreState::default().version + ); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = save_error_store.state.write().expect("write"); + panic!("poison save error store"); + })); + let poisoned_load = save_error_store.load(); + assert!(matches!( + poisoned_load, + Err(RadrootsNostrAccountsError::Store(_)) + )); + + let account_id = RadrootsIdentity::generate().id(); + let vault_store_error = VaultStoreError; + assert!( + vault_store_error + .load_secret_hex(&account_id) + .expect("load") + .is_none() + ); + vault_store_error + .remove_secret(&account_id) + .expect("remove"); + + let vault_load_error = VaultLoadError; + vault_load_error + .store_secret_hex(&account_id, "secret") + .expect("store"); + vault_load_error.remove_secret(&account_id).expect("remove"); + + let vault_invalid_secret = VaultInvalidSecret; + vault_invalid_secret + .store_secret_hex(&account_id, "secret") + .expect("store"); + vault_invalid_secret + .remove_secret(&account_id) + .expect("remove"); + } } diff --git a/crates/nostr-accounts/src/store.rs b/crates/nostr-accounts/src/store.rs @@ -89,6 +89,7 @@ impl RadrootsNostrAccountStore for RadrootsNostrMemoryAccountStore { #[cfg(test)] mod tests { use super::*; + use std::thread; #[test] fn file_store_round_trip() { @@ -102,4 +103,47 @@ mod tests { assert_eq!(loaded.version, state.version); assert!(loaded.accounts.is_empty()); } + + #[test] + fn file_store_load_missing_and_path_accessor() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("missing.json"); + let store = RadrootsNostrFileAccountStore::new(path.as_path()); + + assert_eq!(store.path(), path.as_path()); + let loaded = store.load().expect("load"); + assert_eq!( + loaded.version, + RadrootsNostrAccountStoreState::default().version + ); + assert!(loaded.accounts.is_empty()); + } + + #[test] + fn memory_store_round_trip() { + let store = RadrootsNostrMemoryAccountStore::new(); + let state = RadrootsNostrAccountStoreState::default(); + store.save(&state).expect("save"); + + let loaded = store.load().expect("load"); + assert_eq!(loaded.version, state.version); + assert_eq!(loaded.selected_account_id, state.selected_account_id); + } + + #[test] + fn memory_store_reports_poisoned_lock() { + let store = RadrootsNostrMemoryAccountStore::new(); + let shared = store.state.clone(); + let _ = thread::spawn(move || { + let _guard = shared.write().expect("write"); + panic!("poison memory store"); + }) + .join(); + + let load = store.load(); + assert!(matches!(load, Err(RadrootsNostrAccountsError::Store(_)))); + + let save = store.save(&RadrootsNostrAccountStoreState::default()); + assert!(matches!(save, Err(RadrootsNostrAccountsError::Store(_)))); + } } diff --git a/crates/nostr-accounts/src/vault.rs b/crates/nostr-accounts/src/vault.rs @@ -134,6 +134,7 @@ impl RadrootsNostrSecretVault for RadrootsNostrSecretVaultOsKeyring { mod tests { use super::*; use radroots_identity::RadrootsIdentityId; + use std::thread; #[test] fn memory_vault_round_trip() { @@ -151,4 +152,29 @@ mod tests { let loaded = vault.load_secret_hex(&account_id).expect("load"); assert!(loaded.is_none()); } + + #[test] + fn memory_vault_reports_poisoned_lock() { + let vault = RadrootsNostrSecretVaultMemory::new(); + let account_id = RadrootsIdentityId::parse( + "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", + ) + .expect("account id"); + + let shared = vault.entries.clone(); + let _ = thread::spawn(move || { + let _guard = shared.write().expect("write"); + panic!("poison memory vault"); + }) + .join(); + + let store = vault.store_secret_hex(&account_id, "abc123"); + assert!(matches!(store, Err(RadrootsNostrAccountsError::Vault(_)))); + + let load = vault.load_secret_hex(&account_id); + assert!(matches!(load, Err(RadrootsNostrAccountsError::Vault(_)))); + + let remove = vault.remove_secret(&account_id); + assert!(matches!(remove, Err(RadrootsNostrAccountsError::Vault(_)))); + } }