Skip to main content

ai_usagebar/kiro/
fetch.rs

1//! Orchestrates a Kiro CLI snapshot: read kiro-cli's own cached AWS SSO OIDC
2//! token (`db.rs`), refresh it if it's close to expiry (`oauth.rs` — kiro-cli's
3//! own database is never written back to), persist the refreshed credential in
4//! ai-usagebar's account-scoped cache, then call
5//! `AmazonCodeWhispererService.GetUsageLimits` — the exact operation kiro-cli's
6//! own `/usage` slash command invokes. Cache/stale/error-fallback shape
7//! mirrors `cursor::fetch`.
8
9use std::path::Path;
10use std::time::Duration;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15use crate::cache::{Cache, acquire_lock_async, atomic_write};
16use crate::error::{AppError, Result};
17use crate::usage::KiroSnapshot;
18use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
19
20use super::db::{self, KiroCredentials};
21use super::oauth;
22use super::types::{self, UsageLimitsResponse};
23
24const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
25const REFRESH_TIMEOUT: Duration = Duration::from_secs(15);
26const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
27const REQUEST_TARGET: &str = "AmazonCodeWhispererService.GetUsageLimits";
28const OAUTH_CACHE_FILE: &str = "oauth.json";
29
30#[derive(Debug, Clone)]
31pub struct Endpoints {
32    pub usage_limits: String,
33    pub token: String,
34}
35
36impl Endpoints {
37    /// AWS's regional convention for the public CodeWhisperer Runtime
38    /// endpoint (`<service>.<region>.amazonaws.com`) — confirmed live against
39    /// this account. Enterprise IAM Identity Center accounts may instead be
40    /// proxied through `management.<region>.kiro.dev` (observed in kiro-cli's
41    /// own request trace for the same operation); both speak the same
42    /// request/response shape, so either is a config override away.
43    pub fn for_region(region: &str) -> Result<Self> {
44        oauth::validate_region(region)?;
45        Ok(Self {
46            usage_limits: format!("https://codewhisperer.{region}.amazonaws.com/"),
47            token: oauth::token_endpoint(region)?,
48        })
49    }
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
53struct PersistedOAuth {
54    account: String,
55    access_token: String,
56    refresh_token: String,
57    expires_at: DateTime<Utc>,
58}
59
60/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
61/// specialised to its snapshot.
62pub type FetchOutcome = crate::outcome::Outcome<KiroSnapshot>;
63
64/// Cache-aware fetch. `db_path` is kiro-cli's `data.sqlite3` — the caller
65/// resolves `[kiro] db_path` (config override) vs [`db::default_db_path`],
66/// the same override pattern as `cursor.db_path`.
67pub async fn fetch_snapshot(
68    client: &reqwest::Client,
69    db_path: &Path,
70    cache: &Cache,
71    cache_ttl: Duration,
72) -> Result<FetchOutcome> {
73    fetch_snapshot_at(client, db_path, cache, cache_ttl, None, Utc::now()).await
74}
75
76/// Clock seam for cache rollover tests, mirroring `cursor::fetch::fetch_snapshot_at`.
77///
78/// `endpoints_override` exists only for tests: unlike every fixed-URL vendor
79/// here, Kiro's real endpoint is derived from the account's own region (read
80/// from `db_path`, not known until inside this function), so there is no
81/// fixed default a test could point at mockito. Production always passes
82/// `None` and gets `Endpoints::for_region(&creds.region)`.
83async fn fetch_snapshot_at(
84    client: &reqwest::Client,
85    db_path: &Path,
86    cache: &Cache,
87    cache_ttl: Duration,
88    endpoints_override: Option<&Endpoints>,
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    // Resolve identity before accepting a cache hit — a `kiro-cli login`
95    // switch mid-session must not keep serving the previous account's cache.
96    let mut creds = db::read_credentials(db_path)?;
97
98    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
99        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &creds.account_key, now)
100    {
101        return Ok(outcome);
102    }
103
104    apply_persisted_oauth(cache, &mut creds)?;
105
106    let derived;
107    let endpoints = match endpoints_override {
108        Some(e) => e,
109        None => {
110            derived = Endpoints::for_region(&creds.region)?;
111            &derived
112        }
113    };
114    match fetch_live(client, endpoints, cache, &creds, now).await {
115        Ok(snap) => {
116            let bytes = serde_json::to_vec(&snap_to_json(&snap, &creds.account_key))?;
117            cache.write_payload(&bytes)?;
118            Ok(crate::outcome::Outcome::fresh(snap))
119        }
120        Err(e) if e.is_transient() => fallback_silent(cache, &creds.account_key, now, e),
121        Err(e) => {
122            cache.mark_stale();
123            if let Some((code, msg)) = error_to_pair(&e) {
124                cache.write_last_error(code, &msg);
125            }
126            fallback_with_error(cache, &creds.account_key, now, e)
127        }
128    }
129}
130
131fn fallback_silent(
132    cache: &Cache,
133    account: &str,
134    now: DateTime<Utc>,
135    original: AppError,
136) -> Result<FetchOutcome> {
137    crate::outcome::fallback(cache, None, original, |bytes| {
138        parse_cache_at(bytes, account, now)
139    })
140}
141
142fn fallback_with_error(
143    cache: &Cache,
144    account: &str,
145    now: DateTime<Utc>,
146    original: AppError,
147) -> Result<FetchOutcome> {
148    let last_error = error_to_pair(&original);
149    crate::outcome::fallback(cache, last_error, original, |bytes| {
150        parse_cache_at(bytes, account, now)
151    })
152}
153
154/// Never surface upstream bodies for auth failures: a 401/403 from an
155/// internal Bearer-token endpoint is not guaranteed not to echo something
156/// account-identifying back. Mirrors `cursor::fetch::error_to_pair`.
157fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
158    match e {
159        AppError::Http { status, .. } if matches!(status, 401 | 403) => {
160            Some((*status, "Kiro CLI authentication failed".into()))
161        }
162        AppError::Http { status, body } => Some((*status, body.clone())),
163        AppError::Credentials(msg) => Some((0, msg.clone())),
164        e => Some((0, e.to_string())),
165    }
166}
167
168fn reuse_cache(
169    bytes: &[u8],
170    cache: &Cache,
171    stale: bool,
172    account: &str,
173    now: DateTime<Utc>,
174) -> Result<FetchOutcome> {
175    let snap = parse_cache_at(bytes, account, now)?;
176    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
177}
178
179fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<KiroSnapshot> {
180    let v: serde_json::Value = serde_json::from_slice(bytes)?;
181    if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
182        return Err(AppError::Schema(
183            "kiro cache belongs to a different account; refetching".into(),
184        ));
185    }
186    let plan = v["plan"]
187        .as_str()
188        .filter(|plan| !plan.trim().is_empty())
189        .ok_or_else(|| AppError::Schema("kiro cache: invalid plan".into()))?
190        .to_string();
191    let used = v["used"]
192        .as_f64()
193        .filter(|n| n.is_finite() && *n >= 0.0)
194        .ok_or_else(|| AppError::Schema("kiro cache: invalid used".into()))?;
195    let limit = v["limit"]
196        .as_f64()
197        .filter(|n| n.is_finite() && *n >= 0.0)
198        .ok_or_else(|| AppError::Schema("kiro cache: invalid limit".into()))?;
199    let reset_at = match &v["reset_at"] {
200        serde_json::Value::Null => None,
201        serde_json::Value::String(s) => Some(
202            DateTime::parse_from_rfc3339(s)
203                .map_err(|e| AppError::Schema(format!("kiro cache: invalid reset_at: {e}")))?
204                .with_timezone(&Utc),
205        ),
206        _ => return Err(AppError::Schema("kiro cache: invalid reset_at".into())),
207    };
208    // Only reject a *known-past* reset — the cycle rolled over while cached.
209    // A missing reset (None) is a legitimate shape, not staleness.
210    if let Some(reset_at) = reset_at
211        && reset_at <= now
212    {
213        return Err(AppError::Schema(
214            "kiro cache is past its credit-cycle reset; refetching".into(),
215        ));
216    }
217    Ok(KiroSnapshot {
218        plan,
219        used,
220        limit,
221        reset_at,
222    })
223}
224
225fn snap_to_json(snap: &KiroSnapshot, account: &str) -> serde_json::Value {
226    serde_json::json!({
227        "account": account,
228        "plan": snap.plan,
229        "used": snap.used,
230        "limit": snap.limit,
231        "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
232    })
233}
234
235fn oauth_cache_path(cache: &Cache) -> std::path::PathBuf {
236    cache.dir().join(OAUTH_CACHE_FILE)
237}
238
239fn read_persisted_oauth(cache: &Cache, account: &str) -> Result<Option<PersistedOAuth>> {
240    let path = oauth_cache_path(cache);
241    let bytes = match std::fs::read(&path) {
242        Ok(bytes) => bytes,
243        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
244        Err(e) => return Err(AppError::io_at(&path, e)),
245    };
246    let persisted: PersistedOAuth = serde_json::from_slice(&bytes).map_err(|e| {
247        AppError::Credentials(format!(
248            "ai-usagebar's cached Kiro credentials at {} are malformed ({e}); remove that file and try again",
249            path.display()
250        ))
251    })?;
252    if persisted.account != account {
253        return Ok(None);
254    }
255    if persisted.access_token.trim().is_empty() || persisted.refresh_token.trim().is_empty() {
256        return Err(AppError::Credentials(format!(
257            "ai-usagebar's cached Kiro credentials at {} are incomplete; remove that file and try again",
258            path.display()
259        )));
260    }
261    Ok(Some(persisted))
262}
263
264fn apply_persisted_oauth(cache: &Cache, creds: &mut KiroCredentials) -> Result<()> {
265    let Some(persisted) = read_persisted_oauth(cache, &creds.account_key)? else {
266        return Ok(());
267    };
268    // Kiro may refresh its own database independently. Prefer whichever
269    // account-matched credential has the later expiry.
270    if persisted.expires_at > creds.expires_at {
271        creds.access_token = persisted.access_token;
272        creds.refresh_token = persisted.refresh_token;
273        creds.expires_at = persisted.expires_at;
274    }
275    Ok(())
276}
277
278fn write_persisted_oauth(cache: &Cache, persisted: &PersistedOAuth) -> Result<()> {
279    let path = oauth_cache_path(cache);
280    let bytes = serde_json::to_vec_pretty(persisted)?;
281    atomic_write(&path, &bytes)?;
282    #[cfg(unix)]
283    {
284        use std::os::unix::fs::PermissionsExt;
285        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
286            .map_err(|e| AppError::io_at(&path, e))?;
287    }
288    Ok(())
289}
290
291async fn fetch_live(
292    client: &reqwest::Client,
293    endpoints: &Endpoints,
294    cache: &Cache,
295    creds: &KiroCredentials,
296    now: DateTime<Utc>,
297) -> Result<KiroSnapshot> {
298    let access_token = if oauth::needs_refresh(creds.expires_at.timestamp(), now.timestamp()) {
299        let refreshed = tokio::time::timeout(
300            REFRESH_TIMEOUT,
301            oauth::refresh(
302                client,
303                &endpoints.token,
304                &creds.client_id,
305                &creds.client_secret,
306                &creds.refresh_token,
307            ),
308        )
309        .await
310        .map_err(|_| {
311            AppError::Transport(format!("kiro token refresh timeout: {}", endpoints.token))
312        })?
313        .map_err(|e| {
314            AppError::Credentials(format!(
315                "Kiro CLI token refresh failed ({e}). Run `kiro-cli login` again."
316            ))
317        })?;
318        let expires_in = i64::try_from(refreshed.expires_in)
319            .map_err(|_| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
320        let expires_at_secs = now
321            .timestamp()
322            .checked_add(expires_in)
323            .ok_or_else(|| AppError::Schema("kiro token refresh expiry overflowed".into()))?;
324        let expires_at = DateTime::from_timestamp(expires_at_secs, 0)
325            .ok_or_else(|| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
326        let persisted = PersistedOAuth {
327            account: creds.account_key.clone(),
328            access_token: refreshed.access_token,
329            refresh_token: refreshed
330                .refresh_token
331                .unwrap_or_else(|| creds.refresh_token.clone()),
332            expires_at,
333        };
334        write_persisted_oauth(cache, &persisted).map_err(|e| {
335            AppError::Credentials(format!(
336                "refreshed Kiro CLI credentials could not be saved ({e}); run `kiro-cli login` again if the refresh token was rotated"
337            ))
338        })?;
339        persisted.access_token
340    } else {
341        creds.access_token.clone()
342    };
343
344    let body = serde_json::json!({
345        "origin": "AI_EDITOR",
346        "profileArn": creds.profile_arn,
347        "resourceType": "AGENTIC_REQUEST",
348    });
349
350    let resp = tokio::time::timeout(
351        HTTP_TIMEOUT,
352        client
353            .post(&endpoints.usage_limits)
354            .header("Content-Type", "application/x-amz-json-1.0")
355            .header("x-amz-target", REQUEST_TARGET)
356            .header("Authorization", format!("Bearer {access_token}"))
357            .json(&body)
358            .send(),
359    )
360    .await
361    .map_err(|_| AppError::Transport(format!("kiro timeout: {}", endpoints.usage_limits)))??;
362
363    let status = resp.status();
364    if !status.is_success() {
365        let body = if matches!(status.as_u16(), 401 | 403) {
366            "Kiro CLI authentication failed".into()
367        } else {
368            format!("Kiro CLI API returned HTTP {}", status.as_u16())
369        };
370        return Err(AppError::Http {
371            status: status.as_u16(),
372            body,
373        });
374    }
375
376    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
377    let parsed: UsageLimitsResponse = serde_json::from_slice(&bytes)
378        .map_err(|e| AppError::Schema(format!("kiro usage-limits response: {e}")))?;
379    types::to_snapshot(parsed)
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use rusqlite::Connection;
386    use tempfile::TempDir;
387
388    fn cache_fixture() -> (TempDir, Cache) {
389        let td = TempDir::new().unwrap();
390        let cache = Cache::at(td.path().join("kiro"));
391        cache.ensure_dir().unwrap();
392        (td, cache)
393    }
394
395    fn seed_db(dir: &TempDir, expires_at: &str) -> std::path::PathBuf {
396        let path = dir.path().join("data.sqlite3");
397        let conn = Connection::open(&path).unwrap();
398        conn.execute("CREATE TABLE auth_kv (key TEXT, value TEXT)", [])
399            .unwrap();
400        conn.execute("CREATE TABLE state (key TEXT, value TEXT)", [])
401            .unwrap();
402        let token = serde_json::json!({
403            "access_token": "AT", "refresh_token": "RT",
404            "expires_at": expires_at, "region": "us-east-1",
405        })
406        .to_string();
407        let device =
408            serde_json::json!({"client_id": "CID", "client_secret": "CSECRET"}).to_string();
409        let profile =
410            serde_json::json!({"arn": "arn:aws:codewhisperer:us-east-1:1:profile/A"}).to_string();
411        conn.execute(
412            "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:token', ?1)",
413            [&token],
414        )
415        .unwrap();
416        conn.execute(
417            "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:device-registration', ?1)",
418            [&device],
419        )
420        .unwrap();
421        conn.execute(
422            "INSERT INTO state (key, value) VALUES ('api.codewhisperer.profile', ?1)",
423            [&profile],
424        )
425        .unwrap();
426        path
427    }
428
429    fn account_key(dir: &TempDir) -> String {
430        let path = dir.path().join("data.sqlite3");
431        db::read_credentials(&path).unwrap().account_key
432    }
433
434    fn usage_json() -> String {
435        r#"{
436            "nextDateReset": 4102444800.0,
437            "subscriptionInfo": { "subscriptionTitle": "KIRO POWER" },
438            "usageBreakdownList": [{
439                "resourceType": "CREDIT",
440                "currentUsageWithPrecision": 40.0,
441                "usageLimitWithPrecision": 100.0
442            }]
443        }"#
444        .to_string()
445    }
446
447    #[tokio::test]
448    async fn live_fetch_reads_token_from_db_and_calls_get_usage_limits() {
449        let mut server = mockito::Server::new_async().await;
450        let m = server
451            .mock("POST", "/")
452            .match_header("x-amz-target", "AmazonCodeWhispererService.GetUsageLimits")
453            .match_header("authorization", "Bearer AT")
454            .with_status(200)
455            .with_body(usage_json())
456            .create_async()
457            .await;
458
459        let db_dir = TempDir::new().unwrap();
460        // Far future — no refresh needed.
461        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
462        let client = reqwest::Client::new();
463        let endpoints_url = server.url();
464
465        let creds = db::read_credentials(&db_path).unwrap();
466        let (_cache_dir, cache) = cache_fixture();
467        let out = fetch_live(
468            &client,
469            &Endpoints {
470                usage_limits: endpoints_url,
471                token: "unused".into(),
472            },
473            &cache,
474            &creds,
475            Utc::now(),
476        )
477        .await
478        .unwrap();
479
480        assert_eq!(out.plan, "KIRO POWER");
481        assert_eq!(out.used, 40.0);
482        assert_eq!(out.limit, 100.0);
483        m.assert_async().await;
484    }
485
486    #[tokio::test]
487    async fn expired_token_is_refreshed_before_the_usage_call() {
488        let mut server = mockito::Server::new_async().await;
489        let token_mock = server
490            .mock("POST", "/token")
491            .with_status(200)
492            .with_body(r#"{"accessToken":"NEW-AT","expiresIn":3600}"#)
493            .create_async()
494            .await;
495        let usage_mock = server
496            .mock("POST", "/")
497            .match_header("authorization", "Bearer NEW-AT")
498            .with_status(200)
499            .with_body(usage_json())
500            .create_async()
501            .await;
502
503        let db_dir = TempDir::new().unwrap();
504        // Already expired — must trigger a refresh.
505        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
506        let creds = db::read_credentials(&db_path).unwrap();
507        let (_cache_dir, cache) = cache_fixture();
508        let client = reqwest::Client::new();
509
510        let out = fetch_live(
511            &client,
512            &Endpoints {
513                usage_limits: server.url(),
514                token: format!("{}/token", server.url()),
515            },
516            &cache,
517            &creds,
518            Utc::now(),
519        )
520        .await
521        .unwrap();
522
523        assert_eq!(out.used, 40.0);
524        token_mock.assert_async().await;
525        usage_mock.assert_async().await;
526    }
527
528    #[tokio::test]
529    async fn refreshed_credentials_are_reused_and_rotated_token_is_retained() {
530        let mut server = mockito::Server::new_async().await;
531        let token_mock = server
532            .mock("POST", "/token")
533            .match_body(mockito::Matcher::Json(serde_json::json!({
534                "clientId": "CID",
535                "clientSecret": "CSECRET",
536                "grantType": "refresh_token",
537                "refreshToken": "RT",
538            })))
539            .with_status(200)
540            .with_body(r#"{"accessToken":"NEW-AT","refreshToken":"ROTATED-RT","expiresIn":3600}"#)
541            .expect(1)
542            .create_async()
543            .await;
544        let usage_mock = server
545            .mock("POST", "/")
546            .match_header("authorization", "Bearer NEW-AT")
547            .with_status(200)
548            .with_body(usage_json())
549            .expect(2)
550            .create_async()
551            .await;
552
553        let db_dir = TempDir::new().unwrap();
554        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
555        let (_cache_dir, cache) = cache_fixture();
556        let endpoints = Endpoints {
557            usage_limits: server.url(),
558            token: format!("{}/token", server.url()),
559        };
560        let now = DateTime::parse_from_rfc3339("2026-08-03T12:00:00Z")
561            .unwrap()
562            .with_timezone(&Utc);
563
564        for _ in 0..2 {
565            let out = fetch_snapshot_at(
566                &reqwest::Client::new(),
567                &db_path,
568                &cache,
569                Duration::ZERO,
570                Some(&endpoints),
571                now,
572            )
573            .await
574            .unwrap();
575            assert_eq!(out.snapshot.used, 40.0);
576        }
577
578        let persisted: serde_json::Value =
579            serde_json::from_slice(&std::fs::read(oauth_cache_path(&cache)).unwrap()).unwrap();
580        assert_eq!(persisted["access_token"], "NEW-AT");
581        assert_eq!(persisted["refresh_token"], "ROTATED-RT");
582        assert_eq!(persisted["account"], account_key(&db_dir));
583        #[cfg(unix)]
584        {
585            use std::os::unix::fs::PermissionsExt;
586            let mode = std::fs::metadata(oauth_cache_path(&cache))
587                .unwrap()
588                .permissions()
589                .mode();
590            assert_eq!(mode & 0o077, 0);
591        }
592
593        token_mock.assert_async().await;
594        usage_mock.assert_async().await;
595    }
596
597    #[test]
598    fn persisted_credentials_from_another_account_are_ignored() {
599        let db_dir = TempDir::new().unwrap();
600        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
601        let mut creds = db::read_credentials(&db_path).unwrap();
602        let (_cache_dir, cache) = cache_fixture();
603        write_persisted_oauth(
604            &cache,
605            &PersistedOAuth {
606                account: "another-account".into(),
607                access_token: "OTHER-AT".into(),
608                refresh_token: "OTHER-RT".into(),
609                expires_at: DateTime::parse_from_rfc3339("2100-01-01T00:00:00Z")
610                    .unwrap()
611                    .with_timezone(&Utc),
612            },
613        )
614        .unwrap();
615
616        apply_persisted_oauth(&cache, &mut creds).unwrap();
617
618        assert_eq!(creds.access_token, "AT");
619        assert_eq!(creds.refresh_token, "RT");
620    }
621
622    #[tokio::test]
623    async fn refresh_failure_is_a_credentials_error() {
624        let mut server = mockito::Server::new_async().await;
625        server
626            .mock("POST", "/token")
627            .with_status(400)
628            .with_body(r#"{"error":"invalid_grant"}"#)
629            .create_async()
630            .await;
631
632        let db_dir = TempDir::new().unwrap();
633        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
634        let creds = db::read_credentials(&db_path).unwrap();
635        let (_cache_dir, cache) = cache_fixture();
636        let client = reqwest::Client::new();
637
638        let err = fetch_live(
639            &client,
640            &Endpoints {
641                usage_limits: server.url(),
642                token: format!("{}/token", server.url()),
643            },
644            &cache,
645            &creds,
646            Utc::now(),
647        )
648        .await
649        .unwrap_err();
650        assert!(matches!(err, AppError::Credentials(_)));
651    }
652
653    #[tokio::test]
654    async fn a_stale_cache_falls_back_when_the_live_call_fails() {
655        let mut server = mockito::Server::new_async().await;
656        server
657            .mock("POST", "/")
658            .with_status(500)
659            .with_body("boom")
660            .create_async()
661            .await;
662
663        let db_dir = TempDir::new().unwrap();
664        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
665        let (_cache_dir, cache) = cache_fixture();
666        let account = account_key(&db_dir);
667        cache
668            .write_payload(
669                serde_json::to_vec(&serde_json::json!({
670                    "account": account,
671                    "plan": "KIRO POWER",
672                    "used": 10.0,
673                    "limit": 100.0,
674                    "reset_at": null,
675                }))
676                .unwrap()
677                .as_slice(),
678            )
679            .unwrap();
680        // Force the cache to look old enough to require a live refetch.
681        std::thread::sleep(std::time::Duration::from_millis(5));
682
683        let client = reqwest::Client::new();
684        let out = fetch_snapshot_at(
685            &client,
686            &db_path,
687            &cache,
688            Duration::from_millis(1),
689            Some(&Endpoints {
690                usage_limits: server.url(),
691                token: format!("{}/token", server.url()),
692            }),
693            Utc::now(),
694        )
695        .await
696        .unwrap();
697
698        assert!(out.stale);
699        assert_eq!(out.snapshot.used, 10.0);
700        assert!(out.last_error.is_some());
701    }
702
703    #[tokio::test]
704    async fn fresh_cache_is_served_without_a_network_call() {
705        let db_dir = TempDir::new().unwrap();
706        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
707        let (_cache_dir, cache) = cache_fixture();
708        let account = account_key(&db_dir);
709        cache
710            .write_payload(
711                serde_json::to_vec(&serde_json::json!({
712                    "account": account,
713                    "plan": "KIRO POWER",
714                    "used": 10.0,
715                    "limit": 100.0,
716                    "reset_at": null,
717                }))
718                .unwrap()
719                .as_slice(),
720            )
721            .unwrap();
722
723        let client = reqwest::Client::new();
724        // Deliberately no mock server configured: a fresh-cache hit must
725        // never reach the network, so pointing the override at a bogus
726        // "http://127.0.0.1:1" endpoint is a canary — the test would fail
727        // with a transport error if the cache short-circuit stopped working.
728        let out = fetch_snapshot_at(
729            &client,
730            &db_path,
731            &cache,
732            Duration::from_secs(60),
733            Some(&Endpoints {
734                usage_limits: "http://127.0.0.1:1".into(),
735                token: "http://127.0.0.1:1".into(),
736            }),
737            Utc::now(),
738        )
739        .await
740        .unwrap();
741
742        assert!(!out.stale);
743        assert_eq!(out.snapshot.used, 10.0);
744    }
745
746    #[tokio::test]
747    async fn cache_from_a_different_account_is_not_reused() {
748        let mut server = mockito::Server::new_async().await;
749        let m = server
750            .mock("POST", "/")
751            .with_status(200)
752            .with_body(usage_json())
753            .create_async()
754            .await;
755
756        let db_dir = TempDir::new().unwrap();
757        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
758        let (_cache_dir, cache) = cache_fixture();
759        cache
760            .write_payload(
761                serde_json::to_vec(&serde_json::json!({
762                    "account": "some-other-account",
763                    "plan": "STALE PLAN",
764                    "used": 10.0,
765                    "limit": 100.0,
766                    "reset_at": null,
767                }))
768                .unwrap()
769                .as_slice(),
770            )
771            .unwrap();
772
773        let client = reqwest::Client::new();
774        let out = fetch_snapshot_at(
775            &client,
776            &db_path,
777            &cache,
778            Duration::from_secs(60),
779            Some(&Endpoints {
780                usage_limits: server.url(),
781                token: format!("{}/token", server.url()),
782            }),
783            Utc::now(),
784        )
785        .await
786        .unwrap();
787
788        // The account-mismatched cache was rejected, so this came from the
789        // live mock (used=40, plan="KIRO POWER"), not the stale cache entry.
790        assert_eq!(out.snapshot.used, 40.0);
791        assert_eq!(out.snapshot.plan, "KIRO POWER");
792        assert!(!out.stale);
793        m.assert_async().await;
794    }
795
796    /// The credit cycle rolled over while cached: serving that cache during
797    /// an outage would show last cycle's usage as if it were current. The
798    /// parse must reject it, surfacing the live error instead. Mirrors
799    /// `cursor::fetch`'s `cache_past_its_billing_reset_is_not_served_during_an_outage`.
800    #[tokio::test]
801    async fn cache_past_its_credit_reset_is_not_served_during_an_outage() {
802        let db_dir = TempDir::new().unwrap();
803        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
804        let (_cache_dir, cache) = cache_fixture();
805        let account = account_key(&db_dir);
806        cache
807            .write_payload(
808                serde_json::to_vec(&serde_json::json!({
809                    "account": account,
810                    "plan": "KIRO POWER",
811                    "used": 10.0,
812                    "limit": 100.0,
813                    "reset_at": "2026-08-04T00:00:00Z",
814                }))
815                .unwrap()
816                .as_slice(),
817            )
818            .unwrap();
819
820        let mut server = mockito::Server::new_async().await;
821        server
822            .mock("POST", "/")
823            .with_status(503)
824            .create_async()
825            .await;
826        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
827            .unwrap()
828            .with_timezone(&Utc);
829        let err = fetch_snapshot_at(
830            &reqwest::Client::new(),
831            &db_path,
832            &cache,
833            Duration::from_secs(0),
834            Some(&Endpoints {
835                usage_limits: server.url(),
836                token: format!("{}/token", server.url()),
837            }),
838            now,
839        )
840        .await
841        .unwrap_err();
842        assert!(matches!(err, AppError::Http { status: 503, .. }));
843    }
844}