Skip to main content

briefcase_core/
client.rs

1//! Unified BriefcaseClient for authenticated access to the Briefcase AI platform.
2//!
3//! The `BriefcaseClient` validates an API key against the Briefcase server,
4//! caches the result, and provides permission-gated access to storage and
5//! other SDK features.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use briefcase_core::client::BriefcaseClient;
11//!
12//! # #[tokio::main]
13//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
14//! let client = BriefcaseClient::new("sk-my-key", "https://api.briefcasebrain.com").await?;
15//! println!("Authenticated as: {}", client.client_id());
16//! assert!(client.has_permission("read"));
17//! # Ok(())
18//! # }
19//! ```
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant};
26use thiserror::Error;
27
28#[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
29use crate::models::{DecisionSnapshot, Snapshot};
30#[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
31use crate::storage::{SnapshotQuery, StorageBackend, StorageError};
32
33// ── Data Structures ──────────────────────────────────────────────────────────
34
35/// Information about a validated client returned by the server.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ValidatedClient {
38    pub client_id: String,
39    pub permissions: Vec<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub rate_limit_rps: Option<u32>,
42    #[serde(default)]
43    pub metadata: HashMap<String, String>,
44}
45
46/// Full response from the `POST /api/v1/auth/validate` endpoint.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct AuthResponse {
49    pub valid: bool,
50    pub client: ValidatedClient,
51    pub expires_at: DateTime<Utc>,
52}
53
54/// Configuration for the client's HTTP behaviour and cache policy.
55#[derive(Debug, Clone)]
56pub struct ClientConfig {
57    /// HTTP request timeout in seconds (default: 30).
58    pub timeout_secs: u64,
59    /// How long to cache a successful validation (default: 3600 = 1 hour).
60    pub cache_ttl_secs: u64,
61    /// Maximum number of HTTP retries on transient errors (default: 3).
62    pub max_retries: u32,
63}
64
65impl Default for ClientConfig {
66    fn default() -> Self {
67        Self {
68            timeout_secs: 30,
69            cache_ttl_secs: 3600,
70            max_retries: 3,
71        }
72    }
73}
74
75/// Errors that can occur during client operations.
76#[derive(Error, Debug)]
77pub enum ClientError {
78    #[error("Authentication failed: {0}")]
79    AuthFailed(String),
80
81    #[error("Server unreachable: {0}")]
82    ServerUnreachable(String),
83
84    #[error("Permission denied: requires '{0}'")]
85    PermissionDenied(String),
86
87    #[error("Validation expired")]
88    Expired,
89
90    #[error("No storage backend bound")]
91    NoStorage,
92
93    #[error("Invalid argument: {0}")]
94    InvalidArgument(String),
95
96    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
97    #[error("Storage error: {0}")]
98    Storage(#[from] StorageError),
99}
100
101// ── BriefcaseClient ──────────────────────────────────────────────────────────
102
103/// Cached validation entry.
104struct CacheEntry {
105    client: ValidatedClient,
106    cached_at: Instant,
107}
108
109/// Authenticated client for the Briefcase AI platform.
110///
111/// Created via [`BriefcaseClient::new`] or [`BriefcaseClient::with_config`],
112/// which validate the API key against the server before returning.
113pub struct BriefcaseClient {
114    validated: ValidatedClient,
115    server_url: String,
116    api_key: String,
117    http: reqwest::Client,
118    cache: Arc<Mutex<Option<CacheEntry>>>,
119    cache_ttl: Duration,
120    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
121    storage: Option<Arc<dyn StorageBackend>>,
122}
123
124impl std::fmt::Debug for BriefcaseClient {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("BriefcaseClient")
127            .field("validated", &self.validated)
128            .field("server_url", &self.server_url)
129            .field("api_key", &"[REDACTED]")
130            .field("cache_ttl", &self.cache_ttl)
131            .finish()
132    }
133}
134
135impl BriefcaseClient {
136    /// Create a new client, validating the API key against the server.
137    ///
138    /// Uses default configuration (30s timeout, 1h cache TTL, 3 retries).
139    pub async fn new(api_key: &str, server_url: &str) -> Result<Self, ClientError> {
140        Self::with_config(api_key, server_url, ClientConfig::default()).await
141    }
142
143    /// Create a new client with custom configuration.
144    pub async fn with_config(
145        api_key: &str,
146        server_url: &str,
147        config: ClientConfig,
148    ) -> Result<Self, ClientError> {
149        // Validate inputs
150        if api_key.trim().is_empty() {
151            return Err(ClientError::InvalidArgument(
152                "API key must not be empty".into(),
153            ));
154        }
155        if server_url.trim().is_empty() {
156            return Err(ClientError::InvalidArgument(
157                "Server URL must not be empty".into(),
158            ));
159        }
160
161        let http = reqwest::Client::builder()
162            .timeout(Duration::from_secs(config.timeout_secs))
163            .build()
164            .map_err(|e| ClientError::ServerUnreachable(e.to_string()))?;
165
166        let url = format!("{}/api/v1/auth/validate", server_url.trim_end_matches('/'));
167
168        let auth_response = Self::do_validate(&http, &url, api_key, config.max_retries).await?;
169
170        let validated = auth_response.client;
171        let cache_ttl = Duration::from_secs(config.cache_ttl_secs);
172
173        let cache = Arc::new(Mutex::new(Some(CacheEntry {
174            client: validated.clone(),
175            cached_at: Instant::now(),
176        })));
177
178        Ok(Self {
179            validated,
180            server_url: server_url.trim_end_matches('/').to_string(),
181            api_key: api_key.to_string(),
182            http,
183            cache,
184            cache_ttl,
185            #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
186            storage: None,
187        })
188    }
189
190    /// The authenticated client ID.
191    pub fn client_id(&self) -> &str {
192        &self.validated.client_id
193    }
194
195    /// Granted permissions.
196    pub fn permissions(&self) -> &[String] {
197        &self.validated.permissions
198    }
199
200    /// Check whether this client has a specific permission.
201    pub fn has_permission(&self, perm: &str) -> bool {
202        self.validated.permissions.iter().any(|p| p == perm)
203    }
204
205    /// Re-validate the API key, using the cache if still fresh.
206    pub async fn revalidate(&self) -> Result<ValidatedClient, ClientError> {
207        // Check cache
208        {
209            let guard = self.cache.lock().unwrap();
210            if let Some(entry) = guard.as_ref() {
211                if entry.cached_at.elapsed() < self.cache_ttl {
212                    return Ok(entry.client.clone());
213                }
214            }
215        }
216
217        // Cache miss or expired — call server
218        let url = format!("{}/api/v1/auth/validate", self.server_url);
219        let auth = Self::do_validate(&self.http, &url, &self.api_key, 3).await?;
220
221        // Update cache
222        {
223            let mut guard = self.cache.lock().unwrap();
224            *guard = Some(CacheEntry {
225                client: auth.client.clone(),
226                cached_at: Instant::now(),
227            });
228        }
229
230        Ok(auth.client)
231    }
232
233    /// Explicitly invalidate the validation cache.
234    pub fn invalidate_cache(&self) {
235        let mut guard = self.cache.lock().unwrap();
236        *guard = None;
237    }
238
239    /// Bind a storage backend for delegated operations.
240    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
241    pub fn with_storage(mut self, storage: Arc<dyn StorageBackend>) -> Self {
242        self.storage = Some(storage);
243        self
244    }
245
246    // ── Delegated storage operations ─────────────────────────────────────
247
248    /// Save a decision (requires "write" permission and a bound storage backend).
249    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
250    pub async fn save_decision(&self, decision: &DecisionSnapshot) -> Result<String, ClientError> {
251        self.require_permission("write")?;
252        let storage = self.require_storage()?;
253        storage
254            .save_decision(decision)
255            .await
256            .map_err(ClientError::from)
257    }
258
259    /// Load a decision by ID (requires "read" permission and a bound storage backend).
260    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
261    pub async fn load_decision(&self, decision_id: &str) -> Result<DecisionSnapshot, ClientError> {
262        self.require_permission("read")?;
263        let storage = self.require_storage()?;
264        storage
265            .load_decision(decision_id)
266            .await
267            .map_err(ClientError::from)
268    }
269
270    /// Query snapshots (requires "read" permission and a bound storage backend).
271    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
272    pub async fn query(&self, query: SnapshotQuery) -> Result<Vec<Snapshot>, ClientError> {
273        self.require_permission("read")?;
274        let storage = self.require_storage()?;
275        storage.query(query).await.map_err(ClientError::from)
276    }
277
278    /// Delete a snapshot (requires "delete" permission and a bound storage backend).
279    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
280    pub async fn delete(&self, id: &str) -> Result<bool, ClientError> {
281        self.require_permission("delete")?;
282        let storage = self.require_storage()?;
283        storage.delete(id).await.map_err(ClientError::from)
284    }
285
286    // ── Internal helpers ─────────────────────────────────────────────────
287
288    fn require_permission(&self, perm: &str) -> Result<(), ClientError> {
289        if self.has_permission(perm) {
290            Ok(())
291        } else {
292            Err(ClientError::PermissionDenied(perm.to_string()))
293        }
294    }
295
296    #[cfg(any(feature = "sqlite-storage", feature = "lakefs-storage"))]
297    fn require_storage(&self) -> Result<&Arc<dyn StorageBackend>, ClientError> {
298        self.storage.as_ref().ok_or(ClientError::NoStorage)
299    }
300
301    async fn do_validate(
302        http: &reqwest::Client,
303        url: &str,
304        api_key: &str,
305        max_retries: u32,
306    ) -> Result<AuthResponse, ClientError> {
307        let body = serde_json::json!({ "api_key": api_key });
308        let mut last_err = None;
309
310        for attempt in 0..=max_retries {
311            if attempt > 0 {
312                // Exponential backoff: 100ms, 200ms, 400ms, …
313                let backoff = Duration::from_millis(100 * (1 << (attempt - 1)));
314                tokio::time::sleep(backoff).await;
315            }
316
317            let result = http.post(url).json(&body).send().await;
318
319            match result {
320                Ok(resp) => {
321                    let status = resp.status();
322                    if status.is_success() {
323                        let auth: AuthResponse = resp.json().await.map_err(|e| {
324                            ClientError::ServerUnreachable(format!("Invalid response body: {}", e))
325                        })?;
326                        if !auth.valid {
327                            return Err(ClientError::AuthFailed(
328                                "Server returned valid=false".into(),
329                            ));
330                        }
331                        return Ok(auth);
332                    } else if status == reqwest::StatusCode::UNAUTHORIZED {
333                        // 401 — bad key, don't retry
334                        let text = resp.text().await.unwrap_or_default();
335                        return Err(ClientError::AuthFailed(format!(
336                            "Invalid API key (401): {}",
337                            text
338                        )));
339                    } else if status.is_server_error() {
340                        // 5xx — transient, retry
341                        last_err = Some(ClientError::ServerUnreachable(format!(
342                            "Server error ({})",
343                            status
344                        )));
345                    } else {
346                        // 4xx other — don't retry
347                        let text = resp.text().await.unwrap_or_default();
348                        return Err(ClientError::AuthFailed(format!(
349                            "Unexpected status {}: {}",
350                            status, text
351                        )));
352                    }
353                }
354                Err(e) => {
355                    // Network error — retry
356                    last_err = Some(ClientError::ServerUnreachable(e.to_string()));
357                }
358            }
359        }
360
361        Err(last_err.unwrap_or_else(|| ClientError::ServerUnreachable("Unknown error".into())))
362    }
363}
364
365// ── Tests ────────────────────────────────────────────────────────────────────
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use wiremock::matchers::{method, path};
371    use wiremock::{Mock, MockServer, ResponseTemplate};
372
373    fn mock_auth_response(client_id: &str, permissions: Vec<&str>) -> serde_json::Value {
374        serde_json::json!({
375            "valid": true,
376            "client": {
377                "client_id": client_id,
378                "permissions": permissions,
379                "rate_limit_rps": 100,
380                "metadata": {}
381            },
382            "expires_at": (Utc::now() + chrono::Duration::hours(1)).to_rfc3339()
383        })
384    }
385
386    // ── Construction / validation ────────────────────────────────────────
387
388    #[tokio::test]
389    async fn test_new_valid_key() {
390        let server = MockServer::start().await;
391        Mock::given(method("POST"))
392            .and(path("/api/v1/auth/validate"))
393            .respond_with(
394                ResponseTemplate::new(200)
395                    .set_body_json(mock_auth_response("acme", vec!["read", "write"])),
396            )
397            .mount(&server)
398            .await;
399
400        let client = BriefcaseClient::new("sk-valid", &server.uri())
401            .await
402            .expect("should succeed");
403
404        assert_eq!(client.client_id(), "acme");
405        assert!(client.has_permission("read"));
406        assert!(client.has_permission("write"));
407        assert!(!client.has_permission("admin"));
408    }
409
410    #[tokio::test]
411    async fn test_new_invalid_key() {
412        let server = MockServer::start().await;
413        Mock::given(method("POST"))
414            .and(path("/api/v1/auth/validate"))
415            .respond_with(
416                ResponseTemplate::new(401)
417                    .set_body_json(serde_json::json!({"error": "Invalid API key"})),
418            )
419            .mount(&server)
420            .await;
421
422        let result = BriefcaseClient::new("sk-bad", &server.uri()).await;
423        assert!(result.is_err());
424        let err = result.unwrap_err();
425        assert!(err.to_string().contains("Invalid API key"), "Got: {}", err);
426    }
427
428    #[tokio::test]
429    async fn test_new_server_down() {
430        // Port 1 is almost certainly not listening
431        let result = BriefcaseClient::with_config(
432            "sk-test",
433            "http://127.0.0.1:1",
434            ClientConfig {
435                timeout_secs: 1,
436                cache_ttl_secs: 60,
437                max_retries: 0, // don't retry — fast test
438            },
439        )
440        .await;
441
442        assert!(result.is_err());
443        match result.unwrap_err() {
444            ClientError::ServerUnreachable(_) => {} // expected
445            other => panic!("Expected ServerUnreachable, got: {}", other),
446        }
447    }
448
449    #[tokio::test]
450    async fn test_new_server_500() {
451        let server = MockServer::start().await;
452        Mock::given(method("POST"))
453            .and(path("/api/v1/auth/validate"))
454            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
455            .mount(&server)
456            .await;
457
458        let result = BriefcaseClient::with_config(
459            "sk-test",
460            &server.uri(),
461            ClientConfig {
462                timeout_secs: 2,
463                cache_ttl_secs: 60,
464                max_retries: 0,
465            },
466        )
467        .await;
468
469        assert!(result.is_err());
470        match result.unwrap_err() {
471            ClientError::ServerUnreachable(msg) => {
472                assert!(msg.contains("500"), "Got: {}", msg);
473            }
474            other => panic!("Expected ServerUnreachable, got: {}", other),
475        }
476    }
477
478    #[tokio::test]
479    async fn test_new_empty_key() {
480        let result = BriefcaseClient::new("", "http://localhost:8080").await;
481        assert!(result.is_err());
482        match result.unwrap_err() {
483            ClientError::InvalidArgument(msg) => {
484                assert!(msg.contains("API key"), "Got: {}", msg);
485            }
486            other => panic!("Expected InvalidArgument, got: {}", other),
487        }
488    }
489
490    #[tokio::test]
491    async fn test_new_whitespace_only_key() {
492        let result = BriefcaseClient::new("   ", "http://localhost:8080").await;
493        assert!(result.is_err());
494        match result.unwrap_err() {
495            ClientError::InvalidArgument(msg) => {
496                assert!(msg.contains("API key"), "Got: {}", msg);
497            }
498            other => panic!("Expected InvalidArgument, got: {}", other),
499        }
500    }
501
502    #[tokio::test]
503    async fn test_new_empty_url() {
504        let result = BriefcaseClient::new("sk-test", "").await;
505        assert!(result.is_err());
506        match result.unwrap_err() {
507            ClientError::InvalidArgument(msg) => {
508                assert!(msg.contains("Server URL"), "Got: {}", msg);
509            }
510            other => panic!("Expected InvalidArgument, got: {}", other),
511        }
512    }
513
514    // ── Cache behaviour ─────────────────────────────────────────────────
515
516    #[tokio::test]
517    async fn test_cache_hit() {
518        let server = MockServer::start().await;
519        Mock::given(method("POST"))
520            .and(path("/api/v1/auth/validate"))
521            .respond_with(
522                ResponseTemplate::new(200)
523                    .set_body_json(mock_auth_response("acme", vec!["read"])),
524            )
525            .expect(1) // exactly one HTTP call
526            .mount(&server)
527            .await;
528
529        let client = BriefcaseClient::new("sk-test", &server.uri())
530            .await
531            .unwrap();
532
533        // Second call should use cache, not hit the server
534        let info = client.revalidate().await.unwrap();
535        assert_eq!(info.client_id, "acme");
536    }
537
538    #[tokio::test]
539    async fn test_cache_expired() {
540        let server = MockServer::start().await;
541        Mock::given(method("POST"))
542            .and(path("/api/v1/auth/validate"))
543            .respond_with(
544                ResponseTemplate::new(200)
545                    .set_body_json(mock_auth_response("acme", vec!["read"])),
546            )
547            .expect(2) // initial + revalidation after expiry
548            .mount(&server)
549            .await;
550
551        let client = BriefcaseClient::with_config(
552            "sk-test",
553            &server.uri(),
554            ClientConfig {
555                timeout_secs: 5,
556                cache_ttl_secs: 0, // immediate expiry
557                max_retries: 0,
558            },
559        )
560        .await
561        .unwrap();
562
563        // Short sleep so Instant::now() moves past cached_at
564        tokio::time::sleep(Duration::from_millis(10)).await;
565
566        let info = client.revalidate().await.unwrap();
567        assert_eq!(info.client_id, "acme");
568    }
569
570    #[tokio::test]
571    async fn test_invalidate_cache() {
572        let server = MockServer::start().await;
573        Mock::given(method("POST"))
574            .and(path("/api/v1/auth/validate"))
575            .respond_with(
576                ResponseTemplate::new(200)
577                    .set_body_json(mock_auth_response("acme", vec!["read"])),
578            )
579            .expect(2) // initial + after invalidation
580            .mount(&server)
581            .await;
582
583        let client = BriefcaseClient::new("sk-test", &server.uri())
584            .await
585            .unwrap();
586
587        client.invalidate_cache();
588
589        let info = client.revalidate().await.unwrap();
590        assert_eq!(info.client_id, "acme");
591    }
592
593    // ── Permission checks ───────────────────────────────────────────────
594
595    #[tokio::test]
596    async fn test_permission_check() {
597        let server = MockServer::start().await;
598        Mock::given(method("POST"))
599            .and(path("/api/v1/auth/validate"))
600            .respond_with(
601                ResponseTemplate::new(200)
602                    .set_body_json(mock_auth_response("acme", vec!["read", "write", "replay"])),
603            )
604            .mount(&server)
605            .await;
606
607        let client = BriefcaseClient::new("sk-test", &server.uri())
608            .await
609            .unwrap();
610
611        assert!(client.has_permission("read"));
612        assert!(client.has_permission("write"));
613        assert!(client.has_permission("replay"));
614        assert!(!client.has_permission("delete"));
615        assert!(!client.has_permission("admin"));
616        assert!(!client.has_permission(""));
617    }
618
619    #[tokio::test]
620    async fn test_permissions_list() {
621        let server = MockServer::start().await;
622        Mock::given(method("POST"))
623            .and(path("/api/v1/auth/validate"))
624            .respond_with(
625                ResponseTemplate::new(200)
626                    .set_body_json(mock_auth_response("acme", vec!["read", "write"])),
627            )
628            .mount(&server)
629            .await;
630
631        let client = BriefcaseClient::new("sk-test", &server.uri())
632            .await
633            .unwrap();
634
635        assert_eq!(client.permissions(), &["read", "write"]);
636    }
637
638    // ── Storage delegation ──────────────────────────────────────────────
639
640    #[cfg(feature = "sqlite-storage")]
641    #[tokio::test]
642    async fn test_save_without_storage() {
643        let server = MockServer::start().await;
644        Mock::given(method("POST"))
645            .and(path("/api/v1/auth/validate"))
646            .respond_with(
647                ResponseTemplate::new(200)
648                    .set_body_json(mock_auth_response("acme", vec!["read", "write"])),
649            )
650            .mount(&server)
651            .await;
652
653        let client = BriefcaseClient::new("sk-test", &server.uri())
654            .await
655            .unwrap();
656
657        let decision = DecisionSnapshot::new("test_fn");
658        let result = client.save_decision(&decision).await;
659        assert!(result.is_err());
660        match result.unwrap_err() {
661            ClientError::NoStorage => {} // expected
662            other => panic!("Expected NoStorage, got: {}", other),
663        }
664    }
665
666    #[cfg(feature = "sqlite-storage")]
667    #[tokio::test]
668    async fn test_save_without_write_perm() {
669        use crate::storage::SqliteBackend;
670
671        let server = MockServer::start().await;
672        Mock::given(method("POST"))
673            .and(path("/api/v1/auth/validate"))
674            .respond_with(
675                ResponseTemplate::new(200)
676                    .set_body_json(mock_auth_response("readonly", vec!["read"])),
677            )
678            .mount(&server)
679            .await;
680
681        let storage = Arc::new(SqliteBackend::in_memory().unwrap());
682        let client = BriefcaseClient::new("sk-test", &server.uri())
683            .await
684            .unwrap()
685            .with_storage(storage);
686
687        let decision = DecisionSnapshot::new("test_fn");
688        let result = client.save_decision(&decision).await;
689        assert!(result.is_err());
690        match result.unwrap_err() {
691            ClientError::PermissionDenied(perm) => assert_eq!(perm, "write"),
692            other => panic!("Expected PermissionDenied(write), got: {}", other),
693        }
694    }
695
696    #[cfg(feature = "sqlite-storage")]
697    #[tokio::test]
698    async fn test_save_with_storage() {
699        use crate::storage::SqliteBackend;
700
701        let server = MockServer::start().await;
702        Mock::given(method("POST"))
703            .and(path("/api/v1/auth/validate"))
704            .respond_with(
705                ResponseTemplate::new(200)
706                    .set_body_json(mock_auth_response("acme", vec!["read", "write"])),
707            )
708            .mount(&server)
709            .await;
710
711        let storage = Arc::new(SqliteBackend::in_memory().unwrap());
712        let client = BriefcaseClient::new("sk-test", &server.uri())
713            .await
714            .unwrap()
715            .with_storage(storage);
716
717        let decision = DecisionSnapshot::new("test_fn");
718        let id = client.save_decision(&decision).await.unwrap();
719        assert!(!id.is_empty());
720
721        // Read it back
722        let loaded = client.load_decision(&id).await.unwrap();
723        assert_eq!(loaded.function_name, "test_fn");
724    }
725
726    #[cfg(feature = "sqlite-storage")]
727    #[tokio::test]
728    async fn test_load_without_read_perm() {
729        use crate::storage::SqliteBackend;
730
731        let server = MockServer::start().await;
732        Mock::given(method("POST"))
733            .and(path("/api/v1/auth/validate"))
734            .respond_with(
735                ResponseTemplate::new(200)
736                    .set_body_json(mock_auth_response("writer", vec!["write"])),
737            )
738            .mount(&server)
739            .await;
740
741        let storage = Arc::new(SqliteBackend::in_memory().unwrap());
742        let client = BriefcaseClient::new("sk-test", &server.uri())
743            .await
744            .unwrap()
745            .with_storage(storage);
746
747        let result = client.load_decision("some-id").await;
748        assert!(result.is_err());
749        match result.unwrap_err() {
750            ClientError::PermissionDenied(perm) => assert_eq!(perm, "read"),
751            other => panic!("Expected PermissionDenied(read), got: {}", other),
752        }
753    }
754
755    #[cfg(feature = "sqlite-storage")]
756    #[tokio::test]
757    async fn test_delete_without_perm() {
758        use crate::storage::SqliteBackend;
759
760        let server = MockServer::start().await;
761        Mock::given(method("POST"))
762            .and(path("/api/v1/auth/validate"))
763            .respond_with(
764                ResponseTemplate::new(200)
765                    .set_body_json(mock_auth_response("acme", vec!["read", "write"])),
766            )
767            .mount(&server)
768            .await;
769
770        let storage = Arc::new(SqliteBackend::in_memory().unwrap());
771        let client = BriefcaseClient::new("sk-test", &server.uri())
772            .await
773            .unwrap()
774            .with_storage(storage);
775
776        let result = client.delete("some-id").await;
777        assert!(result.is_err());
778        match result.unwrap_err() {
779            ClientError::PermissionDenied(perm) => assert_eq!(perm, "delete"),
780            other => panic!("Expected PermissionDenied(delete), got: {}", other),
781        }
782    }
783
784    // ── Concurrency ─────────────────────────────────────────────────────
785
786    #[tokio::test]
787    async fn test_concurrent_revalidate() {
788        let server = MockServer::start().await;
789        Mock::given(method("POST"))
790            .and(path("/api/v1/auth/validate"))
791            .respond_with(
792                ResponseTemplate::new(200).set_body_json(mock_auth_response("acme", vec!["read"])),
793            )
794            .mount(&server)
795            .await;
796
797        let client = Arc::new(
798            BriefcaseClient::with_config(
799                "sk-test",
800                &server.uri(),
801                ClientConfig {
802                    timeout_secs: 5,
803                    cache_ttl_secs: 0, // force revalidation each time
804                    max_retries: 0,
805                },
806            )
807            .await
808            .unwrap(),
809        );
810
811        let mut handles = vec![];
812        for _ in 0..10 {
813            let c = client.clone();
814            handles.push(tokio::spawn(async move { c.revalidate().await }));
815        }
816
817        for handle in handles {
818            let result = handle.await.unwrap();
819            assert!(result.is_ok());
820        }
821    }
822
823    // ── Malformed responses ─────────────────────────────────────────────
824
825    #[tokio::test]
826    async fn test_malformed_response() {
827        let server = MockServer::start().await;
828        Mock::given(method("POST"))
829            .and(path("/api/v1/auth/validate"))
830            .respond_with(ResponseTemplate::new(200).set_body_string("this is not json"))
831            .mount(&server)
832            .await;
833
834        let result = BriefcaseClient::with_config(
835            "sk-test",
836            &server.uri(),
837            ClientConfig {
838                timeout_secs: 2,
839                cache_ttl_secs: 60,
840                max_retries: 0,
841            },
842        )
843        .await;
844
845        assert!(result.is_err());
846        match result.unwrap_err() {
847            ClientError::ServerUnreachable(msg) => {
848                assert!(msg.contains("Invalid response body"), "Got: {}", msg);
849            }
850            other => panic!("Expected ServerUnreachable, got: {}", other),
851        }
852    }
853
854    #[tokio::test]
855    async fn test_response_valid_false() {
856        let server = MockServer::start().await;
857        Mock::given(method("POST"))
858            .and(path("/api/v1/auth/validate"))
859            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
860                "valid": false,
861                "client": {
862                    "client_id": "disabled",
863                    "permissions": [],
864                    "metadata": {}
865                },
866                "expires_at": Utc::now().to_rfc3339()
867            })))
868            .mount(&server)
869            .await;
870
871        let result = BriefcaseClient::with_config(
872            "sk-test",
873            &server.uri(),
874            ClientConfig {
875                timeout_secs: 2,
876                cache_ttl_secs: 60,
877                max_retries: 0,
878            },
879        )
880        .await;
881
882        assert!(result.is_err());
883        match result.unwrap_err() {
884            ClientError::AuthFailed(msg) => {
885                assert!(msg.contains("valid=false"), "Got: {}", msg);
886            }
887            other => panic!("Expected AuthFailed, got: {}", other),
888        }
889    }
890
891    // ── Config defaults ─────────────────────────────────────────────────
892
893    #[test]
894    fn test_default_config_values() {
895        let config = ClientConfig::default();
896        assert_eq!(config.timeout_secs, 30);
897        assert_eq!(config.cache_ttl_secs, 3600);
898        assert_eq!(config.max_retries, 3);
899    }
900
901    // ── URL normalization ───────────────────────────────────────────────
902
903    #[tokio::test]
904    async fn test_trailing_slash_stripped() {
905        let server = MockServer::start().await;
906        Mock::given(method("POST"))
907            .and(path("/api/v1/auth/validate"))
908            .respond_with(
909                ResponseTemplate::new(200).set_body_json(mock_auth_response("acme", vec!["read"])),
910            )
911            .mount(&server)
912            .await;
913
914        let url_with_slash = format!("{}/", server.uri());
915        let client = BriefcaseClient::new("sk-test", &url_with_slash)
916            .await
917            .unwrap();
918        assert_eq!(client.client_id(), "acme");
919    }
920}