1use 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 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
60pub type FetchOutcome = crate::outcome::Outcome<KiroSnapshot>;
63
64pub 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
76async 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 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
154fn 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 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 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 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 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 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 #[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 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 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 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 #[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}