Skip to main content

ai_usagebar/antigravity/
cloud.rs

1//! Google Cloud Code client for Antigravity's saved-session fallback.
2//!
3//! When no Antigravity product is running there is no loopback RPC to ask, but
4//! the Google session Antigravity saved in the OS keyring (`credential.rs`)
5//! is still valid. The same Cloud Code endpoints the product itself calls —
6//! `retrieveUserQuotaSummary` for the quota windows and `loadCodeAssist` for
7//! the plan tier — accept that bearer token directly. Access tokens live about
8//! an hour; the refresh token is exchanged at Google's standard token endpoint
9//! and the refreshed access token is persisted in ai-usagebar's own cache,
10//! never written back to the keyring.
11//!
12//! Upstream error bodies are never surfaced: a rejected bearer token or an
13//! `invalid_grant` response can carry account detail, so every non-2xx maps to
14//! a fixed message — same rule as `kiro::oauth::refresh`.
15
16use 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
37/// Refresh this far ahead of the recorded expiry so a slow round-trip never
38/// races the token's death. Mirrors `kiro::oauth::REFRESH_BUFFER_SECS`.
39pub const REFRESH_BUFFER_SECS: i64 = 300;
40
41/// Longest plan label kept from an unrecognised tier name.
42const 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/// Where the fallback talks to. Quota and plan each try every base in order
50/// (the daily channel first, as the product does); tests point them at mockito.
51#[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/// The OAuth client used for refresh: config overrides or the defaults.
75#[derive(Debug, Clone)]
76pub struct OauthClient {
77    pub id: String,
78    pub secret: String,
79}
80
81impl OauthClient {
82    /// `[antigravity] oauth_client_id` + `oauth_client_secret`. Both are
83    /// required for a refresh; nothing is embedded in source, so without them
84    /// the saved access token is used only while it lasts. The pair is
85    /// Antigravity's own public installed-app client (RFC 8252 §8.5: such a
86    /// secret grants nothing on its own — the refresh token in the keyring is
87    /// the credential), but a secret-shaped literal in a tracked file trips
88    /// every secret scanner, so it is the user's line to write.
89    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/// A freshly exchanged access token.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Refreshed {
106    pub access_token: String,
107    pub expires_at: DateTime<Utc>,
108}
109
110/// ai-usagebar's own record of a refreshed access token, scoped to the
111/// keyring session it was minted from (`credential::StoredToken::fingerprint`).
112#[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
137/// Ask each quota base in turn for the user's quota summary. The raw JSON is
138/// returned as-is — either the bare summary or the `{"response": …}` wrapper,
139/// depending on the channel — for the caller to project.
140///
141/// A 401/403 stops immediately: the token is the problem, not the base. Any
142/// other status or a transport failure moves on to the next base; when every
143/// base failed the last error is returned.
144pub 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            // Google answers 403 with `SUBSCRIPTION_REQUIRED` when the
161            // account's plan does not include Antigravity — the session is
162            // fine, the plan is the problem, and the generic "session was
163            // rejected" wording sends the user to re-sign-in for nothing
164            // (#256). Only a body that says otherwise keeps that wording.
165            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
210/// Best-effort plan label from `loadCodeAssist`: the first base that answers
211/// 2xx wins, `paidTier.name` beats `currentTier.name`, and any failure is
212/// simply `None` — the quota figures are the product, the tier is a garnish.
213pub 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
250/// Google's tier ids and display names both reach here (`google_ai_ultra`,
251/// `Google AI Pro`, `free-tier`); the widget shows one short word.
252pub 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
288/// Exchange the keyring's refresh token for a new access token at `token_url`
289/// ([`Endpoints::token`] in production; mockito in tests).
290///
291/// A definitive 4xx (anything but 408/429) means the session is gone —
292/// revoked, or the refresh token rotated by a newer sign-in — and is reported
293/// as a `Credentials` error that tells the user what to do. Everything else is
294/// an `Http` error with a fixed body.
295pub 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
353/// Google returns `expires_in` as a JSON number; some proxies stringify it.
354/// Only a positive integer within a sane range is accepted.
355fn 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
365/// `None` (no recorded expiry) always refreshes; otherwise refresh once the
366/// token is within [`REFRESH_BUFFER_SECS`] of dying.
367pub 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
374/// Where the refreshed access token is persisted, inside the vendor cache.
375pub fn oauth_cache_path(cache: &Cache) -> PathBuf {
376    cache.dir().join(OAUTH_CACHE_FILE)
377}
378
379/// Whether a usable Google session has already been persisted for Antigravity,
380/// without touching the keyring.
381///
382/// `detect` needs to know that the remote fallback would work, but it promises
383/// to be cheap and silent, and reading the keyring is not unconditionally
384/// silent — on macOS `security find-generic-password` can raise a Keychain
385/// prompt, and a background probe that pops a system dialog is worse than the
386/// provider it would have enabled (see #148 for how badly that goes). This
387/// reads only our own cache file, so it cannot prompt, cannot block and cannot
388/// reach the network. It misses the very first run, before any fetch has
389/// persisted a token; that is the deliberate trade.
390pub 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
398/// The persisted token for exactly this keyring session. Absent, unreadable,
399/// malformed, empty, or minted from a different session all read as `None`:
400/// the worst case is one extra refresh round-trip.
401pub 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
410/// Atomically write the persisted token, owner-only on unix (kiro pattern).
411pub 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    /// The detect probe must treat anything it cannot positively read as
427    /// "no session", so a corrupt cache never advertises a provider that
428    /// would then fail its first fetch.
429    #[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    /// An expired token still counts: `detect` is asking "would the remote
461    /// path work", and an expired access token refreshes rather than failing.
462    #[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        // #256: a free-plan account gets 403 SUBSCRIPTION_REQUIRED — the
591        // session is fine, the plan is the problem. The wording must say so
592        // instead of sending the user to re-sign-in for nothing.
593        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}