Skip to main content

cdk_mintd/
config_service.rs

1//! Validation and lifecycle rules for database-backed mintd configuration.
2
3use std::fmt;
4use std::str::FromStr;
5use std::sync::Arc;
6
7use bip39::Mnemonic;
8use bitcoin::bip32::Xpriv;
9use bitcoin::hashes::{sha256, Hash};
10use bitcoin::secp256k1::Secp256k1;
11use bitcoin::Network;
12use cdk_signatory::signatory::Signatory;
13use thiserror::Error;
14
15use crate::config::{Database, DatabaseEngine, Settings};
16use crate::config_store::{ConfigEnvelope, ConfigRepository, ConfigStoreError, DocumentState};
17use crate::secret::{SecretRef, SecretRefError, SecretResolveError};
18
19const SIGNING_IDENTITY_DOMAIN: &[u8] = b"cdk-mintd/signing-identity/v1\0";
20
21/// Cryptographic identity of the configured signer.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub(crate) struct SigningIdentity {
24    pub(crate) pubkey: cdk::nuts::PublicKey,
25    pub(crate) fingerprint: String,
26}
27
28/// A validated document and its resolved runtime settings.
29#[derive(Clone)]
30pub struct ResolvedConfiguration {
31    /// Original import document containing secret references.
32    pub document: String,
33    /// Runtime settings with secret references resolved.
34    pub settings: Settings,
35}
36
37impl fmt::Debug for ResolvedConfiguration {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.debug_struct("ResolvedConfiguration")
40            .field("document", &self.document)
41            .field("settings", &"[resolved configuration redacted]")
42            .finish_non_exhaustive()
43    }
44}
45
46/// Configuration selected for daemon startup.
47#[derive(Clone)]
48pub(crate) struct StartupConfiguration {
49    pub(crate) resolved: ResolvedConfiguration,
50    pub(crate) state: DocumentState,
51    pub(crate) revision: u64,
52    pub(crate) signing_identity: SigningIdentity,
53    pub(crate) remote_signatory: Option<Arc<cdk_signatory::SignatoryRpcClient>>,
54}
55
56impl fmt::Debug for StartupConfiguration {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.debug_struct("StartupConfiguration")
59            .field("resolved", &self.resolved)
60            .field("state", &self.state)
61            .field("revision", &self.revision)
62            .field("signing_identity", &self.signing_identity)
63            .field("remote_signatory", &self.remote_signatory.is_some())
64            .finish()
65    }
66}
67
68struct SigningIdentityResolution {
69    identity: SigningIdentity,
70    remote_signatory: Option<Arc<cdk_signatory::SignatoryRpcClient>>,
71}
72
73/// Result of a configuration apply operation.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct ApplyOutcome {
76    /// Applying a new document requires a daemon restart.
77    pub restart_required: bool,
78}
79
80/// Result of a configuration rollback operation.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct RollbackOutcome {
83    /// The restored document requires a daemon restart before it is active.
84    pub restart_required: bool,
85}
86
87/// Database-backed configuration failures.
88#[derive(Debug, Error)]
89pub enum ConfigurationServiceError {
90    /// The TOML document could not be parsed.
91    #[error("invalid mintd configuration document: {0}")]
92    Parse(#[from] config::ConfigError),
93
94    /// A secret was embedded directly instead of referenced.
95    #[error("{field} must use an `env:VARIABLE` or `file:/absolute/path` secret reference")]
96    LiteralSecret {
97        /// Configuration field containing the literal.
98        field: &'static str,
99    },
100
101    /// An environment secret could not be resolved.
102    #[error("could not resolve {field} from environment variable {name}")]
103    EnvironmentSecret {
104        /// Configuration field being resolved.
105        field: &'static str,
106        /// Referenced variable.
107        name: String,
108    },
109
110    /// A file secret could not be resolved.
111    #[error("could not resolve {field} from secret file {}: {source}", path.display())]
112    FileSecret {
113        /// Configuration field being resolved.
114        field: &'static str,
115        /// Referenced file.
116        path: std::path::PathBuf,
117        /// File access failure.
118        #[source]
119        source: std::io::Error,
120    },
121
122    /// A secret reference was empty or resolved to an empty value.
123    #[error("secret reference for {field} resolved to an empty value")]
124    EmptySecret {
125        /// Configuration field being resolved.
126        field: &'static str,
127    },
128
129    /// Runtime validation rejected the resolved settings.
130    #[error("invalid mintd configuration: {0}")]
131    Validation(String),
132
133    /// The document points at a different primary database.
134    #[error("primary database settings do not match the bootstrap database settings")]
135    PrimaryDatabaseChange,
136
137    /// The configured signer could not be identified.
138    #[error("could not determine configured mint signing identity: {0}")]
139    SigningIdentity(String),
140
141    /// The configured signer differs from the database identity.
142    #[error(
143        "configured signing identity does not match this mint database; signer migration is not supported by config apply"
144    )]
145    SigningIdentityChange,
146
147    /// Persistent configuration storage failed.
148    #[error(transparent)]
149    Store(#[from] ConfigStoreError),
150}
151
152/// Service for the single authoritative configuration record.
153#[derive(Debug, Clone)]
154pub(crate) struct ConfigurationService {
155    repository: ConfigRepository,
156    primary_database: Database,
157}
158
159impl ConfigurationService {
160    pub(crate) fn new(repository: ConfigRepository, primary_database: Database) -> Self {
161        Self {
162            repository,
163            primary_database,
164        }
165    }
166
167    /// Parses, resolves, and validates an import document.
168    pub fn validate_document(
169        document: &str,
170    ) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
171        let mut settings = Settings::try_from_toml(document)?;
172        validate_secret_references(&settings)?;
173        prune_inactive_configuration(&mut settings);
174        resolve_secrets(&mut settings)?;
175        crate::validate_settings(&settings)
176            .map_err(|error| ConfigurationServiceError::Validation(error.to_string()))?;
177        Ok(ResolvedConfiguration {
178            document: document.to_owned(),
179            settings,
180        })
181    }
182
183    /// Validates an import document and verifies its configured signer.
184    pub(crate) async fn validate_import(
185        document: &str,
186    ) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
187        Ok(Self::validated_import(document).await?.0)
188    }
189
190    /// Initializes an empty configuration repository.
191    pub(crate) async fn initialize(
192        &self,
193        document: &str,
194        database_pubkey: Option<cdk::nuts::PublicKey>,
195    ) -> Result<(), ConfigurationServiceError> {
196        let (resolved, signing_identity) = Self::validated_import(document).await?;
197        self.require_primary_database(&resolved.settings.database)?;
198        if database_pubkey.is_some_and(|pubkey| pubkey != signing_identity.pubkey) {
199            return Err(ConfigurationServiceError::SigningIdentityChange);
200        }
201        self.repository
202            .initialize(ConfigEnvelope::new(
203                resolved.document,
204                signing_identity.fingerprint,
205            ))
206            .await?;
207        Ok(())
208    }
209
210    /// Validates and optionally replaces the authoritative document.
211    pub(crate) async fn apply(
212        &self,
213        document: &str,
214        validate_only: bool,
215    ) -> Result<ApplyOutcome, ConfigurationServiceError> {
216        let (resolved, signing_identity) = Self::validated_import(document).await?;
217        self.require_primary_database(&resolved.settings.database)?;
218        let current = self.repository.active().await?;
219        if current.signing_identity != signing_identity.fingerprint {
220            return Err(ConfigurationServiceError::SigningIdentityChange);
221        }
222        if !validate_only {
223            self.repository
224                .replace(resolved.document, &signing_identity.fingerprint)
225                .await?;
226        }
227        Ok(ApplyOutcome {
228            restart_required: !validate_only,
229        })
230    }
231
232    /// Loads and validates the document selected for startup.
233    pub(crate) async fn startup(&self) -> Result<StartupConfiguration, ConfigurationServiceError> {
234        let envelope = self.repository.active().await?;
235        let resolved = Self::validate_document(&envelope.toml)?;
236        self.require_primary_database(&resolved.settings.database)?;
237        let signing_resolution = resolve_signing_identity_async(&resolved.settings).await?;
238        validate_authored_mint_pubkey(&resolved.settings, &signing_resolution.identity)?;
239        if envelope.signing_identity != signing_resolution.identity.fingerprint {
240            return Err(ConfigurationServiceError::SigningIdentityChange);
241        }
242        Ok(StartupConfiguration {
243            resolved,
244            state: envelope.state(),
245            revision: envelope.revision,
246            signing_identity: signing_resolution.identity,
247            remote_signatory: signing_resolution.remote_signatory,
248        })
249    }
250
251    /// Returns the stored import document without resolved secrets.
252    pub(crate) async fn document(&self) -> Result<String, ConfigurationServiceError> {
253        Ok(self.repository.active().await?.toml)
254    }
255
256    /// Reports whether the stored configuration still requires a restart.
257    #[cfg(any(feature = "management-rpc", test))]
258    pub(crate) async fn has_pending_configuration(
259        &self,
260    ) -> Result<bool, ConfigurationServiceError> {
261        Ok(matches!(
262            self.repository.active().await?.state(),
263            DocumentState::Pending
264        ))
265    }
266
267    /// Marks the current startup document applied if it has not been replaced.
268    pub(crate) async fn mark_applied(
269        &self,
270        expected_revision: u64,
271    ) -> Result<bool, ConfigurationServiceError> {
272        Ok(self.repository.mark_applied(expected_revision).await?)
273    }
274
275    /// Stages the last configuration known to have reached applied state.
276    pub(crate) async fn rollback(&self) -> Result<RollbackOutcome, ConfigurationServiceError> {
277        Ok(RollbackOutcome {
278            restart_required: self.repository.rollback().await?,
279        })
280    }
281
282    fn require_primary_database(
283        &self,
284        configured: &Database,
285    ) -> Result<(), ConfigurationServiceError> {
286        if !same_primary_database(configured, &self.primary_database) {
287            return Err(ConfigurationServiceError::PrimaryDatabaseChange);
288        }
289        Ok(())
290    }
291
292    async fn validated_import(
293        document: &str,
294    ) -> Result<(ResolvedConfiguration, SigningIdentity), ConfigurationServiceError> {
295        let resolved = Self::validate_document(document)?;
296        let signing_identity = discover_signing_identity_async(&resolved.settings).await?;
297        validate_authored_mint_pubkey(&resolved.settings, &signing_identity)?;
298        Ok((resolved, signing_identity))
299    }
300}
301
302pub(crate) fn discover_signing_identity(
303    settings: &Settings,
304) -> Result<SigningIdentity, ConfigurationServiceError> {
305    let pubkey = if settings.enabled_signatory().is_some() {
306        return Err(ConfigurationServiceError::SigningIdentity(
307            "remote signatory identity requires asynchronous validation".to_owned(),
308        ));
309    } else if let Some(seed) = settings
310        .info
311        .seed
312        .as_deref()
313        .filter(|seed| !seed.is_empty())
314    {
315        root_pubkey(seed.as_bytes())?
316    } else if let Some(mnemonic) = settings.info.mnemonic.as_deref() {
317        let mnemonic = Mnemonic::from_str(mnemonic)
318            .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?;
319        root_pubkey(&mnemonic.to_seed_normalized(""))?
320    } else {
321        return Err(ConfigurationServiceError::SigningIdentity(
322            "no local signing source is configured".to_owned(),
323        ));
324    };
325    Ok(signing_identity_from_pubkey(pubkey))
326}
327
328/// Resolves the signer, including a configured remote signatory.
329pub(crate) async fn discover_signing_identity_async(
330    settings: &Settings,
331) -> Result<SigningIdentity, ConfigurationServiceError> {
332    Ok(resolve_signing_identity_async(settings).await?.identity)
333}
334
335async fn resolve_signing_identity_async(
336    settings: &Settings,
337) -> Result<SigningIdentityResolution, ConfigurationServiceError> {
338    if let Some(signatory) = settings.enabled_signatory() {
339        let client = Arc::new(
340            cdk_signatory::SignatoryRpcClient::new(
341                &signatory.address,
342                signatory.port,
343                signatory.tls_dir.clone(),
344            )
345            .await
346            .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?,
347        );
348        let pubkey = client
349            .keysets()
350            .await
351            .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?
352            .pubkey;
353        Ok(SigningIdentityResolution {
354            identity: signing_identity_from_pubkey(pubkey),
355            remote_signatory: Some(client),
356        })
357    } else {
358        Ok(SigningIdentityResolution {
359            identity: discover_signing_identity(settings)?,
360            remote_signatory: None,
361        })
362    }
363}
364
365fn root_pubkey(seed: &[u8]) -> Result<cdk::nuts::PublicKey, ConfigurationServiceError> {
366    let secp = Secp256k1::new();
367    let xpriv = Xpriv::new_master(Network::Bitcoin, seed)
368        .map_err(|error| ConfigurationServiceError::SigningIdentity(error.to_string()))?;
369    Ok(xpriv.to_keypair(&secp).public_key().into())
370}
371
372fn signing_identity_from_pubkey(pubkey: cdk::nuts::PublicKey) -> SigningIdentity {
373    let mut input = SIGNING_IDENTITY_DOMAIN.to_vec();
374    input.extend_from_slice(&pubkey.to_bytes());
375    SigningIdentity {
376        pubkey,
377        fingerprint: sha256::Hash::hash(&input).to_string(),
378    }
379}
380
381fn validate_authored_mint_pubkey(
382    settings: &Settings,
383    signing_identity: &SigningIdentity,
384) -> Result<(), ConfigurationServiceError> {
385    if settings
386        .mint_info
387        .pubkey
388        .is_some_and(|pubkey| pubkey != signing_identity.pubkey)
389    {
390        return Err(ConfigurationServiceError::SigningIdentityChange);
391    }
392    Ok(())
393}
394
395fn same_primary_database(configured: &Database, bootstrap: &Database) -> bool {
396    if configured.engine != bootstrap.engine {
397        return false;
398    }
399    if configured.engine != DatabaseEngine::Postgres {
400        return true;
401    }
402    match (&configured.postgres, &bootstrap.postgres) {
403        (Some(configured), Some(bootstrap)) => {
404            configured.url == bootstrap.url
405                && configured.tls_mode == bootstrap.tls_mode
406                && configured.max_connections == bootstrap.max_connections
407                && configured.connection_timeout_seconds == bootstrap.connection_timeout_seconds
408        }
409        _ => false,
410    }
411}
412
413pub(crate) fn prune_inactive_configuration(settings: &mut Settings) {
414    if settings.database.engine != DatabaseEngine::Postgres {
415        settings.database.postgres = None;
416    }
417    if settings
418        .auth
419        .as_ref()
420        .is_some_and(|auth| !auth.auth_enabled)
421    {
422        settings.auth = None;
423    }
424    if settings.auth.is_none() || settings.database.engine != DatabaseEngine::Postgres {
425        settings.auth_database = None;
426    }
427    if settings
428        .signatory
429        .as_ref()
430        .is_some_and(|signatory| !signatory.enabled)
431    {
432        settings.signatory = None;
433    }
434
435    #[cfg(feature = "ldk-node")]
436    if !settings
437        .payment_backend
438        .iter()
439        .any(|backend| backend.backend == crate::config::PaymentBackendType::LdkNode)
440    {
441        settings.ldk_node = None;
442    }
443    #[cfg(feature = "bdk")]
444    if !settings
445        .onchain
446        .as_ref()
447        .is_some_and(|onchain| onchain.onchain_backend == crate::config::OnchainBackend::Bdk)
448    {
449        settings.bdk = None;
450    }
451}
452
453fn validate_secret_references(settings: &Settings) -> Result<(), ConfigurationServiceError> {
454    validate_optional_secret_reference(settings.info.seed.as_deref(), "info.seed")?;
455    validate_optional_secret_reference(settings.info.mnemonic.as_deref(), "info.mnemonic")?;
456
457    if let Some(postgres) = settings.database.postgres.as_ref() {
458        validate_secret_reference(&postgres.url, "database.postgres.url")?;
459    }
460    if let Some(postgres) = settings
461        .auth_database
462        .as_ref()
463        .and_then(|database| database.postgres.as_ref())
464    {
465        validate_secret_reference(&postgres.url, "auth_database.postgres.url")?;
466    }
467    #[cfg(feature = "bdk")]
468    if let Some(bdk) = settings.bdk.as_ref() {
469        validate_optional_secret_reference(
470            bdk.bitcoind_rpc_password.as_deref(),
471            "bdk.bitcoind_rpc_password",
472        )?;
473        validate_optional_secret_reference(bdk.mnemonic.as_deref(), "bdk.mnemonic")?;
474    }
475    #[cfg(feature = "ldk-node")]
476    if let Some(ldk_node) = settings.ldk_node.as_ref() {
477        validate_optional_secret_reference(
478            ldk_node.bitcoind_rpc_password.as_deref(),
479            "ldk_node.bitcoind_rpc_password",
480        )?;
481        validate_optional_secret_reference(
482            ldk_node.ldk_node_mnemonic.as_deref(),
483            "ldk_node.ldk_node_mnemonic",
484        )?;
485    }
486    #[cfg(feature = "redis")]
487    if let cdk_axum::cache::Backend::Redis(redis) = &settings.info.http_cache.backend {
488        validate_secret_reference(
489            &redis.connection_string,
490            "info.http_cache.connection_string",
491        )?;
492        if let Some(cluster_nodes) = redis.cluster_nodes.as_ref() {
493            for node in cluster_nodes {
494                validate_secret_reference(node, "info.http_cache.cluster_nodes")?;
495            }
496        }
497    }
498    Ok(())
499}
500
501fn validate_optional_secret_reference(
502    value: Option<&str>,
503    field: &'static str,
504) -> Result<(), ConfigurationServiceError> {
505    if let Some(value) = value {
506        validate_secret_reference(value, field)?;
507    }
508    Ok(())
509}
510
511fn validate_secret_reference(
512    value: &str,
513    field: &'static str,
514) -> Result<(), ConfigurationServiceError> {
515    if value.is_empty() {
516        return Ok(());
517    }
518    SecretRef::parse(value).map_err(|error| match error {
519        SecretRefError::EmptyEnvironmentName => ConfigurationServiceError::EmptySecret { field },
520        SecretRefError::Literal
521        | SecretRefError::EmptyFilePath
522        | SecretRefError::RelativeFilePath { .. } => {
523            ConfigurationServiceError::LiteralSecret { field }
524        }
525    })?;
526    Ok(())
527}
528
529fn resolve_secrets(settings: &mut Settings) -> Result<(), ConfigurationServiceError> {
530    if settings.enabled_signatory().is_none() {
531        resolve_optional_secret(&mut settings.info.seed, "info.seed")?;
532        resolve_optional_trimmed_secret(&mut settings.info.mnemonic, "info.mnemonic")?;
533    }
534
535    if let Some(postgres) = settings.database.postgres.as_mut() {
536        resolve_secret(&mut postgres.url, "database.postgres.url")?;
537    }
538    if let Some(postgres) = settings
539        .auth_database
540        .as_mut()
541        .and_then(|database| database.postgres.as_mut())
542    {
543        resolve_secret(&mut postgres.url, "auth_database.postgres.url")?;
544    }
545    #[cfg(feature = "bdk")]
546    if let Some(bdk) = settings.bdk.as_mut() {
547        resolve_optional_secret(&mut bdk.bitcoind_rpc_password, "bdk.bitcoind_rpc_password")?;
548        resolve_optional_trimmed_secret(&mut bdk.mnemonic, "bdk.mnemonic")?;
549    }
550    #[cfg(feature = "ldk-node")]
551    if let Some(ldk_node) = settings.ldk_node.as_mut() {
552        resolve_optional_secret(
553            &mut ldk_node.bitcoind_rpc_password,
554            "ldk_node.bitcoind_rpc_password",
555        )?;
556        resolve_optional_trimmed_secret(
557            &mut ldk_node.ldk_node_mnemonic,
558            "ldk_node.ldk_node_mnemonic",
559        )?;
560    }
561    #[cfg(feature = "redis")]
562    if let cdk_axum::cache::Backend::Redis(redis) = &mut settings.info.http_cache.backend {
563        resolve_secret(
564            &mut redis.connection_string,
565            "info.http_cache.connection_string",
566        )?;
567        if let Some(cluster_nodes) = redis.cluster_nodes.as_mut() {
568            for node in cluster_nodes {
569                resolve_secret(node, "info.http_cache.cluster_nodes")?;
570            }
571        }
572    }
573    Ok(())
574}
575
576fn resolve_optional_secret(
577    value: &mut Option<String>,
578    field: &'static str,
579) -> Result<(), ConfigurationServiceError> {
580    if let Some(value) = value.as_mut() {
581        resolve_secret(value, field)?;
582    }
583    Ok(())
584}
585
586fn resolve_optional_trimmed_secret(
587    value: &mut Option<String>,
588    field: &'static str,
589) -> Result<(), ConfigurationServiceError> {
590    if let Some(value) = value.as_mut() {
591        resolve_trimmed_secret(value, field)?;
592    }
593    Ok(())
594}
595
596fn resolve_secret(
597    value: &mut String,
598    field: &'static str,
599) -> Result<(), ConfigurationServiceError> {
600    resolve_secret_with(value, field, |resolved| resolved)
601}
602
603fn resolve_trimmed_secret(
604    value: &mut String,
605    field: &'static str,
606) -> Result<(), ConfigurationServiceError> {
607    resolve_secret_with(value, field, |resolved| resolved.trim().to_owned())
608}
609
610fn resolve_secret_with(
611    value: &mut String,
612    field: &'static str,
613    normalize: impl FnOnce(String) -> String,
614) -> Result<(), ConfigurationServiceError> {
615    validate_secret_reference(value, field)?;
616    if value.is_empty() {
617        return Ok(());
618    }
619    let reference = SecretRef::parse(value).map_err(|error| match error {
620        SecretRefError::EmptyEnvironmentName => ConfigurationServiceError::EmptySecret { field },
621        SecretRefError::Literal
622        | SecretRefError::EmptyFilePath
623        | SecretRefError::RelativeFilePath { .. } => {
624            ConfigurationServiceError::LiteralSecret { field }
625        }
626    })?;
627    let resolved = reference.resolve().map_err(|error| match error {
628        SecretResolveError::Environment { name } => {
629            ConfigurationServiceError::EnvironmentSecret { field, name }
630        }
631        SecretResolveError::File { path, source } => ConfigurationServiceError::FileSecret {
632            field,
633            path,
634            source,
635        },
636    })?;
637    let resolved = normalize(resolved);
638    if resolved.is_empty() {
639        return Err(ConfigurationServiceError::EmptySecret { field });
640    }
641    *value = resolved;
642    Ok(())
643}
644
645#[cfg(test)]
646mod tests {
647    #[cfg(feature = "sqlite")]
648    use std::sync::Arc;
649
650    #[cfg(feature = "sqlite")]
651    use cdk_sqlite::mint::memory;
652
653    use super::*;
654
655    const TEST_MNEMONIC_ONE: &str =
656        "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
657    const TEST_MNEMONIC_TWO: &str =
658        "legal winner thank year wave sausage worth useful legal winner thank yellow";
659
660    #[cfg(feature = "fakewallet")]
661    fn document(secret_reference: &str, name: &str) -> String {
662        format!(
663            r#"
664[info]
665mnemonic = "{secret_reference}"
666
667[mint_info]
668name = "{name}"
669
670[payment_backend]
671backend = "fakewallet"
672
673[fake_wallet]
674
675[database]
676engine = "sqlite"
677"#
678        )
679    }
680
681    #[cfg(feature = "fakewallet")]
682    fn remote_signatory_document(info_fields: &str, extra_sections: &str) -> String {
683        format!(
684            r#"
685[info]
686{info_fields}
687
688[signatory]
689enabled = true
690allow_insecure = true
691
692[payment_backend]
693backend = "fakewallet"
694
695[fake_wallet]
696
697[database]
698engine = "sqlite"
699
700{extra_sections}
701"#
702        )
703    }
704
705    #[cfg(feature = "sqlite")]
706    async fn service() -> ConfigurationService {
707        let database = Arc::new(memory::empty().await.expect("in-memory database"));
708        ConfigurationService::new(ConfigRepository::new(database), Database::default())
709    }
710
711    #[test]
712    fn literal_signing_secret_is_rejected() {
713        let error = ConfigurationService::validate_document(
714            r#"
715[info]
716mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
717"#,
718        )
719        .expect_err("literal mnemonic should fail");
720        assert!(matches!(
721            error,
722            ConfigurationServiceError::LiteralSecret {
723                field: "info.mnemonic"
724            }
725        ));
726    }
727
728    #[cfg(feature = "fakewallet")]
729    #[test]
730    fn remote_signatory_rejects_local_signing_material_without_resolving_it() {
731        let missing_secret =
732            crate::test_utils::unique_temp_path("remote_signatory_unused_local_secret");
733        let document = format!(
734            r#"
735[info]
736mnemonic = "file:{}"
737
738[signatory]
739enabled = true
740allow_insecure = true
741
742[payment_backend]
743backend = "fakewallet"
744
745[fake_wallet]
746
747[database]
748engine = "sqlite"
749"#,
750            missing_secret.display()
751        );
752        let error = ConfigurationService::validate_document(&document)
753            .expect_err("remote and local signing sources should conflict");
754        assert!(
755            matches!(
756                &error,
757                ConfigurationServiceError::Validation(message)
758                    if message.contains("Remote signatory configuration cannot include")
759            ),
760            "unexpected error: {error}"
761        );
762    }
763
764    #[cfg(feature = "fakewallet")]
765    #[test]
766    fn remote_signatory_configuration_does_not_require_local_signing_material() {
767        let resolved = ConfigurationService::validate_document(
768            r#"
769[signatory]
770enabled = true
771allow_insecure = true
772
773[payment_backend]
774backend = "fakewallet"
775
776[fake_wallet]
777
778[database]
779engine = "sqlite"
780"#,
781        )
782        .expect("remote signatory should be a complete signing source");
783        assert!(resolved.settings.info.seed.is_none());
784        assert!(resolved.settings.info.mnemonic.is_none());
785    }
786
787    #[cfg(feature = "fakewallet")]
788    #[test]
789    fn literal_secrets_in_inactive_or_skipped_sections_are_rejected() {
790        let assert_rejected = |document: String, expected_field| {
791            let error = ConfigurationService::validate_document(&document)
792                .expect_err("literal secret should be rejected before pruning");
793            assert!(
794                matches!(
795                    error,
796                    ConfigurationServiceError::LiteralSecret { field }
797                        if field == expected_field
798                ),
799                "unexpected error for {expected_field}: {error}"
800            );
801        };
802
803        assert_rejected(
804            remote_signatory_document(r#"seed = "plaintext-secret""#, ""),
805            "info.seed",
806        );
807        assert_rejected(
808            remote_signatory_document(
809                "",
810                r#"
811[database.postgres]
812url = "postgresql://operator:plaintext-secret@localhost/cdk"
813"#,
814            ),
815            "database.postgres.url",
816        );
817        assert_rejected(
818            remote_signatory_document(
819                "",
820                r#"
821[auth_database.postgres]
822url = "postgresql://operator:plaintext-secret@localhost/cdk"
823"#,
824            ),
825            "auth_database.postgres.url",
826        );
827
828        #[cfg(feature = "bdk")]
829        assert_rejected(
830            remote_signatory_document(
831                "",
832                r#"
833[bdk]
834mnemonic = "plaintext-secret"
835"#,
836            ),
837            "bdk.mnemonic",
838        );
839        #[cfg(feature = "ldk-node")]
840        assert_rejected(
841            remote_signatory_document(
842                "",
843                r#"
844[ldk_node]
845ldk_node_mnemonic = "plaintext-secret"
846"#,
847            ),
848            "ldk_node.ldk_node_mnemonic",
849        );
850        #[cfg(feature = "redis")]
851        assert_rejected(
852            remote_signatory_document(
853                "",
854                r#"
855[info.http_cache]
856backend = "redis"
857connection_string = "redis://operator:plaintext-secret@localhost"
858"#,
859            ),
860            "info.http_cache.connection_string",
861        );
862        #[cfg(feature = "redis")]
863        assert_rejected(
864            remote_signatory_document(
865                "",
866                r#"
867[info.http_cache]
868backend = "redis"
869connection_string = ""
870cluster_nodes = ["redis://operator:plaintext-secret@localhost"]
871"#,
872            ),
873            "info.http_cache.cluster_nodes",
874        );
875    }
876
877    #[cfg(feature = "fakewallet")]
878    #[test]
879    fn inactive_secret_references_are_validated_but_not_resolved() {
880        let _env_lock = crate::test_utils::env_lock();
881        const MISSING: &str = "CDK_MINTD_TEST_MISSING_INACTIVE_POSTGRES_SECRET";
882        std::env::remove_var(MISSING);
883        let document = remote_signatory_document(
884            "",
885            &format!(
886                r#"
887[database.postgres]
888url = "env:{MISSING}"
889"#
890            ),
891        );
892
893        ConfigurationService::validate_document(&document)
894            .expect("inactive valid reference should not be resolved");
895    }
896
897    #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
898    #[tokio::test]
899    async fn rejected_inactive_literal_secret_is_not_persisted() {
900        let service = service().await;
901        let document = remote_signatory_document(
902            "",
903            r#"
904[database.postgres]
905url = "postgresql://operator:plaintext-secret@localhost/cdk"
906"#,
907        );
908
909        assert!(matches!(
910            service.initialize(&document, None).await,
911            Err(ConfigurationServiceError::LiteralSecret {
912                field: "database.postgres.url"
913            })
914        ));
915        assert!(matches!(
916            service.document().await,
917            Err(ConfigurationServiceError::Store(
918                ConfigStoreError::NotInitialized
919            ))
920        ));
921    }
922
923    #[cfg(feature = "fakewallet")]
924    #[test]
925    fn missing_empty_and_relative_secret_references_are_rejected() {
926        let _env_lock = crate::test_utils::env_lock();
927        const MISSING: &str = "CDK_MINTD_TEST_MISSING_CONFIG_SECRET";
928        const EMPTY: &str = "CDK_MINTD_TEST_EMPTY_CONFIG_SECRET";
929        std::env::remove_var(MISSING);
930        std::env::set_var(EMPTY, "  ");
931
932        assert!(matches!(
933            ConfigurationService::validate_document(&document(
934                &format!("env:{MISSING}"),
935                "missing"
936            )),
937            Err(ConfigurationServiceError::EnvironmentSecret { .. })
938        ));
939        assert!(matches!(
940            ConfigurationService::validate_document(&document(&format!("env:{EMPTY}"), "empty")),
941            Err(ConfigurationServiceError::EmptySecret { .. })
942        ));
943        assert!(matches!(
944            ConfigurationService::validate_document(&document("file:relative/secret", "relative")),
945            Err(ConfigurationServiceError::LiteralSecret { .. })
946        ));
947
948        std::env::remove_var(EMPTY);
949    }
950
951    #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
952    #[tokio::test]
953    async fn initialize_apply_and_validate_only_use_one_record() {
954        let secret_path = crate::test_utils::unique_temp_path("atomic_config_secret");
955        std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
956        let secret_reference = format!("file:{}", secret_path.display());
957        let service = service().await;
958        let first = document(&secret_reference, "first");
959        let second = document(&secret_reference, "second");
960
961        service
962            .initialize(&first, None)
963            .await
964            .expect("initialize configuration");
965        assert!(matches!(
966            service.initialize(&first, None).await,
967            Err(ConfigurationServiceError::Store(
968                ConfigStoreError::AlreadyInitialized
969            ))
970        ));
971
972        let outcome = service
973            .apply(&second, true)
974            .await
975            .expect("validate replacement");
976        assert!(!outcome.restart_required);
977        assert_eq!(service.document().await.expect("stored document"), first);
978
979        let running_snapshot = service.startup().await.expect("running snapshot");
980        assert!(service
981            .mark_applied(running_snapshot.revision)
982            .await
983            .expect("mark first document applied"));
984        let outcome = service
985            .apply(&second, false)
986            .await
987            .expect("replace configuration");
988        assert!(outcome.restart_required);
989        assert_eq!(service.document().await.expect("stored document"), second);
990        assert_eq!(running_snapshot.resolved.settings.mint_info.name, "first");
991        let next_startup = service.startup().await.expect("startup document");
992        assert_eq!(next_startup.resolved.settings.mint_info.name, "second");
993        assert_eq!(next_startup.state, DocumentState::Pending);
994
995        let rollback = service.rollback().await.expect("rollback pending document");
996        assert!(rollback.restart_required);
997        assert_eq!(service.document().await.expect("restored document"), first);
998        assert!(service
999            .has_pending_configuration()
1000            .await
1001            .expect("restored document requires activation"));
1002
1003        let _ = std::fs::remove_file(secret_path);
1004    }
1005
1006    #[cfg(feature = "fakewallet")]
1007    #[test]
1008    fn startup_document_ignores_general_operational_environment_overrides() {
1009        let _env_lock = crate::test_utils::env_lock();
1010        let secret_path = crate::test_utils::unique_temp_path("startup_config_secret");
1011        std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1012        std::env::set_var(crate::env_vars::ENV_LISTEN_PORT, "6553");
1013        let document = format!(
1014            r#"
1015[info]
1016listen_port = 8091
1017mnemonic = "file:{}"
1018
1019[payment_backend]
1020backend = "fakewallet"
1021
1022[fake_wallet]
1023
1024[database]
1025engine = "sqlite"
1026"#,
1027            secret_path.display()
1028        );
1029        let resolved =
1030            ConfigurationService::validate_document(&document).expect("validate startup document");
1031        assert_eq!(resolved.settings.info.listen_port, 8091);
1032
1033        std::env::remove_var(crate::env_vars::ENV_LISTEN_PORT);
1034        let _ = std::fs::remove_file(secret_path);
1035    }
1036
1037    #[test]
1038    fn configuration_without_payment_backend_is_rejected() {
1039        let secret_path = crate::test_utils::unique_temp_path("no_payment_backend_secret");
1040        std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1041        let document = format!(
1042            r#"
1043[info]
1044mnemonic = "file:{}"
1045
1046[payment_backend]
1047backend = "none"
1048
1049[database]
1050engine = "sqlite"
1051"#,
1052            secret_path.display()
1053        );
1054        let error = ConfigurationService::validate_document(&document)
1055            .expect_err("configuration without a payment backend should fail");
1056        assert!(
1057            matches!(
1058                &error,
1059                ConfigurationServiceError::Validation(message)
1060                    if message.contains("At least one payment backend")
1061            ),
1062            "unexpected error: {error}"
1063        );
1064
1065        let _ = std::fs::remove_file(secret_path);
1066    }
1067
1068    #[cfg(feature = "fakewallet")]
1069    #[test]
1070    fn selected_backend_without_its_configuration_section_is_rejected() {
1071        let secret_path = crate::test_utils::unique_temp_path("missing_backend_section_secret");
1072        std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1073        let document = format!(
1074            r#"
1075[info]
1076mnemonic = "file:{}"
1077
1078[payment_backend]
1079backend = "fakewallet"
1080
1081[database]
1082engine = "sqlite"
1083"#,
1084            secret_path.display()
1085        );
1086        let error = ConfigurationService::validate_document(&document)
1087            .expect_err("selected backend without its config section should fail");
1088        assert!(
1089            matches!(
1090                &error,
1091                ConfigurationServiceError::Validation(message)
1092                    if message.contains(
1093                        "Fake wallet backend selected but [fake_wallet] config section is missing"
1094                    )
1095            ),
1096            "unexpected error: {error}"
1097        );
1098
1099        let _ = std::fs::remove_file(secret_path);
1100    }
1101
1102    #[cfg(feature = "bdk")]
1103    #[test]
1104    fn opaque_environment_and_file_secrets_preserve_whitespace() {
1105        let _env_lock = crate::test_utils::env_lock();
1106        const PASSWORD_ENV: &str = "CDK_MINTD_TEST_WHITESPACE_BDK_PASSWORD";
1107        let mnemonic_path = crate::test_utils::unique_temp_path("whitespace_secret_mnemonic");
1108        let url_path = crate::test_utils::unique_temp_path("whitespace_secret_postgres_url");
1109        std::fs::write(&mnemonic_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1110        std::fs::write(&url_path, "\tpostgresql://user:pass@localhost/cdk\n")
1111            .expect("write postgres url secret");
1112        std::env::set_var(PASSWORD_ENV, " password-secret ");
1113        let document = format!(
1114            r#"
1115[info]
1116mnemonic = "file:{}"
1117
1118[payment_backend]
1119backend = "none"
1120
1121[onchain]
1122onchain_backend = "bdk"
1123
1124[bdk]
1125network = "regtest"
1126bitcoind_rpc_password = "env:{PASSWORD_ENV}"
1127
1128[database]
1129engine = "postgres"
1130
1131[database.postgres]
1132url = "file:{}"
1133"#,
1134            mnemonic_path.display(),
1135            url_path.display()
1136        );
1137
1138        let resolved = ConfigurationService::validate_document(&document)
1139            .expect("opaque secrets containing whitespace should validate");
1140        let bdk = resolved
1141            .settings
1142            .bdk
1143            .expect("bdk configuration should be present");
1144        assert_eq!(
1145            bdk.bitcoind_rpc_password.as_deref(),
1146            Some(" password-secret ")
1147        );
1148        let postgres = resolved
1149            .settings
1150            .database
1151            .postgres
1152            .expect("postgres configuration should be present");
1153        assert_eq!(postgres.url, "\tpostgresql://user:pass@localhost/cdk\n");
1154
1155        std::env::remove_var(PASSWORD_ENV);
1156        let _ = std::fs::remove_file(mnemonic_path);
1157        let _ = std::fs::remove_file(url_path);
1158    }
1159
1160    #[cfg(feature = "fakewallet")]
1161    #[test]
1162    fn mnemonic_secret_trims_surrounding_whitespace() {
1163        let mnemonic_path = crate::test_utils::unique_temp_path("trimmed_config_mnemonic");
1164        std::fs::write(&mnemonic_path, format!("  {TEST_MNEMONIC_ONE}\n"))
1165            .expect("write signing secret");
1166
1167        let resolved = ConfigurationService::validate_document(&document(
1168            &format!("file:{}", mnemonic_path.display()),
1169            "trimmed",
1170        ))
1171        .expect("mnemonic surrounding whitespace should be normalized");
1172        assert_eq!(
1173            resolved.settings.info.mnemonic.as_deref(),
1174            Some(TEST_MNEMONIC_ONE)
1175        );
1176
1177        let _ = std::fs::remove_file(mnemonic_path);
1178    }
1179
1180    #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1181    #[tokio::test]
1182    async fn apply_rejects_signer_and_primary_database_changes() {
1183        let signer_path = crate::test_utils::unique_temp_path("signer_config_secret");
1184        let postgres_path = crate::test_utils::unique_temp_path("postgres_config_secret");
1185        std::fs::write(&signer_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1186        std::fs::write(&postgres_path, "postgresql://localhost/cdk-test")
1187            .expect("write postgres secret");
1188        let service = service().await;
1189        let first = document(&format!("file:{}", signer_path.display()), "first");
1190        service
1191            .initialize(&first, None)
1192            .await
1193            .expect("initialize configuration");
1194
1195        std::fs::write(&signer_path, TEST_MNEMONIC_TWO).expect("replace signing secret");
1196        assert!(matches!(
1197            service.apply(&first, false).await,
1198            Err(ConfigurationServiceError::SigningIdentityChange)
1199        ));
1200
1201        std::fs::write(&signer_path, TEST_MNEMONIC_ONE).expect("restore signing secret");
1202        let postgres = format!(
1203            r#"
1204[info]
1205mnemonic = "file:{}"
1206
1207[payment_backend]
1208backend = "fakewallet"
1209
1210[fake_wallet]
1211
1212[database]
1213engine = "postgres"
1214
1215[database.postgres]
1216url = "file:{}"
1217"#,
1218            signer_path.display(),
1219            postgres_path.display()
1220        );
1221        assert!(matches!(
1222            service.apply(&postgres, false).await,
1223            Err(ConfigurationServiceError::PrimaryDatabaseChange)
1224        ));
1225
1226        let _ = std::fs::remove_file(signer_path);
1227        let _ = std::fs::remove_file(postgres_path);
1228    }
1229
1230    #[cfg(feature = "fakewallet")]
1231    #[test]
1232    fn seed_secret_and_inactive_sections_are_resolved_and_pruned() {
1233        let _env_lock = crate::test_utils::env_lock();
1234        const SEED_ENV: &str = "CDK_MINTD_TEST_CONFIG_SEED_SECRET";
1235        let seed = "a".repeat(32);
1236        std::env::set_var(SEED_ENV, &seed);
1237
1238        let document = format!(
1239            r#"
1240[info]
1241seed = "env:{SEED_ENV}"
1242
1243[mint_info]
1244name = "pruned"
1245
1246[payment_backend]
1247backend = "fakewallet"
1248
1249[fake_wallet]
1250
1251[database]
1252engine = "sqlite"
1253
1254[database.postgres]
1255url = "env:SHOULD_BE_PRUNED"
1256
1257[auth]
1258auth_enabled = false
1259openid_discovery = "https://example.com/.well-known/openid-configuration"
1260openid_client_id = "client"
1261
1262[auth_database]
1263[auth_database.postgres]
1264url = "env:SHOULD_BE_PRUNED"
1265
1266[signatory]
1267enabled = false
1268address = "127.0.0.1"
1269port = 15060
1270allow_insecure = true
1271"#
1272        );
1273
1274        let resolved = ConfigurationService::validate_document(&document)
1275            .expect("validate seed-backed document");
1276        assert_eq!(resolved.settings.info.seed.as_deref(), Some(seed.as_str()));
1277        assert!(resolved.settings.database.postgres.is_none());
1278        assert!(resolved.settings.auth.is_none());
1279        assert!(resolved.settings.auth_database.is_none());
1280        assert!(resolved.settings.signatory.is_none());
1281        assert!(format!("{resolved:?}").contains("redacted"));
1282
1283        let identity = discover_signing_identity(&resolved.settings).expect("seed identity");
1284        assert!(!identity.fingerprint.is_empty());
1285
1286        std::env::remove_var(SEED_ENV);
1287    }
1288
1289    #[cfg(feature = "fakewallet")]
1290    #[test]
1291    fn file_secret_errors_empty_env_name_and_missing_file_are_reported() {
1292        let missing = crate::test_utils::unique_temp_path("missing_config_secret");
1293        assert!(matches!(
1294            ConfigurationService::validate_document(&document("env:", "empty-name")),
1295            Err(ConfigurationServiceError::EmptySecret {
1296                field: "info.mnemonic"
1297            })
1298        ));
1299        assert!(matches!(
1300            ConfigurationService::validate_document(&document(
1301                &format!("file:{}", missing.display()),
1302                "missing-file"
1303            )),
1304            Err(ConfigurationServiceError::FileSecret {
1305                field: "info.mnemonic",
1306                ..
1307            })
1308        ));
1309    }
1310
1311    #[cfg(feature = "fakewallet")]
1312    #[tokio::test]
1313    async fn authored_mint_pubkey_must_match_signer() {
1314        let secret_one = crate::test_utils::unique_temp_path("pubkey_config_secret_one");
1315        let secret_two = crate::test_utils::unique_temp_path("pubkey_config_secret_two");
1316        std::fs::write(&secret_one, TEST_MNEMONIC_ONE).expect("write signing secret");
1317        std::fs::write(&secret_two, TEST_MNEMONIC_TWO).expect("write other signing secret");
1318
1319        let identity = discover_signing_identity(
1320            &ConfigurationService::validate_document(&document(
1321                &format!("file:{}", secret_one.display()),
1322                "identity",
1323            ))
1324            .expect("resolve identity document")
1325            .settings,
1326        )
1327        .expect("discover identity");
1328        let other = discover_signing_identity(
1329            &ConfigurationService::validate_document(&document(
1330                &format!("file:{}", secret_two.display()),
1331                "other",
1332            ))
1333            .expect("resolve other document")
1334            .settings,
1335        )
1336        .expect("discover other");
1337
1338        let mismatch = format!(
1339            r#"
1340[info]
1341mnemonic = "file:{}"
1342
1343[mint_info]
1344name = "mismatch"
1345pubkey = "{}"
1346
1347[payment_backend]
1348backend = "fakewallet"
1349
1350[fake_wallet]
1351
1352[database]
1353engine = "sqlite"
1354"#,
1355            secret_one.display(),
1356            other.pubkey
1357        );
1358        assert!(matches!(
1359            ConfigurationService::validate_import(&mismatch).await,
1360            Err(ConfigurationServiceError::SigningIdentityChange)
1361        ));
1362
1363        let matching = format!(
1364            r#"
1365[info]
1366mnemonic = "file:{}"
1367
1368[mint_info]
1369name = "match"
1370pubkey = "{}"
1371
1372[payment_backend]
1373backend = "fakewallet"
1374
1375[fake_wallet]
1376
1377[database]
1378engine = "sqlite"
1379"#,
1380            secret_one.display(),
1381            identity.pubkey
1382        );
1383        ConfigurationService::validate_import(&matching)
1384            .await
1385            .expect("matching pubkey");
1386        let _ = std::fs::remove_file(secret_one);
1387        let _ = std::fs::remove_file(secret_two);
1388    }
1389
1390    #[test]
1391    fn remote_signatory_requires_async_discovery() {
1392        let settings = Settings {
1393            signatory: Some(crate::config::Signatory {
1394                enabled: true,
1395                address: "127.0.0.1".to_owned(),
1396                port: 15060,
1397                tls_dir: None,
1398                allow_insecure: true,
1399            }),
1400            ..Default::default()
1401        };
1402        assert!(matches!(
1403            discover_signing_identity(&settings),
1404            Err(ConfigurationServiceError::SigningIdentity(message))
1405                if message.contains("asynchronous")
1406        ));
1407    }
1408
1409    #[test]
1410    fn same_primary_database_compares_engine_and_postgres_fields() {
1411        let sqlite = Database::default();
1412        let other_sqlite = Database {
1413            engine: DatabaseEngine::Sqlite,
1414            postgres: Some(crate::config::PostgresConfig {
1415                url: "postgresql://ignored".to_owned(),
1416                ..Default::default()
1417            }),
1418        };
1419        assert!(same_primary_database(&sqlite, &other_sqlite));
1420
1421        let left = Database {
1422            engine: DatabaseEngine::Postgres,
1423            postgres: Some(crate::config::PostgresConfig {
1424                url: "postgresql://a".to_owned(),
1425                tls_mode: Some("disable".to_owned()),
1426                max_connections: Some(5),
1427                connection_timeout_seconds: Some(3),
1428            }),
1429        };
1430        let right = Database {
1431            engine: DatabaseEngine::Postgres,
1432            postgres: Some(crate::config::PostgresConfig {
1433                url: "postgresql://a".to_owned(),
1434                tls_mode: Some("disable".to_owned()),
1435                max_connections: Some(5),
1436                connection_timeout_seconds: Some(3),
1437            }),
1438        };
1439        assert!(same_primary_database(&left, &right));
1440        let mut different = right.clone();
1441        different.postgres.as_mut().expect("postgres").url = "postgresql://b".to_owned();
1442        assert!(!same_primary_database(&left, &different));
1443        assert!(!same_primary_database(
1444            &left,
1445            &Database {
1446                engine: DatabaseEngine::Postgres,
1447                postgres: None,
1448            }
1449        ));
1450        assert!(!same_primary_database(&sqlite, &left));
1451    }
1452
1453    #[cfg(all(feature = "sqlite", feature = "fakewallet"))]
1454    #[tokio::test]
1455    async fn initialize_rejects_existing_mint_pubkey_mismatch_and_mark_applied_tracks_document() {
1456        let secret_path = crate::test_utils::unique_temp_path("init_pubkey_config_secret");
1457        std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("write signing secret");
1458        let service = service().await;
1459        let first = document(&format!("file:{}", secret_path.display()), "first");
1460        let identity = discover_signing_identity(
1461            &ConfigurationService::validate_document(&first)
1462                .expect("resolve")
1463                .settings,
1464        )
1465        .expect("identity");
1466
1467        std::fs::write(&secret_path, TEST_MNEMONIC_TWO).expect("swap mnemonic");
1468        let other = discover_signing_identity(
1469            &ConfigurationService::validate_document(&document(
1470                &format!("file:{}", secret_path.display()),
1471                "other",
1472            ))
1473            .expect("resolve other")
1474            .settings,
1475        )
1476        .expect("other identity");
1477        std::fs::write(&secret_path, TEST_MNEMONIC_ONE).expect("restore mnemonic");
1478
1479        assert!(matches!(
1480            service.initialize(&first, Some(other.pubkey)).await,
1481            Err(ConfigurationServiceError::SigningIdentityChange)
1482        ));
1483
1484        service
1485            .initialize(&first, Some(identity.pubkey))
1486            .await
1487            .expect("initialize with matching mint pubkey");
1488        assert!(service
1489            .has_pending_configuration()
1490            .await
1491            .expect("initialized document is pending"));
1492        let initial_revision = service.startup().await.expect("initial startup").revision;
1493        assert!(service
1494            .mark_applied(initial_revision)
1495            .await
1496            .expect("mark applied"));
1497        assert!(!service
1498            .has_pending_configuration()
1499            .await
1500            .expect("applied document is active"));
1501        let startup = service.startup().await.expect("startup");
1502        assert_eq!(startup.state, DocumentState::Applied);
1503        assert_eq!(startup.signing_identity.pubkey, identity.pubkey);
1504        assert!(startup.remote_signatory.is_none());
1505
1506        let second = document(&format!("file:{}", secret_path.display()), "second");
1507        service
1508            .apply(&second, false)
1509            .await
1510            .expect("replace document");
1511        assert!(service
1512            .has_pending_configuration()
1513            .await
1514            .expect("replacement requires restart"));
1515        assert!(!service
1516            .mark_applied(initial_revision)
1517            .await
1518            .expect("stale document remains unapplied"));
1519
1520        let _ = std::fs::remove_file(secret_path);
1521    }
1522}