Skip to main content

ai_usagebar/devin/
fetch.rs

1//! Read Devin quota status and keep only normalized metrics in the local cache.
2//!
3//! The CLI key travels only as the `metadata.apiKey` field of the JSON request
4//! body, to the fixed Cognition host. Neither the raw response nor the
5//! credential is persisted.
6
7use 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);
21// Devin-owned login marker: only the 16-hex truncated SHA-256 fingerprint,
22// stored in plain text like the payload's account field, never the CLI key.
23const 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
47/// The client refuses every redirect, so the CLI key is never forwarded or
48/// re-sent through one, including a same-origin redirect.
49fn 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
56/// Devin keeps its own client instead of the process-wide one that the widget
57/// and TUI share: that client follows redirects, and this provider must never
58/// send the CLI key through one.
59static 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
68/// Production entry point; resolves the official CLI file on every call,
69/// including cache hits, so a changed login cannot inherit another account's
70/// cached quota. Credentials are resolved before any client is built, so a
71/// missing login costs nothing.
72pub 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
128// Called under the fetch lock. Cache::forget/clear_backoff do not own .account;
129// it may survive either operation and is reconciled here before cache reuse.
130fn 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    // The credential behind this cache changed hands or was never bound: the
147    // sidecars describe a previous or unestablished login and must not shape
148    // the new one's refresh; a valid current-login payload is retained.
149    if !same_login
150        || payload
151            .as_deref()
152            .is_some_and(|bytes| belongs_to_other_login(bytes, fingerprint))
153    {
154        // Strict cleanup intentionally stays Devin-local until a separate
155        // shared-cache change can centralize ownership and the sidecar list
156        // with Cache::forget.
157        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    // The client's own timeout bounds the request; the error is rebuilt from
195    // fixed text so nothing from the request (URL, headers) can reach a log.
196    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        // The response body is never copied into the error: a 401/403 from a
216        // key-bearing endpoint may echo account detail. The real status is
217        // kept, and `Cache::write_last_error` redacts 401/403 messages.
218        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
226/// Only an HTTP status or a schema message survives into `.last_error` and the
227/// tooltip. Everything else (I/O while reading the body, client setup) is
228/// collapsed to fixed text on purpose: those errors can carry a path or a
229/// request detail, and this is a key-bearing request. Model Studio exposes
230/// `e.to_string()` here; the loss of diagnosis is accepted for Devin.
231fn 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
239/// True only for a payload that names a *different* login. A payload that
240/// does not parse or carries no identity is left for the normal replace-on-
241/// success path rather than treated as another account's.
242fn 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    /// Distinct from the fallback, so the body matchers prove the caller's
353    /// version reaches the request rather than a constant.
354    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    /// The production client itself, so the redirect tests exercise the real
372    /// no-redirect policy rather than a test-local copy of it.
373    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        // remove_file rejects a directory on Unix and Windows. The payload is
874        // removed first, so this exercises partial cleanup without committing
875        // the new identity.
876        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}