1use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::cache::{Cache, atomic_write};
23use crate::error::{AppError, Result};
24use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
25
26const DAILY_BASE: &str = "https://daily-cloudcode-pa.googleapis.com";
27const PROD_BASE: &str = "https://cloudcode-pa.googleapis.com";
28const QUOTA_PATH: &str = "/v1internal:retrieveUserQuotaSummary";
29const LOAD_CODE_ASSIST_PATH: &str = "/v1internal:loadCodeAssist";
30const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
31
32const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
33const QUOTA_USER_AGENT: &str = "antigravity";
34const LOAD_CODE_ASSIST_USER_AGENT: &str = "agy";
35const OAUTH_CACHE_FILE: &str = "oauth.json";
36
37pub const REFRESH_BUFFER_SECS: i64 = 300;
40
41const MAX_PLAN_CHARS: usize = 32;
43
44const SESSION_REJECTED: &str = "Antigravity's Google session was rejected";
45const REFRESH_FAILED: &str = "Antigravity token refresh failed";
46const SESSION_EXPIRED: &str =
47 "Antigravity's saved Google session expired; open Antigravity to sign in again";
48
49#[derive(Debug, Clone)]
52pub struct Endpoints {
53 pub quota: Vec<String>,
54 pub load_code_assist: Vec<String>,
55 pub token: String,
56}
57
58impl Default for Endpoints {
59 fn default() -> Self {
60 Self {
61 quota: vec![
62 format!("{DAILY_BASE}{QUOTA_PATH}"),
63 format!("{PROD_BASE}{QUOTA_PATH}"),
64 ],
65 load_code_assist: vec![
66 format!("{DAILY_BASE}{LOAD_CODE_ASSIST_PATH}"),
67 format!("{PROD_BASE}{LOAD_CODE_ASSIST_PATH}"),
68 ],
69 token: TOKEN_URL.to_string(),
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct OauthClient {
77 pub id: String,
78 pub secret: String,
79}
80
81impl OauthClient {
82 pub fn from_config(id: Option<&str>, secret: Option<&str>) -> Option<Self> {
90 let pick = |value: Option<&str>| {
91 value
92 .map(str::trim)
93 .filter(|value| !value.is_empty())
94 .map(str::to_string)
95 };
96 Some(Self {
97 id: pick(id)?,
98 secret: pick(secret)?,
99 })
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Refreshed {
106 pub access_token: String,
107 pub expires_at: DateTime<Utc>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
113pub struct PersistedOAuth {
114 pub fingerprint: String,
115 pub access_token: String,
116 pub expires_at: DateTime<Utc>,
117}
118
119async fn post_json(
120 client: &reqwest::Client,
121 url: &str,
122 access_token: &str,
123 user_agent: &str,
124) -> std::result::Result<reqwest::Response, reqwest::Error> {
125 client
126 .post(url)
127 .timeout(HTTP_TIMEOUT)
128 .header("Authorization", format!("Bearer {access_token}"))
129 .header("Content-Type", "application/json")
130 .header("Accept", "application/json")
131 .header("User-Agent", user_agent)
132 .body("{}")
133 .send()
134 .await
135}
136
137pub async fn fetch_quota(
145 client: &reqwest::Client,
146 endpoints: &Endpoints,
147 access_token: &str,
148) -> Result<serde_json::Value> {
149 let mut last_error = AppError::Transport("no Antigravity quota endpoint configured".into());
150 for url in &endpoints.quota {
151 let resp = match post_json(client, url, access_token, QUOTA_USER_AGENT).await {
152 Ok(resp) => resp,
153 Err(e) => {
154 last_error = e.into();
155 continue;
156 }
157 };
158 let status = resp.status();
159 if matches!(status.as_u16(), 401 | 403) {
160 if status.as_u16() == 403 {
166 if let Ok(bytes) = read_body_capped(resp, MAX_BODY_BYTES).await
167 && String::from_utf8_lossy(&bytes).contains("SUBSCRIPTION_REQUIRED")
168 {
169 return Err(AppError::Http {
170 status: 403,
171 body: "this account's plan has no Antigravity quota — the Cloud Code \
172 API requires a paid subscription, so there is nothing to \
173 report (disable [antigravity] if this is expected)"
174 .into(),
175 });
176 }
177 return Err(AppError::Http {
178 status: 403,
179 body: SESSION_REJECTED.into(),
180 });
181 }
182 return Err(AppError::Http {
183 status: status.as_u16(),
184 body: SESSION_REJECTED.into(),
185 });
186 }
187 if !status.is_success() {
188 last_error = AppError::Http {
189 status: status.as_u16(),
190 body: format!(
191 "Antigravity quota endpoint returned HTTP {}",
192 status.as_u16()
193 ),
194 };
195 continue;
196 }
197 match read_body_capped(resp, MAX_BODY_BYTES).await {
198 Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
199 Ok(value) => return Ok(value),
200 Err(e) => {
201 last_error = AppError::Schema(format!("antigravity quota response: {e}"));
202 }
203 },
204 Err(e) => last_error = e,
205 }
206 }
207 Err(last_error)
208}
209
210pub async fn fetch_plan(
214 client: &reqwest::Client,
215 endpoints: &Endpoints,
216 access_token: &str,
217) -> Option<String> {
218 for url in &endpoints.load_code_assist {
219 let Ok(resp) = post_json(client, url, access_token, LOAD_CODE_ASSIST_USER_AGENT).await
220 else {
221 continue;
222 };
223 let status = resp.status();
224 if matches!(status.as_u16(), 401 | 403) {
225 return None;
226 }
227 if !status.is_success() {
228 continue;
229 }
230 let Ok(bytes) = read_body_capped(resp, MAX_BODY_BYTES).await else {
231 continue;
232 };
233 let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
234 continue;
235 };
236 return plan_from_value(&value);
237 }
238 None
239}
240
241fn plan_from_value(value: &serde_json::Value) -> Option<String> {
242 let body = value.get("response").unwrap_or(value);
243 ["paidTier", "currentTier"]
244 .iter()
245 .filter_map(|tier| body.get(tier)?.get("name")?.as_str())
246 .map(format_plan)
247 .find(|plan| !plan.is_empty())
248}
249
250pub fn format_plan(raw: &str) -> String {
253 let words: Vec<String> = raw
254 .split(|c: char| c.is_whitespace() || c == '_' || c == '-')
255 .filter(|word| !word.is_empty())
256 .map(str::to_lowercase)
257 .collect();
258 for (needle, label) in [("ultra", "Ultra"), ("pro", "Pro"), ("free", "Free")] {
259 if words.iter().any(|word| word == needle) {
260 return label.to_string();
261 }
262 }
263 let title: Vec<String> = words
264 .iter()
265 .map(|word| {
266 let mut chars = word.chars();
267 match chars.next() {
268 Some(first) => first.to_uppercase().chain(chars).collect(),
269 None => String::new(),
270 }
271 })
272 .collect();
273 title
274 .join(" ")
275 .chars()
276 .take(MAX_PLAN_CHARS)
277 .collect::<String>()
278 .trim_end()
279 .to_string()
280}
281
282#[derive(Debug, Deserialize)]
283struct TokenResponse {
284 access_token: String,
285 expires_in: Option<serde_json::Value>,
286}
287
288pub async fn refresh(
296 client: &reqwest::Client,
297 token_url: &str,
298 oauth: &OauthClient,
299 refresh_token: &str,
300) -> Result<Refreshed> {
301 let form = [
302 ("grant_type", "refresh_token"),
303 ("refresh_token", refresh_token),
304 ("client_id", oauth.id.as_str()),
305 ("client_secret", oauth.secret.as_str()),
306 ];
307 let resp = client
308 .post(token_url)
309 .timeout(HTTP_TIMEOUT)
310 .header("Accept", "application/json")
311 .form(&form)
312 .send()
313 .await?;
314 let status = resp.status();
315 let body = read_body_capped(resp, MAX_BODY_BYTES).await?;
316 if !status.is_success() {
317 let code = status.as_u16();
318 if (400..500).contains(&code) && !matches!(code, 408 | 429) {
319 return Err(AppError::Credentials(SESSION_EXPIRED.into()));
320 }
321 return Err(AppError::Http {
322 status: code,
323 body: REFRESH_FAILED.into(),
324 });
325 }
326 let parsed: TokenResponse = serde_json::from_slice(&body)
327 .map_err(|e| AppError::Schema(format!("antigravity token refresh response: {e}")))?;
328 if parsed.access_token.trim().is_empty() {
329 return Err(AppError::Schema(
330 "antigravity token refresh response: access_token is empty".into(),
331 ));
332 }
333 let expires_in = parsed
334 .expires_in
335 .as_ref()
336 .and_then(expires_in_secs)
337 .ok_or_else(|| {
338 AppError::Schema("antigravity token refresh response: invalid expires_in".into())
339 })?;
340 let expires_at_secs = Utc::now()
341 .timestamp()
342 .checked_add(expires_in)
343 .ok_or_else(|| AppError::Schema("antigravity token refresh expiry overflowed".into()))?;
344 let expires_at = DateTime::from_timestamp(expires_at_secs, 0).ok_or_else(|| {
345 AppError::Schema("antigravity token refresh expiry is out of range".into())
346 })?;
347 Ok(Refreshed {
348 access_token: parsed.access_token,
349 expires_at,
350 })
351}
352
353fn expires_in_secs(value: &serde_json::Value) -> Option<i64> {
356 const MAX_SECS: i64 = 366 * 24 * 60 * 60;
357 let secs = match value {
358 serde_json::Value::Number(number) => number.as_i64()?,
359 serde_json::Value::String(text) => text.trim().parse::<i64>().ok()?,
360 _ => return None,
361 };
362 (1..=MAX_SECS).contains(&secs).then_some(secs)
363}
364
365pub fn needs_refresh(expires_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
368 match expires_at {
369 None => true,
370 Some(expires_at) => expires_at.timestamp() < now.timestamp() + REFRESH_BUFFER_SECS,
371 }
372}
373
374pub fn oauth_cache_path(cache: &Cache) -> PathBuf {
376 cache.dir().join(OAUTH_CACHE_FILE)
377}
378
379pub fn has_persisted_session(path: &Path) -> bool {
391 let Ok(bytes) = std::fs::read(path) else {
392 return false;
393 };
394 serde_json::from_slice::<PersistedOAuth>(&bytes)
395 .is_ok_and(|saved| !saved.access_token.is_empty() && !saved.fingerprint.is_empty())
396}
397
398pub fn read_persisted(path: &Path, fingerprint: &str) -> Option<PersistedOAuth> {
402 let bytes = std::fs::read(path).ok()?;
403 let persisted: PersistedOAuth = serde_json::from_slice(&bytes).ok()?;
404 if persisted.fingerprint != fingerprint || persisted.access_token.trim().is_empty() {
405 return None;
406 }
407 Some(persisted)
408}
409
410pub fn write_persisted(path: &Path, value: &PersistedOAuth) -> Result<()> {
412 let bytes = serde_json::to_vec_pretty(value)?;
413 atomic_write(path, &bytes)?;
414 #[cfg(unix)]
415 {
416 use std::os::unix::fs::PermissionsExt;
417 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
418 .map_err(|e| AppError::io_at(path, e))?;
419 }
420 Ok(())
421}
422
423#[cfg(test)]
424mod tests {
425
426 #[test]
430 fn has_persisted_session_is_true_only_for_a_readable_token() {
431 let td = tempfile::TempDir::new().unwrap();
432 let path = td.path().join("oauth.json");
433
434 assert!(!has_persisted_session(&path), "missing file");
435
436 std::fs::write(&path, b"").unwrap();
437 assert!(!has_persisted_session(&path), "empty file");
438
439 std::fs::write(&path, b"{ not json").unwrap();
440 assert!(!has_persisted_session(&path), "malformed");
441
442 let no_token = serde_json::json!({
443 "fingerprint": "abc", "access_token": "",
444 "expires_at": "2026-01-01T00:00:00Z"
445 });
446 std::fs::write(&path, no_token.to_string()).unwrap();
447 assert!(!has_persisted_session(&path), "empty access token");
448
449 let good = serde_json::json!({
450 "fingerprint": "abc", "access_token": "ya29.test",
451 "expires_at": "2026-01-01T00:00:00Z"
452 });
453 std::fs::write(&path, good.to_string()).unwrap();
454 assert!(
455 has_persisted_session(&path),
456 "a readable token is a session"
457 );
458 }
459
460 #[test]
463 fn an_expired_persisted_token_still_counts_as_a_session() {
464 let td = tempfile::TempDir::new().unwrap();
465 let path = td.path().join("oauth.json");
466 let stale = serde_json::json!({
467 "fingerprint": "abc", "access_token": "ya29.stale",
468 "expires_at": "2000-01-01T00:00:00Z"
469 });
470 std::fs::write(&path, stale.to_string()).unwrap();
471 assert!(has_persisted_session(&path));
472 }
473 use super::*;
474 use mockito::Matcher;
475 use tempfile::TempDir;
476
477 fn endpoints(server: &mockito::Server) -> Endpoints {
478 let base = server.url();
479 Endpoints {
480 quota: vec![format!("{base}/daily/quota"), format!("{base}/prod/quota")],
481 load_code_assist: vec![format!("{base}/daily/plan"), format!("{base}/prod/plan")],
482 token: format!("{base}/token"),
483 }
484 }
485
486 #[test]
487 fn default_endpoints_try_daily_then_prod() {
488 let e = Endpoints::default();
489 assert_eq!(
490 e.quota,
491 vec![
492 "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary",
493 "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary",
494 ]
495 );
496 assert_eq!(
497 e.load_code_assist,
498 vec![
499 "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
500 "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
501 ]
502 );
503 assert_eq!(e.token, "https://oauth2.googleapis.com/token");
504 }
505
506 fn test_client() -> OauthClient {
507 OauthClient {
508 id: "test-client".into(),
509 secret: "test-client-secret".into(),
510 }
511 }
512
513 #[test]
514 fn oauth_client_needs_both_halves_and_trims_them() {
515 assert!(OauthClient::from_config(None, None).is_none());
516 assert!(OauthClient::from_config(Some("id"), Some("")).is_none());
517 assert!(OauthClient::from_config(Some(" "), Some("s")).is_none());
518 let both = OauthClient::from_config(Some(" my-id "), Some(" my-secret ")).unwrap();
519 assert_eq!(both.id, "my-id");
520 assert_eq!(both.secret, "my-secret");
521 }
522
523 #[tokio::test]
524 async fn quota_falls_through_from_daily_to_prod() {
525 let mut server = mockito::Server::new_async().await;
526 let daily = server
527 .mock("POST", "/daily/quota")
528 .match_header("authorization", "Bearer AT")
529 .match_header("content-type", "application/json")
530 .match_header("accept", "application/json")
531 .match_header("user-agent", "antigravity")
532 .match_body("{}")
533 .with_status(500)
534 .with_body("boom")
535 .expect(1)
536 .create_async()
537 .await;
538 let prod = server
539 .mock("POST", "/prod/quota")
540 .match_header("authorization", "Bearer AT")
541 .match_header("user-agent", "antigravity")
542 .with_status(200)
543 .with_body(r#"{"response":{"buckets":[{"modelId":"gemini","remainingFraction":0.5}]}}"#)
544 .expect(1)
545 .create_async()
546 .await;
547
548 let value = fetch_quota(&reqwest::Client::new(), &endpoints(&server), "AT")
549 .await
550 .unwrap();
551 assert_eq!(value["response"]["buckets"][0]["remainingFraction"], 0.5);
552 daily.assert_async().await;
553 prod.assert_async().await;
554 }
555
556 #[tokio::test]
557 async fn quota_401_stops_without_trying_the_next_base() {
558 let mut server = mockito::Server::new_async().await;
559 let daily = server
560 .mock("POST", "/daily/quota")
561 .with_status(401)
562 .with_body(r#"{"error":{"message":"sensitive detail"}}"#)
563 .expect(1)
564 .create_async()
565 .await;
566 let prod = server
567 .mock("POST", "/prod/quota")
568 .with_status(200)
569 .with_body("{}")
570 .expect(0)
571 .create_async()
572 .await;
573
574 let err = fetch_quota(&reqwest::Client::new(), &endpoints(&server), "AT")
575 .await
576 .unwrap_err();
577 match err {
578 AppError::Http { status, body } => {
579 assert_eq!(status, 401);
580 assert_eq!(body, SESSION_REJECTED);
581 }
582 other => panic!("expected Http, got {other:?}"),
583 }
584 daily.assert_async().await;
585 prod.assert_async().await;
586 }
587
588 #[tokio::test]
589 async fn quota_403_subscription_required_names_the_real_problem() {
590 let mut server = mockito::Server::new_async().await;
594 let daily = server
595 .mock("POST", "/daily/quota")
596 .with_status(403)
597 .with_body(
598 r#"{"error":{"code":403,"status":"PERMISSION_DENIED","details":
599 [{"reason":"SUBSCRIPTION_REQUIRED","domain":"cloud.google.com"}]}}"#,
600 )
601 .expect(1)
602 .create_async()
603 .await;
604 let prod = server
605 .mock("POST", "/prod/quota")
606 .with_status(200)
607 .with_body("{}")
608 .expect(0)
609 .create_async()
610 .await;
611
612 let err = fetch_quota(&reqwest::Client::new(), &endpoints(&server), "AT")
613 .await
614 .unwrap_err();
615 match err {
616 AppError::Http { status, body } => {
617 assert_eq!(status, 403);
618 assert!(body.contains("paid subscription"), "{body}");
619 assert!(!body.contains("SUBSCRIPTION_REQUIRED"), "{body}");
620 }
621 other => panic!("expected Http, got {other:?}"),
622 }
623 daily.assert_async().await;
624 prod.assert_async().await;
625 }
626
627 #[tokio::test]
628 async fn quota_403_without_subscription_reason_keeps_the_session_wording() {
629 let mut server = mockito::Server::new_async().await;
630 server
631 .mock("POST", "/daily/quota")
632 .with_status(403)
633 .with_body(r#"{"error":{"message":"acl detail"}}"#)
634 .create_async()
635 .await;
636
637 let err = fetch_quota(&reqwest::Client::new(), &endpoints(&server), "AT")
638 .await
639 .unwrap_err();
640 match err {
641 AppError::Http { status, body } => {
642 assert_eq!(status, 403);
643 assert_eq!(body, SESSION_REJECTED);
644 }
645 other => panic!("expected Http, got {other:?}"),
646 }
647 }
648
649 #[tokio::test]
650 async fn quota_reports_the_last_error_when_every_base_fails() {
651 let mut server = mockito::Server::new_async().await;
652 server
653 .mock("POST", "/daily/quota")
654 .with_status(500)
655 .create_async()
656 .await;
657 server
658 .mock("POST", "/prod/quota")
659 .with_status(503)
660 .with_body("private upstream text")
661 .create_async()
662 .await;
663
664 let err = fetch_quota(&reqwest::Client::new(), &endpoints(&server), "AT")
665 .await
666 .unwrap_err();
667 match err {
668 AppError::Http { status, body } => {
669 assert_eq!(status, 503);
670 assert!(!body.contains("private upstream text"));
671 }
672 other => panic!("expected Http, got {other:?}"),
673 }
674 }
675
676 #[tokio::test]
677 async fn plan_prefers_paid_tier_and_sends_the_agy_user_agent() {
678 let mut server = mockito::Server::new_async().await;
679 let m = server
680 .mock("POST", "/daily/plan")
681 .match_header("authorization", "Bearer AT")
682 .match_header("user-agent", "agy")
683 .match_body("{}")
684 .with_status(200)
685 .with_body(
686 r#"{"currentTier":{"id":"free-tier","name":"Free"},"paidTier":{"id":"google_ai_pro","name":"Google AI Pro"}}"#,
687 )
688 .create_async()
689 .await;
690
691 let plan = fetch_plan(&reqwest::Client::new(), &endpoints(&server), "AT").await;
692 assert_eq!(plan.as_deref(), Some("Pro"));
693 m.assert_async().await;
694 }
695
696 #[tokio::test]
697 async fn plan_falls_back_to_current_tier_and_to_the_next_base() {
698 let mut server = mockito::Server::new_async().await;
699 server
700 .mock("POST", "/daily/plan")
701 .with_status(500)
702 .create_async()
703 .await;
704 server
705 .mock("POST", "/prod/plan")
706 .with_status(200)
707 .with_body(r#"{"response":{"currentTier":{"name":"GOOGLE_AI_ULTRA"}}}"#)
708 .create_async()
709 .await;
710
711 let plan = fetch_plan(&reqwest::Client::new(), &endpoints(&server), "AT").await;
712 assert_eq!(plan.as_deref(), Some("Ultra"));
713 }
714
715 #[tokio::test]
716 async fn plan_is_none_when_nothing_answers_usefully() {
717 let mut server = mockito::Server::new_async().await;
718 server
719 .mock("POST", "/daily/plan")
720 .with_status(200)
721 .with_body("not json")
722 .create_async()
723 .await;
724 server
725 .mock("POST", "/prod/plan")
726 .with_status(200)
727 .with_body(r#"{"currentTier":{"id":"x"}}"#)
728 .create_async()
729 .await;
730 assert_eq!(
731 fetch_plan(&reqwest::Client::new(), &endpoints(&server), "AT").await,
732 None
733 );
734 }
735
736 #[test]
737 fn format_plan_normalises_known_tiers() {
738 assert_eq!(format_plan("Google AI Pro"), "Pro");
739 assert_eq!(format_plan("google_ai_ultra"), "Ultra");
740 assert_eq!(format_plan("GOOGLE_AI_ULTRA"), "Ultra");
741 assert_eq!(format_plan("free-tier"), "Free");
742 assert_eq!(format_plan("Free"), "Free");
743 assert_eq!(format_plan(" legacy_team plan "), "Legacy Team Plan");
744 assert_eq!(format_plan(""), "");
745 let long = format_plan(&"word ".repeat(20));
746 assert!(long.chars().count() <= MAX_PLAN_CHARS);
747 assert!(!long.ends_with(' '));
748 }
749
750 #[tokio::test]
751 async fn refresh_sends_the_form_body_and_parses_the_token() {
752 let mut server = mockito::Server::new_async().await;
753 let m = server
754 .mock("POST", "/token")
755 .match_header("content-type", "application/x-www-form-urlencoded")
756 .match_body(Matcher::AllOf(vec![
757 Matcher::UrlEncoded("grant_type".into(), "refresh_token".into()),
758 Matcher::UrlEncoded("refresh_token".into(), "old-rt".into()),
759 Matcher::UrlEncoded("client_id".into(), "cid".into()),
760 Matcher::UrlEncoded("client_secret".into(), "csecret".into()),
761 ]))
762 .with_status(200)
763 .with_body(r#"{"access_token":"new-at","expires_in":3599,"token_type":"Bearer"}"#)
764 .create_async()
765 .await;
766 let oauth = OauthClient {
767 id: "cid".into(),
768 secret: "csecret".into(),
769 };
770 let before = Utc::now();
771 let refreshed = refresh(
772 &reqwest::Client::new(),
773 &format!("{}/token", server.url()),
774 &oauth,
775 "old-rt",
776 )
777 .await
778 .unwrap();
779 assert_eq!(refreshed.access_token, "new-at");
780 let delta = refreshed.expires_at.timestamp() - before.timestamp();
781 assert!((3590..=3610).contains(&delta), "{delta}");
782 m.assert_async().await;
783 }
784
785 #[tokio::test]
786 async fn refresh_400_is_a_credentials_error_that_does_not_echo_the_body() {
787 let mut server = mockito::Server::new_async().await;
788 server
789 .mock("POST", "/token")
790 .with_status(400)
791 .with_body(r#"{"error":"invalid_grant","error_description":"sensitive detail"}"#)
792 .create_async()
793 .await;
794 let err = refresh(
795 &reqwest::Client::new(),
796 &format!("{}/token", server.url()),
797 &test_client(),
798 "old-rt",
799 )
800 .await
801 .unwrap_err();
802 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
803 let text = err.to_string();
804 assert!(!text.contains("sensitive detail"));
805 assert!(!text.contains("invalid_grant"));
806 assert!(text.contains("sign in again"));
807 }
808
809 #[tokio::test]
810 async fn refresh_5xx_and_429_are_http_errors_with_a_fixed_body() {
811 for status in [429u16, 503] {
812 let mut server = mockito::Server::new_async().await;
813 server
814 .mock("POST", "/token")
815 .with_status(status.into())
816 .with_body("private upstream text")
817 .create_async()
818 .await;
819 let err = refresh(
820 &reqwest::Client::new(),
821 &format!("{}/token", server.url()),
822 &test_client(),
823 "old-rt",
824 )
825 .await
826 .unwrap_err();
827 match err {
828 AppError::Http { status: got, body } => {
829 assert_eq!(got, status);
830 assert_eq!(body, REFRESH_FAILED);
831 }
832 other => panic!("expected Http, got {other:?}"),
833 }
834 }
835 }
836
837 #[tokio::test]
838 async fn refresh_rejects_malformed_success_bodies() {
839 for body in [
840 r#"{"access_token":"","expires_in":3600}"#,
841 r#"{"access_token":"new","expires_in":0}"#,
842 r#"{"access_token":"new","expires_in":"soon"}"#,
843 r#"{"access_token":"new"}"#,
844 "not json",
845 ] {
846 let mut server = mockito::Server::new_async().await;
847 server
848 .mock("POST", "/token")
849 .with_status(200)
850 .with_body(body)
851 .create_async()
852 .await;
853 let err = refresh(
854 &reqwest::Client::new(),
855 &format!("{}/token", server.url()),
856 &test_client(),
857 "old-rt",
858 )
859 .await
860 .unwrap_err();
861 assert!(matches!(err, AppError::Schema(_)), "{body}: {err:?}");
862 }
863 }
864
865 #[test]
866 fn needs_refresh_threshold() {
867 let now = DateTime::parse_from_rfc3339("2026-08-03T12:00:00Z")
868 .unwrap()
869 .with_timezone(&Utc);
870 assert!(needs_refresh(None, now));
871 assert!(needs_refresh(
872 Some(now + chrono::Duration::seconds(REFRESH_BUFFER_SECS - 1)),
873 now
874 ));
875 assert!(needs_refresh(Some(now - chrono::Duration::hours(1)), now));
876 assert!(!needs_refresh(
877 Some(now + chrono::Duration::seconds(REFRESH_BUFFER_SECS + 60)),
878 now
879 ));
880 }
881
882 #[test]
883 fn persisted_token_round_trips_and_is_scoped_to_its_fingerprint() {
884 let td = TempDir::new().unwrap();
885 let cache = Cache::at(td.path().join("antigravity"));
886 cache.ensure_dir().unwrap();
887 let path = oauth_cache_path(&cache);
888 assert_eq!(read_persisted(&path, "abcd"), None);
889
890 let value = PersistedOAuth {
891 fingerprint: "abcd".into(),
892 access_token: "AT".into(),
893 expires_at: DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
894 .unwrap()
895 .with_timezone(&Utc),
896 };
897 write_persisted(&path, &value).unwrap();
898
899 assert_eq!(read_persisted(&path, "abcd"), Some(value.clone()));
900 assert_eq!(read_persisted(&path, "other"), None);
901
902 #[cfg(unix)]
903 {
904 use std::os::unix::fs::PermissionsExt;
905 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
906 assert_eq!(mode & 0o077, 0);
907 }
908 }
909
910 #[test]
911 fn malformed_or_empty_persisted_token_reads_as_none() {
912 let td = TempDir::new().unwrap();
913 let path = td.path().join("oauth.json");
914 std::fs::write(&path, b"{not json").unwrap();
915 assert_eq!(read_persisted(&path, "abcd"), None);
916 std::fs::write(
917 &path,
918 br#"{"fingerprint":"abcd","access_token":" ","expires_at":"2030-01-01T00:00:00Z"}"#,
919 )
920 .unwrap();
921 assert_eq!(read_persisted(&path, "abcd"), None);
922 }
923}