1use crate::pubky::Pubky;
2use anyhow::Context;
3
4use std::{
5 net::{IpAddr, Ipv4Addr, SocketAddr},
6 path::{Path, PathBuf},
7 str::FromStr,
8};
9
10use crate::common::testnet_keypair;
11use crate::Testnet;
12use http_relay::HttpRelay;
13use pubky_common::constants::testnet_ports;
14use pubky_homeserver::{
15 AppContext, ConfigToml, ConnectionString, DataDir, DomainPort, HomeserverApp, MockDataDir,
16 PersistentDataDir,
17};
18
19const BIND_ALL: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
21
22fn apply_static_testnet_overrides(config: &mut ConfigToml, bootstrap_nodes: Vec<DomainPort>) {
24 config.pkdns.dht_bootstrap_nodes = Some(bootstrap_nodes);
25 config.pkdns.dht_relay_nodes = None;
26 config.drive.icann_listen_socket =
27 SocketAddr::new(BIND_ALL, testnet_ports::HOMESERVER_ICANN_HTTP);
28 config.drive.pubky_listen_socket =
29 SocketAddr::new(BIND_ALL, testnet_ports::HOMESERVER_PUBKY_HTTPS);
30 config.admin.enabled = true;
31 config.admin.listen_socket = SocketAddr::new(BIND_ALL, testnet_ports::HOMESERVER_ADMIN);
32}
33
34#[derive(Debug)]
36enum StorageMode {
37 InMemory,
39 Persistent(PathBuf),
41}
42
43pub struct StaticTestnetBuilder {
69 homeserver_config: Option<PathBuf>,
70 mode: StorageMode,
71}
72
73impl StaticTestnetBuilder {
74 fn new() -> Self {
75 Self {
76 homeserver_config: None,
77 mode: StorageMode::InMemory,
78 }
79 }
80
81 pub fn homeserver_config(mut self, path: PathBuf) -> Self {
87 self.homeserver_config = Some(path);
88 self
89 }
90
91 pub fn persistent(mut self, data_dir: PathBuf) -> Self {
96 self.mode = StorageMode::Persistent(data_dir);
97 self
98 }
99
100 pub async fn build(self) -> anyhow::Result<StaticTestnet> {
102 let mut testnet = StaticTestnet::start_infra().await?;
103
104 let persistent = match self.mode {
105 StorageMode::InMemory => {
106 testnet
107 .run_in_memory_homeserver(self.homeserver_config.as_deref())
108 .await
109 .map_err(|e| anyhow::anyhow!("Failed to run in-memory homeserver: {}", e))?;
110 false
111 }
112 StorageMode::Persistent(data_dir) => {
113 testnet
114 .run_persistent_homeserver(data_dir, self.homeserver_config.as_deref())
115 .await
116 .map_err(|e| anyhow::anyhow!("Failed to run persistent homeserver: {}", e))?;
117 true
118 }
119 };
120 testnet.is_persistent = persistent;
121
122 Ok(testnet)
123 }
124}
125
126pub struct StaticTestnet {
149 pub testnet: Testnet,
151 is_persistent: bool,
153 #[allow(dead_code)]
154 fixed_bootstrap_node: Option<pkarr::mainline::Dht>, #[allow(dead_code)]
156 temp_dirs: Vec<tempfile::TempDir>, }
158
159impl StaticTestnet {
160 pub fn builder() -> StaticTestnetBuilder {
162 StaticTestnetBuilder::new()
163 }
164
165 pub async fn start() -> anyhow::Result<Self> {
167 Self::builder().build().await
168 }
169
170 #[deprecated(
172 since = "0.9.0",
173 note = "Use StaticTestnet::builder().homeserver_config(path).build() instead"
174 )]
175 pub async fn start_with_homeserver_config(config_path: PathBuf) -> anyhow::Result<Self> {
176 Self::builder().homeserver_config(config_path).build().await
177 }
178
179 pub fn is_persistent(&self) -> bool {
181 self.is_persistent
182 }
183
184 async fn start_infra() -> anyhow::Result<Self> {
187 let testnet = Testnet::new().await?;
188 let fixed_bootsrap =
189 Self::run_fixed_bootsrap_node(&testnet.dht.bootstrap).map_err(|e| {
190 anyhow::anyhow!(
191 "Failed to run bootstrap node on port {}: {}",
192 testnet_ports::BOOTSTRAP,
193 e
194 )
195 })?;
196
197 let mut testnet = Self {
198 testnet,
199 fixed_bootstrap_node: fixed_bootsrap,
200 temp_dirs: vec![],
201 is_persistent: false,
202 };
203
204 testnet.run_fixed_pkarr_relays().await.map_err(|e| {
205 anyhow::anyhow!(
206 "Failed to run pkarr relay on port {}: {}",
207 testnet_ports::PKARR_RELAY,
208 e
209 )
210 })?;
211 testnet.run_fixed_http_relay().await.map_err(|e| {
212 anyhow::anyhow!(
213 "Failed to run http relay on port {}: {}",
214 testnet_ports::HTTP_RELAY,
215 e
216 )
217 })?;
218
219 Ok(testnet)
220 }
221
222 pub async fn create_random_homeserver(
224 &mut self,
225 ) -> anyhow::Result<&pubky_homeserver::HomeserverApp> {
226 self.testnet.create_random_homeserver().await
227 }
228
229 pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
231 self.testnet.client_builder()
232 }
233
234 pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
236 self.testnet.client()
237 }
238
239 pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
243 self.testnet.sdk()
244 }
245
246 pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
248 self.testnet.pkarr_client_builder()
249 }
250
251 pub fn homeserver_app(&self) -> &pubky_homeserver::HomeserverApp {
253 self.testnet
254 .homeservers
255 .first()
256 .expect("homeservers should be non-empty")
257 }
258
259 pub fn http_relay(&self) -> &HttpRelay {
261 self.testnet
262 .http_relays
263 .first()
264 .expect("http relays should be non-empty")
265 }
266
267 pub fn pkarr_relay(&self) -> &pkarr_relay::Relay {
269 self.testnet
270 .pkarr_relays
271 .first()
272 .expect("pkarr relays should be non-empty")
273 }
274
275 pub fn bootstrap_nodes(&self) -> Vec<String> {
277 let mut nodes = vec![];
278 if let Some(dht) = &self.fixed_bootstrap_node {
279 #[allow(deprecated, reason = "mainline has no synchronous replacement")]
280 nodes.push(dht.info().local_addr().to_string());
281 }
282 nodes.extend(
283 self.testnet
284 .dht_bootstrap_nodes()
285 .iter()
286 .map(|node| node.to_string()),
287 );
288 nodes
289 }
290
291 fn run_fixed_bootsrap_node(
294 other_bootstrap_nodes: &[String],
295 ) -> anyhow::Result<Option<pkarr::mainline::Dht>> {
296 let port_suffix = format!(":{}", testnet_ports::BOOTSTRAP);
297 if other_bootstrap_nodes
298 .iter()
299 .any(|node| node.ends_with(&port_suffix))
300 {
301 return Ok(None);
302 }
303
304 let mut builder = pkarr::mainline::Dht::builder();
305 let dht = builder
306 .port(testnet_ports::BOOTSTRAP)
307 .bootstrap(other_bootstrap_nodes)
308 .server_mode()
309 .build()?;
310 Ok(Some(dht))
311 }
312
313 async fn run_fixed_pkarr_relays(&mut self) -> anyhow::Result<()> {
315 let temp_dir = tempfile::tempdir()?; let mut builder = pkarr_relay::Relay::builder();
317 builder
318 .http_port(testnet_ports::PKARR_RELAY)
319 .storage(temp_dir.path().to_path_buf())
320 .disable_rate_limiter()
321 .report_policy(pkarr::dht::ReportPolicy::testnet())
322 .dht(|config| {
323 config.bootstrap = Some(
324 self.testnet
325 .dht
326 .bootstrap
327 .iter()
328 .map(|address| address.parse().expect("testnet bootstrap address is valid"))
329 .collect(),
330 );
331 config
332 });
333 let relay = unsafe { builder.run() }.await?;
334 self.testnet.pkarr_relays.push(relay);
335 self.temp_dirs.push(temp_dir);
336 Ok(())
337 }
338
339 async fn run_fixed_http_relay(&mut self) -> anyhow::Result<()> {
341 let relay = HttpRelay::builder()
342 .bind_address(BIND_ALL)
343 .http_port(testnet_ports::HTTP_RELAY)
344 .cors_allow_all(true)
345 .run()
346 .await?;
347 self.testnet.http_relays.push(relay);
348 Ok(())
349 }
350
351 fn parse_bootstrap_nodes(&self) -> anyhow::Result<Vec<DomainPort>> {
352 self.bootstrap_nodes()
353 .iter()
354 .map(|node| {
355 DomainPort::from_str(node).map_err(|e| {
356 anyhow::anyhow!("Failed to parse bootstrap node '{}': {}", node, e)
357 })
358 })
359 .collect()
360 }
361
362 async fn run_persistent_homeserver(
363 &mut self,
364 data_dir: PathBuf,
365 config_path: Option<&Path>,
366 ) -> anyhow::Result<()> {
367 let persistent_dir = PersistentDataDir::new(data_dir);
368
369 if let Some(source) = config_path {
370 persistent_dir.seed_config(source).context(
371 "Remove --homeserver-config to use the existing config, \
372 or delete the file to replace it.",
373 )?;
374 }
375
376 persistent_dir.ensure_data_dir_exists_and_is_writable()?;
381
382 let testnet_dir = TestnetDataDir {
385 inner: persistent_dir,
386 dht_bootstrap_nodes: self.parse_bootstrap_nodes()?,
387 postgres_connection_string: self.testnet.postgres_connection_string.clone(),
388 };
389
390 let context = AppContext::read_from(testnet_dir).await?;
391 let homeserver = HomeserverApp::start(context).await?;
392 self.testnet.homeservers.push(homeserver);
393 Ok(())
394 }
395
396 async fn run_in_memory_homeserver(&mut self, config_path: Option<&Path>) -> anyhow::Result<()> {
397 let mut config = if let Some(config_path) = config_path {
398 ConfigToml::from_file(config_path)?
399 } else {
400 ConfigToml::default_test_config()
401 };
402 apply_static_testnet_overrides(&mut config, self.parse_bootstrap_nodes()?);
403 let mock = MockDataDir::new(config, Some(testnet_keypair()))?;
404
405 let homeserver = HomeserverApp::start_with_mock_data_dir(mock).await?;
406 self.testnet.homeservers.push(homeserver);
407 Ok(())
408 }
409}
410
411#[derive(Debug, Clone)]
416struct TestnetDataDir {
417 inner: PersistentDataDir,
418 dht_bootstrap_nodes: Vec<DomainPort>,
419 postgres_connection_string: Option<ConnectionString>,
420}
421
422impl DataDir for TestnetDataDir {
423 fn path(&self) -> &Path {
424 self.inner.path()
425 }
426
427 fn ensure_data_dir_exists_and_is_writable(&self) -> anyhow::Result<()> {
428 self.inner.ensure_data_dir_exists_and_is_writable()
429 }
430
431 fn read_or_create_config_file(&self) -> anyhow::Result<ConfigToml> {
432 let mut config = self.inner.read_or_create_config_file()?;
433 apply_static_testnet_overrides(&mut config, self.dht_bootstrap_nodes.clone());
434 if let Some(connection_string) = &self.postgres_connection_string {
435 config.general.database_url = connection_string.clone();
436 }
437 if config.general.database_url.is_test_db() {
438 anyhow::bail!(
439 "Persistent testnet requires a real database. \
440 Remove `?pubky-test=true` from the connection string to use a persistent database."
441 );
442 }
443 Ok(config)
444 }
445
446 fn read_or_create_keypair(&self) -> anyhow::Result<pubky_common::crypto::Keypair> {
447 let secret_file = self.inner.get_secret_file_path();
448 if !secret_file.exists() {
449 let keypair = testnet_keypair();
452 keypair.write_secret_key_file(&secret_file)?;
453 tracing::info!(
454 "Seeded deterministic keypair (pubkey {}) at {}",
455 keypair.public_key(),
456 secret_file.display()
457 );
458 }
459 self.inner.read_or_create_keypair()
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use tempfile::TempDir;
467
468 #[test]
469 fn testnet_data_dir_overrides_dht_config() {
470 let temp = TempDir::new().unwrap();
471 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
472 persistent.init().unwrap();
473
474 let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
475 let testnet_dir = TestnetDataDir {
476 inner: persistent.clone(),
477 dht_bootstrap_nodes: bootstrap.clone(),
478 postgres_connection_string: None,
479 };
480
481 let config = testnet_dir.read_or_create_config_file().unwrap();
482 assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
483 assert_eq!(config.pkdns.dht_relay_nodes, None);
484 }
485
486 #[test]
487 fn testnet_data_dir_delegates_path() {
488 let temp = TempDir::new().unwrap();
489 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
490 let testnet_dir = TestnetDataDir {
491 inner: persistent.clone(),
492 dht_bootstrap_nodes: vec![],
493 postgres_connection_string: None,
494 };
495
496 assert_eq!(testnet_dir.path(), persistent.path());
497 }
498
499 #[test]
500 fn testnet_data_dir_seeds_deterministic_keypair() {
501 let temp = TempDir::new().unwrap();
502 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
503 persistent.init().unwrap();
504
505 std::fs::remove_file(persistent.get_secret_file_path()).unwrap();
508
509 let testnet_dir = TestnetDataDir {
510 inner: persistent.clone(),
511 dht_bootstrap_nodes: vec![],
512 postgres_connection_string: None,
513 };
514
515 let expected = testnet_keypair();
516 let kp = testnet_dir.read_or_create_keypair().unwrap();
517 assert_eq!(
518 kp.public_key(),
519 expected.public_key(),
520 "TestnetDataDir should seed the deterministic keypair"
521 );
522
523 let kp2 = testnet_dir.read_or_create_keypair().unwrap();
525 assert_eq!(kp.public_key(), kp2.public_key());
526 }
527
528 #[test]
529 fn testnet_data_dir_preserves_existing_keypair() {
530 let temp = TempDir::new().unwrap();
531 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
532 persistent.init().unwrap();
533
534 let existing_kp = persistent.read_or_create_keypair().unwrap();
536
537 let testnet_dir = TestnetDataDir {
538 inner: persistent,
539 dht_bootstrap_nodes: vec![],
540 postgres_connection_string: None,
541 };
542
543 let kp = testnet_dir.read_or_create_keypair().unwrap();
544 assert_eq!(
545 kp.public_key(),
546 existing_kp.public_key(),
547 "TestnetDataDir should not overwrite an existing keypair"
548 );
549 }
550
551 #[test]
552 fn config_seeding_copies_file_to_empty_data_dir() {
553 let temp = TempDir::new().unwrap();
554 let data_dir = temp.path().join("testnet");
555 let persistent = PersistentDataDir::new(data_dir);
556
557 let source_config = temp.path().join("custom.toml");
558 let sample = ConfigToml::sample_string();
559 std::fs::write(&source_config, &sample).unwrap();
560
561 persistent.seed_config(&source_config).unwrap();
562
563 assert!(persistent.get_config_file_path().exists());
564 let content = std::fs::read_to_string(persistent.get_config_file_path()).unwrap();
565 assert_eq!(content, sample);
566 }
567
568 #[test]
569 fn config_seeding_rejects_when_config_already_exists() {
570 let temp = TempDir::new().unwrap();
571 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
572 persistent.init().unwrap();
573
574 let source = temp.path().join("other.toml");
575 std::fs::write(&source, "[general]\nsignup_mode = \"open\"\n").unwrap();
576
577 let result = persistent.seed_config(&source);
578 assert!(
579 result.is_err(),
580 "Seeding should be rejected when config.toml already exists"
581 );
582 assert!(
583 result.unwrap_err().to_string().contains("already exists"),
584 "Error message should mention existing config"
585 );
586 }
587
588 #[test]
589 fn builder_defaults_to_in_memory() {
590 let builder = StaticTestnet::builder();
591 assert!(
592 matches!(builder.mode, StorageMode::InMemory),
593 "Default mode should be InMemory"
594 );
595 assert!(builder.homeserver_config.is_none());
596 }
597
598 #[test]
599 fn builder_persistent_sets_mode() {
600 let dir = PathBuf::from("/tmp/test-data");
601 let builder = StaticTestnet::builder().persistent(dir.clone());
602 match &builder.mode {
603 StorageMode::Persistent(d) => assert_eq!(d, &dir),
604 StorageMode::InMemory => panic!("Expected Persistent mode"),
605 }
606 }
607
608 #[test]
609 fn builder_homeserver_config_sets_path() {
610 let config = PathBuf::from("/tmp/my-config.toml");
611 let builder = StaticTestnet::builder().homeserver_config(config.clone());
612 assert_eq!(builder.homeserver_config, Some(config));
613 }
614
615 #[test]
616 fn builder_chaining_all_options() {
617 let dir = PathBuf::from("/tmp/data");
618 let config = PathBuf::from("/tmp/config.toml");
619 let builder = StaticTestnet::builder()
620 .homeserver_config(config.clone())
621 .persistent(dir.clone());
622 assert_eq!(builder.homeserver_config, Some(config));
623 assert!(matches!(builder.mode, StorageMode::Persistent(d) if d == dir));
624 }
625
626 #[test]
627 fn persistent_data_dir_init_creates_structure() {
628 let temp = TempDir::new().unwrap();
629 let data_dir = temp.path().join("new-testnet");
630 let persistent = PersistentDataDir::new(data_dir.clone());
631 persistent.init().unwrap();
632
633 assert!(
634 persistent.get_config_file_path().exists(),
635 "config.toml should be created"
636 );
637 let kp = persistent.read_or_create_keypair().unwrap();
639 let kp2 = persistent.read_or_create_keypair().unwrap();
640 assert_eq!(
641 kp.public_key(),
642 kp2.public_key(),
643 "Keypair should be stable across reads"
644 );
645 }
646
647 #[test]
648 fn seeded_config_is_readable_by_testnet_data_dir() {
649 let temp = TempDir::new().unwrap();
650 let data_dir = temp.path().join("testnet");
651 let persistent = PersistentDataDir::new(data_dir);
652
653 let config_content = "[general]\nsignup_mode = \"token_required\"\n";
655 let source = temp.path().join("seed.toml");
656 std::fs::write(&source, config_content).unwrap();
657
658 std::fs::create_dir_all(persistent.path()).unwrap();
660 std::fs::copy(&source, persistent.get_config_file_path()).unwrap();
661 persistent.init().unwrap();
662
663 let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
665 let testnet_dir = TestnetDataDir {
666 inner: persistent,
667 dht_bootstrap_nodes: bootstrap.clone(),
668 postgres_connection_string: None,
669 };
670
671 let config = testnet_dir.read_or_create_config_file().unwrap();
672 assert_eq!(
674 config.general.signup_mode,
675 pubky_homeserver::SignupMode::TokenRequired
676 );
677 assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
679 assert_eq!(config.pkdns.dht_relay_nodes, None);
680 assert_eq!(
682 config.drive.icann_listen_socket.port(),
683 testnet_ports::HOMESERVER_ICANN_HTTP
684 );
685 assert_eq!(
686 config.drive.pubky_listen_socket.port(),
687 testnet_ports::HOMESERVER_PUBKY_HTTPS
688 );
689 assert_eq!(
690 config.admin.listen_socket.port(),
691 testnet_ports::HOMESERVER_ADMIN
692 );
693 assert!(config.admin.enabled);
694 }
695
696 #[test]
697 fn persistent_state_survives_restart() {
698 let temp = TempDir::new().unwrap();
699 let data_dir = temp.path().join("testnet");
700 let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
701 let expected_key = testnet_keypair();
702
703 let source = temp.path().join("seed.toml");
705 std::fs::write(&source, "[general]\nsignup_mode = \"token_required\"\n").unwrap();
706
707 let persistent1 = PersistentDataDir::new(data_dir.clone());
708 persistent1.seed_config(&source).unwrap();
709 persistent1
712 .ensure_data_dir_exists_and_is_writable()
713 .unwrap();
714 persistent1.read_or_create_config_file().unwrap();
715
716 let dir1 = TestnetDataDir {
717 inner: persistent1,
718 dht_bootstrap_nodes: bootstrap.clone(),
719 postgres_connection_string: None,
720 };
721 let kp1 = dir1.read_or_create_keypair().unwrap();
722 let config1 = dir1.read_or_create_config_file().unwrap();
723 assert_eq!(
724 kp1.public_key(),
725 expected_key.public_key(),
726 "First run should use the deterministic keypair"
727 );
728 drop(dir1);
729
730 let persistent2 = PersistentDataDir::new(data_dir);
732 let dir2 = TestnetDataDir {
733 inner: persistent2,
734 dht_bootstrap_nodes: bootstrap,
735 postgres_connection_string: None,
736 };
737 let kp2 = dir2.read_or_create_keypair().unwrap();
738 let config2 = dir2.read_or_create_config_file().unwrap();
739
740 assert_eq!(
741 kp1.public_key(),
742 kp2.public_key(),
743 "Keypair should persist across restarts"
744 );
745 assert_eq!(
746 config1.general.signup_mode, config2.general.signup_mode,
747 "Config should persist across restarts"
748 );
749 assert_eq!(
750 config2.general.signup_mode,
751 pubky_homeserver::SignupMode::TokenRequired
752 );
753 }
754}