1use 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#[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#[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#[derive(Debug, Clone)]
56pub struct ClientConfig {
57 pub timeout_secs: u64,
59 pub cache_ttl_secs: u64,
61 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#[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
101struct CacheEntry {
105 client: ValidatedClient,
106 cached_at: Instant,
107}
108
109pub 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 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 pub async fn with_config(
145 api_key: &str,
146 server_url: &str,
147 config: ClientConfig,
148 ) -> Result<Self, ClientError> {
149 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 pub fn client_id(&self) -> &str {
192 &self.validated.client_id
193 }
194
195 pub fn permissions(&self) -> &[String] {
197 &self.validated.permissions
198 }
199
200 pub fn has_permission(&self, perm: &str) -> bool {
202 self.validated.permissions.iter().any(|p| p == perm)
203 }
204
205 pub async fn revalidate(&self) -> Result<ValidatedClient, ClientError> {
207 {
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 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 {
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 pub fn invalidate_cache(&self) {
235 let mut guard = self.cache.lock().unwrap();
236 *guard = None;
237 }
238
239 #[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 #[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 #[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 #[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 #[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 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 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 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 last_err = Some(ClientError::ServerUnreachable(format!(
342 "Server error ({})",
343 status
344 )));
345 } else {
346 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 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#[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 #[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 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, },
439 )
440 .await;
441
442 assert!(result.is_err());
443 match result.unwrap_err() {
444 ClientError::ServerUnreachable(_) => {} 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 #[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) .mount(&server)
527 .await;
528
529 let client = BriefcaseClient::new("sk-test", &server.uri())
530 .await
531 .unwrap();
532
533 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) .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, max_retries: 0,
558 },
559 )
560 .await
561 .unwrap();
562
563 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) .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 #[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 #[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 => {} 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 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 #[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, 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 #[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 #[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 #[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}