Skip to main content

devboy_storage/
lib.rs

1//! Secure credential storage with multiple backends.
2//!
3//! This crate provides credential storage with support for:
4//!
5//! - **OS Keychain**: macOS Keychain, Windows Credential Manager, Linux Secret Service
6//! - **Environment Variables**: For CI/CD and containerized environments
7//! - **Chain Store**: Composable fallback between multiple backends
8//!
9//! # Credential Resolution Order
10//!
11//! When using `ChainStore::default_chain()`, credentials are resolved in this order:
12//!
13//! 1. **Environment variables** (highest priority, for CI/CD)
14//!    - `DEVBOY_{PROVIDER}_TOKEN` (e.g., `DEVBOY_GITHUB_TOKEN`)
15//!    - `{PROVIDER}_TOKEN` (fallback, e.g., `GITHUB_TOKEN`)
16//! 2. **OS Keychain** (for local development)
17//!
18//! # Example
19//!
20//! ```ignore
21//! use devboy_storage::{ChainStore, CredentialStore};
22//!
23//! // Use the default chain (env vars -> keychain)
24//! let store = ChainStore::default_chain();
25//!
26//! // This will check DEVBOY_GITHUB_TOKEN, then GITHUB_TOKEN,
27//! // then keychain for "github.token"
28//! let token = store.get("github.token")?;
29//!
30//! // Or use keychain directly for local development
31//! use devboy_storage::KeychainStore;
32//! let keychain = KeychainStore::new();
33//! keychain.store("gitlab.token", "glpat-xxx")?;
34//! ```
35
36#![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
48/// Service name used in OS keychain.
49const SERVICE_NAME: &str = "devboy-tools";
50
51/// Credential storage trait.
52///
53/// Implementations can use OS keychain, environment variables, in-memory storage,
54/// or other backends.
55pub trait CredentialStore: Send + Sync {
56    /// Store a credential securely.
57    ///
58    /// The key should follow the convention: `{provider}.{credential_name}`
59    /// For example: `gitlab.token`, `github.token`, `jira.email`.
60    ///
61    /// The value is taken as `&SecretString` so callers cannot accidentally
62    /// log or otherwise leak the plaintext on its way into storage.
63    fn store(&self, key: &str, value: &SecretString) -> Result<()>;
64
65    /// Retrieve a stored credential.
66    ///
67    /// Returns `Ok(None)` if the credential doesn't exist. The returned
68    /// `SecretString` redacts itself in `Debug` output and zeroizes the
69    /// buffer on drop — call `.expose_secret()` only at the boundary that
70    /// actually consumes the secret (HTTP header, etc.).
71    fn get(&self, key: &str) -> Result<Option<SecretString>>;
72
73    /// Delete a stored credential.
74    ///
75    /// Returns `Ok(())` even if the credential didn't exist.
76    fn delete(&self, key: &str) -> Result<()>;
77
78    /// Check if a credential exists.
79    fn exists(&self, key: &str) -> bool {
80        matches!(self.get(key), Ok(Some(_)))
81    }
82
83    /// Check if this credential store is available and functional.
84    ///
85    /// Returns `true` if the store can be used for credential operations.
86    /// This is useful for checking keychain availability in CI/container environments.
87    fn is_available(&self) -> bool {
88        true
89    }
90
91    /// Check if this store supports write operations.
92    ///
93    /// Some stores (like `EnvVarStore`) are read-only.
94    fn is_writable(&self) -> bool {
95        true
96    }
97}
98
99// =============================================================================
100// KeychainStore - OS Keychain implementation
101// =============================================================================
102
103/// Credential store using the OS keychain.
104///
105/// This is the recommended store for production use. It securely stores
106/// credentials in:
107/// - macOS: Keychain Services
108/// - Windows: Credential Manager
109/// - Linux: Secret Service (GNOME Keyring / KWallet)
110#[derive(Debug)]
111pub struct KeychainStore {
112    service_name: String,
113}
114
115impl KeychainStore {
116    /// Create a new keychain store with the default service name.
117    pub fn new() -> Self {
118        Self {
119            service_name: SERVICE_NAME.to_string(),
120        }
121    }
122
123    /// Create a keychain store with a custom service name.
124    ///
125    /// Useful for testing to avoid conflicts with real credentials.
126    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                // Already deleted, that's fine
201                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        // Try to create an entry - if this fails, keychain is not available
213        // We don't actually write anything, just check if the backend is functional
214        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// =============================================================================
225// MemoryStore - In-memory implementation for testing
226// =============================================================================
227
228/// In-memory credential store for testing.
229///
230/// This store keeps credentials in memory wrapped in [`SecretString`]
231/// (zeroize-on-drop, redacted `Debug`) for unit tests that don't want
232/// to interact with the real OS keychain. The `Debug` impl shows the
233/// key set and a count, never the values, so accidentally logging a
234/// `MemoryStore` cannot leak plaintext.
235#[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    /// Create a new in-memory store.
256    pub fn new() -> Self {
257        Self::default()
258    }
259
260    /// Create a store pre-populated with credentials. Accepts plaintext
261    /// `(key, value)` pairs for test ergonomics; the values are wrapped
262    /// in [`SecretString`] before storage.
263    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        // Clone the SecretString directly — no `expose_secret()` call,
282        // no extra plaintext String allocation, and the cached value
283        // keeps the same zeroize-on-drop discipline as the input.
284        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
306// =============================================================================
307// EnvVarStore - Environment variable implementation for CI/CD
308// =============================================================================
309
310/// Default prefix for environment variables.
311const DEFAULT_ENV_PREFIX: &str = "DEVBOY";
312
313/// Credential store using environment variables.
314///
315/// This is a read-only store that reads credentials from environment variables.
316/// It's designed for CI/CD pipelines and containerized environments where
317/// OS keychain may not be available.
318///
319/// # Key to Environment Variable Mapping
320///
321/// The key is converted to an environment variable name:
322/// - Converted to uppercase
323/// - Dots (`.`), slashes (`/`), and dashes (`-`) replaced with underscores (`_`)
324/// - Prefixed with `DEVBOY_` by default
325///
326/// Examples:
327/// - `github.token` → `DEVBOY_GITHUB_TOKEN` (then `GITHUB_TOKEN` as fallback)
328/// - `contexts.dashboard.github.token` → `DEVBOY_CONTEXTS_DASHBOARD_GITHUB_TOKEN`
329/// - `devboy-cloud.token` → `DEVBOY_DEVBOY_CLOUD_TOKEN`
330///
331/// # Example
332///
333/// ```ignore
334/// use devboy_storage::{EnvVarStore, CredentialStore};
335///
336/// // Reads from DEVBOY_GITHUB_TOKEN env var (if set)
337/// let store = EnvVarStore::new();
338/// let token = store.get("github.token")?;
339/// ```
340/// Function type for reading environment variables.
341/// Defaults to `std::env::var`, but can be replaced in tests.
342type EnvReader = fn(&str) -> std::result::Result<String, std::env::VarError>;
343
344/// Wrapper around `std::env::var` matching the `EnvReader` signature.
345fn read_env_var(key: &str) -> std::result::Result<String, std::env::VarError> {
346    std::env::var(key)
347}
348
349/// Environment-variable-backed credential store.
350///
351/// Resolves secrets by name through `std::env::var` (or an injected
352/// reader, for testing). Used as the CI / Docker fallback when the OS
353/// keychain is unavailable.
354pub struct EnvVarStore {
355    /// Prefix for environment variables (e.g., "DEVBOY").
356    prefix: String,
357    /// Whether to fall back to unprefixed variable names.
358    fallback_without_prefix: bool,
359    /// Function to read environment variables (injectable for testing).
360    env_reader: EnvReader,
361}
362
363impl EnvVarStore {
364    /// Create a new environment variable store with default settings.
365    ///
366    /// Uses `DEVBOY_` prefix and enables fallback to unprefixed variables.
367    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    /// Create with a custom prefix.
376    ///
377    /// # Example
378    ///
379    /// ```ignore
380    /// let store = EnvVarStore::with_prefix("MYAPP");
381    /// // Will check MYAPP_GITHUB_TOKEN, then GITHUB_TOKEN
382    /// ```
383    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    /// Disable fallback to unprefixed environment variables.
392    ///
393    /// When disabled, only `{PREFIX}_{KEY}` format is checked.
394    pub fn without_fallback(mut self) -> Self {
395        self.fallback_without_prefix = false;
396        self
397    }
398
399    /// Replace the environment variable reader (for testing).
400    #[cfg(test)]
401    fn with_env_reader(mut self, reader: EnvReader) -> Self {
402        self.env_reader = reader;
403        self
404    }
405
406    /// Convert a credential key to environment variable name.
407    ///
408    /// - Uppercase the key
409    /// - Replace `.`, `/`, and `-` with `_`
410    fn key_to_env_name(&self, key: &str) -> String {
411        key.to_uppercase().replace(['.', '/', '-'], "_")
412    }
413
414    /// Get the prefixed environment variable name.
415    fn prefixed_env_name(&self, key: &str) -> String {
416        format!("{}_{}", self.prefix, self.key_to_env_name(key))
417    }
418
419    /// Get the unprefixed environment variable name.
420    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        // Try prefixed first (e.g., DEVBOY_GITHUB_TOKEN)
450        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        // Fallback to unprefixed (e.g., GITHUB_TOKEN)
457        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
480// =============================================================================
481// ChainStore - Composable credential store with fallback
482// =============================================================================
483
484/// Composable credential store that chains multiple backends.
485///
486/// Attempts to retrieve credentials from each store in order until one succeeds.
487/// Write operations go to the first writable store.
488///
489/// # Default Chain
490///
491/// Use `ChainStore::default_chain()` for the recommended configuration:
492/// 1. Environment variables (highest priority, for CI/CD)
493/// 2. OS Keychain (for local development)
494///
495/// # Example
496///
497/// ```ignore
498/// use devboy_storage::{ChainStore, CredentialStore};
499///
500/// // Use default chain
501/// let store = ChainStore::default_chain();
502///
503/// // Or create custom chain
504/// use devboy_storage::{EnvVarStore, MemoryStore};
505/// let store = ChainStore::new(vec![
506///     Box::new(EnvVarStore::new()),
507///     Box::new(MemoryStore::new()),
508/// ]);
509/// ```
510pub struct ChainStore {
511    stores: Vec<Box<dyn CredentialStore>>,
512}
513
514impl ChainStore {
515    /// Create a chain store from a list of stores.
516    ///
517    /// Stores are tried in order for read operations.
518    /// The first writable store is used for write operations.
519    pub fn new(stores: Vec<Box<dyn CredentialStore>>) -> Self {
520        Self { stores }
521    }
522
523    /// Create the default credential chain.
524    ///
525    /// Order:
526    /// 1. Environment variables (`EnvVarStore`)
527    /// 2. OS Keychain (`KeychainStore`)
528    ///
529    /// This is the recommended configuration for most use cases:
530    /// - CI/CD can set `DEVBOY_*` or provider-specific env vars
531    /// - Local development uses keychain transparently
532    pub fn default_chain() -> Self {
533        Self::new(vec![
534            Box::new(EnvVarStore::new()),
535            Box::new(KeychainStore::new()),
536        ])
537    }
538
539    /// Create a chain for CI/CD environments (no keychain).
540    ///
541    /// Only uses environment variables and memory store.
542    /// Useful when keychain is not available.
543    pub fn ci_chain() -> Self {
544        Self::new(vec![
545            Box::new(EnvVarStore::new()),
546            Box::new(MemoryStore::new()),
547        ])
548    }
549
550    /// Get the number of stores in the chain.
551    pub fn len(&self) -> usize {
552        self.stores.len()
553    }
554
555    /// Check if the chain is empty.
556    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        // Try each writable and available store in order
572        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        // Try each store in order, tracking errors
591        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                    // Log error but continue to next store
598                    debug!(key = key, error = %e, "Store returned error, trying next");
599                    last_error = Some(e);
600                }
601            }
602        }
603        // If all stores returned errors, propagate the last one
604        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        // Delete from all writable stores
613        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            // No writable stores, but that's ok for delete
631            Ok(())
632        }
633    }
634
635    fn is_available(&self) -> bool {
636        // Available if at least one store is available
637        self.stores.iter().any(|s| s.is_available())
638    }
639
640    fn is_writable(&self) -> bool {
641        // Writable if at least one store is writable
642        self.stores.iter().any(|s| s.is_writable())
643    }
644}
645
646// =============================================================================
647// Helper functions
648// =============================================================================
649
650/// Standard credential key for a provider's API token.
651pub fn token_key(provider: &str) -> String {
652    format!("{}/token", provider)
653}
654
655/// Build the default credential chain, optionally wrapping the whole thing in a TTL
656/// cache. Call this from host binaries (CLI, MCP server entrypoint) so the cache
657/// configuration stays consistent.
658///
659/// - `cache_ttl_secs == 0` → no cache, returns the raw [`ChainStore`].
660/// - `cache_ttl_secs > 0` → wraps in [`CachedStore`] with the requested TTL.
661pub 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
673/// Build a store on top of a user-provided backend (mainly useful for CI variants or
674/// custom test harnesses). Same cache semantics as [`build_default_store`].
675pub 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
689/// Standard credential key for a provider's email (used by Jira).
690pub 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
711        store.store("test/key", &secret("test-value")).unwrap();
712
713        // Get
714        let value = store.get("test/key").unwrap();
715        assert_eq!(exposed(&value), Some("test-value"));
716
717        // Exists
718        assert!(store.exists("test/key"));
719        assert!(!store.exists("nonexistent"));
720
721        // Delete
722        store.delete("test/key").unwrap();
723        let value = store.get("test/key").unwrap();
724        assert!(value.is_none());
725
726        // Delete non-existent (should not error)
727        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        // Delete non-existent key should succeed
763        store.delete("nonexistent/key").unwrap();
764
765        // Verify it's still not there
766        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        // Test the default exists() impl from the trait
796        let store = MemoryStore::new();
797
798        store.store("key1", &secret("val1")).unwrap();
799
800        // CredentialStore::exists uses the default impl calling get()
801        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    // Note: KeychainStore tests are not included here because they would
824    // interact with the real OS keychain. Integration tests for KeychainStore
825    // should be run separately with appropriate cleanup.
826
827    // =========================================================================
828    // EnvVarStore tests
829    // =========================================================================
830
831    #[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        // Test various key formats
856        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        // Dashes should also be converted
863        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    /// Mock env reader that returns values from a static map.
885    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        // Should NOT find it because fallback is disabled
928        // (TEST_NO_FALLBACK_TOKEN exists but only as unprefixed)
929        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    // =========================================================================
961    // ChainStore tests
962    // =========================================================================
963
964    #[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); // EnvVarStore + KeychainStore
975        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); // EnvVarStore + MemoryStore
982    }
983
984    #[test]
985    fn test_chain_store_get_first_match_wins() {
986        // Create chain with two memory stores
987        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        // key1 should come from first store
996        assert_eq!(exposed(&chain.get("key1").unwrap()), Some("value1"));
997
998        // key2 should come from second store (not in first)
999        assert_eq!(exposed(&chain.get("key2").unwrap()), Some("value2"));
1000
1001        // key3 not found in either
1002        assert!(chain.get("key3").unwrap().is_none());
1003    }
1004
1005    #[test]
1006    fn test_chain_store_store_to_first_writable() {
1007        // EnvVarStore (read-only) + MemoryStore (writable)
1008        let chain = ChainStore::new(vec![
1009            Box::new(EnvVarStore::new()),
1010            Box::new(MemoryStore::new()),
1011        ]);
1012
1013        // Should store to MemoryStore (first writable)
1014        chain.store("test.key", &secret("test-value")).unwrap();
1015
1016        // Should retrieve from chain
1017        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        // Chain with only read-only stores
1023        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        // Store in both
1036        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        // Delete should remove from both
1042        chain.delete("key").unwrap();
1043
1044        // Neither should have the key now
1045        assert!(chain.get("key").unwrap().is_none());
1046    }
1047
1048    #[test]
1049    fn test_chain_store_is_available() {
1050        // Empty chain
1051        let empty = ChainStore::new(vec![]);
1052        assert!(!empty.is_available());
1053
1054        // Chain with available store
1055        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        // Read-only chain
1062        let read_only = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
1063        assert!(!read_only.is_writable());
1064
1065        // Writable chain
1066        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        // This tests the real use case: env var takes priority over memory
1073
1074        // Set up env var store with mock reader
1075        let env_store = EnvVarStore::new().with_env_reader(mock_env_reader);
1076
1077        // Set up memory store with different value
1078        let memory = MemoryStore::with_credentials([(
1079            "chain.test.token".to_string(),
1080            "from-memory".to_string(),
1081        )]);
1082
1083        // Chain: env -> memory
1084        let chain = ChainStore::new(vec![Box::new(env_store), Box::new(memory)]);
1085
1086        // Env var should win
1087        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        // Memory store with value
1096        let memory = MemoryStore::with_credentials([(
1097            "fallback.test.token".to_string(),
1098            "from-memory".to_string(),
1099        )]);
1100
1101        // Chain: env (empty) -> memory
1102        let chain = ChainStore::new(vec![Box::new(EnvVarStore::new()), Box::new(memory)]);
1103
1104        // Should fall back to memory
1105        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    // =========================================================================
1120    // build_default_store / wrap_with_cache factories (Wire-up #2)
1121    // =========================================================================
1122
1123    #[test]
1124    fn test_build_default_store_zero_ttl_returns_writable_chain() {
1125        let store = build_default_store(0);
1126        // Default chain: env vars (read-only) + keychain (writable) → overall writable.
1127        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        // Cache must not break write-capability delegation.
1134        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        // Second call returns the same value — cached or not, semantics are identical.
1152        assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
1153    }
1154}