Skip to main content

pubky_testnet/
testnet.rs

1#![doc = include_str!("../README.md")]
2//!
3
4#![deny(missing_docs)]
5#![deny(rustdoc::broken_intra_doc_links)]
6#![cfg_attr(any(), deny(clippy::unwrap_used))]
7use anyhow::Result;
8use http_relay::HttpRelay;
9use pubky::{Keypair, Pubky};
10use pubky_homeserver::{
11    storage_config::StorageConfigToml, ConfigToml, ConnectionString, DomainPort, HomeserverApp,
12    MockDataDir,
13};
14use std::{str::FromStr, time::Duration};
15use url::Url;
16
17/// A local test network for Pubky Core development.
18/// Can create a flexible amount of pkarr relays, http relays and homeservers.
19///
20/// Keeps track of the components and can create new ones.
21/// Cleans up all resources when dropped.
22pub struct Testnet {
23    pub(crate) dht: pkarr::mainline::Testnet,
24    pub(crate) pkarr_relays: Vec<pkarr_relay::Relay>,
25    pub(crate) http_relays: Vec<HttpRelay>,
26    pub(crate) homeservers: Vec<HomeserverApp>,
27    pub(crate) postgres_connection_string: Option<ConnectionString>,
28
29    temp_dirs: Vec<tempfile::TempDir>,
30}
31
32impl Testnet {
33    fn new_inner(seeded: bool) -> Result<Self> {
34        let dht = match seeded {
35            true => pkarr::mainline::Testnet::new(2)?,
36            false => pkarr::mainline::Testnet::new_unseeded(2)?,
37        };
38
39        let testnet = Self {
40            dht,
41            pkarr_relays: vec![],
42            http_relays: vec![],
43            homeservers: vec![],
44            temp_dirs: vec![],
45            postgres_connection_string: Self::extract_postgres_connection_string_from_env_variable(
46            ),
47        };
48
49        Ok(testnet)
50    }
51
52    /// Run a new testnet with a (fully-initialized) local DHT.
53    pub async fn new() -> Result<Self> {
54        Self::new_inner(true)
55    }
56
57    /// Run a new testnet with a (faster, but partially-initialized) local DHT.
58    pub async fn new_unseeded() -> Result<Self> {
59        Self::new_inner(false)
60    }
61
62    /// Run a new testnet with a local DHT.
63    /// Pass an optional postgres connection string to use for the homeserver.
64    /// If None, the default test connection string is used.
65    pub async fn new_with_custom_postgres(
66        postgres_connection_string: ConnectionString,
67    ) -> Result<Self> {
68        let dht = pkarr::mainline::Testnet::builder(2).build()?;
69        let testnet: Testnet = Self {
70            dht,
71            pkarr_relays: vec![],
72            http_relays: vec![],
73            homeservers: vec![],
74            temp_dirs: vec![],
75            postgres_connection_string: Some(postgres_connection_string),
76        };
77
78        Ok(testnet)
79    }
80
81    /// Extract the postgres connection string from the TEST_PUBKY_CONNECTION_STRING environment variable.
82    /// If the environment variable is not set, None is returned.
83    /// If the environment variable is set, but the connection string is invalid, a warning is logged and None is returned.
84    fn extract_postgres_connection_string_from_env_variable() -> Option<ConnectionString> {
85        if let Ok(raw_con_string) = std::env::var("TEST_PUBKY_CONNECTION_STRING") {
86            if let Ok(con_string) = ConnectionString::new(&raw_con_string) {
87                return Some(con_string);
88            } else {
89                tracing::warn!("Invalid database connection string in TEST_PUBKY_CONNECTION_STRING environment variable. Ignoring it.");
90            }
91        }
92        None
93    }
94
95    /// Run the full homeserver app with core and admin server.
96    ///
97    /// Uses [`ConfigToml::default_test_config()`] which enables the admin server.
98    /// Automatically listens on ephemeral ports and uses this Testnet's bootstrap nodes and relays.
99    pub async fn create_homeserver(&mut self) -> Result<&HomeserverApp> {
100        let mut config = ConfigToml::default_test_config();
101        if let Some(connection_string) = self.postgres_connection_string.as_ref() {
102            config.general.database_url = connection_string.clone();
103        }
104        let mock_dir = MockDataDir::new(config, Some(Keypair::from_secret(&[0; 32])))?;
105        self.create_homeserver_app_with_mock(mock_dir).await
106    }
107
108    /// Run the full homeserver app with core and admin server using a freshly generated random keypair.
109    ///
110    /// Uses [`ConfigToml::default_test_config()`] which enables the admin server.
111    /// Automatically listens on ephemeral ports and uses this Testnet's bootstrap nodes and relays.
112    pub async fn create_random_homeserver(&mut self) -> Result<&HomeserverApp> {
113        let mut config = ConfigToml::default_test_config();
114        if let Some(connection_string) = self.postgres_connection_string.as_ref() {
115            config.general.database_url = connection_string.clone();
116        }
117        let mock_dir = MockDataDir::new(config, Some(Keypair::random()))?;
118        self.create_homeserver_app_with_mock(mock_dir).await
119    }
120
121    /// Run the full homeserver app with core and admin server
122    /// Automatically listens on the configured ports.
123    /// Automatically uses the configured bootstrap nodes and relays in this Testnet.
124    pub async fn create_homeserver_app_with_mock(
125        &mut self,
126        mut mock_dir: MockDataDir,
127    ) -> Result<&HomeserverApp> {
128        mock_dir.config_toml.pkdns.dht_bootstrap_nodes = Some(self.dht_bootstrap_nodes());
129        if !self.dht_relay_urls().is_empty() {
130            mock_dir.config_toml.pkdns.dht_relay_nodes = Some(self.dht_relay_urls().to_vec());
131        }
132        mock_dir.config_toml.storage = StorageConfigToml::InMemory;
133        let homeserver = HomeserverApp::start_with_mock_data_dir(mock_dir).await?;
134        self.homeservers.push(homeserver);
135        Ok(self
136            .homeservers
137            .last()
138            .expect("homeservers should be non-empty"))
139    }
140
141    /// Run an HTTP Relay
142    pub async fn create_http_relay(&mut self) -> Result<&HttpRelay> {
143        let relay = HttpRelay::builder()
144            .http_port(0) // Random available port
145            .cors_allow_all(true)
146            .run()
147            .await?;
148        self.http_relays.push(relay);
149        Ok(self
150            .http_relays
151            .last()
152            .expect("http relays should be non-empty"))
153    }
154
155    /// Run a new Pkarr relay.
156    ///
157    /// You can access the list of relays at [Self::pkarr_relays].
158    pub async fn create_pkarr_relay(&mut self) -> Result<Url> {
159        let dir = tempfile::tempdir()?;
160        let mut builder = pkarr_relay::Relay::builder();
161        builder
162            .disable_rate_limiter()
163            .http_port(0)
164            .storage(dir.path().to_path_buf())
165            .pkarr(|builder| {
166                builder.no_default_network();
167                builder.bootstrap(&self.dht.bootstrap);
168                builder
169            });
170        let relay = unsafe { builder.run().await? };
171        let url = relay.local_url();
172        self.pkarr_relays.push(relay);
173        self.temp_dirs.push(dir);
174        Ok(url)
175    }
176
177    // === Getters ===
178
179    /// Returns a list of DHT bootstrapping nodes.
180    pub fn dht_bootstrap_nodes(&self) -> Vec<DomainPort> {
181        self.dht
182            .nodes
183            .iter()
184            .map(|node| {
185                let addr = node.info().local_addr();
186                DomainPort::from_str(&format!("{}:{}", addr.ip(), addr.port()))
187                    .expect("boostrap nodes from the pkarr dht are always valid domain:port pairs")
188            })
189            .collect()
190    }
191
192    /// Returns a list of pkarr relays.
193    pub fn dht_relay_urls(&self) -> Vec<Url> {
194        self.pkarr_relays.iter().map(|r| r.local_url()).collect()
195    }
196
197    /// Create a [pubky::PubkyHttpClientBuilder] and configure it to use this local test network.
198    pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
199        let relays = self.dht_relay_urls();
200
201        let mut builder = pubky::PubkyHttpClient::builder();
202        builder.pkarr(|builder| {
203            builder.no_default_network();
204            builder.bootstrap(&self.dht.bootstrap);
205            if relays.is_empty() {
206                builder.no_relays();
207            } else {
208                builder
209                    .relays(&relays)
210                    .expect("testnet relays should be valid urls");
211            }
212            // 100ms timeout for requests. This makes methods like `resolve_most_recent` fast
213            // because it doesn't need to wait the default 2s which would slow down the tests.
214            builder.request_timeout(Duration::from_millis(100));
215            builder
216        });
217
218        builder
219    }
220
221    /// Creates a [`pubky::PubkyHttpClient`] pre-configured to use this test network.
222    ///
223    /// This is a convenience method that builds a client from `Self::client_builder`.
224    pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
225        self.client_builder().build()
226    }
227
228    /// Creates a [`pubky::Pubky`] SDK facade pre-configured to use this test network.
229    ///
230    /// This is a convenience method that builds a client from `Self::client_builder`.
231    pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
232        Ok(Pubky::with_client(self.client()?))
233    }
234
235    /// Create a [pkarr::ClientBuilder] and configure it to use this local test network.
236    pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
237        let relays = self.dht_relay_urls();
238        let mut builder = pkarr::Client::builder();
239        builder.no_default_network(); // Remove DHT bootstrap nodes and relays
240        builder.bootstrap(&self.dht.bootstrap);
241        if !relays.is_empty() {
242            builder
243                .relays(&relays)
244                .expect("Testnet relays should be valid urls");
245        }
246
247        builder
248    }
249}
250
251#[cfg(test)]
252mod test {
253    use crate::Testnet;
254    use pubky::Keypair;
255
256    /// Make sure the components are kept alive even when dropped.
257    #[tokio::test]
258    #[crate::test]
259    async fn test_keep_relays_alive_even_when_dropped() {
260        let mut testnet = Testnet::new().await.unwrap();
261        {
262            let _relay = testnet.create_http_relay().await.unwrap();
263        }
264        assert_eq!(testnet.http_relays.len(), 1);
265    }
266
267    /// Boostrap node conversion
268    #[tokio::test]
269    #[crate::test]
270    async fn test_boostrap_node_conversion() {
271        let testnet = Testnet::new().await.unwrap();
272        let nodes = testnet.dht_bootstrap_nodes();
273        assert_eq!(nodes.len(), 2);
274    }
275
276    /// Test that a user can signup in the testnet.
277    /// This is an e2e tests to check if everything is correct.
278    #[tokio::test]
279    #[crate::test]
280    async fn test_signup() {
281        let mut testnet = Testnet::new().await.unwrap();
282        testnet.create_homeserver().await.unwrap();
283
284        let hs = testnet.homeservers.first().unwrap();
285        let sdk = testnet.sdk().unwrap();
286
287        let signer = sdk.signer(Keypair::random());
288
289        let session = signer.signup(&hs.public_key(), None).await.unwrap();
290        assert_eq!(session.info().public_key(), &signer.public_key());
291    }
292
293    #[tokio::test]
294    async fn test_independent_dhts() {
295        let t1 = Testnet::new().await.unwrap();
296        let t2 = Testnet::new().await.unwrap();
297
298        assert_ne!(t1.dht.bootstrap, t2.dht.bootstrap);
299    }
300
301    /// If everything is linked correctly, the hs_pubky should be resolvable from the pkarr client.
302    #[tokio::test]
303    async fn test_homeserver_resolvable() {
304        let mut testnet = Testnet::new().await.unwrap();
305        let hs_pubky = testnet.create_homeserver().await.unwrap().public_key();
306
307        // Make sure the pkarr packet of the hs is resolvable.
308        let pkarr_client = testnet.pkarr_client_builder().build().unwrap();
309        let _packet = pkarr_client.resolve(&hs_pubky).await.unwrap();
310
311        // Make sure the pkarr can resolve the hs_pubky.
312        let pubkey = hs_pubky.z32();
313        let _endpoint = pkarr_client
314            .resolve_https_endpoint(pubkey.as_str())
315            .await
316            .unwrap();
317    }
318
319    /// Test relay resolvable.
320    /// This simulates pkarr clients in a browser.
321    /// Made due to https://github.com/pubky/pkarr/issues/140
322    #[tokio::test]
323    #[crate::test]
324    async fn test_pkarr_relay_resolvable() {
325        let mut testnet = Testnet::new().await.unwrap();
326        testnet.create_pkarr_relay().await.unwrap();
327
328        let keypair = Keypair::random();
329
330        // Publish packet on the DHT without using the relay.
331        let client = testnet.pkarr_client_builder().build().unwrap();
332        let signed = pkarr::SignedPacket::builder().sign(&keypair).unwrap();
333        client.publish(&signed, None).await.unwrap();
334
335        // Resolve packet with a new client to prevent caching
336        // Only use the DHT, no relays
337        let client = testnet.pkarr_client_builder().no_relays().build().unwrap();
338        let packet = client.resolve(&keypair.public_key()).await;
339        assert!(
340            packet.is_some(),
341            "Published packet is not available over the DHT."
342        );
343
344        // Resolve packet with a new client to prevent caching
345        // Only use the relay, no DHT
346        // This simulates pkarr clients in a browser.
347        let client = testnet.pkarr_client_builder().no_dht().build().unwrap();
348        let packet = client.resolve(&keypair.public_key()).await;
349        assert!(
350            packet.is_some(),
351            "Published packet is not available over the relay only."
352        );
353    }
354}