Skip to main content

bitwarden_server_communication_config/
repository.rs

1use bitwarden_error::bitwarden_error;
2use thiserror::Error;
3
4use crate::ServerCommunicationConfig;
5
6/// Repository errors for configuration storage operations
7#[derive(Debug, Error, Clone, PartialEq, Eq)]
8#[bitwarden_error(flat)]
9pub enum ServerCommunicationConfigRepositoryError {
10    /// Error occurred while retrieving configuration
11    #[error("Failed to get configuration: {0}")]
12    GetError(String),
13
14    /// Error occurred while saving configuration
15    #[error("Failed to save configuration: {0}")]
16    SaveError(String),
17}
18
19/// Repository for storing server communication configuration
20///
21/// This trait abstracts storage to allow TypeScript implementations via State Provider
22/// in WASM contexts, while also supporting in-memory implementations for testing.
23pub trait ServerCommunicationConfigRepository: Send + Sync {
24    /// Error type returned by `get()` operations
25    type GetError: std::fmt::Debug + Send + Sync + 'static;
26    /// Error type returned by `save()` operations
27    type SaveError: std::fmt::Debug + Send + Sync + 'static;
28
29    /// Retrieves configuration for a hostname
30    ///
31    /// # Arguments
32    ///
33    /// * `hostname` - The server hostname (e.g., "vault.amazon.com")
34    ///
35    /// # Returns
36    ///
37    /// - `Ok(Some(config))` - Configuration exists for this hostname
38    /// - `Ok(None)` - No configuration exists (not an error)
39    /// - `Err(e)` - Storage operation failed
40    fn get(
41        &self,
42        hostname: String,
43    ) -> impl std::future::Future<Output = Result<Option<ServerCommunicationConfig>, Self::GetError>>;
44
45    /// Saves configuration for a hostname
46    ///
47    /// Overwrites any existing configuration for this hostname.
48    ///
49    /// # Arguments
50    ///
51    /// * `hostname` - The server hostname (e.g., "vault.amazon.com")
52    /// * `config` - The configuration to store
53    ///
54    /// # Returns
55    ///
56    /// - `Ok(())` - Configuration saved successfully
57    /// - `Err(e)` - Storage operation failed
58    fn save(
59        &self,
60        hostname: String,
61        config: ServerCommunicationConfig,
62    ) -> impl std::future::Future<Output = Result<(), Self::SaveError>>;
63}
64
65#[cfg(test)]
66mod tests {
67    use std::{collections::HashMap, sync::Arc};
68
69    use tokio::sync::RwLock;
70
71    use super::*;
72    use crate::{BootstrapConfig, SsoCookieVendorConfig};
73
74    /// In-memory implementation of the repository for testing
75    #[derive(Default, Clone)]
76    struct InMemoryRepository {
77        storage: Arc<RwLock<HashMap<String, ServerCommunicationConfig>>>,
78    }
79
80    impl ServerCommunicationConfigRepository for InMemoryRepository {
81        type GetError = ();
82        type SaveError = ();
83
84        async fn get(&self, hostname: String) -> Result<Option<ServerCommunicationConfig>, ()> {
85            Ok(self.storage.read().await.get(&hostname).cloned())
86        }
87
88        async fn save(
89            &self,
90            hostname: String,
91            config: ServerCommunicationConfig,
92        ) -> Result<(), ()> {
93            self.storage.write().await.insert(hostname, config);
94            Ok(())
95        }
96    }
97
98    #[tokio::test]
99    async fn repository_get_none() {
100        let repo = InMemoryRepository::default();
101        let result = repo.get("vault.example.com".to_string()).await.unwrap();
102        assert!(result.is_none());
103    }
104
105    #[tokio::test]
106    async fn repository_save_and_get() {
107        use crate::AcquiredCookie;
108
109        let repo = InMemoryRepository::default();
110        let config = ServerCommunicationConfig {
111            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
112                idp_login_url: Some("https://example.com/login".to_string()),
113                cookie_name: Some("TestCookie".to_string()),
114                cookie_domain: Some("example.com".to_string()),
115                cookie_value: Some(vec![AcquiredCookie {
116                    name: "TestCookie".to_string(),
117                    value: "cookie-value-123".to_string(),
118                }]),
119            }),
120        };
121
122        // Save
123        repo.save("vault.example.com".to_string(), config.clone())
124            .await
125            .unwrap();
126
127        // Get
128        let retrieved = repo
129            .get("vault.example.com".to_string())
130            .await
131            .unwrap()
132            .unwrap();
133
134        if let BootstrapConfig::SsoCookieVendor(vendor_config) = retrieved.bootstrap {
135            assert_eq!(vendor_config.cookie_name, Some("TestCookie".to_string()));
136            assert_eq!(vendor_config.cookie_value.as_ref().unwrap().len(), 1);
137            assert_eq!(
138                vendor_config.cookie_value.as_ref().unwrap()[0].value,
139                "cookie-value-123"
140            );
141        } else {
142            panic!("Expected SsoCookieVendor");
143        }
144    }
145
146    #[tokio::test]
147    async fn repository_overwrite() {
148        let repo = InMemoryRepository::default();
149
150        // Save first config
151        let config1 = ServerCommunicationConfig {
152            bootstrap: BootstrapConfig::Direct,
153        };
154        repo.save("vault.example.com".to_string(), config1)
155            .await
156            .unwrap();
157
158        // Overwrite with second config
159        let config2 = ServerCommunicationConfig {
160            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
161                idp_login_url: Some("https://example.com".to_string()),
162                cookie_name: Some("Cookie".to_string()),
163                cookie_domain: Some("example.com".to_string()),
164                cookie_value: None,
165            }),
166        };
167        repo.save("vault.example.com".to_string(), config2)
168            .await
169            .unwrap();
170
171        // Verify second config is retrieved
172        let retrieved = repo
173            .get("vault.example.com".to_string())
174            .await
175            .unwrap()
176            .unwrap();
177        assert!(matches!(
178            retrieved.bootstrap,
179            BootstrapConfig::SsoCookieVendor(_)
180        ));
181    }
182
183    #[tokio::test]
184    async fn repository_multiple_hostnames() {
185        let repo = InMemoryRepository::default();
186
187        let config1 = ServerCommunicationConfig {
188            bootstrap: BootstrapConfig::Direct,
189        };
190        let config2 = ServerCommunicationConfig {
191            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
192                idp_login_url: Some("https://example.com".to_string()),
193                cookie_name: Some("Cookie".to_string()),
194                cookie_domain: Some("example.com".to_string()),
195                cookie_value: None,
196            }),
197        };
198
199        // Save different configs for different hostnames
200        repo.save("vault1.example.com".to_string(), config1)
201            .await
202            .unwrap();
203        repo.save("vault2.example.com".to_string(), config2)
204            .await
205            .unwrap();
206
207        // Verify each hostname has its own config
208        let retrieved1 = repo
209            .get("vault1.example.com".to_string())
210            .await
211            .unwrap()
212            .unwrap();
213        assert!(matches!(retrieved1.bootstrap, BootstrapConfig::Direct));
214
215        let retrieved2 = repo
216            .get("vault2.example.com".to_string())
217            .await
218            .unwrap()
219            .unwrap();
220        assert!(matches!(
221            retrieved2.bootstrap,
222            BootstrapConfig::SsoCookieVendor(_)
223        ));
224    }
225}