node: Create shared test environment

We move and combine some of the test code, so that the CLI tests and
node tests use shared code.
This commit is contained in:
Alexis Sellier 2023-02-05 00:24:51 +01:00
parent abb288e742
commit b542ddcd6c
No known key found for this signature in database
6 changed files with 293 additions and 259 deletions

1
Cargo.lock generated
View File

@ -1852,6 +1852,7 @@ dependencies = [
"radicle",
"radicle-cob",
"radicle-crypto",
"radicle-node",
"serde",
"serde_json",
"serde_yaml",

View File

@ -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" }

View File

@ -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>(

View File

@ -1,4 +1,5 @@
pub mod arbitrary;
pub mod environment;
pub mod gossip;
pub mod handle;
pub mod logger;

View File

@ -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<MemorySigner> {
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<G> {
pub home: Home,
pub signer: G,
pub storage: Storage,
}
/// Handle to a running node.
pub struct NodeHandle<G: Signer + cyphernet::EcSign + 'static> {
pub id: NodeId,
pub storage: Storage,
pub signer: G,
pub addr: net::SocketAddr,
pub thread: ManuallyDrop<thread::JoinHandle<Result<(), runtime::Error>>>,
pub handle: ManuallyDrop<Handle<G>>,
}
impl<G: Signer + cyphernet::EcSign + 'static> Drop for NodeHandle<G> {
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<G: Signer + cyphernet::EcSign> NodeHandle<G> {
/// Connect this node to another node, and wait for the connection to be established both ways.
pub fn connect(&mut self, remote: &NodeHandle<G>) {
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::<BTreeSet<_>>();
let remote_sessions = remote_sessions
.negotiated()
.map(|(id, _)| id)
.collect::<BTreeSet<_>>();
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<MockSigner> {
/// Create a new node.
pub fn init(base: &Path) -> Self {
let home = base.join(
iter::repeat_with(fastrand::alphanumeric)
.take(8)
.collect::<String>(),
);
let home = Home::new(home).init().unwrap();
let signer = MockSigner::default();
let storage = Storage::open(home.storage()).unwrap();
Self {
home,
signer,
storage,
}
}
}
impl<G: cyphernet::EcSign<Pk = NodeId, Sig = Signature> + Signer + Clone> Node<G> {
/// Spawn a node in its own thread.
pub fn spawn(self, config: service::Config) -> NodeHandle<G> {
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::<String>();
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<Item = &'a NodeHandle<G>>,
) -> BTreeSet<(Id, NodeId)> {
let nodes = nodes.into_iter().collect::<Vec<_>>();
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
}

View File

@ -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<thread::JoinHandle<Result<(), runtime::Error>>>,
handle: ManuallyDrop<Handle<MockSigner>>,
}
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::<BTreeSet<_>>();
let remote_sessions = remote_sessions
.negotiated()
.map(|(id, _)| id)
.collect::<BTreeSet<_>>();
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::<String>(),
);
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::<String>();
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<Item = &'a NodeHandle>) -> BTreeSet<(Id, NodeId)> {
let nodes = nodes.into_iter().collect::<Vec<_>>();
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());