lib

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

commit 065b2dad72e629911a01f1fb451ef98ec26dbdb7
parent 6a64451afdbc9fce8daa340849da1ad5411cf5aa
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 20:07:14 +0000

integration: prove restart and account isolation

- restore multiple accounts and selection from a reopened database
- verify account-owned namespaces cannot cross identity boundaries
- inject test credentials behind the same FFI secret-store port
- prove async profile callbacks stop after explicit unsubscribe

Diffstat:
Mcrates/studio_ffi/Cargo.toml | 3+++
Mcrates/studio_ffi/src/commands.rs | 18+++++++++---------
Mcrates/studio_ffi/src/observer.rs | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Acrates/studio_storage/tests/restart_isolation.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 177 insertions(+), 12 deletions(-)

diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml @@ -18,6 +18,9 @@ tokio.workspace = true uniffi.workspace = true [dev-dependencies] +nostr.workspace = true +nostr-relay-builder.workspace = true +nostr-sdk.workspace = true tempfile = "=3.23.0" [lints] diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -7,7 +7,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use radroots_studio_application::{ - Clock, RelayRuntimeMode, RemovalConfirmationToken, SdkNostrClient, + Clock, RelayRuntimeMode, RemovalConfirmationToken, SdkNostrClient, SecretStore, relay_configuration_from_environment, }; use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; @@ -61,7 +61,7 @@ impl RemovalRequest { pub(crate) struct RuntimeCore { pub(crate) adapter: PersistentAppCore, - pub(crate) secrets: OsKeyringSecretStore, + pub(crate) secrets: Arc<dyn SecretStore>, pub(crate) clock: SystemClock, pub(crate) nostr: SdkNostrClient, pub(crate) observers: Mutex<BTreeSet<radroots_studio_application::ObserverHandle>>, @@ -98,7 +98,7 @@ impl StudioAppCore { blocking(move || { inner .adapter - .bootstrap(&inner.secrets, &inner.clock) + .bootstrap(inner.secrets.as_ref(), &inner.clock) .map(|snapshot| (&snapshot).into()) }) .await @@ -119,7 +119,7 @@ impl StudioAppCore { blocking(move || { let receipt = inner .adapter - .generate_account(&inner.secrets, &inner.clock)?; + .generate_account(inner.secrets.as_ref(), &inner.clock)?; Ok(GeneratedAccountDto { account: receipt.account().into(), snapshot: (&inner.adapter.core().snapshot()).into(), @@ -143,7 +143,7 @@ impl StudioAppCore { blocking(move || { inner .adapter - .import_secret_key(input, &inner.secrets, &inner.clock)?; + .import_secret_key(input, inner.secrets.as_ref(), &inner.clock)?; Ok((&inner.adapter.core().snapshot()).into()) }) .await @@ -183,7 +183,7 @@ impl StudioAppCore { blocking(move || { inner .adapter - .activate_account(public_key, &inner.secrets, &inner.clock) + .activate_account(public_key, inner.secrets.as_ref(), &inner.clock) .map(|snapshot| (&snapshot).into()) }) .await @@ -260,7 +260,7 @@ impl StudioAppCore { blocking(move || { inner .adapter - .confirm_account_removal(token, &inner.secrets, &inner.clock) + .confirm_account_removal(token, inner.secrets.as_ref(), &inner.clock) .map(|snapshot| (&snapshot).into()) }) .await @@ -279,7 +279,7 @@ impl StudioAppCore { Ok(Arc::new(Self { inner: Arc::new(RuntimeCore { adapter, - secrets: OsKeyringSecretStore, + secrets: Arc::new(OsKeyringSecretStore), clock: SystemClock, nostr: SdkNostrClient::new(Duration::from_secs(5)), observers: Mutex::new(BTreeSet::new()), @@ -371,7 +371,7 @@ mod tests { inner: Arc::new(RuntimeCore { adapter: PersistentAppCore::in_memory(RelayConfiguration::default()) .expect("in-memory core"), - secrets: radroots_studio_storage::OsKeyringSecretStore, + secrets: Arc::new(radroots_studio_application::InMemorySecretStore::default()), clock: SystemClock, nostr: radroots_studio_application::SdkNostrClient::new( std::time::Duration::from_millis(10), diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs @@ -114,12 +114,19 @@ fn closed_error() -> StudioError { #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; + use std::time::Duration; + use nostr::{EventBuilder, Keys, Metadata}; + use nostr_relay_builder::MockRelay; + use nostr_sdk::Client; use radroots_studio_application::RelayConfiguration; + use radroots_studio_domain::RelayUrl; use radroots_studio_storage::PersistentAppCore; use crate::commands::{RuntimeCore, SystemClock}; - use crate::{AppSnapshotDto, StudioAppCore, StudioObserver}; + use crate::{AppSnapshotDto, ProfileLoadStateDto, StudioAppCore, StudioObserver}; + + const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; #[derive(Default)] struct RecordingObserver { @@ -137,10 +144,14 @@ mod tests { } fn core() -> Arc<StudioAppCore> { + core_with_relays(RelayConfiguration::default()) + } + + fn core_with_relays(relays: RelayConfiguration) -> Arc<StudioAppCore> { Arc::new(StudioAppCore { inner: Arc::new(RuntimeCore { - adapter: PersistentAppCore::in_memory(RelayConfiguration::default()).expect("core"), - secrets: radroots_studio_storage::OsKeyringSecretStore, + adapter: PersistentAppCore::in_memory(relays).expect("core"), + secrets: Arc::new(radroots_studio_application::InMemorySecretStore::default()), clock: SystemClock, nostr: radroots_studio_application::SdkNostrClient::new( std::time::Duration::from_millis(10), @@ -189,6 +200,62 @@ mod tests { assert!(core.inner.observers.lock().expect("observers").is_empty()); } + #[tokio::test] + async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() { + let local_relay = MockRelay::run().await.expect("local relay"); + let relay_url = local_relay.url().await; + let publisher = Client::new(Keys::parse(SECRET_HEX).expect("known key")); + publisher + .add_relay(relay_url.clone()) + .await + .expect("publisher relay"); + publisher.connect().await; + publisher.wait_for_connection(Duration::from_secs(2)).await; + publisher + .send_event_builder(EventBuilder::metadata( + &Metadata::new().display_name("FFI Profile"), + )) + .await + .expect("publish profile"); + + let core = core_with_relays(RelayConfiguration::new(vec![ + RelayUrl::parse(relay_url.as_str()).expect("relay URL"), + ])); + core.bootstrap().await.expect("bootstrap"); + let observer = Arc::new(RecordingObserver::default()); + *observer.core.lock().expect("core") = Some(Arc::clone(&core)); + let subscription = core + .subscribe(Box::new(ArcObserver(observer.clone()))) + .expect("subscribe"); + let imported = core + .import_secret_key(SECRET_HEX.to_owned()) + .await + .expect("import"); + let public_key = imported.selected_public_key_hex.expect("selection"); + core.activate_account(public_key).await.expect("activate"); + core.refresh_active_profile().await.expect("refresh"); + + let snapshots = observer.snapshots.lock().expect("snapshots").clone(); + assert!(snapshots.iter().any(|snapshot| { + snapshot.active_account.as_ref().is_some_and(|active| { + active.profile_state == ProfileLoadStateDto::Fresh + && active + .profile + .as_ref() + .and_then(|profile| profile.display_name.as_deref()) + == Some("FFI Profile") + }) + })); + subscription.unsubscribe(); + let count = observer.snapshots.lock().expect("snapshots").len(); + core.sign_out().await.expect("sign out"); + assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count); + + core.shutdown(); + publisher.shutdown().await; + local_relay.shutdown(); + } + struct ArcObserver(Arc<RecordingObserver>); impl StudioObserver for ArcObserver { diff --git a/crates/studio_storage/tests/restart_isolation.rs b/crates/studio_storage/tests/restart_isolation.rs @@ -0,0 +1,95 @@ +use std::fs; + +use radroots_studio_application::{ + AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore, + RelayConfiguration, SessionState, +}; +use radroots_studio_domain::{SecretKeyInput, UnixTimestamp}; +use radroots_studio_storage::PersistentAppCore; +use tempfile::tempdir; + +const SECRET_A: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; +const SECRET_B: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(200).expect("fixed timestamp") + } +} + +#[test] +fn restart_restores_selection_and_keeps_account_namespaces_isolated() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let secrets = InMemorySecretStore::default(); + let (owner_a, owner_b); + + { + let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) + .expect("persistent adapter"); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + owner_a = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_A.to_owned()).expect("secret A"), + &secrets, + &FixedClock, + ) + .expect("account A") + .account() + .public_key(); + owner_b = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_B.to_owned()).expect("secret B"), + &secrets, + &FixedClock, + ) + .expect("account B") + .account() + .public_key(); + adapter + .database() + .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a") + .expect("namespace A"); + adapter + .database() + .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b") + .expect("namespace B"); + adapter.select_account(owner_b).expect("select B"); + } + + let reopened = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); + let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); + assert_eq!(restored.accounts().len(), 2); + assert_eq!(restored.selected_account(), Some(owner_b)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert_eq!( + reopened + .database() + .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) + .expect("read A"), + Some("account-a".to_owned()) + ); + assert_eq!( + reopened + .database() + .get_value(owner_b, AccountPreferenceKey::NamespaceProbe) + .expect("read B"), + Some("account-b".to_owned()) + ); + + let database = fs::read(path).expect("database bytes"); + assert!( + !database + .windows(SECRET_A.len()) + .any(|bytes| bytes == SECRET_A.as_bytes()) + ); + assert!( + !database + .windows(SECRET_B.len()) + .any(|bytes| bytes == SECRET_B.as_bytes()) + ); + assert!(!database.windows(5).any(|bytes| bytes == b"nsec1")); +}