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 + 'static {
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        let repo = InMemoryRepository::default();
108        let config = ServerCommunicationConfig {
109            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
110                idp_login_url: "https://example.com/login".to_string(),
111                cookie_name: "TestCookie".to_string(),
112                cookie_domain: "example.com".to_string(),
113                cookie_value: Some("cookie-value-123".to_string()),
114            }),
115        };
116
117        // Save
118        repo.save("vault.example.com".to_string(), config.clone())
119            .await
120            .unwrap();
121
122        // Get
123        let retrieved = repo
124            .get("vault.example.com".to_string())
125            .await
126            .unwrap()
127            .unwrap();
128
129        if let BootstrapConfig::SsoCookieVendor(vendor_config) = retrieved.bootstrap {
130            assert_eq!(vendor_config.cookie_name, "TestCookie");
131            assert_eq!(
132                vendor_config.cookie_value,
133                Some("cookie-value-123".to_string())
134            );
135        } else {
136            panic!("Expected SsoCookieVendor");
137        }
138    }
139
140    #[tokio::test]
141    async fn repository_overwrite() {
142        let repo = InMemoryRepository::default();
143
144        // Save first config
145        let config1 = ServerCommunicationConfig {
146            bootstrap: BootstrapConfig::Direct,
147        };
148        repo.save("vault.example.com".to_string(), config1)
149            .await
150            .unwrap();
151
152        // Overwrite with second config
153        let config2 = ServerCommunicationConfig {
154            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
155                idp_login_url: "https://example.com".to_string(),
156                cookie_name: "Cookie".to_string(),
157                cookie_domain: "example.com".to_string(),
158                cookie_value: None,
159            }),
160        };
161        repo.save("vault.example.com".to_string(), config2)
162            .await
163            .unwrap();
164
165        // Verify second config is retrieved
166        let retrieved = repo
167            .get("vault.example.com".to_string())
168            .await
169            .unwrap()
170            .unwrap();
171        assert!(matches!(
172            retrieved.bootstrap,
173            BootstrapConfig::SsoCookieVendor(_)
174        ));
175    }
176
177    #[tokio::test]
178    async fn repository_multiple_hostnames() {
179        let repo = InMemoryRepository::default();
180
181        let config1 = ServerCommunicationConfig {
182            bootstrap: BootstrapConfig::Direct,
183        };
184        let config2 = ServerCommunicationConfig {
185            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
186                idp_login_url: "https://example.com".to_string(),
187                cookie_name: "Cookie".to_string(),
188                cookie_domain: "example.com".to_string(),
189                cookie_value: None,
190            }),
191        };
192
193        // Save different configs for different hostnames
194        repo.save("vault1.example.com".to_string(), config1)
195            .await
196            .unwrap();
197        repo.save("vault2.example.com".to_string(), config2)
198            .await
199            .unwrap();
200
201        // Verify each hostname has its own config
202        let retrieved1 = repo
203            .get("vault1.example.com".to_string())
204            .await
205            .unwrap()
206            .unwrap();
207        assert!(matches!(retrieved1.bootstrap, BootstrapConfig::Direct));
208
209        let retrieved2 = repo
210            .get("vault2.example.com".to_string())
211            .await
212            .unwrap()
213            .unwrap();
214        assert!(matches!(
215            retrieved2.bootstrap,
216            BootstrapConfig::SsoCookieVendor(_)
217        ));
218    }
219}