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