use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
pub api_key: String,
pub user_id: String,
pub email: String,
#[serde(default)]
pub name: Option<String>,
pub authenticated_at: DateTime<Utc>,
pub auth_url: String,
}
impl Credentials {
pub fn is_valid(&self) -> bool {
!self.api_key.is_empty() && self.api_key.starts_with("mecha_")
}
pub fn masked_api_key(&self) -> String {
if self.api_key.len() > 12 {
format!("{}...{}", &self.api_key[..12], &self.api_key[self.api_key.len() - 4..])
} else {
"***".to_string()
}
}
pub fn display_name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.email)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u32,
pub interval: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DeviceCodeStatus {
Pending,
Authorized {
api_key: String,
user_id: String,
email: String,
name: Option<String>,
},
Denied,
Expired,
}
impl DeviceCodeStatus {}
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "error", rename_all = "snake_case")]
pub enum AuthError {
#[error("Network error: {message}")]
NetworkError { message: String },
#[error("Server error ({status_code:?}): {message}")]
ServerError { message: String, status_code: Option<u16> },
#[error("Device code expired")]
ExpiredCode,
#[error("Access denied by user")]
AccessDenied,
#[error("Invalid credentials: {message}")]
InvalidCredentials { message: String },
#[error("Rate limited, retry after {retry_after:?} seconds")]
RateLimited { retry_after: Option<u32> },
}
#[cfg(test)]
mod tests {
use super::*;
fn make_credentials(api_key: &str, name: Option<&str>) -> Credentials {
Credentials {
api_key: api_key.to_string(),
user_id: "usr_123".to_string(),
email: "user@example.com".to_string(),
name: name.map(|n| n.to_string()),
authenticated_at: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
auth_url: "https://mecha.industries/api/auth".to_string(),
}
}
#[test]
fn masked_api_key_masks_long_keys() {
let creds = make_credentials("mecha_1234567890abcdef", None);
assert_eq!(creds.masked_api_key(), "mecha_123456...cdef");
}
#[test]
fn masked_api_key_fully_masks_short_keys() {
let creds = make_credentials("mecha_abcdef", None);
assert_eq!(creds.masked_api_key(), "***");
let creds = make_credentials("short", None);
assert_eq!(creds.masked_api_key(), "***");
}
#[test]
fn display_name_prefers_name_over_email() {
let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
assert_eq!(creds.display_name(), "Ada Lovelace");
}
#[test]
fn display_name_falls_back_to_email_when_no_name() {
let creds = make_credentials("mecha_1234567890abcdef", None);
assert_eq!(creds.display_name(), "user@example.com");
}
#[test]
fn credentials_roundtrip_serialization() {
let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
let json = serde_json::to_string(&creds).unwrap();
let deserialized: Credentials = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.api_key, creds.api_key);
assert_eq!(deserialized.user_id, creds.user_id);
assert_eq!(deserialized.email, creds.email);
assert_eq!(deserialized.name, creds.name);
assert_eq!(deserialized.auth_url, creds.auth_url);
}
#[test]
fn credentials_deserialization_defaults_missing_name() {
let json = r#"{
"api_key": "mecha_abc123",
"user_id": "usr_1",
"email": "user@example.com",
"authenticated_at": "2024-01-01T00:00:00Z",
"auth_url": "https://mecha.industries/api/auth"
}"#;
let creds: Credentials = serde_json::from_str(json).unwrap();
assert_eq!(creds.name, None);
}
#[test]
fn device_code_response_roundtrip_serialization() {
let response = DeviceCodeResponse {
device_code: "devcode123".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://mecha.industries/device".to_string(),
expires_in: 900,
interval: 5,
};
let json = serde_json::to_string(&response).unwrap();
let deserialized: DeviceCodeResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.device_code, response.device_code);
assert_eq!(deserialized.user_code, response.user_code);
assert_eq!(deserialized.verification_uri, response.verification_uri);
assert_eq!(deserialized.expires_in, response.expires_in);
assert_eq!(deserialized.interval, response.interval);
}
#[test]
fn device_code_status_serializes_pending_with_tag() {
let status = DeviceCodeStatus::Pending;
let json = serde_json::to_value(&status).unwrap();
assert_eq!(json, serde_json::json!({ "status": "pending" }));
}
#[test]
fn device_code_status_serializes_denied_and_expired() {
assert_eq!(
serde_json::to_value(DeviceCodeStatus::Denied).unwrap(),
serde_json::json!({ "status": "denied" })
);
assert_eq!(
serde_json::to_value(DeviceCodeStatus::Expired).unwrap(),
serde_json::json!({ "status": "expired" })
);
}
#[test]
fn device_code_status_roundtrips_authorized_variant() {
let status = DeviceCodeStatus::Authorized {
api_key: "mecha_abc123".to_string(),
user_id: "usr_1".to_string(),
email: "user@example.com".to_string(),
name: Some("Ada Lovelace".to_string()),
};
let json = serde_json::to_string(&status).unwrap();
let deserialized: DeviceCodeStatus = serde_json::from_str(&json).unwrap();
match deserialized {
DeviceCodeStatus::Authorized {
api_key,
user_id,
email,
name,
} => {
assert_eq!(api_key, "mecha_abc123");
assert_eq!(user_id, "usr_1");
assert_eq!(email, "user@example.com");
assert_eq!(name, Some("Ada Lovelace".to_string()));
}
other => panic!("expected Authorized variant, got {:?}", other),
}
}
#[test]
fn device_code_status_deserializes_from_status_tag() {
let json = r#"{"status": "pending"}"#;
let status: DeviceCodeStatus = serde_json::from_str(json).unwrap();
assert!(matches!(status, DeviceCodeStatus::Pending));
}
#[test]
fn auth_error_display_messages() {
assert_eq!(
AuthError::NetworkError {
message: "connection refused".to_string()
}
.to_string(),
"Network error: connection refused"
);
assert_eq!(
AuthError::ServerError {
message: "boom".to_string(),
status_code: Some(500)
}
.to_string(),
"Server error (Some(500)): boom"
);
assert_eq!(AuthError::ExpiredCode.to_string(), "Device code expired");
assert_eq!(AuthError::AccessDenied.to_string(), "Access denied by user");
assert_eq!(
AuthError::InvalidCredentials {
message: "bad json".to_string()
}
.to_string(),
"Invalid credentials: bad json"
);
assert_eq!(
AuthError::RateLimited { retry_after: Some(30) }.to_string(),
"Rate limited, retry after Some(30) seconds"
);
}
#[test]
fn auth_error_roundtrip_serialization() {
let err = AuthError::RateLimited { retry_after: Some(15) };
let json = serde_json::to_string(&err).unwrap();
let deserialized: AuthError = serde_json::from_str(&json).unwrap();
match deserialized {
AuthError::RateLimited { retry_after } => assert_eq!(retry_after, Some(15)),
other => panic!("expected RateLimited variant, got {:?}", other),
}
}
}