Skip to main content

ai_usagebar/cursor/
fetch.rs

1//! Fetch Cursor's included-usage summary from `GET /api/usage-summary`,
2//! authenticated with the session token read out of the local `state.vscdb`
3//! (see `db.rs`). Cache/stale/error-fallback shape mirrors `kimi::fetch`.
4//!
5//! Credit grants are a second call, `POST GetClientVisibleCreditGrants` on
6//! `api2.cursor.sh`, with the same access token as a Bearer credential. That
7//! call is best-effort: a timeout, a non-2xx, or a body that is not a grant
8//! list leaves `credits` empty and the usage bars up.
9
10use std::path::Path;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14
15use crate::cache::{Cache, acquire_lock_async};
16use crate::error::{AppError, Result};
17use crate::usage::{CursorCreditGrant, CursorSnapshot};
18use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
19
20use super::db;
21use super::types::{self, UsageSummary};
22
23pub const BASE_URL: &str = "https://cursor.com";
24/// Connect-RPC the spending page's Credits card is drawn from. Same access
25/// token as the usage-summary cookie, sent as a Bearer credential.
26pub const CREDITS_URL: &str =
27    "https://api2.cursor.sh/aiserver.v1.DashboardService/GetClientVisibleCreditGrants";
28const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
29const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
30/// The dashboard endpoint gates on browser-looking headers; a plain
31/// `reqwest` request with only the cookie is rejected by its CORS check.
32const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
33    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
34
35#[derive(Debug, Clone)]
36pub struct Endpoints {
37    pub summary: String,
38    pub credits: String,
39}
40
41impl Default for Endpoints {
42    fn default() -> Self {
43        Self {
44            summary: format!("{BASE_URL}/api/usage-summary"),
45            credits: CREDITS_URL.into(),
46        }
47    }
48}
49
50/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
51/// specialised to its snapshot.
52pub type FetchOutcome = crate::outcome::Outcome<CursorSnapshot>;
53
54/// Cache-aware fetch. `db_path` is Cursor's `state.vscdb` — the caller resolves
55/// `[cursor] db_path` (config override) vs [`db::default_db_path`], the same
56/// override pattern as `openai.codex_auth_path`. `agent_auth_path` is the
57/// headless `cursor-agent` CLI's own `auth.json`, tried when `db_path` is
58/// missing — see `db::resolve_access_token`, which on macOS also tries the
59/// CLI's Keychain items.
60pub async fn fetch_snapshot(
61    client: &reqwest::Client,
62    db_path: &Path,
63    agent_auth_path: &Path,
64    cache: &Cache,
65    endpoints: &Endpoints,
66    cache_ttl: Duration,
67) -> Result<FetchOutcome> {
68    fetch_snapshot_at(
69        client,
70        db_path,
71        agent_auth_path,
72        cache,
73        endpoints,
74        cache_ttl,
75        Utc::now(),
76    )
77    .await
78}
79
80/// Clock seam for cache rollover tests. Cursor's payload describes one billing
81/// cycle, so serving it after `reset_at` would knowingly show the prior cycle.
82async fn fetch_snapshot_at(
83    client: &reqwest::Client,
84    db_path: &Path,
85    agent_auth_path: &Path,
86    cache: &Cache,
87    endpoints: &Endpoints,
88    cache_ttl: Duration,
89    now: DateTime<Utc>,
90) -> Result<FetchOutcome> {
91    cache.ensure_dir()?;
92    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
93
94    // Resolve the local identity before accepting a cache hit. Cursor can switch
95    // accounts in-place in this database; returning the cache first would show
96    // the previous account's private usage until the TTL elapsed.
97    let token = db::resolve_access_token(db_path, agent_auth_path)?;
98    let auth = db::session_auth_for(db_path, &token)?;
99
100    // A payload written before credit grants existed has no `credits` key.
101    // Serving it fresh would hide a balance the spending page is showing
102    // until the TTL elapsed, so that cache is not a hit.
103    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
104        && payload_records_credits(&bytes)
105        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &auth.account_key, now)
106    {
107        return Ok(outcome);
108    }
109    // #515: during a backoff `fresh_payload` returns bytes this refresh can
110    // refuse, such as a payload past its billing-cycle reset or one written
111    // before credit grants existed. The shared backoff still applies, as it
112    // does for the Anthropic API since #496: surface the countdown instead of
113    // contacting the server, or a refresh interval shorter than the backoff
114    // would re-request and re-arm it forever.
115    if let Some(remaining) = cache.backoff_remaining() {
116        return Err(AppError::Http {
117            status: 429,
118            body: format!(
119                "rate limited; next attempt in {}",
120                crate::cache::human_backoff(remaining)
121            ),
122        });
123    }
124
125    match fetch_live(client, endpoints, &auth, &token).await {
126        Ok(snap) => {
127            let bytes = serde_json::to_vec(&snap_to_json(&snap, &auth.account_key))?;
128            cache.write_payload(&bytes)?;
129            Ok(crate::outcome::Outcome::fresh(snap))
130        }
131        Err(e) if e.is_transient() => fallback_silent(cache, &auth.account_key, now, e),
132        Err(e) => {
133            cache.mark_stale();
134            if let Some((code, msg)) = error_to_pair(&e) {
135                cache.write_last_error(code, &msg);
136            }
137            fallback_with_error(cache, &auth.account_key, now, e)
138        }
139    }
140}
141
142fn fallback_silent(
143    cache: &Cache,
144    account: &str,
145    now: DateTime<Utc>,
146    original: AppError,
147) -> Result<FetchOutcome> {
148    crate::outcome::fallback(cache, None, original, |bytes| {
149        parse_cache_at(bytes, account, now)
150    })
151}
152
153fn fallback_with_error(
154    cache: &Cache,
155    account: &str,
156    now: DateTime<Utc>,
157    original: AppError,
158) -> Result<FetchOutcome> {
159    let last_error = error_to_pair(&original);
160    crate::outcome::fallback(cache, last_error, original, |bytes| {
161        parse_cache_at(bytes, account, now)
162    })
163}
164
165/// Never surface upstream bodies for auth failures: the request carried a
166/// session cookie derived from a signed-in token, and 401/403 bodies from a
167/// scraped web endpoint are not guaranteed not to echo it back.
168fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
169    match e {
170        AppError::Http { status, .. } if matches!(status, 401 | 403) => {
171            Some((*status, "Cursor authentication failed".into()))
172        }
173        AppError::Http { status, body } => Some((*status, body.clone())),
174        e => Some((0, e.to_string())),
175    }
176}
177
178fn reuse_cache(
179    bytes: &[u8],
180    cache: &Cache,
181    stale: bool,
182    account: &str,
183    now: DateTime<Utc>,
184) -> Result<FetchOutcome> {
185    let snap = parse_cache_at(bytes, account, now)?;
186    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
187}
188
189fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<CursorSnapshot> {
190    let v: serde_json::Value = serde_json::from_slice(bytes)?;
191    if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
192        return Err(AppError::Schema(
193            "cursor cache belongs to a different account; refetching".into(),
194        ));
195    }
196    let int = |key: &str| -> Result<i32> {
197        v[key]
198            .as_i64()
199            .filter(|n| *n >= 0)
200            .and_then(|n| i32::try_from(n).ok())
201            .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}")))
202    };
203    let optional_cents = |key: &str| -> Result<Option<i64>> {
204        match v.get(key) {
205            None | Some(serde_json::Value::Null) => Ok(None),
206            Some(value) => value
207                .as_i64()
208                .filter(|n| *n >= 0)
209                .map(Some)
210                .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}"))),
211        }
212    };
213    let plan = v["plan"]
214        .as_str()
215        .filter(|plan| !plan.trim().is_empty())
216        .ok_or_else(|| AppError::Schema("cursor cache: invalid plan".into()))?
217        .to_string();
218    let reset_at = parse_cache_datetime(&v["reset_at"])?
219        .ok_or_else(|| AppError::Schema("cursor cache: missing reset timestamp".into()))?;
220    if reset_at <= now {
221        return Err(AppError::Schema(
222            "cursor cache is past its billing-cycle reset; refetching".into(),
223        ));
224    }
225    Ok(CursorSnapshot {
226        plan,
227        auto_pct: int("auto_pct")?,
228        api_pct: int("api_pct")?,
229        total_pct: int("total_pct")?,
230        unlimited: v["unlimited"]
231            .as_bool()
232            .ok_or_else(|| AppError::Schema("cursor cache: invalid unlimited flag".into()))?,
233        on_demand_enabled: v["on_demand_enabled"]
234            .as_bool()
235            .ok_or_else(|| AppError::Schema("cursor cache: invalid on-demand flag".into()))?,
236        on_demand_used_cents: optional_cents("on_demand_used_cents")?,
237        on_demand_limit_cents: optional_cents("on_demand_limit_cents")?,
238        reset_at: Some(reset_at),
239        cycle_start: v
240            .get("cycle_start")
241            .and_then(|c| parse_cache_datetime(c).ok())
242            .flatten(),
243        credits: parse_cached_credits(&v),
244    })
245}
246
247/// A missing `credits` key is a cache written before grants existed. A bad
248/// entry is skipped so one corrupt grant cannot throw away the usage bars.
249/// `credits` must be present, even as an empty list. Absence means the
250/// payload predates the field and is not a complete snapshot.
251fn payload_records_credits(bytes: &[u8]) -> bool {
252    serde_json::from_slice::<serde_json::Value>(bytes)
253        .ok()
254        .is_some_and(|value| value.get("credits").is_some())
255}
256
257fn parse_cached_credits(v: &serde_json::Value) -> Vec<CursorCreditGrant> {
258    let Some(items) = v.get("credits").and_then(serde_json::Value::as_array) else {
259        return Vec::new();
260    };
261    items.iter().filter_map(cached_credit).collect()
262}
263
264fn cached_credit(item: &serde_json::Value) -> Option<CursorCreditGrant> {
265    let remaining_cents = item.get("remaining_cents")?.as_i64().filter(|n| *n >= 0)?;
266    let total_cents = item.get("total_cents")?.as_i64().filter(|n| *n > 0)?;
267    let expires_at = match item.get("expires_at") {
268        None | Some(serde_json::Value::Null) => None,
269        Some(raw) => Some(parse_cache_datetime(raw).ok().flatten()?),
270    };
271    let display_name = item
272        .get("display_name")
273        .and_then(serde_json::Value::as_str)
274        .unwrap_or("")
275        .trim()
276        .chars()
277        .take(160)
278        .collect();
279    Some(CursorCreditGrant {
280        remaining_cents,
281        total_cents,
282        expires_at,
283        display_name,
284    })
285}
286
287fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
288    match v {
289        serde_json::Value::Null => Ok(None),
290        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
291            .map(|dt| Some(dt.into()))
292            .map_err(|e| AppError::Schema(format!("cursor cache: invalid reset timestamp: {e}"))),
293        _ => Err(AppError::Schema(
294            "cursor cache: invalid reset timestamp".into(),
295        )),
296    }
297}
298
299fn snap_to_json(snap: &CursorSnapshot, account: &str) -> serde_json::Value {
300    serde_json::json!({
301        "account": account,
302        "plan": snap.plan,
303        "auto_pct": snap.auto_pct,
304        "api_pct": snap.api_pct,
305        "total_pct": snap.total_pct,
306        "unlimited": snap.unlimited,
307        "on_demand_enabled": snap.on_demand_enabled,
308        "on_demand_used_cents": snap.on_demand_used_cents,
309        "on_demand_limit_cents": snap.on_demand_limit_cents,
310        "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
311        "cycle_start": snap.cycle_start.map(|dt| dt.to_rfc3339()),
312        "credits": snap.credits.iter().map(|grant| serde_json::json!({
313            "remaining_cents": grant.remaining_cents,
314            "total_cents": grant.total_cents,
315            "expires_at": grant.expires_at.map(|dt| dt.to_rfc3339()),
316            "display_name": grant.display_name,
317        })).collect::<Vec<_>>(),
318    })
319}
320
321async fn fetch_live(
322    client: &reqwest::Client,
323    endpoints: &Endpoints,
324    auth: &db::SessionAuth,
325    access_token: &str,
326) -> Result<CursorSnapshot> {
327    // usage-summary keys off the session cookie alone (no `?user=` param); the
328    // browser-ish headers get past its CORS gate. The endpoint sits behind
329    // Vercel's WAF, which negotiates client hints (`accept-ch`) and denies
330    // automated-looking clients outright (#410) — send the full set a browser
331    // sends, not only the UA.
332    let resp = tokio::time::timeout(
333        HTTP_TIMEOUT,
334        client
335            .get(&endpoints.summary)
336            .header(
337                "Cookie",
338                format!("WorkosCursorSessionToken={}", auth.cookie_value),
339            )
340            .header("Origin", BASE_URL)
341            .header("Referer", format!("{BASE_URL}/dashboard"))
342            .header("User-Agent", BROWSER_UA)
343            .header("Accept", "application/json, text/plain, */*")
344            .header("Accept-Language", "en-US,en;q=0.9")
345            .header(
346                "sec-ch-ua",
347                "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
348            )
349            .header("sec-ch-ua-mobile", "?0")
350            .header("sec-ch-ua-platform", "\"macOS\"")
351            .header("sec-fetch-dest", "empty")
352            .header("sec-fetch-mode", "cors")
353            .header("sec-fetch-site", "same-origin")
354            .send(),
355    )
356    .await
357    .map_err(|_| AppError::Transport(format!("cursor timeout: {}", endpoints.summary)))??;
358
359    let status = resp.status();
360    if !status.is_success() {
361        let body = match status.as_u16() {
362            401 => "Cursor authentication failed".into(),
363            // cursor.com is behind Vercel's WAF, which denies non-browser TLS
364            // fingerprints outright (#410): a 403 here can be an edge block on
365            // a perfectly good session, not a credential verdict.
366            403 => "Cursor request denied: authentication failed or an edge block \
367                    (cursor.com gates non-browser clients)"
368                .into(),
369            other => format!("Cursor API returned HTTP {other}"),
370        };
371        return Err(AppError::Http {
372            status: status.as_u16(),
373            body,
374        });
375    }
376
377    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
378    let parsed: UsageSummary = serde_json::from_slice(&bytes)
379        .map_err(|e| AppError::Schema(format!("cursor usage-summary response: {e}")))?;
380    let mut snap = types::to_snapshot(parsed)?;
381    snap.credits = fetch_credits(client, endpoints, access_token).await;
382    Ok(snap)
383}
384
385/// Best-effort. Anything other than a 2xx grant list — including auth failure
386/// on this call alone — is an empty balance, not an error on the usage bars.
387async fn fetch_credits(
388    client: &reqwest::Client,
389    endpoints: &Endpoints,
390    access_token: &str,
391) -> Vec<CursorCreditGrant> {
392    let Ok(Ok(resp)) = tokio::time::timeout(
393        HTTP_TIMEOUT,
394        client
395            .post(&endpoints.credits)
396            .header("Content-Type", "application/json")
397            .header("Authorization", format!("Bearer {access_token}"))
398            .header("Connect-Protocol-Version", "1")
399            .body("{}")
400            .send(),
401    )
402    .await
403    else {
404        return Vec::new();
405    };
406    if !resp.status().is_success() {
407        return Vec::new();
408    }
409    let Ok(bytes) = read_body_capped(resp, MAX_BODY_BYTES).await else {
410        return Vec::new();
411    };
412    types::parse_credit_grants(&bytes)
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use rusqlite::Connection;
419    use tempfile::TempDir;
420
421    fn cache_fixture() -> (TempDir, Cache) {
422        let td = TempDir::new().unwrap();
423        let cache = Cache::at(td.path().join("cursor"));
424        cache.ensure_dir().unwrap();
425        (td, cache)
426    }
427
428    /// A minimal, unsigned JWT with `sub: "auth0|<user_id>"` — signature
429    /// verification is never performed (see `db::parse_jwt_claims`).
430    fn fake_token(user_id: &str) -> String {
431        use base64::Engine;
432        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
433        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
434            .encode(serde_json::json!({"sub": format!("auth0|{user_id}")}).to_string());
435        format!("{header}.{payload}.sig")
436    }
437
438    fn seed_state_db(dir: &TempDir, token: &str) -> std::path::PathBuf {
439        let path = dir.path().join("state.vscdb");
440        let conn = Connection::open(&path).unwrap();
441        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
442            .unwrap();
443        conn.execute(
444            "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
445            [token],
446        )
447        .unwrap();
448        path
449    }
450
451    fn account_key(token: &str) -> String {
452        db::session_auth(token).unwrap().account_key
453    }
454
455    /// A path that never exists, for tests that only care about the IDE
456    /// `db_path` and want the agent fallback to stay out of the way.
457    fn no_agent_auth() -> std::path::PathBuf {
458        std::path::PathBuf::from("/nonexistent/cursor-agent-auth.json")
459    }
460
461    fn endpoints_for(server: &mockito::Server) -> Endpoints {
462        Endpoints {
463            summary: format!("{}/api/usage-summary", server.url()),
464            credits: format!(
465                "{}/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
466                server.url()
467            ),
468        }
469    }
470
471    fn cached_snapshot(account: &str, reset_at: &str) -> Vec<u8> {
472        serde_json::to_vec(&serde_json::json!({
473            "account": account,
474            "plan": "Ultra",
475            "auto_pct": 40,
476            "api_pct": 10,
477            "total_pct": 30,
478            "unlimited": false,
479            "on_demand_enabled": false,
480            "reset_at": reset_at,
481        }))
482        .unwrap()
483    }
484
485    fn sample_json() -> String {
486        r#"{
487            "billingCycleEnd": "2099-08-04T00:35:51.000Z",
488            "membershipType": "ultra",
489            "isUnlimited": false,
490            "individualUsage": {
491                "plan": { "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5 },
492                "onDemand": { "enabled": false }
493            }
494        }"#
495        .to_string()
496    }
497
498    #[tokio::test]
499    async fn live_fetch_reads_token_from_db_and_sends_the_session_cookie() {
500        let mut server = mockito::Server::new_async().await;
501        let token = fake_token("user_123");
502        let m = server
503            .mock("GET", "/api/usage-summary")
504            .match_header(
505                "cookie",
506                format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
507            )
508            // The WAF-facing browser header set (#410): the endpoint negotiates
509            // client hints and denies automated-looking clients, so the full
510            // set is part of the request contract.
511            .match_header("user-agent", BROWSER_UA)
512            .match_header("accept", "application/json, text/plain, */*")
513            .match_header("accept-language", "en-US,en;q=0.9")
514            .match_header("sec-ch-ua-mobile", "?0")
515            .match_header("sec-ch-ua-platform", "\"macOS\"")
516            .match_header("sec-fetch-site", "same-origin")
517            .with_status(200)
518            .with_body(sample_json())
519            .create_async()
520            .await;
521
522        let db_dir = TempDir::new().unwrap();
523        let db_path = seed_state_db(&db_dir, &token);
524        let (_cache_dir, cache) = cache_fixture();
525        let client = reqwest::Client::new();
526        let endpoints = endpoints_for(&server);
527
528        let out = fetch_snapshot(
529            &client,
530            &db_path,
531            &no_agent_auth(),
532            &cache,
533            &endpoints,
534            Duration::from_secs(0),
535        )
536        .await
537        .unwrap();
538        m.assert_async().await;
539        assert_eq!(out.snapshot.plan, "Ultra");
540        assert_eq!(out.snapshot.auto_pct, 98);
541        assert_eq!(out.snapshot.api_pct, 100);
542        assert!(!out.stale);
543    }
544
545    #[tokio::test]
546    async fn missing_db_file_is_a_credentials_error_with_no_cache_to_fall_back_on() {
547        let (_cache_dir, cache) = cache_fixture();
548        let client = reqwest::Client::new();
549        let endpoints = Endpoints::default();
550        let db_path = std::path::Path::new("/nonexistent/state.vscdb");
551
552        let err = fetch_snapshot(
553            &client,
554            db_path,
555            &no_agent_auth(),
556            &cache,
557            &endpoints,
558            Duration::from_secs(0),
559        )
560        .await
561        .unwrap_err();
562        assert!(matches!(err, AppError::Credentials(_)));
563    }
564
565    #[tokio::test]
566    async fn agent_auth_file_is_used_when_the_ide_db_is_missing() {
567        let mut server = mockito::Server::new_async().await;
568        let token = fake_token("user_123");
569        let m = server
570            .mock("GET", "/api/usage-summary")
571            .match_header(
572                "cookie",
573                format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
574            )
575            .with_status(200)
576            .with_body(sample_json())
577            .create_async()
578            .await;
579
580        let dir = TempDir::new().unwrap();
581        let db_path = dir.path().join("state.vscdb"); // deliberately never seeded
582        let agent_path = dir.path().join("auth.json");
583        std::fs::write(
584            &agent_path,
585            serde_json::json!({"accessToken": token, "refreshToken": "r"}).to_string(),
586        )
587        .unwrap();
588        let (_cache_dir, cache) = cache_fixture();
589        let client = reqwest::Client::new();
590        let endpoints = endpoints_for(&server);
591
592        let out = fetch_snapshot(
593            &client,
594            &db_path,
595            &agent_path,
596            &cache,
597            &endpoints,
598            Duration::from_secs(0),
599        )
600        .await
601        .unwrap();
602        m.assert_async().await;
603        assert_eq!(out.snapshot.plan, "Ultra");
604        assert!(!out.stale);
605    }
606
607    #[tokio::test]
608    async fn http_error_falls_back_to_cache_and_hides_the_upstream_body() {
609        let mut server = mockito::Server::new_async().await;
610        let token = fake_token("user_123");
611        server
612            .mock("GET", "/api/usage-summary")
613            .with_status(401)
614            .with_body(r#"{"detail":"leaked-looking body"}"#)
615            .create_async()
616            .await;
617
618        let db_dir = TempDir::new().unwrap();
619        let db_path = seed_state_db(&db_dir, &token);
620        let (_cache_dir, cache) = cache_fixture();
621        cache
622            .write_payload(&cached_snapshot(
623                &account_key(&token),
624                "2099-08-04T00:00:00Z",
625            ))
626            .unwrap();
627
628        let client = reqwest::Client::new();
629        let endpoints = endpoints_for(&server);
630        let out = fetch_snapshot(
631            &client,
632            &db_path,
633            &no_agent_auth(),
634            &cache,
635            &endpoints,
636            Duration::from_secs(0),
637        )
638        .await
639        .unwrap();
640        assert!(out.stale);
641        assert_eq!(out.snapshot.auto_pct, 40);
642        let (code, msg) = out.last_error.unwrap();
643        assert_eq!(code, 401);
644        assert_eq!(msg, "Cursor authentication failed");
645        assert!(!msg.contains("leaked-looking"));
646    }
647
648    #[tokio::test]
649    async fn fresh_cache_is_used_after_verifying_the_current_account() {
650        let token = fake_token("user_123");
651        let db_dir = TempDir::new().unwrap();
652        let db_path = seed_state_db(&db_dir, &token);
653        let (_cache_dir, cache) = cache_fixture();
654        cache
655            .write_payload(
656                serde_json::json!({
657                    "account": account_key(&token),
658                    "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
659                    "unlimited": false, "on_demand_enabled": true,
660                    "on_demand_used_cents": 1785,
661                    "on_demand_limit_cents": 35000,
662                    "reset_at": "2099-08-04T00:00:00Z",
663                    "credits": [],
664                })
665                .to_string()
666                .as_bytes(),
667            )
668            .unwrap();
669
670        let client = reqwest::Client::new();
671        let endpoints = Endpoints::default();
672        let out = fetch_snapshot(
673            &client,
674            &db_path,
675            &no_agent_auth(),
676            &cache,
677            &endpoints,
678            Duration::from_secs(3600),
679        )
680        .await
681        .unwrap();
682        assert_eq!(out.snapshot.auto_pct, 7);
683        assert!(out.snapshot.on_demand_enabled);
684        assert_eq!(out.snapshot.on_demand_used_cents, Some(1785));
685        assert_eq!(out.snapshot.on_demand_limit_cents, Some(35000));
686        assert!(!out.stale);
687    }
688
689    #[tokio::test]
690    async fn fresh_cache_without_a_credits_field_is_refetched() {
691        let token = fake_token("user_123");
692        let db_dir = TempDir::new().unwrap();
693        let db_path = seed_state_db(&db_dir, &token);
694        let (_cache_dir, cache) = cache_fixture();
695        cache
696            .write_payload(
697                serde_json::json!({
698                    "account": account_key(&token),
699                    "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
700                    "unlimited": false, "on_demand_enabled": false,
701                    "reset_at": "2099-08-04T00:00:00Z",
702                })
703                .to_string()
704                .as_bytes(),
705            )
706            .unwrap();
707
708        let mut server = mockito::Server::new_async().await;
709        let summary = server
710            .mock("GET", "/api/usage-summary")
711            .with_status(200)
712            .with_body(sample_json())
713            .expect(1)
714            .create_async()
715            .await;
716        let grants = server
717            .mock(
718                "POST",
719                "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
720            )
721            .with_status(200)
722            .with_body(
723                r#"{"grants":[{"remainingCents":2100,"totalCents":2500,"expiresAtMs":1793577600000,"displayName":"Promo"}]}"#,
724            )
725            .expect(1)
726            .create_async()
727            .await;
728
729        let out = fetch_snapshot(
730            &reqwest::Client::new(),
731            &db_path,
732            &no_agent_auth(),
733            &cache,
734            &endpoints_for(&server),
735            Duration::from_secs(3600),
736        )
737        .await
738        .unwrap();
739        summary.assert_async().await;
740        grants.assert_async().await;
741        assert_eq!(out.snapshot.auto_pct, 98);
742        assert_eq!(out.snapshot.credits.len(), 1);
743        assert_eq!(out.snapshot.credits[0].remaining_cents, 2100);
744        assert!(!out.stale);
745    }
746
747    #[tokio::test]
748    async fn switching_accounts_rejects_a_fresh_cache_and_refetches() {
749        let old_token = fake_token("old_account");
750        let new_token = fake_token("new_account");
751        let db_dir = TempDir::new().unwrap();
752        let db_path = seed_state_db(&db_dir, &new_token);
753        let (_cache_dir, cache) = cache_fixture();
754        cache
755            .write_payload(&cached_snapshot(
756                &account_key(&old_token),
757                "2099-08-04T00:00:00Z",
758            ))
759            .unwrap();
760
761        let mut server = mockito::Server::new_async().await;
762        let request = server
763            .mock("GET", "/api/usage-summary")
764            .match_header(
765                "cookie",
766                format!("WorkosCursorSessionToken=new_account%3A%3A{new_token}").as_str(),
767            )
768            .with_status(200)
769            .with_body(sample_json())
770            .expect(1)
771            .create_async()
772            .await;
773        let endpoints = endpoints_for(&server);
774
775        let out = fetch_snapshot(
776            &reqwest::Client::new(),
777            &db_path,
778            &no_agent_auth(),
779            &cache,
780            &endpoints,
781            Duration::from_secs(3600),
782        )
783        .await
784        .unwrap();
785        request.assert_async().await;
786        assert_eq!(out.snapshot.auto_pct, 98);
787        assert!(!out.stale);
788    }
789
790    #[tokio::test]
791    async fn cache_past_its_billing_reset_is_not_served_during_an_outage() {
792        let token = fake_token("user_123");
793        let db_dir = TempDir::new().unwrap();
794        let db_path = seed_state_db(&db_dir, &token);
795        let (_cache_dir, cache) = cache_fixture();
796        cache
797            .write_payload(&cached_snapshot(
798                &account_key(&token),
799                "2026-08-04T00:00:00Z",
800            ))
801            .unwrap();
802
803        let mut server = mockito::Server::new_async().await;
804        server
805            .mock("GET", "/api/usage-summary")
806            .with_status(503)
807            .create_async()
808            .await;
809        let endpoints = endpoints_for(&server);
810        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
811            .unwrap()
812            .with_timezone(&Utc);
813        let err = fetch_snapshot_at(
814            &reqwest::Client::new(),
815            &db_path,
816            &no_agent_auth(),
817            &cache,
818            &endpoints,
819            Duration::from_secs(0),
820            now,
821        )
822        .await
823        .unwrap_err();
824        assert!(matches!(err, AppError::Http { status: 503, .. }));
825    }
826
827    /// #515: the cached payload is past its billing-cycle reset, so
828    /// `reuse_cache` refuses it, but a 429 backoff armed moments ago still
829    /// applies. The refresh must surface the shared countdown without
830    /// contacting the server.
831    #[tokio::test]
832    async fn an_active_backoff_is_not_bypassed_after_the_billing_reset() {
833        let token = fake_token("user_123");
834        let db_dir = TempDir::new().unwrap();
835        let db_path = seed_state_db(&db_dir, &token);
836        let (_cache_dir, cache) = cache_fixture();
837        cache
838            .write_payload(
839                serde_json::json!({
840                    "account": account_key(&token),
841                    "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
842                    "unlimited": false, "on_demand_enabled": false,
843                    "reset_at": "2026-08-04T00:00:00Z",
844                    "credits": [],
845                })
846                .to_string()
847                .as_bytes(),
848            )
849            .unwrap();
850        // Armed against the real clock, like a genuine 429 would be; the
851        // payload above is seconds fresh, so `fresh_payload` returns it and
852        // the reset check refuses it.
853        cache.note_rate_limit_at(std::time::SystemTime::now());
854
855        let mut server = mockito::Server::new_async().await;
856        let summary = server
857            .mock("GET", "/api/usage-summary")
858            .with_status(500)
859            .with_body("must not be contacted during backoff")
860            .expect(0)
861            .create_async()
862            .await;
863        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
864            .unwrap()
865            .with_timezone(&Utc);
866        let err = fetch_snapshot_at(
867            &reqwest::Client::new(),
868            &db_path,
869            &no_agent_auth(),
870            &cache,
871            &endpoints_for(&server),
872            Duration::from_secs(600),
873            now,
874        )
875        .await
876        .unwrap_err();
877        summary.assert_async().await;
878        match &err {
879            AppError::Http { status, body } => {
880                assert_eq!(status, &429, "body: {body}");
881                assert!(body.contains("rate limited"), "body: {body}");
882            }
883            other => panic!("expected the shared backoff 429, got {other:?}"),
884        }
885    }
886
887    #[test]
888    fn cached_percentages_are_range_checked_before_narrowing() {
889        let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
890            .unwrap()
891            .with_timezone(&Utc);
892        let mut payload: serde_json::Value =
893            serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
894        payload["auto_pct"] = serde_json::json!(i64::MAX);
895        let err =
896            parse_cache_at(&serde_json::to_vec(&payload).unwrap(), "account", now).unwrap_err();
897        assert!(matches!(err, AppError::Schema(_)));
898    }
899
900    #[tokio::test]
901    async fn credit_grants_ride_along_and_a_failed_grant_call_keeps_the_bars() {
902        let mut server = mockito::Server::new_async().await;
903        let token = fake_token("user_123");
904        let summary = server
905            .mock("GET", "/api/usage-summary")
906            .with_status(200)
907            .with_body(sample_json())
908            .expect(2)
909            .create_async()
910            .await;
911        let grants = server
912            .mock(
913                "POST",
914                "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
915            )
916            .match_header("authorization", format!("Bearer {token}").as_str())
917            .match_header("connect-protocol-version", "1")
918            .match_body("{}")
919            .with_status(200)
920            .with_body(
921                r#"{"grants":[{"remainingCents":"2100","totalCents":2500,"expiresAtMs":"1793577600000","displayName":"Promo"}]}"#,
922            )
923            .expect(1)
924            .create_async()
925            .await;
926
927        let db_dir = TempDir::new().unwrap();
928        let db_path = seed_state_db(&db_dir, &token);
929        let (_cache_dir, cache) = cache_fixture();
930        let client = reqwest::Client::new();
931        let endpoints = endpoints_for(&server);
932
933        let out = fetch_snapshot(
934            &client,
935            &db_path,
936            &no_agent_auth(),
937            &cache,
938            &endpoints,
939            Duration::from_secs(0),
940        )
941        .await
942        .unwrap();
943        grants.assert_async().await;
944        assert!(!out.stale);
945        assert_eq!(out.snapshot.auto_pct, 98);
946        assert_eq!(out.snapshot.credits.len(), 1);
947        assert_eq!(out.snapshot.credits[0].remaining_cents, 2100);
948        assert_eq!(out.snapshot.credits[0].total_cents, 2500);
949        assert_eq!(out.snapshot.credits[0].display_name, "Promo");
950        assert!(out.last_error.is_none());
951
952        // Registered after the success mock so it is the one the refetch hits.
953        // A grant-call failure must not stale the bars or surface the body.
954        let down = server
955            .mock(
956                "POST",
957                "/aiserver.v1.DashboardService/GetClientVisibleCreditGrants",
958            )
959            .with_status(503)
960            .with_body("upstream body")
961            .expect(1)
962            .create_async()
963            .await;
964        let again = fetch_snapshot(
965            &client,
966            &db_path,
967            &no_agent_auth(),
968            &cache,
969            &endpoints,
970            Duration::from_secs(0),
971        )
972        .await
973        .unwrap();
974        down.assert_async().await;
975        summary.assert_async().await;
976        assert!(!again.stale);
977        assert_eq!(again.snapshot.auto_pct, 98);
978        assert!(again.snapshot.credits.is_empty());
979        assert!(again.last_error.is_none());
980    }
981
982    #[test]
983    fn cache_round_trips_credit_grants_and_skips_a_bad_one() {
984        let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
985            .unwrap()
986            .with_timezone(&Utc);
987        let expires = DateTime::parse_from_rfc3339("2026-11-02T00:00:00Z")
988            .unwrap()
989            .with_timezone(&Utc);
990        let snap = CursorSnapshot {
991            plan: "Pro".into(),
992            auto_pct: 1,
993            api_pct: 2,
994            total_pct: 1,
995            unlimited: false,
996            on_demand_enabled: false,
997            on_demand_used_cents: None,
998            on_demand_limit_cents: None,
999            reset_at: Some(
1000                DateTime::parse_from_rfc3339("2026-08-04T00:00:00Z")
1001                    .unwrap()
1002                    .with_timezone(&Utc),
1003            ),
1004            cycle_start: None,
1005            credits: vec![CursorCreditGrant {
1006                remaining_cents: 2100,
1007                total_cents: 2500,
1008                expires_at: Some(expires),
1009                display_name: "Promo".into(),
1010            }],
1011        };
1012        let parsed = parse_cache_at(
1013            &serde_json::to_vec(&snap_to_json(&snap, "account")).unwrap(),
1014            "account",
1015            now,
1016        )
1017        .unwrap();
1018        assert_eq!(parsed.credits, snap.credits);
1019
1020        let mut legacy: serde_json::Value =
1021            serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
1022        let without =
1023            parse_cache_at(&serde_json::to_vec(&legacy).unwrap(), "account", now).unwrap();
1024        assert!(without.credits.is_empty());
1025
1026        legacy["credits"] = serde_json::json!([
1027            {"remaining_cents": -1, "total_cents": 100},
1028            {"remaining_cents": 2100, "total_cents": 2500, "display_name": "Kept"}
1029        ]);
1030        let mixed = parse_cache_at(&serde_json::to_vec(&legacy).unwrap(), "account", now).unwrap();
1031        assert_eq!(mixed.credits.len(), 1);
1032        assert_eq!(mixed.credits[0].display_name, "Kept");
1033        assert!(mixed.credits[0].expires_at.is_none());
1034    }
1035}