1use 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#[derive(Debug, Clone, PartialEq, Eq)]
23pub(crate) struct SigningIdentity {
24 pub(crate) pubkey: cdk::nuts::PublicKey,
25 pub(crate) fingerprint: String,
26}
27
28#[derive(Clone)]
30pub struct ResolvedConfiguration {
31 pub document: String,
33 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct ApplyOutcome {
76 pub restart_required: bool,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct RollbackOutcome {
83 pub restart_required: bool,
85}
86
87#[derive(Debug, Error)]
89pub enum ConfigurationServiceError {
90 #[error("invalid mintd configuration document: {0}")]
92 Parse(#[from] config::ConfigError),
93
94 #[error("{field} must use an `env:VARIABLE` or `file:/absolute/path` secret reference")]
96 LiteralSecret {
97 field: &'static str,
99 },
100
101 #[error("could not resolve {field} from environment variable {name}")]
103 EnvironmentSecret {
104 field: &'static str,
106 name: String,
108 },
109
110 #[error("could not resolve {field} from secret file {}: {source}", path.display())]
112 FileSecret {
113 field: &'static str,
115 path: std::path::PathBuf,
117 #[source]
119 source: std::io::Error,
120 },
121
122 #[error("secret reference for {field} resolved to an empty value")]
124 EmptySecret {
125 field: &'static str,
127 },
128
129 #[error("invalid mintd configuration: {0}")]
131 Validation(String),
132
133 #[error("primary database settings do not match the bootstrap database settings")]
135 PrimaryDatabaseChange,
136
137 #[error("could not determine configured mint signing identity: {0}")]
139 SigningIdentity(String),
140
141 #[error(
143 "configured signing identity does not match this mint database; signer migration is not supported by config apply"
144 )]
145 SigningIdentityChange,
146
147 #[error(transparent)]
149 Store(#[from] ConfigStoreError),
150}
151
152#[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 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 pub(crate) async fn validate_import(
185 document: &str,
186 ) -> Result<ResolvedConfiguration, ConfigurationServiceError> {
187 Ok(Self::validated_import(document).await?.0)
188 }
189
190 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 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 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 pub(crate) async fn document(&self) -> Result<String, ConfigurationServiceError> {
253 Ok(self.repository.active().await?.toml)
254 }
255
256 #[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 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 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
328pub(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}