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