1#![deny(rustdoc::broken_intra_doc_links)]
37#![deny(rustdoc::private_intra_doc_links)]
38#![deny(rustdoc::invalid_html_tags)]
39use devboy_core::{Error, Result};
40use keyring::Entry;
41use secrecy::{ExposeSecret, SecretString};
42use tracing::{debug, warn};
43
44pub mod cache;
45
46pub use cache::CachedStore;
47
48const SERVICE_NAME: &str = "devboy-tools";
50
51pub trait CredentialStore: Send + Sync {
56 fn store(&self, key: &str, value: &SecretString) -> Result<()>;
64
65 fn get(&self, key: &str) -> Result<Option<SecretString>>;
72
73 fn delete(&self, key: &str) -> Result<()>;
77
78 fn exists(&self, key: &str) -> bool {
80 matches!(self.get(key), Ok(Some(_)))
81 }
82
83 fn is_available(&self) -> bool {
88 true
89 }
90
91 fn is_writable(&self) -> bool {
95 true
96 }
97}
98
99#[derive(Debug)]
111pub struct KeychainStore {
112 service_name: String,
113}
114
115impl KeychainStore {
116 pub fn new() -> Self {
118 Self {
119 service_name: SERVICE_NAME.to_string(),
120 }
121 }
122
123 pub fn with_service_name(service_name: impl Into<String>) -> Self {
127 Self {
128 service_name: service_name.into(),
129 }
130 }
131
132 fn make_entry(&self, key: &str) -> std::result::Result<Entry, keyring::Error> {
133 Entry::new(&self.service_name, key)
134 }
135}
136
137impl Default for KeychainStore {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl CredentialStore for KeychainStore {
144 fn store(&self, key: &str, value: &SecretString) -> Result<()> {
145 debug!(key = key, "Storing credential in keychain");
146
147 let entry = self.make_entry(key).map_err(|e| {
148 Error::Storage(format!(
149 "Failed to create keychain entry for '{}': {}",
150 key, e
151 ))
152 })?;
153
154 entry
155 .set_password(value.expose_secret())
156 .map_err(|e| Error::Storage(format!("Failed to store credential '{}': {}", key, e)))?;
157
158 Ok(())
159 }
160
161 fn get(&self, key: &str) -> Result<Option<SecretString>> {
162 debug!(key = key, "Retrieving credential from keychain");
163
164 let entry = self.make_entry(key).map_err(|e| {
165 Error::Storage(format!(
166 "Failed to create keychain entry for '{}': {}",
167 key, e
168 ))
169 })?;
170
171 match entry.get_password() {
172 Ok(password) => Ok(Some(SecretString::from(password))),
173 Err(keyring::Error::NoEntry) => {
174 debug!(key = key, "Credential not found");
175 Ok(None)
176 }
177 Err(e) => {
178 warn!(key = key, error = %e, "Failed to retrieve credential");
179 Err(Error::Storage(format!(
180 "Failed to retrieve credential '{}': {}",
181 key, e
182 )))
183 }
184 }
185 }
186
187 fn delete(&self, key: &str) -> Result<()> {
188 debug!(key = key, "Deleting credential from keychain");
189
190 let entry = self.make_entry(key).map_err(|e| {
191 Error::Storage(format!(
192 "Failed to create keychain entry for '{}': {}",
193 key, e
194 ))
195 })?;
196
197 match entry.delete_credential() {
198 Ok(()) => Ok(()),
199 Err(keyring::Error::NoEntry) => {
200 debug!(key = key, "Credential was already deleted");
202 Ok(())
203 }
204 Err(e) => Err(Error::Storage(format!(
205 "Failed to delete credential '{}': {}",
206 key, e
207 ))),
208 }
209 }
210
211 fn is_available(&self) -> bool {
212 match self.make_entry("__devboy_availability_check__") {
215 Ok(_) => true,
216 Err(e) => {
217 debug!(error = %e, "Keychain not available");
218 false
219 }
220 }
221 }
222}
223
224#[derive(Default)]
236pub struct MemoryStore {
237 credentials: std::sync::RwLock<std::collections::HashMap<String, SecretString>>,
238}
239
240impl std::fmt::Debug for MemoryStore {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 let creds = self.credentials.read();
243 let (count, keys) = match &creds {
244 Ok(map) => (map.len(), map.keys().cloned().collect::<Vec<_>>()),
245 Err(_) => (0, vec!["<lock-poisoned>".to_string()]),
246 };
247 f.debug_struct("MemoryStore")
248 .field("credentials", &format!("<{count} redacted secret(s)>"))
249 .field("keys", &keys)
250 .finish()
251 }
252}
253
254impl MemoryStore {
255 pub fn new() -> Self {
257 Self::default()
258 }
259
260 pub fn with_credentials(credentials: impl IntoIterator<Item = (String, String)>) -> Self {
264 let store = Self::new();
265 {
266 let mut creds = store.credentials.write().unwrap();
267 for (k, v) in credentials {
268 creds.insert(k, SecretString::from(v));
269 }
270 }
271 store
272 }
273}
274
275impl CredentialStore for MemoryStore {
276 fn store(&self, key: &str, value: &SecretString) -> Result<()> {
277 let mut creds = self
278 .credentials
279 .write()
280 .map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
281 creds.insert(key.to_string(), value.clone());
285 Ok(())
286 }
287
288 fn get(&self, key: &str) -> Result<Option<SecretString>> {
289 let creds = self
290 .credentials
291 .read()
292 .map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
293 Ok(creds.get(key).cloned())
294 }
295
296 fn delete(&self, key: &str) -> Result<()> {
297 let mut creds = self
298 .credentials
299 .write()
300 .map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
301 creds.remove(key);
302 Ok(())
303 }
304}
305
306const DEFAULT_ENV_PREFIX: &str = "DEVBOY";
312
313type EnvReader = fn(&str) -> std::result::Result<String, std::env::VarError>;
343
344fn read_env_var(key: &str) -> std::result::Result<String, std::env::VarError> {
346 std::env::var(key)
347}
348
349pub struct EnvVarStore {
355 prefix: String,
357 fallback_without_prefix: bool,
359 env_reader: EnvReader,
361}
362
363impl EnvVarStore {
364 pub fn new() -> Self {
368 Self {
369 prefix: DEFAULT_ENV_PREFIX.to_string(),
370 fallback_without_prefix: true,
371 env_reader: read_env_var,
372 }
373 }
374
375 pub fn with_prefix(prefix: impl Into<String>) -> Self {
384 Self {
385 prefix: prefix.into(),
386 fallback_without_prefix: true,
387 env_reader: read_env_var,
388 }
389 }
390
391 pub fn without_fallback(mut self) -> Self {
395 self.fallback_without_prefix = false;
396 self
397 }
398
399 #[cfg(test)]
401 fn with_env_reader(mut self, reader: EnvReader) -> Self {
402 self.env_reader = reader;
403 self
404 }
405
406 fn key_to_env_name(&self, key: &str) -> String {
411 key.to_uppercase().replace(['.', '/', '-'], "_")
412 }
413
414 fn prefixed_env_name(&self, key: &str) -> String {
416 format!("{}_{}", self.prefix, self.key_to_env_name(key))
417 }
418
419 fn unprefixed_env_name(&self, key: &str) -> String {
421 self.key_to_env_name(key)
422 }
423}
424
425impl std::fmt::Debug for EnvVarStore {
426 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427 f.debug_struct("EnvVarStore")
428 .field("prefix", &self.prefix)
429 .field("fallback_without_prefix", &self.fallback_without_prefix)
430 .finish()
431 }
432}
433
434impl Default for EnvVarStore {
435 fn default() -> Self {
436 Self::new()
437 }
438}
439
440impl CredentialStore for EnvVarStore {
441 fn store(&self, _key: &str, _value: &SecretString) -> Result<()> {
442 Err(Error::Storage(
443 "EnvVarStore is read-only. Use OS keychain or set environment variables directly."
444 .to_string(),
445 ))
446 }
447
448 fn get(&self, key: &str) -> Result<Option<SecretString>> {
449 let prefixed = self.prefixed_env_name(key);
451 if let Ok(value) = (self.env_reader)(&prefixed) {
452 debug!(key = key, env_var = %prefixed, "Found credential in environment variable");
453 return Ok(Some(SecretString::from(value)));
454 }
455
456 if self.fallback_without_prefix {
458 let unprefixed = self.unprefixed_env_name(key);
459 if let Ok(value) = (self.env_reader)(&unprefixed) {
460 debug!(key = key, env_var = %unprefixed, "Found credential in environment variable (unprefixed)");
461 return Ok(Some(SecretString::from(value)));
462 }
463 }
464
465 debug!(key = key, "Credential not found in environment variables");
466 Ok(None)
467 }
468
469 fn delete(&self, _key: &str) -> Result<()> {
470 Err(Error::Storage(
471 "EnvVarStore is read-only. Environment variables cannot be deleted.".to_string(),
472 ))
473 }
474
475 fn is_writable(&self) -> bool {
476 false
477 }
478}
479
480pub struct ChainStore {
511 stores: Vec<Box<dyn CredentialStore>>,
512}
513
514impl ChainStore {
515 pub fn new(stores: Vec<Box<dyn CredentialStore>>) -> Self {
520 Self { stores }
521 }
522
523 pub fn default_chain() -> Self {
533 Self::new(vec![
534 Box::new(EnvVarStore::new()),
535 Box::new(KeychainStore::new()),
536 ])
537 }
538
539 pub fn ci_chain() -> Self {
544 Self::new(vec![
545 Box::new(EnvVarStore::new()),
546 Box::new(MemoryStore::new()),
547 ])
548 }
549
550 pub fn len(&self) -> usize {
552 self.stores.len()
553 }
554
555 pub fn is_empty(&self) -> bool {
557 self.stores.is_empty()
558 }
559}
560
561impl std::fmt::Debug for ChainStore {
562 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563 f.debug_struct("ChainStore")
564 .field("stores_count", &self.stores.len())
565 .finish()
566 }
567}
568
569impl CredentialStore for ChainStore {
570 fn store(&self, key: &str, value: &SecretString) -> Result<()> {
571 let mut last_error: Option<Error> = None;
573 for store in &self.stores {
574 if store.is_writable() && store.is_available() {
575 match store.store(key, value) {
576 Ok(()) => return Ok(()),
577 Err(e) => {
578 debug!(key = key, error = %e, "Store write failed, trying next");
579 last_error = Some(e);
580 }
581 }
582 }
583 }
584 Err(last_error.unwrap_or_else(|| {
585 Error::Storage("No writable credential store available in chain".to_string())
586 }))
587 }
588
589 fn get(&self, key: &str) -> Result<Option<SecretString>> {
590 let mut last_error: Option<Error> = None;
592 for store in &self.stores {
593 match store.get(key) {
594 Ok(Some(value)) => return Ok(Some(value)),
595 Ok(None) => continue,
596 Err(e) => {
597 debug!(key = key, error = %e, "Store returned error, trying next");
599 last_error = Some(e);
600 }
601 }
602 }
603 if let Some(e) = last_error {
605 Err(e)
606 } else {
607 Ok(None)
608 }
609 }
610
611 fn delete(&self, key: &str) -> Result<()> {
612 let mut deleted_any = false;
614 let mut last_error: Option<Error> = None;
615
616 for store in &self.stores {
617 if store.is_writable() {
618 match store.delete(key) {
619 Ok(()) => deleted_any = true,
620 Err(e) => last_error = Some(e),
621 }
622 }
623 }
624
625 if deleted_any {
626 Ok(())
627 } else if let Some(e) = last_error {
628 Err(e)
629 } else {
630 Ok(())
632 }
633 }
634
635 fn is_available(&self) -> bool {
636 self.stores.iter().any(|s| s.is_available())
638 }
639
640 fn is_writable(&self) -> bool {
641 self.stores.iter().any(|s| s.is_writable())
643 }
644}
645
646pub fn token_key(provider: &str) -> String {
652 format!("{}/token", provider)
653}
654
655pub fn build_default_store(cache_ttl_secs: u64) -> Box<dyn CredentialStore> {
662 let chain = ChainStore::default_chain();
663 if cache_ttl_secs == 0 {
664 Box::new(chain)
665 } else {
666 Box::new(CachedStore::new(
667 chain,
668 std::time::Duration::from_secs(cache_ttl_secs),
669 ))
670 }
671}
672
673pub fn wrap_with_cache<S: CredentialStore + 'static>(
676 inner: S,
677 cache_ttl_secs: u64,
678) -> Box<dyn CredentialStore> {
679 if cache_ttl_secs == 0 {
680 Box::new(inner)
681 } else {
682 Box::new(CachedStore::new(
683 inner,
684 std::time::Duration::from_secs(cache_ttl_secs),
685 ))
686 }
687}
688
689pub fn email_key(provider: &str) -> String {
691 format!("{}/email", provider)
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 fn secret(s: &str) -> SecretString {
699 SecretString::from(s.to_string())
700 }
701
702 fn exposed(s: &Option<SecretString>) -> Option<&str> {
703 s.as_ref().map(|v| v.expose_secret())
704 }
705
706 #[test]
707 fn test_memory_store_basic() {
708 let store = MemoryStore::new();
709
710 store.store("test/key", &secret("test-value")).unwrap();
712
713 let value = store.get("test/key").unwrap();
715 assert_eq!(exposed(&value), Some("test-value"));
716
717 assert!(store.exists("test/key"));
719 assert!(!store.exists("nonexistent"));
720
721 store.delete("test/key").unwrap();
723 let value = store.get("test/key").unwrap();
724 assert!(value.is_none());
725
726 store.delete("nonexistent").unwrap();
728 }
729
730 #[test]
731 fn test_memory_store_with_credentials() {
732 let store = MemoryStore::with_credentials([
733 ("gitlab/token".to_string(), "glpat-xxx".to_string()),
734 ("github/token".to_string(), "ghp-yyy".to_string()),
735 ]);
736
737 assert_eq!(
738 exposed(&store.get("gitlab/token").unwrap()),
739 Some("glpat-xxx")
740 );
741 assert_eq!(
742 exposed(&store.get("github/token").unwrap()),
743 Some("ghp-yyy")
744 );
745 }
746
747 #[test]
748 fn test_token_key() {
749 assert_eq!(token_key("gitlab"), "gitlab/token");
750 assert_eq!(token_key("github"), "github/token");
751 }
752
753 #[test]
754 fn test_email_key() {
755 assert_eq!(email_key("jira"), "jira/email");
756 }
757
758 #[test]
759 fn test_memory_store_delete_nonexistent() {
760 let store = MemoryStore::new();
761
762 store.delete("nonexistent/key").unwrap();
764
765 assert!(store.get("nonexistent/key").unwrap().is_none());
767 }
768
769 #[test]
770 fn test_memory_store_exists() {
771 let store = MemoryStore::new();
772
773 assert!(!store.exists("test/key"));
774
775 store.store("test/key", &secret("value")).unwrap();
776 assert!(store.exists("test/key"));
777
778 store.delete("test/key").unwrap();
779 assert!(!store.exists("test/key"));
780 }
781
782 #[test]
783 fn test_memory_store_overwrite() {
784 let store = MemoryStore::new();
785
786 store.store("test/key", &secret("value1")).unwrap();
787 assert_eq!(exposed(&store.get("test/key").unwrap()), Some("value1"));
788
789 store.store("test/key", &secret("value2")).unwrap();
790 assert_eq!(exposed(&store.get("test/key").unwrap()), Some("value2"));
791 }
792
793 #[test]
794 fn test_credential_store_exists_default_impl() {
795 let store = MemoryStore::new();
797
798 store.store("key1", &secret("val1")).unwrap();
799
800 assert!(CredentialStore::exists(&store, "key1"));
802 assert!(!CredentialStore::exists(&store, "key2"));
803 }
804
805 #[test]
806 fn test_keychain_store_new() {
807 let store = KeychainStore::new();
808 assert_eq!(store.service_name, "devboy-tools");
809 }
810
811 #[test]
812 fn test_keychain_store_with_service_name() {
813 let store = KeychainStore::with_service_name("test-service");
814 assert_eq!(store.service_name, "test-service");
815 }
816
817 #[test]
818 fn test_keychain_store_default() {
819 let store = KeychainStore::default();
820 assert_eq!(store.service_name, "devboy-tools");
821 }
822
823 #[test]
832 fn test_env_var_store_new() {
833 let store = EnvVarStore::new();
834 assert_eq!(store.prefix, "DEVBOY");
835 assert!(store.fallback_without_prefix);
836 }
837
838 #[test]
839 fn test_env_var_store_with_prefix() {
840 let store = EnvVarStore::with_prefix("CUSTOM");
841 assert_eq!(store.prefix, "CUSTOM");
842 assert!(store.fallback_without_prefix);
843 }
844
845 #[test]
846 fn test_env_var_store_without_fallback() {
847 let store = EnvVarStore::new().without_fallback();
848 assert!(!store.fallback_without_prefix);
849 }
850
851 #[test]
852 fn test_env_var_store_key_to_env_name() {
853 let store = EnvVarStore::new();
854
855 assert_eq!(store.key_to_env_name("github.token"), "GITHUB_TOKEN");
857 assert_eq!(store.key_to_env_name("gitlab/token"), "GITLAB_TOKEN");
858 assert_eq!(
859 store.key_to_env_name("contexts.dashboard.github.token"),
860 "CONTEXTS_DASHBOARD_GITHUB_TOKEN"
861 );
862 assert_eq!(
864 store.key_to_env_name("devboy-cloud.token"),
865 "DEVBOY_CLOUD_TOKEN"
866 );
867 }
868
869 #[test]
870 fn test_env_var_store_prefixed_env_name() {
871 let store = EnvVarStore::new();
872 assert_eq!(
873 store.prefixed_env_name("github.token"),
874 "DEVBOY_GITHUB_TOKEN"
875 );
876
877 let custom = EnvVarStore::with_prefix("MYAPP");
878 assert_eq!(
879 custom.prefixed_env_name("github.token"),
880 "MYAPP_GITHUB_TOKEN"
881 );
882 }
883
884 fn mock_env_reader(key: &str) -> std::result::Result<String, std::env::VarError> {
886 match key {
887 "DEVBOY_TEST_TOKEN" => Ok("prefixed-value".into()),
888 "TEST_FALLBACK_TOKEN" => Ok("unprefixed-value".into()),
889 "DEVBOY_TEST_PRIORITY_TOKEN" => Ok("prefixed".into()),
890 "TEST_PRIORITY_TOKEN" => Ok("unprefixed".into()),
891 "TEST_NO_FALLBACK_TOKEN" => Ok("unprefixed-value".into()),
892 "DEVBOY_CHAIN_TEST_TOKEN" => Ok("from-env".into()),
893 _ => Err(std::env::VarError::NotPresent),
894 }
895 }
896
897 #[test]
898 fn test_env_var_store_get_prefixed() {
899 let store = EnvVarStore::new().with_env_reader(mock_env_reader);
900
901 let result = store.get("test.token").unwrap();
902 assert_eq!(exposed(&result), Some("prefixed-value"));
903 }
904
905 #[test]
906 fn test_env_var_store_get_unprefixed_fallback() {
907 let store = EnvVarStore::new().with_env_reader(mock_env_reader);
908
909 let result = store.get("test.fallback.token").unwrap();
910 assert_eq!(exposed(&result), Some("unprefixed-value"));
911 }
912
913 #[test]
914 fn test_env_var_store_prefixed_takes_priority() {
915 let store = EnvVarStore::new().with_env_reader(mock_env_reader);
916
917 let result = store.get("test.priority.token").unwrap();
918 assert_eq!(exposed(&result), Some("prefixed"));
919 }
920
921 #[test]
922 fn test_env_var_store_no_fallback() {
923 let store = EnvVarStore::new()
924 .without_fallback()
925 .with_env_reader(mock_env_reader);
926
927 let result = store.get("test.no.fallback.token").unwrap();
930 assert!(result.is_none());
931 }
932
933 #[test]
934 fn test_env_var_store_not_found() {
935 let store = EnvVarStore::new().with_env_reader(mock_env_reader);
936
937 let result = store.get("nonexistent.key.that.does.not.exist").unwrap();
938 assert!(result.is_none());
939 }
940
941 #[test]
942 fn test_env_var_store_is_read_only() {
943 let store = EnvVarStore::new();
944
945 assert!(!store.is_writable());
946
947 let store_result = store.store("test.key", &secret("value"));
948 assert!(store_result.is_err());
949
950 let delete_result = store.delete("test.key");
951 assert!(delete_result.is_err());
952 }
953
954 #[test]
955 fn test_env_var_store_default() {
956 let store = EnvVarStore::default();
957 assert_eq!(store.prefix, "DEVBOY");
958 }
959
960 #[test]
965 fn test_chain_store_new() {
966 let store = ChainStore::new(vec![]);
967 assert!(store.is_empty());
968 assert_eq!(store.len(), 0);
969 }
970
971 #[test]
972 fn test_chain_store_default_chain() {
973 let store = ChainStore::default_chain();
974 assert_eq!(store.len(), 2); assert!(!store.is_empty());
976 }
977
978 #[test]
979 fn test_chain_store_ci_chain() {
980 let store = ChainStore::ci_chain();
981 assert_eq!(store.len(), 2); }
983
984 #[test]
985 fn test_chain_store_get_first_match_wins() {
986 let store1 = MemoryStore::with_credentials([("key1".to_string(), "value1".to_string())]);
988 let store2 = MemoryStore::with_credentials([
989 ("key1".to_string(), "value2".to_string()),
990 ("key2".to_string(), "value2".to_string()),
991 ]);
992
993 let chain = ChainStore::new(vec![Box::new(store1), Box::new(store2)]);
994
995 assert_eq!(exposed(&chain.get("key1").unwrap()), Some("value1"));
997
998 assert_eq!(exposed(&chain.get("key2").unwrap()), Some("value2"));
1000
1001 assert!(chain.get("key3").unwrap().is_none());
1003 }
1004
1005 #[test]
1006 fn test_chain_store_store_to_first_writable() {
1007 let chain = ChainStore::new(vec![
1009 Box::new(EnvVarStore::new()),
1010 Box::new(MemoryStore::new()),
1011 ]);
1012
1013 chain.store("test.key", &secret("test-value")).unwrap();
1015
1016 assert_eq!(exposed(&chain.get("test.key").unwrap()), Some("test-value"));
1018 }
1019
1020 #[test]
1021 fn test_chain_store_no_writable_store_error() {
1022 let chain = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
1024
1025 let result = chain.store("test.key", &secret("value"));
1026 assert!(result.is_err());
1027 assert!(result.unwrap_err().to_string().contains("No writable"));
1028 }
1029
1030 #[test]
1031 fn test_chain_store_delete_from_all_writable() {
1032 let store1 = MemoryStore::new();
1033 let store2 = MemoryStore::new();
1034
1035 store1.store("key", &secret("val1")).unwrap();
1037 store2.store("key", &secret("val2")).unwrap();
1038
1039 let chain = ChainStore::new(vec![Box::new(store1), Box::new(store2)]);
1040
1041 chain.delete("key").unwrap();
1043
1044 assert!(chain.get("key").unwrap().is_none());
1046 }
1047
1048 #[test]
1049 fn test_chain_store_is_available() {
1050 let empty = ChainStore::new(vec![]);
1052 assert!(!empty.is_available());
1053
1054 let with_memory = ChainStore::new(vec![Box::new(MemoryStore::new())]);
1056 assert!(with_memory.is_available());
1057 }
1058
1059 #[test]
1060 fn test_chain_store_is_writable() {
1061 let read_only = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
1063 assert!(!read_only.is_writable());
1064
1065 let writable = ChainStore::new(vec![Box::new(MemoryStore::new())]);
1067 assert!(writable.is_writable());
1068 }
1069
1070 #[test]
1071 fn test_chain_store_env_var_priority() {
1072 let env_store = EnvVarStore::new().with_env_reader(mock_env_reader);
1076
1077 let memory = MemoryStore::with_credentials([(
1079 "chain.test.token".to_string(),
1080 "from-memory".to_string(),
1081 )]);
1082
1083 let chain = ChainStore::new(vec![Box::new(env_store), Box::new(memory)]);
1085
1086 assert_eq!(
1088 exposed(&chain.get("chain.test.token").unwrap()),
1089 Some("from-env")
1090 );
1091 }
1092
1093 #[test]
1094 fn test_chain_store_fallback_to_memory_when_env_empty() {
1095 let memory = MemoryStore::with_credentials([(
1097 "fallback.test.token".to_string(),
1098 "from-memory".to_string(),
1099 )]);
1100
1101 let chain = ChainStore::new(vec![Box::new(EnvVarStore::new()), Box::new(memory)]);
1103
1104 assert_eq!(
1106 exposed(&chain.get("fallback.test.token").unwrap()),
1107 Some("from-memory")
1108 );
1109 }
1110
1111 #[test]
1112 fn test_chain_store_debug_impl() {
1113 let chain = ChainStore::default_chain();
1114 let debug_str = format!("{:?}", chain);
1115 assert!(debug_str.contains("ChainStore"));
1116 assert!(debug_str.contains("stores_count"));
1117 }
1118
1119 #[test]
1124 fn test_build_default_store_zero_ttl_returns_writable_chain() {
1125 let store = build_default_store(0);
1126 assert!(store.is_writable());
1128 }
1129
1130 #[test]
1131 fn test_build_default_store_positive_ttl_delegates_writable() {
1132 let store = build_default_store(60);
1133 assert!(store.is_writable());
1135 }
1136
1137 #[test]
1138 fn test_wrap_with_cache_zero_ttl_is_passthrough() {
1139 let inner = MemoryStore::with_credentials([("k".to_string(), "v".to_string())]);
1140 let store = wrap_with_cache(inner, 0);
1141 assert_eq!(exposed(&store.get("k").unwrap()), Some("v"));
1142 }
1143
1144 #[test]
1145 fn test_wrap_with_cache_populated_ttl_caches_lookups() {
1146 let inner = MemoryStore::with_credentials([("k".to_string(), "v1".to_string())]);
1147 let store = wrap_with_cache(inner, 60);
1148
1149 assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
1150
1151 assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
1153 }
1154}