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