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:
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(_))));
+ }
}