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<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<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 = 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 resolve_database_mode(
428 &self,
429 conf: &ConfigToml,
430 ) -> anyhow::Result<pubky_homeserver::DatabaseMode> {
431 self.inner.resolve_database_mode(conf)
432 }
433
434 fn ensure_data_dir_exists_and_is_writable(&self) -> anyhow::Result<()> {
435 self.inner.ensure_data_dir_exists_and_is_writable()
436 }
437
438 fn read_or_create_config_file(&self) -> anyhow::Result<ConfigToml> {
439 let mut config = self.inner.read_or_create_config_file()?;
440 apply_static_testnet_overrides(&mut config, self.dht_bootstrap_nodes.clone());
441 config.general.database_url = self
442 .postgres_connection_string
443 .clone()
444 .or(config.general.database_url);
445 if config.general.database_url.is_none() {
446 anyhow::bail!(
447 "Persistent testnet requires an explicit database URL. \
448 Set `database_url` in config.toml under [general]."
449 );
450 }
451 Ok(config)
452 }
453
454 fn read_or_create_keypair(&self) -> anyhow::Result<pubky_common::crypto::Keypair> {
455 let secret_file = self.inner.get_secret_file_path();
456 if !secret_file.exists() {
457 let keypair = testnet_keypair();
460 keypair.write_secret_key_file(&secret_file)?;
461 tracing::info!(
462 "Seeded deterministic keypair (pubkey {}) at {}",
463 keypair.public_key(),
464 secret_file.display()
465 );
466 }
467 self.inner.read_or_create_keypair()
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use tempfile::TempDir;
475
476 #[test]
477 fn testnet_data_dir_overrides_dht_config() {
478 let temp = TempDir::new().unwrap();
479 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
480 persistent.init().unwrap();
481
482 let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
483 let testnet_dir = TestnetDataDir {
484 inner: persistent.clone(),
485 dht_bootstrap_nodes: bootstrap.clone(),
486 postgres_connection_string: None,
487 };
488
489 let config = testnet_dir.read_or_create_config_file().unwrap();
490 assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
491 assert_eq!(config.pkdns.dht_relay_nodes, None);
492 }
493
494 #[test]
495 fn testnet_data_dir_delegates_path() {
496 let temp = TempDir::new().unwrap();
497 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
498 let testnet_dir = TestnetDataDir {
499 inner: persistent.clone(),
500 dht_bootstrap_nodes: vec![],
501 postgres_connection_string: None,
502 };
503
504 assert_eq!(testnet_dir.path(), persistent.path());
505 }
506
507 #[test]
508 fn testnet_data_dir_seeds_deterministic_keypair() {
509 let temp = TempDir::new().unwrap();
510 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
511 persistent.init().unwrap();
512
513 std::fs::remove_file(persistent.get_secret_file_path()).unwrap();
516
517 let testnet_dir = TestnetDataDir {
518 inner: persistent.clone(),
519 dht_bootstrap_nodes: vec![],
520 postgres_connection_string: None,
521 };
522
523 let expected = testnet_keypair();
524 let kp = testnet_dir.read_or_create_keypair().unwrap();
525 assert_eq!(
526 kp.public_key(),
527 expected.public_key(),
528 "TestnetDataDir should seed the deterministic keypair"
529 );
530
531 let kp2 = testnet_dir.read_or_create_keypair().unwrap();
533 assert_eq!(kp.public_key(), kp2.public_key());
534 }
535
536 #[test]
537 fn testnet_data_dir_preserves_existing_keypair() {
538 let temp = TempDir::new().unwrap();
539 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
540 persistent.init().unwrap();
541
542 let existing_kp = persistent.read_or_create_keypair().unwrap();
544
545 let testnet_dir = TestnetDataDir {
546 inner: persistent,
547 dht_bootstrap_nodes: vec![],
548 postgres_connection_string: None,
549 };
550
551 let kp = testnet_dir.read_or_create_keypair().unwrap();
552 assert_eq!(
553 kp.public_key(),
554 existing_kp.public_key(),
555 "TestnetDataDir should not overwrite an existing keypair"
556 );
557 }
558
559 #[test]
560 fn config_seeding_copies_file_to_empty_data_dir() {
561 let temp = TempDir::new().unwrap();
562 let data_dir = temp.path().join("testnet");
563 let persistent = PersistentDataDir::new(data_dir);
564
565 let source_config = temp.path().join("custom.toml");
566 let sample = ConfigToml::sample_string();
567 std::fs::write(&source_config, &sample).unwrap();
568
569 persistent.seed_config(&source_config).unwrap();
570
571 assert!(persistent.get_config_file_path().exists());
572 let content = std::fs::read_to_string(persistent.get_config_file_path()).unwrap();
573 assert_eq!(content, sample);
574 }
575
576 #[test]
577 fn config_seeding_rejects_when_config_already_exists() {
578 let temp = TempDir::new().unwrap();
579 let persistent = PersistentDataDir::new(temp.path().to_path_buf());
580 persistent.init().unwrap();
581
582 let source = temp.path().join("other.toml");
583 std::fs::write(&source, "[general]\nsignup_mode = \"open\"\n").unwrap();
584
585 let result = persistent.seed_config(&source);
586 assert!(
587 result.is_err(),
588 "Seeding should be rejected when config.toml already exists"
589 );
590 assert!(
591 result.unwrap_err().to_string().contains("already exists"),
592 "Error message should mention existing config"
593 );
594 }
595
596 #[test]
597 fn builder_defaults_to_in_memory() {
598 let builder = StaticTestnet::builder();
599 assert!(
600 matches!(builder.mode, StorageMode::InMemory),
601 "Default mode should be InMemory"
602 );
603 assert!(builder.homeserver_config.is_none());
604 }
605
606 #[test]
607 fn builder_persistent_sets_mode() {
608 let dir = PathBuf::from("/tmp/test-data");
609 let builder = StaticTestnet::builder().persistent(dir.clone());
610 match &builder.mode {
611 StorageMode::Persistent(d) => assert_eq!(d, &dir),
612 StorageMode::InMemory => panic!("Expected Persistent mode"),
613 }
614 }
615
616 #[test]
617 fn builder_homeserver_config_sets_path() {
618 let config = PathBuf::from("/tmp/my-config.toml");
619 let builder = StaticTestnet::builder().homeserver_config(config.clone());
620 assert_eq!(builder.homeserver_config, Some(config));
621 }
622
623 #[test]
624 fn builder_chaining_all_options() {
625 let dir = PathBuf::from("/tmp/data");
626 let config = PathBuf::from("/tmp/config.toml");
627 let builder = StaticTestnet::builder()
628 .homeserver_config(config.clone())
629 .persistent(dir.clone());
630 assert_eq!(builder.homeserver_config, Some(config));
631 assert!(matches!(builder.mode, StorageMode::Persistent(d) if d == dir));
632 }
633
634 #[test]
635 fn persistent_data_dir_init_creates_structure() {
636 let temp = TempDir::new().unwrap();
637 let data_dir = temp.path().join("new-testnet");
638 let persistent = PersistentDataDir::new(data_dir.clone());
639 persistent.init().unwrap();
640
641 assert!(
642 persistent.get_config_file_path().exists(),
643 "config.toml should be created"
644 );
645 let kp = persistent.read_or_create_keypair().unwrap();
647 let kp2 = persistent.read_or_create_keypair().unwrap();
648 assert_eq!(
649 kp.public_key(),
650 kp2.public_key(),
651 "Keypair should be stable across reads"
652 );
653 }
654
655 #[test]
656 fn seeded_config_is_readable_by_testnet_data_dir() {
657 let temp = TempDir::new().unwrap();
658 let data_dir = temp.path().join("testnet");
659 let persistent = PersistentDataDir::new(data_dir);
660
661 let config_content = "[general]\nsignup_mode = \"token_required\"\n";
663 let source = temp.path().join("seed.toml");
664 std::fs::write(&source, config_content).unwrap();
665
666 std::fs::create_dir_all(persistent.path()).unwrap();
668 std::fs::copy(&source, persistent.get_config_file_path()).unwrap();
669 persistent.init().unwrap();
670
671 let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
673 let testnet_dir = TestnetDataDir {
674 inner: persistent,
675 dht_bootstrap_nodes: bootstrap.clone(),
676 postgres_connection_string: None,
677 };
678
679 let config = testnet_dir.read_or_create_config_file().unwrap();
680 assert_eq!(
682 config.general.signup_mode,
683 pubky_homeserver::SignupMode::TokenRequired
684 );
685 assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
687 assert_eq!(config.pkdns.dht_relay_nodes, None);
688 assert_eq!(
690 config.drive.icann_listen_socket.port(),
691 testnet_ports::HOMESERVER_ICANN_HTTP
692 );
693 assert_eq!(
694 config.drive.pubky_listen_socket.port(),
695 testnet_ports::HOMESERVER_PUBKY_HTTPS
696 );
697 assert_eq!(
698 config.admin.listen_socket.port(),
699 testnet_ports::HOMESERVER_ADMIN
700 );
701 assert!(config.admin.enabled);
702 }
703
704 #[test]
705 fn persistent_state_survives_restart() {
706 let temp = TempDir::new().unwrap();
707 let data_dir = temp.path().join("testnet");
708 let bootstrap = vec![DomainPort::from_str("127.0.0.1:6881").unwrap()];
709 let expected_key = testnet_keypair();
710
711 let source = temp.path().join("seed.toml");
713 std::fs::write(&source, "[general]\nsignup_mode = \"token_required\"\n").unwrap();
714
715 let persistent1 = PersistentDataDir::new(data_dir.clone());
716 persistent1.seed_config(&source).unwrap();
717 persistent1
720 .ensure_data_dir_exists_and_is_writable()
721 .unwrap();
722 persistent1.read_or_create_config_file().unwrap();
723
724 let dir1 = TestnetDataDir {
725 inner: persistent1,
726 dht_bootstrap_nodes: bootstrap.clone(),
727 postgres_connection_string: None,
728 };
729 let kp1 = dir1.read_or_create_keypair().unwrap();
730 let config1 = dir1.read_or_create_config_file().unwrap();
731 assert_eq!(
732 kp1.public_key(),
733 expected_key.public_key(),
734 "First run should use the deterministic keypair"
735 );
736 drop(dir1);
737
738 let persistent2 = PersistentDataDir::new(data_dir);
740 let dir2 = TestnetDataDir {
741 inner: persistent2,
742 dht_bootstrap_nodes: bootstrap,
743 postgres_connection_string: None,
744 };
745 let kp2 = dir2.read_or_create_keypair().unwrap();
746 let config2 = dir2.read_or_create_config_file().unwrap();
747
748 assert_eq!(
749 kp1.public_key(),
750 kp2.public_key(),
751 "Keypair should persist across restarts"
752 );
753 assert_eq!(
754 config1.general.signup_mode, config2.general.signup_mode,
755 "Config should persist across restarts"
756 );
757 assert_eq!(
758 config2.general.signup_mode,
759 pubky_homeserver::SignupMode::TokenRequired
760 );
761 }
762}