From 91ee5e103d1fd959ad5b6e0fd6e60965ebac3ec0 Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Wed, 11 Feb 2026 18:04:35 -0600 Subject: [PATCH] test: Add stateful test wallets Adds a reusable test framework (`TestWallet`) that creates in-memory wallets backed by SQLite for isolated integration tests of RPC methods. Each test creates a fresh `TestWallet` with a unique database name, so `cargo test` can run all tests in parallel without interference. As an example, stateful wallet tests are included in this commit for `z_listaccounts` and for a few keystore operations. --- zallet/src/components.rs | 4 + zallet/src/components/database.rs | 14 +- zallet/src/components/database/connection.rs | 31 +++- zallet/src/components/database/testing.rs | 85 +++++++++ .../json_rpc/methods/list_accounts.rs | 120 +++++++++++++ zallet/src/components/keystore.rs | 133 ++++++++++++++ zallet/src/components/keystore/testing.rs | 30 ++++ zallet/src/components/testing.rs | 170 ++++++++++++++++++ 8 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 zallet/src/components/database/testing.rs create mode 100644 zallet/src/components/keystore/testing.rs create mode 100644 zallet/src/components/testing.rs diff --git a/zallet/src/components.rs b/zallet/src/components.rs index 1ab4f649..923e5dcd 100644 --- a/zallet/src/components.rs +++ b/zallet/src/components.rs @@ -16,6 +16,10 @@ pub(crate) mod tracing; #[cfg(zallet_build = "wallet")] pub(crate) mod keystore; +#[cfg(test)] +#[cfg(zallet_build = "wallet")] +pub(crate) mod testing; + /// A handle to a background task spawned by a component. /// /// Background tasks in Zallet are either one-shot (expected to terminate before Zallet), diff --git a/zallet/src/components/database.rs b/zallet/src/components/database.rs index aed35513..687d79b7 100644 --- a/zallet/src/components/database.rs +++ b/zallet/src/components/database.rs @@ -25,6 +25,9 @@ mod ext; #[cfg(test)] mod tests; +#[cfg(test)] +pub(crate) mod testing; + pub(crate) type DbHandle = deadpool::managed::Object; /// Returns the full list of migrations defined in Zallet, to be applied alongside the @@ -52,6 +55,15 @@ impl fmt::Debug for Database { } impl Database { + /// Creates a Database from an existing pool. + /// + /// Note: This does NOT apply migrations. Caller must ensure + /// the database is already initialized. + #[cfg(test)] + pub(crate) fn from_pool(pool: connection::WalletPool) -> Self { + Self { db_data_pool: pool } + } + pub(crate) async fn open(config: &ZalletConfig) -> Result { let path = config.wallet_db_path(); @@ -59,7 +71,7 @@ impl Database { .await .map_err(|e| ErrorKind::Init.context(e))?; - let db_data_pool = connection::pool(&path, config.consensus.network())?; + let db_data_pool = connection::pool(&path, config.consensus.network(), None)?; let database = Self { db_data_pool }; diff --git a/zallet/src/components/database/connection.rs b/zallet/src/components/database/connection.rs index dfc292e6..348e8060 100644 --- a/zallet/src/components/database/connection.rs +++ b/zallet/src/components/database/connection.rs @@ -28,11 +28,38 @@ use crate::{ network::Network, }; -pub(super) fn pool(path: impl AsRef, params: Network) -> Result { +/// Configuration for the database connection pool. +#[derive(Clone, Debug)] +pub(crate) struct PoolConfig { + /// Maximum number of connections in the pool. + pub max_size: usize, +} + +impl Default for PoolConfig { + fn default() -> Self { + // Default deadpool size is 16 + Self { max_size: 16 } + } +} + +pub(super) fn pool( + path: impl AsRef, + params: Network, + pool_config: Option, +) -> Result { let config = deadpool_sqlite::Config::new(path.as_ref()); let manager = WalletManager::from_config(&config, params); + + let dp_config = match pool_config { + Some(cfg) => deadpool::managed::PoolConfig { + max_size: cfg.max_size, + ..deadpool::managed::PoolConfig::default() + }, + None => deadpool::managed::PoolConfig::default(), + }; + WalletPool::builder(manager) - .config(deadpool::managed::PoolConfig::default()) + .config(dp_config) .build() .map_err(|e| ErrorKind::Generic.context(e).into()) } diff --git a/zallet/src/components/database/testing.rs b/zallet/src/components/database/testing.rs new file mode 100644 index 00000000..8cb333f3 --- /dev/null +++ b/zallet/src/components/database/testing.rs @@ -0,0 +1,85 @@ +//! Test utilities for database operations. + +use zcash_client_sqlite::wallet::init::WalletMigrator; +use zcash_protocol::consensus::Parameters; + +use super::{DbHandle, all_external_migrations, connection}; +use crate::{error::Error, network::Network}; + +/// Creates an in-memory database pool for testing. +/// +/// Uses pool size 2 to allow concurrent access from both the wallet handle +/// and the keystore (which needs its own handle for database operations). +/// +/// Uses a shared in-memory database URI so all connections in the pool +/// access the same database. +pub(crate) fn test_pool(params: Network) -> Result { + // Use a shared in-memory database with a unique name per pool + // The `cache=shared` mode allows multiple connections to share the same database + // The unique name ensures different tests don't interfere with each other + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let db_name = format!( + "file:zallet_test_{}?mode=memory&cache=shared", + COUNTER.fetch_add(1, Ordering::SeqCst) + ); + + connection::pool( + &db_name, + params, + Some(connection::PoolConfig { max_size: 2 }), + ) +} + +/// A test database wrapper that provides access to the pool and handles. +/// +/// This wraps an in-memory SQLite database with all migrations applied, +/// suitable for unit and integration tests. +pub(crate) struct TestDatabase { + pool: connection::WalletPool, + params: Network, +} + +impl TestDatabase { + /// Creates a new in-memory test database with migrations applied. + pub(crate) async fn new(params: Network) -> Result { + let pool = test_pool(params)?; + let db = Self { pool, params }; + db.init().await?; + Ok(db) + } + + /// Initializes the database schema by applying all migrations. + async fn init(&self) -> Result<(), Error> { + let handle = self.handle().await?; + let params = self.params; + handle.with_mut(|mut db_data| { + WalletMigrator::new() + .with_external_migrations(all_external_migrations(params.network_type())) + .init_or_migrate(&mut db_data) + .map_err(|e| crate::error::ErrorKind::Init.context(e)) + })?; + Ok(()) + } + + /// Gets a database handle from the pool. + pub(crate) async fn handle(&self) -> Result { + self.pool + .get() + .await + .map_err(|e| crate::error::ErrorKind::Generic.context(e).into()) + } + + /// Returns the network parameters. + #[allow(dead_code)] + pub(crate) fn params(&self) -> Network { + self.params + } + + /// Returns a reference to the underlying pool. + /// + /// This is useful for creating a `Database` wrapper for KeyStore. + pub(crate) fn pool(&self) -> &connection::WalletPool { + &self.pool + } +} diff --git a/zallet/src/components/json_rpc/methods/list_accounts.rs b/zallet/src/components/json_rpc/methods/list_accounts.rs index ef094853..56886a47 100644 --- a/zallet/src/components/json_rpc/methods/list_accounts.rs +++ b/zallet/src/components/json_rpc/methods/list_accounts.rs @@ -160,3 +160,123 @@ pub(super) fn account_details( Ok(f(name, seedfp, account, addresses)) } + +#[cfg(test)] +mod tests { + mod integration { + use zcash_protocol::consensus; + + use crate::{components::testing::TestWallet, network::Network}; + + use super::super::*; + + /// Test z_listaccounts returns empty list for a wallet with no accounts. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn list_accounts_empty_wallet() { + let network = Network::Consensus(consensus::Network::MainNetwork); + let wallet = TestWallet::new(network).await.unwrap(); + + let handle = wallet.handle().await.unwrap(); + + let result = call(handle.as_ref(), Some(true)); + + assert!(result.is_ok()); + let accounts = result.unwrap(); + assert!(accounts.0.is_empty(), "Expected empty account list"); + } + + /// Test z_listaccounts returns a single account correctly. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn list_accounts_single_account() { + let network = Network::Consensus(consensus::Network::MainNetwork); + let wallet = TestWallet::new(network).await.unwrap(); + + let account = wallet + .account_builder() + .with_name("my_account") + .build() + .await + .unwrap(); + + let handle = wallet.handle().await.unwrap(); + + let result = call(handle.as_ref(), Some(true)); + + assert!(result.is_ok()); + let accounts = result.unwrap(); + assert_eq!(accounts.0.len(), 1, "Expected exactly one account"); + + let listed = &accounts.0[0]; + assert_eq!( + listed.account_uuid, + account.account_id.expose_uuid().to_string() + ); + assert_eq!(listed.name.as_deref(), Some("my_account")); + assert!(listed.seedfp.is_some(), "Should have seed fingerprint"); + } + + /// Test z_listaccounts returns multiple accounts correctly. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn list_accounts_multiple_accounts() { + let network = Network::Consensus(consensus::Network::MainNetwork); + let wallet = TestWallet::new(network).await.unwrap(); + + let account1 = wallet + .account_builder() + .with_name("first_account") + .build() + .await + .unwrap(); + + let account2 = wallet + .account_builder() + .with_name("second_account") + .build() + .await + .unwrap(); + + let handle = wallet.handle().await.unwrap(); + + let result = call(handle.as_ref(), Some(true)); + + assert!(result.is_ok()); + let accounts = result.unwrap(); + assert_eq!(accounts.0.len(), 2, "Expected exactly two accounts"); + + // Verify both accounts are present (order may vary) + let uuids: Vec<_> = accounts.0.iter().map(|a| a.account_uuid.as_str()).collect(); + assert!(uuids.contains(&account1.account_id.expose_uuid().to_string().as_str())); + assert!(uuids.contains(&account2.account_id.expose_uuid().to_string().as_str())); + + // Verify names are present + let names: Vec<_> = accounts + .0 + .iter() + .filter_map(|a| a.name.as_deref()) + .collect(); + assert!(names.contains(&"first_account")); + assert!(names.contains(&"second_account")); + } + + /// Test z_listaccounts with include_addresses=false omits addresses. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn list_accounts_without_addresses() { + let network = Network::Consensus(consensus::Network::MainNetwork); + let wallet = TestWallet::new(network).await.unwrap(); + + let _account = wallet.account_builder().build().await.unwrap(); + + let handle = wallet.handle().await.unwrap(); + + let result = call(handle.as_ref(), Some(false)); + + assert!(result.is_ok()); + let accounts = result.unwrap(); + assert_eq!(accounts.0.len(), 1); + assert!( + accounts.0[0].addresses.is_none(), + "Addresses should be omitted when include_addresses is false" + ); + } + } +} diff --git a/zallet/src/components/keystore.rs b/zallet/src/components/keystore.rs index 9e6890b5..6db58fef 100644 --- a/zallet/src/components/keystore.rs +++ b/zallet/src/components/keystore.rs @@ -146,6 +146,9 @@ pub(super) mod db; mod error; pub(crate) use error::KeystoreError; +#[cfg(test)] +pub(crate) mod testing; + type RelockTask = (SystemTime, JoinHandle<()>); #[derive(Clone)] @@ -170,6 +173,23 @@ impl fmt::Debug for KeyStore { } impl KeyStore { + /// Creates a KeyStore for testing with pre-initialized identities. + /// + /// This bypasses the file-reading logic and allows tests to inject + /// identities directly. + #[cfg(test)] + pub(crate) fn new_for_testing( + db: Database, + identities: Vec>, + ) -> Self { + Self { + db, + encrypted_identities: None, + identities: Arc::new(RwLock::new(identities)), + relock_task: Arc::new(Mutex::new(None)), + } + } + pub(crate) fn new(config: &ZalletConfig, db: Database) -> Result { // TODO: Maybe support storing the identity in `zallet.toml` instead of as a // separate file on disk? @@ -872,3 +892,116 @@ fn decrypt_standalone_transparent_privkey( Ok(secret_key) } + +#[cfg(test)] +mod tests { + mod integration { + use bip0039::{English, Mnemonic}; + use secrecy::ExposeSecret; + use zcash_protocol::consensus; + + use crate::{ + components::database::{Database, testing::TestDatabase}, + network::Network, + }; + + use super::super::testing::test_keystore; + + /// Test mnemonic lifecycle: encrypt, store, list, and decrypt. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn mnemonic_lifecycle() { + let network = Network::Consensus(consensus::Network::MainNetwork); + let test_db = TestDatabase::new(network).await.unwrap(); + let database = Database::from_pool(test_db.pool().clone()); + let keystore = test_keystore(database).await.unwrap(); + + // Initially no seeds + let seed_fps = keystore.list_seed_fingerprints().await.unwrap(); + assert!(seed_fps.is_empty(), "Should have no seeds initially"); + + // Create a mnemonic and store it + let mnemonic: Mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + .parse() + .unwrap(); + + let stored_fp = keystore + .encrypt_and_store_mnemonic(mnemonic.clone()) + .await + .unwrap(); + + // Verify seed is now listed + let seed_fps = keystore.list_seed_fingerprints().await.unwrap(); + assert_eq!(seed_fps.len(), 1); + assert!(seed_fps.contains(&stored_fp)); + + // Decrypt the seed and verify it matches + let decrypted_seed = keystore.decrypt_seed(&stored_fp).await.unwrap(); + + // The seed should match what we get from the mnemonic + let expected_seed = mnemonic.to_seed(""); + assert_eq!(decrypted_seed.expose_secret(), &expected_seed[..]); + } + + /// Test storing multiple mnemonics. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn multiple_mnemonics() { + let network = Network::Consensus(consensus::Network::MainNetwork); + let test_db = TestDatabase::new(network).await.unwrap(); + let database = Database::from_pool(test_db.pool().clone()); + let keystore = test_keystore(database).await.unwrap(); + + // Store first mnemonic + let mnemonic1: Mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + .parse() + .unwrap(); + let fp1 = keystore + .encrypt_and_store_mnemonic(mnemonic1) + .await + .unwrap(); + + // Store second mnemonic (different phrase) + let mnemonic2: Mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" + .parse() + .unwrap(); + let fp2 = keystore + .encrypt_and_store_mnemonic(mnemonic2) + .await + .unwrap(); + + // Fingerprints should be different + assert_ne!( + fp1, fp2, + "Different mnemonics should have different fingerprints" + ); + + // Both should be listed + let seed_fps = keystore.list_seed_fingerprints().await.unwrap(); + assert_eq!(seed_fps.len(), 2); + assert!(seed_fps.contains(&fp1)); + assert!(seed_fps.contains(&fp2)); + } + + /// Test that keystore is not locked when using test identities. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn keystore_unlocked_for_testing() { + let network = Network::Consensus(consensus::Network::MainNetwork); + let test_db = TestDatabase::new(network).await.unwrap(); + let database = Database::from_pool(test_db.pool().clone()); + let keystore = test_keystore(database).await.unwrap(); + + // Test keystore should not be locked + assert!( + !keystore.is_locked().await, + "Test keystore should not be locked" + ); + + // Should not use encrypted identities + assert!( + !keystore.uses_encrypted_identities(), + "Test keystore should not use encrypted identities" + ); + } + } +} diff --git a/zallet/src/components/keystore/testing.rs b/zallet/src/components/keystore/testing.rs new file mode 100644 index 00000000..99e7ccdc --- /dev/null +++ b/zallet/src/components/keystore/testing.rs @@ -0,0 +1,30 @@ +//! Test utilities for keystore operations. + +use super::KeyStore; +use crate::{components::database::Database, error::Error}; + +/// Generates a test age identity and its corresponding recipient. +/// +/// Returns a tuple of (identity, recipient_string) suitable for testing. +pub(crate) fn generate_test_identity() -> (age::x25519::Identity, String) { + let identity = age::x25519::Identity::generate(); + let recipient = identity.to_public(); + (identity, recipient.to_string()) +} + +/// Creates a test KeyStore with a freshly generated identity. +/// +/// The keystore will be unlocked (identities loaded) and have recipients +/// initialized in the database. +pub(crate) async fn test_keystore(db: Database) -> Result { + let (identity, recipient_string) = generate_test_identity(); + + let keystore = KeyStore::new_for_testing(db, vec![Box::new(identity)]); + + // Initialize recipients so encryption operations work + keystore + .initialize_recipients(vec![recipient_string]) + .await?; + + Ok(keystore) +} diff --git a/zallet/src/components/testing.rs b/zallet/src/components/testing.rs new file mode 100644 index 00000000..9bc49902 --- /dev/null +++ b/zallet/src/components/testing.rs @@ -0,0 +1,170 @@ +//! Unified test fixtures for wallet testing. +//! +//! This module provides test utilities for creating in-memory wallets with +//! accounts, suitable for unit and integration testing of wallet functionality. + +use bip0039::{Count, English, Mnemonic}; +use rand::{RngCore, rngs::OsRng}; +use zcash_client_backend::{ + data_api::{AccountBirthday, WalletWrite, chain::ChainState}, + keys::UnifiedSpendingKey, +}; +use zcash_primitives::block::BlockHash; +use zcash_protocol::consensus::BlockHeight; +use zip32::fingerprint::SeedFingerprint; + +use crate::{ + components::{ + database::{Database, DbHandle, testing::TestDatabase}, + keystore::{KeyStore, testing::test_keystore}, + }, + error::Error, + network::Network, +}; + +/// A complete test wallet environment with database and keystore. +/// +/// This provides an in-memory wallet suitable for testing RPC methods +/// and other wallet functionality without requiring disk access. +pub(crate) struct TestWallet { + db: TestDatabase, + keystore: KeyStore, +} + +impl TestWallet { + /// Creates a new test wallet with in-memory database. + pub(crate) async fn new(network: Network) -> Result { + let db = TestDatabase::new(network).await?; + + // Create a Database wrapper for KeyStore using the same pool + let database = Database::from_pool(db.pool().clone()); + let keystore = test_keystore(database).await?; + + Ok(Self { db, keystore }) + } + + /// Gets a database handle. + pub(crate) async fn handle(&self) -> Result { + self.db.handle().await + } + + /// Returns a reference to the keystore. + pub(crate) fn keystore(&self) -> &KeyStore { + &self.keystore + } + + /// Returns the network parameters. + #[allow(dead_code)] + pub(crate) fn params(&self) -> Network { + self.db.params() + } + + /// Creates a new test account builder. + pub(crate) fn account_builder(&self) -> TestAccountBuilder<'_> { + TestAccountBuilder::new(self) + } +} + +/// Builder for creating test accounts. +pub(crate) struct TestAccountBuilder<'a> { + wallet: &'a TestWallet, + mnemonic: Option>, + birthday_height: u32, + name: String, +} + +impl<'a> TestAccountBuilder<'a> { + fn new(wallet: &'a TestWallet) -> Self { + Self { + wallet, + mnemonic: None, + birthday_height: 1, + name: "test_account".into(), + } + } + + /// Sets a specific mnemonic phrase. + #[allow(dead_code)] + pub(crate) fn with_mnemonic(mut self, mnemonic: Mnemonic) -> Self { + self.mnemonic = Some(mnemonic); + self + } + + /// Sets the account birthday height. + #[allow(dead_code)] + pub(crate) fn with_birthday(mut self, height: u32) -> Self { + self.birthday_height = height; + self + } + + /// Sets the account name. + #[allow(dead_code)] + pub(crate) fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Builds the test account. + pub(crate) async fn build(self) -> Result { + // Generate or use provided mnemonic + let mnemonic = self.mnemonic.unwrap_or_else(|| { + // Adapted from `Mnemonic::generate` so we can use `OsRng` directly. + const BITS_PER_BYTE: usize = 8; + const MAX_ENTROPY_BITS: usize = Count::Words24.entropy_bits(); + const ENTROPY_BYTES: usize = MAX_ENTROPY_BITS / BITS_PER_BYTE; + + let mut entropy = [0u8; ENTROPY_BYTES]; + OsRng.fill_bytes(&mut entropy); + + Mnemonic::::from_entropy(entropy) + .expect("valid entropy length won't fail to generate the mnemonic") + }); + + // Store mnemonic in keystore + let seed_fp = self + .wallet + .keystore() + .encrypt_and_store_mnemonic(mnemonic.clone()) + .await?; + + // Decrypt seed for account creation + let seed = self.wallet.keystore().decrypt_seed(&seed_fp).await?; + + // Create minimal birthday with empty chain state (no tree data needed for basic tests) + let chain_state = ChainState::empty( + BlockHeight::from_u32(self.birthday_height.saturating_sub(1)), + BlockHash([0; 32]), + ); + let birthday = AccountBirthday::from_parts(chain_state, None); + + // Create account in database + let handle = self.wallet.handle().await?; + let name = self.name.clone(); + let (account_id, usk) = handle + .with_mut(|mut db| db.create_account(&name, &seed, &birthday, None)) + .map_err(|e| crate::error::ErrorKind::Generic.context(e))?; + + Ok(TestAccount { + account_id, + usk, + seed_fp, + seed, + mnemonic, + }) + } +} + +/// A test account with its keys and metadata. +#[allow(dead_code)] +pub(crate) struct TestAccount { + /// The account identifier in the database. + pub account_id: zcash_client_sqlite::AccountUuid, + /// The unified spending key for this account. + pub usk: UnifiedSpendingKey, + /// The seed fingerprint. + pub seed_fp: SeedFingerprint, + /// The decrypted seed bytes. + pub seed: secrecy::SecretVec, + /// The mnemonic phrase. + pub mnemonic: Mnemonic, +}