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