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 has_included_allowance,
182 weekly_pct,
183 has_available_usage: v["has_available_usage"].as_bool().unwrap_or(false),
184 on_demand_enabled: v["on_demand_enabled"].as_bool().unwrap_or(false),
185 period_start,
186 reset_at,
187 window,
188 })
189}
190
191fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
192 match v {
193 serde_json::Value::Null => Ok(None),
194 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
195 .map(|dt| Some(dt.with_timezone(&Utc)))
196 .map_err(|e| AppError::Schema(format!("grokbot cache: invalid timestamp: {e}"))),
197 _ => Err(AppError::Schema("grokbot cache: invalid timestamp".into())),
198 }
199}
200
201fn snap_to_json(snap: &GrokbotSnapshot, fingerprint: &str) -> serde_json::Value {
202 serde_json::json!({
203 "account": fingerprint,
204 "plan": snap.plan,
205 "has_included_allowance": snap.has_included_allowance,
206 "weekly_pct": snap.weekly_pct,
207 "has_available_usage": snap.has_available_usage,
208 "on_demand_enabled": snap.on_demand_enabled,
209 "period_start": snap.period_start.map(|dt| dt.to_rfc3339()),
210 "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
211 })
212}
213
214fn oauth_cache_path(cache: &Cache) -> std::path::PathBuf {
215 cache.dir().join(OAUTH_CACHE_FILE)
216}
217
218fn read_persisted_oauth(cache: &Cache, fingerprint: &str) -> Result<Option<PersistedOAuth>> {
219 let path = oauth_cache_path(cache);
220 let bytes = match std::fs::read(&path) {
221 Ok(bytes) => bytes,
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
223 Err(e) => return Err(AppError::io_at(&path, e)),
224 };
225 let persisted: PersistedOAuth = serde_json::from_slice(&bytes).map_err(|e| {
226 AppError::Credentials(format!(
227 "ai-usagebar's cached Grok Bot credentials at {} are malformed ({e}); remove that file and try again",
228 crate::display::sanitize_untrusted_path(&path)
229 ))
230 })?;
231 if persisted.fingerprint != fingerprint {
234 return Ok(None);
235 }
236 if persisted.access_token.trim().is_empty() || persisted.refresh_token.trim().is_empty() {
237 return Err(AppError::Credentials(format!(
238 "ai-usagebar's cached Grok Bot credentials at {} are incomplete; remove that file and try again",
239 crate::display::sanitize_untrusted_path(&path)
240 )));
241 }
242 Ok(Some(persisted))
243}
244
245fn write_persisted_oauth(cache: &Cache, persisted: &PersistedOAuth) -> Result<()> {
246 let path = oauth_cache_path(cache);
247 let bytes = serde_json::to_vec_pretty(persisted)?;
248 atomic_write(&path, &bytes)?;
249 #[cfg(unix)]
250 {
251 use std::os::unix::fs::PermissionsExt;
252 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
253 .map_err(|e| AppError::io_at(&path, e))?;
254 }
255 Ok(())
256}
257
258async fn fetch_live(
259 client: &reqwest::Client,
260 endpoints: &Endpoints,
261 cache: &Cache,
262 creds: &GrokbotCredentials,
263) -> Result<GrokbotSnapshot> {
264 let (access_token, refresh_token) = match read_persisted_oauth(cache, &creds.fingerprint)? {
268 Some(persisted) => (persisted.access_token, persisted.refresh_token),
269 None => (creds.access_token.clone(), creds.refresh_token.clone()),
270 };
271
272 let resp = usage_call(client, endpoints, &access_token).await?;
273 let status = resp.status();
274 if matches!(status.as_u16(), 401 | 403) {
275 let refreshed = refresh(client, &endpoints.token, &refresh_token).await?;
278 let persisted = PersistedOAuth {
279 fingerprint: super::creds::fingerprint_of(&refreshed.refresh_token),
280 access_token: refreshed.access_token,
281 refresh_token: refreshed.refresh_token,
282 };
283 write_persisted_oauth(cache, &persisted).map_err(|e| {
284 AppError::Credentials(format!(
285 "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"
286 ))
287 })?;
288 let retry = usage_call(client, endpoints, &persisted.access_token).await?;
289 return parse_usage_response(retry).await;
290 }
291 parse_usage_response(resp).await
292}
293
294async fn usage_call(
302 client: &reqwest::Client,
303 endpoints: &Endpoints,
304 access_token: &str,
305) -> Result<reqwest::Response> {
306 tokio::time::timeout(
307 HTTP_TIMEOUT,
308 client
309 .post(&endpoints.usage)
310 .header("Content-Type", "application/json")
311 .header("Authorization", format!("Bearer {access_token}"))
312 .header("Connect-Protocol-Version", "1")
313 .header("x-cursor-client-type", "sand")
314 .header("x-cursor-client-version", "0.1.0")
315 .header("x-sand-box-namespace", "prod")
316 .header("x-ghost-mode", "true")
317 .header("x-request-id", request_id())
318 .body("{}")
319 .send(),
320 )
321 .await
322 .map_err(|_| AppError::Transport(format!("grokbot timeout: {}", endpoints.usage)))?
323 .map_err(|e| AppError::Transport(format!("grokbot transport: {e}")))
324}
325
326async fn parse_usage_response(resp: reqwest::Response) -> Result<GrokbotSnapshot> {
327 let status = resp.status();
328 if !status.is_success() {
329 let body = if matches!(status.as_u16(), 401 | 403) {
332 "Grok Bot authentication failed".into()
333 } else {
334 format!("Grok Bot API returned HTTP {}", status.as_u16())
335 };
336 return Err(AppError::Http {
337 status: status.as_u16(),
338 body,
339 });
340 }
341 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
342 let parsed: SandUsageStatus = serde_json::from_slice(&bytes)
343 .map_err(|e| AppError::Schema(format!("grokbot usage response: {e}")))?;
344 parsed.into_snapshot()
345}
346
347#[derive(Debug, Deserialize)]
348struct RefreshResponse {
349 access_token: String,
350 refresh_token: Option<String>,
352}
353
354async fn refresh(
355 client: &reqwest::Client,
356 token_url: &str,
357 refresh_token: &str,
358) -> Result<RefreshedPair> {
359 let body = serde_json::json!({
360 "client_id": CLIENT_ID,
361 "grant_type": "refresh_token",
362 "refresh_token": refresh_token,
363 });
364 let resp = tokio::time::timeout(REFRESH_TIMEOUT, client.post(token_url).json(&body).send())
365 .await
366 .map_err(|_| AppError::Transport(format!("grokbot token refresh timeout: {token_url}")))?
367 .map_err(|e| AppError::Transport(format!("grokbot token refresh: {e}")))?;
368 if !resp.status().is_success() {
369 return Err(AppError::Credentials(
370 "Grok Bot token refresh was rejected; sign in to the Grok Bot desktop app again".into(),
371 ));
372 }
373 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
374 let parsed: RefreshResponse = serde_json::from_slice(&bytes)
375 .map_err(|e| AppError::Schema(format!("grokbot token refresh response: {e}")))?;
376 if parsed.access_token.trim().is_empty() {
377 return Err(AppError::Schema(
378 "grokbot token refresh returned no access token".into(),
379 ));
380 }
381 Ok(RefreshedPair {
382 access_token: parsed.access_token,
383 refresh_token: parsed
384 .refresh_token
385 .filter(|token| !token.trim().is_empty())
386 .unwrap_or_else(|| refresh_token.to_string()),
387 })
388}
389
390struct RefreshedPair {
391 access_token: String,
392 refresh_token: String,
393}
394
395fn request_id() -> String {
401 use sha2::{Digest, Sha256};
402 use std::sync::atomic::{AtomicU64, Ordering};
403 static COUNTER: AtomicU64 = AtomicU64::new(0);
404
405 let nanos = std::time::SystemTime::now()
406 .duration_since(std::time::UNIX_EPOCH)
407 .map(|d| d.as_nanos())
408 .unwrap_or(0);
409 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
410 let digest = Sha256::digest(format!("grokbot:{}:{nanos}:{seq}", std::process::id()).as_bytes());
411 let mut bytes = [0u8; 16];
412 bytes.copy_from_slice(&digest[..16]);
413 bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; let mut out = String::with_capacity(36);
416 for (i, byte) in bytes.iter().enumerate() {
417 if matches!(i, 4 | 6 | 8 | 10) {
418 out.push('-');
419 }
420 use std::fmt::Write as _;
421 let _ = write!(out, "{byte:02x}");
422 }
423 out
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use tempfile::TempDir;
430
431 fn cache_fixture() -> (TempDir, Cache) {
432 let td = TempDir::new().unwrap();
433 let cache = Cache::at(td.path().join("grokbot"));
434 cache.ensure_dir().unwrap();
435 (td, cache)
436 }
437
438 fn test_endpoints(base: &str) -> Endpoints {
439 Endpoints {
440 usage: format!("{base}/aiserver.v1.DashboardService/GetSandUsageStatus"),
441 token: format!("{base}/oauth/token"),
442 }
443 }
444
445 fn test_creds() -> GrokbotCredentials {
446 GrokbotCredentials {
447 access_token: "at-stored".into(),
448 refresh_token: "rt-stored".into(),
449 fingerprint: super::super::creds::fingerprint_of("rt-stored"),
450 }
451 }
452
453 fn usage_json() -> &'static str {
455 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"}"#
456 }
457
458 fn sample_seed(fingerprint: &str) -> serde_json::Value {
459 serde_json::json!({
460 "account": fingerprint,
461 "plan": "Grok Bot Plan",
462 "has_included_allowance": true,
463 "weekly_pct": 30,
464 "has_available_usage": true,
465 "on_demand_enabled": false,
466 "period_start": "2026-09-11T18:43:19.645Z",
467 "reset_at": "2026-09-18T18:43:19.645Z",
468 })
469 }
470
471 fn usage_mock(server: &mut mockito::ServerGuard, bearer: &str) -> mockito::Mock {
473 server
474 .mock("POST", "/aiserver.v1.DashboardService/GetSandUsageStatus")
475 .match_header("authorization", format!("Bearer {bearer}").as_str())
476 .match_header("content-type", "application/json")
477 .match_header("connect-protocol-version", "1")
478 .match_header("x-cursor-client-type", "sand")
479 .match_header("x-cursor-client-version", "0.1.0")
480 .match_header("x-sand-box-namespace", "prod")
481 .match_header("x-ghost-mode", "true")
482 .match_header(
483 "x-request-id",
484 mockito::Matcher::Regex(r"^[0-9a-f-]{36}$".into()),
485 )
486 .match_header("x-cursor-checksum", mockito::Matcher::Missing)
488 .match_body(mockito::Matcher::Json(serde_json::json!({})))
489 }
490
491 #[tokio::test]
492 async fn live_200_returns_a_snapshot_and_sends_the_connect_rpc_headers() {
493 let mut server = mockito::Server::new_async().await;
494 let m = usage_mock(&mut server, "at-stored")
495 .with_status(200)
496 .with_body(usage_json())
497 .create_async()
498 .await;
499
500 let (_td, cache) = cache_fixture();
501 let out = fetch_snapshot_with(
502 &reqwest::Client::new(),
503 &test_creds(),
504 &cache,
505 &test_endpoints(&server.url()),
506 Duration::ZERO,
507 )
508 .await
509 .unwrap();
510
511 m.assert_async().await;
512 assert_eq!(out.snapshot.plan, "Grok Bot Plan");
513 assert!(out.snapshot.has_included_allowance);
514 assert_eq!(out.snapshot.weekly_pct, 12);
515 assert_eq!(out.snapshot.window, Some(chrono::Duration::days(7)));
516 assert!(!out.stale);
517 }
518
519 #[tokio::test]
520 async fn a_401_refreshes_persists_and_retries_once() {
521 let mut server = mockito::Server::new_async().await;
522 let stale = usage_mock(&mut server, "at-stored")
523 .with_status(401)
524 .with_body(r#"{"error":"expired"}"#)
525 .expect(1)
526 .create_async()
527 .await;
528 let refresh = server
529 .mock("POST", "/oauth/token")
530 .match_body(mockito::Matcher::Json(serde_json::json!({
531 "client_id": CLIENT_ID,
532 "grant_type": "refresh_token",
533 "refresh_token": "rt-stored",
534 })))
535 .with_status(200)
536 .with_body(
537 r#"{"access_token":"at-fresh","refresh_token":"rt-rotated","expires_in":3600}"#,
538 )
539 .expect(1)
540 .create_async()
541 .await;
542 let retried = usage_mock(&mut server, "at-fresh")
543 .with_status(200)
544 .with_body(usage_json())
545 .expect(1)
546 .create_async()
547 .await;
548
549 let (_td, cache) = cache_fixture();
550 let out = fetch_snapshot_with(
551 &reqwest::Client::new(),
552 &test_creds(),
553 &cache,
554 &test_endpoints(&server.url()),
555 Duration::ZERO,
556 )
557 .await
558 .unwrap();
559
560 stale.assert_async().await;
561 refresh.assert_async().await;
562 retried.assert_async().await;
563 assert_eq!(out.snapshot.weekly_pct, 12);
564
565 let persisted: serde_json::Value =
568 serde_json::from_slice(&std::fs::read(oauth_cache_path(&cache)).unwrap()).unwrap();
569 assert_eq!(persisted["access_token"], "at-fresh");
570 assert_eq!(persisted["refresh_token"], "rt-rotated");
571 assert_eq!(
572 persisted["fingerprint"],
573 super::super::creds::fingerprint_of("rt-rotated")
574 );
575 #[cfg(unix)]
576 {
577 use std::os::unix::fs::PermissionsExt;
578 let mode = std::fs::metadata(oauth_cache_path(&cache))
579 .unwrap()
580 .permissions()
581 .mode();
582 assert_eq!(mode & 0o077, 0);
583 }
584 }
585
586 #[tokio::test]
587 async fn a_persisted_pair_is_used_in_place_of_the_apps_older_access_token() {
588 let mut server = mockito::Server::new_async().await;
589 let m = usage_mock(&mut server, "at-fresh")
590 .with_status(200)
591 .with_body(usage_json())
592 .create_async()
593 .await;
594
595 let (_td, cache) = cache_fixture();
596 write_persisted_oauth(
597 &cache,
598 &PersistedOAuth {
599 fingerprint: super::super::creds::fingerprint_of("rt-stored"),
601 access_token: "at-fresh".into(),
602 refresh_token: "rt-stored".into(),
603 },
604 )
605 .unwrap();
606
607 let out = fetch_snapshot_with(
608 &reqwest::Client::new(),
609 &test_creds(),
610 &cache,
611 &test_endpoints(&server.url()),
612 Duration::ZERO,
613 )
614 .await
615 .unwrap();
616
617 m.assert_async().await;
618 assert_eq!(out.snapshot.weekly_pct, 12);
619 }
620
621 #[tokio::test]
622 async fn a_persisted_pair_from_a_previous_sign_in_is_ignored() {
623 let mut server = mockito::Server::new_async().await;
624 let m = usage_mock(&mut server, "at-stored")
625 .with_status(200)
626 .with_body(usage_json())
627 .create_async()
628 .await;
629
630 let (_td, cache) = cache_fixture();
631 write_persisted_oauth(
632 &cache,
633 &PersistedOAuth {
634 fingerprint: super::super::creds::fingerprint_of("rt-someone-else"),
635 access_token: "at-stranger".into(),
636 refresh_token: "rt-stranger".into(),
637 },
638 )
639 .unwrap();
640
641 let out = fetch_snapshot_with(
642 &reqwest::Client::new(),
643 &test_creds(),
644 &cache,
645 &test_endpoints(&server.url()),
646 Duration::ZERO,
647 )
648 .await
649 .unwrap();
650
651 m.assert_async().await;
652 assert_eq!(out.snapshot.weekly_pct, 12);
653 }
654
655 #[tokio::test]
656 async fn a_rejected_refresh_is_a_credentials_error_naming_the_app() {
657 let mut server = mockito::Server::new_async().await;
658 usage_mock(&mut server, "at-stored")
659 .with_status(401)
660 .create_async()
661 .await;
662 server
663 .mock("POST", "/oauth/token")
664 .with_status(400)
665 .with_body(r#"{"error":"invalid_grant"}"#)
666 .create_async()
667 .await;
668
669 let (_td, cache) = cache_fixture();
670 let err = fetch_snapshot_with(
671 &reqwest::Client::new(),
672 &test_creds(),
673 &cache,
674 &test_endpoints(&server.url()),
675 Duration::ZERO,
676 )
677 .await
678 .unwrap_err();
679
680 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
681 assert!(
682 err.to_string()
683 .contains("sign in to the Grok Bot desktop app"),
684 "{err}"
685 );
686 assert!(!oauth_cache_path(&cache).exists());
688 }
689
690 #[tokio::test]
691 async fn http_500_falls_back_to_the_cache_with_a_redacted_body() {
692 let mut server = mockito::Server::new_async().await;
693 server
694 .mock("POST", "/aiserver.v1.DashboardService/GetSandUsageStatus")
695 .with_status(500)
696 .with_body("proxy secret: <token>")
697 .create_async()
698 .await;
699
700 let (_td, cache) = cache_fixture();
701 cache
702 .write_payload(
703 sample_seed(&test_creds().fingerprint)
704 .to_string()
705 .as_bytes(),
706 )
707 .unwrap();
708
709 let out = fetch_snapshot_with(
710 &reqwest::Client::new(),
711 &test_creds(),
712 &cache,
713 &test_endpoints(&server.url()),
714 Duration::ZERO,
715 )
716 .await
717 .unwrap();
718
719 assert!(out.stale);
720 assert_eq!(out.snapshot.weekly_pct, 30);
721 assert_eq!(
722 out.last_error,
723 Some((500, "Grok Bot API returned HTTP 500".into()))
724 );
725 }
726
727 #[tokio::test]
728 async fn http_401_after_refresh_also_falls_back_with_a_redacted_body() {
729 let mut server = mockito::Server::new_async().await;
730 server
731 .mock("POST", "/aiserver.v1.DashboardService/GetSandUsageStatus")
732 .with_status(403)
733 .with_body(r#"{"error":"the access token itself, echoed"}"#)
734 .create_async()
735 .await;
736 server
737 .mock("POST", "/oauth/token")
738 .with_status(200)
739 .with_body(r#"{"access_token":"at-fresh","refresh_token":"rt-rotated"}"#)
740 .create_async()
741 .await;
742
743 let (_td, cache) = cache_fixture();
744 let err = fetch_snapshot_with(
745 &reqwest::Client::new(),
746 &test_creds(),
747 &cache,
748 &test_endpoints(&server.url()),
749 Duration::ZERO,
750 )
751 .await
752 .unwrap_err();
753
754 match err {
755 AppError::Http { status, body } => {
756 assert_eq!(status, 403);
757 assert_eq!(body, "Grok Bot authentication failed");
758 }
759 other => panic!("expected Http 403, got {other:?}"),
760 }
761 }
762
763 #[tokio::test]
764 async fn a_fresh_cache_is_served_without_a_network_call() {
765 let (_td, cache) = cache_fixture();
766 cache
767 .write_payload(
768 sample_seed(&test_creds().fingerprint)
769 .to_string()
770 .as_bytes(),
771 )
772 .unwrap();
773
774 let out = fetch_snapshot_with(
776 &reqwest::Client::new(),
777 &test_creds(),
778 &cache,
779 &test_endpoints("http://127.0.0.1:1"),
780 Duration::from_secs(60),
781 )
782 .await
783 .unwrap();
784
785 assert!(!out.stale);
786 assert_eq!(out.snapshot.weekly_pct, 30);
787 assert_eq!(out.snapshot.window, Some(chrono::Duration::days(7)));
788 }
789
790 #[tokio::test]
791 async fn a_cache_from_a_previous_sign_in_is_not_reused() {
792 let mut server = mockito::Server::new_async().await;
793 let m = usage_mock(&mut server, "at-stored")
794 .with_status(200)
795 .with_body(usage_json())
796 .create_async()
797 .await;
798
799 let (_td, cache) = cache_fixture();
800 cache
801 .write_payload(sample_seed("some-other-fingerprint").to_string().as_bytes())
802 .unwrap();
803
804 let out = fetch_snapshot_with(
805 &reqwest::Client::new(),
806 &test_creds(),
807 &cache,
808 &test_endpoints(&server.url()),
809 Duration::from_secs(3600),
811 )
812 .await
813 .unwrap();
814
815 m.assert_async().await;
816 assert!(!out.stale);
817 assert_eq!(out.snapshot.weekly_pct, 12);
818 }
819
820 #[tokio::test]
821 async fn a_transport_error_with_a_stale_cache_uses_the_cache() {
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(
832 &reqwest::Client::new(),
833 &test_creds(),
834 &cache,
835 &test_endpoints("http://127.0.0.1:1"),
836 Duration::ZERO,
837 )
838 .await
839 .unwrap();
840
841 assert!(out.stale);
842 assert_eq!(out.snapshot.weekly_pct, 30);
843 }
844
845 #[test]
846 fn a_cache_with_an_out_of_range_percent_is_rejected() {
847 let mut v = sample_seed("fp");
848 v["weekly_pct"] = serde_json::json!(150);
849 let err = parse_cache_at(v.to_string().as_bytes(), "fp").unwrap_err();
850 assert!(err.to_string().contains("weekly_pct"), "{err}");
851 }
852
853 #[test]
854 fn a_snapshot_survives_the_cache_round_trip() {
855 let snap = GrokbotSnapshot {
856 plan: "Grok Bot Plan".into(),
857 has_included_allowance: true,
858 weekly_pct: 42,
859 has_available_usage: true,
860 on_demand_enabled: true,
861 period_start: DateTime::parse_from_rfc3339("2026-09-11T18:43:19.645Z")
862 .map(|dt| dt.with_timezone(&Utc))
863 .ok(),
864 reset_at: DateTime::parse_from_rfc3339("2026-09-18T18:43:19.645Z")
865 .map(|dt| dt.with_timezone(&Utc))
866 .ok(),
867 window: Some(chrono::Duration::days(7)),
868 };
869 let bytes = serde_json::to_vec(&snap_to_json(&snap, "fp")).unwrap();
870 assert_eq!(parse_cache_at(&bytes, "fp").unwrap(), snap);
871 }
872
873 #[test]
874 fn request_ids_are_uuid_v4_shaped_and_unique() {
875 let a = request_id();
876 let b = request_id();
877 assert_ne!(a, b);
878 for id in [&a, &b] {
879 assert_eq!(id.len(), 36, "{id}");
880 assert_eq!(&id[14..15], "4", "version nibble: {id}");
881 assert!(
882 matches!(&id[19..20], "8" | "9" | "a" | "b"),
883 "variant bits: {id}"
884 );
885 assert!(
886 id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'),
887 "{id}"
888 );
889 }
890 }
891}