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