use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use thiserror::Error;
#[cfg(feature = "sqlite-storage")]
use crate::models::{DecisionSnapshot, Snapshot};
#[cfg(feature = "sqlite-storage")]
use crate::storage::{SnapshotQuery, StorageBackend, StorageError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedClient {
pub client_id: String,
pub permissions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate_limit_rps: Option<u32>,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
pub valid: bool,
pub client: ValidatedClient,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub timeout_secs: u64,
pub cache_ttl_secs: u64,
pub max_retries: u32,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
timeout_secs: 30,
cache_ttl_secs: 3600,
max_retries: 3,
}
}
}
#[derive(Error, Debug)]
pub enum ClientError {
#[error("Authentication failed: {0}")]
AuthFailed(String),
#[error("Server unreachable: {0}")]
ServerUnreachable(String),
#[error("Permission denied: requires '{0}'")]
PermissionDenied(String),
#[error("Validation expired")]
Expired,
#[error("No storage backend bound")]
NoStorage,
#[error("Invalid argument: {0}")]
InvalidArgument(String),
#[cfg(feature = "sqlite-storage")]
#[error("Storage error: {0}")]
Storage(#[from] StorageError),
}
struct CacheEntry {
client: ValidatedClient,
cached_at: Instant,
}
pub struct BriefcaseClient {
validated: ValidatedClient,
server_url: String,
api_key: String,
http: reqwest::Client,
cache: Arc<Mutex<Option<CacheEntry>>>,
cache_ttl: Duration,
#[cfg(feature = "sqlite-storage")]
storage: Option<Arc<dyn StorageBackend>>,
}
impl std::fmt::Debug for BriefcaseClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BriefcaseClient")
.field("validated", &self.validated)
.field("server_url", &self.server_url)
.field("api_key", &"[REDACTED]")
.field("cache_ttl", &self.cache_ttl)
.finish()
}
}
impl BriefcaseClient {
pub async fn new(api_key: &str, server_url: &str) -> Result<Self, ClientError> {
Self::with_config(api_key, server_url, ClientConfig::default()).await
}
pub async fn with_config(
api_key: &str,
server_url: &str,
config: ClientConfig,
) -> Result<Self, ClientError> {
if api_key.trim().is_empty() {
return Err(ClientError::InvalidArgument(
"API key must not be empty".into(),
));
}
if server_url.trim().is_empty() {
return Err(ClientError::InvalidArgument(
"Server URL must not be empty".into(),
));
}
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| ClientError::ServerUnreachable(e.to_string()))?;
let url = format!("{}/api/v1/auth/validate", server_url.trim_end_matches('/'));
let auth_response = Self::do_validate(&http, &url, api_key, config.max_retries).await?;
let validated = auth_response.client;
let cache_ttl = Duration::from_secs(config.cache_ttl_secs);
let cache = Arc::new(Mutex::new(Some(CacheEntry {
client: validated.clone(),
cached_at: Instant::now(),
})));
Ok(Self {
validated,
server_url: server_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
http,
cache,
cache_ttl,
#[cfg(feature = "sqlite-storage")]
storage: None,
})
}
pub fn client_id(&self) -> &str {
&self.validated.client_id
}
pub fn permissions(&self) -> &[String] {
&self.validated.permissions
}
pub fn has_permission(&self, perm: &str) -> bool {
self.validated.permissions.iter().any(|p| p == perm)
}
pub async fn revalidate(&self) -> Result<ValidatedClient, ClientError> {
{
let guard = self.cache.lock().unwrap();
if let Some(entry) = guard.as_ref() {
if entry.cached_at.elapsed() < self.cache_ttl {
return Ok(entry.client.clone());
}
}
}
let url = format!("{}/api/v1/auth/validate", self.server_url);
let auth = Self::do_validate(&self.http, &url, &self.api_key, 3).await?;
{
let mut guard = self.cache.lock().unwrap();
*guard = Some(CacheEntry {
client: auth.client.clone(),
cached_at: Instant::now(),
});
}
Ok(auth.client)
}
pub fn invalidate_cache(&self) {
let mut guard = self.cache.lock().unwrap();
*guard = None;
}
#[cfg(feature = "sqlite-storage")]
pub fn with_storage(mut self, storage: Arc<dyn StorageBackend>) -> Self {
self.storage = Some(storage);
self
}
#[cfg(feature = "sqlite-storage")]
pub async fn save_decision(&self, decision: &DecisionSnapshot) -> Result<String, ClientError> {
self.require_permission("write")?;
let storage = self.require_storage()?;
storage
.save_decision(decision)
.await
.map_err(ClientError::from)
}
#[cfg(feature = "sqlite-storage")]
pub async fn load_decision(&self, decision_id: &str) -> Result<DecisionSnapshot, ClientError> {
self.require_permission("read")?;
let storage = self.require_storage()?;
storage
.load_decision(decision_id)
.await
.map_err(ClientError::from)
}
#[cfg(feature = "sqlite-storage")]
pub async fn query(&self, query: SnapshotQuery) -> Result<Vec<Snapshot>, ClientError> {
self.require_permission("read")?;
let storage = self.require_storage()?;
storage.query(query).await.map_err(ClientError::from)
}
#[cfg(feature = "sqlite-storage")]
pub async fn delete(&self, id: &str) -> Result<bool, ClientError> {
self.require_permission("delete")?;
let storage = self.require_storage()?;
storage.delete(id).await.map_err(ClientError::from)
}
fn require_permission(&self, perm: &str) -> Result<(), ClientError> {
if self.has_permission(perm) {
Ok(())
} else {
Err(ClientError::PermissionDenied(perm.to_string()))
}
}
#[cfg(feature = "sqlite-storage")]
fn require_storage(&self) -> Result<&Arc<dyn StorageBackend>, ClientError> {
self.storage.as_ref().ok_or(ClientError::NoStorage)
}
async fn do_validate(
http: &reqwest::Client,
url: &str,
api_key: &str,
max_retries: u32,
) -> Result<AuthResponse, ClientError> {
let body = serde_json::json!({ "api_key": api_key });
let mut last_err = None;
for attempt in 0..=max_retries {
if attempt > 0 {
let backoff = Duration::from_millis(100 * (1 << (attempt - 1)));
tokio::time::sleep(backoff).await;
}
let result = http.post(url).json(&body).send().await;
match result {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
let auth: AuthResponse = resp.json().await.map_err(|e| {
ClientError::ServerUnreachable(format!("Invalid response body: {}", e))
})?;
if !auth.valid {
return Err(ClientError::AuthFailed(
"Server returned valid=false".into(),
));
}
return Ok(auth);
} else if status == reqwest::StatusCode::UNAUTHORIZED {
let text = resp.text().await.unwrap_or_default();
return Err(ClientError::AuthFailed(format!(
"Invalid API key (401): {}",
text
)));
} else if status.is_server_error() {
last_err = Some(ClientError::ServerUnreachable(format!(
"Server error ({})",
status
)));
} else {
let text = resp.text().await.unwrap_or_default();
return Err(ClientError::AuthFailed(format!(
"Unexpected status {}: {}",
status, text
)));
}
}
Err(e) => {
last_err = Some(ClientError::ServerUnreachable(e.to_string()));
}
}
}
Err(last_err.unwrap_or_else(|| ClientError::ServerUnreachable("Unknown error".into())))
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mock_auth_response(client_id: &str, permissions: Vec<&str>) -> serde_json::Value {
serde_json::json!({
"valid": true,
"client": {
"client_id": client_id,
"permissions": permissions,
"rate_limit_rps": 100,
"metadata": {}
},
"expires_at": (Utc::now() + chrono::Duration::hours(1)).to_rfc3339()
})
}
#[tokio::test]
async fn test_new_valid_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read", "write"])),
)
.mount(&server)
.await;
let client = BriefcaseClient::new("sk-valid", &server.uri())
.await
.expect("should succeed");
assert_eq!(client.client_id(), "acme");
assert!(client.has_permission("read"));
assert!(client.has_permission("write"));
assert!(!client.has_permission("admin"));
}
#[tokio::test]
async fn test_new_invalid_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(401)
.set_body_json(serde_json::json!({"error": "Invalid API key"})),
)
.mount(&server)
.await;
let result = BriefcaseClient::new("sk-bad", &server.uri()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid API key"), "Got: {}", err);
}
#[tokio::test]
async fn test_new_server_down() {
let result = BriefcaseClient::with_config(
"sk-test",
"http://127.0.0.1:1",
ClientConfig {
timeout_secs: 1,
cache_ttl_secs: 60,
max_retries: 0, },
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::ServerUnreachable(_) => {} other => panic!("Expected ServerUnreachable, got: {}", other),
}
}
#[tokio::test]
async fn test_new_server_500() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&server)
.await;
let result = BriefcaseClient::with_config(
"sk-test",
&server.uri(),
ClientConfig {
timeout_secs: 2,
cache_ttl_secs: 60,
max_retries: 0,
},
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::ServerUnreachable(msg) => {
assert!(msg.contains("500"), "Got: {}", msg);
}
other => panic!("Expected ServerUnreachable, got: {}", other),
}
}
#[tokio::test]
async fn test_new_empty_key() {
let result = BriefcaseClient::new("", "http://localhost:8080").await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::InvalidArgument(msg) => {
assert!(msg.contains("API key"), "Got: {}", msg);
}
other => panic!("Expected InvalidArgument, got: {}", other),
}
}
#[tokio::test]
async fn test_new_whitespace_only_key() {
let result = BriefcaseClient::new(" ", "http://localhost:8080").await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::InvalidArgument(msg) => {
assert!(msg.contains("API key"), "Got: {}", msg);
}
other => panic!("Expected InvalidArgument, got: {}", other),
}
}
#[tokio::test]
async fn test_new_empty_url() {
let result = BriefcaseClient::new("sk-test", "").await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::InvalidArgument(msg) => {
assert!(msg.contains("Server URL"), "Got: {}", msg);
}
other => panic!("Expected InvalidArgument, got: {}", other),
}
}
#[tokio::test]
async fn test_cache_hit() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read"])),
)
.expect(1) .mount(&server)
.await;
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap();
let info = client.revalidate().await.unwrap();
assert_eq!(info.client_id, "acme");
}
#[tokio::test]
async fn test_cache_expired() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read"])),
)
.expect(2) .mount(&server)
.await;
let client = BriefcaseClient::with_config(
"sk-test",
&server.uri(),
ClientConfig {
timeout_secs: 5,
cache_ttl_secs: 0, max_retries: 0,
},
)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let info = client.revalidate().await.unwrap();
assert_eq!(info.client_id, "acme");
}
#[tokio::test]
async fn test_invalidate_cache() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read"])),
)
.expect(2) .mount(&server)
.await;
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap();
client.invalidate_cache();
let info = client.revalidate().await.unwrap();
assert_eq!(info.client_id, "acme");
}
#[tokio::test]
async fn test_permission_check() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read", "write", "replay"])),
)
.mount(&server)
.await;
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap();
assert!(client.has_permission("read"));
assert!(client.has_permission("write"));
assert!(client.has_permission("replay"));
assert!(!client.has_permission("delete"));
assert!(!client.has_permission("admin"));
assert!(!client.has_permission(""));
}
#[tokio::test]
async fn test_permissions_list() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read", "write"])),
)
.mount(&server)
.await;
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap();
assert_eq!(client.permissions(), &["read", "write"]);
}
#[cfg(feature = "sqlite-storage")]
#[tokio::test]
async fn test_save_without_storage() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read", "write"])),
)
.mount(&server)
.await;
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap();
let decision = DecisionSnapshot::new("test_fn");
let result = client.save_decision(&decision).await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::NoStorage => {} other => panic!("Expected NoStorage, got: {}", other),
}
}
#[cfg(feature = "sqlite-storage")]
#[tokio::test]
async fn test_save_without_write_perm() {
use crate::storage::SqliteBackend;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("readonly", vec!["read"])),
)
.mount(&server)
.await;
let storage = Arc::new(SqliteBackend::in_memory().unwrap());
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap()
.with_storage(storage);
let decision = DecisionSnapshot::new("test_fn");
let result = client.save_decision(&decision).await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::PermissionDenied(perm) => assert_eq!(perm, "write"),
other => panic!("Expected PermissionDenied(write), got: {}", other),
}
}
#[cfg(feature = "sqlite-storage")]
#[tokio::test]
async fn test_save_with_storage() {
use crate::storage::SqliteBackend;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read", "write"])),
)
.mount(&server)
.await;
let storage = Arc::new(SqliteBackend::in_memory().unwrap());
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap()
.with_storage(storage);
let decision = DecisionSnapshot::new("test_fn");
let id = client.save_decision(&decision).await.unwrap();
assert!(!id.is_empty());
let loaded = client.load_decision(&id).await.unwrap();
assert_eq!(loaded.function_name, "test_fn");
}
#[cfg(feature = "sqlite-storage")]
#[tokio::test]
async fn test_load_without_read_perm() {
use crate::storage::SqliteBackend;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("writer", vec!["write"])),
)
.mount(&server)
.await;
let storage = Arc::new(SqliteBackend::in_memory().unwrap());
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap()
.with_storage(storage);
let result = client.load_decision("some-id").await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::PermissionDenied(perm) => assert_eq!(perm, "read"),
other => panic!("Expected PermissionDenied(read), got: {}", other),
}
}
#[cfg(feature = "sqlite-storage")]
#[tokio::test]
async fn test_delete_without_perm() {
use crate::storage::SqliteBackend;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mock_auth_response("acme", vec!["read", "write"])),
)
.mount(&server)
.await;
let storage = Arc::new(SqliteBackend::in_memory().unwrap());
let client = BriefcaseClient::new("sk-test", &server.uri())
.await
.unwrap()
.with_storage(storage);
let result = client.delete("some-id").await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::PermissionDenied(perm) => assert_eq!(perm, "delete"),
other => panic!("Expected PermissionDenied(delete), got: {}", other),
}
}
#[tokio::test]
async fn test_concurrent_revalidate() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_auth_response("acme", vec!["read"])),
)
.mount(&server)
.await;
let client = Arc::new(
BriefcaseClient::with_config(
"sk-test",
&server.uri(),
ClientConfig {
timeout_secs: 5,
cache_ttl_secs: 0, max_retries: 0,
},
)
.await
.unwrap(),
);
let mut handles = vec![];
for _ in 0..10 {
let c = client.clone();
handles.push(tokio::spawn(async move { c.revalidate().await }));
}
for handle in handles {
let result = handle.await.unwrap();
assert!(result.is_ok());
}
}
#[tokio::test]
async fn test_malformed_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(ResponseTemplate::new(200).set_body_string("this is not json"))
.mount(&server)
.await;
let result = BriefcaseClient::with_config(
"sk-test",
&server.uri(),
ClientConfig {
timeout_secs: 2,
cache_ttl_secs: 60,
max_retries: 0,
},
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::ServerUnreachable(msg) => {
assert!(msg.contains("Invalid response body"), "Got: {}", msg);
}
other => panic!("Expected ServerUnreachable, got: {}", other),
}
}
#[tokio::test]
async fn test_response_valid_false() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"valid": false,
"client": {
"client_id": "disabled",
"permissions": [],
"metadata": {}
},
"expires_at": Utc::now().to_rfc3339()
})))
.mount(&server)
.await;
let result = BriefcaseClient::with_config(
"sk-test",
&server.uri(),
ClientConfig {
timeout_secs: 2,
cache_ttl_secs: 60,
max_retries: 0,
},
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
ClientError::AuthFailed(msg) => {
assert!(msg.contains("valid=false"), "Got: {}", msg);
}
other => panic!("Expected AuthFailed, got: {}", other),
}
}
#[test]
fn test_default_config_values() {
let config = ClientConfig::default();
assert_eq!(config.timeout_secs, 30);
assert_eq!(config.cache_ttl_secs, 3600);
assert_eq!(config.max_retries, 3);
}
#[tokio::test]
async fn test_trailing_slash_stripped() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/auth/validate"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_auth_response("acme", vec!["read"])),
)
.mount(&server)
.await;
let url_with_slash = format!("{}/", server.uri());
let client = BriefcaseClient::new("sk-test", &url_with_slash)
.await
.unwrap();
assert_eq!(client.client_id(), "acme");
}
}