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