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<pkarr::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<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    /// 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 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            // Seed the deterministic keypair so the persistent testnet uses the
450            // same well-known identity as the in-memory one.
451            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        // Remove the random keypair that init() created so TestnetDataDir
506        // seeds the deterministic one instead.
507        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        // Second call should return the same key (read from disk).
524        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        // init() created a random keypair — TestnetDataDir should NOT overwrite it.
535        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        // Keypair file should exist after init
638        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        // Write a config override with signup_mode = "token_required" (non-default for tests)
654        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        // Seed: create dir, copy config, init
659        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        // Wrap in TestnetDataDir and read config
664        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        // Seeded value should be preserved
673        assert_eq!(
674            config.general.signup_mode,
675            pubky_homeserver::SignupMode::TokenRequired
676        );
677        // DHT bootstrap should be overridden by TestnetDataDir
678        assert_eq!(config.pkdns.dht_bootstrap_nodes, Some(bootstrap));
679        assert_eq!(config.pkdns.dht_relay_nodes, None);
680        // Fixed ports should be applied
681        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        // First "run": seed config, let TestnetDataDir create the keypair
704        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        // Only init the dir structure + config, but NOT the keypair — let
710        // TestnetDataDir seed the deterministic one.
711        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        // Second "run": same dir, no seeding — simulates restart
731        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}