#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
#![cfg_attr(any(), deny(clippy::unwrap_used))]
use anyhow::Result;
use http_relay::HttpRelay;
use pubky::{Keypair, Pubky};
use pubky_homeserver::{
storage_config::StorageConfigToml, ConfigToml, ConnectionString, DomainPort, HomeserverApp,
MockDataDir,
};
use std::{str::FromStr, time::Duration};
use url::Url;
pub struct Testnet {
pub(crate) dht: pkarr::mainline::Testnet,
pub(crate) pkarr_relays: Vec<pkarr_relay::Relay>,
pub(crate) http_relays: Vec<HttpRelay>,
pub(crate) homeservers: Vec<HomeserverApp>,
pub(crate) postgres_connection_string: Option<ConnectionString>,
temp_dirs: Vec<tempfile::TempDir>,
}
impl Testnet {
fn new_inner(seeded: bool) -> Result<Self> {
let dht = match seeded {
true => pkarr::mainline::Testnet::new(2)?,
false => pkarr::mainline::Testnet::new_unseeded(2)?,
};
let testnet = Self {
dht,
pkarr_relays: vec![],
http_relays: vec![],
homeservers: vec![],
temp_dirs: vec![],
postgres_connection_string: Self::extract_postgres_connection_string_from_env_variable(
),
};
Ok(testnet)
}
pub async fn new() -> Result<Self> {
Self::new_inner(true)
}
pub async fn new_unseeded() -> Result<Self> {
Self::new_inner(false)
}
pub async fn new_with_custom_postgres(
postgres_connection_string: ConnectionString,
) -> Result<Self> {
let dht = pkarr::mainline::Testnet::builder(2).build()?;
let testnet: Testnet = Self {
dht,
pkarr_relays: vec![],
http_relays: vec![],
homeservers: vec![],
temp_dirs: vec![],
postgres_connection_string: Some(postgres_connection_string),
};
Ok(testnet)
}
fn extract_postgres_connection_string_from_env_variable() -> Option<ConnectionString> {
if let Ok(raw_con_string) = std::env::var("TEST_PUBKY_CONNECTION_STRING") {
if let Ok(con_string) = ConnectionString::new(&raw_con_string) {
return Some(con_string);
} else {
tracing::warn!("Invalid database connection string in TEST_PUBKY_CONNECTION_STRING environment variable. Ignoring it.");
}
}
None
}
pub async fn create_homeserver(&mut self) -> Result<&HomeserverApp> {
let mut config = ConfigToml::default_test_config();
if let Some(connection_string) = self.postgres_connection_string.as_ref() {
config.general.database_url = connection_string.clone();
}
let mock_dir = MockDataDir::new(config, Some(Keypair::from_secret(&[0; 32])))?;
self.create_homeserver_app_with_mock(mock_dir).await
}
pub async fn create_random_homeserver(&mut self) -> Result<&HomeserverApp> {
let mut config = ConfigToml::default_test_config();
if let Some(connection_string) = self.postgres_connection_string.as_ref() {
config.general.database_url = connection_string.clone();
}
let mock_dir = MockDataDir::new(config, Some(Keypair::random()))?;
self.create_homeserver_app_with_mock(mock_dir).await
}
pub async fn create_homeserver_app_with_mock(
&mut self,
mut mock_dir: MockDataDir,
) -> Result<&HomeserverApp> {
mock_dir.config_toml.pkdns.dht_bootstrap_nodes = Some(self.dht_bootstrap_nodes());
if !self.dht_relay_urls().is_empty() {
mock_dir.config_toml.pkdns.dht_relay_nodes = Some(self.dht_relay_urls().to_vec());
}
mock_dir.config_toml.storage = StorageConfigToml::InMemory;
let homeserver = HomeserverApp::start_with_mock_data_dir(mock_dir).await?;
self.homeservers.push(homeserver);
Ok(self
.homeservers
.last()
.expect("homeservers should be non-empty"))
}
pub async fn create_http_relay(&mut self) -> Result<&HttpRelay> {
let relay = HttpRelay::builder()
.http_port(0) .cors_allow_all(true)
.run()
.await?;
self.http_relays.push(relay);
Ok(self
.http_relays
.last()
.expect("http relays should be non-empty"))
}
pub async fn create_pkarr_relay(&mut self) -> Result<Url> {
let dir = tempfile::tempdir()?;
let mut builder = pkarr_relay::Relay::builder();
builder
.disable_rate_limiter()
.http_port(0)
.storage(dir.path().to_path_buf())
.pkarr(|builder| {
builder.no_default_network();
builder.bootstrap(&self.dht.bootstrap);
builder
});
let relay = unsafe { builder.run().await? };
let url = relay.local_url();
self.pkarr_relays.push(relay);
self.temp_dirs.push(dir);
Ok(url)
}
pub fn dht_bootstrap_nodes(&self) -> Vec<DomainPort> {
self.dht
.nodes
.iter()
.map(|node| {
let addr = node.info().local_addr();
DomainPort::from_str(&format!("{}:{}", addr.ip(), addr.port()))
.expect("boostrap nodes from the pkarr dht are always valid domain:port pairs")
})
.collect()
}
pub fn dht_relay_urls(&self) -> Vec<Url> {
self.pkarr_relays.iter().map(|r| r.local_url()).collect()
}
pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
let relays = self.dht_relay_urls();
let mut builder = pubky::PubkyHttpClient::builder();
builder.pkarr(|builder| {
builder.no_default_network();
builder.bootstrap(&self.dht.bootstrap);
if relays.is_empty() {
builder.no_relays();
} else {
builder
.relays(&relays)
.expect("testnet relays should be valid urls");
}
builder.request_timeout(Duration::from_millis(100));
builder
});
builder
}
pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
self.client_builder().build()
}
pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
Ok(Pubky::with_client(self.client()?))
}
pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
let relays = self.dht_relay_urls();
let mut builder = pkarr::Client::builder();
builder.no_default_network(); builder.bootstrap(&self.dht.bootstrap);
if !relays.is_empty() {
builder
.relays(&relays)
.expect("Testnet relays should be valid urls");
}
builder
}
}
#[cfg(test)]
mod test {
use crate::Testnet;
use pubky::Keypair;
#[tokio::test]
#[crate::test]
async fn test_keep_relays_alive_even_when_dropped() {
let mut testnet = Testnet::new().await.unwrap();
{
let _relay = testnet.create_http_relay().await.unwrap();
}
assert_eq!(testnet.http_relays.len(), 1);
}
#[tokio::test]
#[crate::test]
async fn test_boostrap_node_conversion() {
let testnet = Testnet::new().await.unwrap();
let nodes = testnet.dht_bootstrap_nodes();
assert_eq!(nodes.len(), 2);
}
#[tokio::test]
#[crate::test]
async fn test_signup() {
let mut testnet = Testnet::new().await.unwrap();
testnet.create_homeserver().await.unwrap();
let hs = testnet.homeservers.first().unwrap();
let sdk = testnet.sdk().unwrap();
let signer = sdk.signer(Keypair::random());
let session = signer.signup(&hs.public_key(), None).await.unwrap();
assert_eq!(session.info().public_key(), &signer.public_key());
}
#[tokio::test]
async fn test_independent_dhts() {
let t1 = Testnet::new().await.unwrap();
let t2 = Testnet::new().await.unwrap();
assert_ne!(t1.dht.bootstrap, t2.dht.bootstrap);
}
#[tokio::test]
async fn test_homeserver_resolvable() {
let mut testnet = Testnet::new().await.unwrap();
let hs_pubky = testnet.create_homeserver().await.unwrap().public_key();
let pkarr_client = testnet.pkarr_client_builder().build().unwrap();
let _packet = pkarr_client.resolve(&hs_pubky).await.unwrap();
let pubkey = hs_pubky.z32();
let _endpoint = pkarr_client
.resolve_https_endpoint(pubkey.as_str())
.await
.unwrap();
}
#[tokio::test]
#[crate::test]
async fn test_pkarr_relay_resolvable() {
let mut testnet = Testnet::new().await.unwrap();
testnet.create_pkarr_relay().await.unwrap();
let keypair = Keypair::random();
let client = testnet.pkarr_client_builder().build().unwrap();
let signed = pkarr::SignedPacket::builder().sign(&keypair).unwrap();
client.publish(&signed, None).await.unwrap();
let client = testnet.pkarr_client_builder().no_relays().build().unwrap();
let packet = client.resolve(&keypair.public_key()).await;
assert!(
packet.is_some(),
"Published packet is not available over the DHT."
);
let client = testnet.pkarr_client_builder().no_dht().build().unwrap();
let packet = client.resolve(&keypair.public_key()).await;
assert!(
packet.is_some(),
"Published packet is not available over the relay only."
);
}
}