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            // A refresh that never reached the token endpoint says nothing
315            // about the login: keep it transient, so the cache covers it the
316            // way it covers the usage call's own network failures.
317            if e.is_transient() {
318                e
319            } else {
320                AppError::Credentials(format!(
321                    "Kiro CLI token refresh failed ({e}). Run `kiro-cli login` again."
322                ))
323            }
324        })?;
325        let expires_in = i64::try_from(refreshed.expires_in)
326            .map_err(|_| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
327        let expires_at_secs = now
328            .timestamp()
329            .checked_add(expires_in)
330            .ok_or_else(|| AppError::Schema("kiro token refresh expiry overflowed".into()))?;
331        let expires_at = DateTime::from_timestamp(expires_at_secs, 0)
332            .ok_or_else(|| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
333        let persisted = PersistedOAuth {
334            account: creds.account_key.clone(),
335            access_token: refreshed.access_token,
336            refresh_token: refreshed
337                .refresh_token
338                .unwrap_or_else(|| creds.refresh_token.clone()),
339            expires_at,
340        };
341        write_persisted_oauth(cache, &persisted).map_err(|e| {
342            AppError::Credentials(format!(
343                "refreshed Kiro CLI credentials could not be saved ({e}); run `kiro-cli login` again if the refresh token was rotated"
344            ))
345        })?;
346        persisted.access_token
347    } else {
348        creds.access_token.clone()
349    };
350
351    let body = serde_json::json!({
352        "origin": "AI_EDITOR",
353        "profileArn": creds.profile_arn,
354        "resourceType": "AGENTIC_REQUEST",
355    });
356
357    let resp = tokio::time::timeout(
358        HTTP_TIMEOUT,
359        client
360            .post(&endpoints.usage_limits)
361            .header("Content-Type", "application/x-amz-json-1.0")
362            .header("x-amz-target", REQUEST_TARGET)
363            .header("Authorization", format!("Bearer {access_token}"))
364            .json(&body)
365            .send(),
366    )
367    .await
368    .map_err(|_| AppError::Transport(format!("kiro timeout: {}", endpoints.usage_limits)))??;
369
370    let status = resp.status();
371    if !status.is_success() {
372        let body = if matches!(status.as_u16(), 401 | 403) {
373            "Kiro CLI authentication failed".into()
374        } else {
375            format!("Kiro CLI API returned HTTP {}", status.as_u16())
376        };
377        return Err(AppError::Http {
378            status: status.as_u16(),
379            body,
380        });
381    }
382
383    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
384    let parsed: UsageLimitsResponse = serde_json::from_slice(&bytes)
385        .map_err(|e| AppError::Schema(format!("kiro usage-limits response: {e}")))?;
386    types::to_snapshot(parsed)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use rusqlite::Connection;
393    use tempfile::TempDir;
394
395    fn cache_fixture() -> (TempDir, Cache) {
396        let td = TempDir::new().unwrap();
397        let cache = Cache::at(td.path().join("kiro"));
398        cache.ensure_dir().unwrap();
399        (td, cache)
400    }
401
402    fn seed_db(dir: &TempDir, expires_at: &str) -> std::path::PathBuf {
403        let path = dir.path().join("data.sqlite3");
404        let conn = Connection::open(&path).unwrap();
405        conn.execute("CREATE TABLE auth_kv (key TEXT, value TEXT)", [])
406            .unwrap();
407        conn.execute("CREATE TABLE state (key TEXT, value TEXT)", [])
408            .unwrap();
409        let token = serde_json::json!({
410            "access_token": "AT", "refresh_token": "RT",
411            "expires_at": expires_at, "region": "us-east-1",
412        })
413        .to_string();
414        let device =
415            serde_json::json!({"client_id": "CID", "client_secret": "CSECRET"}).to_string();
416        let profile =
417            serde_json::json!({"arn": "arn:aws:codewhisperer:us-east-1:1:profile/A"}).to_string();
418        conn.execute(
419            "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:token', ?1)",
420            [&token],
421        )
422        .unwrap();
423        conn.execute(
424            "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:device-registration', ?1)",
425            [&device],
426        )
427        .unwrap();
428        conn.execute(
429            "INSERT INTO state (key, value) VALUES ('api.codewhisperer.profile', ?1)",
430            [&profile],
431        )
432        .unwrap();
433        path
434    }
435
436    fn account_key(dir: &TempDir) -> String {
437        let path = dir.path().join("data.sqlite3");
438        db::read_credentials(&path).unwrap().account_key
439    }
440
441    fn usage_json() -> String {
442        r#"{
443            "nextDateReset": 4102444800.0,
444            "subscriptionInfo": { "subscriptionTitle": "KIRO POWER" },
445            "usageBreakdownList": [{
446                "resourceType": "CREDIT",
447                "currentUsageWithPrecision": 40.0,
448                "usageLimitWithPrecision": 100.0
449            }]
450        }"#
451        .to_string()
452    }
453
454    #[tokio::test]
455    async fn live_fetch_reads_token_from_db_and_calls_get_usage_limits() {
456        let mut server = mockito::Server::new_async().await;
457        let m = server
458            .mock("POST", "/")
459            .match_header("x-amz-target", "AmazonCodeWhispererService.GetUsageLimits")
460            .match_header("authorization", "Bearer AT")
461            .with_status(200)
462            .with_body(usage_json())
463            .create_async()
464            .await;
465
466        let db_dir = TempDir::new().unwrap();
467        // Far future — no refresh needed.
468        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
469        let client = reqwest::Client::new();
470        let endpoints_url = server.url();
471
472        let creds = db::read_credentials(&db_path).unwrap();
473        let (_cache_dir, cache) = cache_fixture();
474        let out = fetch_live(
475            &client,
476            &Endpoints {
477                usage_limits: endpoints_url,
478                token: "unused".into(),
479            },
480            &cache,
481            &creds,
482            Utc::now(),
483        )
484        .await
485        .unwrap();
486
487        assert_eq!(out.plan, "KIRO POWER");
488        assert_eq!(out.used, 40.0);
489        assert_eq!(out.limit, 100.0);
490        m.assert_async().await;
491    }
492
493    #[tokio::test]
494    async fn expired_token_is_refreshed_before_the_usage_call() {
495        let mut server = mockito::Server::new_async().await;
496        let token_mock = server
497            .mock("POST", "/token")
498            .with_status(200)
499            .with_body(r#"{"accessToken":"NEW-AT","expiresIn":3600}"#)
500            .create_async()
501            .await;
502        let usage_mock = server
503            .mock("POST", "/")
504            .match_header("authorization", "Bearer NEW-AT")
505            .with_status(200)
506            .with_body(usage_json())
507            .create_async()
508            .await;
509
510        let db_dir = TempDir::new().unwrap();
511        // Already expired — must trigger a refresh.
512        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
513        let creds = db::read_credentials(&db_path).unwrap();
514        let (_cache_dir, cache) = cache_fixture();
515        let client = reqwest::Client::new();
516
517        let out = fetch_live(
518            &client,
519            &Endpoints {
520                usage_limits: server.url(),
521                token: format!("{}/token", server.url()),
522            },
523            &cache,
524            &creds,
525            Utc::now(),
526        )
527        .await
528        .unwrap();
529
530        assert_eq!(out.used, 40.0);
531        token_mock.assert_async().await;
532        usage_mock.assert_async().await;
533    }
534
535    #[tokio::test]
536    async fn refreshed_credentials_are_reused_and_rotated_token_is_retained() {
537        let mut server = mockito::Server::new_async().await;
538        let token_mock = server
539            .mock("POST", "/token")
540            .match_body(mockito::Matcher::Json(serde_json::json!({
541                "clientId": "CID",
542                "clientSecret": "CSECRET",
543                "grantType": "refresh_token",
544                "refreshToken": "RT",
545            })))
546            .with_status(200)
547            .with_body(r#"{"accessToken":"NEW-AT","refreshToken":"ROTATED-RT","expiresIn":3600}"#)
548            .expect(1)
549            .create_async()
550            .await;
551        let usage_mock = server
552            .mock("POST", "/")
553            .match_header("authorization", "Bearer NEW-AT")
554            .with_status(200)
555            .with_body(usage_json())
556            .expect(2)
557            .create_async()
558            .await;
559
560        let db_dir = TempDir::new().unwrap();
561        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
562        let (_cache_dir, cache) = cache_fixture();
563        let endpoints = Endpoints {
564            usage_limits: server.url(),
565            token: format!("{}/token", server.url()),
566        };
567        let now = DateTime::parse_from_rfc3339("2026-08-03T12:00:00Z")
568            .unwrap()
569            .with_timezone(&Utc);
570
571        for _ in 0..2 {
572            let out = fetch_snapshot_at(
573                &reqwest::Client::new(),
574                &db_path,
575                &cache,
576                Duration::ZERO,
577                Some(&endpoints),
578                now,
579            )
580            .await
581            .unwrap();
582            assert_eq!(out.snapshot.used, 40.0);
583        }
584
585        let persisted: serde_json::Value =
586            serde_json::from_slice(&std::fs::read(oauth_cache_path(&cache)).unwrap()).unwrap();
587        assert_eq!(persisted["access_token"], "NEW-AT");
588        assert_eq!(persisted["refresh_token"], "ROTATED-RT");
589        assert_eq!(persisted["account"], account_key(&db_dir));
590        #[cfg(unix)]
591        {
592            use std::os::unix::fs::PermissionsExt;
593            let mode = std::fs::metadata(oauth_cache_path(&cache))
594                .unwrap()
595                .permissions()
596                .mode();
597            assert_eq!(mode & 0o077, 0);
598        }
599
600        token_mock.assert_async().await;
601        usage_mock.assert_async().await;
602    }
603
604    #[test]
605    fn persisted_credentials_from_another_account_are_ignored() {
606        let db_dir = TempDir::new().unwrap();
607        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
608        let mut creds = db::read_credentials(&db_path).unwrap();
609        let (_cache_dir, cache) = cache_fixture();
610        write_persisted_oauth(
611            &cache,
612            &PersistedOAuth {
613                account: "another-account".into(),
614                access_token: "OTHER-AT".into(),
615                refresh_token: "OTHER-RT".into(),
616                expires_at: DateTime::parse_from_rfc3339("2100-01-01T00:00:00Z")
617                    .unwrap()
618                    .with_timezone(&Utc),
619            },
620        )
621        .unwrap();
622
623        apply_persisted_oauth(&cache, &mut creds).unwrap();
624
625        assert_eq!(creds.access_token, "AT");
626        assert_eq!(creds.refresh_token, "RT");
627    }
628
629    #[tokio::test]
630    async fn refresh_failure_is_a_credentials_error() {
631        let mut server = mockito::Server::new_async().await;
632        server
633            .mock("POST", "/token")
634            .with_status(400)
635            .with_body(r#"{"error":"invalid_grant"}"#)
636            .create_async()
637            .await;
638
639        let db_dir = TempDir::new().unwrap();
640        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
641        let creds = db::read_credentials(&db_path).unwrap();
642        let (_cache_dir, cache) = cache_fixture();
643        let client = reqwest::Client::new();
644
645        let err = fetch_live(
646            &client,
647            &Endpoints {
648                usage_limits: server.url(),
649                token: format!("{}/token", server.url()),
650            },
651            &cache,
652            &creds,
653            Utc::now(),
654        )
655        .await
656        .unwrap_err();
657        assert!(matches!(err, AppError::Credentials(_)));
658    }
659
660    /// A refresh that cannot reach the token endpoint is a network failure,
661    /// not a rejected login: it must not record "run `kiro-cli login`" as the
662    /// refresh error, and with no cache to fall back on it stays transient.
663    #[tokio::test]
664    async fn a_refresh_that_cannot_connect_is_not_a_sign_in_error() {
665        let db_dir = TempDir::new().unwrap();
666        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
667        let (_cache_dir, cache) = cache_fixture();
668
669        let err = fetch_snapshot_at(
670            &reqwest::Client::new(),
671            &db_path,
672            &cache,
673            Duration::ZERO,
674            Some(&Endpoints {
675                usage_limits: "http://127.0.0.1:1".into(),
676                token: "http://127.0.0.1:1/token".into(),
677            }),
678            Utc::now(),
679        )
680        .await
681        .unwrap_err();
682
683        assert!(err.is_transient(), "{err:?}");
684        assert_eq!(cache.read_last_error(), None);
685    }
686
687    #[tokio::test]
688    async fn a_stale_cache_falls_back_when_the_live_call_fails() {
689        let mut server = mockito::Server::new_async().await;
690        server
691            .mock("POST", "/")
692            .with_status(500)
693            .with_body("boom")
694            .create_async()
695            .await;
696
697        let db_dir = TempDir::new().unwrap();
698        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
699        let (_cache_dir, cache) = cache_fixture();
700        let account = account_key(&db_dir);
701        cache
702            .write_payload(
703                serde_json::to_vec(&serde_json::json!({
704                    "account": account,
705                    "plan": "KIRO POWER",
706                    "used": 10.0,
707                    "limit": 100.0,
708                    "reset_at": null,
709                }))
710                .unwrap()
711                .as_slice(),
712            )
713            .unwrap();
714        // Force the cache to look old enough to require a live refetch.
715        std::thread::sleep(std::time::Duration::from_millis(5));
716
717        let client = reqwest::Client::new();
718        let out = fetch_snapshot_at(
719            &client,
720            &db_path,
721            &cache,
722            Duration::from_millis(1),
723            Some(&Endpoints {
724                usage_limits: server.url(),
725                token: format!("{}/token", server.url()),
726            }),
727            Utc::now(),
728        )
729        .await
730        .unwrap();
731
732        assert!(out.stale);
733        assert_eq!(out.snapshot.used, 10.0);
734        assert!(out.last_error.is_some());
735    }
736
737    #[tokio::test]
738    async fn fresh_cache_is_served_without_a_network_call() {
739        let db_dir = TempDir::new().unwrap();
740        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
741        let (_cache_dir, cache) = cache_fixture();
742        let account = account_key(&db_dir);
743        cache
744            .write_payload(
745                serde_json::to_vec(&serde_json::json!({
746                    "account": account,
747                    "plan": "KIRO POWER",
748                    "used": 10.0,
749                    "limit": 100.0,
750                    "reset_at": null,
751                }))
752                .unwrap()
753                .as_slice(),
754            )
755            .unwrap();
756
757        let client = reqwest::Client::new();
758        // Deliberately no mock server configured: a fresh-cache hit must
759        // never reach the network, so pointing the override at a bogus
760        // "http://127.0.0.1:1" endpoint is a canary — the test would fail
761        // with a transport error if the cache short-circuit stopped working.
762        let out = fetch_snapshot_at(
763            &client,
764            &db_path,
765            &cache,
766            Duration::from_secs(60),
767            Some(&Endpoints {
768                usage_limits: "http://127.0.0.1:1".into(),
769                token: "http://127.0.0.1:1".into(),
770            }),
771            Utc::now(),
772        )
773        .await
774        .unwrap();
775
776        assert!(!out.stale);
777        assert_eq!(out.snapshot.used, 10.0);
778    }
779
780    #[tokio::test]
781    async fn cache_from_a_different_account_is_not_reused() {
782        let mut server = mockito::Server::new_async().await;
783        let m = server
784            .mock("POST", "/")
785            .with_status(200)
786            .with_body(usage_json())
787            .create_async()
788            .await;
789
790        let db_dir = TempDir::new().unwrap();
791        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
792        let (_cache_dir, cache) = cache_fixture();
793        cache
794            .write_payload(
795                serde_json::to_vec(&serde_json::json!({
796                    "account": "some-other-account",
797                    "plan": "STALE PLAN",
798                    "used": 10.0,
799                    "limit": 100.0,
800                    "reset_at": null,
801                }))
802                .unwrap()
803                .as_slice(),
804            )
805            .unwrap();
806
807        let client = reqwest::Client::new();
808        let out = fetch_snapshot_at(
809            &client,
810            &db_path,
811            &cache,
812            Duration::from_secs(60),
813            Some(&Endpoints {
814                usage_limits: server.url(),
815                token: format!("{}/token", server.url()),
816            }),
817            Utc::now(),
818        )
819        .await
820        .unwrap();
821
822        // The account-mismatched cache was rejected, so this came from the
823        // live mock (used=40, plan="KIRO POWER"), not the stale cache entry.
824        assert_eq!(out.snapshot.used, 40.0);
825        assert_eq!(out.snapshot.plan, "KIRO POWER");
826        assert!(!out.stale);
827        m.assert_async().await;
828    }
829
830    /// The credit cycle rolled over while cached: serving that cache during
831    /// an outage would show last cycle's usage as if it were current. The
832    /// parse must reject it, surfacing the live error instead. Mirrors
833    /// `cursor::fetch`'s `cache_past_its_billing_reset_is_not_served_during_an_outage`.
834    #[tokio::test]
835    async fn cache_past_its_credit_reset_is_not_served_during_an_outage() {
836        let db_dir = TempDir::new().unwrap();
837        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
838        let (_cache_dir, cache) = cache_fixture();
839        let account = account_key(&db_dir);
840        cache
841            .write_payload(
842                serde_json::to_vec(&serde_json::json!({
843                    "account": account,
844                    "plan": "KIRO POWER",
845                    "used": 10.0,
846                    "limit": 100.0,
847                    "reset_at": "2026-08-04T00:00:00Z",
848                }))
849                .unwrap()
850                .as_slice(),
851            )
852            .unwrap();
853
854        let mut server = mockito::Server::new_async().await;
855        server
856            .mock("POST", "/")
857            .with_status(503)
858            .create_async()
859            .await;
860        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
861            .unwrap()
862            .with_timezone(&Utc);
863        let err = fetch_snapshot_at(
864            &reqwest::Client::new(),
865            &db_path,
866            &cache,
867            Duration::from_secs(0),
868            Some(&Endpoints {
869                usage_limits: server.url(),
870                token: format!("{}/token", server.url()),
871            }),
872            now,
873        )
874        .await
875        .unwrap_err();
876        assert!(matches!(err, AppError::Http { status: 503, .. }));
877    }
878}