app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

accounts.rs (28189B)


      1 use std::{
      2     fs,
      3     path::{Path, PathBuf},
      4 };
      5 
      6 use radroots_app_core::AppSharedAccountsPaths;
      7 use radroots_app_sqlite::{AppSqliteError, AppSqliteStore};
      8 use radroots_app_view::{
      9     AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, AppIdentityProjection,
     10     FarmId, FarmerActivationProjection, SelectedAccountProjection, SelectedSurfaceProjection,
     11 };
     12 use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityId};
     13 use radroots_nostr_accounts::prelude::{
     14     RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError,
     15     RadrootsNostrAccountsManager,
     16 };
     17 use radroots_secret_vault::{
     18     RadrootsHostVaultCapabilities, RadrootsSecretBackend, RadrootsSecretBackendAvailability,
     19     RadrootsSecretBackendSelection,
     20 };
     21 use thiserror::Error;
     22 
     23 pub struct DesktopAccountsBootstrap {
     24     pub accounts_manager: Option<RadrootsNostrAccountsManager>,
     25 }
     26 
     27 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     28 pub enum DesktopLocalIdentityImportMode {
     29     RawSecretKey,
     30     EncryptedSecretKey,
     31 }
     32 
     33 #[derive(Clone, Debug, Eq, PartialEq)]
     34 pub struct DesktopLocalIdentityImportRequest {
     35     pub mode: DesktopLocalIdentityImportMode,
     36     pub secret_text: String,
     37     pub password: Option<String>,
     38 }
     39 
     40 impl DesktopLocalIdentityImportRequest {
     41     pub fn new(
     42         mode: DesktopLocalIdentityImportMode,
     43         secret_text: impl Into<String>,
     44         password: Option<String>,
     45     ) -> Self {
     46         Self {
     47             mode,
     48             secret_text: secret_text.into(),
     49             password,
     50         }
     51     }
     52 
     53     pub fn raw_secret_key(secret_text: impl Into<String>) -> Self {
     54         Self::new(
     55             DesktopLocalIdentityImportMode::RawSecretKey,
     56             secret_text,
     57             None,
     58         )
     59     }
     60 
     61     pub fn encrypted_secret_key(
     62         secret_text: impl Into<String>,
     63         password: impl Into<String>,
     64     ) -> Self {
     65         Self::new(
     66             DesktopLocalIdentityImportMode::EncryptedSecretKey,
     67             secret_text,
     68             Some(password.into()),
     69         )
     70     }
     71 }
     72 
     73 pub fn bootstrap_desktop_accounts(
     74     paths: &AppSharedAccountsPaths,
     75     _sqlite_store: &AppSqliteStore,
     76 ) -> Result<DesktopAccountsBootstrap, DesktopAccountsBootstrapError> {
     77     bootstrap_desktop_accounts_with_availability(paths, secret_backend_availability()?)
     78 }
     79 
     80 pub fn generate_local_account(
     81     manager: &RadrootsNostrAccountsManager,
     82     sqlite_store: &AppSqliteStore,
     83     label: Option<String>,
     84 ) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
     85     manager.generate_identity(label, true)?;
     86     Ok(identity_projection_from_manager(manager, sqlite_store)?)
     87 }
     88 
     89 pub fn import_local_account(
     90     manager: &RadrootsNostrAccountsManager,
     91     sqlite_store: &AppSqliteStore,
     92     request: &DesktopLocalIdentityImportRequest,
     93 ) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
     94     let identity = import_identity(request)?;
     95     manager.upsert_identity(&identity, None, true)?;
     96     Ok(identity_projection_from_manager(manager, sqlite_store)?)
     97 }
     98 
     99 pub fn select_local_account(
    100     manager: &RadrootsNostrAccountsManager,
    101     sqlite_store: &AppSqliteStore,
    102     account_id: &str,
    103 ) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
    104     let account_id = RadrootsIdentityId::parse(account_id.trim())?;
    105     manager.set_default_account(&account_id)?;
    106     Ok(identity_projection_from_manager(manager, sqlite_store)?)
    107 }
    108 
    109 pub fn select_active_surface(
    110     manager: &RadrootsNostrAccountsManager,
    111     sqlite_store: &AppSqliteStore,
    112     active_surface: ActiveSurface,
    113 ) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
    114     let Some(selected_account) = selected_account_record(manager)? else {
    115         return Ok(identity_projection_from_manager(manager, sqlite_store)?);
    116     };
    117     let selected_projection =
    118         selected_account_projection_from_record(&selected_account, sqlite_store)?;
    119     let activation = AccountSurfaceActivationProjection::new(
    120         selected_projection.account.account_id.clone(),
    121         SelectedSurfaceProjection::new(active_surface),
    122         selected_projection.farmer_activation.clone(),
    123     );
    124 
    125     sqlite_store.save_surface_activation(&activation)?;
    126     Ok(identity_projection_from_manager(manager, sqlite_store)?)
    127 }
    128 
    129 pub fn remove_selected_local_key(
    130     manager: &RadrootsNostrAccountsManager,
    131     sqlite_store: &AppSqliteStore,
    132 ) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
    133     let Some(selected_account) = selected_account_record(manager)? else {
    134         return Ok(identity_projection_from_manager(manager, sqlite_store)?);
    135     };
    136     let account_id = selected_account.account_id.to_string();
    137 
    138     sqlite_store.clear_surface_activation(account_id.as_str())?;
    139     manager.remove_account(&selected_account.account_id)?;
    140     if let Some(next_account) = manager.list_accounts()?.into_iter().next() {
    141         manager.set_default_account(&next_account.account_id)?;
    142     }
    143 
    144     Ok(identity_projection_from_manager(manager, sqlite_store)?)
    145 }
    146 
    147 pub fn reset_local_device_state(
    148     manager: &RadrootsNostrAccountsManager,
    149     sqlite_store: &AppSqliteStore,
    150     accounts_paths: &AppSharedAccountsPaths,
    151 ) -> Result<AppIdentityProjection, DesktopAccountsCommandError> {
    152     let account_ids = manager
    153         .list_accounts()?
    154         .into_iter()
    155         .map(|record| record.account_id)
    156         .collect::<Vec<_>>();
    157 
    158     for account_id in &account_ids {
    159         sqlite_store.clear_surface_activation(account_id.as_str())?;
    160     }
    161     for account_id in account_ids {
    162         manager.remove_account(&account_id)?;
    163     }
    164 
    165     remove_accounts_file_if_present(accounts_paths.store_path.as_path())?;
    166     Ok(identity_projection_from_manager(manager, sqlite_store)?)
    167 }
    168 
    169 fn bootstrap_desktop_accounts_with_availability(
    170     paths: &AppSharedAccountsPaths,
    171     availability: RadrootsSecretBackendAvailability,
    172 ) -> Result<DesktopAccountsBootstrap, DesktopAccountsBootstrapError> {
    173     ensure_directory(paths.data_root.as_path())?;
    174     ensure_directory(paths.secrets_root.as_path())?;
    175 
    176     let selection = local_account_secret_backend_selection();
    177     let (accounts_manager, _) = RadrootsNostrAccountsManager::new_local_file_backed(
    178         paths.store_path.as_path(),
    179         paths.secrets_root.as_path(),
    180         selection,
    181         availability,
    182         "radroots_app_encrypted_file",
    183     )?;
    184     Ok(DesktopAccountsBootstrap {
    185         accounts_manager: Some(accounts_manager),
    186     })
    187 }
    188 
    189 fn ensure_directory(path: &Path) -> Result<(), DesktopAccountsBootstrapError> {
    190     fs::create_dir_all(path).map_err(|source| DesktopAccountsBootstrapError::CreateDirectory {
    191         path: path.to_path_buf(),
    192         source,
    193     })
    194 }
    195 
    196 fn local_account_secret_backend_selection() -> RadrootsSecretBackendSelection {
    197     RadrootsSecretBackendSelection {
    198         primary: RadrootsSecretBackend::EncryptedFile,
    199         fallback: None,
    200     }
    201 }
    202 
    203 fn secret_backend_availability()
    204 -> Result<RadrootsSecretBackendAvailability, DesktopAccountsBootstrapError> {
    205     Ok(RadrootsSecretBackendAvailability {
    206         host_vault: RadrootsHostVaultCapabilities::unavailable(),
    207         encrypted_file: true,
    208         external_command: false,
    209         memory: false,
    210     })
    211 }
    212 
    213 fn import_identity(
    214     request: &DesktopLocalIdentityImportRequest,
    215 ) -> Result<RadrootsIdentity, DesktopAccountsCommandError> {
    216     match request.mode {
    217         DesktopLocalIdentityImportMode::RawSecretKey => Ok(RadrootsIdentity::from_secret_key_str(
    218             request.secret_text.trim(),
    219         )?),
    220         DesktopLocalIdentityImportMode::EncryptedSecretKey => {
    221             let Some(password) = request.password.as_deref() else {
    222                 return Err(DesktopAccountsCommandError::EncryptedImportPasswordRequired);
    223             };
    224             Ok(RadrootsIdentity::from_encrypted_secret_key_str(
    225                 request.secret_text.trim(),
    226                 password,
    227             )?)
    228         }
    229     }
    230 }
    231 
    232 fn remove_accounts_file_if_present(path: &Path) -> Result<(), DesktopAccountsCommandError> {
    233     match fs::remove_file(path) {
    234         Ok(()) => Ok(()),
    235         Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
    236         Err(source) => Err(DesktopAccountsCommandError::RemoveAccountStore {
    237             path: path.to_path_buf(),
    238             source,
    239         }),
    240     }
    241 }
    242 
    243 pub(crate) fn identity_projection_from_manager(
    244     manager: &RadrootsNostrAccountsManager,
    245     sqlite_store: &AppSqliteStore,
    246 ) -> Result<AppIdentityProjection, DesktopAccountsProjectionError> {
    247     let roster_records = manager.list_accounts()?;
    248     let roster = account_roster_from_records(roster_records.as_slice());
    249 
    250     match manager.default_account_status()? {
    251         RadrootsNostrAccountStatus::NotConfigured => {
    252             Ok(AppIdentityProjection::missing_with_roster(roster))
    253         }
    254         RadrootsNostrAccountStatus::PublicOnly { account }
    255         | RadrootsNostrAccountStatus::Ready { account } => Ok(AppIdentityProjection::ready(
    256             roster,
    257             selected_account_projection_from_record(&account, sqlite_store)?,
    258         )),
    259     }
    260 }
    261 
    262 fn selected_account_projection_from_record(
    263     record: &RadrootsNostrAccountRecord,
    264     sqlite_store: &AppSqliteStore,
    265 ) -> Result<SelectedAccountProjection, DesktopAccountsProjectionError> {
    266     let account = account_summary_from_record(record);
    267 
    268     Ok(
    269         match sqlite_store.load_surface_activation(account.account_id.as_str())? {
    270             Some(activation) => {
    271                 SelectedAccountProjection::from_surface_activation(account, activation)
    272             }
    273             None => {
    274                 let activation = default_farmer_surface_activation(account.account_id.as_str());
    275                 sqlite_store.save_surface_activation(&activation)?;
    276                 SelectedAccountProjection::from_surface_activation(account, activation)
    277             }
    278         },
    279     )
    280 }
    281 
    282 fn selected_account_record(
    283     manager: &RadrootsNostrAccountsManager,
    284 ) -> Result<Option<RadrootsNostrAccountRecord>, RadrootsNostrAccountsError> {
    285     match manager.default_account_status()? {
    286         RadrootsNostrAccountStatus::NotConfigured => Ok(None),
    287         RadrootsNostrAccountStatus::PublicOnly { account }
    288         | RadrootsNostrAccountStatus::Ready { account } => Ok(Some(account)),
    289     }
    290 }
    291 
    292 fn default_farmer_surface_activation(account_id: &str) -> AccountSurfaceActivationProjection {
    293     AccountSurfaceActivationProjection::new(
    294         account_id,
    295         SelectedSurfaceProjection::new(ActiveSurface::Farmer),
    296         FarmerActivationProjection::active(FarmId::new()),
    297     )
    298 }
    299 
    300 fn account_roster_from_records(records: &[RadrootsNostrAccountRecord]) -> Vec<AccountSummary> {
    301     records.iter().map(account_summary_from_record).collect()
    302 }
    303 
    304 fn account_summary_from_record(record: &RadrootsNostrAccountRecord) -> AccountSummary {
    305     AccountSummary {
    306         account_id: record.account_id.to_string(),
    307         npub: record.public_identity.public_key_npub.clone(),
    308         label: record.label.clone(),
    309         custody: radroots_app_view::AccountCustody::LocalManaged,
    310     }
    311 }
    312 
    313 #[derive(Debug, Error)]
    314 pub enum DesktopAccountsProjectionError {
    315     #[error(transparent)]
    316     Accounts(#[from] RadrootsNostrAccountsError),
    317     #[error(transparent)]
    318     Sqlite(#[from] AppSqliteError),
    319 }
    320 
    321 #[derive(Debug, Error)]
    322 pub enum DesktopAccountsCommandError {
    323     #[error(transparent)]
    324     Accounts(#[from] RadrootsNostrAccountsError),
    325     #[error(transparent)]
    326     Identity(#[from] IdentityError),
    327     #[error(transparent)]
    328     Sqlite(#[from] AppSqliteError),
    329     #[error(transparent)]
    330     Projection(#[from] DesktopAccountsProjectionError),
    331     #[error("encrypted secret key import requires a password")]
    332     EncryptedImportPasswordRequired,
    333     #[error("failed to remove account store {path}: {source}")]
    334     RemoveAccountStore {
    335         path: PathBuf,
    336         source: std::io::Error,
    337     },
    338 }
    339 
    340 #[derive(Debug, Error)]
    341 pub enum DesktopAccountsBootstrapError {
    342     #[error("failed to create runtime directory {path}: {source}")]
    343     CreateDirectory {
    344         path: PathBuf,
    345         source: std::io::Error,
    346     },
    347     #[error(transparent)]
    348     Accounts(#[from] RadrootsNostrAccountsError),
    349     #[error(transparent)]
    350     Projection(#[from] DesktopAccountsProjectionError),
    351 }
    352 
    353 #[cfg(test)]
    354 mod tests {
    355     use std::{
    356         fs,
    357         path::PathBuf,
    358         sync::Arc,
    359         time::{SystemTime, UNIX_EPOCH},
    360     };
    361 
    362     use radroots_app_core::AppSharedAccountsPaths;
    363     use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget};
    364     use radroots_app_view::{
    365         AccountSurfaceActivationProjection, ActiveSurface, AppStartupGate, IdentityReadiness,
    366         SelectedSurfaceProjection,
    367     };
    368     use radroots_identity::RadrootsIdentity;
    369     use radroots_nostr_accounts::prelude::{
    370         RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore,
    371         RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory,
    372     };
    373     use radroots_secret_vault::RadrootsHostVaultCapabilities;
    374 
    375     use super::{
    376         DesktopLocalIdentityImportRequest, account_summary_from_record,
    377         bootstrap_desktop_accounts_with_availability, generate_local_account,
    378         identity_projection_from_manager, import_local_account, remove_selected_local_key,
    379         reset_local_device_state, select_local_account, selected_account_projection_from_record,
    380         selected_account_record,
    381     };
    382 
    383     fn temp_shared_accounts_paths(label: &str) -> AppSharedAccountsPaths {
    384         let suffix = SystemTime::now()
    385             .duration_since(UNIX_EPOCH)
    386             .expect("clock")
    387             .as_nanos();
    388         let base = std::env::temp_dir().join(format!("radroots_app_accounts_{label}_{suffix}"));
    389 
    390         AppSharedAccountsPaths {
    391             data_root: base.join("data/shared/accounts"),
    392             secrets_root: base.join("secrets/shared/accounts"),
    393             store_path: base.join("data/shared/accounts/store.json"),
    394         }
    395     }
    396 
    397     fn unavailable_secret_backend_availability()
    398     -> radroots_secret_vault::RadrootsSecretBackendAvailability {
    399         radroots_secret_vault::RadrootsSecretBackendAvailability {
    400             host_vault: RadrootsHostVaultCapabilities::unavailable(),
    401             encrypted_file: false,
    402             external_command: false,
    403             memory: false,
    404         }
    405     }
    406 
    407     #[test]
    408     fn bootstrap_fails_when_encrypted_file_backend_is_unavailable() {
    409         let paths = temp_shared_accounts_paths("blocked");
    410         fs::create_dir_all(paths.data_root.as_path()).expect("data root should create");
    411         fs::create_dir_all(paths.secrets_root.as_path()).expect("secrets root should create");
    412         match bootstrap_desktop_accounts_with_availability(
    413             &paths,
    414             unavailable_secret_backend_availability(),
    415         ) {
    416             Err(super::DesktopAccountsBootstrapError::Accounts(_)) => {}
    417             Err(other) => panic!("unexpected bootstrap error: {other}"),
    418             Ok(_) => panic!("bootstrap should fail when encrypted file backend is unavailable"),
    419         }
    420 
    421         cleanup_paths(&paths);
    422     }
    423 
    424     #[test]
    425     fn manager_projection_uses_selected_account_and_activation_state() {
    426         let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store");
    427         let manager = RadrootsNostrAccountsManager::new(
    428             Arc::new(RadrootsNostrMemoryAccountStore::new()),
    429             Arc::new(RadrootsNostrSecretVaultMemory::new()),
    430         )
    431         .expect("memory manager should build");
    432         let account_id = manager
    433             .generate_identity(Some("North field".to_owned()), true)
    434             .expect("account should generate");
    435         let selected_account = selected_account_record(&manager)
    436             .expect("selected account should load")
    437             .expect("selected account should exist");
    438         let selected_account_summary = account_summary_from_record(&selected_account);
    439         let selected_account_projection =
    440             selected_account_projection_from_record(&selected_account, &sqlite_store)
    441                 .expect("selected account projection");
    442 
    443         assert_eq!(
    444             selected_account_projection.account,
    445             selected_account_summary
    446         );
    447         assert_eq!(
    448             selected_account_projection.selected_surface,
    449             SelectedSurfaceProjection::new(ActiveSurface::Farmer)
    450         );
    451         assert!(selected_account_projection.farmer_activation.is_active());
    452 
    453         let activation = AccountSurfaceActivationProjection::new(
    454             account_id.as_str(),
    455             SelectedSurfaceProjection::new(ActiveSurface::Farmer),
    456             radroots_app_view::FarmerActivationProjection::active(radroots_app_view::FarmId::new()),
    457         );
    458         sqlite_store
    459             .save_surface_activation(&activation)
    460             .expect("surface activation should save");
    461 
    462         let projection =
    463             identity_projection_from_manager(&manager, &sqlite_store).expect("projection");
    464 
    465         assert_eq!(projection.readiness, IdentityReadiness::Ready);
    466         assert_eq!(projection.startup_gate(), AppStartupGate::Farmer);
    467         assert_eq!(projection.roster.len(), 1);
    468         assert_eq!(
    469             projection
    470                 .selected_account
    471                 .as_ref()
    472                 .map(|account| account.account.account_id.as_str()),
    473             Some(account_id.as_str())
    474         );
    475         assert_eq!(
    476             projection
    477                 .selected_account
    478                 .as_ref()
    479                 .map(|account| account.active_surface()),
    480             Some(ActiveSurface::Farmer)
    481         );
    482         assert!(
    483             projection
    484                 .selected_account
    485                 .as_ref()
    486                 .is_some_and(|account| account.farmer_activation.is_active())
    487         );
    488     }
    489 
    490     #[test]
    491     fn command_generate_and_select_support_multiple_local_accounts() {
    492         let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store");
    493         let manager = RadrootsNostrAccountsManager::new(
    494             Arc::new(RadrootsNostrMemoryAccountStore::new()),
    495             Arc::new(RadrootsNostrSecretVaultMemory::new()),
    496         )
    497         .expect("memory manager should build");
    498 
    499         let first_projection =
    500             generate_local_account(&manager, &sqlite_store, Some("First".to_owned()))
    501                 .expect("first account should generate");
    502         let first_account_id = first_projection
    503             .selected_account
    504             .as_ref()
    505             .expect("first selected account")
    506             .account
    507             .account_id
    508             .clone();
    509 
    510         let second_projection =
    511             generate_local_account(&manager, &sqlite_store, Some("Second".to_owned()))
    512                 .expect("second account should generate");
    513         let second_account_id = second_projection
    514             .selected_account
    515             .as_ref()
    516             .expect("second selected account")
    517             .account
    518             .account_id
    519             .clone();
    520 
    521         assert_eq!(first_projection.roster.len(), 1);
    522         assert_eq!(second_projection.roster.len(), 2);
    523         assert_ne!(first_account_id, second_account_id);
    524         assert_eq!(
    525             second_projection
    526                 .selected_account
    527                 .as_ref()
    528                 .map(|account| account.account.label.as_deref()),
    529             Some(Some("Second"))
    530         );
    531         assert_eq!(second_projection.startup_gate(), AppStartupGate::Farmer);
    532     }
    533 
    534     #[test]
    535     fn command_import_supports_raw_and_encrypted_secret_keys() {
    536         let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store");
    537         let manager = RadrootsNostrAccountsManager::new(
    538             Arc::new(RadrootsNostrMemoryAccountStore::new()),
    539             Arc::new(RadrootsNostrSecretVaultMemory::new()),
    540         )
    541         .expect("memory manager should build");
    542         let raw_identity = RadrootsIdentity::generate();
    543         let encrypted_identity = RadrootsIdentity::generate();
    544         let encrypted_secret = encrypted_identity
    545             .encrypt_secret_key_ncryptsec("radroots-password")
    546             .expect("encrypted secret should export");
    547 
    548         let raw_projection = import_local_account(
    549             &manager,
    550             &sqlite_store,
    551             &DesktopLocalIdentityImportRequest::raw_secret_key(raw_identity.nsec()),
    552         )
    553         .expect("raw import should succeed");
    554         let encrypted_projection = import_local_account(
    555             &manager,
    556             &sqlite_store,
    557             &DesktopLocalIdentityImportRequest::encrypted_secret_key(
    558                 encrypted_secret,
    559                 "radroots-password",
    560             ),
    561         )
    562         .expect("encrypted import should succeed");
    563 
    564         assert_eq!(raw_projection.roster.len(), 1);
    565         assert_eq!(encrypted_projection.roster.len(), 2);
    566         assert_eq!(
    567             encrypted_projection
    568                 .selected_account
    569                 .as_ref()
    570                 .map(|account| account.account.account_id.as_str()),
    571             Some(encrypted_identity.id().as_str())
    572         );
    573     }
    574 
    575     #[test]
    576     fn command_select_refreshes_selected_account_activation() {
    577         let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store");
    578         let manager = RadrootsNostrAccountsManager::new(
    579             Arc::new(RadrootsNostrMemoryAccountStore::new()),
    580             Arc::new(RadrootsNostrSecretVaultMemory::new()),
    581         )
    582         .expect("memory manager should build");
    583         let first_account_id = manager
    584             .generate_identity(Some("First".to_owned()), true)
    585             .expect("first account should generate");
    586         let second_account_id = manager
    587             .generate_identity(Some("Second".to_owned()), false)
    588             .expect("second account should generate");
    589         let activation = AccountSurfaceActivationProjection::new(
    590             second_account_id.as_str(),
    591             SelectedSurfaceProjection::new(ActiveSurface::Farmer),
    592             radroots_app_view::FarmerActivationProjection::active(radroots_app_view::FarmId::new()),
    593         );
    594         sqlite_store
    595             .save_surface_activation(&activation)
    596             .expect("surface activation should save");
    597 
    598         let projection = select_local_account(&manager, &sqlite_store, second_account_id.as_str())
    599             .expect("selection should refresh");
    600 
    601         assert_eq!(
    602             projection
    603                 .selected_account
    604                 .as_ref()
    605                 .map(|account| account.account.account_id.as_str()),
    606             Some(second_account_id.as_str())
    607         );
    608         assert_eq!(projection.startup_gate(), AppStartupGate::Farmer);
    609         assert_eq!(
    610             projection
    611                 .selected_account
    612                 .as_ref()
    613                 .map(|account| account.active_surface()),
    614             Some(ActiveSurface::Farmer)
    615         );
    616         assert_eq!(
    617             selected_account_record(&manager)
    618                 .expect("selected account")
    619                 .map(|account| account.account_id),
    620             Some(second_account_id.clone())
    621         );
    622         assert_ne!(
    623             first_account_id,
    624             selected_account_record(&manager)
    625                 .expect("selected account")
    626                 .expect("selected")
    627                 .account_id
    628         );
    629     }
    630 
    631     #[test]
    632     fn command_remove_selected_local_key_clears_activation_and_selects_next_account() {
    633         let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store");
    634         let manager = RadrootsNostrAccountsManager::new(
    635             Arc::new(RadrootsNostrMemoryAccountStore::new()),
    636             Arc::new(RadrootsNostrSecretVaultMemory::new()),
    637         )
    638         .expect("memory manager should build");
    639         let first_account_id = manager
    640             .generate_identity(Some("First".to_owned()), true)
    641             .expect("first account should generate");
    642         let second_account_id = manager
    643             .generate_identity(Some("Second".to_owned()), false)
    644             .expect("second account should generate");
    645         manager
    646             .set_default_account(&first_account_id)
    647             .expect("first account should remain selected");
    648         let activation = AccountSurfaceActivationProjection::new(
    649             first_account_id.as_str(),
    650             SelectedSurfaceProjection::new(ActiveSurface::Farmer),
    651             radroots_app_view::FarmerActivationProjection::active(radroots_app_view::FarmId::new()),
    652         );
    653         sqlite_store
    654             .save_surface_activation(&activation)
    655             .expect("surface activation should save");
    656 
    657         let projection = remove_selected_local_key(&manager, &sqlite_store)
    658             .expect("selected local key should remove");
    659 
    660         assert_eq!(projection.roster.len(), 1);
    661         assert_eq!(
    662             projection
    663                 .selected_account
    664                 .as_ref()
    665                 .map(|account| account.account.account_id.as_str()),
    666             Some(second_account_id.as_str())
    667         );
    668         assert_eq!(
    669             sqlite_store
    670                 .load_surface_activation(first_account_id.as_str())
    671                 .expect("removed activation should load"),
    672             None
    673         );
    674     }
    675 
    676     #[test]
    677     fn command_reset_local_device_state_removes_store_file_and_all_activations() {
    678         let paths = temp_shared_accounts_paths("reset");
    679         fs::create_dir_all(paths.data_root.as_path()).expect("data root should create");
    680         fs::create_dir_all(paths.secrets_root.as_path()).expect("secrets root should create");
    681         let manager = RadrootsNostrAccountsManager::new(
    682             Arc::new(RadrootsNostrFileAccountStore::new(
    683                 paths.store_path.as_path(),
    684             )),
    685             Arc::new(RadrootsNostrSecretVaultMemory::new()),
    686         )
    687         .expect("file-backed manager should build");
    688         let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store");
    689         let first_account_id = manager
    690             .generate_identity(Some("First".to_owned()), true)
    691             .expect("first account should generate");
    692         let second_account_id = manager
    693             .generate_identity(Some("Second".to_owned()), false)
    694             .expect("second account should generate");
    695         sqlite_store
    696             .save_surface_activation(&AccountSurfaceActivationProjection::new(
    697                 first_account_id.as_str(),
    698                 SelectedSurfaceProjection::new(ActiveSurface::Farmer),
    699                 radroots_app_view::FarmerActivationProjection::active(
    700                     radroots_app_view::FarmId::new(),
    701                 ),
    702             ))
    703             .expect("first activation should save");
    704         sqlite_store
    705             .save_surface_activation(&AccountSurfaceActivationProjection::new(
    706                 second_account_id.as_str(),
    707                 SelectedSurfaceProjection::new(ActiveSurface::Farmer),
    708                 radroots_app_view::FarmerActivationProjection::active(
    709                     radroots_app_view::FarmId::new(),
    710                 ),
    711             ))
    712             .expect("second activation should save");
    713         assert!(paths.store_path.exists());
    714 
    715         let projection = reset_local_device_state(&manager, &sqlite_store, &paths)
    716             .expect("device state should reset");
    717 
    718         assert_eq!(projection.readiness, IdentityReadiness::MissingAccount);
    719         assert_eq!(projection.startup_gate(), AppStartupGate::SetupRequired);
    720         assert!(projection.roster.is_empty());
    721         assert!(projection.selected_account.is_none());
    722         assert!(!paths.store_path.exists());
    723         assert_eq!(
    724             sqlite_store
    725                 .load_surface_activation(first_account_id.as_str())
    726                 .expect("first activation should load"),
    727             None
    728         );
    729         assert_eq!(
    730             sqlite_store
    731                 .load_surface_activation(second_account_id.as_str())
    732                 .expect("second activation should load"),
    733             None
    734         );
    735 
    736         cleanup_paths(&paths);
    737     }
    738 
    739     fn cleanup_paths(paths: &AppSharedAccountsPaths) {
    740         let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else {
    741             return;
    742         };
    743         let _ = fs::remove_dir_all(base);
    744     }
    745 }