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