1use 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
28pub const CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
32
33#[derive(Debug, Clone)]
34pub struct Endpoints {
35 pub usage: String,
37 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
50pub type FetchOutcome = crate::outcome::Outcome<GrokbotSnapshot>;
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
59struct PersistedOAuth {
60 fingerprint: String,
61 access_token: String,
62 refresh_token: String,
63}
64
65pub 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
76pub 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 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
129fn 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 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 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 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 let refreshed = refresh(client, &endpoints.token, &refresh_token).await?;
285 let persisted = PersistedOAuth {
286 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
305async 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 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 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
406fn 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; bytes[8] = (bytes[8] & 0x3f) | 0x80; 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 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 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 .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 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 #[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 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 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 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 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 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}