1use std::io::ErrorKind;
8use std::sync::LazyLock;
9use std::time::Duration;
10
11use crate::cache::{Cache, acquire_lock_async, atomic_write};
12use crate::error::{AppError, Result};
13use crate::usage::{DevinSnapshot, UsageWindow};
14use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
15
16use super::creds::{API_ORIGIN, Credentials};
17use super::types::{DAILY_WINDOW, WEEKLY_WINDOW, parse_response};
18
19const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
20const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
21const ACCOUNT_FILE: &str = ".account";
24const USER_STATUS_PATH: &str = "/exa.seat_management_pb.SeatManagementService/GetUserStatus";
25
26#[derive(Debug, Clone)]
27pub struct Endpoints {
28 pub base: String,
29}
30
31impl Endpoints {
32 pub fn user_status_url(&self) -> String {
33 format!("{}{}", self.base.trim_end_matches('/'), USER_STATUS_PATH)
34 }
35}
36
37impl Default for Endpoints {
38 fn default() -> Self {
39 Self {
40 base: API_ORIGIN.to_string(),
41 }
42 }
43}
44
45pub type FetchOutcome = crate::outcome::Outcome<DevinSnapshot>;
46
47fn build_client(timeout: Duration) -> reqwest::Result<reqwest::Client> {
50 reqwest::Client::builder()
51 .timeout(timeout)
52 .redirect(reqwest::redirect::Policy::none())
53 .build()
54}
55
56static CLIENT: LazyLock<Option<reqwest::Client>> =
60 LazyLock::new(|| build_client(HTTP_TIMEOUT).ok());
61
62fn http_client() -> Result<&'static reqwest::Client> {
63 CLIENT
64 .as_ref()
65 .ok_or_else(|| AppError::Other("Devin HTTP client initialization failed".into()))
66}
67
68pub async fn fetch_snapshot(
73 cfg: &crate::config::DevinConfig,
74 cache: &Cache,
75 cache_ttl: Duration,
76) -> Result<FetchOutcome> {
77 let credentials = super::resolve_credentials(cfg)?;
78 fetch_snapshot_with(
79 http_client()?,
80 &credentials,
81 &super::client_version(),
82 cache,
83 &Endpoints::default(),
84 cache_ttl,
85 )
86 .await
87}
88
89async fn fetch_snapshot_with(
90 client: &reqwest::Client,
91 credentials: &Credentials,
92 client_version: &str,
93 cache: &Cache,
94 endpoints: &Endpoints,
95 cache_ttl: Duration,
96) -> Result<FetchOutcome> {
97 cache.ensure_dir()?;
98 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
99
100 bind_cache_login(cache, &credentials.fingerprint)?;
101
102 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
103 && let Ok(outcome) = reuse_cache(&bytes, cache, false, &credentials.fingerprint)
104 {
105 return Ok(outcome);
106 }
107
108 match usage_call(client, credentials, client_version, endpoints).await {
109 Ok(snapshot) => {
110 cache.write_payload(&serde_json::to_vec(&snapshot_to_cache(
111 &snapshot,
112 &credentials.fingerprint,
113 ))?)?;
114 Ok(crate::outcome::Outcome::fresh(snapshot))
115 }
116 Err(error) if error.is_transient() => {
117 fallback_silent(cache, &credentials.fingerprint, error)
118 }
119 Err(error) => {
120 cache.mark_stale();
121 let (code, message) = error_to_pair(&error);
122 let diagnostic = cache.write_last_error(code, &message);
123 fallback_with_error(cache, diagnostic, &credentials.fingerprint, error)
124 }
125 }
126}
127
128fn bind_cache_login(cache: &Cache, fingerprint: &str) -> Result<()> {
131 let account_path = cache.dir().join(ACCOUNT_FILE);
132 let account = match std::fs::read(&account_path) {
133 Ok(bytes) => Some(bytes),
134 Err(error) if error.kind() == ErrorKind::NotFound => None,
135 Err(error) => return Err(AppError::io_at(&account_path, error)),
136 };
137 let payload = cache.maybe_payload()?;
138 let valid_payload = payload
139 .as_deref()
140 .is_some_and(|bytes| parse_cache_at(bytes, fingerprint).is_ok());
141 let same_login = match account.as_deref() {
142 Some(account) => account == fingerprint.as_bytes(),
143 None => valid_payload,
144 };
145
146 if !same_login
150 || payload
151 .as_deref()
152 .is_some_and(|bytes| belongs_to_other_login(bytes, fingerprint))
153 {
154 for path in [
158 cache.payload_path(),
159 cache.stale_path(),
160 cache.last_error_path(),
161 cache.retry_after_path(),
162 ] {
163 if valid_payload && path == cache.payload_path() {
164 continue;
165 }
166 if let Err(error) = std::fs::remove_file(&path)
167 && error.kind() != ErrorKind::NotFound
168 {
169 return Err(AppError::io_at(path, error));
170 }
171 }
172 }
173 if account.as_deref() != Some(fingerprint.as_bytes()) {
174 atomic_write(&account_path, fingerprint.as_bytes())?;
175 }
176 Ok(())
177}
178
179async fn usage_call(
180 client: &reqwest::Client,
181 credentials: &Credentials,
182 client_version: &str,
183 endpoints: &Endpoints,
184) -> Result<DevinSnapshot> {
185 let body = serde_json::json!({
186 "metadata": {
187 "apiKey": &credentials.api_key,
188 "ideName": "devin",
189 "ideVersion": client_version,
190 "extensionVersion": client_version,
191 "locale": "en"
192 }
193 });
194 let response = client
197 .post(endpoints.user_status_url())
198 .header(reqwest::header::ACCEPT, "application/json")
199 .header(reqwest::header::CONTENT_TYPE, "application/json")
200 .header("connect-protocol-version", "1")
201 .json(&body)
202 .send()
203 .await
204 .map_err(|error| {
205 AppError::Transport(if error.is_timeout() {
206 "Devin status request timed out".into()
207 } else {
208 "Devin status request failed".into()
209 })
210 })?;
211
212 let status = response.status();
213 let bytes = read_body_capped(response, MAX_BODY_BYTES).await?;
214 if !status.is_success() {
215 return Err(AppError::Http {
219 status: status.as_u16(),
220 body: format!("Devin status endpoint returned HTTP {}", status.as_u16()),
221 });
222 }
223 parse_response(&bytes)
224}
225
226fn error_to_pair(error: &AppError) -> (u16, String) {
232 match error {
233 AppError::Http { status, body } => (*status, body.clone()),
234 AppError::Schema(message) => (0, message.clone()),
235 _ => (0, "Devin status request failed; details suppressed".into()),
236 }
237}
238
239fn belongs_to_other_login(bytes: &[u8], fingerprint: &str) -> bool {
243 serde_json::from_slice::<serde_json::Value>(bytes)
244 .ok()
245 .and_then(|value| {
246 value
247 .get("account")
248 .and_then(serde_json::Value::as_str)
249 .map(|account| account != fingerprint)
250 })
251 .unwrap_or(false)
252}
253
254fn fallback_silent(cache: &Cache, fingerprint: &str, original: AppError) -> Result<FetchOutcome> {
255 crate::outcome::fallback(cache, None, original, |bytes| {
256 parse_cache_at(bytes, fingerprint)
257 })
258}
259
260fn fallback_with_error(
261 cache: &Cache,
262 last_error: (u16, String),
263 fingerprint: &str,
264 original: AppError,
265) -> Result<FetchOutcome> {
266 crate::outcome::fallback(cache, Some(last_error), original, |bytes| {
267 parse_cache_at(bytes, fingerprint)
268 })
269}
270
271fn reuse_cache(
272 bytes: &[u8],
273 cache: &Cache,
274 stale: bool,
275 fingerprint: &str,
276) -> Result<FetchOutcome> {
277 let snapshot = parse_cache_at(bytes, fingerprint)?;
278 Ok(crate::outcome::Outcome::cached(snapshot, cache, stale))
279}
280
281fn snapshot_to_cache(snapshot: &DevinSnapshot, fingerprint: &str) -> serde_json::Value {
282 let window = |window: &Option<UsageWindow>| match window {
283 Some(window) => serde_json::json!({
284 "used_pct": window.utilization_pct,
285 "reset_unix": window.resets_at.map(|at| at.timestamp()),
286 }),
287 None => serde_json::Value::Null,
288 };
289 serde_json::json!({
290 "account": fingerprint,
291 "daily": window(&snapshot.daily),
292 "weekly": window(&snapshot.weekly),
293 "overage_balance_micros": snapshot.overage_balance_micros,
294 })
295}
296
297fn parse_cache_at(bytes: &[u8], fingerprint: &str) -> Result<DevinSnapshot> {
298 let value: serde_json::Value = serde_json::from_slice(bytes)
299 .map_err(|_| AppError::Schema("Devin cache was invalid JSON".into()))?;
300 if value.get("account").and_then(serde_json::Value::as_str) != Some(fingerprint) {
301 return Err(AppError::Schema(
302 "Devin cache belongs to a different login; refetching".into(),
303 ));
304 }
305 let invalid = |field: &str| AppError::Schema(format!("Devin cache: invalid {field}"));
306 let window = |field: &str, duration| -> Result<Option<UsageWindow>> {
307 let Some(value) = value.get(field) else {
308 return Err(invalid(field));
309 };
310 if value.is_null() {
311 return Ok(None);
312 }
313 let pct = value
314 .get("used_pct")
315 .and_then(serde_json::Value::as_i64)
316 .filter(|pct| (0..=100).contains(pct))
317 .ok_or_else(|| invalid(field))? as i32;
318 let reset = match value.get("reset_unix") {
319 None | Some(serde_json::Value::Null) => None,
320 Some(value) => {
321 let seconds = value
322 .as_i64()
323 .filter(|seconds| *seconds >= 0)
324 .ok_or_else(|| invalid(field))?;
325 Some(chrono::DateTime::from_timestamp(seconds, 0).ok_or_else(|| invalid(field))?)
326 }
327 };
328 Ok(Some(UsageWindow {
329 utilization_pct: pct,
330 resets_at: reset,
331 window_duration: duration,
332 }))
333 };
334 let overage_balance_micros = match value.get("overage_balance_micros") {
335 Some(serde_json::Value::Null) => None,
336 Some(value) => Some(value.as_i64().ok_or_else(|| invalid("overage balance"))?),
337 None => return Err(invalid("overage balance")),
338 };
339 Ok(DevinSnapshot {
340 daily: window("daily", DAILY_WINDOW)?,
341 weekly: window("weekly", WEEKLY_WINDOW)?,
342 overage_balance_micros,
343 })
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use tempfile::TempDir;
350
351 const CANARY: &str = "DevinSecretCanary-593827";
352 const TEST_CLIENT_VERSION: &str = "3000.11.3";
355
356 fn credentials(secret: &str) -> (TempDir, Credentials) {
357 let dir = TempDir::new().unwrap();
358 let path = dir.path().join("credentials.toml");
359 std::fs::write(&path, format!("windsurf_api_key = {secret:?}\n")).unwrap();
360 let credentials = super::super::creds::read_from(&path).unwrap();
361 (dir, credentials)
362 }
363
364 fn cache_fixture() -> (TempDir, Cache) {
365 let dir = TempDir::new().unwrap();
366 let cache = Cache::at(dir.path().join("devin"));
367 cache.ensure_dir().unwrap();
368 (dir, cache)
369 }
370
371 fn safe_client() -> reqwest::Client {
374 http_client().unwrap().clone()
375 }
376
377 fn client_with_timeout(timeout: Duration) -> reqwest::Client {
378 build_client(timeout).unwrap()
379 }
380
381 fn response_body() -> String {
382 serde_json::json!({
383 "userStatus": {"planStatus": {
384 "dailyQuotaRemainingPercent": 100,
385 "dailyQuotaResetAtUnix": "1791014400",
386 "weeklyQuotaRemainingPercent": "69",
387 "weeklyQuotaResetAtUnix": 1791100800,
388 "overageBalanceMicros": "9168615"
389 }}
390 })
391 .to_string()
392 }
393
394 fn request_mock(server: &mut mockito::ServerGuard, secret: &str) -> mockito::Mock {
395 server
396 .mock("POST", USER_STATUS_PATH)
397 .match_header("content-type", "application/json")
398 .match_header("connect-protocol-version", "1")
399 .match_body(mockito::Matcher::Json(serde_json::json!({
400 "metadata": {
401 "apiKey": secret,
402 "ideName": "devin",
403 "ideVersion": TEST_CLIENT_VERSION,
404 "extensionVersion": TEST_CLIENT_VERSION,
405 "locale": "en"
406 }
407 })))
408 }
409
410 fn snapshot() -> DevinSnapshot {
411 DevinSnapshot {
412 daily: Some(UsageWindow {
413 utilization_pct: 42,
414 resets_at: chrono::DateTime::from_timestamp(1_791_014_400, 0),
415 window_duration: DAILY_WINDOW,
416 }),
417 weekly: None,
418 overage_balance_micros: Some(9_168_615),
419 }
420 }
421
422 #[tokio::test]
423 async fn request_uses_the_cli_protocol_and_cache_contains_only_normalized_values() {
424 let (_credential_dir, credentials) = credentials(CANARY);
425 let mut server = mockito::Server::new_async().await;
426 let request = request_mock(&mut server, CANARY)
427 .with_status(200)
428 .with_body(response_body())
429 .create_async()
430 .await;
431 let (_cache_dir, cache) = cache_fixture();
432 let outcome = fetch_snapshot_with(
433 &safe_client(),
434 &credentials,
435 TEST_CLIENT_VERSION,
436 &cache,
437 &Endpoints { base: server.url() },
438 Duration::ZERO,
439 )
440 .await
441 .unwrap();
442 request.assert_async().await;
443 assert_eq!(outcome.snapshot.daily.as_ref().unwrap().utilization_pct, 0);
444 assert_eq!(
445 outcome.snapshot.weekly.as_ref().unwrap().utilization_pct,
446 31
447 );
448 assert_eq!(outcome.snapshot.overage_balance_micros, Some(9_168_615));
449 let stored = std::fs::read_to_string(cache.payload_path()).unwrap();
450 assert!(!stored.contains(CANARY));
451 assert!(!stored.contains("userStatus"));
452 assert!(!stored.contains("dailyQuotaRemainingPercent"));
453 assert_eq!(
454 parse_cache_at(stored.as_bytes(), &credentials.fingerprint).unwrap(),
455 outcome.snapshot
456 );
457 }
458
459 #[tokio::test]
460 async fn authentication_errors_hide_response_bodies_from_cache_and_fallback() {
461 for status in [401, 403] {
462 let (_credential_dir, credentials) = credentials(CANARY);
463 let mut server = mockito::Server::new_async().await;
464 let request = server
465 .mock("POST", USER_STATUS_PATH)
466 .with_status(status)
467 .with_body(format!("credential echo: {CANARY}"))
468 .create_async()
469 .await;
470 let (_cache_dir, cache) = cache_fixture();
471 cache
472 .write_payload(
473 &serde_json::to_vec(&snapshot_to_cache(&snapshot(), &credentials.fingerprint))
474 .unwrap(),
475 )
476 .unwrap();
477
478 let outcome = fetch_snapshot_with(
479 &safe_client(),
480 &credentials,
481 TEST_CLIENT_VERSION,
482 &cache,
483 &Endpoints { base: server.url() },
484 Duration::ZERO,
485 )
486 .await
487 .unwrap();
488 request.assert_async().await;
489 assert!(outcome.stale);
490 let (code, error) = outcome.last_error.unwrap();
491 assert_eq!(code, status as u16);
492 assert_eq!(error, crate::error::AUTH_FAILURE_MESSAGE);
493 assert!(!error.contains(CANARY));
494 for item in std::fs::read_dir(cache.dir()).unwrap() {
495 let item = item.unwrap();
496 if item.file_type().unwrap().is_file() {
497 let bytes = std::fs::read(item.path()).unwrap();
498 assert!(!String::from_utf8_lossy(&bytes).contains(CANARY));
499 }
500 }
501 }
502 }
503
504 #[tokio::test]
505 async fn missing_cli_credentials_fail_before_creating_or_touching_the_cache() {
506 let dir = TempDir::new().unwrap();
507 let credential_path = dir.path().join("missing-credentials.toml");
508 let cache_dir = dir.path().join("cache");
509 let cache = Cache::at(cache_dir.clone());
510 let cfg = crate::config::DevinConfig {
511 credentials_path: Some(credential_path),
512 ..Default::default()
513 };
514
515 let error = fetch_snapshot(&cfg, &cache, Duration::ZERO)
516 .await
517 .unwrap_err();
518
519 assert!(matches!(error, AppError::Credentials(_)));
520 assert!(!cache_dir.exists());
521 }
522
523 #[tokio::test]
524 async fn a_timed_out_response_body_is_a_transient_transport_error() {
525 let (_credential_dir, credentials) = credentials(CANARY);
526 let mut server = mockito::Server::new_async().await;
527 let request = request_mock(&mut server, CANARY)
528 .with_status(200)
529 .with_chunked_body(|writer| {
530 writer.write_all(b"{")?;
531 std::thread::sleep(Duration::from_millis(500));
532 writer.write_all(b"}")
533 })
534 .create_async()
535 .await;
536 let (_cache_dir, cache) = cache_fixture();
537
538 let error = fetch_snapshot_with(
539 &client_with_timeout(Duration::from_millis(200)),
540 &credentials,
541 TEST_CLIENT_VERSION,
542 &cache,
543 &Endpoints { base: server.url() },
544 Duration::ZERO,
545 )
546 .await
547 .unwrap_err();
548
549 request.assert_async().await;
550 assert!(matches!(error, AppError::Transport(_)), "{error:?}");
551 assert!(!cache.payload_path().exists());
552 }
553
554 #[tokio::test]
555 async fn a_cross_origin_redirect_is_not_followed_with_the_cli_key() {
556 let (_credential_dir, credentials) = credentials(CANARY);
557 let mut server = mockito::Server::new_async().await;
558 let request = request_mock(&mut server, CANARY)
559 .with_status(307)
560 .with_header("location", "https://attacker.invalid/collect")
561 .create_async()
562 .await;
563 let (_cache_dir, cache) = cache_fixture();
564 let error = fetch_snapshot_with(
565 &safe_client(),
566 &credentials,
567 TEST_CLIENT_VERSION,
568 &cache,
569 &Endpoints { base: server.url() },
570 Duration::ZERO,
571 )
572 .await
573 .unwrap_err();
574 request.assert_async().await;
575 assert!(matches!(error, AppError::Http { status: 307, .. }));
576 assert!(!error.to_string().contains(CANARY));
577 }
578
579 #[tokio::test]
580 async fn a_same_origin_redirect_is_not_followed_with_the_cli_key() {
581 let (_credential_dir, credentials) = credentials(CANARY);
582 let mut server = mockito::Server::new_async().await;
583 let destination = format!("{}/redirected", server.url());
584 let request = request_mock(&mut server, CANARY)
585 .with_status(307)
586 .with_header("location", &destination)
587 .create_async()
588 .await;
589 let redirected = server
590 .mock("POST", "/redirected")
591 .expect(0)
592 .create_async()
593 .await;
594 let (_cache_dir, cache) = cache_fixture();
595 let error = fetch_snapshot_with(
596 &safe_client(),
597 &credentials,
598 TEST_CLIENT_VERSION,
599 &cache,
600 &Endpoints { base: server.url() },
601 Duration::ZERO,
602 )
603 .await
604 .unwrap_err();
605 request.assert_async().await;
606 redirected.assert_async().await;
607 assert!(matches!(error, AppError::Http { status: 307, .. }));
608 assert!(!error.to_string().contains(CANARY));
609 }
610
611 #[tokio::test]
612 async fn a_cache_for_another_login_is_refetched_not_reused() {
613 let (_old_dir, old_credentials) = credentials("old-account-key");
614 let (_new_dir, credentials) = credentials("new-account-key");
615 let mut server = mockito::Server::new_async().await;
616 let request = request_mock(&mut server, "new-account-key")
617 .with_status(200)
618 .with_body(response_body())
619 .create_async()
620 .await;
621 let (_cache_dir, cache) = cache_fixture();
622 let mut old_snapshot = snapshot();
623 old_snapshot.daily.as_mut().unwrap().utilization_pct = 99;
624 cache
625 .write_payload(
626 &serde_json::to_vec(&snapshot_to_cache(
627 &old_snapshot,
628 &old_credentials.fingerprint,
629 ))
630 .unwrap(),
631 )
632 .unwrap();
633 let outcome = fetch_snapshot_with(
634 &safe_client(),
635 &credentials,
636 TEST_CLIENT_VERSION,
637 &cache,
638 &Endpoints { base: server.url() },
639 Duration::from_secs(3600),
640 )
641 .await
642 .unwrap();
643 request.assert_async().await;
644 assert_eq!(outcome.snapshot.daily.unwrap().utilization_pct, 0);
645 }
646
647 #[tokio::test]
648 async fn an_auth_rejection_without_a_cache_keeps_the_real_status() {
649 for status in [401, 403] {
650 let (_credential_dir, credentials) = credentials(CANARY);
651 let mut server = mockito::Server::new_async().await;
652 let request = server
653 .mock("POST", USER_STATUS_PATH)
654 .with_status(status)
655 .with_body(format!("credential echo: {CANARY}"))
656 .create_async()
657 .await;
658 let (_cache_dir, cache) = cache_fixture();
659
660 let error = fetch_snapshot_with(
661 &safe_client(),
662 &credentials,
663 TEST_CLIENT_VERSION,
664 &cache,
665 &Endpoints { base: server.url() },
666 Duration::ZERO,
667 )
668 .await
669 .unwrap_err();
670
671 request.assert_async().await;
672 assert!(
673 matches!(&error, AppError::Http { status: got, .. } if *got == status as u16),
674 "{error:?}"
675 );
676 assert!(!error.to_string().contains(CANARY));
677 assert!(
678 error
679 .user_message()
680 .contains(crate::error::AUTH_FAILURE_MESSAGE)
681 );
682 assert_eq!(
683 cache.read_last_error(),
684 Some((
685 status as u16,
686 crate::error::AUTH_FAILURE_MESSAGE.to_string()
687 ))
688 );
689 }
690 }
691
692 #[tokio::test]
693 async fn a_changed_login_forgets_the_previous_logins_payload_and_backoff() {
694 let (_old_dir, old_credentials) = credentials("old-account-key");
695 let (_new_dir, credentials) = credentials("new-account-key");
696 let mut server = mockito::Server::new_async().await;
697 let request = request_mock(&mut server, "new-account-key")
698 .with_status(200)
699 .with_body(response_body())
700 .expect(1)
701 .create_async()
702 .await;
703 let (_cache_dir, cache) = cache_fixture();
704 cache
705 .write_payload(
706 &serde_json::to_vec(&snapshot_to_cache(
707 &snapshot(),
708 &old_credentials.fingerprint,
709 ))
710 .unwrap(),
711 )
712 .unwrap();
713 cache.mark_stale();
714 cache.write_last_error(500, "previous login failed");
715 cache.note_rate_limit_at(std::time::SystemTime::now());
716 assert!(cache.backoff_remaining().is_some());
717
718 let outcome = fetch_snapshot_with(
719 &safe_client(),
720 &credentials,
721 TEST_CLIENT_VERSION,
722 &cache,
723 &Endpoints { base: server.url() },
724 Duration::from_secs(3600),
725 )
726 .await
727 .unwrap();
728
729 request.assert_async().await;
730 assert!(!outcome.stale);
731 assert!(outcome.last_error.is_none());
732 assert_eq!(outcome.snapshot.daily.unwrap().utilization_pct, 0);
733 assert!(!cache.is_stale());
734 assert!(cache.read_last_error().is_none());
735 assert!(cache.backoff_remaining().is_none());
736 let stored = std::fs::read(cache.payload_path()).unwrap();
737 assert!(parse_cache_at(&stored, &credentials.fingerprint).is_ok());
738 }
739
740 #[tokio::test]
741 async fn a_cold_cache_backoff_is_scoped_to_the_login_across_reopens() {
742 let (_old_dir, old_credentials) = credentials(CANARY);
743 let (_new_dir, new_credentials) = credentials("new-account-key");
744 let mut server = mockito::Server::new_async().await;
745 let old_request = request_mock(&mut server, CANARY)
746 .with_status(429)
747 .expect(1)
748 .create_async()
749 .await;
750 let new_request = request_mock(&mut server, "new-account-key")
751 .with_status(200)
752 .with_body(response_body())
753 .expect(1)
754 .create_async()
755 .await;
756 let (_cache_dir, cache) = cache_fixture();
757 let endpoints = Endpoints { base: server.url() };
758 let client = safe_client();
759
760 let first = fetch_snapshot_with(
761 &client,
762 &old_credentials,
763 TEST_CLIENT_VERSION,
764 &cache,
765 &endpoints,
766 Duration::ZERO,
767 )
768 .await
769 .unwrap_err();
770 assert!(matches!(first, AppError::Http { status: 429, .. }));
771 assert!(!cache.payload_path().exists());
772 assert!(cache.backoff_remaining().is_some());
773 let retry_after = std::fs::read(cache.retry_after_path()).unwrap();
774
775 let cache = Cache::at(cache.dir().to_path_buf());
776 let same_login = fetch_snapshot_with(
777 &client,
778 &old_credentials,
779 TEST_CLIENT_VERSION,
780 &cache,
781 &endpoints,
782 Duration::ZERO,
783 )
784 .await
785 .unwrap_err();
786 assert!(matches!(same_login, AppError::Http { status: 429, .. }));
787 assert_eq!(
788 std::fs::read(cache.retry_after_path()).unwrap(),
789 retry_after
790 );
791 old_request.assert_async().await;
792
793 let cache = Cache::at(cache.dir().to_path_buf());
794 let changed_login = fetch_snapshot_with(
795 &client,
796 &new_credentials,
797 TEST_CLIENT_VERSION,
798 &cache,
799 &endpoints,
800 Duration::from_secs(3600),
801 )
802 .await
803 .unwrap();
804 new_request.assert_async().await;
805 assert!(!changed_login.stale);
806 assert!(changed_login.last_error.is_none());
807 assert!(!cache.is_stale());
808 assert!(cache.read_last_error().is_none());
809 assert!(cache.backoff_remaining().is_none());
810 let stored = std::fs::read(cache.payload_path()).unwrap();
811 assert_eq!(
812 parse_cache_at(&stored, &new_credentials.fingerprint).unwrap(),
813 changed_login.snapshot
814 );
815 let account = std::fs::read_to_string(cache.dir().join(ACCOUNT_FILE)).unwrap();
816 assert_eq!(account, new_credentials.fingerprint);
817 assert_eq!(account.len(), 16);
818 assert!(
819 account
820 .bytes()
821 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
822 );
823 assert!(!account.contains(CANARY));
824 assert!(!account.contains("new-account-key"));
825 }
826
827 #[test]
828 fn unowned_backoff_is_discarded_once_when_migrating_a_cold_cache() {
829 let (_credential_dir, credentials) = credentials(CANARY);
830 let (_cache_dir, cache) = cache_fixture();
831 cache.mark_stale();
832 cache.write_last_error(429, "legacy rate limit");
833 assert!(!cache.payload_path().exists());
834 assert!(!cache.dir().join(ACCOUNT_FILE).exists());
835 assert!(cache.backoff_remaining().is_some());
836
837 bind_cache_login(&cache, &credentials.fingerprint).unwrap();
838 assert!(!cache.is_stale());
839 assert!(cache.read_last_error().is_none());
840 assert!(cache.backoff_remaining().is_none());
841 assert_eq!(
842 std::fs::read_to_string(cache.dir().join(ACCOUNT_FILE)).unwrap(),
843 credentials.fingerprint
844 );
845
846 cache.write_last_error(429, "current login rate limit");
847 let retry_after = std::fs::read(cache.retry_after_path()).unwrap();
848 let cache = Cache::at(cache.dir().to_path_buf());
849 bind_cache_login(&cache, &credentials.fingerprint).unwrap();
850 assert_eq!(
851 std::fs::read(cache.retry_after_path()).unwrap(),
852 retry_after
853 );
854 assert_eq!(
855 cache.read_last_error(),
856 Some((429, "current login rate limit".into()))
857 );
858 }
859
860 #[test]
861 fn failed_cache_invalidation_does_not_record_a_new_login() {
862 let (_cache_dir, cache) = cache_fixture();
863 let old_fingerprint = crate::cache::fingerprint_of("old-account-key");
864 let new_fingerprint = crate::cache::fingerprint_of("new-account-key");
865 let account_path = cache.dir().join(ACCOUNT_FILE);
866 std::fs::write(&account_path, &old_fingerprint).unwrap();
867 cache
868 .write_payload(
869 &serde_json::to_vec(&snapshot_to_cache(&snapshot(), &old_fingerprint)).unwrap(),
870 )
871 .unwrap();
872 cache.write_last_error(429, "old login rate limit");
873 std::fs::create_dir(cache.stale_path()).unwrap();
877
878 let error = bind_cache_login(&cache, &new_fingerprint).unwrap_err();
879 assert!(matches!(error, AppError::Io { .. }));
880 assert!(!cache.payload_path().exists());
881 assert!(cache.stale_path().is_dir());
882 assert!(cache.last_error_path().exists());
883 assert!(cache.retry_after_path().exists());
884 assert_eq!(
885 std::fs::read_to_string(account_path).unwrap(),
886 old_fingerprint
887 );
888 }
889
890 #[test]
891 fn a_valid_current_payload_survives_an_old_login_marker() {
892 let (_cache_dir, cache) = cache_fixture();
893 let old_fingerprint = crate::cache::fingerprint_of("old-account-key");
894 let fingerprint = crate::cache::fingerprint_of("new-account-key");
895 let payload = serde_json::to_vec(&snapshot_to_cache(&snapshot(), &fingerprint)).unwrap();
896 cache.write_payload(&payload).unwrap();
897 std::fs::write(cache.dir().join(ACCOUNT_FILE), old_fingerprint).unwrap();
898 cache.mark_stale();
899 cache.write_last_error(429, "old login rate limit");
900
901 bind_cache_login(&cache, &fingerprint).unwrap();
902
903 assert_eq!(cache.maybe_payload().unwrap(), Some(payload));
904 assert!(!cache.stale_path().exists());
905 assert!(!cache.last_error_path().exists());
906 assert!(!cache.retry_after_path().exists());
907 assert_eq!(
908 std::fs::read_to_string(cache.dir().join(ACCOUNT_FILE)).unwrap(),
909 fingerprint
910 );
911 }
912
913 #[test]
914 fn a_surviving_login_marker_after_forget_is_safe_to_rebind() {
915 let (_cache_dir, cache) = cache_fixture();
916 let fingerprint = crate::cache::fingerprint_of("old-account-key");
917 bind_cache_login(&cache, &fingerprint).unwrap();
918 cache.write_last_error(429, "old login rate limit");
919 cache.forget();
920 assert_eq!(
921 std::fs::read_to_string(cache.dir().join(ACCOUNT_FILE)).unwrap(),
922 fingerprint
923 );
924
925 bind_cache_login(&cache, &fingerprint).unwrap();
926 assert!(!cache.payload_path().exists());
927 assert!(!cache.retry_after_path().exists());
928
929 let new_fingerprint = crate::cache::fingerprint_of("new-account-key");
930 bind_cache_login(&cache, &new_fingerprint).unwrap();
931 assert_eq!(
932 std::fs::read_to_string(cache.dir().join(ACCOUNT_FILE)).unwrap(),
933 new_fingerprint
934 );
935 }
936
937 #[tokio::test]
938 async fn the_same_login_keeps_its_backoff() {
939 let (_credential_dir, credentials) = credentials(CANARY);
940 let mut server = mockito::Server::new_async().await;
941 let request = request_mock(&mut server, CANARY)
942 .expect(0)
943 .create_async()
944 .await;
945 let (_cache_dir, cache) = cache_fixture();
946 cache
947 .write_payload(
948 &serde_json::to_vec(&snapshot_to_cache(&snapshot(), &credentials.fingerprint))
949 .unwrap(),
950 )
951 .unwrap();
952 cache.note_rate_limit_at(std::time::SystemTime::now());
953
954 let outcome = fetch_snapshot_with(
955 &safe_client(),
956 &credentials,
957 TEST_CLIENT_VERSION,
958 &cache,
959 &Endpoints { base: server.url() },
960 Duration::ZERO,
961 )
962 .await
963 .unwrap();
964
965 request.assert_async().await;
966 assert_eq!(outcome.snapshot, snapshot());
967 assert!(cache.backoff_remaining().is_some());
968 assert_eq!(
969 std::fs::read_to_string(cache.dir().join(ACCOUNT_FILE)).unwrap(),
970 credentials.fingerprint
971 );
972 }
973
974 #[tokio::test]
975 async fn a_corrupt_cache_is_replaced_by_a_successful_fetch() {
976 let (_credential_dir, credentials) = credentials(CANARY);
977 let mut server = mockito::Server::new_async().await;
978 let request = request_mock(&mut server, CANARY)
979 .with_status(200)
980 .with_body(response_body())
981 .create_async()
982 .await;
983 let (_cache_dir, cache) = cache_fixture();
984 cache.write_payload(b"not-json").unwrap();
985
986 let outcome = fetch_snapshot_with(
987 &safe_client(),
988 &credentials,
989 TEST_CLIENT_VERSION,
990 &cache,
991 &Endpoints { base: server.url() },
992 Duration::from_secs(3600),
993 )
994 .await
995 .unwrap();
996
997 request.assert_async().await;
998 assert!(!outcome.stale);
999 assert_eq!(outcome.snapshot.daily.unwrap().utilization_pct, 0);
1000 let stored = std::fs::read(cache.payload_path()).unwrap();
1001 assert!(parse_cache_at(&stored, &credentials.fingerprint).is_ok());
1002 }
1003
1004 #[tokio::test]
1005 async fn an_expired_cache_is_refreshed_and_replaced() {
1006 let (_credential_dir, credentials) = credentials(CANARY);
1007 let mut server = mockito::Server::new_async().await;
1008 let request = request_mock(&mut server, CANARY)
1009 .with_status(200)
1010 .with_body(response_body())
1011 .create_async()
1012 .await;
1013 let (_cache_dir, cache) = cache_fixture();
1014 cache
1015 .write_payload(
1016 &serde_json::to_vec(&snapshot_to_cache(&snapshot(), &credentials.fingerprint))
1017 .unwrap(),
1018 )
1019 .unwrap();
1020 std::fs::OpenOptions::new()
1021 .write(true)
1022 .open(cache.payload_path())
1023 .unwrap()
1024 .set_times(std::fs::FileTimes::new().set_modified(std::time::SystemTime::UNIX_EPOCH))
1025 .unwrap();
1026
1027 let outcome = fetch_snapshot_with(
1028 &safe_client(),
1029 &credentials,
1030 TEST_CLIENT_VERSION,
1031 &cache,
1032 &Endpoints { base: server.url() },
1033 Duration::from_secs(3600),
1034 )
1035 .await
1036 .unwrap();
1037
1038 request.assert_async().await;
1039 assert!(!outcome.stale);
1040 assert_eq!(outcome.snapshot.daily.unwrap().utilization_pct, 0);
1041 }
1042
1043 #[tokio::test]
1044 async fn rate_limits_arm_backoff_and_skip_the_next_network_request() {
1045 let (_credential_dir, credentials) = credentials(CANARY);
1046 let mut server = mockito::Server::new_async().await;
1047 let request = request_mock(&mut server, CANARY)
1048 .with_status(429)
1049 .with_body("rate-limit-body-must-not-be-persisted")
1050 .expect(1)
1051 .create_async()
1052 .await;
1053 let (_cache_dir, cache) = cache_fixture();
1054 cache
1055 .write_payload(
1056 &serde_json::to_vec(&snapshot_to_cache(&snapshot(), &credentials.fingerprint))
1057 .unwrap(),
1058 )
1059 .unwrap();
1060 let endpoints = Endpoints { base: server.url() };
1061
1062 let first = fetch_snapshot_with(
1063 &safe_client(),
1064 &credentials,
1065 TEST_CLIENT_VERSION,
1066 &cache,
1067 &endpoints,
1068 Duration::ZERO,
1069 )
1070 .await
1071 .unwrap();
1072 assert!(first.stale);
1073 assert_eq!(first.last_error.as_ref().map(|(code, _)| *code), Some(429));
1074 assert!(cache.backoff_remaining().is_some());
1075 assert!(
1076 !std::fs::read_to_string(cache.last_error_path())
1077 .unwrap()
1078 .contains("rate-limit-body-must-not-be-persisted")
1079 );
1080
1081 let second = fetch_snapshot_with(
1082 &safe_client(),
1083 &credentials,
1084 TEST_CLIENT_VERSION,
1085 &cache,
1086 &endpoints,
1087 Duration::ZERO,
1088 )
1089 .await
1090 .unwrap();
1091 request.assert_async().await;
1092 assert!(second.stale);
1093 assert_eq!(second.last_error.as_ref().map(|(code, _)| *code), Some(429));
1094 }
1095
1096 #[tokio::test]
1097 async fn a_fresh_identity_matching_cache_does_not_touch_the_network() {
1098 let (_credential_dir, credentials) = credentials(CANARY);
1099 let (_cache_dir, cache) = cache_fixture();
1100 cache
1101 .write_payload(
1102 &serde_json::to_vec(&snapshot_to_cache(&snapshot(), &credentials.fingerprint))
1103 .unwrap(),
1104 )
1105 .unwrap();
1106 let outcome = fetch_snapshot_with(
1107 &safe_client(),
1108 &credentials,
1109 TEST_CLIENT_VERSION,
1110 &cache,
1111 &Endpoints {
1112 base: "http://127.0.0.1:1".into(),
1113 },
1114 Duration::from_secs(60),
1115 )
1116 .await
1117 .unwrap();
1118 assert_eq!(outcome.snapshot, snapshot());
1119 assert!(!outcome.stale);
1120 }
1121
1122 #[test]
1123 fn cache_requires_identity_and_valid_metric_shapes() {
1124 let snapshot = snapshot();
1125 let encoded = serde_json::to_vec(&snapshot_to_cache(&snapshot, "account-a")).unwrap();
1126 assert_eq!(parse_cache_at(&encoded, "account-a").unwrap(), snapshot);
1127 assert!(parse_cache_at(&encoded, "account-b").is_err());
1128 for invalid in [
1129 serde_json::json!({"account":"account-a","daily":{"used_pct":101},"weekly":null,"overage_balance_micros":null}),
1130 serde_json::json!({"account":"account-a","daily":null,"weekly":null}),
1131 serde_json::json!({"account":"account-a","daily":{"used_pct":1,"reset_unix":-1},"weekly":null,"overage_balance_micros":null}),
1132 ] {
1133 assert!(parse_cache_at(invalid.to_string().as_bytes(), "account-a").is_err());
1134 }
1135 }
1136}