Skip to main content

pubky_testnet/
static_testnet.rs

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
19/// The bind address used for all static testnet listeners.
20const BIND_ALL: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
21
22/// Apply the fixed static-testnet port and DHT overrides to a config.
23fn 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/// How the testnet stores homeserver state.
35#[derive(Debug)]
36enum StorageMode {
37    /// All state lives in memory / temp dirs and is lost on shutdown.
38    InMemory,
39    /// State is persisted to the given data directory across restarts.
40    Persistent(PathBuf),
41}
42
43/// Builder for configuring and starting a [`StaticTestnet`].
44///
45/// # Examples
46///
47/// ```no_run
48/// # async fn example() -> anyhow::Result<()> {
49/// use pubky_testnet::StaticTestnet;
50///
51/// // In-memory (default)
52/// let testnet = StaticTestnet::builder().build().await?;
53///
54/// // In-memory with custom config
55/// let testnet = StaticTestnet::builder()
56///     .homeserver_config("my-config.toml".into())
57///     .build()
58///     .await?;
59///
60/// // Persistent
61/// let testnet = StaticTestnet::builder()
62///     .persistent("./my-testnet".into())
63///     .build()
64///     .await?;
65/// # Ok(())
66/// # }
67/// ```
68pub 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    /// Set a custom homeserver config file.
82    ///
83    /// In in-memory mode, this overrides the default config.
84    /// In persistent mode, this seeds the initial `config.toml` on first run
85    /// (errors if one already exists in the data directory).
86    pub fn homeserver_config(mut self, path: PathBuf) -> Self {
87        self.homeserver_config = Some(path);
88        self
89    }
90
91    /// Enable persistent mode with the given data directory.
92    ///
93    /// The directory is auto-initialized on first run (config.toml, secret, data/files/).
94    /// On subsequent runs, the existing state is picked up.
95    pub fn persistent(mut self, data_dir: PathBuf) -> Self {
96        self.mode = StorageMode::Persistent(data_dir);
97        self
98    }
99
100    /// Build and start the testnet with the configured options.
101    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
126/// A testnet for **interactive / CLI use** — all ports are fixed and well-known.
127///
128/// Use this when you need a long-running testnet that external processes can
129/// connect to (e.g. browser tests, mobile apps, or manual debugging). The fixed
130/// ports make it easy to hard-code endpoints in client configuration.
131///
132/// Supports two storage modes:
133/// - **In-memory** (default) — all state is lost on shutdown.
134/// - **Persistent** — state is stored on disk and survives restarts.
135///   Enable with `.persistent(data_dir)` on the builder.
136///
137/// For automated tests with random ports, see [`EphemeralTestnet`](crate::EphemeralTestnet).
138///
139/// # Fixed ports
140/// - DHT bootstrap node: `6881`
141/// - pkarr relay: `15411`
142/// - HTTP relay: `15412`
143/// - Homeserver ICANN HTTP: `6286`
144/// - Homeserver Pubky HTTPS: `6287`
145/// - Homeserver admin: `6288`
146///
147/// The homeserver address is hardcoded to `8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo`.
148pub struct StaticTestnet {
149    /// Inner flexible testnet.
150    pub testnet: Testnet,
151    /// Whether the homeserver is using persistent on-disk storage.
152    is_persistent: bool,
153    #[allow(dead_code)]
154    fixed_bootstrap_node: Option<mainline::Dht>, // Keep alive
155    #[allow(dead_code)]
156    temp_dirs: Vec<tempfile::TempDir>, // Keep temp dirs alive for the pkarr relay
157}
158
159impl StaticTestnet {
160    /// Create a builder for configuring the testnet.
161    pub fn builder() -> StaticTestnetBuilder {
162        StaticTestnetBuilder::new()
163    }
164
165    /// Run an in-memory testnet with the default homeserver config.
166    pub async fn start() -> anyhow::Result<Self> {
167        Self::builder().build().await
168    }
169
170    /// Run an in-memory testnet with a custom homeserver config.
171    #[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    /// Whether this testnet is running in persistent mode.
180    pub fn is_persistent(&self) -> bool {
181        self.is_persistent
182    }
183
184    /// Start the shared infrastructure (DHT bootstrap node, pkarr relay, http relay)
185    /// without a homeserver.
186    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    /// Create an additional homeserver with a random keypair.
223    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    /// Create a new pubky client builder.
230    pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
231        self.testnet.client_builder()
232    }
233
234    /// Creates a [`pubky::PubkyHttpClient`] pre-configured to use this test network.
235    pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
236        self.testnet.client()
237    }
238
239    /// Creates a [`pubky::Pubky`] SDK facade pre-configured to use this test network.
240    ///
241    /// This is a convenience method that builds a client from `Self::client_builder`.
242    pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
243        self.testnet.sdk()
244    }
245
246    /// Create a new pkarr client builder.
247    pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
248        self.testnet.pkarr_client_builder()
249    }
250
251    /// Get the homeserver in the testnet.
252    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    /// Get the http relay in the testnet.
260    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    /// Get the pkarr relay in the testnet.
268    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    /// Get the bootstrap nodes for the testnet.
276    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    /// Create a fixed bootstrap node on port 6881 if it is not already running.
292    /// If it's already running, return None.
293    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    /// Creates a fixed pkarr relay with a temporary storage directory.
314    async fn run_fixed_pkarr_relays(&mut self) -> anyhow::Result<()> {
315        let temp_dir = tempfile::tempdir()?; // Gets cleaned up automatically when it drops
316        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    /// Creates a fixed http relay.
340    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        // Don't call persistent_dir.init() here — it would create a random
377        // keypair before TestnetDataDir gets a chance to seed the deterministic
378        // one. AppContext::read_from() below will call the DataDir methods on
379        // our TestnetDataDir wrapper, which seeds the correct keypair.
380        persistent_dir.ensure_data_dir_exists_and_is_writable()?;
381
382        // Wrap the persistent dir so the homeserver joins the testnet's local DHT
383        // instead of the mainnet bootstrap nodes from the on-disk config.
384        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/// A [`PersistentDataDir`] wrapper that overrides DHT config for testnet use.
412///
413/// This ensures the homeserver connects to the testnet's local DHT bootstrap
414/// nodes rather than mainnet, while still using persistent on-disk storage.
415#[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            // Seed the deterministic keypair so the persistent testnet uses the
458            // same well-known identity as the in-memory one.
459            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        // Remove the random keypair that init() created so TestnetDataDir
514        // seeds the deterministic one instead.
515        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        // Second call should return the same key (read from disk).
532        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        // init() created a random keypair — TestnetDataDir should NOT overwrite it.
543        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        // Keypair file should exist after init
646        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        // Write a config override with signup_mode = "token_required" (non-default for tests)
662        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        // Seed: create dir, copy config, init
667        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        // Wrap in TestnetDataDir and read config
672        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        // Seeded value should be preserved
681        assert_eq!(
682            config.general.signup_mode,
683            pubky_homeserver::SignupMode::TokenRequired
684        );
685        // DHT bootstrap should be overridden by TestnetDataDir
686        assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
687        assert_eq!(config.pkdns.dht_relay_nodes, None);
688        // Fixed ports should be applied
689        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        // First "run": seed config, let TestnetDataDir create the keypair
712        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        // Only init the dir structure + config, but NOT the keypair — let
718        // TestnetDataDir seed the deterministic one.
719        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        // Second "run": same dir, no seeding — simulates restart
739        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}