Skip to main content

bitwarden_server_communication_config/
client.rs

1use crate::{
2    AcquireCookieError, AcquiredCookie, BootstrapConfig, BootstrapConfigRequest,
3    ServerCommunicationConfig, ServerCommunicationConfigPlatformApi,
4    ServerCommunicationConfigRepository, SetCommunicationTypeRequest, SsoCookieVendorConfig,
5};
6
7/// Server communication configuration client
8pub struct ServerCommunicationConfigClient<R, P>
9where
10    R: ServerCommunicationConfigRepository,
11    P: ServerCommunicationConfigPlatformApi,
12{
13    repository: R,
14    platform_api: P,
15}
16
17impl<R, P> ServerCommunicationConfigClient<R, P>
18where
19    R: ServerCommunicationConfigRepository,
20    P: ServerCommunicationConfigPlatformApi,
21{
22    /// Creates a new server communication configuration client
23    ///
24    /// # Arguments
25    ///
26    /// * `repository` - Cookie storage implementation (e.g a StateProvider hook)
27    /// * `platform_api` - Cookie acquistion implementation
28    pub fn new(repository: R, platform_api: P) -> Self {
29        Self {
30            repository,
31            platform_api,
32        }
33    }
34
35    /// Retrieves the server communication configuration for a domain
36    pub async fn get_config(
37        &self,
38        domain: String,
39    ) -> Result<ServerCommunicationConfig, R::GetError> {
40        Ok(self
41            .repository
42            .get(domain)
43            .await?
44            .unwrap_or(ServerCommunicationConfig {
45                bootstrap: BootstrapConfig::Direct,
46            }))
47    }
48
49    /// Returns whether this domain uses cookie bootstrapping
50    ///
51    /// Returns `true` if the domain has an `SsoCookieVendor` configuration,
52    /// regardless of whether cookies have already been acquired. This is useful
53    /// for determining if the domain requires the bootstrap flow (e.g., to show
54    /// appropriate UI), without making claims about cookie validity.
55    pub async fn needs_bootstrap(&self, domain: String) -> bool {
56        if let Ok(Some(config)) = self.repository.get(domain).await {
57            return matches!(config.bootstrap, BootstrapConfig::SsoCookieVendor(_));
58        }
59        false
60    }
61
62    /// Returns cookies to include in HTTP requests
63    ///
64    /// Returns the stored cookies as-is. For sharded cookies, each entry includes
65    /// the full cookie name with its `-{N}` suffix (e.g., `AWSELBAuthSessionCookie-0`).
66    #[deprecated(
67        note = "Use get_cookies() instead, which will acquire cookies if not present in the config"
68    )]
69    pub async fn cookies(&self, domain: String) -> Vec<(String, String)> {
70        if let Ok(Some(config)) = self.repository.get(domain).await
71            && let BootstrapConfig::SsoCookieVendor(vendor_config) = config.bootstrap
72            && let Some(acquired_cookies) = vendor_config.cookie_value
73        {
74            return acquired_cookies
75                .into_iter()
76                .map(|cookie| (cookie.name, cookie.value))
77                .collect();
78        }
79        Vec::new()
80    }
81
82    /// Returns cookies to include in HTTP requests. For sharded cookies, each entry includes
83    /// the full cookie name with its `-{N}` suffix (e.g., `AWSELBAuthSessionCookie-0`).
84    ///
85    /// - If the configuration is not found or is Direct, returns an empty vector.
86    /// - If the configuration is SsoCookieVendor but has no acquired cookies, it will acquire them
87    ///   using the platform API and return the acquired cookies.
88    pub async fn get_cookies(
89        &self,
90        domain: String,
91    ) -> Result<Vec<AcquiredCookie>, AcquireCookieError> {
92        let config = self
93            .repository
94            .get(domain.clone())
95            .await
96            .map_err(|e| AcquireCookieError::RepositoryGetError(format!("{:?}", e)))?;
97
98        let Some(config) = config else {
99            return Ok(vec![]);
100        };
101
102        let BootstrapConfig::SsoCookieVendor(vendor_config) = config.bootstrap else {
103            return Ok(vec![]);
104        };
105
106        match vendor_config.cookie_value {
107            Some(ref cookies) => Ok(cookies.clone()),
108            None => Ok(self.acquire_cookie(&domain).await?),
109        }
110    }
111
112    /// Sets the server communication configuration for a domain
113    ///
114    /// This method saves the provided communication configuration to the repository.
115    /// Typically called when receiving the `/api/config` response from the server.
116    ///
117    /// The request type intentionally excludes `cookie_value`, since cookies are
118    /// managed separately via [`Self::acquire_cookie`]. Any previously acquired
119    /// cookies stored in the repository are preserved across calls to this method.
120    ///
121    /// # Arguments
122    ///
123    /// * `domain` - The server domain (e.g., "vault.acme.com")
124    /// * `request` - The server communication configuration to store
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the repository save operation fails
129    #[deprecated(
130        note = "Use set_communication_type_v2() instead, which extracts the domain from the config"
131    )]
132    pub async fn set_communication_type(
133        &self,
134        domain: String,
135        request: SetCommunicationTypeRequest,
136    ) -> Result<(), R::SaveError> {
137        let existing_cookie_value = match &request.bootstrap {
138            BootstrapConfigRequest::SsoCookieVendor(_) => self
139                .repository
140                .get(domain.clone())
141                .await
142                .ok()
143                .flatten()
144                .and_then(|existing| match existing.bootstrap {
145                    BootstrapConfig::SsoCookieVendor(v) => v.cookie_value,
146                    _ => None,
147                }),
148            BootstrapConfigRequest::Direct => None,
149        };
150
151        let config = ServerCommunicationConfig {
152            bootstrap: match request.bootstrap {
153                BootstrapConfigRequest::Direct => BootstrapConfig::Direct,
154                BootstrapConfigRequest::SsoCookieVendor(v) => {
155                    BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
156                        idp_login_url: v.idp_login_url,
157                        cookie_name: v.cookie_name,
158                        cookie_domain: v.cookie_domain,
159                        vault_url: v.vault_url,
160                        cookie_value: existing_cookie_value,
161                    })
162                }
163            },
164        };
165
166        self.repository.save(domain, config).await
167    }
168
169    /// Sets the server communication configuration using the domain from the config itself
170    ///
171    /// Extracts the `cookie_domain` from the `SsoCookieVendor` config and uses it as the
172    /// storage key. If the config is `Direct`, the call is silently ignored.
173    ///
174    /// Typically called when receiving the `/api/config` response from the server.
175    ///
176    /// # Arguments
177    ///
178    /// * `config` - The server communication configuration to store
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if the repository save operation fails
183    pub async fn set_communication_type_v2(
184        &self,
185        request: SetCommunicationTypeRequest,
186    ) -> Result<(), R::SaveError> {
187        let BootstrapConfigRequest::SsoCookieVendor(v) = request.bootstrap else {
188            return Ok(());
189        };
190
191        let domain = v.cookie_domain.clone();
192
193        let existing_cookie_value = self
194            .repository
195            .get(domain.clone())
196            .await
197            .ok()
198            .flatten()
199            .and_then(|existing| match existing.bootstrap {
200                BootstrapConfig::SsoCookieVendor(v) => v.cookie_value,
201                _ => None,
202            });
203
204        let config = ServerCommunicationConfig {
205            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
206                idp_login_url: v.idp_login_url,
207                cookie_name: v.cookie_name,
208                cookie_domain: v.cookie_domain,
209                vault_url: v.vault_url,
210                cookie_value: existing_cookie_value,
211            }),
212        };
213
214        self.repository.save(domain, config).await
215    }
216
217    /// Acquires a cookie from the platform and saves it to the repository
218    ///
219    /// This method calls the platform API to trigger cookie acquisition (e.g., browser
220    /// redirect to IdP), then validates and stores the acquired cookie in the repository.
221    ///
222    /// # Arguments
223    ///
224    /// * `domain` - The server domain (e.g., "vault.acme.com")
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if:
229    /// - Cookie acquisition was cancelled by the user ([`AcquireCookieError::Cancelled`])
230    /// - Server configuration doesn't support SSO cookies
231    ///   ([`AcquireCookieError::UnsupportedConfiguration`])
232    /// - Acquired cookie name doesn't match expected name
233    ///   ([`AcquireCookieError::CookieNameMismatch`])
234    /// - Repository operations fail ([`AcquireCookieError::RepositoryGetError`] or
235    ///   [`AcquireCookieError::RepositorySaveError`])
236    pub async fn acquire_cookie(
237        &self,
238        domain: &str,
239    ) -> Result<Vec<AcquiredCookie>, AcquireCookieError> {
240        // Get existing configuration - we need this to know what cookie to expect
241        let mut config = self
242            .repository
243            .get(domain.to_string())
244            .await
245            .map_err(|e| AcquireCookieError::RepositoryGetError(format!("{:?}", e)))?
246            .ok_or(AcquireCookieError::UnsupportedConfiguration)?;
247
248        // Verify this is an SSO cookie vendor configuration and get mutable reference
249        let BootstrapConfig::SsoCookieVendor(ref mut vendor_config) = config.bootstrap else {
250            return Err(AcquireCookieError::UnsupportedConfiguration);
251        };
252
253        let expected_cookie_name = &vendor_config.cookie_name;
254
255        // Reject empty vault_url - the platform needs a real URL to redirect to
256        if vendor_config.vault_url.is_empty() {
257            return Err(AcquireCookieError::UnsupportedConfiguration);
258        }
259        let vault_url = vendor_config.vault_url.clone();
260
261        // Call platform API to acquire cookies, passing vault_url
262        let cookies = self
263            .platform_api
264            .acquire_cookies(vault_url)
265            .await
266            .ok_or(AcquireCookieError::Cancelled)?;
267
268        // Validate that all cookies match the expected base name
269        // Cookie names should either:
270        // 1. Exactly match the expected name (unsharded cookie)
271        // 2. Match the pattern {expected_name}-{N} where N is a digit (sharded cookies)
272        //
273        // AWS ALB shards cookies > 4KB with naming pattern: {base_name}-{N}
274        // where N starts at 0 (e.g., AWSELBAuthSessionCookie-0, AWSELBAuthSessionCookie-1)
275        let all_cookies_match = cookies.iter().all(|cookie| {
276            cookie.name == *expected_cookie_name
277                || cookie
278                    .name
279                    .strip_prefix(&format!("{}-", expected_cookie_name))
280                    .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
281        });
282
283        if !all_cookies_match {
284            // Find the first mismatched cookie for error reporting
285            let mismatched = cookies
286                .iter()
287                .find(|cookie| {
288                    cookie.name != *expected_cookie_name
289                        && !cookie
290                            .name
291                            .strip_prefix(&format!("{}-", expected_cookie_name))
292                            .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
293                })
294                .expect("all_cookies_match is false, so at least one cookie must not match");
295
296            return Err(AcquireCookieError::CookieNameMismatch {
297                expected: expected_cookie_name.clone(),
298                actual: mismatched.name.clone(),
299            });
300        }
301
302        // Update the cookie values using the mutable reference we already have
303        vendor_config.cookie_value = Some(cookies.clone());
304
305        // Save the updated config
306        self.repository
307            .save(domain.to_string(), config)
308            .await
309            .map_err(|e| AcquireCookieError::RepositorySaveError(format!("{:?}", e)))?;
310
311        Ok(cookies)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use std::collections::HashMap;
318
319    use tokio::sync::RwLock;
320
321    use super::*;
322    use crate::{SsoCookieVendorConfig, SsoCookieVendorConfigRequest};
323
324    /// Mock in-memory repository for testing
325    #[derive(Default, Clone)]
326    struct MockRepository {
327        storage: std::sync::Arc<RwLock<HashMap<String, ServerCommunicationConfig>>>,
328    }
329
330    impl ServerCommunicationConfigRepository for MockRepository {
331        type GetError = ();
332        type SaveError = ();
333
334        async fn get(&self, domain: String) -> Result<Option<ServerCommunicationConfig>, ()> {
335            Ok(self.storage.read().await.get(&domain).cloned())
336        }
337
338        async fn save(&self, domain: String, config: ServerCommunicationConfig) -> Result<(), ()> {
339            self.storage.write().await.insert(domain, config);
340            Ok(())
341        }
342    }
343
344    /// Mock platform API for testing
345    #[derive(Clone)]
346    struct MockPlatformApi {
347        cookies_to_return: std::sync::Arc<RwLock<Option<Vec<AcquiredCookie>>>>,
348    }
349
350    impl MockPlatformApi {
351        fn new() -> Self {
352            Self {
353                cookies_to_return: std::sync::Arc::new(RwLock::new(None)),
354            }
355        }
356
357        async fn set_cookies(&self, cookies: Option<Vec<AcquiredCookie>>) {
358            *self.cookies_to_return.write().await = cookies;
359        }
360    }
361
362    #[async_trait::async_trait]
363    impl ServerCommunicationConfigPlatformApi for MockPlatformApi {
364        async fn acquire_cookies(&self, _vault_url: String) -> Option<Vec<AcquiredCookie>> {
365            self.cookies_to_return.read().await.clone()
366        }
367    }
368
369    #[tokio::test]
370    async fn get_config_returns_direct_when_not_found() {
371        let repo = MockRepository::default();
372        let platform_api = MockPlatformApi::new();
373        let client = ServerCommunicationConfigClient::new(repo, platform_api);
374
375        let config = client
376            .get_config("vault.example.com".to_string())
377            .await
378            .unwrap();
379
380        assert!(matches!(config.bootstrap, BootstrapConfig::Direct));
381    }
382
383    #[tokio::test]
384    async fn get_config_returns_saved_config() {
385        let repo = MockRepository::default();
386        let config = ServerCommunicationConfig {
387            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
388                idp_login_url: Some("https://example.com".to_string()),
389                cookie_name: "TestCookie".to_string(),
390                cookie_domain: "example.com".to_string(),
391                vault_url: "https://vault.example.com".to_string(),
392                cookie_value: Some(vec![AcquiredCookie {
393                    name: "TestCookie".to_string(),
394                    value: "value123".to_string(),
395                }]),
396            }),
397        };
398
399        repo.save("vault.example.com".to_string(), config.clone())
400            .await
401            .unwrap();
402
403        let platform_api = MockPlatformApi::new();
404        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
405        let retrieved = client
406            .get_config("vault.example.com".to_string())
407            .await
408            .unwrap();
409
410        assert!(matches!(
411            retrieved.bootstrap,
412            BootstrapConfig::SsoCookieVendor(_)
413        ));
414    }
415
416    #[tokio::test]
417    async fn needs_bootstrap_true_for_sso_cookie_vendor() {
418        let repo = MockRepository::default();
419        let config = ServerCommunicationConfig {
420            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
421                idp_login_url: Some("https://example.com".to_string()),
422                cookie_name: "TestCookie".to_string(),
423                cookie_domain: "example.com".to_string(),
424                vault_url: "https://vault.example.com".to_string(),
425                cookie_value: None,
426            }),
427        };
428
429        repo.save("vault.example.com".to_string(), config)
430            .await
431            .unwrap();
432
433        let platform_api = MockPlatformApi::new();
434        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
435        assert!(
436            client
437                .needs_bootstrap("vault.example.com".to_string())
438                .await
439        );
440    }
441
442    #[tokio::test]
443    async fn needs_bootstrap_true_when_cookie_already_present() {
444        let repo = MockRepository::default();
445        let config = ServerCommunicationConfig {
446            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
447                idp_login_url: Some("https://example.com".to_string()),
448                cookie_name: "TestCookie".to_string(),
449                cookie_domain: "example.com".to_string(),
450                vault_url: "https://vault.example.com".to_string(),
451                cookie_value: Some(vec![AcquiredCookie {
452                    name: "TestCookie".to_string(),
453                    value: "value123".to_string(),
454                }]),
455            }),
456        };
457
458        repo.save("vault.example.com".to_string(), config)
459            .await
460            .unwrap();
461
462        let platform_api = MockPlatformApi::new();
463        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
464        assert!(
465            client
466                .needs_bootstrap("vault.example.com".to_string())
467                .await
468        );
469    }
470
471    #[tokio::test]
472    async fn needs_bootstrap_false_for_direct() {
473        let repo = MockRepository::default();
474        let config = ServerCommunicationConfig {
475            bootstrap: BootstrapConfig::Direct,
476        };
477
478        repo.save("vault.example.com".to_string(), config)
479            .await
480            .unwrap();
481
482        let platform_api = MockPlatformApi::new();
483        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
484        assert!(
485            !client
486                .needs_bootstrap("vault.example.com".to_string())
487                .await
488        );
489    }
490
491    #[tokio::test]
492    #[allow(deprecated)]
493    async fn cookies_returns_empty_for_direct() {
494        let repo = MockRepository::default();
495        let config = ServerCommunicationConfig {
496            bootstrap: BootstrapConfig::Direct,
497        };
498
499        repo.save("vault.example.com".to_string(), config)
500            .await
501            .unwrap();
502
503        let platform_api = MockPlatformApi::new();
504        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
505        let cookies = client.cookies("vault.example.com".to_string()).await;
506
507        assert!(cookies.is_empty());
508    }
509
510    #[tokio::test]
511    #[allow(deprecated)]
512    async fn cookies_returns_empty_when_value_none() {
513        let repo = MockRepository::default();
514        let config = ServerCommunicationConfig {
515            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
516                idp_login_url: Some("https://example.com".to_string()),
517                cookie_name: "TestCookie".to_string(),
518                cookie_domain: "example.com".to_string(),
519                vault_url: "https://vault.example.com".to_string(),
520                cookie_value: None,
521            }),
522        };
523
524        repo.save("vault.example.com".to_string(), config)
525            .await
526            .unwrap();
527
528        let platform_api = MockPlatformApi::new();
529        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
530        let cookies = client.cookies("vault.example.com".to_string()).await;
531
532        assert!(cookies.is_empty());
533    }
534
535    #[tokio::test]
536    #[allow(deprecated)]
537    async fn cookies_returns_unsharded_cookie_without_suffix() {
538        let repo = MockRepository::default();
539        let config = ServerCommunicationConfig {
540            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
541                idp_login_url: Some("https://example.com".to_string()),
542                cookie_name: "AWSELBAuthSessionCookie".to_string(),
543                cookie_domain: "example.com".to_string(),
544                vault_url: "https://vault.example.com".to_string(),
545                cookie_value: Some(vec![AcquiredCookie {
546                    name: "AWSELBAuthSessionCookie".to_string(),
547                    value: "eyJhbGciOiJFUzI1NiIsImtpZCI6Im...".to_string(),
548                }]),
549            }),
550        };
551
552        repo.save("vault.example.com".to_string(), config)
553            .await
554            .unwrap();
555
556        let platform_api = MockPlatformApi::new();
557        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
558        let cookies = client.cookies("vault.example.com".to_string()).await;
559
560        // Single cookie without suffix
561        assert_eq!(cookies.len(), 1);
562        assert_eq!(cookies[0].0, "AWSELBAuthSessionCookie");
563        assert_eq!(cookies[0].1, "eyJhbGciOiJFUzI1NiIsImtpZCI6Im...");
564    }
565
566    #[tokio::test]
567    #[allow(deprecated)]
568    async fn cookies_returns_empty_when_no_config() {
569        let repo = MockRepository::default();
570        let platform_api = MockPlatformApi::new();
571        let client = ServerCommunicationConfigClient::new(repo, platform_api);
572        let cookies = client.cookies("vault.example.com".to_string()).await;
573
574        assert!(cookies.is_empty());
575    }
576
577    #[tokio::test]
578    #[allow(deprecated)]
579    async fn cookies_returns_shards_with_numbered_suffixes() {
580        let repo = MockRepository::default();
581        let config = ServerCommunicationConfig {
582            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
583                idp_login_url: Some("https://example.com".to_string()),
584                cookie_name: "AWSELBAuthSessionCookie".to_string(),
585                cookie_domain: "example.com".to_string(),
586                vault_url: "https://vault.example.com".to_string(),
587                cookie_value: Some(vec![
588                    AcquiredCookie {
589                        name: "AWSELBAuthSessionCookie-0".to_string(),
590                        value: "shard0value".to_string(),
591                    },
592                    AcquiredCookie {
593                        name: "AWSELBAuthSessionCookie-1".to_string(),
594                        value: "shard1value".to_string(),
595                    },
596                    AcquiredCookie {
597                        name: "AWSELBAuthSessionCookie-2".to_string(),
598                        value: "shard2value".to_string(),
599                    },
600                ]),
601            }),
602        };
603
604        repo.save("vault.example.com".to_string(), config)
605            .await
606            .unwrap();
607
608        let platform_api = MockPlatformApi::new();
609        let client = ServerCommunicationConfigClient::new(repo, platform_api);
610        let cookies = client.cookies("vault.example.com".to_string()).await;
611
612        // Each shard is returned as stored with -N suffix
613        assert_eq!(cookies.len(), 3);
614        assert_eq!(
615            cookies[0],
616            (
617                "AWSELBAuthSessionCookie-0".to_string(),
618                "shard0value".to_string()
619            )
620        );
621        assert_eq!(
622            cookies[1],
623            (
624                "AWSELBAuthSessionCookie-1".to_string(),
625                "shard1value".to_string()
626            )
627        );
628        assert_eq!(
629            cookies[2],
630            (
631                "AWSELBAuthSessionCookie-2".to_string(),
632                "shard2value".to_string()
633            )
634        );
635    }
636
637    #[tokio::test]
638    async fn acquire_cookie_saves_when_cookie_returned() {
639        let repo = MockRepository::default();
640        let platform_api = MockPlatformApi::new();
641
642        // Setup existing config with SsoCookieVendor
643        let config = ServerCommunicationConfig {
644            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
645                idp_login_url: Some("https://example.com".to_string()),
646                cookie_name: "TestCookie".to_string(),
647                cookie_domain: "example.com".to_string(),
648                vault_url: "https://vault.example.com".to_string(),
649                cookie_value: None,
650            }),
651        };
652        repo.save("vault.example.com".to_string(), config)
653            .await
654            .unwrap();
655
656        // Configure platform API to return a cookie with correct name
657        platform_api
658            .set_cookies(Some(vec![AcquiredCookie {
659                name: "TestCookie".to_string(),
660                value: "acquired-cookie-value".to_string(),
661            }]))
662            .await;
663
664        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
665
666        // Call acquire_cookie - should succeed
667        client.acquire_cookie("vault.example.com").await.unwrap();
668
669        // Verify cookie was saved
670        let saved_config = repo
671            .get("vault.example.com".to_string())
672            .await
673            .unwrap()
674            .unwrap();
675
676        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
677            assert_eq!(vendor_config.cookie_value.as_ref().unwrap().len(), 1);
678            assert_eq!(
679                vendor_config.cookie_value.as_ref().unwrap()[0].name,
680                "TestCookie"
681            );
682            assert_eq!(
683                vendor_config.cookie_value.as_ref().unwrap()[0].value,
684                "acquired-cookie-value"
685            );
686        } else {
687            panic!("Expected SsoCookieVendor config");
688        }
689    }
690
691    #[tokio::test]
692    async fn acquire_cookie_returns_cancelled_when_none() {
693        let repo = MockRepository::default();
694        let platform_api = MockPlatformApi::new();
695
696        // Setup existing config
697        let config = ServerCommunicationConfig {
698            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
699                idp_login_url: Some("https://example.com".to_string()),
700                cookie_name: "TestCookie".to_string(),
701                cookie_domain: "example.com".to_string(),
702                vault_url: "https://vault.example.com".to_string(),
703                cookie_value: None,
704            }),
705        };
706        repo.save("vault.example.com".to_string(), config)
707            .await
708            .unwrap();
709
710        // Platform API returns None (user cancelled)
711        platform_api.set_cookies(None).await;
712
713        let client = ServerCommunicationConfigClient::new(repo, platform_api);
714
715        let result = client.acquire_cookie("vault.example.com").await;
716
717        assert!(matches!(result, Err(AcquireCookieError::Cancelled)));
718    }
719
720    #[tokio::test]
721    async fn acquire_cookie_returns_unsupported_for_direct_config() {
722        let repo = MockRepository::default();
723        let platform_api = MockPlatformApi::new();
724
725        // Setup Direct config
726        let config = ServerCommunicationConfig {
727            bootstrap: BootstrapConfig::Direct,
728        };
729        repo.save("vault.example.com".to_string(), config)
730            .await
731            .unwrap();
732
733        // Platform API returns a cookie
734        platform_api
735            .set_cookies(Some(vec![AcquiredCookie {
736                name: "TestCookie".to_string(),
737                value: "cookie-value".to_string(),
738            }]))
739            .await;
740
741        let client = ServerCommunicationConfigClient::new(repo, platform_api);
742
743        let result = client.acquire_cookie("vault.example.com").await;
744
745        // Should return UnsupportedConfiguration because config is Direct
746        assert!(matches!(
747            result,
748            Err(AcquireCookieError::UnsupportedConfiguration)
749        ));
750    }
751
752    #[tokio::test]
753    async fn acquire_cookie_validates_cookie_name() {
754        let repo = MockRepository::default();
755        let platform_api = MockPlatformApi::new();
756
757        // Setup config expecting "ExpectedCookie"
758        let config = ServerCommunicationConfig {
759            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
760                idp_login_url: Some("https://example.com".to_string()),
761                cookie_name: "ExpectedCookie".to_string(),
762                cookie_domain: "example.com".to_string(),
763                vault_url: "https://vault.example.com".to_string(),
764                cookie_value: None,
765            }),
766        };
767        repo.save("vault.example.com".to_string(), config)
768            .await
769            .unwrap();
770
771        // Platform API returns wrong cookie name
772        platform_api
773            .set_cookies(Some(vec![AcquiredCookie {
774                name: "WrongCookie".to_string(),
775                value: "some-value".to_string(),
776            }]))
777            .await;
778
779        let client = ServerCommunicationConfigClient::new(repo, platform_api);
780
781        let result = client.acquire_cookie("vault.example.com").await;
782
783        // Should return CookieNameMismatch
784        match result {
785            Err(AcquireCookieError::CookieNameMismatch { expected, actual }) => {
786                assert_eq!(expected, "ExpectedCookie");
787                assert_eq!(actual, "WrongCookie");
788            }
789            _ => panic!("Expected CookieNameMismatch error"),
790        }
791    }
792
793    #[tokio::test]
794    async fn acquire_cookie_returns_unsupported_when_no_config() {
795        let repo = MockRepository::default();
796        let platform_api = MockPlatformApi::new();
797
798        // No config saved for this domain
799
800        let client = ServerCommunicationConfigClient::new(repo, platform_api);
801
802        let result = client.acquire_cookie("vault.example.com").await;
803
804        // Should return UnsupportedConfiguration because no config exists
805        assert!(matches!(
806            result,
807            Err(AcquireCookieError::UnsupportedConfiguration)
808        ));
809    }
810
811    #[tokio::test]
812    async fn acquire_cookie_accepts_sharded_cookies_with_numbered_suffixes() {
813        let repo = MockRepository::default();
814        let platform_api = MockPlatformApi::new();
815
816        // Setup config expecting "AWSELBAuthSessionCookie"
817        let config = ServerCommunicationConfig {
818            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
819                idp_login_url: Some("https://example.com".to_string()),
820                cookie_name: "AWSELBAuthSessionCookie".to_string(),
821                cookie_domain: "example.com".to_string(),
822                vault_url: "https://vault.example.com".to_string(),
823                cookie_value: None,
824            }),
825        };
826        repo.save("vault.example.com".to_string(), config)
827            .await
828            .unwrap();
829
830        // Platform API returns multiple shards with -N suffixes
831        platform_api
832            .set_cookies(Some(vec![
833                AcquiredCookie {
834                    name: "AWSELBAuthSessionCookie-0".to_string(),
835                    value: "shard0value".to_string(),
836                },
837                AcquiredCookie {
838                    name: "AWSELBAuthSessionCookie-1".to_string(),
839                    value: "shard1value".to_string(),
840                },
841                AcquiredCookie {
842                    name: "AWSELBAuthSessionCookie-2".to_string(),
843                    value: "shard2value".to_string(),
844                },
845            ]))
846            .await;
847
848        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
849
850        // Should succeed
851        client.acquire_cookie("vault.example.com").await.unwrap();
852
853        // Verify all shards were saved
854        let saved_config = repo
855            .get("vault.example.com".to_string())
856            .await
857            .unwrap()
858            .unwrap();
859
860        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
861            assert_eq!(vendor_config.cookie_value.as_ref().unwrap().len(), 3);
862            assert_eq!(
863                vendor_config.cookie_value.as_ref().unwrap()[0].name,
864                "AWSELBAuthSessionCookie-0"
865            );
866            assert_eq!(
867                vendor_config.cookie_value.as_ref().unwrap()[1].name,
868                "AWSELBAuthSessionCookie-1"
869            );
870            assert_eq!(
871                vendor_config.cookie_value.as_ref().unwrap()[2].name,
872                "AWSELBAuthSessionCookie-2"
873            );
874        } else {
875            panic!("Expected SsoCookieVendor config");
876        }
877    }
878
879    #[tokio::test]
880    async fn acquire_cookie_accepts_unsharded_cookie_without_suffix() {
881        let repo = MockRepository::default();
882        let platform_api = MockPlatformApi::new();
883
884        // Setup config
885        let config = ServerCommunicationConfig {
886            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
887                idp_login_url: Some("https://example.com".to_string()),
888                cookie_name: "SessionCookie".to_string(),
889                cookie_domain: "example.com".to_string(),
890                vault_url: "https://vault.example.com".to_string(),
891                cookie_value: None,
892            }),
893        };
894        repo.save("vault.example.com".to_string(), config)
895            .await
896            .unwrap();
897
898        // Platform API returns single cookie without suffix
899        platform_api
900            .set_cookies(Some(vec![AcquiredCookie {
901                name: "SessionCookie".to_string(),
902                value: "single-cookie-value".to_string(),
903            }]))
904            .await;
905
906        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
907
908        // Should succeed
909        client.acquire_cookie("vault.example.com").await.unwrap();
910
911        // Verify value was saved
912        let saved_config = repo
913            .get("vault.example.com".to_string())
914            .await
915            .unwrap()
916            .unwrap();
917
918        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
919            assert_eq!(vendor_config.cookie_value.as_ref().unwrap().len(), 1);
920            assert_eq!(
921                vendor_config.cookie_value.as_ref().unwrap()[0].name,
922                "SessionCookie"
923            );
924            assert_eq!(
925                vendor_config.cookie_value.as_ref().unwrap()[0].value,
926                "single-cookie-value"
927            );
928        } else {
929            panic!("Expected SsoCookieVendor config");
930        }
931    }
932
933    #[tokio::test]
934    async fn get_cookies_returns_empty_for_direct() {
935        let repo = MockRepository::default();
936        let config = ServerCommunicationConfig {
937            bootstrap: BootstrapConfig::Direct,
938        };
939
940        repo.save("vault.example.com".to_string(), config)
941            .await
942            .unwrap();
943
944        let platform_api = MockPlatformApi::new();
945        let client = ServerCommunicationConfigClient::new(repo, platform_api);
946        let cookies = client
947            .get_cookies("vault.example.com".to_string())
948            .await
949            .unwrap();
950
951        assert!(cookies.is_empty());
952    }
953
954    #[tokio::test]
955    async fn get_cookies_returns_empty_when_no_config() {
956        let repo = MockRepository::default();
957        let platform_api = MockPlatformApi::new();
958        let client = ServerCommunicationConfigClient::new(repo, platform_api);
959        let cookies = client
960            .get_cookies("vault.example.com".to_string())
961            .await
962            .unwrap();
963
964        assert!(cookies.is_empty());
965    }
966
967    #[tokio::test]
968    async fn get_cookies_returns_existing_cookies() {
969        let repo = MockRepository::default();
970        let config = ServerCommunicationConfig {
971            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
972                idp_login_url: Some("https://example.com".to_string()),
973                cookie_name: "TestCookie".to_string(),
974                cookie_domain: "example.com".to_string(),
975                vault_url: "https://vault.example.com".to_string(),
976                cookie_value: Some(vec![AcquiredCookie {
977                    name: "TestCookie".to_string(),
978                    value: "existing-value".to_string(),
979                }]),
980            }),
981        };
982
983        repo.save("vault.example.com".to_string(), config)
984            .await
985            .unwrap();
986
987        let platform_api = MockPlatformApi::new();
988        let client = ServerCommunicationConfigClient::new(repo, platform_api);
989        let cookies = client
990            .get_cookies("vault.example.com".to_string())
991            .await
992            .unwrap();
993
994        assert_eq!(cookies.len(), 1);
995        assert_eq!(cookies[0].name, "TestCookie");
996        assert_eq!(cookies[0].value, "existing-value");
997    }
998
999    #[tokio::test]
1000    async fn get_cookies_acquires_when_none_present() {
1001        let repo = MockRepository::default();
1002        let config = ServerCommunicationConfig {
1003            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
1004                idp_login_url: Some("https://example.com".to_string()),
1005                cookie_name: "TestCookie".to_string(),
1006                cookie_domain: "example.com".to_string(),
1007                vault_url: "https://vault.example.com".to_string(),
1008                cookie_value: None,
1009            }),
1010        };
1011
1012        repo.save("vault.example.com".to_string(), config)
1013            .await
1014            .unwrap();
1015
1016        let platform_api = MockPlatformApi::new();
1017        platform_api
1018            .set_cookies(Some(vec![AcquiredCookie {
1019                name: "TestCookie".to_string(),
1020                value: "acquired-value".to_string(),
1021            }]))
1022            .await;
1023
1024        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1025        let cookies = client
1026            .get_cookies("vault.example.com".to_string())
1027            .await
1028            .unwrap();
1029
1030        assert_eq!(cookies.len(), 1);
1031        assert_eq!(cookies[0].name, "TestCookie");
1032        assert_eq!(cookies[0].value, "acquired-value");
1033
1034        // Verify cookies were also saved to repo
1035        let saved_config = repo
1036            .get("vault.example.com".to_string())
1037            .await
1038            .unwrap()
1039            .unwrap();
1040
1041        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
1042            assert_eq!(vendor_config.cookie_value.as_ref().unwrap().len(), 1);
1043            assert_eq!(
1044                vendor_config.cookie_value.as_ref().unwrap()[0].value,
1045                "acquired-value"
1046            );
1047        } else {
1048            panic!("Expected SsoCookieVendor config");
1049        }
1050    }
1051
1052    #[tokio::test]
1053    async fn get_cookies_returns_cancelled_when_acquisition_fails() {
1054        let repo = MockRepository::default();
1055        let config = ServerCommunicationConfig {
1056            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
1057                idp_login_url: Some("https://example.com".to_string()),
1058                cookie_name: "TestCookie".to_string(),
1059                cookie_domain: "example.com".to_string(),
1060                vault_url: "https://vault.example.com".to_string(),
1061                cookie_value: None,
1062            }),
1063        };
1064
1065        repo.save("vault.example.com".to_string(), config)
1066            .await
1067            .unwrap();
1068
1069        // Platform API returns None (user cancelled)
1070        let platform_api = MockPlatformApi::new();
1071        platform_api.set_cookies(None).await;
1072
1073        let client = ServerCommunicationConfigClient::new(repo, platform_api);
1074        let result = client.get_cookies("vault.example.com".to_string()).await;
1075
1076        assert!(matches!(result, Err(AcquireCookieError::Cancelled)));
1077    }
1078
1079    #[tokio::test]
1080    async fn get_cookies_returns_existing_sharded_cookies() {
1081        let repo = MockRepository::default();
1082        let config = ServerCommunicationConfig {
1083            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
1084                idp_login_url: Some("https://example.com".to_string()),
1085                cookie_name: "AWSELBAuthSessionCookie".to_string(),
1086                cookie_domain: "example.com".to_string(),
1087                vault_url: "https://vault.example.com".to_string(),
1088                cookie_value: Some(vec![
1089                    AcquiredCookie {
1090                        name: "AWSELBAuthSessionCookie-0".to_string(),
1091                        value: "shard0value".to_string(),
1092                    },
1093                    AcquiredCookie {
1094                        name: "AWSELBAuthSessionCookie-1".to_string(),
1095                        value: "shard1value".to_string(),
1096                    },
1097                ]),
1098            }),
1099        };
1100
1101        repo.save("vault.example.com".to_string(), config)
1102            .await
1103            .unwrap();
1104
1105        let platform_api = MockPlatformApi::new();
1106        let client = ServerCommunicationConfigClient::new(repo, platform_api);
1107        let cookies = client
1108            .get_cookies("vault.example.com".to_string())
1109            .await
1110            .unwrap();
1111
1112        assert_eq!(cookies.len(), 2);
1113        assert_eq!(cookies[0].name, "AWSELBAuthSessionCookie-0");
1114        assert_eq!(cookies[1].name, "AWSELBAuthSessionCookie-1");
1115    }
1116
1117    #[tokio::test]
1118    #[allow(deprecated)]
1119    async fn set_communication_type_saves_direct_config() {
1120        let repo = MockRepository::default();
1121        let platform_api = MockPlatformApi::new();
1122        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1123
1124        let request = SetCommunicationTypeRequest {
1125            bootstrap: BootstrapConfigRequest::Direct,
1126        };
1127
1128        client
1129            .set_communication_type("vault.example.com".to_string(), request)
1130            .await
1131            .unwrap();
1132
1133        let saved_config = repo
1134            .get("vault.example.com".to_string())
1135            .await
1136            .unwrap()
1137            .unwrap();
1138
1139        assert!(matches!(saved_config.bootstrap, BootstrapConfig::Direct));
1140    }
1141
1142    #[tokio::test]
1143    #[allow(deprecated)]
1144    async fn set_communication_type_saves_sso_cookie_vendor_config() {
1145        let repo = MockRepository::default();
1146        let platform_api = MockPlatformApi::new();
1147        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1148
1149        let request = SetCommunicationTypeRequest {
1150            bootstrap: BootstrapConfigRequest::SsoCookieVendor(SsoCookieVendorConfigRequest {
1151                idp_login_url: Some("https://idp.example.com/login".to_string()),
1152                cookie_name: "SessionCookie".to_string(),
1153                cookie_domain: "vault.example.com".to_string(),
1154                vault_url: "https://vault.example.com".to_string(),
1155            }),
1156        };
1157
1158        client
1159            .set_communication_type("vault.example.com".to_string(), request)
1160            .await
1161            .unwrap();
1162
1163        let saved_config = repo
1164            .get("vault.example.com".to_string())
1165            .await
1166            .unwrap()
1167            .unwrap();
1168
1169        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
1170            assert_eq!(
1171                vendor_config.idp_login_url,
1172                Some("https://idp.example.com/login".to_string())
1173            );
1174            assert_eq!(vendor_config.cookie_name, "SessionCookie");
1175            assert_eq!(vendor_config.cookie_domain, "vault.example.com");
1176            assert!(vendor_config.cookie_value.is_none());
1177        } else {
1178            panic!("Expected SsoCookieVendor config");
1179        }
1180    }
1181
1182    #[tokio::test]
1183    #[allow(deprecated)]
1184    async fn set_communication_type_overwrites_existing_config() {
1185        let repo = MockRepository::default();
1186        let platform_api = MockPlatformApi::new();
1187
1188        // Setup existing Direct config
1189        let old_config = ServerCommunicationConfig {
1190            bootstrap: BootstrapConfig::Direct,
1191        };
1192        repo.save("vault.example.com".to_string(), old_config)
1193            .await
1194            .unwrap();
1195
1196        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1197
1198        // Overwrite with SsoCookieVendor config
1199        let request = SetCommunicationTypeRequest {
1200            bootstrap: BootstrapConfigRequest::SsoCookieVendor(SsoCookieVendorConfigRequest {
1201                idp_login_url: Some("https://new-idp.example.com/login".to_string()),
1202                cookie_name: "NewCookie".to_string(),
1203                cookie_domain: "vault.example.com".to_string(),
1204                vault_url: "https://vault.example.com".to_string(),
1205            }),
1206        };
1207
1208        client
1209            .set_communication_type("vault.example.com".to_string(), request)
1210            .await
1211            .unwrap();
1212
1213        let saved_config = repo
1214            .get("vault.example.com".to_string())
1215            .await
1216            .unwrap()
1217            .unwrap();
1218
1219        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
1220            assert_eq!(
1221                vendor_config.idp_login_url,
1222                Some("https://new-idp.example.com/login".to_string())
1223            );
1224            assert_eq!(vendor_config.cookie_name, "NewCookie");
1225        } else {
1226            panic!("Expected SsoCookieVendor config");
1227        }
1228    }
1229
1230    #[tokio::test]
1231    #[allow(deprecated)]
1232    async fn set_communication_type_preserves_per_domain_isolation() {
1233        let repo = MockRepository::default();
1234        let platform_api = MockPlatformApi::new();
1235        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1236
1237        let request1 = SetCommunicationTypeRequest {
1238            bootstrap: BootstrapConfigRequest::Direct,
1239        };
1240        client
1241            .set_communication_type("vault1.example.com".to_string(), request1)
1242            .await
1243            .unwrap();
1244
1245        let request2 = SetCommunicationTypeRequest {
1246            bootstrap: BootstrapConfigRequest::SsoCookieVendor(SsoCookieVendorConfigRequest {
1247                idp_login_url: Some("https://idp.example.com/login".to_string()),
1248                cookie_name: "TestCookie".to_string(),
1249                cookie_domain: "vault2.example.com".to_string(),
1250                vault_url: "https://vault2.example.com".to_string(),
1251            }),
1252        };
1253        client
1254            .set_communication_type("vault2.example.com".to_string(), request2)
1255            .await
1256            .unwrap();
1257
1258        let saved_config1 = repo
1259            .get("vault1.example.com".to_string())
1260            .await
1261            .unwrap()
1262            .unwrap();
1263        assert!(matches!(saved_config1.bootstrap, BootstrapConfig::Direct));
1264
1265        let saved_config2 = repo
1266            .get("vault2.example.com".to_string())
1267            .await
1268            .unwrap()
1269            .unwrap();
1270        assert!(matches!(
1271            saved_config2.bootstrap,
1272            BootstrapConfig::SsoCookieVendor(_)
1273        ));
1274    }
1275
1276    #[tokio::test]
1277    #[allow(deprecated)]
1278    async fn set_communication_type_preserves_existing_cookies() {
1279        let repo = MockRepository::default();
1280        let platform_api = MockPlatformApi::new();
1281
1282        // Setup existing config with acquired cookies
1283        let existing_config = ServerCommunicationConfig {
1284            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
1285                idp_login_url: Some("https://idp.example.com/login".to_string()),
1286                cookie_name: "SessionCookie".to_string(),
1287                cookie_domain: "example.com".to_string(),
1288                vault_url: "https://vault.example.com".to_string(),
1289                cookie_value: Some(vec![AcquiredCookie {
1290                    name: "SessionCookie".to_string(),
1291                    value: "previously-acquired-value".to_string(),
1292                }]),
1293            }),
1294        };
1295        repo.save("vault.example.com".to_string(), existing_config)
1296            .await
1297            .unwrap();
1298
1299        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1300
1301        // Re-emit the same config (as happens during auth state transitions)
1302        let request = SetCommunicationTypeRequest {
1303            bootstrap: BootstrapConfigRequest::SsoCookieVendor(SsoCookieVendorConfigRequest {
1304                idp_login_url: Some("https://idp.example.com/login".to_string()),
1305                cookie_name: "SessionCookie".to_string(),
1306                cookie_domain: "example.com".to_string(),
1307                vault_url: "https://vault.example.com".to_string(),
1308            }),
1309        };
1310
1311        client
1312            .set_communication_type("vault.example.com".to_string(), request)
1313            .await
1314            .unwrap();
1315
1316        // Verify cookies were preserved
1317        let saved_config = repo
1318            .get("vault.example.com".to_string())
1319            .await
1320            .unwrap()
1321            .unwrap();
1322
1323        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
1324            let cookies = vendor_config
1325                .cookie_value
1326                .expect("cookies should be preserved");
1327            assert_eq!(cookies.len(), 1);
1328            assert_eq!(cookies[0].name, "SessionCookie");
1329            assert_eq!(cookies[0].value, "previously-acquired-value");
1330        } else {
1331            panic!("Expected SsoCookieVendor config");
1332        }
1333    }
1334
1335    #[tokio::test]
1336    async fn set_communication_type_v2_saves_using_cookie_domain() {
1337        let repo = MockRepository::default();
1338        let platform_api = MockPlatformApi::new();
1339        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1340
1341        let request = SetCommunicationTypeRequest {
1342            bootstrap: BootstrapConfigRequest::SsoCookieVendor(SsoCookieVendorConfigRequest {
1343                idp_login_url: Some("https://idp.example.com/login".to_string()),
1344                cookie_name: "SessionCookie".to_string(),
1345                cookie_domain: "vault.example.com".to_string(),
1346                vault_url: "https://vault.example.com".to_string(),
1347            }),
1348        };
1349
1350        client.set_communication_type_v2(request).await.unwrap();
1351
1352        // Verify config was saved under the cookie_domain key
1353        let saved_config = repo
1354            .get("vault.example.com".to_string())
1355            .await
1356            .unwrap()
1357            .unwrap();
1358
1359        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved_config.bootstrap {
1360            assert_eq!(
1361                vendor_config.idp_login_url,
1362                Some("https://idp.example.com/login".to_string())
1363            );
1364            assert_eq!(vendor_config.cookie_name, "SessionCookie");
1365        } else {
1366            panic!("Expected SsoCookieVendor config");
1367        }
1368    }
1369
1370    #[tokio::test]
1371    async fn acquire_cookie_returns_unsupported_when_vault_url_empty() {
1372        let repo = MockRepository::default();
1373        let platform_api = MockPlatformApi::new();
1374
1375        // Setup config with SsoCookieVendor but vault_url is empty string
1376        let config = ServerCommunicationConfig {
1377            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
1378                idp_login_url: Some("https://example.com".to_string()),
1379                cookie_name: "TestCookie".to_string(),
1380                cookie_domain: "example.com".to_string(),
1381                vault_url: "".to_string(), // Empty vault_url
1382                cookie_value: None,
1383            }),
1384        };
1385        repo.save("vault.example.com".to_string(), config)
1386            .await
1387            .unwrap();
1388
1389        // Platform API is ready to return cookies (but shouldn't be called)
1390        platform_api
1391            .set_cookies(Some(vec![AcquiredCookie {
1392                name: "TestCookie".to_string(),
1393                value: "value".to_string(),
1394            }]))
1395            .await;
1396
1397        let client = ServerCommunicationConfigClient::new(repo, platform_api);
1398
1399        let result = client.acquire_cookie("vault.example.com").await;
1400
1401        // Should return UnsupportedConfiguration because vault_url is empty
1402        assert!(matches!(
1403            result,
1404            Err(AcquireCookieError::UnsupportedConfiguration)
1405        ));
1406    }
1407
1408    #[tokio::test]
1409    async fn set_communication_type_v2_ignores_direct_config() {
1410        let repo = MockRepository::default();
1411        let platform_api = MockPlatformApi::new();
1412        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1413
1414        let request = SetCommunicationTypeRequest {
1415            bootstrap: BootstrapConfigRequest::Direct,
1416        };
1417
1418        client.set_communication_type_v2(request).await.unwrap();
1419
1420        // Verify nothing was saved
1421        assert!(repo.storage.read().await.is_empty());
1422    }
1423
1424    #[tokio::test]
1425    async fn set_communication_type_v2_preserves_existing_cookies() {
1426        let repo = MockRepository::default();
1427        let platform_api = MockPlatformApi::new();
1428
1429        // Setup existing config with acquired cookies
1430        let existing_config = ServerCommunicationConfig {
1431            bootstrap: BootstrapConfig::SsoCookieVendor(SsoCookieVendorConfig {
1432                idp_login_url: Some("https://idp.example.com/login".to_string()),
1433                cookie_name: "SessionCookie".to_string(),
1434                cookie_domain: "vault.example.com".to_string(),
1435                vault_url: "https://vault.example.com".to_string(),
1436                cookie_value: Some(vec![AcquiredCookie {
1437                    name: "SessionCookie".to_string(),
1438                    value: "previously-acquired-value".to_string(),
1439                }]),
1440            }),
1441        };
1442        repo.save("vault.example.com".to_string(), existing_config)
1443            .await
1444            .unwrap();
1445
1446        let client = ServerCommunicationConfigClient::new(repo.clone(), platform_api);
1447
1448        // Re-save the config without cookies (as would arrive from /api/config)
1449        let new_request = SetCommunicationTypeRequest {
1450            bootstrap: BootstrapConfigRequest::SsoCookieVendor(SsoCookieVendorConfigRequest {
1451                idp_login_url: Some("https://idp.example.com/login".to_string()),
1452                cookie_name: "SessionCookie".to_string(),
1453                cookie_domain: "vault.example.com".to_string(),
1454                vault_url: "https://vault.example.com".to_string(),
1455            }),
1456        };
1457        client.set_communication_type_v2(new_request).await.unwrap();
1458
1459        // Verify cookies were preserved
1460        let saved = repo
1461            .get("vault.example.com".to_string())
1462            .await
1463            .unwrap()
1464            .unwrap();
1465        if let BootstrapConfig::SsoCookieVendor(vendor_config) = saved.bootstrap {
1466            let cookies = vendor_config
1467                .cookie_value
1468                .expect("cookies should be preserved");
1469            assert_eq!(cookies.len(), 1);
1470            assert_eq!(cookies[0].value, "previously-acquired-value");
1471        } else {
1472            panic!("Expected SsoCookieVendor config");
1473        }
1474    }
1475}