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