Skip to main content

briefcase_core/
control.rs

1//! # Control Plane Abstraction
2//!
3//! Defines the `ControlPlane` trait for looking up client authentication and
4//! authorization records.  Implementations include:
5//!
6//! - **`RemoteControlPlane`** — reads client records from a remote control
7//!   repository (the production source of truth).
8//! - **`LocalControlPlane`** — resolves clients from a static in-memory list
9//!   (useful for dev/test or as a fallback).
10//! - **`FallbackControlPlane`** — chains a *primary* and *secondary* control
11//!   plane: the primary is tried first and, on any error, the secondary is
12//!   consulted.  This lets you run the remote backend as the primary with
13//!   local config as the fallback.
14//!
15//! ## Client record schema
16//!
17//! Records live at `clients/by-key/{sha256(api_key)}.json` in the control
18//! repository.  The JSON schema is defined by [`ClientRecord`].
19//!
20//! ## Extending
21//!
22//! To add a new backend (e.g. PostgreSQL, Redis, Vault), implement the
23//! `ControlPlane` trait and wire it into the server's `AppState`.
24
25use 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// ── Errors ──────────────────────────────────────────────────────────────────
34
35#[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// ── Client record schema ────────────────────────────────────────────────────
54
55/// Canonical client record stored in the control plane.
56///
57/// In the remote backend this is the JSON payload at
58/// `clients/by-key/{sha256(api_key)}.json`.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ClientRecord {
61    /// Unique human-readable identifier (e.g. "acme", "internal-tools").
62    pub client_id: String,
63
64    /// Whether the client is currently active.  Inactive clients fail auth
65    /// even if the key matches.
66    #[serde(default = "default_active")]
67    pub is_active: bool,
68
69    /// Granted permission strings (e.g. `["read", "write", "replay", "delete"]`).
70    #[serde(default = "default_permissions")]
71    pub permissions: Vec<String>,
72
73    /// Optional per-client rate limit (requests per second).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub rate_limit_rps: Option<u32>,
76
77    /// Arbitrary key-value metadata.
78    #[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// ── Trait ────────────────────────────────────────────────────────────────────
96
97/// Abstraction over the source of truth for client authentication and
98/// authorization.
99///
100/// Implementors resolve an API key to a [`ClientRecord`].  The server calls
101/// this on the `POST /api/v1/auth/validate` path and, optionally, in the
102/// per-request middleware.
103#[cfg(feature = "async")]
104#[async_trait]
105pub trait ControlPlane: Send + Sync {
106    /// Look up a client by raw API key.
107    ///
108    /// Implementations MUST:
109    /// 1. Hash the key (SHA-256) before any network/storage lookup.
110    /// 2. Return `Err(NotFound)` when no record matches.
111    /// 3. Return `Err(Inactive)` when the record exists but `is_active` is
112    ///    false.
113    async fn lookup_client(&self, api_key: &str) -> Result<ClientRecord, ControlPlaneError>;
114
115    /// Quick connectivity check.  Returns `true` if the backend is reachable.
116    async fn health_check(&self) -> bool {
117        true
118    }
119
120    /// Human-readable backend name for logging (e.g. "remote", "local").
121    fn backend_name(&self) -> &str;
122}
123
124// ── Helpers ─────────────────────────────────────────────────────────────────
125
126/// SHA-256 hash of an API key, returned as a lowercase hex string.
127pub 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// ── Remote implementation ────────────────────────────────────────────────────
135
136#[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    /// Reads client records from a remote control repository.
145    ///
146    /// Records are stored at `clients/by-key/{sha256(api_key)}.json` and
147    /// must conform to the [`ClientRecord`] JSON schema.
148    pub struct RemoteControlPlane {
149        backend: LakeFSBackend,
150    }
151
152    impl RemoteControlPlane {
153        /// Create a new remote control plane.
154        ///
155        /// The `config` should point at the `_briefcase_control` repository
156        /// (or whichever repo holds client records).
157        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        /// Build the object path for a given key hash.
165        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
212// ── Local (in-memory) implementation ────────────────────────────────────────
213
214/// Resolves clients from a static list — typically parsed from env vars.
215///
216/// This is the simplest control plane: no network calls, no hashing lookup.
217/// It uses constant-time comparison for security.
218pub struct LocalControlPlane {
219    clients: Vec<LocalClient>,
220}
221
222/// A client entry for local (in-memory) lookup.
223#[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    /// Create from a list of client entries.
233    pub fn new(clients: Vec<LocalClient>) -> Self {
234        Self { clients }
235    }
236
237    /// Constant-time string comparison.
238    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// ── Fallback (chained) implementation ───────────────────────────────────────
274
275/// Chains a primary and secondary control plane.
276///
277/// On `lookup_client`, the primary is tried first.  If it returns `NotFound`
278/// or `Unreachable`, the secondary is consulted.  Errors like `Inactive` or
279/// `MalformedRecord` from the primary are NOT retried — those indicate the
280/// record *was* found but is invalid.
281#[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            // Record found but inactive — don't retry, this is authoritative
301            Err(ControlPlaneError::Inactive) => Err(ControlPlaneError::Inactive),
302            // Record found but malformed — don't retry
303            Err(ControlPlaneError::MalformedRecord(msg)) => {
304                Err(ControlPlaneError::MalformedRecord(msg))
305            }
306            // Not found or unreachable — try secondary
307            Err(_primary_err) => self.secondary.lookup_client(api_key).await,
308        }
309    }
310
311    async fn health_check(&self) -> bool {
312        // Healthy if either backend is reachable
313        self.primary.health_check().await || self.secondary.health_check().await
314    }
315
316    fn backend_name(&self) -> &str {
317        "fallback"
318    }
319}
320
321// ── Tests ───────────────────────────────────────────────────────────────────
322
323#[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); // SHA-256 = 32 bytes = 64 hex chars
345        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
346    }
347
348    #[test]
349    fn test_hash_api_key_known_value() {
350        // SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
351        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); // default
364        assert_eq!(record.permissions.len(), 4); // defaults
365        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    // --- LocalControlPlane tests ---
423
424    #[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        // Substring should NOT match
490        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    // --- FallbackControlPlane tests ---
508
509    /// A mock control plane that always returns a fixed record.
510    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                // Recreate the error since Error doesn't impl Clone
547                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    // --- RemoteControlPlane tests (wiremock) ---
641
642    #[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            // Connection/server errors map to Unreachable
777            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            // Only client_id — all other fields use defaults
790            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); // defaults
805            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            // Verify that different keys produce different lookup paths
849            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}