1use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use std::collections::HashMap;
28use thiserror::Error;
29
30#[cfg(feature = "async")]
31use async_trait::async_trait;
32
33#[derive(Error, Debug)]
36pub enum ControlPlaneError {
37 #[error("Client not found for the provided API key")]
38 NotFound,
39
40 #[error("Client record is inactive (disabled)")]
41 Inactive,
42
43 #[error("Backend unreachable: {0}")]
44 Unreachable(String),
45
46 #[error("Malformed client record: {0}")]
47 MalformedRecord(String),
48
49 #[error("Internal error: {0}")]
50 Internal(String),
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ClientRecord {
61 pub client_id: String,
63
64 #[serde(default = "default_active")]
67 pub is_active: bool,
68
69 #[serde(default = "default_permissions")]
71 pub permissions: Vec<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub rate_limit_rps: Option<u32>,
76
77 #[serde(default)]
79 pub metadata: HashMap<String, String>,
80}
81
82fn default_active() -> bool {
83 true
84}
85
86fn default_permissions() -> Vec<String> {
87 vec![
88 "read".into(),
89 "write".into(),
90 "replay".into(),
91 "delete".into(),
92 ]
93}
94
95#[cfg(feature = "async")]
104#[async_trait]
105pub trait ControlPlane: Send + Sync {
106 async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError>;
114
115 async fn health_check(&self) -> bool {
117 true
118 }
119
120 fn backend_name(&self) -> &str;
122}
123
124pub fn hash_api_key(api_key: &str) -> String {
128 let mut hasher = Sha256::new();
129 hasher.update(api_key.as_bytes());
130 let result = hasher.finalize();
131 result.iter().map(|b| format!("{:02x}", b)).collect()
132}
133
134#[cfg(all(feature = "async", feature = "networking"))]
137pub mod remote_control {
138 use super::*;
139 use crate::storage::{
140 lakefs::{LakeFSBackend, LakeFSConfig},
141 StorageBackend,
142 };
143
144 pub struct RemoteControlPlane {
149 backend: LakeFSBackend,
150 }
151
152 impl RemoteControlPlane {
153 pub fn new(config: LakeFSConfig) -> Result<Self, ControlPlaneError> {
158 let backend = LakeFSBackend::new(config).map_err(|e| {
159 ControlPlaneError::Internal(format!("Failed to create remote backend: {}", e))
160 })?;
161 Ok(Self { backend })
162 }
163
164 pub fn client_path(key_hash: &str) -> String {
166 format!("clients/by-key/{}.json", key_hash)
167 }
168 }
169
170 #[async_trait]
171 impl ControlPlane for RemoteControlPlane {
172 async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError> {
173 let key_hash = hash_api_key(api_key);
174 let path = Self::client_path(&key_hash);
175
176 let data = self
177 .backend
178 .download_object(&path)
179 .await
180 .map_err(|e| match e {
181 crate::storage::StorageError::NotFound(_) => ControlPlaneError::NotFound,
182 crate::storage::StorageError::ConnectionError(msg) => {
183 ControlPlaneError::Unreachable(msg)
184 }
185 other => ControlPlaneError::Internal(other.to_string()),
186 })?;
187
188 let record: ClientRecord = serde_json::from_slice(&data).map_err(|e| {
189 ControlPlaneError::MalformedRecord(format!(
190 "Failed to parse client record at {}: {}",
191 path, e
192 ))
193 })?;
194
195 if !record.is_active {
196 return Err(ControlPlaneError::Inactive);
197 }
198
199 Ok(record)
200 }
201
202 async fn health_check(&self) -> bool {
203 (self.backend.health_check().await).unwrap_or_default()
204 }
205
206 fn backend_name(&self) -> &str {
207 "remote"
208 }
209 }
210}
211
212pub struct LocalControlPlane {
219 clients: Vec<LocalClient>,
220}
221
222#[derive(Clone, Debug)]
224pub struct LocalClient {
225 pub client_id: String,
226 pub api_key: String,
227 pub permissions: Vec<String>,
228 pub rate_limit_rps: Option<u32>,
229}
230
231impl LocalControlPlane {
232 pub fn new(clients: Vec<LocalClient>) -> Self {
234 Self { clients }
235 }
236
237 fn constant_time_eq(a: &str, b: &str) -> bool {
239 if a.len() != b.len() {
240 return false;
241 }
242 a.as_bytes()
243 .iter()
244 .zip(b.as_bytes().iter())
245 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
246 == 0
247 }
248}
249
250#[cfg(feature = "async")]
251#[async_trait]
252impl ControlPlane for LocalControlPlane {
253 async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError> {
254 for client in &self.clients {
255 if Self::constant_time_eq(&client.api_key, api_key) {
256 return Ok(ClientRecord {
257 client_id: client.client_id.clone(),
258 is_active: true,
259 permissions: client.permissions.clone(),
260 rate_limit_rps: client.rate_limit_rps,
261 metadata: HashMap::new(),
262 });
263 }
264 }
265 Err(ControlPlaneError::NotFound)
266 }
267
268 fn backend_name(&self) -> &str {
269 "local"
270 }
271}
272
273#[cfg(feature = "async")]
282pub struct FallbackControlPlane {
283 primary: Box<dyn ControlPlane>,
284 secondary: Box<dyn ControlPlane>,
285}
286
287#[cfg(feature = "async")]
288impl FallbackControlPlane {
289 pub fn new(primary: Box<dyn ControlPlane>, secondary: Box<dyn ControlPlane>) -> Self {
290 Self { primary, secondary }
291 }
292}
293
294#[cfg(feature = "async")]
295#[async_trait]
296impl ControlPlane for FallbackControlPlane {
297 async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError> {
298 match self.primary.lookup_client(api_key).await {
299 Ok(record) => Ok(record),
300 Err(ControlPlaneError::Inactive) => Err(ControlPlaneError::Inactive),
302 Err(ControlPlaneError::MalformedRecord(msg)) => {
304 Err(ControlPlaneError::MalformedRecord(msg))
305 }
306 Err(_primary_err) => self.secondary.lookup_client(api_key).await,
308 }
309 }
310
311 async fn health_check(&self) -> bool {
312 self.primary.health_check().await || self.secondary.health_check().await
314 }
315
316 fn backend_name(&self) -> &str {
317 "fallback"
318 }
319}
320
321#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_hash_api_key_deterministic() {
329 let h1 = hash_api_key("sk-test-key-123");
330 let h2 = hash_api_key("sk-test-key-123");
331 assert_eq!(h1, h2);
332 }
333
334 #[test]
335 fn test_hash_api_key_different_keys_different_hashes() {
336 let h1 = hash_api_key("sk-key-a");
337 let h2 = hash_api_key("sk-key-b");
338 assert_ne!(h1, h2);
339 }
340
341 #[test]
342 fn test_hash_api_key_is_hex() {
343 let h = hash_api_key("sk-test");
344 assert_eq!(h.len(), 64); assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
346 }
347
348 #[test]
349 fn test_hash_api_key_known_value() {
350 let h = hash_api_key("hello");
352 assert_eq!(
353 h,
354 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
355 );
356 }
357
358 #[test]
359 fn test_client_record_deserialize_minimal() {
360 let json = r#"{"client_id": "acme"}"#;
361 let record: ClientRecord = serde_json::from_str(json).unwrap();
362 assert_eq!(record.client_id, "acme");
363 assert!(record.is_active); assert_eq!(record.permissions.len(), 4); assert!(record.rate_limit_rps.is_none());
366 assert!(record.metadata.is_empty());
367 }
368
369 #[test]
370 fn test_client_record_deserialize_full() {
371 let json = r#"{
372 "client_id": "acme",
373 "is_active": false,
374 "permissions": ["read"],
375 "rate_limit_rps": 50,
376 "metadata": {"tier": "enterprise"}
377 }"#;
378 let record: ClientRecord = serde_json::from_str(json).unwrap();
379 assert_eq!(record.client_id, "acme");
380 assert!(!record.is_active);
381 assert_eq!(record.permissions, vec!["read"]);
382 assert_eq!(record.rate_limit_rps, Some(50));
383 assert_eq!(record.metadata.get("tier"), Some(&"enterprise".to_string()));
384 }
385
386 #[test]
387 fn test_client_record_serialize_roundtrip() {
388 let record = ClientRecord {
389 client_id: "test".into(),
390 is_active: true,
391 permissions: vec!["read".into(), "write".into()],
392 rate_limit_rps: Some(100),
393 metadata: HashMap::new(),
394 };
395 let json = serde_json::to_string(&record).unwrap();
396 let back: ClientRecord = serde_json::from_str(&json).unwrap();
397 assert_eq!(back.client_id, "test");
398 assert_eq!(back.permissions, vec!["read", "write"]);
399 assert_eq!(back.rate_limit_rps, Some(100));
400 }
401
402 #[test]
403 fn test_client_record_rate_limit_omitted_in_json() {
404 let record = ClientRecord {
405 client_id: "test".into(),
406 is_active: true,
407 permissions: vec![],
408 rate_limit_rps: None,
409 metadata: HashMap::new(),
410 };
411 let json = serde_json::to_string(&record).unwrap();
412 assert!(!json.contains("rate_limit_rps"));
413 }
414
415 #[test]
416 fn test_client_record_missing_client_id_fails() {
417 let json = r#"{"permissions": ["read"]}"#;
418 let result: Result<ClientRecord, _> = serde_json::from_str(json);
419 assert!(result.is_err());
420 }
421
422 #[tokio::test]
425 async fn test_local_lookup_found() {
426 let cp = LocalControlPlane::new(vec![LocalClient {
427 client_id: "acme".into(),
428 api_key: "sk-acme-123".into(),
429 permissions: vec!["read".into(), "write".into()],
430 rate_limit_rps: Some(100),
431 }]);
432 let record = cp.lookup_client("sk-acme-123").await.unwrap();
433 assert_eq!(record.client_id, "acme");
434 assert!(record.is_active);
435 assert_eq!(record.permissions, vec!["read", "write"]);
436 assert_eq!(record.rate_limit_rps, Some(100));
437 }
438
439 #[tokio::test]
440 async fn test_local_lookup_not_found() {
441 let cp = LocalControlPlane::new(vec![LocalClient {
442 client_id: "acme".into(),
443 api_key: "sk-acme-123".into(),
444 permissions: vec![],
445 rate_limit_rps: None,
446 }]);
447 let result = cp.lookup_client("sk-wrong").await;
448 assert!(matches!(result, Err(ControlPlaneError::NotFound)));
449 }
450
451 #[tokio::test]
452 async fn test_local_lookup_empty_list() {
453 let cp = LocalControlPlane::new(vec![]);
454 let result = cp.lookup_client("sk-anything").await;
455 assert!(matches!(result, Err(ControlPlaneError::NotFound)));
456 }
457
458 #[tokio::test]
459 async fn test_local_lookup_multiple_clients() {
460 let cp = LocalControlPlane::new(vec![
461 LocalClient {
462 client_id: "acme".into(),
463 api_key: "sk-acme".into(),
464 permissions: vec!["read".into()],
465 rate_limit_rps: None,
466 },
467 LocalClient {
468 client_id: "beta".into(),
469 api_key: "sk-beta".into(),
470 permissions: vec!["write".into()],
471 rate_limit_rps: None,
472 },
473 ]);
474 let r1 = cp.lookup_client("sk-acme").await.unwrap();
475 assert_eq!(r1.client_id, "acme");
476
477 let r2 = cp.lookup_client("sk-beta").await.unwrap();
478 assert_eq!(r2.client_id, "beta");
479 }
480
481 #[tokio::test]
482 async fn test_local_constant_time_prevents_substring_match() {
483 let cp = LocalControlPlane::new(vec![LocalClient {
484 client_id: "acme".into(),
485 api_key: "sk-acme-123".into(),
486 permissions: vec![],
487 rate_limit_rps: None,
488 }]);
489 assert!(cp.lookup_client("sk-acme").await.is_err());
491 assert!(cp.lookup_client("sk-acme-1234").await.is_err());
492 assert!(cp.lookup_client("sk-acme-12").await.is_err());
493 }
494
495 #[tokio::test]
496 async fn test_local_backend_name() {
497 let cp = LocalControlPlane::new(vec![]);
498 assert_eq!(cp.backend_name(), "local");
499 }
500
501 #[tokio::test]
502 async fn test_local_health_check() {
503 let cp = LocalControlPlane::new(vec![]);
504 assert!(cp.health_check().await);
505 }
506
507 struct MockControlPlane {
511 name: &'static str,
512 record: Option<ClientRecord>,
513 error: Option<ControlPlaneError>,
514 }
515
516 impl MockControlPlane {
517 fn succeeding(name: &'static str, client_id: &str) -> Self {
518 Self {
519 name,
520 record: Some(ClientRecord {
521 client_id: client_id.into(),
522 is_active: true,
523 permissions: vec!["read".into()],
524 rate_limit_rps: None,
525 metadata: HashMap::new(),
526 }),
527 error: None,
528 }
529 }
530
531 fn failing(name: &'static str, error: ControlPlaneError) -> Self {
532 Self {
533 name,
534 record: None,
535 error: Some(error),
536 }
537 }
538 }
539
540 #[async_trait]
541 impl ControlPlane for MockControlPlane {
542 async fn lookup_client(&self, _api_key: &str) -> Result<ClientRecord, ControlPlaneError> {
543 if let Some(ref record) = self.record {
544 Ok(record.clone())
545 } else if let Some(ref err) = self.error {
546 match err {
548 ControlPlaneError::NotFound => Err(ControlPlaneError::NotFound),
549 ControlPlaneError::Inactive => Err(ControlPlaneError::Inactive),
550 ControlPlaneError::Unreachable(m) => {
551 Err(ControlPlaneError::Unreachable(m.clone()))
552 }
553 ControlPlaneError::MalformedRecord(m) => {
554 Err(ControlPlaneError::MalformedRecord(m.clone()))
555 }
556 ControlPlaneError::Internal(m) => Err(ControlPlaneError::Internal(m.clone())),
557 }
558 } else {
559 Err(ControlPlaneError::NotFound)
560 }
561 }
562
563 fn backend_name(&self) -> &str {
564 self.name
565 }
566 }
567
568 #[tokio::test]
569 async fn test_fallback_primary_succeeds() {
570 let primary = MockControlPlane::succeeding("primary", "from-primary");
571 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
572 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
573
574 let record = cp.lookup_client("any-key").await.unwrap();
575 assert_eq!(record.client_id, "from-primary");
576 }
577
578 #[tokio::test]
579 async fn test_fallback_primary_not_found_falls_through() {
580 let primary = MockControlPlane::failing("primary", ControlPlaneError::NotFound);
581 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
582 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
583
584 let record = cp.lookup_client("any-key").await.unwrap();
585 assert_eq!(record.client_id, "from-secondary");
586 }
587
588 #[tokio::test]
589 async fn test_fallback_primary_unreachable_falls_through() {
590 let primary =
591 MockControlPlane::failing("primary", ControlPlaneError::Unreachable("timeout".into()));
592 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
593 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
594
595 let record = cp.lookup_client("any-key").await.unwrap();
596 assert_eq!(record.client_id, "from-secondary");
597 }
598
599 #[tokio::test]
600 async fn test_fallback_primary_inactive_does_not_fall_through() {
601 let primary = MockControlPlane::failing("primary", ControlPlaneError::Inactive);
602 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
603 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
604
605 let result = cp.lookup_client("any-key").await;
606 assert!(matches!(result, Err(ControlPlaneError::Inactive)));
607 }
608
609 #[tokio::test]
610 async fn test_fallback_primary_malformed_does_not_fall_through() {
611 let primary = MockControlPlane::failing(
612 "primary",
613 ControlPlaneError::MalformedRecord("bad json".into()),
614 );
615 let secondary = MockControlPlane::succeeding("secondary", "from-secondary");
616 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
617
618 let result = cp.lookup_client("any-key").await;
619 assert!(matches!(result, Err(ControlPlaneError::MalformedRecord(_))));
620 }
621
622 #[tokio::test]
623 async fn test_fallback_both_not_found() {
624 let primary = MockControlPlane::failing("primary", ControlPlaneError::NotFound);
625 let secondary = MockControlPlane::failing("secondary", ControlPlaneError::NotFound);
626 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
627
628 let result = cp.lookup_client("any-key").await;
629 assert!(matches!(result, Err(ControlPlaneError::NotFound)));
630 }
631
632 #[tokio::test]
633 async fn test_fallback_backend_name() {
634 let primary = MockControlPlane::succeeding("primary", "x");
635 let secondary = MockControlPlane::succeeding("secondary", "y");
636 let cp = FallbackControlPlane::new(Box::new(primary), Box::new(secondary));
637 assert_eq!(cp.backend_name(), "fallback");
638 }
639
640 #[cfg(all(feature = "async", feature = "networking"))]
643 mod remote_tests {
644 use super::super::remote_control::RemoteControlPlane;
645 use super::*;
646 use crate::storage::lakefs::LakeFSConfig;
647 use wiremock::matchers::{method, path_regex, query_param};
648 use wiremock::{Mock, MockServer, ResponseTemplate};
649
650 fn control_config(endpoint: &str) -> LakeFSConfig {
651 LakeFSConfig::new(
652 endpoint,
653 "_briefcase_control",
654 "main",
655 "test_key",
656 "test_secret",
657 )
658 }
659
660 fn sample_record() -> serde_json::Value {
661 serde_json::json!({
662 "client_id": "acme-corp",
663 "is_active": true,
664 "permissions": ["read", "write", "replay"],
665 "rate_limit_rps": 200,
666 "metadata": {"tier": "enterprise", "region": "us-east-1"}
667 })
668 }
669
670 #[tokio::test]
671 async fn test_remote_lookup_success() {
672 let mock_server = MockServer::start().await;
673 let config = control_config(&mock_server.uri());
674 let cp = RemoteControlPlane::new(config).unwrap();
675
676 let key_hash = hash_api_key("sk-acme-secret");
677 let expected_path = format!("clients/by-key/{}.json", key_hash);
678
679 Mock::given(method("GET"))
680 .and(path_regex(
681 "/api/v1/repositories/_briefcase_control/refs/main/objects",
682 ))
683 .and(query_param("path", &expected_path))
684 .respond_with(ResponseTemplate::new(200).set_body_json(sample_record()))
685 .expect(1)
686 .mount(&mock_server)
687 .await;
688
689 let record = cp.lookup_client("sk-acme-secret").await.unwrap();
690 assert_eq!(record.client_id, "acme-corp");
691 assert!(record.is_active);
692 assert_eq!(record.permissions, vec!["read", "write", "replay"]);
693 assert_eq!(record.rate_limit_rps, Some(200));
694 assert_eq!(record.metadata.get("tier"), Some(&"enterprise".to_string()));
695 }
696
697 #[tokio::test]
698 async fn test_remote_lookup_not_found() {
699 let mock_server = MockServer::start().await;
700 let config = control_config(&mock_server.uri());
701 let cp = RemoteControlPlane::new(config).unwrap();
702
703 Mock::given(method("GET"))
704 .and(path_regex(
705 "/api/v1/repositories/_briefcase_control/refs/main/objects",
706 ))
707 .respond_with(ResponseTemplate::new(404))
708 .expect(1)
709 .mount(&mock_server)
710 .await;
711
712 let result = cp.lookup_client("sk-unknown").await;
713 assert!(matches!(result, Err(ControlPlaneError::NotFound)));
714 }
715
716 #[tokio::test]
717 async fn test_remote_lookup_inactive_client() {
718 let mock_server = MockServer::start().await;
719 let config = control_config(&mock_server.uri());
720 let cp = RemoteControlPlane::new(config).unwrap();
721
722 let inactive_record = serde_json::json!({
723 "client_id": "disabled-client",
724 "is_active": false,
725 "permissions": ["read"]
726 });
727
728 Mock::given(method("GET"))
729 .and(path_regex(
730 "/api/v1/repositories/_briefcase_control/refs/main/objects",
731 ))
732 .respond_with(ResponseTemplate::new(200).set_body_json(inactive_record))
733 .expect(1)
734 .mount(&mock_server)
735 .await;
736
737 let result = cp.lookup_client("sk-disabled").await;
738 assert!(matches!(result, Err(ControlPlaneError::Inactive)));
739 }
740
741 #[tokio::test]
742 async fn test_remote_lookup_malformed_json() {
743 let mock_server = MockServer::start().await;
744 let config = control_config(&mock_server.uri());
745 let cp = RemoteControlPlane::new(config).unwrap();
746
747 Mock::given(method("GET"))
748 .and(path_regex(
749 "/api/v1/repositories/_briefcase_control/refs/main/objects",
750 ))
751 .respond_with(ResponseTemplate::new(200).set_body_string("not valid json{{{"))
752 .expect(1)
753 .mount(&mock_server)
754 .await;
755
756 let result = cp.lookup_client("sk-bad-record").await;
757 assert!(matches!(result, Err(ControlPlaneError::MalformedRecord(_))));
758 }
759
760 #[tokio::test]
761 async fn test_remote_lookup_server_error() {
762 let mock_server = MockServer::start().await;
763 let config = control_config(&mock_server.uri());
764 let cp = RemoteControlPlane::new(config).unwrap();
765
766 Mock::given(method("GET"))
767 .and(path_regex(
768 "/api/v1/repositories/_briefcase_control/refs/main/objects",
769 ))
770 .respond_with(ResponseTemplate::new(500).set_body_string("internal error"))
771 .expect(1)
772 .mount(&mock_server)
773 .await;
774
775 let result = cp.lookup_client("sk-server-error").await;
776 assert!(matches!(
778 result,
779 Err(ControlPlaneError::Unreachable(_)) | Err(ControlPlaneError::Internal(_))
780 ));
781 }
782
783 #[tokio::test]
784 async fn test_remote_lookup_minimal_record() {
785 let mock_server = MockServer::start().await;
786 let config = control_config(&mock_server.uri());
787 let cp = RemoteControlPlane::new(config).unwrap();
788
789 let minimal = serde_json::json!({"client_id": "minimal-client"});
791
792 Mock::given(method("GET"))
793 .and(path_regex(
794 "/api/v1/repositories/_briefcase_control/refs/main/objects",
795 ))
796 .respond_with(ResponseTemplate::new(200).set_body_json(minimal))
797 .expect(1)
798 .mount(&mock_server)
799 .await;
800
801 let record = cp.lookup_client("sk-minimal").await.unwrap();
802 assert_eq!(record.client_id, "minimal-client");
803 assert!(record.is_active);
804 assert_eq!(record.permissions.len(), 4); assert!(record.rate_limit_rps.is_none());
806 }
807
808 #[tokio::test]
809 async fn test_remote_health_check_healthy() {
810 let mock_server = MockServer::start().await;
811 let config = control_config(&mock_server.uri());
812 let cp = RemoteControlPlane::new(config).unwrap();
813
814 Mock::given(method("GET"))
815 .and(path_regex("/api/v1/repositories/_briefcase_control"))
816 .respond_with(ResponseTemplate::new(200))
817 .mount(&mock_server)
818 .await;
819
820 assert!(cp.health_check().await);
821 }
822
823 #[tokio::test]
824 async fn test_remote_health_check_unhealthy() {
825 let mock_server = MockServer::start().await;
826 let config = control_config(&mock_server.uri());
827 let cp = RemoteControlPlane::new(config).unwrap();
828
829 Mock::given(method("GET"))
830 .and(path_regex("/api/v1/repositories/_briefcase_control"))
831 .respond_with(ResponseTemplate::new(503))
832 .mount(&mock_server)
833 .await;
834
835 assert!(!cp.health_check().await);
836 }
837
838 #[tokio::test]
839 async fn test_remote_backend_name() {
840 let mock_server = MockServer::start().await;
841 let config = control_config(&mock_server.uri());
842 let cp = RemoteControlPlane::new(config).unwrap();
843 assert_eq!(cp.backend_name(), "remote");
844 }
845
846 #[tokio::test]
847 async fn test_remote_key_hash_determines_path() {
848 let h1 = hash_api_key("sk-key-alpha");
850 let h2 = hash_api_key("sk-key-beta");
851 assert_ne!(h1, h2);
852
853 let p1 = RemoteControlPlane::client_path(&h1);
854 let p2 = RemoteControlPlane::client_path(&h2);
855 assert_ne!(p1, p2);
856 assert!(p1.starts_with("clients/by-key/"));
857 assert!(p1.ends_with(".json"));
858 }
859 }
860}