use std::path::PathBuf; use std::str::FromStr; use radicle::cob::cache::COBS_DB_FILE; use radicle::crypto::ssh::{keystore::MemorySigner, Keystore}; use radicle::crypto::{KeyPair, Seed}; use radicle::git; use radicle::node::policy::store as policy; use radicle::node::{self, UserAgent}; use radicle::node::{Alias, Config, POLICIES_DB_FILE}; use radicle::profile::Home; use radicle::profile::{self}; use radicle::storage::git::transport; use radicle::{Profile, Storage}; use radicle_localtime::LocalTime; use radicle_node::test::node::{Node, NodeHandle}; use crate::util::formula::formula; pub(crate) mod config { use super::*; use radicle::node::config::{Config, Limits, Network, RateLimit, RateLimits}; /// Configuration for a test seed node. /// /// It sets the `RateLimit::capacity` to `usize::MAX` ensuring /// that there are no rate limits for test nodes, since they all /// operate on the same IP address. This prevents any announcement /// messages from being dropped. pub fn seed(alias: &'static str) -> Config { Config { network: Network::Test, relay: node::config::Relay::Always, limits: Limits { rate: RateLimits { inbound: RateLimit { fill_rate: 1.0, capacity: usize::MAX, } .into(), outbound: RateLimit { fill_rate: 1.0, capacity: usize::MAX, } .into(), }, ..Limits::default() }, external_addresses: vec![node::Address::from_str(&format!( "{alias}.radicle.example:8776" )) .unwrap()], ..node(alias) } } /// Relay node config. pub fn relay(alias: &'static str) -> Config { Config { relay: node::config::Relay::Always, ..node(alias) } } /// Test node config. pub fn node(alias: &'static str) -> Config { Config::test(Alias::new(alias)) } } /// Test environment. pub struct Environment { tempdir: tempfile::TempDir, users: usize, } impl Default for Environment { fn default() -> Self { Self::new() } } impl Environment { /// Create a new test environment. pub fn new() -> Self { Self { tempdir: tempfile::TempDir::new().unwrap(), users: 0, } } /// Return the path of the temporary directory at which /// this testing environment is rooted. pub fn tempdir(&self) -> PathBuf { self.tempdir.path().into() } /// Return the home directory of the user with the given alias. /// This is in analogy to a Unix home directory. pub fn unix_home(&self, has_alias: &impl HasAlias) -> PathBuf { self.tempdir().join(has_alias.alias().to_string()) } /// Return the Radicle path of the user with the given alias. /// This is in analogy to `$RAD_HOME` and always a subdirectory of /// the user's home directory (see [`Environment::unix_home`]). pub fn rad_home(&self, has_alias: &impl HasAlias) -> Home { Home::new(self.unix_home(has_alias).join(".radicle")).unwrap() } /// Path to the working directory of the user with the given alias. /// Tests that need to act on multiple repositories should crate /// subdirecories within this directory. pub fn work(&self, has_alias: &impl HasAlias) -> PathBuf { self.unix_home(has_alias).join("work") } /// Create a new default configuration. pub fn config(&self, alias: &str) -> profile::Config { let alias = Alias::new(alias); profile::Config { node: node::Config::test(alias), cli: radicle::cli::Config { hints: false }, public_explorer: radicle::explorer::Explorer::default(), preferred_seeds: vec![], web: radicle::web::Config::default(), } } /// Create a new profile in this environment. /// This should be used when a running node is not required. /// Using this function is only necessary if the desired configuration /// differs from the default provided by [`Environment::config`] as /// for this default the convenience function [`Environment::profile`] /// is provided. pub fn profile_with(&mut self, config: profile::Config) -> Profile { let alias = config.alias().clone(); let home = self.rad_home(&alias); let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32])); let policies_db = home.node().join(POLICIES_DB_FILE); let cobs_db = home.cobs().join(COBS_DB_FILE); config.write(&home.config()).unwrap(); let storage = Storage::open( home.storage(), radicle::git::UserInfo { alias: alias.clone(), key: keypair.pk.into(), }, ) .unwrap(); let mut db = home.cobs_db_mut().unwrap(); db.migrate(radicle::cob::migrate::ignore).unwrap(); policy::Store::open(policies_db).unwrap(); home.database_mut() .unwrap() .init( &keypair.pk.into(), config.node.features(), &alias, &UserAgent::default(), LocalTime::now().into(), config.node.external_addresses.iter(), ) .unwrap(); radicle::cob::cache::Store::open(cobs_db).unwrap(); transport::local::register(storage.clone()); let keystore = Keystore::new(&home.keys()); keystore.store(keypair.clone(), "radicle", None).unwrap(); // Ensures that each user has a unique but deterministic public key. self.users += 1; Profile { home, storage, keystore, public_key: keypair.pk.into(), config, } } /// Create a new profile using a the default configuration from [`Environment::config`]. pub fn profile(&mut self, alias: &'static str) -> Profile { self.profile_with(self.config(alias)) } /// Create a new node in this environment. This should be used when a running node /// is required. Use [`Environment::profile`] otherwise. /// Using this function is only necessary when the node configuration differs /// from the standard ones ([`config::node`], [`config::relay`], [`config::seed`]), /// as for each of them a convenience function /// (resp. [`Environment::node`], [`Environment::relay`], [`Environment::seed`]). /// is provided to reduce boilerplate. pub fn node_with(&mut self, node: Config) -> Node { let alias = node.alias.clone(); let profile = self.profile_with(profile::Config { node, ..self.config(alias.as_ref()) }); Node::new(profile) } /// Convenience method for creating a [`Node`] /// using configuration [`config::node`] within this [`Environment`]. pub fn node(&mut self, alias: &'static str) -> Node { self.node_with(config::node(alias)) } /// Convenience method for creating a [`Node`] /// using configuration [`config::relay`] within this [`Environment`]. pub fn relay(&mut self, alias: &'static str) -> Node { self.node_with(config::relay(alias)) } /// Convenience method for creating a [`Node`] /// using configuration [`config::seed`] within this [`Environment`]. pub fn seed(&mut self, alias: &'static str) -> Node { self.node_with(config::seed(alias)) } /// Convenience method for placing repository fixture into the /// directory returned by [`Environment::work`] for the user. /// Use this only in tests that act on *a single repository* only. /// For tests that need to act on multiple repositories, /// create the repositories as subdirectories of the working /// directory returned by [`Environment::work`]. pub fn repository( &self, has_alias: &impl HasAlias, ) -> (radicle_cli::git::Repository, git::raw::Oid) { radicle::test::fixtures::repository(self.work(has_alias).as_path()) } // Convenience method for executing a test formula with standard configuration. pub fn test( &self, test_file: &'static str, subject: &impl HasAlias, ) -> Result<(), Box> { formula( self.work(subject).as_ref(), PathBuf::from("examples").join(test_file.to_owned() + ".md"), )? .env("USER", subject.alias().as_ref()) .env("RAD_HOME", self.rad_home(subject).path().to_string_lossy()) .env( "JJ_CONFIG", self.unix_home(subject) .join(".jjconfig.toml") .to_string_lossy(), ) .run()?; Ok(()) } /// Convenience method for executing multiple test formulas with standard configuration. pub fn tests( &self, test_files: impl IntoIterator, subject: &impl HasAlias, ) -> Result<(), Box> { for test_file in test_files { self.test(test_file, subject)?; } Ok(()) } /// Convenience method for creating exactly one profile with alias "alice" /// and running tests within it. pub fn alice(test_files: impl IntoIterator) { let mut env = Environment::new(); let alice = env.profile("alice"); env.repository(&alice); env.tests(test_files, &alice).unwrap(); } } pub trait HasAlias { fn alias(&self) -> &Alias; } impl HasAlias for Alias { fn alias(&self) -> &Alias { self } } impl HasAlias for Node { fn alias(&self) -> &Alias { &self.config.alias } } impl HasAlias for Profile { fn alias(&self) -> &Alias { self.config.alias() } } impl HasAlias for NodeHandle { fn alias(&self) -> &Alias { &self.alias } }