Skip to main content

ai_usagebar/grokbot/
fetch.rs

1//! Fetch Grok Bot usage from
2//! `aiserver.v1.DashboardService/GetSandUsageStatus` (Connect-RPC), with the
3//! desktop app's own OAuth session (`creds.rs`). The session's access token
4//! has no locally recorded expiry, so the flow is: try it, and on a 401/403
5//! refresh through Cursor's public OAuth client, persist the rotated pair in
6//! ai-usagebar's vendor cache (`oauth.json`, scoped by a fingerprint of the
7//! refresh token, mode 0600 — never back to the app's file), and retry once.
8//! Cache/stale/error-fallback shape mirrors `kiro::fetch`.
9
10use std::time::Duration;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15use crate::cache::{Cache, acquire_lock_async, atomic_write};
16use crate::error::{AppError, Result};
17use crate::usage::GrokbotSnapshot;
18use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
19
20use super::creds::GrokbotCredentials;
21use super::types::SandUsageStatus;
22
23const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
24const REFRESH_TIMEOUT: Duration = Duration::from_secs(15);
25const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
26const OAUTH_CACHE_FILE: &str = "oauth.json";
27
28/// The public OAuth client id the Cursor CLI itself uses for its installed-app
29/// flow — public by definition (it ships in the CLI), embedded here for the
30/// same reason the Kimi Code CLI's is.
31pub const CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
32
33#[derive(Debug, Clone)]
34pub struct Endpoints {
35    /// `GetSandUsageStatus`.
36    pub usage: String,
37    /// OAuth token endpoint for the refresh grant.
38    pub token: String,
39}
40
41impl Default for Endpoints {
42    fn default() -> Self {
43        Self {
44            usage: "https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus".into(),
45            token: "https://api2.cursor.sh/oauth/token".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<GrokbotSnapshot>;
53
54/// The rotated token pair, persisted in the vendor cache. Keyed by a
55/// fingerprint of the refresh token, so a re-login in the app (which rewrites
56/// `sand-secrets.json` with a new pair) never inherits the old session's
57/// cached tokens — the kiro/minimax treatment.
58#[derive(Debug, Clone, Deserialize, Serialize)]
59struct PersistedOAuth {
60    fingerprint: String,
61    access_token: String,
62    refresh_token: String,
63}
64
65/// Production entry: resolve the desktop app's session, then fetch.
66pub async fn fetch_snapshot(
67    client: &reqwest::Client,
68    cfg: &crate::config::GrokbotConfig,
69    cache: &Cache,
70    cache_ttl: Duration,
71) -> Result<FetchOutcome> {
72    let creds = super::resolve_credentials(cfg)?;
73    fetch_snapshot_with(client, &creds, cache, &Endpoints::default(), cache_ttl).await
74}
75
76/// Fetch with the credential and endpoints resolved by the caller.
77pub async fn fetch_snapshot_with(
78    client: &reqwest::Client,
79    creds: &GrokbotCredentials,
80    cache: &Cache,
81    endpoints: &Endpoints,
82    cache_ttl: Duration,
83) -> Result<FetchOutcome> {
84    cache.ensure_dir()?;
85    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
86
87    // The cache is scoped to the sign-in: a re-login in the app must not keep
88    // serving the previous session's payload.
89    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
90        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &creds.fingerprint)
91    {
92        return Ok(outcome);
93    }
94
95    match fetch_live(client, endpoints, cache, creds).await {
96        Ok(snap) => {
97            let bytes = serde_json::to_vec(&snap_to_json(&snap, &creds.fingerprint))?;
98            cache.write_payload(&bytes)?;
99            Ok(crate::outcome::Outcome::fresh(snap))
100        }
101        Err(e) if e.is_transient() => fallback_silent(cache, &creds.fingerprint, e),
102        Err(e) => {
103            cache.mark_stale();
104            if let Some((code, msg)) = error_to_pair(&e) {
105                cache.write_last_error(code, &msg);
106            }
107            fallback_with_error(cache, &creds.fingerprint, e)
108        }
109    }
110}
111
112fn fallback_silent(cache: &Cache, fingerprint: &str, original: AppError) -> Result<FetchOutcome> {
113    crate::outcome::fallback(cache, None, original, |bytes| {
114        parse_cache_at(bytes, fingerprint)
115    })
116}
117
118fn fallback_with_error(
119    cache: &Cache,
120    fingerprint: &str,
121    original: AppError,
122) -> Result<FetchOutcome> {
123    let last_error = error_to_pair(&original);
124    crate::outcome::fallback(cache, last_error, original, |bytes| {
125        parse_cache_at(bytes, fingerprint)
126    })
127}
128
129/// Never surface upstream bodies for auth failures: a 401/403 from a
130/// Bearer-token endpoint is not guaranteed not to echo something
131/// account-identifying back. Mirrors `kiro::fetch::error_to_pair`.
132fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
133    match e {
134        AppError::Http { status, .. } if matches!(status, 401 | 403) => {
135            Some((*status, "Grok Bot authentication failed".into()))
136        }
137        AppError::Http { status, body } => Some((*status, body.clone())),
138        AppError::Credentials(msg) => Some((0, msg.clone())),
139        e => Some((0, e.to_string())),
140    }
141}
142
143fn reuse_cache(
144    bytes: &[u8],
145    cache: &Cache,
146    stale: bool,
147    fingerprint: &str,
148) -> Result<FetchOutcome> {
149    let snap = parse_cache_at(bytes, fingerprint)?;
150    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
151}
152
153fn parse_cache_at(bytes: &[u8], fingerprint: &str) -> Result<GrokbotSnapshot> {
154    let v: serde_json::Value = serde_json::from_slice(bytes)?;
155    if v.get("account").and_then(serde_json::Value::as_str) != Some(fingerprint) {
156        return Err(AppError::Schema(
157            "grokbot cache belongs to a different sign-in; refetching".into(),
158        ));
159    }
160    let invalid = |field: &str| AppError::Schema(format!("grokbot cache: invalid {field}"));
161    let plan = v["plan"]
162        .as_str()
163        .filter(|plan| !plan.trim().is_empty())
164        .ok_or_else(|| invalid("plan"))?
165        .to_string();
166    let has_included_allowance = v["has_included_allowance"]
167        .as_bool()
168        .ok_or_else(|| invalid("has_included_allowance"))?;
169    let weekly_pct = v["weekly_pct"]
170        .as_i64()
171        .filter(|pct| (0..=100).contains(pct))
172        .ok_or_else(|| invalid("weekly_pct"))? as i32;
173    let period_start = parse_cache_datetime(&v["period_start"])?;
174    let reset_at = parse_cache_datetime(&v["reset_at"])?;
175    let window = match (period_start, reset_at) {
176        (Some(start), Some(reset)) if reset > start => Some(reset - start),
177        _ => None,
178    };
179    Ok(GrokbotSnapshot {
180        plan,
181        // Absent in caches written before the field existed.
182        billed_by: v["billed_by"]
183            .as_str()
184            .map(str::trim)
185            .filter(|name| !name.is_empty() && name.chars().count() <= 64)
186            .map(str::to_string),
187        has_included_allowance,
188        weekly_pct,
189        has_available_usage: v["has_available_usage"].as_bool().unwrap_or(false),
190        on_demand_enabled: v["on_demand_enabled"].as_bool().unwrap_or(false),
191        period_start,
192        reset_at,
193        window,
194    })
195}
196
197fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
198    match v {
199        serde_json::Value::Null => Ok(None),
200        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
201            .map(|dt| Some(dt.with_timezone(&Utc)))
202            .map_err(|e| AppError::Schema(format!("grokbot cache: invalid timestamp: {e}"))),
203        _ => Err(AppError::Schema("grokbot cache: invalid timestamp".into())),
204    }
205}
206
207fn snap_to_json(snap: &GrokbotSnapshot, fingerprint: &str) -> serde_json::Value {
208    serde_json::json!({
209        "account": fingerprint,
210        "plan": snap.plan,
211        "billed_by": snap.billed_by,
212        "has_included_allowance": snap.has_included_allowance,
213        "weekly_pct": snap.weekly_pct,
214        "has_available_usage": snap.has_available_usage,
215        "on_demand_enabled": snap.on_demand_enabled,
216        "period_start": snap.period_start.map(|dt| dt.to_rfc3339()),
217        "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
218    })
219}
220
221fn oauth_cache_path(cache: &Cache) -> std::path::PathBuf {
222    cache.dir().join(OAUTH_CACHE_FILE)
223}
224
225fn read_persisted_oauth(cache: &Cache, fingerprint: &str) -> Result<Option<PersistedOAuth>> {
226    let path = oauth_cache_path(cache);
227    let bytes = match std::fs::read(&path) {
228        Ok(bytes) => bytes,
229        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
230        Err(e) => return Err(AppError::io_at(&path, e)),
231    };
232    let persisted: PersistedOAuth = serde_json::from_slice(&bytes).map_err(|e| {
233        AppError::Credentials(format!(
234            "ai-usagebar's cached Grok Bot credentials at {} are malformed ({e}); remove that file and try again",
235            crate::display::sanitize_untrusted_path(&path)
236        ))
237    })?;
238    // The app re-signed-in since this was persisted: a different refresh
239    // token means a different session, and the cached pair belongs to it.
240    if persisted.fingerprint != fingerprint {
241        return Ok(None);
242    }
243    if persisted.access_token.trim().is_empty() || persisted.refresh_token.trim().is_empty() {
244        return Err(AppError::Credentials(format!(
245            "ai-usagebar's cached Grok Bot credentials at {} are incomplete; remove that file and try again",
246            crate::display::sanitize_untrusted_path(&path)
247        )));
248    }
249    Ok(Some(persisted))
250}
251
252fn write_persisted_oauth(cache: &Cache, persisted: &PersistedOAuth) -> Result<()> {
253    let path = oauth_cache_path(cache);
254    let bytes = serde_json::to_vec_pretty(persisted)?;
255    atomic_write(&path, &bytes)?;
256    #[cfg(unix)]
257    {
258        use std::os::unix::fs::PermissionsExt;
259        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
260            .map_err(|e| AppError::io_at(&path, e))?;
261    }
262    Ok(())
263}
264
265async fn fetch_live(
266    client: &reqwest::Client,
267    endpoints: &Endpoints,
268    cache: &Cache,
269    creds: &GrokbotCredentials,
270) -> Result<GrokbotSnapshot> {
271    // A previously rotated pair wins over the app's file when it belongs to
272    // the same sign-in: the app does not rewrite its own file on refresh, so
273    // its access token is the older one.
274    let (access_token, refresh_token) = match read_persisted_oauth(cache, &creds.fingerprint)? {
275        Some(persisted) => (persisted.access_token, persisted.refresh_token),
276        None => (creds.access_token.clone(), creds.refresh_token.clone()),
277    };
278
279    let resp = usage_call(client, endpoints, &access_token).await?;
280    let status = resp.status();
281    if matches!(status.as_u16(), 401 | 403) {
282        // The access token died (no local expiry records when). Refresh with
283        // the paired refresh token and retry exactly once.
284        let refreshed = refresh(client, &endpoints.token, &refresh_token).await?;
285        let persisted = PersistedOAuth {
286            // Scoped to the sign-in the app's file holds, which is what the
287            // next poll looks the pair up by. The app does not rewrite that
288            // file on a refresh, so a rotated token's own fingerprint would
289            // never be asked for again.
290            fingerprint: creds.fingerprint.clone(),
291            access_token: refreshed.access_token,
292            refresh_token: refreshed.refresh_token,
293        };
294        write_persisted_oauth(cache, &persisted).map_err(|e| {
295            AppError::Credentials(format!(
296                "the refreshed Grok Bot credentials could not be saved ({e}); sign in to the Grok Bot desktop app again if the refresh token was rotated"
297            ))
298        })?;
299        let retry = usage_call(client, endpoints, &persisted.access_token).await?;
300        return parse_usage_response(retry).await;
301    }
302    parse_usage_response(resp).await
303}
304
305/// One `GetSandUsageStatus` call: Connect-RPC with the app's client headers.
306///
307/// `x-cursor-checksum` is deliberately **not** sent: it is Cursor's machine
308/// checksum, which we cannot reproduce. Residual risk: the server could start
309/// requiring it, which would surface as a 4xx and the cached payload (or its
310/// absence) is the honest answer — flagged for the reporter's live test
311/// (#206).
312async fn usage_call(
313    client: &reqwest::Client,
314    endpoints: &Endpoints,
315    access_token: &str,
316) -> Result<reqwest::Response> {
317    tokio::time::timeout(
318        HTTP_TIMEOUT,
319        client
320            .post(&endpoints.usage)
321            .header("Content-Type", "application/json")
322            .header("Authorization", format!("Bearer {access_token}"))
323            .header("Connect-Protocol-Version", "1")
324            .header("x-cursor-client-type", "sand")
325            .header("x-cursor-client-version", "0.1.0")
326            .header("x-sand-box-namespace", "prod")
327            .header("x-ghost-mode", "true")
328            .header("x-request-id", request_id())
329            .body("{}")
330            .send(),
331    )
332    .await
333    .map_err(|_| AppError::Transport(format!("grokbot timeout: {}", endpoints.usage)))?
334    .map_err(|e| AppError::Transport(format!("grokbot transport: {e}")))
335}
336
337async fn parse_usage_response(resp: reqwest::Response) -> Result<GrokbotSnapshot> {
338    let status = resp.status();
339    if !status.is_success() {
340        // Never surface upstream/proxy bodies: they can contain credentials
341        // or arbitrary markup. The cache records the redacted form centrally.
342        let body = if matches!(status.as_u16(), 401 | 403) {
343            "Grok Bot authentication failed".into()
344        } else {
345            format!("Grok Bot API returned HTTP {}", status.as_u16())
346        };
347        return Err(AppError::Http {
348            status: status.as_u16(),
349            body,
350        });
351    }
352    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
353    let parsed: SandUsageStatus = serde_json::from_slice(&bytes)
354        .map_err(|e| AppError::Schema(format!("grokbot usage response: {e}")))?;
355    parsed.into_snapshot()
356}
357
358#[derive(Debug, Deserialize)]
359struct RefreshResponse {
360    access_token: String,
361    /// Absent means the refresh token was not rotated; keep the current one.
362    refresh_token: Option<String>,
363}
364
365async fn refresh(
366    client: &reqwest::Client,
367    token_url: &str,
368    refresh_token: &str,
369) -> Result<RefreshedPair> {
370    let body = serde_json::json!({
371        "client_id": CLIENT_ID,
372        "grant_type": "refresh_token",
373        "refresh_token": refresh_token,
374    });
375    let resp = tokio::time::timeout(REFRESH_TIMEOUT, client.post(token_url).json(&body).send())
376        .await
377        .map_err(|_| AppError::Transport(format!("grokbot token refresh timeout: {token_url}")))?
378        .map_err(|e| AppError::Transport(format!("grokbot token refresh: {e}")))?;
379    if !resp.status().is_success() {
380        return Err(AppError::Credentials(
381            "Grok Bot token refresh was rejected; sign in to the Grok Bot desktop app again".into(),
382        ));
383    }
384    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
385    let parsed: RefreshResponse = serde_json::from_slice(&bytes)
386        .map_err(|e| AppError::Schema(format!("grokbot token refresh response: {e}")))?;
387    if parsed.access_token.trim().is_empty() {
388        return Err(AppError::Schema(
389            "grokbot token refresh returned no access token".into(),
390        ));
391    }
392    Ok(RefreshedPair {
393        access_token: parsed.access_token,
394        refresh_token: parsed
395            .refresh_token
396            .filter(|token| !token.trim().is_empty())
397            .unwrap_or_else(|| refresh_token.to_string()),
398    })
399}
400
401struct RefreshedPair {
402    access_token: String,
403    refresh_token: String,
404}
405
406/// A UUID-v4-shaped request id for the `x-request-id` header. `uuid` is
407/// deliberately not a dependency: a Connect-RPC request id is a tracing
408/// value, not a credential, so one is minted from process/time/counter
409/// entropy mixed through SHA-256 (already in the tree) and shaped per
410/// RFC 4122 §4.4.
411fn request_id() -> String {
412    use sha2::{Digest, Sha256};
413    use std::sync::atomic::{AtomicU64, Ordering};
414    static COUNTER: AtomicU64 = AtomicU64::new(0);
415
416    let nanos = std::time::SystemTime::now()
417        .duration_since(std::time::UNIX_EPOCH)
418        .map(|d| d.as_nanos())
419        .unwrap_or(0);
420    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
421    let digest = Sha256::digest(format!("grokbot:{}:{nanos}:{seq}", std::process::id()).as_bytes());
422    let mut bytes = [0u8; 16];
423    bytes.copy_from_slice(&digest[..16]);
424    bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
425    bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
426    let mut out = String::with_capacity(36);
427    for (i, byte) in bytes.iter().enumerate() {
428        if matches!(i, 4 | 6 | 8 | 10) {
429            out.push('-');
430        }
431        use std::fmt::Write as _;
432        let _ = write!(out, "{byte:02x}");
433    }
434    out
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use tempfile::TempDir;
441
442    fn cache_fixture() -> (TempDir, Cache) {
443        let td = TempDir::new().unwrap();
444        let cache = Cache::at(td.path().join("grokbot"));
445        cache.ensure_dir().unwrap();
446        (td, cache)
447    }
448
449    fn test_endpoints(base: &str) -> Endpoints {
450        Endpoints {
451            usage: format!("{base}/aiserver.v1.DashboardService/GetSandUsageStatus"),
452            token: format!("{base}/oauth/token"),
453        }
454    }
455
456    fn test_creds() -> GrokbotCredentials {
457        GrokbotCredentials {
458            access_token: "at-stored".into(),
459            refresh_token: "rt-stored".into(),
460            fingerprint: crate::cache::fingerprint_of("rt-stored"),
461        }
462    }
463
464    /// The reporter's capture, lightly trimmed.
465    fn usage_json() -> &'static str {
466        r#"{"currentPeriodStart":"2026-09-11T18:43:19.645Z","nextResetTimestampUtc":"2026-09-18T18:43:19.645Z","usagePercent":12,"hasAvailableUsage":true,"hasNonZeroIncludedLimit":true,"onDemandSettings":{"visible":true,"eligible":true,"enabled":false},"grokPlanLabel":"Grok Bot Plan","cursorPlanName":"Pro"}"#
467    }
468
469    fn sample_seed(fingerprint: &str) -> serde_json::Value {
470        serde_json::json!({
471            "account": fingerprint,
472            "plan": "Grok Bot Plan",
473            "has_included_allowance": true,
474            "weekly_pct": 30,
475            "has_available_usage": true,
476            "on_demand_enabled": false,
477            "period_start": "2026-09-11T18:43:19.645Z",
478            "reset_at": "2026-09-18T18:43:19.645Z",
479        })
480    }
481
482    /// The mock usage endpoint, matching every header the client must send.
483    fn usage_mock(server: &mut mockito::ServerGuard, bearer: &str) -> mockito::Mock {
484        server
485            .mock("POST", "/aiserver.v1.DashboardService/GetSandUsageStatus")
486            .match_header("authorization", format!("Bearer {bearer}").as_str())
487            .match_header("content-type", "application/json")
488            .match_header("connect-protocol-version", "1")
489            .match_header("x-cursor-client-type", "sand")
490            .match_header("x-cursor-client-version", "0.1.0")
491            .match_header("x-sand-box-namespace", "prod")
492            .match_header("x-ghost-mode", "true")
493            .match_header(
494                "x-request-id",
495                mockito::Matcher::Regex(r"^[0-9a-f-]{36}$".into()),
496            )
497            // We cannot reproduce Cursor's machine checksum; it must not go out.
498            .match_header("x-cursor-checksum", mockito::Matcher::Missing)
499            .match_body(mockito::Matcher::Json(serde_json::json!({})))
500    }
501
502    #[tokio::test]
503    async fn live_200_returns_a_snapshot_and_sends_the_connect_rpc_headers() {
504        let mut server = mockito::Server::new_async().await;
505        let m = usage_mock(&mut server, "at-stored")
506            .with_status(200)
507            .with_body(usage_json())
508            .create_async()
509            .await;
510
511        let (_td, cache) = cache_fixture();
512        let out = fetch_snapshot_with(
513            &reqwest::Client::new(),
514            &test_creds(),
515            &cache,
516            &test_endpoints(&server.url()),
517            Duration::ZERO,
518        )
519        .await
520        .unwrap();
521
522        m.assert_async().await;
523        assert_eq!(out.snapshot.plan, "Grok Bot Plan");
524        assert!(out.snapshot.has_included_allowance);
525        assert_eq!(out.snapshot.weekly_pct, 12);
526        assert_eq!(out.snapshot.window, Some(chrono::Duration::days(7)));
527        assert!(!out.stale);
528    }
529
530    #[tokio::test]
531    async fn a_401_refreshes_persists_and_retries_once() {
532        let mut server = mockito::Server::new_async().await;
533        let stale = usage_mock(&mut server, "at-stored")
534            .with_status(401)
535            .with_body(r#"{"error":"expired"}"#)
536            .expect(1)
537            .create_async()
538            .await;
539        let refresh = server
540            .mock("POST", "/oauth/token")
541            .match_body(mockito::Matcher::Json(serde_json::json!({
542                "client_id": CLIENT_ID,
543                "grant_type": "refresh_token",
544                "refresh_token": "rt-stored",
545            })))
546            .with_status(200)
547            .with_body(
548                r#"{"access_token":"at-fresh","refresh_token":"rt-rotated","expires_in":3600}"#,
549            )
550            .expect(1)
551            .create_async()
552            .await;
553        let retried = usage_mock(&mut server, "at-fresh")
554            .with_status(200)
555            .with_body(usage_json())
556            .expect(1)
557            .create_async()
558            .await;
559
560        let (_td, cache) = cache_fixture();
561        let out = fetch_snapshot_with(
562            &reqwest::Client::new(),
563            &test_creds(),
564            &cache,
565            &test_endpoints(&server.url()),
566            Duration::ZERO,
567        )
568        .await
569        .unwrap();
570
571        stale.assert_async().await;
572        refresh.assert_async().await;
573        retried.assert_async().await;
574        assert_eq!(out.snapshot.weekly_pct, 12);
575
576        // The rotated pair persisted to the vendor cache, scoped by the
577        // sign-in it was refreshed from, and never back to the app's file.
578        let persisted: serde_json::Value =
579            serde_json::from_slice(&std::fs::read(oauth_cache_path(&cache)).unwrap()).unwrap();
580        assert_eq!(persisted["access_token"], "at-fresh");
581        assert_eq!(persisted["refresh_token"], "rt-rotated");
582        assert_eq!(
583            persisted["fingerprint"],
584            crate::cache::fingerprint_of("rt-stored")
585        );
586        #[cfg(unix)]
587        {
588            use std::os::unix::fs::PermissionsExt;
589            let mode = std::fs::metadata(oauth_cache_path(&cache))
590                .unwrap()
591                .permissions()
592                .mode();
593            assert_eq!(mode & 0o077, 0);
594        }
595    }
596
597    /// The app does not rewrite its file on a refresh, so the poll after a
598    /// rotation still reads the old pair from it. It has to find the rotated
599    /// pair in the vendor cache, not try the expired access token and spend
600    /// the original refresh token again.
601    #[tokio::test]
602    async fn the_next_poll_reuses_a_rotated_pair() {
603        let mut server = mockito::Server::new_async().await;
604        let stale = usage_mock(&mut server, "at-stored")
605            .with_status(401)
606            .with_body(r#"{"error":"expired"}"#)
607            .expect(1)
608            .create_async()
609            .await;
610        let refresh = server
611            .mock("POST", "/oauth/token")
612            .with_status(200)
613            .with_body(r#"{"access_token":"at-fresh","refresh_token":"rt-rotated"}"#)
614            .expect(1)
615            .create_async()
616            .await;
617        let fresh = usage_mock(&mut server, "at-fresh")
618            .with_status(200)
619            .with_body(usage_json())
620            .expect(2)
621            .create_async()
622            .await;
623
624        let (_td, cache) = cache_fixture();
625        for _ in 0..2 {
626            let out = fetch_snapshot_with(
627                &reqwest::Client::new(),
628                &test_creds(),
629                &cache,
630                &test_endpoints(&server.url()),
631                Duration::ZERO,
632            )
633            .await
634            .unwrap();
635            assert_eq!(out.snapshot.weekly_pct, 12);
636        }
637
638        stale.assert_async().await;
639        refresh.assert_async().await;
640        fresh.assert_async().await;
641    }
642
643    #[tokio::test]
644    async fn a_persisted_pair_is_used_in_place_of_the_apps_older_access_token() {
645        let mut server = mockito::Server::new_async().await;
646        let m = usage_mock(&mut server, "at-fresh")
647            .with_status(200)
648            .with_body(usage_json())
649            .create_async()
650            .await;
651
652        let (_td, cache) = cache_fixture();
653        write_persisted_oauth(
654            &cache,
655            &PersistedOAuth {
656                // Same sign-in as the app's file, so the rotation is honored.
657                fingerprint: crate::cache::fingerprint_of("rt-stored"),
658                access_token: "at-fresh".into(),
659                refresh_token: "rt-stored".into(),
660            },
661        )
662        .unwrap();
663
664        let out = fetch_snapshot_with(
665            &reqwest::Client::new(),
666            &test_creds(),
667            &cache,
668            &test_endpoints(&server.url()),
669            Duration::ZERO,
670        )
671        .await
672        .unwrap();
673
674        m.assert_async().await;
675        assert_eq!(out.snapshot.weekly_pct, 12);
676    }
677
678    #[tokio::test]
679    async fn a_persisted_pair_from_a_previous_sign_in_is_ignored() {
680        let mut server = mockito::Server::new_async().await;
681        let m = usage_mock(&mut server, "at-stored")
682            .with_status(200)
683            .with_body(usage_json())
684            .create_async()
685            .await;
686
687        let (_td, cache) = cache_fixture();
688        write_persisted_oauth(
689            &cache,
690            &PersistedOAuth {
691                fingerprint: crate::cache::fingerprint_of("rt-someone-else"),
692                access_token: "at-stranger".into(),
693                refresh_token: "rt-stranger".into(),
694            },
695        )
696        .unwrap();
697
698        let out = fetch_snapshot_with(
699            &reqwest::Client::new(),
700            &test_creds(),
701            &cache,
702            &test_endpoints(&server.url()),
703            Duration::ZERO,
704        )
705        .await
706        .unwrap();
707
708        m.assert_async().await;
709        assert_eq!(out.snapshot.weekly_pct, 12);
710    }
711
712    #[tokio::test]
713    async fn a_rejected_refresh_is_a_credentials_error_naming_the_app() {
714        let mut server = mockito::Server::new_async().await;
715        usage_mock(&mut server, "at-stored")
716            .with_status(401)
717            .create_async()
718            .await;
719        server
720            .mock("POST", "/oauth/token")
721            .with_status(400)
722            .with_body(r#"{"error":"invalid_grant"}"#)
723            .create_async()
724            .await;
725
726        let (_td, cache) = cache_fixture();
727        let err = fetch_snapshot_with(
728            &reqwest::Client::new(),
729            &test_creds(),
730            &cache,
731            &test_endpoints(&server.url()),
732            Duration::ZERO,
733        )
734        .await
735        .unwrap_err();
736
737        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
738        assert!(
739            err.to_string()
740                .contains("sign in to the Grok Bot desktop app"),
741            "{err}"
742        );
743        // The rejected pair must not persist.
744        assert!(!oauth_cache_path(&cache).exists());
745    }
746
747    #[tokio::test]
748    async fn http_500_falls_back_to_the_cache_with_a_redacted_body() {
749        let mut server = mockito::Server::new_async().await;
750        server
751            .mock("POST", "/aiserver.v1.DashboardService/GetSandUsageStatus")
752            .with_status(500)
753            .with_body("proxy secret: <token>")
754            .create_async()
755            .await;
756
757        let (_td, cache) = cache_fixture();
758        cache
759            .write_payload(
760                sample_seed(&test_creds().fingerprint)
761                    .to_string()
762                    .as_bytes(),
763            )
764            .unwrap();
765
766        let out = fetch_snapshot_with(
767            &reqwest::Client::new(),
768            &test_creds(),
769            &cache,
770            &test_endpoints(&server.url()),
771            Duration::ZERO,
772        )
773        .await
774        .unwrap();
775
776        assert!(out.stale);
777        assert_eq!(out.snapshot.weekly_pct, 30);
778        assert_eq!(
779            out.last_error,
780            Some((500, "Grok Bot API returned HTTP 500".into()))
781        );
782    }
783
784    #[tokio::test]
785    async fn http_401_after_refresh_also_falls_back_with_a_redacted_body() {
786        let mut server = mockito::Server::new_async().await;
787        server
788            .mock("POST", "/aiserver.v1.DashboardService/GetSandUsageStatus")
789            .with_status(403)
790            .with_body(r#"{"error":"the access token itself, echoed"}"#)
791            .create_async()
792            .await;
793        server
794            .mock("POST", "/oauth/token")
795            .with_status(200)
796            .with_body(r#"{"access_token":"at-fresh","refresh_token":"rt-rotated"}"#)
797            .create_async()
798            .await;
799
800        let (_td, cache) = cache_fixture();
801        let err = fetch_snapshot_with(
802            &reqwest::Client::new(),
803            &test_creds(),
804            &cache,
805            &test_endpoints(&server.url()),
806            Duration::ZERO,
807        )
808        .await
809        .unwrap_err();
810
811        match err {
812            AppError::Http { status, body } => {
813                assert_eq!(status, 403);
814                assert_eq!(body, "Grok Bot authentication failed");
815            }
816            other => panic!("expected Http 403, got {other:?}"),
817        }
818    }
819
820    #[tokio::test]
821    async fn a_fresh_cache_is_served_without_a_network_call() {
822        let (_td, cache) = cache_fixture();
823        cache
824            .write_payload(
825                sample_seed(&test_creds().fingerprint)
826                    .to_string()
827                    .as_bytes(),
828            )
829            .unwrap();
830
831        // No mock server at all: a cache hit must never reach the network.
832        let out = fetch_snapshot_with(
833            &reqwest::Client::new(),
834            &test_creds(),
835            &cache,
836            &test_endpoints("http://127.0.0.1:1"),
837            Duration::from_secs(60),
838        )
839        .await
840        .unwrap();
841
842        assert!(!out.stale);
843        assert_eq!(out.snapshot.weekly_pct, 30);
844        assert_eq!(out.snapshot.window, Some(chrono::Duration::days(7)));
845    }
846
847    #[tokio::test]
848    async fn a_cache_from_a_previous_sign_in_is_not_reused() {
849        let mut server = mockito::Server::new_async().await;
850        let m = usage_mock(&mut server, "at-stored")
851            .with_status(200)
852            .with_body(usage_json())
853            .create_async()
854            .await;
855
856        let (_td, cache) = cache_fixture();
857        cache
858            .write_payload(sample_seed("some-other-fingerprint").to_string().as_bytes())
859            .unwrap();
860
861        let out = fetch_snapshot_with(
862            &reqwest::Client::new(),
863            &test_creds(),
864            &cache,
865            &test_endpoints(&server.url()),
866            // A long TTL: only the fingerprint mismatch can explain a refetch.
867            Duration::from_secs(3600),
868        )
869        .await
870        .unwrap();
871
872        m.assert_async().await;
873        assert!(!out.stale);
874        assert_eq!(out.snapshot.weekly_pct, 12);
875    }
876
877    #[tokio::test]
878    async fn a_transport_error_with_a_stale_cache_uses_the_cache() {
879        let (_td, cache) = cache_fixture();
880        cache
881            .write_payload(
882                sample_seed(&test_creds().fingerprint)
883                    .to_string()
884                    .as_bytes(),
885            )
886            .unwrap();
887
888        let out = fetch_snapshot_with(
889            &reqwest::Client::new(),
890            &test_creds(),
891            &cache,
892            &test_endpoints("http://127.0.0.1:1"),
893            Duration::ZERO,
894        )
895        .await
896        .unwrap();
897
898        assert!(out.stale);
899        assert_eq!(out.snapshot.weekly_pct, 30);
900    }
901
902    #[test]
903    fn a_cache_with_an_out_of_range_percent_is_rejected() {
904        let mut v = sample_seed("fp");
905        v["weekly_pct"] = serde_json::json!(150);
906        let err = parse_cache_at(v.to_string().as_bytes(), "fp").unwrap_err();
907        assert!(err.to_string().contains("weekly_pct"), "{err}");
908    }
909
910    #[test]
911    fn a_snapshot_survives_the_cache_round_trip() {
912        let snap = GrokbotSnapshot {
913            plan: "Grok Bot Plan".into(),
914            billed_by: Some("Cursor Ultra".into()),
915            has_included_allowance: true,
916            weekly_pct: 42,
917            has_available_usage: true,
918            on_demand_enabled: true,
919            period_start: DateTime::parse_from_rfc3339("2026-09-11T18:43:19.645Z")
920                .map(|dt| dt.with_timezone(&Utc))
921                .ok(),
922            reset_at: DateTime::parse_from_rfc3339("2026-09-18T18:43:19.645Z")
923                .map(|dt| dt.with_timezone(&Utc))
924                .ok(),
925            window: Some(chrono::Duration::days(7)),
926        };
927        let bytes = serde_json::to_vec(&snap_to_json(&snap, "fp")).unwrap();
928        assert_eq!(parse_cache_at(&bytes, "fp").unwrap(), snap);
929        // A cache written before `billed_by` existed still parses, unnamed.
930        let mut older = snap_to_json(&snap, "fp");
931        older.as_object_mut().unwrap().remove("billed_by");
932        let parsed = parse_cache_at(older.to_string().as_bytes(), "fp").unwrap();
933        assert_eq!(parsed.billed_by, None);
934    }
935
936    #[test]
937    fn request_ids_are_uuid_v4_shaped_and_unique() {
938        let a = request_id();
939        let b = request_id();
940        assert_ne!(a, b);
941        for id in [&a, &b] {
942            assert_eq!(id.len(), 36, "{id}");
943            assert_eq!(&id[14..15], "4", "version nibble: {id}");
944            assert!(
945                matches!(&id[19..20], "8" | "9" | "a" | "b"),
946                "variant bits: {id}"
947            );
948            assert!(
949                id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'),
950                "{id}"
951            );
952        }
953    }
954}