1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Credentials {
10 pub api_key: String,
11
12 pub user_id: String,
13
14 pub email: String,
15
16 #[serde(default)]
17 pub name: Option<String>,
18
19 pub authenticated_at: DateTime<Utc>,
20
21 pub auth_url: String,
22}
23
24impl Credentials {
25 pub fn is_valid(&self) -> bool {
34 !self.api_key.is_empty()
35 }
36
37 pub fn masked_api_key(&self) -> String {
39 if self.api_key.len() > 12 {
40 format!("{}...{}", &self.api_key[..12], &self.api_key[self.api_key.len() - 4..])
41 } else {
42 "***".to_string()
43 }
44 }
45
46 pub fn display_name(&self) -> &str {
48 self.name.as_deref().unwrap_or(&self.email)
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DeviceCodeResponse {
54 pub device_code: String,
55
56 pub user_code: String,
57
58 pub verification_uri: String,
59
60 #[serde(default)]
61 pub verification_uri_complete: Option<String>,
62
63 pub expires_in: u32,
64
65 pub interval: u32,
66}
67
68impl DeviceCodeResponse {
69 pub fn browser_url(&self) -> &str {
73 self.verification_uri_complete
74 .as_deref()
75 .unwrap_or(&self.verification_uri)
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(tag = "status", rename_all = "snake_case")]
81pub enum DeviceCodeStatus {
82 Pending,
83
84 Authorized {
85 api_key: String,
86 user_id: String,
87 email: String,
88 name: Option<String>,
89 },
90
91 Denied,
92
93 Expired,
94}
95
96impl DeviceCodeStatus {}
97
98#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
99#[serde(tag = "error", rename_all = "snake_case")]
100pub enum AuthError {
101 #[error("Network error: {message}")]
102 NetworkError { message: String },
103
104 #[error("Server error ({status_code:?}): {message}")]
105 ServerError { message: String, status_code: Option<u16> },
106
107 #[error("Device code expired")]
108 ExpiredCode,
109
110 #[error("Access denied by user")]
111 AccessDenied,
112
113 #[error("Invalid credentials: {message}")]
114 InvalidCredentials { message: String },
115
116 #[error("Rate limited, retry after {retry_after:?} seconds")]
117 RateLimited { retry_after: Option<u32> },
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 fn make_credentials(api_key: &str, name: Option<&str>) -> Credentials {
125 Credentials {
126 api_key: api_key.to_string(),
127 user_id: "usr_123".to_string(),
128 email: "user@example.com".to_string(),
129 name: name.map(|n| n.to_string()),
130 authenticated_at: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
131 .unwrap()
132 .with_timezone(&Utc),
133 auth_url: "https://mecha.industries/api/auth".to_string(),
134 }
135 }
136
137 #[test]
138 fn masked_api_key_masks_long_keys() {
139 let creds = make_credentials("mecha_1234567890abcdef", None);
140 assert_eq!(creds.masked_api_key(), "mecha_123456...cdef");
141 }
142
143 #[test]
144 fn masked_api_key_fully_masks_short_keys() {
145 let creds = make_credentials("mecha_abcdef", None);
146 assert_eq!(creds.masked_api_key(), "***");
147
148 let creds = make_credentials("short", None);
149 assert_eq!(creds.masked_api_key(), "***");
150 }
151
152 #[test]
153 fn display_name_prefers_name_over_email() {
154 let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
155 assert_eq!(creds.display_name(), "Ada Lovelace");
156 }
157
158 #[test]
159 fn display_name_falls_back_to_email_when_no_name() {
160 let creds = make_credentials("mecha_1234567890abcdef", None);
161 assert_eq!(creds.display_name(), "user@example.com");
162 }
163
164 #[test]
165 fn credentials_roundtrip_serialization() {
166 let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
167 let json = serde_json::to_string(&creds).unwrap();
168 let deserialized: Credentials = serde_json::from_str(&json).unwrap();
169
170 assert_eq!(deserialized.api_key, creds.api_key);
171 assert_eq!(deserialized.user_id, creds.user_id);
172 assert_eq!(deserialized.email, creds.email);
173 assert_eq!(deserialized.name, creds.name);
174 assert_eq!(deserialized.auth_url, creds.auth_url);
175 }
176
177 #[test]
178 fn credentials_deserialization_defaults_missing_name() {
179 let json = r#"{
180 "api_key": "mecha_abc123",
181 "user_id": "usr_1",
182 "email": "user@example.com",
183 "authenticated_at": "2024-01-01T00:00:00Z",
184 "auth_url": "https://mecha.industries/api/auth"
185 }"#;
186
187 let creds: Credentials = serde_json::from_str(json).unwrap();
188 assert_eq!(creds.name, None);
189 }
190
191 #[test]
192 fn device_code_response_roundtrip_serialization() {
193 let response = DeviceCodeResponse {
194 device_code: "devcode123".to_string(),
195 user_code: "ABCD-1234".to_string(),
196 verification_uri: "https://mecha.industries/device".to_string(),
197 verification_uri_complete: Some("https://mecha.industries/device?code=ABCD-1234".to_string()),
198 expires_in: 900,
199 interval: 5,
200 };
201
202 let json = serde_json::to_string(&response).unwrap();
203 let deserialized: DeviceCodeResponse = serde_json::from_str(&json).unwrap();
204
205 assert_eq!(deserialized.device_code, response.device_code);
206 assert_eq!(deserialized.user_code, response.user_code);
207 assert_eq!(deserialized.verification_uri, response.verification_uri);
208 assert_eq!(
209 deserialized.verification_uri_complete,
210 response.verification_uri_complete
211 );
212 assert_eq!(deserialized.expires_in, response.expires_in);
213 assert_eq!(deserialized.interval, response.interval);
214 }
215
216 #[test]
217 fn device_code_response_deserializes_without_verification_uri_complete() {
218 let json = r#"{
219 "device_code": "devcode123",
220 "user_code": "ABCD-1234",
221 "verification_uri": "https://mecha.industries/device",
222 "expires_in": 900,
223 "interval": 5
224 }"#;
225
226 let response: DeviceCodeResponse = serde_json::from_str(json).unwrap();
227 assert_eq!(response.verification_uri_complete, None);
228 assert_eq!(response.browser_url(), "https://mecha.industries/device");
229 }
230
231 #[test]
232 fn browser_url_prefers_verification_uri_complete_when_present() {
233 let response = DeviceCodeResponse {
234 device_code: "devcode123".to_string(),
235 user_code: "ABCD-1234".to_string(),
236 verification_uri: "https://mecha.industries/device".to_string(),
237 verification_uri_complete: Some("https://mecha.industries/device?code=ABCD-1234".to_string()),
238 expires_in: 900,
239 interval: 5,
240 };
241
242 assert_eq!(response.browser_url(), "https://mecha.industries/device?code=ABCD-1234");
243 }
244
245 #[test]
246 fn device_code_status_serializes_pending_with_tag() {
247 let status = DeviceCodeStatus::Pending;
248 let json = serde_json::to_value(&status).unwrap();
249 assert_eq!(json, serde_json::json!({ "status": "pending" }));
250 }
251
252 #[test]
253 fn device_code_status_serializes_denied_and_expired() {
254 assert_eq!(
255 serde_json::to_value(DeviceCodeStatus::Denied).unwrap(),
256 serde_json::json!({ "status": "denied" })
257 );
258 assert_eq!(
259 serde_json::to_value(DeviceCodeStatus::Expired).unwrap(),
260 serde_json::json!({ "status": "expired" })
261 );
262 }
263
264 #[test]
265 fn device_code_status_roundtrips_authorized_variant() {
266 let status = DeviceCodeStatus::Authorized {
267 api_key: "mecha_abc123".to_string(),
268 user_id: "usr_1".to_string(),
269 email: "user@example.com".to_string(),
270 name: Some("Ada Lovelace".to_string()),
271 };
272
273 let json = serde_json::to_string(&status).unwrap();
274 let deserialized: DeviceCodeStatus = serde_json::from_str(&json).unwrap();
275
276 match deserialized {
277 DeviceCodeStatus::Authorized {
278 api_key,
279 user_id,
280 email,
281 name,
282 } => {
283 assert_eq!(api_key, "mecha_abc123");
284 assert_eq!(user_id, "usr_1");
285 assert_eq!(email, "user@example.com");
286 assert_eq!(name, Some("Ada Lovelace".to_string()));
287 }
288 other => panic!("expected Authorized variant, got {:?}", other),
289 }
290 }
291
292 #[test]
293 fn device_code_status_deserializes_from_status_tag() {
294 let json = r#"{"status": "pending"}"#;
295 let status: DeviceCodeStatus = serde_json::from_str(json).unwrap();
296 assert!(matches!(status, DeviceCodeStatus::Pending));
297 }
298
299 #[test]
300 fn auth_error_display_messages() {
301 assert_eq!(
302 AuthError::NetworkError {
303 message: "connection refused".to_string()
304 }
305 .to_string(),
306 "Network error: connection refused"
307 );
308
309 assert_eq!(
310 AuthError::ServerError {
311 message: "boom".to_string(),
312 status_code: Some(500)
313 }
314 .to_string(),
315 "Server error (Some(500)): boom"
316 );
317
318 assert_eq!(AuthError::ExpiredCode.to_string(), "Device code expired");
319 assert_eq!(AuthError::AccessDenied.to_string(), "Access denied by user");
320
321 assert_eq!(
322 AuthError::InvalidCredentials {
323 message: "bad json".to_string()
324 }
325 .to_string(),
326 "Invalid credentials: bad json"
327 );
328
329 assert_eq!(
330 AuthError::RateLimited { retry_after: Some(30) }.to_string(),
331 "Rate limited, retry after Some(30) seconds"
332 );
333 }
334
335 #[test]
336 fn auth_error_roundtrip_serialization() {
337 let err = AuthError::RateLimited { retry_after: Some(15) };
338 let json = serde_json::to_string(&err).unwrap();
339 let deserialized: AuthError = serde_json::from_str(&json).unwrap();
340
341 match deserialized {
342 AuthError::RateLimited { retry_after } => assert_eq!(retry_after, Some(15)),
343 other => panic!("expected RateLimited variant, got {:?}", other),
344 }
345 }
346}