diff --git a/Cargo.lock b/Cargo.lock index c67c669d..d4d49b25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1852,6 +1852,7 @@ dependencies = [ "radicle", "radicle-cob", "radicle-crypto", + "radicle-node", "serde", "serde_json", "serde_yaml", diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml index 4b82b281..2134a339 100644 --- a/radicle-cli/Cargo.toml +++ b/radicle-cli/Cargo.toml @@ -43,5 +43,6 @@ path = "../radicle-crypto" pretty_assertions = { version = "1.3.0" } tempfile = { version = "3.3.0" } radicle = { path = "../radicle", features = ["test"] } +radicle-node = { path = "../radicle-node", features = ["test"] } shlex = { version = "1.1.0" } snapbox = { version = "0.4.3" } diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index 37fd0a11..ae84a97a 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -1,56 +1,14 @@ use std::env; use std::path::Path; -use radicle::crypto::ssh::Keystore; -use radicle::crypto::KeyPair; use radicle::git; -use radicle::profile::{Home, Profile}; -use radicle::storage::git::transport; -use radicle::storage::git::Storage; +use radicle::profile::Home; use radicle::test::fixtures; +use radicle_node::test::environment::Environment; + mod framework; use framework::TestFormula; -use radicle_crypto::Seed; - -/// Test environment. -pub struct Environment { - tempdir: tempfile::TempDir, - users: usize, -} - -impl Environment { - /// Create a new test environment. - fn new() -> Self { - Self { - tempdir: tempfile::tempdir().unwrap(), - users: 0, - } - } - - /// Create a new profile in this environment. - fn profile(&mut self, name: &str) -> Profile { - let home = Home::new(self.tempdir.path().join(name)).init().unwrap(); - let storage = Storage::open(home.storage()).unwrap(); - let keystore = Keystore::new(&home.keys()); - let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32])); - - transport::local::register(storage.clone()); - keystore - .store(keypair.clone(), "radicle", "radicle".to_owned()) - .unwrap(); - - // Ensures that each user has a unique but deterministic public key. - self.users += 1; - - Profile { - home, - storage, - keystore, - public_key: keypair.pk.into(), - } - } -} /// Run a CLI test file. fn test<'a>( diff --git a/radicle-node/src/test.rs b/radicle-node/src/test.rs index 8660381a..c480a954 100644 --- a/radicle-node/src/test.rs +++ b/radicle-node/src/test.rs @@ -1,4 +1,5 @@ pub mod arbitrary; +pub mod environment; pub mod gossip; pub mod handle; pub mod logger; diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs new file mode 100644 index 00000000..1e2ce71a --- /dev/null +++ b/radicle-node/src/test/environment.rs @@ -0,0 +1,264 @@ +use std::mem::ManuallyDrop; +use std::path::Path; +use std::{ + collections::{BTreeMap, BTreeSet}, + iter, net, thread, + time::Duration, +}; + +use radicle::crypto::ssh::{keystore::MemorySigner, Keystore}; +use radicle::crypto::test::signer::MockSigner; +use radicle::crypto::{KeyPair, Seed, Signature, Signer}; +use radicle::git::refname; +use radicle::identity::Id; +use radicle::node::Handle as _; +use radicle::profile::Home; +use radicle::profile::Profile; +use radicle::rad; +use radicle::storage::ReadStorage; +use radicle::test::fixtures; +use radicle::Storage; + +use crate::node::NodeId; +use crate::storage::git::transport; +use crate::{runtime, runtime::Handle, service, Runtime}; + +/// Test environment. +pub struct Environment { + tempdir: tempfile::TempDir, + users: usize, +} + +impl Default for Environment { + fn default() -> Self { + Self { + tempdir: tempfile::tempdir().unwrap(), + users: 0, + } + } +} + +impl Environment { + /// Create a new test environment. + pub fn new() -> Self { + Self::default() + } + + /// Create a new node in this environment. This should be used when a running node + /// is required. Use [`Environment::profile`] otherwise. + pub fn node(&mut self, name: &str) -> Node { + let profile = self.profile(name); + let signer = MemorySigner::load(&profile.keystore, "radicle".to_owned().into()).unwrap(); + + Node { + home: profile.home, + signer, + storage: profile.storage, + } + } + + /// Create a new profile in this environment. + /// This should be used when a running node is not required. + pub fn profile(&mut self, name: &str) -> Profile { + let home = Home::new(self.tempdir.path().join(name)).init().unwrap(); + let storage = Storage::open(home.storage()).unwrap(); + let keystore = Keystore::new(&home.keys()); + let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32])); + + transport::local::register(storage.clone()); + keystore + .store(keypair.clone(), "radicle", "radicle".to_owned()) + .unwrap(); + + // Ensures that each user has a unique but deterministic public key. + self.users += 1; + + Profile { + home, + storage, + keystore, + public_key: keypair.pk.into(), + } + } +} + +/// A node that can be run. +pub struct Node { + pub home: Home, + pub signer: G, + pub storage: Storage, +} + +/// Handle to a running node. +pub struct NodeHandle { + pub id: NodeId, + pub storage: Storage, + pub signer: G, + pub addr: net::SocketAddr, + pub thread: ManuallyDrop>>, + pub handle: ManuallyDrop>, +} + +impl Drop for NodeHandle { + fn drop(&mut self) { + log::debug!(target: "test", "Node {} shutting down..", self.id); + + unsafe { ManuallyDrop::take(&mut self.handle) } + .shutdown() + .unwrap(); + unsafe { ManuallyDrop::take(&mut self.thread) } + .join() + .unwrap() + .unwrap(); + } +} + +impl NodeHandle { + /// Connect this node to another node, and wait for the connection to be established both ways. + pub fn connect(&mut self, remote: &NodeHandle) { + self.handle.connect(remote.id, remote.addr.into()).unwrap(); + + loop { + let local_sessions = self.handle.sessions().unwrap(); + let remote_sessions = remote.handle.sessions().unwrap(); + + let local_sessions = local_sessions + .negotiated() + .map(|(id, _)| id) + .collect::>(); + let remote_sessions = remote_sessions + .negotiated() + .map(|(id, _)| id) + .collect::>(); + + if local_sessions.contains(&remote.id) && remote_sessions.contains(&self.id) { + log::debug!(target: "test", "Connection between {} and {} established", self.id, remote.id); + break; + } + thread::sleep(Duration::from_millis(100)); + } + } +} + +impl Node { + /// Create a new node. + pub fn init(base: &Path) -> Self { + let home = base.join( + iter::repeat_with(fastrand::alphanumeric) + .take(8) + .collect::(), + ); + let home = Home::new(home).init().unwrap(); + let signer = MockSigner::default(); + let storage = Storage::open(home.storage()).unwrap(); + + Self { + home, + signer, + storage, + } + } +} + +impl + Signer + Clone> Node { + /// Spawn a node in its own thread. + pub fn spawn(self, config: service::Config) -> NodeHandle { + let listen = vec![([0, 0, 0, 0], 0).into()]; + let proxy = net::SocketAddr::new(net::Ipv4Addr::LOCALHOST.into(), 9050); + let daemon = ([0, 0, 0, 0], fastrand::u16(1025..)).into(); + let rt = Runtime::init( + self.home, + config, + listen, + proxy, + daemon, + self.signer.clone(), + ) + .unwrap(); + let addr = *rt.local_addrs.first().unwrap(); + let id = *self.signer.public_key(); + let handle = ManuallyDrop::new(rt.handle.clone()); + let thread = ManuallyDrop::new( + thread::Builder::new() + .name(id.to_string()) + .spawn(move || rt.run()) + .unwrap(), + ); + + NodeHandle { + id, + storage: self.storage, + signer: self.signer, + addr, + handle, + thread, + } + } + + /// Populate a storage instance with a project. + pub fn project(&mut self, name: &str) -> Id { + transport::local::register(self.storage.clone()); + + let tmp = tempfile::tempdir().unwrap(); + let (repo, _) = fixtures::gen::repository(tmp.path()); + let description = iter::repeat_with(fastrand::alphabetic) + .take(12) + .collect::(); + let id = rad::init( + &repo, + name, + &description, + refname!("master"), + &self.signer, + &self.storage, + ) + .map(|(id, _, _)| id) + .unwrap(); + + log::debug!( + target: "test", + "Initialized project {id} for node {}", self.signer.public_key() + ); + + id + } +} + +/// Checks whether the nodes have converged in their routing tables. +#[track_caller] +pub fn converge<'a, G: Signer + cyphernet::EcSign + 'static>( + nodes: impl IntoIterator>, +) -> BTreeSet<(Id, NodeId)> { + let nodes = nodes.into_iter().collect::>(); + + let mut all_routes = BTreeSet::<(Id, NodeId)>::new(); + let mut remaining = BTreeMap::from_iter(nodes.iter().map(|node| (node.id, node))); + + // First build the set of all routes. + for node in &nodes { + let inv = node.storage.inventory().unwrap(); + + for rid in inv { + all_routes.insert((rid, node.id)); + } + } + + // Then, while there are nodes remaining to converge, check each node to see if + // its routing table has all routes. If so, remove it from the remaining nodes. + while !remaining.is_empty() { + remaining.retain(|_, node| { + let routing = node.handle.routing().unwrap(); + let routes = BTreeSet::from_iter(routing.try_iter()); + + if routes == all_routes { + log::debug!(target: "test", "Node {} has converged", node.id); + return false; + } else { + log::debug!(target: "test", "Node {} has {:?}", node.id, routes); + } + true + }); + thread::sleep(Duration::from_millis(100)); + } + all_routes +} diff --git a/radicle-node/src/tests/e2e.rs b/radicle-node/src/tests/e2e.rs index 3c11d322..9860cc23 100644 --- a/radicle-node/src/tests/e2e.rs +++ b/radicle-node/src/tests/e2e.rs @@ -1,203 +1,12 @@ -use std::mem::ManuallyDrop; -use std::path::Path; -use std::{ - collections::{BTreeMap, BTreeSet}, - iter, net, thread, - time::Duration, -}; - -use radicle::crypto::test::signer::MockSigner; use radicle::crypto::Signer; -use radicle::git::refname; -use radicle::identity::Id; use radicle::node::{FetchResult, Handle as _}; -use radicle::profile::Home; -use radicle::storage::{ReadRepository, ReadStorage, WriteStorage}; -use radicle::test::fixtures; -use radicle::Storage; +use radicle::storage::{ReadRepository, WriteStorage}; use radicle::{assert_matches, rad}; -use crate::node::NodeId; +use crate::service; use crate::storage::git::transport; +use crate::test::environment::{converge, Node}; use crate::test::logger; -use crate::{runtime, runtime::Handle, service, Runtime}; - -/// A node that can be run. -struct Node { - home: Home, - signer: MockSigner, - storage: Storage, -} - -/// Handle to a running node. -struct NodeHandle { - id: NodeId, - storage: Storage, - signer: MockSigner, - addr: net::SocketAddr, - thread: ManuallyDrop>>, - handle: ManuallyDrop>, -} - -impl Drop for NodeHandle { - fn drop(&mut self) { - log::debug!(target: "test", "Node {} shutting down..", self.id); - - unsafe { ManuallyDrop::take(&mut self.handle) } - .shutdown() - .unwrap(); - unsafe { ManuallyDrop::take(&mut self.thread) } - .join() - .unwrap() - .unwrap(); - } -} - -impl NodeHandle { - /// Connect this node to another node, and wait for the connection to be established both ways. - fn connect(&mut self, remote: &NodeHandle) { - self.handle.connect(remote.id, remote.addr.into()).unwrap(); - - loop { - let local_sessions = self.handle.sessions().unwrap(); - let remote_sessions = remote.handle.sessions().unwrap(); - - let local_sessions = local_sessions - .negotiated() - .map(|(id, _)| id) - .collect::>(); - let remote_sessions = remote_sessions - .negotiated() - .map(|(id, _)| id) - .collect::>(); - - if local_sessions.contains(&remote.id) && remote_sessions.contains(&self.id) { - log::debug!(target: "test", "Connection between {} and {} established", self.id, remote.id); - break; - } - thread::sleep(Duration::from_millis(100)); - } - } -} - -impl Node { - /// Create a new node. - fn new(base: &Path) -> Self { - let home = base.join( - iter::repeat_with(fastrand::alphanumeric) - .take(8) - .collect::(), - ); - let home = Home::new(home).init().unwrap(); - let signer = MockSigner::default(); - let storage = Storage::open(home.storage()).unwrap(); - - Self { - home, - signer, - storage, - } - } - - /// Spawn a node in its own thread. - fn spawn(self, config: service::Config) -> NodeHandle { - let listen = vec![([0, 0, 0, 0], 0).into()]; - let proxy = net::SocketAddr::new(net::Ipv4Addr::LOCALHOST.into(), 9050); - let daemon = ([0, 0, 0, 0], fastrand::u16(1025..)).into(); - let rt = Runtime::init( - self.home, - config, - listen, - proxy, - daemon, - self.signer.clone(), - ) - .unwrap(); - let addr = *rt.local_addrs.first().unwrap(); - let id = *self.signer.public_key(); - let handle = ManuallyDrop::new(rt.handle.clone()); - let thread = ManuallyDrop::new( - thread::Builder::new() - .name(id.to_string()) - .spawn(move || rt.run()) - .unwrap(), - ); - - NodeHandle { - id, - storage: self.storage, - signer: self.signer, - addr, - handle, - thread, - } - } - - /// Populate a storage instance with a project. - fn project(&mut self, name: &str) -> Id { - transport::local::register(self.storage.clone()); - - let tmp = tempfile::tempdir().unwrap(); - let (repo, _) = fixtures::gen::repository(tmp.path()); - let description = iter::repeat_with(fastrand::alphabetic) - .take(12) - .collect::(); - let id = rad::init( - &repo, - name, - &description, - refname!("master"), - &self.signer, - &self.storage, - ) - .map(|(id, _, _)| id) - .unwrap(); - - log::debug!( - target: "test", - "Initialized project {id} for node {}", self.signer.public_key() - ); - - id - } -} - -/// Checks whether the nodes have converged in their routing tables. -#[track_caller] -fn converge<'a>(nodes: impl IntoIterator) -> BTreeSet<(Id, NodeId)> { - let nodes = nodes.into_iter().collect::>(); - - let mut all_routes = BTreeSet::<(Id, NodeId)>::new(); - let mut remaining = BTreeMap::from_iter(nodes.iter().map(|node| (node.id, node))); - - // First build the set of all routes. - for node in &nodes { - let inv = node.storage.inventory().unwrap(); - - for rid in inv { - all_routes.insert((rid, node.id)); - } - } - - // Then, while there are nodes remaining to converge, check each node to see if - // its routing table has all routes. If so, remove it from the remaining nodes. - while !remaining.is_empty() { - remaining.retain(|_, node| { - let routing = node.handle.routing().unwrap(); - let routes = BTreeSet::from_iter(routing.try_iter()); - - if routes == all_routes { - log::debug!(target: "test", "Node {} has converged", node.id); - return false; - } else { - log::debug!(target: "test", "Node {} has {:?}", node.id, routes); - } - true - }); - thread::sleep(Duration::from_millis(100)); - } - all_routes -} #[test] // @@ -208,8 +17,8 @@ fn test_inventory_sync_basic() { let tmp = tempfile::tempdir().unwrap(); - let mut alice = Node::new(tmp.path()); - let mut bob = Node::new(tmp.path()); + let mut alice = Node::init(tmp.path()); + let mut bob = Node::init(tmp.path()); alice.project("alice"); bob.project("bob"); @@ -232,9 +41,9 @@ fn test_inventory_sync_bridge() { let tmp = tempfile::tempdir().unwrap(); - let mut alice = Node::new(tmp.path()); - let mut bob = Node::new(tmp.path()); - let mut eve = Node::new(tmp.path()); + let mut alice = Node::init(tmp.path()); + let mut bob = Node::init(tmp.path()); + let mut eve = Node::init(tmp.path()); alice.project("alice"); bob.project("bob"); @@ -262,10 +71,10 @@ fn test_inventory_sync_ring() { let tmp = tempfile::tempdir().unwrap(); - let mut alice = Node::new(tmp.path()); - let mut bob = Node::new(tmp.path()); - let mut eve = Node::new(tmp.path()); - let mut carol = Node::new(tmp.path()); + let mut alice = Node::init(tmp.path()); + let mut bob = Node::init(tmp.path()); + let mut eve = Node::init(tmp.path()); + let mut carol = Node::init(tmp.path()); alice.project("alice"); bob.project("bob"); @@ -299,11 +108,11 @@ fn test_inventory_sync_star() { let tmp = tempfile::tempdir().unwrap(); - let mut alice = Node::new(tmp.path()); - let mut bob = Node::new(tmp.path()); - let mut eve = Node::new(tmp.path()); - let mut carol = Node::new(tmp.path()); - let mut dave = Node::new(tmp.path()); + let mut alice = Node::init(tmp.path()); + let mut bob = Node::init(tmp.path()); + let mut eve = Node::init(tmp.path()); + let mut carol = Node::init(tmp.path()); + let mut dave = Node::init(tmp.path()); alice.project("alice"); bob.project("bob"); @@ -331,8 +140,8 @@ fn test_replication() { logger::init(log::Level::Debug); let tmp = tempfile::tempdir().unwrap(); - let alice = Node::new(tmp.path()); - let mut bob = Node::new(tmp.path()); + let alice = Node::init(tmp.path()); + let mut bob = Node::init(tmp.path()); let acme = bob.project("acme"); let mut alice = alice.spawn(service::Config::default()); @@ -388,8 +197,8 @@ fn test_clone() { logger::init(log::Level::Debug); let tmp = tempfile::tempdir().unwrap(); - let alice = Node::new(tmp.path()); - let mut bob = Node::new(tmp.path()); + let alice = Node::init(tmp.path()); + let mut bob = Node::init(tmp.path()); let acme = bob.project("acme"); let mut alice = alice.spawn(service::Config::default()); @@ -438,8 +247,8 @@ fn test_fetch_up_to_date() { logger::init(log::Level::Debug); let tmp = tempfile::tempdir().unwrap(); - let alice = Node::new(tmp.path()); - let mut bob = Node::new(tmp.path()); + let alice = Node::init(tmp.path()); + let mut bob = Node::init(tmp.path()); let acme = bob.project("acme"); let mut alice = alice.spawn(service::Config::default());