Skip to main content

omni_dev/gmail/
auth.rs

1//! Gmail OAuth2 authentication: authorization-code + PKCE login, credential
2//! storage, and in-memory access-token refresh.
3//!
4//! See [ADR-0063](../../../docs/adrs/adr-0063.md) for the design rationale.
5//! The loopback-listener + browser-launch shape follows the Snowflake
6//! client's external-browser SSO flow (`crate::snowflake::client`'s private
7//! `auth` module), extended with PKCE (RFC 7636), a `state` nonce, and an
8//! `error=` branch — none of which a static-token or SSO-only flow needs.
9
10use std::net::{IpAddr, Ipv4Addr, SocketAddr};
11use std::path::Path;
12use std::process::{Command, Stdio};
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use base64::Engine as _;
17use chrono::{DateTime, TimeDelta, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use tokio::io::{AsyncReadExt, AsyncWriteExt};
21use tokio::net::TcpListener;
22use url::Url;
23
24use crate::gmail::account::{self, ResolvedAccount};
25use crate::gmail::chrome_profile;
26use crate::gmail::error::{GmailError, GrantContext};
27use crate::request_log;
28use crate::utils::browser_command::split_browser_command;
29use crate::utils::env::SystemEnv;
30use crate::utils::secret::Secret;
31use crate::utils::settings::{active_profile_from, GmailAccountSettings, GmailSettings, Settings};
32
33/// Environment variable / settings key for the user's Google Cloud OAuth2
34/// client id.
35pub const GMAIL_CLIENT_ID: &str = "GMAIL_CLIENT_ID";
36/// Environment variable / settings key for the user's Google Cloud OAuth2
37/// client secret.
38pub const GMAIL_CLIENT_SECRET: &str = "GMAIL_CLIENT_SECRET";
39/// Environment variable / settings key for the stored OAuth2 refresh token.
40pub const GMAIL_REFRESH_TOKEN: &str = "GMAIL_REFRESH_TOKEN";
41/// Environment variable / settings key recording the scope granted at login.
42pub const GMAIL_SCOPE: &str = "GMAIL_SCOPE";
43/// Environment variable overriding the real Gmail API host.
44///
45/// Process-env only — never written to `settings.json` by `auth login`,
46/// unlike the four keys above (`crate::gmail::client::GmailClient`'s
47/// default base URL). Useful for:
48/// - Tests that point at a wiremock server (e.g. `http://127.0.0.1:PORT`).
49/// - Environments where outbound traffic must go through a forced proxy.
50///
51/// Mirrors `DATADOG_API_URL` (`crate::datadog::auth`); unlike Datadog, Gmail
52/// has no per-tenant site/region the override is *deriving from* — it's a
53/// flat replacement of the one real host, not a site substitution.
54pub const GMAIL_API_URL: &str = "GMAIL_API_URL";
55
56/// Google's OAuth2 authorization endpoint.
57const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
58/// Google's OAuth2 token endpoint.
59const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
60/// Read-only Gmail scope — the default.
61pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/gmail.readonly";
62/// Read-write Gmail scope — required for label add/remove; opt-in.
63pub const SCOPE_MODIFY: &str = "https://www.googleapis.com/auth/gmail.modify";
64
65/// How long to wait for the browser sign-in callback before giving up.
66const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
67/// How much slack to leave before an access token's tracked expiry before
68/// proactively refreshing it.
69const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
70/// Upper bound on a trusted `expires_in` from the token endpoint. Comfortably
71/// inside what `TimeDelta::seconds` and `DateTime<Utc>` addition can
72/// represent without panicking, and far beyond any real OAuth token
73/// lifetime — an out-of-range value is clamped rather than trusted, so a
74/// misbehaving or malicious token endpoint can't crash the process (#1531).
75const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
76
77/// The Gmail OAuth2 scope granted at login.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum GmailScope {
80    /// List/read messages, threads, labels, and history. Default; never
81    /// requests send.
82    #[default]
83    ReadOnly,
84    /// Everything [`ReadOnly`](Self::ReadOnly) grants, plus label add/remove
85    /// (`batchModify`).
86    Modify,
87}
88
89impl GmailScope {
90    /// Returns the wire scope string Google expects.
91    #[must_use]
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Self::ReadOnly => SCOPE_READONLY,
95            Self::Modify => SCOPE_MODIFY,
96        }
97    }
98
99    /// Parses Google's space-separated granted-scope response, treating the
100    /// presence of the modify scope anywhere in it as [`Modify`](Self::Modify)
101    /// and the readonly scope (with no modify) as [`ReadOnly`](Self::ReadOnly).
102    ///
103    /// Returns `None` when neither Gmail scope is present — e.g. the user
104    /// left the Gmail permission unticked on Google's consent screen — so
105    /// callers can reject the grant instead of silently defaulting to
106    /// [`ReadOnly`](Self::ReadOnly).
107    #[must_use]
108    pub fn from_granted(granted: &str) -> Option<Self> {
109        let tokens: Vec<&str> = granted.split_whitespace().collect();
110        if tokens.contains(&SCOPE_MODIFY) {
111            Some(Self::Modify)
112        } else if tokens.contains(&SCOPE_READONLY) {
113            Some(Self::ReadOnly)
114        } else {
115            None
116        }
117    }
118
119    /// Whether this scope allows label-mutating calls (`batchModify`).
120    #[must_use]
121    pub fn allows_modify(self) -> bool {
122        matches!(self, Self::Modify)
123    }
124}
125
126/// Gmail OAuth2 credentials.
127#[derive(Debug, Clone)]
128pub struct GmailCredentials {
129    /// OAuth2 client id (not secret — visible in the browser's own network
130    /// traffic during login regardless).
131    pub client_id: String,
132    /// OAuth2 client secret (redacted in `Debug` output).
133    pub client_secret: Secret,
134    /// The stored refresh token (redacted in `Debug` output).
135    pub refresh_token: Secret,
136    /// The scope granted at the login that produced this refresh token.
137    pub scope: GmailScope,
138}
139
140/// Secret-free presence/scope report, safe to serialise (e.g. over MCP).
141#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
142pub struct GmailAuthStatus {
143    /// Whether [`GMAIL_CLIENT_ID`] is present.
144    pub has_client_id: bool,
145    /// Whether [`GMAIL_CLIENT_SECRET`] is present.
146    pub has_client_secret: bool,
147    /// Whether [`GMAIL_REFRESH_TOKEN`] is present.
148    pub has_refresh_token: bool,
149    /// The granted scope, if recorded. `None` when unset.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub scope: Option<String>,
152}
153
154/// Resolves the active Gmail account for this call (issue #1500), folding an
155/// explicit per-call override together with the ambient
156/// `--account`/[`account::GMAIL_ACCOUNT_ENV`] value. The one seam every
157/// credential CRUD entry point in this module and [`crate::gmail::import`]
158/// routes through.
159pub(crate) fn resolve(gmail: &GmailSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
160    let explicit = fold_explicit(explicit);
161    account::resolve_account(&SystemEnv, gmail, explicit.as_deref())
162}
163
164/// Like [`resolve`], but for account-creating writes (`gmail auth login`,
165/// `gmail auth import`) — see
166/// [`account::resolve_account_for_write`] for why an explicit target need
167/// not already exist.
168pub(crate) fn resolve_for_write(
169    gmail: &GmailSettings,
170    explicit: Option<&str>,
171) -> Result<ResolvedAccount> {
172    let explicit = fold_explicit(explicit);
173    account::resolve_account_for_write(&SystemEnv, gmail, explicit.as_deref())
174}
175
176/// Folds an explicit per-call account override together with the ambient
177/// `--account`/[`account::GMAIL_ACCOUNT_ENV`] value — shared by [`resolve`]
178/// and [`resolve_for_write`].
179fn fold_explicit(explicit: Option<&str>) -> Option<String> {
180    explicit
181        .map(str::to_string)
182        .or_else(|| account::active_gmail_account_from(&SystemEnv))
183}
184
185/// Resolves the [`BrowserConfig`] `gmail auth login` should open the
186/// authorization URL with, honoring a named account's manual
187/// `browser_command` override and opt-in automatic Chrome-profile
188/// resolution (issue #1505). `explicit` is folded exactly like
189/// [`resolve_for_write`]'s. A [`ResolvedAccount::Legacy`] account (no named
190/// accounts configured, or a literal credential env set) always yields
191/// today's [`BrowserLaunch::Auto`] — zero migration.
192pub(crate) fn resolve_browser_config_for(
193    gmail: &GmailSettings,
194    explicit: Option<&str>,
195) -> Result<BrowserConfig> {
196    match resolve_for_write(gmail, explicit)? {
197        ResolvedAccount::Legacy => Ok(BrowserConfig::default()),
198        ResolvedAccount::Named(name) => build_browser_config(
199            gmail.accounts.get(&name),
200            chrome_profile::resolve_launch_command,
201        ),
202    }
203}
204
205/// The pure/injectable core of [`resolve_browser_config_for`] —
206/// `resolve_chrome_profile` is [`chrome_profile::resolve_launch_command`] in
207/// production, a stub in tests, so this stays testable without touching a
208/// real Chrome install.
209///
210/// Precedence:
211/// 1. `account.browser_command` set (non-blank) → used verbatim; a
212///    malformed command is a hard error.
213/// 2. `account.chrome_profile_from_email` set *and* `account.email_address`
214///    set → automatic resolution; any resolution failure (per
215///    `resolve_chrome_profile`'s fail-open contract) falls back to `Auto`.
216/// 3. Otherwise → `Auto`.
217fn build_browser_config(
218    account: Option<&GmailAccountSettings>,
219    resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
220) -> Result<BrowserConfig> {
221    let Some(account) = account else {
222        return Ok(BrowserConfig::default());
223    };
224
225    if let Some(command) = account
226        .browser_command
227        .as_deref()
228        .map(str::trim)
229        .filter(|command| !command.is_empty())
230    {
231        return Ok(BrowserConfig {
232            launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
233            ..BrowserConfig::default()
234        });
235    }
236
237    if account.chrome_profile_from_email {
238        if let Some(email) = account.email_address.as_deref() {
239            if let Some(args) = resolve_chrome_profile(email) {
240                return Ok(BrowserConfig {
241                    launch: BrowserLaunch::Command(args),
242                    ..BrowserConfig::default()
243                });
244            }
245        } else {
246            tracing::info!(
247                "chrome_profile_from_email is set but email_address is not; \
248                 falling back to the default browser"
249            );
250        }
251    }
252
253    Ok(BrowserConfig::default())
254}
255
256/// Loads Gmail credentials from environment variables or settings.json.
257///
258/// Environment variables take precedence over the settings file.
259pub fn load_credentials() -> Result<GmailCredentials> {
260    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
261}
262
263/// [`load_credentials`], but honoring the named-account resolution added by
264/// issue #1500. `explicit` is the already-resolved `--account`/
265/// [`account::GMAIL_ACCOUNT_ENV`] override, if any (`None` still resolves
266/// the ambient env var — see [`resolve`]). Falls through to
267/// [`load_credentials_with`]'s exact legacy behavior when no named account
268/// applies — the zero-migration guarantee.
269pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<GmailCredentials> {
270    let settings = Settings::load().unwrap_or_default();
271    match resolve(&settings.gmail, explicit)? {
272        ResolvedAccount::Legacy => {
273            let profile = active_profile_from(&SystemEnv);
274            load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
275                settings,
276                profile.as_deref(),
277            ))
278        }
279        ResolvedAccount::Named(name) => load_named_credentials(&settings.gmail, &name),
280    }
281}
282
283/// Reads `gmail.accounts.<name>` into [`GmailCredentials`], wrapping
284/// `client_secret`/`refresh_token` into [`Secret`] immediately, mirroring
285/// [`load_credentials_with`].
286fn load_named_credentials(gmail: &GmailSettings, name: &str) -> Result<GmailCredentials> {
287    let account = gmail
288        .accounts
289        .get(name)
290        .ok_or(GmailError::CredentialsNotFound)?;
291    let client_id = account
292        .client_id
293        .clone()
294        .ok_or(GmailError::CredentialsNotFound)?;
295    let client_secret = account
296        .client_secret
297        .clone()
298        .ok_or(GmailError::CredentialsNotFound)?;
299    let refresh_token = account
300        .refresh_token
301        .clone()
302        .ok_or(GmailError::CredentialsNotFound)?;
303    let scope = account
304        .scope
305        .as_deref()
306        .and_then(GmailScope::from_granted)
307        .unwrap_or_default();
308
309    Ok(GmailCredentials {
310        client_id,
311        client_secret: client_secret.into(),
312        refresh_token: refresh_token.into(),
313        scope,
314    })
315}
316
317/// Forces the legacy (pre-migration) credential path regardless of any
318/// active named account. Used only by `gmail account import-legacy`, which
319/// must read the *true* legacy env-resolved credentials to migrate, not
320/// whatever named account happens to be ambient.
321pub(crate) fn load_credentials_legacy() -> Result<GmailCredentials> {
322    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
323}
324
325/// [`load_credentials`] over an injected
326/// [`EnvSource`](crate::utils::env::EnvSource).
327///
328/// Tests pass a pure `MapEnv` so credential resolution is exercised without
329/// mutating the process environment (issue #1030 / STYLE-0028).
330pub(crate) fn load_credentials_with(
331    env: &impl crate::utils::env::EnvSource,
332) -> Result<GmailCredentials> {
333    let client_id = env
334        .var(GMAIL_CLIENT_ID)
335        .ok_or(GmailError::CredentialsNotFound)?;
336    let client_secret = env
337        .var(GMAIL_CLIENT_SECRET)
338        .ok_or(GmailError::CredentialsNotFound)?;
339    let refresh_token = env
340        .var(GMAIL_REFRESH_TOKEN)
341        .ok_or(GmailError::CredentialsNotFound)?;
342    // Unlike login (which rejects an unparseable grant outright), a stored
343    // scope that no longer parses degrades to ReadOnly rather than erroring:
344    // it was already validated when written, and failing closed here is
345    // safe (a stale Modify silently becoming ReadOnly still fails at the
346    // API, not open).
347    let scope = env
348        .var(GMAIL_SCOPE)
349        .and_then(|s| GmailScope::from_granted(&s))
350        .unwrap_or_default();
351
352    Ok(GmailCredentials {
353        client_id,
354        client_secret: client_secret.into(),
355        refresh_token: refresh_token.into(),
356        scope,
357    })
358}
359
360/// Builds a [`GmailAuthStatus`] from the current settings / environment.
361///
362/// Reports credential presence without leaking any secret values. Safe to
363/// call with no credentials configured.
364pub fn status() -> GmailAuthStatus {
365    status_with(&crate::utils::settings::SettingsEnv::load())
366}
367
368/// [`status`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
369pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> GmailAuthStatus {
370    GmailAuthStatus {
371        has_client_id: env.var(GMAIL_CLIENT_ID).is_some(),
372        has_client_secret: env.var(GMAIL_CLIENT_SECRET).is_some(),
373        has_refresh_token: env.var(GMAIL_REFRESH_TOKEN).is_some(),
374        scope: env.var(GMAIL_SCOPE),
375    }
376}
377
378/// [`status`], but honoring the named-account resolution added by issue
379/// #1500. `explicit` is the already-resolved `--account`/
380/// [`account::GMAIL_ACCOUNT_ENV`] override, if any. Unlike [`status`], this
381/// can fail — once named accounts exist, resolution itself can (e.g. an
382/// unknown or ambiguous account) — so callers that want [`status`]'s
383/// never-fails presence report keep calling that instead.
384///
385/// Only compiled with the `mcp` feature — the MCP `gmail_auth_status` tool
386/// is its sole consumer; the CLI's `gmail auth status` goes through
387/// [`load_credentials_for`] instead.
388#[cfg(feature = "mcp")]
389pub(crate) fn status_for(explicit: Option<&str>) -> Result<GmailAuthStatus> {
390    let settings = Settings::load().unwrap_or_default();
391    match resolve(&settings.gmail, explicit)? {
392        ResolvedAccount::Legacy => {
393            let profile = active_profile_from(&SystemEnv);
394            Ok(status_with(
395                &crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
396            ))
397        }
398        ResolvedAccount::Named(name) => Ok(status_from_named(&settings.gmail, &name)),
399    }
400}
401
402/// Builds a [`GmailAuthStatus`] from `gmail.accounts.<name>`'s presence
403/// flags — the named-account counterpart of [`status_with`].
404///
405/// Only compiled with the `mcp` feature — see [`status_for`], its sole
406/// caller.
407#[cfg(feature = "mcp")]
408fn status_from_named(gmail: &GmailSettings, name: &str) -> GmailAuthStatus {
409    let account = gmail.accounts.get(name);
410    GmailAuthStatus {
411        has_client_id: account.is_some_and(|a| a.client_id.is_some()),
412        has_client_secret: account.is_some_and(|a| a.client_secret.is_some()),
413        has_refresh_token: account.is_some_and(|a| a.refresh_token.is_some()),
414        scope: account.and_then(|a| a.scope.clone()),
415    }
416}
417
418/// Opportunistic `email_address` backfill for `name`, populated by `gmail
419/// auth status --all` after a successful live `users.getProfile` call.
420/// Never used for authentication, never written by `login`/`import`. A
421/// no-op when `name` already has an `email_address` — an explicit or
422/// previously-backfilled value is never overwritten (issue #1505).
423pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
424    let settings = Settings::load().unwrap_or_default();
425    if settings
426        .gmail
427        .accounts
428        .get(name)
429        .is_some_and(|account| account.email_address.is_some())
430    {
431        return Ok(());
432    }
433    Settings::upsert_gmail_account(
434        &Settings::get_settings_path()?,
435        name,
436        &[(
437            "email_address",
438            serde_json::Value::String(email.to_string()),
439        )],
440    )
441}
442
443/// Saves Gmail credentials to `~/.omni-dev/settings.json`.
444///
445/// Merges the four credential keys into the active profile's `env` map (the
446/// base `env` when no profile is active), preserving all other settings.
447pub fn save_credentials(credentials: &GmailCredentials) -> Result<()> {
448    save_credentials_to(
449        &Settings::get_settings_path()?,
450        active_profile_from(&SystemEnv).as_deref(),
451        credentials,
452    )
453}
454
455/// [`save_credentials`], writing to an explicit settings-file path and env
456/// map (`profiles.<name>.env` when `profile` is `Some`, base `env` otherwise).
457pub(crate) fn save_credentials_to(
458    settings_path: &Path,
459    profile: Option<&str>,
460    credentials: &GmailCredentials,
461) -> Result<()> {
462    Settings::upsert_env_vars_in(
463        settings_path,
464        profile,
465        &[
466            (GMAIL_CLIENT_ID, credentials.client_id.as_str()),
467            (
468                GMAIL_CLIENT_SECRET,
469                credentials.client_secret.expose_secret(),
470            ),
471            (
472                GMAIL_REFRESH_TOKEN,
473                credentials.refresh_token.expose_secret(),
474            ),
475            (GMAIL_SCOPE, credentials.scope.as_str()),
476        ],
477    )
478}
479
480/// The `gmail.accounts.<name>` field names/values for `credentials` — the
481/// named-account counterpart of the flat `GMAIL_*` env keys
482/// [`save_credentials_to`] writes.
483fn named_account_vars(credentials: &GmailCredentials) -> [(&str, serde_json::Value); 4] {
484    [
485        (
486            "client_id",
487            serde_json::Value::String(credentials.client_id.clone()),
488        ),
489        (
490            "client_secret",
491            serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
492        ),
493        (
494            "refresh_token",
495            serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
496        ),
497        (
498            "scope",
499            serde_json::Value::String(credentials.scope.as_str().to_string()),
500        ),
501    ]
502}
503
504/// Removes Gmail credential keys from `~/.omni-dev/settings.json` — this
505/// *is* `gmail auth logout`.
506///
507/// Returns `true` if any Gmail key was present and removed, `false`
508/// otherwise.
509pub fn remove_credentials() -> Result<bool> {
510    remove_credentials_at(
511        &Settings::get_settings_path()?,
512        active_profile_from(&SystemEnv).as_deref(),
513    )
514}
515
516/// [`remove_credentials`], operating on an explicit settings-file path and
517/// env map.
518pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
519    Settings::remove_env_vars_in(
520        settings_path,
521        profile,
522        &[
523            GMAIL_CLIENT_ID,
524            GMAIL_CLIENT_SECRET,
525            GMAIL_REFRESH_TOKEN,
526            GMAIL_SCOPE,
527        ],
528    )
529}
530
531/// [`remove_credentials`], but honoring the named-account resolution added
532/// by issue #1500. `explicit` is the already-resolved `--account`/
533/// [`account::GMAIL_ACCOUNT_ENV`] override, if any. Removes the whole
534/// `gmail.accounts.<name>` entry — an account is coherent as a unit.
535pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
536    let settings = Settings::load().unwrap_or_default();
537    match resolve(&settings.gmail, explicit)? {
538        ResolvedAccount::Legacy => remove_credentials_at(
539            &Settings::get_settings_path()?,
540            active_profile_from(&SystemEnv).as_deref(),
541        ),
542        ResolvedAccount::Named(name) => {
543            Settings::remove_gmail_account(&Settings::get_settings_path()?, &name)
544        }
545    }
546}
547
548// ── Browser launch ──────────────────────────────────────────────────────
549
550/// How to open the authorization URL during login.
551///
552/// Deliberately duplicated from (not shared with)
553/// [`crate::snowflake::client::config::BrowserLaunch`] — a small, stable
554/// shape with no existing "generic browser launch" module to promote into;
555/// extract only on a third consumer.
556#[derive(Clone, Debug, Default)]
557pub enum BrowserLaunch {
558    /// Open with the OS default handler (`open` / `xdg-open` / `start`).
559    #[default]
560    Auto,
561    /// Run a custom command; `{url}` (or a trailing arg) receives the
562    /// authorization URL. Use this to target a specific Chrome profile,
563    /// e.g. `Google Chrome --profile-directory=Profile 1 --new-window {url}`.
564    Command(Vec<String>),
565    /// Do not open a browser; the authorization URL is logged for manual
566    /// opening.
567    Manual,
568}
569
570/// Loopback OAuth2 callback settings.
571#[derive(Clone, Debug)]
572pub struct BrowserConfig {
573    /// How to open the authorization URL.
574    pub launch: BrowserLaunch,
575    /// Bind address for the loopback callback listener.
576    pub callback_addr: IpAddr,
577    /// Bind port for the callback listener (`0` = OS-assigned ephemeral port).
578    pub callback_port: u16,
579}
580
581impl Default for BrowserConfig {
582    fn default() -> Self {
583        Self {
584            launch: BrowserLaunch::Auto,
585            callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
586            callback_port: 0,
587        }
588    }
589}
590
591/// Opens `url` in the configured browser.
592// `{url}` is a literal command placeholder we substitute, not a format string.
593#[allow(clippy::literal_string_with_formatting_args)]
594fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
595    match launch {
596        BrowserLaunch::Manual => {
597            tracing::info!("Open this URL in a browser to sign in to Gmail:\n{url}");
598            Ok(())
599        }
600        BrowserLaunch::Command(args) => {
601            let mut parts = args.iter();
602            let program = parts
603                .next()
604                .ok_or_else(|| GmailError::InvalidBrowserCommand("empty browser command".into()))?;
605            let mut command = Command::new(program);
606            let mut placed = false;
607            for arg in parts {
608                if arg.contains("{url}") {
609                    command.arg(arg.replace("{url}", url));
610                    placed = true;
611                } else {
612                    command.arg(arg);
613                }
614            }
615            if !placed {
616                command.arg(url);
617            }
618            spawn_detached(command)
619        }
620        BrowserLaunch::Auto => {
621            let program = if cfg!(target_os = "macos") {
622                "open"
623            } else if cfg!(target_os = "windows") {
624                "explorer"
625            } else {
626                "xdg-open"
627            };
628            let mut command = Command::new(program);
629            command.arg(url);
630            spawn_detached(command)
631        }
632    }
633}
634
635/// Spawns a browser command detached from this process's stdio.
636fn spawn_detached(mut command: Command) -> Result<()> {
637    command
638        .stdin(Stdio::null())
639        .stdout(Stdio::null())
640        .stderr(Stdio::null())
641        .spawn()
642        .map(|_| ())
643        .context("Failed to launch the browser")
644}
645
646// ── PKCE + state ────────────────────────────────────────────────────────
647
648/// A pending login's PKCE verifier and CSRF `state` nonce, generated fresh
649/// per login attempt and never persisted.
650struct PendingLogin {
651    state: String,
652    code_verifier: String,
653}
654
655fn generate_pending_login() -> PendingLogin {
656    PendingLogin {
657        state: crate::browser::auth::generate_token(),
658        code_verifier: crate::browser::auth::generate_token(),
659    }
660}
661
662/// Derives the PKCE `code_challenge` (RFC 7636, `S256` method) from a
663/// `code_verifier`.
664fn code_challenge(code_verifier: &str) -> String {
665    let digest = Sha256::digest(code_verifier.as_bytes());
666    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
667}
668
669fn build_authorization_url(
670    client_id: &str,
671    redirect_uri: &str,
672    scope: GmailScope,
673    state: &str,
674    code_challenge: &str,
675) -> Result<Url> {
676    let mut url =
677        Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Gmail authorization endpoint")?;
678    url.query_pairs_mut()
679        .append_pair("client_id", client_id)
680        .append_pair("redirect_uri", redirect_uri)
681        .append_pair("response_type", "code")
682        .append_pair("scope", scope.as_str())
683        .append_pair("state", state)
684        .append_pair("code_challenge", code_challenge)
685        .append_pair("code_challenge_method", "S256")
686        .append_pair("access_type", "offline")
687        // Without forcing re-consent, Google may not re-issue a refresh
688        // token on a second login — which would silently break re-auth
689        // after the 7-day testing-mode refresh-token expiry.
690        .append_pair("prompt", "consent");
691    Ok(url)
692}
693
694// ── Loopback callback capture ───────────────────────────────────────────
695
696/// The parsed loopback callback: either `code`+`state`, or an `error`
697/// (optionally with `error_description`).
698#[derive(Debug)]
699pub(crate) struct CallbackResult {
700    code: Option<String>,
701    state: Option<String>,
702    error: Option<String>,
703    error_description: Option<String>,
704}
705
706/// Binds the loopback callback listener, returning it along with the
707/// OS-assigned port so the authorization URL's `redirect_uri` can be built
708/// before the browser is opened.
709pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
710    let listener = TcpListener::bind(SocketAddr::new(
711        browser.callback_addr,
712        browser.callback_port,
713    ))
714    .await
715    .context("Failed to start the local OAuth callback listener")?;
716    let port = listener
717        .local_addr()
718        .context("Failed to read the callback listener's port")?
719        .port();
720    Ok((listener, port))
721}
722
723/// Waits for the browser's callback connection using the default
724/// [`CALLBACK_TIMEOUT`].
725pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
726    wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
727}
728
729/// Accepts one loopback connection and extracts the OAuth callback's query
730/// parameters from the redirected `GET` request line.
731///
732/// Never logs the raw request or query string — only that a callback was
733/// received — so the authorization `code` can never reach the request log
734/// via this path (see ADR-0063's redaction discussion).
735pub(crate) async fn wait_for_callback_with_timeout(
736    listener: TcpListener,
737    timeout: Duration,
738) -> Result<CallbackResult> {
739    let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
740        .await
741        .map_err(|_| GmailError::CallbackTimeout(timeout.as_secs()))?
742        .context("Failed to accept the browser's callback connection")?;
743
744    let mut buf = vec![0u8; 8192];
745    let n = stream
746        .read(&mut buf)
747        .await
748        .context("Failed to read the callback request")?;
749    let request = String::from_utf8_lossy(&buf[..n]);
750
751    let result = parse_callback(&request).ok_or(GmailError::MalformedCallback)?;
752    tracing::info!("Gmail OAuth callback received");
753
754    let body = if result.error.is_some() {
755        "<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
756    } else {
757        "<html><body>Gmail sign-in complete. You can close this tab.</body></html>"
758    };
759    let response =
760        format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
761    let _ = stream.write_all(response.as_bytes()).await;
762    let _ = stream.flush().await;
763
764    Ok(result)
765}
766
767/// Extracts `code`/`state`/`error`/`error_description` from an HTTP
768/// request's first line only — headers and body are never inspected.
769fn parse_callback(request: &str) -> Option<CallbackResult> {
770    let first_line = request.lines().next()?;
771    let path = first_line.split_whitespace().nth(1)?; // "/?code=…&state=…"
772    let query = path.split_once('?')?.1;
773
774    let mut result = CallbackResult {
775        code: None,
776        state: None,
777        error: None,
778        error_description: None,
779    };
780    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
781        match key.as_ref() {
782            "code" => result.code = Some(value.into_owned()),
783            "state" => result.state = Some(value.into_owned()),
784            "error" => result.error = Some(value.into_owned()),
785            "error_description" => result.error_description = Some(value.into_owned()),
786            _ => {}
787        }
788    }
789    Some(result)
790}
791
792// ── Token exchange / refresh ────────────────────────────────────────────
793
794#[derive(Debug, Deserialize)]
795struct TokenResponse {
796    access_token: String,
797    #[serde(default)]
798    refresh_token: Option<String>,
799    expires_in: i64,
800    #[serde(default)]
801    scope: Option<String>,
802}
803
804#[derive(Debug, Deserialize)]
805struct TokenErrorResponse {
806    error: String,
807    #[serde(default)]
808    error_description: Option<String>,
809}
810
811async fn exchange_code_for_tokens(
812    http: &reqwest::Client,
813    token_endpoint: &str,
814    client_id: &str,
815    client_secret: &str,
816    code: &str,
817    code_verifier: &str,
818    redirect_uri: &str,
819) -> Result<TokenResponse> {
820    let params = [
821        ("grant_type", "authorization_code"),
822        ("code", code),
823        ("client_id", client_id),
824        ("client_secret", client_secret),
825        ("redirect_uri", redirect_uri),
826        ("code_verifier", code_verifier),
827    ];
828    post_token_request(http, token_endpoint, &params, GrantContext::CodeExchange).await
829}
830
831async fn refresh_access_token(
832    http: &reqwest::Client,
833    token_endpoint: &str,
834    client_id: &str,
835    client_secret: &str,
836    refresh_token: &str,
837) -> Result<TokenResponse> {
838    let params = [
839        ("grant_type", "refresh_token"),
840        ("refresh_token", refresh_token),
841        ("client_id", client_id),
842        ("client_secret", client_secret),
843    ];
844    post_token_request(http, token_endpoint, &params, GrantContext::Refresh).await
845}
846
847/// POSTs a token request. All secrets travel in the `.form(...)` body, never
848/// the URL — `token_endpoint` carries no query string, so the request-log's
849/// URL redaction has nothing to redact and nothing to miss either.
850async fn post_token_request(
851    http: &reqwest::Client,
852    token_endpoint: &str,
853    params: &[(&str, &str)],
854    context: GrantContext,
855) -> Result<TokenResponse> {
856    let started = std::time::Instant::now();
857    let result = http.post(token_endpoint).form(params).send().await;
858    request_log::record_http_result("gmail", "POST", token_endpoint, started, &result);
859    let response = result.context("Failed to send token request to Google")?;
860
861    if !response.status().is_success() {
862        let body = response.text().await.unwrap_or_default();
863        if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
864            if err.error == "invalid_grant" {
865                return Err(GmailError::InvalidGrant(context).into());
866            }
867            return Err(anyhow::anyhow!(
868                "Google token endpoint rejected the request: {} ({})",
869                err.error,
870                err.error_description.unwrap_or_default()
871            ));
872        }
873        return Err(anyhow::anyhow!(
874            "Google token endpoint returned an unparsable error body: {body}"
875        ));
876    }
877
878    response
879        .json::<TokenResponse>()
880        .await
881        .context("Failed to parse Google's token response")
882}
883
884// ── Session (in-memory access-token lifecycle) ──────────────────────────
885
886/// The mutable access-token state, refreshed by [`GmailSession::refresh_locked`].
887struct TokenState {
888    access_token: Secret,
889    expires_at: DateTime<Utc>,
890}
891
892/// A live Gmail OAuth2 session: holds the refresh token and the current
893/// in-memory access token, refreshing on demand.
894///
895/// Uses [`tokio::sync::Mutex`] (not `std::sync::Mutex`) held *across* the
896/// refresh network call — unlike
897/// [`SnowflakeSession::renew`](crate::snowflake::client::SnowflakeSession::renew),
898/// which releases its lock before the network call and accepts concurrent
899/// refreshes racing each other. Gmail's design requires single-flight
900/// refresh (issue #1465's explicit "concurrent callers don't stampede"
901/// requirement): a second concurrent caller blocks on this mutex and, once
902/// unblocked, observes the already-refreshed token instead of issuing a
903/// second POST.
904pub struct GmailSession {
905    http: reqwest::Client,
906    client_id: String,
907    client_secret: Secret,
908    refresh_token: Secret,
909    token_endpoint: String,
910    state: tokio::sync::Mutex<TokenState>,
911}
912
913impl GmailSession {
914    /// Creates a session against Google's real token endpoint.
915    pub(crate) fn new(http: reqwest::Client, credentials: &GmailCredentials) -> Self {
916        Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
917    }
918
919    /// [`new`](Self::new) against an explicit token endpoint — the test seam
920    /// for pointing at a wiremock server.
921    pub(crate) fn new_with_token_endpoint(
922        http: reqwest::Client,
923        credentials: &GmailCredentials,
924        token_endpoint: &str,
925    ) -> Self {
926        Self {
927            http,
928            client_id: credentials.client_id.clone(),
929            client_secret: credentials.client_secret.clone(),
930            refresh_token: credentials.refresh_token.clone(),
931            token_endpoint: token_endpoint.to_string(),
932            state: tokio::sync::Mutex::new(TokenState {
933                access_token: Secret::new(""),
934                // No access token is ever persisted (ADR-0063 Decision 2), so
935                // every fresh session starts expired and refreshes on its
936                // very first call.
937                expires_at: DateTime::<Utc>::MIN_UTC,
938            }),
939        }
940    }
941
942    /// Returns a valid access token, refreshing proactively when the
943    /// tracked expiry is within [`REFRESH_SKEW`].
944    pub(crate) async fn access_token(&self) -> Result<Secret> {
945        let mut state = self.state.lock().await;
946        if Utc::now() + REFRESH_SKEW >= state.expires_at {
947            self.refresh_locked(&mut state).await?;
948        }
949        Ok(state.access_token.clone())
950    }
951
952    /// Forces a refresh, but only if `observed` is still the current token —
953    /// i.e. no other caller already refreshed while this caller was waiting
954    /// on the lock. Used as the reactive safety net after an HTTP 401 (clock
955    /// skew, or server-side revocation the proactive check can't see).
956    pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
957        let mut state = self.state.lock().await;
958        if state.access_token != *observed {
959            return Ok(state.access_token.clone());
960        }
961        self.refresh_locked(&mut state).await?;
962        Ok(state.access_token.clone())
963    }
964
965    async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
966        let response = refresh_access_token(
967            &self.http,
968            &self.token_endpoint,
969            &self.client_id,
970            self.client_secret.expose_secret(),
971            self.refresh_token.expose_secret(),
972        )
973        .await?;
974        state.access_token = response.access_token.into();
975        let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
976        state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
977        Ok(())
978    }
979}
980
981// ── Login orchestration ─────────────────────────────────────────────────
982
983/// Runs the OAuth2 authorization-code + PKCE login flow, persisting the
984/// resulting refresh token to `~/.omni-dev/settings.json`.
985pub async fn login(
986    client_id: &str,
987    client_secret: &Secret,
988    scope: GmailScope,
989    browser: &BrowserConfig,
990) -> Result<GmailAuthStatus> {
991    login_to(
992        &Settings::get_settings_path()?,
993        active_profile_from(&SystemEnv).as_deref(),
994        client_id,
995        client_secret,
996        scope,
997        browser,
998        TOKEN_ENDPOINT,
999    )
1000    .await
1001}
1002
1003/// [`login`], writing to an explicit settings-file path/profile and against
1004/// an explicit token endpoint — the test seam for a wiremock server.
1005pub(crate) async fn login_to(
1006    settings_path: &Path,
1007    profile: Option<&str>,
1008    client_id: &str,
1009    client_secret: &Secret,
1010    scope: GmailScope,
1011    browser: &BrowserConfig,
1012    token_endpoint: &str,
1013) -> Result<GmailAuthStatus> {
1014    let credentials =
1015        run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1016    save_credentials_to(settings_path, profile, &credentials)?;
1017    Ok(status_from_credentials(&credentials))
1018}
1019
1020/// [`login`], but honoring the named-account resolution added by issue
1021/// #1500: runs the same OAuth2 flow, then persists to
1022/// `gmail.accounts.<name>` when a named account is active instead of the
1023/// legacy `env`/profile map. `explicit` is the already-resolved
1024/// `--account`/[`account::GMAIL_ACCOUNT_ENV`] override, if any — resolved
1025/// via [`resolve_for_write`], so an explicit name need not already be
1026/// configured (this is how a new account is created).
1027pub(crate) async fn login_for(
1028    explicit: Option<&str>,
1029    client_id: &str,
1030    client_secret: &Secret,
1031    scope: GmailScope,
1032    browser: &BrowserConfig,
1033) -> Result<GmailAuthStatus> {
1034    let settings = Settings::load().unwrap_or_default();
1035    match resolve_for_write(&settings.gmail, explicit)? {
1036        ResolvedAccount::Legacy => {
1037            login_to(
1038                &Settings::get_settings_path()?,
1039                active_profile_from(&SystemEnv).as_deref(),
1040                client_id,
1041                client_secret,
1042                scope,
1043                browser,
1044                TOKEN_ENDPOINT,
1045            )
1046            .await
1047        }
1048        ResolvedAccount::Named(name) => {
1049            let credentials =
1050                run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1051            Settings::upsert_gmail_account(
1052                &Settings::get_settings_path()?,
1053                &name,
1054                &named_account_vars(&credentials),
1055            )?;
1056            Ok(status_from_credentials(&credentials))
1057        }
1058    }
1059}
1060
1061/// Runs the OAuth2 authorization-code + PKCE flow against `token_endpoint`
1062/// and returns the resulting credentials, without persisting them — the
1063/// shared core both [`login_to`] (legacy path) and [`login_for`]'s Named
1064/// branch build on.
1065async fn run_login_flow(
1066    client_id: &str,
1067    client_secret: &Secret,
1068    scope: GmailScope,
1069    browser: &BrowserConfig,
1070    token_endpoint: &str,
1071) -> Result<GmailCredentials> {
1072    let (listener, port) = bind_callback_listener(browser).await?;
1073    let redirect_uri = format!("http://127.0.0.1:{port}");
1074
1075    let pending = generate_pending_login();
1076    let challenge = code_challenge(&pending.code_verifier);
1077    let auth_url =
1078        build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1079    open_browser(&browser.launch, auth_url.as_str())?;
1080
1081    let callback = wait_for_callback(listener).await?;
1082    if let Some(error) = callback.error {
1083        return Err(GmailError::authorization_denied(
1084            &error,
1085            callback.error_description.as_deref(),
1086        )
1087        .into());
1088    }
1089    let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1090        return Err(GmailError::MalformedCallback.into());
1091    };
1092    // Plain equality, not constant-time: `state` is a CSRF nonce carried in
1093    // a browser-visible URL, not a secret — there's nothing for a timing
1094    // side-channel to extract here (unlike `constant_time_eq`'s real use
1095    // guarding a bridge auth token in `src/browser/auth.rs`).
1096    if returned_state != pending.state {
1097        return Err(GmailError::StateMismatch.into());
1098    }
1099
1100    let http = reqwest::Client::builder()
1101        .connect_timeout(crate::utils::http::connect_timeout())
1102        .read_timeout(crate::utils::http::read_timeout())
1103        .build()
1104        .context("Failed to build HTTP client")?;
1105    let tokens = exchange_code_for_tokens(
1106        &http,
1107        token_endpoint,
1108        client_id,
1109        client_secret.expose_secret(),
1110        &code,
1111        &pending.code_verifier,
1112        &redirect_uri,
1113    )
1114    .await?;
1115    let refresh_token = tokens
1116        .refresh_token
1117        .ok_or(GmailError::MalformedTokenResponse("refresh_token"))?;
1118    let granted_raw = tokens.scope.unwrap_or_default();
1119    let granted_scope = GmailScope::from_granted(&granted_raw).ok_or_else(|| {
1120        let received = if granted_raw.trim().is_empty() {
1121            "none".to_string()
1122        } else {
1123            granted_raw
1124                .split_whitespace()
1125                .collect::<Vec<_>>()
1126                .join(", ")
1127        };
1128        GmailError::NoGmailScopeGranted(received)
1129    })?;
1130
1131    Ok(GmailCredentials {
1132        client_id: client_id.to_string(),
1133        client_secret: client_secret.clone(),
1134        refresh_token: refresh_token.into(),
1135        scope: granted_scope,
1136    })
1137}
1138
1139/// Builds the "just authenticated" [`GmailAuthStatus`] from freshly-obtained
1140/// `credentials` (all fields present by construction).
1141fn status_from_credentials(credentials: &GmailCredentials) -> GmailAuthStatus {
1142    GmailAuthStatus {
1143        has_client_id: true,
1144        has_client_secret: true,
1145        has_refresh_token: true,
1146        scope: Some(credentials.scope.as_str().to_string()),
1147    }
1148}
1149
1150#[cfg(test)]
1151#[allow(clippy::unwrap_used, clippy::expect_used)]
1152mod tests {
1153    use std::fs;
1154    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1155    use std::sync::Arc;
1156
1157    use super::*;
1158
1159    // ── Pure helpers ─────────────────────────────────────────────────
1160
1161    #[test]
1162    fn scope_as_str_matches_google_scope_strings() {
1163        assert_eq!(GmailScope::ReadOnly.as_str(), SCOPE_READONLY);
1164        assert_eq!(GmailScope::Modify.as_str(), SCOPE_MODIFY);
1165    }
1166
1167    #[test]
1168    fn scope_from_granted_detects_modify_anywhere_in_the_list() {
1169        assert_eq!(
1170            GmailScope::from_granted(&format!("{SCOPE_READONLY} {SCOPE_MODIFY}")),
1171            Some(GmailScope::Modify)
1172        );
1173    }
1174
1175    #[test]
1176    fn scope_from_granted_detects_readonly_with_no_modify() {
1177        assert_eq!(
1178            GmailScope::from_granted(SCOPE_READONLY),
1179            Some(GmailScope::ReadOnly)
1180        );
1181    }
1182
1183    #[test]
1184    fn scope_from_granted_is_none_when_no_gmail_scope_present() {
1185        assert_eq!(GmailScope::from_granted("openid email profile"), None);
1186    }
1187
1188    #[test]
1189    fn scope_from_granted_is_none_for_empty_string() {
1190        assert_eq!(GmailScope::from_granted(""), None);
1191    }
1192
1193    #[test]
1194    fn scope_allows_modify() {
1195        assert!(!GmailScope::ReadOnly.allows_modify());
1196        assert!(GmailScope::Modify.allows_modify());
1197    }
1198
1199    #[test]
1200    fn code_challenge_matches_rfc_7636_test_vector() {
1201        // RFC 7636 Appendix B.1.
1202        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1203        let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1204        assert_eq!(code_challenge(verifier), expected);
1205    }
1206
1207    #[test]
1208    fn code_challenge_output_is_url_safe_no_padding() {
1209        let challenge = code_challenge("some-verifier-value");
1210        assert!(!challenge.contains('+'));
1211        assert!(!challenge.contains('/'));
1212        assert!(!challenge.contains('='));
1213    }
1214
1215    #[test]
1216    fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1217        let pending = generate_pending_login();
1218        assert_ne!(pending.state, pending.code_verifier);
1219        assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1220        assert!(pending
1221            .code_verifier
1222            .chars()
1223            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1224    }
1225
1226    #[test]
1227    fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1228        let url = build_authorization_url(
1229            "client-123",
1230            "http://127.0.0.1:5555",
1231            GmailScope::ReadOnly,
1232            "state-abc",
1233            "challenge-xyz",
1234        )
1235        .unwrap();
1236        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1237        assert_eq!(query.get("client_id").unwrap(), "client-123");
1238        assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1239        assert_eq!(query.get("response_type").unwrap(), "code");
1240        assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1241        assert_eq!(query.get("state").unwrap(), "state-abc");
1242        assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1243        assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1244        assert_eq!(query.get("access_type").unwrap(), "offline");
1245        assert_eq!(query.get("prompt").unwrap(), "consent");
1246    }
1247
1248    #[test]
1249    fn build_authorization_url_uses_modify_scope_when_requested() {
1250        let url = build_authorization_url("c", "http://127.0.0.1:1", GmailScope::Modify, "s", "ch")
1251            .unwrap();
1252        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1253        assert_eq!(query.get("scope").unwrap(), SCOPE_MODIFY);
1254    }
1255
1256    #[test]
1257    fn parse_callback_extracts_code_and_state() {
1258        let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1259        let result = parse_callback(request).unwrap();
1260        assert_eq!(result.code.as_deref(), Some("abc123"));
1261        assert_eq!(result.state.as_deref(), Some("xyz789"));
1262        assert!(result.error.is_none());
1263    }
1264
1265    #[test]
1266    fn parse_callback_extracts_error_and_error_description() {
1267        let request =
1268            "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1269        let result = parse_callback(request).unwrap();
1270        assert_eq!(result.error.as_deref(), Some("access_denied"));
1271        assert_eq!(result.error_description.as_deref(), Some("user declined"));
1272    }
1273
1274    #[test]
1275    fn parse_callback_missing_query_string_is_none() {
1276        assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1277        assert!(parse_callback("garbage").is_none());
1278    }
1279
1280    #[test]
1281    fn parse_callback_ignores_unrecognized_query_keys() {
1282        let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1283        let result = parse_callback(request).unwrap();
1284        assert_eq!(result.code.as_deref(), Some("abc"));
1285        assert_eq!(result.state.as_deref(), Some("xyz"));
1286    }
1287
1288    #[test]
1289    fn open_browser_manual_logs_and_succeeds() {
1290        assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1291    }
1292
1293    #[test]
1294    fn open_browser_command_substitutes_url_placeholder() {
1295        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1296        assert!(open_browser(&launch, "https://example/auth").is_ok());
1297    }
1298
1299    #[test]
1300    fn open_browser_command_appends_url_when_no_placeholder() {
1301        let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1302        assert!(open_browser(&launch, "https://example/auth").is_ok());
1303    }
1304
1305    #[test]
1306    fn open_browser_command_passes_through_args_without_the_placeholder() {
1307        // A trailing flag with no `{url}` substring (e.g. `--verbose`) is
1308        // passed to the command unmodified, and the URL is still appended
1309        // since no arg claimed the placeholder.
1310        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1311        assert!(open_browser(&launch, "https://example/auth").is_ok());
1312    }
1313
1314    #[test]
1315    fn open_browser_command_rejects_empty_args() {
1316        let launch = BrowserLaunch::Command(vec![]);
1317        let err = open_browser(&launch, "u").unwrap_err();
1318        assert!(err.to_string().contains("empty browser command"));
1319    }
1320
1321    // ── build_browser_config (issue #1505) ──────────────────────────────
1322
1323    fn assert_is_auto(config: BrowserConfig) {
1324        assert!(matches!(config.launch, BrowserLaunch::Auto));
1325    }
1326
1327    #[test]
1328    fn build_browser_config_defaults_to_auto_with_no_account() {
1329        assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1330    }
1331
1332    #[test]
1333    fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1334        let account = GmailAccountSettings {
1335            email_address: Some("alice@example.com".to_string()),
1336            ..GmailAccountSettings::default()
1337        };
1338        // chrome_profile_from_email is false, so the resolver must never run
1339        // even though email_address is set.
1340        assert_is_auto(
1341            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1342        );
1343    }
1344
1345    #[test]
1346    fn build_browser_config_uses_browser_command_verbatim() {
1347        let account = GmailAccountSettings {
1348            browser_command: Some("chrome --new-window {url}".to_string()),
1349            ..GmailAccountSettings::default()
1350        };
1351        let config =
1352            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1353        assert!(matches!(
1354            config.launch,
1355            BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1356        ));
1357    }
1358
1359    #[test]
1360    fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1361        let account = GmailAccountSettings {
1362            browser_command: Some("chrome {url}".to_string()),
1363            chrome_profile_from_email: true,
1364            email_address: Some("alice@example.com".to_string()),
1365            ..GmailAccountSettings::default()
1366        };
1367        let config =
1368            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1369        assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1370    }
1371
1372    #[test]
1373    fn build_browser_config_rejects_a_malformed_browser_command() {
1374        let account = GmailAccountSettings {
1375            browser_command: Some("chrome \"--flag".to_string()),
1376            ..GmailAccountSettings::default()
1377        };
1378        let err =
1379            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1380        assert!(err.to_string().contains("browser_command"));
1381    }
1382
1383    #[test]
1384    fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1385        let account = GmailAccountSettings {
1386            chrome_profile_from_email: true,
1387            email_address: Some("alice@example.com".to_string()),
1388            ..GmailAccountSettings::default()
1389        };
1390        let config = build_browser_config(Some(&account), |email| {
1391            assert_eq!(email, "alice@example.com");
1392            Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1393        })
1394        .unwrap();
1395        assert!(matches!(
1396            config.launch,
1397            BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1398        ));
1399    }
1400
1401    #[test]
1402    fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1403        let account = GmailAccountSettings {
1404            chrome_profile_from_email: true,
1405            email_address: Some("alice@example.com".to_string()),
1406            ..GmailAccountSettings::default()
1407        };
1408        assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1409    }
1410
1411    #[test]
1412    fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1413        let account = GmailAccountSettings {
1414            chrome_profile_from_email: true,
1415            ..GmailAccountSettings::default()
1416        };
1417        assert_is_auto(
1418            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1419        );
1420    }
1421
1422    // ── resolve_browser_config_for (issue #1505) ────────────────────────
1423
1424    #[test]
1425    fn resolve_browser_config_for_legacy_account_defaults_to_auto() {
1426        let guard = crate::gmail::test_support::EnvGuard::take();
1427        let _dir = guard.clear_credentials();
1428        std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
1429        std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
1430        std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
1431
1432        let gmail = GmailSettings::default();
1433        assert_is_auto(resolve_browser_config_for(&gmail, None).unwrap());
1434    }
1435
1436    #[test]
1437    fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1438        let guard = crate::gmail::test_support::EnvGuard::take();
1439        let _dir = guard.clear_credentials();
1440
1441        let mut gmail = GmailSettings::default();
1442        gmail.accounts.insert(
1443            "work".to_string(),
1444            GmailAccountSettings {
1445                email_address: Some("alice@example.com".to_string()),
1446                ..GmailAccountSettings::default()
1447            },
1448        );
1449
1450        // chrome_profile_from_email is false, so this never touches the
1451        // real chrome_profile::resolve_launch_command resolver.
1452        assert_is_auto(resolve_browser_config_for(&gmail, Some("work")).unwrap());
1453    }
1454
1455    // ── Loopback listener (real sockets, no wiremock) ───────────────────
1456
1457    #[tokio::test]
1458    async fn wait_for_callback_times_out_when_nothing_connects() {
1459        let browser = BrowserConfig::default();
1460        let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1461        let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1462            .await
1463            .unwrap_err();
1464        assert!(matches!(
1465            err.downcast_ref::<GmailError>(),
1466            Some(GmailError::CallbackTimeout(_))
1467        ));
1468    }
1469
1470    #[tokio::test]
1471    async fn wait_for_callback_reads_a_real_connection() {
1472        let browser = BrowserConfig::default();
1473        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1474
1475        let client = tokio::spawn(async move {
1476            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1477                .await
1478                .unwrap();
1479            stream
1480                .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1481                .await
1482                .unwrap();
1483        });
1484
1485        let result = wait_for_callback(listener).await.unwrap();
1486        client.await.unwrap();
1487        assert_eq!(result.code.as_deref(), Some("abc"));
1488        assert_eq!(result.state.as_deref(), Some("xyz"));
1489    }
1490
1491    #[tokio::test]
1492    async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1493        let browser = BrowserConfig::default();
1494        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1495
1496        let client = tokio::spawn(async move {
1497            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1498                .await
1499                .unwrap();
1500            stream.write_all(b"not an http request").await.unwrap();
1501        });
1502
1503        let err = wait_for_callback(listener).await.unwrap_err();
1504        client.await.unwrap();
1505        assert!(matches!(
1506            err.downcast_ref::<GmailError>(),
1507            Some(GmailError::MalformedCallback)
1508        ));
1509    }
1510
1511    // ── Token exchange / refresh (wiremock) ─────────────────────────────
1512
1513    #[tokio::test]
1514    async fn exchange_code_for_tokens_posts_expected_form_body() {
1515        let server = wiremock::MockServer::start().await;
1516        wiremock::Mock::given(wiremock::matchers::method("POST"))
1517            .and(wiremock::matchers::path("/token"))
1518            .and(wiremock::matchers::body_string_contains(
1519                "grant_type=authorization_code",
1520            ))
1521            .and(wiremock::matchers::body_string_contains(
1522                "code_verifier=verifier-1",
1523            ))
1524            .and(wiremock::matchers::body_string_contains(
1525                "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1526            ))
1527            .respond_with(
1528                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1529                    "access_token": "at-1",
1530                    "refresh_token": "rt-1",
1531                    "expires_in": 3600,
1532                    "scope": SCOPE_READONLY,
1533                })),
1534            )
1535            .expect(1)
1536            .mount(&server)
1537            .await;
1538
1539        let http = reqwest::Client::new();
1540        let token_endpoint = format!("{}/token", server.uri());
1541        let response = exchange_code_for_tokens(
1542            &http,
1543            &token_endpoint,
1544            "client-1",
1545            "secret-1",
1546            "code-1",
1547            "verifier-1",
1548            "http://127.0.0.1:9999",
1549        )
1550        .await
1551        .unwrap();
1552        assert_eq!(response.access_token, "at-1");
1553        assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1554    }
1555
1556    #[tokio::test]
1557    async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1558        let server = wiremock::MockServer::start().await;
1559        wiremock::Mock::given(wiremock::matchers::method("POST"))
1560            .respond_with(
1561                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1562                    "error": "invalid_grant",
1563                    "error_description": "Bad Request",
1564                })),
1565            )
1566            .mount(&server)
1567            .await;
1568
1569        let http = reqwest::Client::new();
1570        let err = exchange_code_for_tokens(
1571            &http,
1572            &server.uri(),
1573            "c",
1574            "s",
1575            "code",
1576            "verifier",
1577            "http://127.0.0.1:1",
1578        )
1579        .await
1580        .unwrap_err();
1581        assert!(err.to_string().contains("PKCE"));
1582    }
1583
1584    // ── login_to (end-to-end: state mismatch / access_denied) ───────────
1585    //
1586    // These exercise `login_to` itself rather than its sub-components in
1587    // isolation, so a regression in how it wires the loopback callback into
1588    // the state-mismatch/access_denied branches would actually be caught.
1589    // The callback port is picked by binding-then-dropping a std listener
1590    // (a well-known "reserve a free port" trick) so the test's connector
1591    // task can dial it directly — `login_to` binds the real listener before
1592    // opening the browser, so the connector retries briefly to cover the
1593    // small window before that bind completes.
1594    //
1595    // That reserve-then-drop is itself a TOCTOU race against the rest of the
1596    // parallel test suite: another test can grab the same ephemeral port
1597    // before `login_to`'s own bind runs, which fails outright rather than
1598    // retrying (see issue #1489). `run_with_port_retry` bounds a retry of
1599    // the whole reserve/connect/bind attempt on exactly that failure, so a
1600    // lost race just tries again with a fresh port instead of flaking.
1601
1602    async fn connect_and_send(port: u16, request_line: &[u8]) {
1603        let mut stream = loop {
1604            match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1605                Ok(stream) => break stream,
1606                Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1607            }
1608        };
1609        stream.write_all(request_line).await.unwrap();
1610    }
1611
1612    fn reserve_free_port() -> u16 {
1613        std::net::TcpListener::bind("127.0.0.1:0")
1614            .unwrap()
1615            .local_addr()
1616            .unwrap()
1617            .port()
1618    }
1619
1620    /// True if `err` is `bind_callback_listener`'s wrapped `AddrInUse` —
1621    /// i.e. some other process/test won the race for the port reserved by
1622    /// [`reserve_free_port`] before `login_to` could rebind it.
1623    fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1624        err.to_string()
1625            .contains("Failed to start the local OAuth callback listener")
1626    }
1627
1628    const PORT_RETRY_ATTEMPTS: u32 = 5;
1629
1630    /// Runs `attempt`, which reserves its own port via [`reserve_free_port`]
1631    /// and returns `login_to`'s result, retrying up to
1632    /// [`PORT_RETRY_ATTEMPTS`] times when the attempt loses the ephemeral
1633    /// port race (see the module comment above `connect_and_send`).
1634    async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<GmailAuthStatus>
1635    where
1636        F: FnMut(u16) -> Fut,
1637        Fut: std::future::Future<Output = Result<GmailAuthStatus>>,
1638    {
1639        for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1640            let result = attempt(reserve_free_port()).await;
1641            let is_retryable_conflict =
1642                matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1643            if !is_retryable_conflict {
1644                return result;
1645            }
1646        }
1647        unreachable!("loop always returns on its last iteration")
1648    }
1649
1650    /// Awaits `connector` normally, unless `result` shows `login_to` lost
1651    /// the callback-port race — in which case the connector, which will
1652    /// never see a connection on the now-taken port, is aborted instead of
1653    /// hung.
1654    async fn finish_connector(
1655        connector: tokio::task::JoinHandle<()>,
1656        result: &Result<GmailAuthStatus>,
1657    ) {
1658        match result {
1659            Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1660            _ => connector.await.unwrap(),
1661        }
1662    }
1663
1664    /// Polls `path` until it holds non-empty content, then returns it —
1665    /// used to read back the authorization URL that `open_browser`'s
1666    /// captured shell command writes asynchronously.
1667    async fn wait_for_captured_url(path: &Path) -> String {
1668        loop {
1669            if let Ok(contents) = std::fs::read_to_string(path) {
1670                if !contents.is_empty() {
1671                    return contents;
1672                }
1673            }
1674            tokio::time::sleep(Duration::from_millis(2)).await;
1675        }
1676    }
1677
1678    /// Shared body for the three `login_to_*` tests that drive a single
1679    /// fixed callback request line through `login_to` and expect it to
1680    /// error: reserves a port, spawns the connector, calls `login_to`, and
1681    /// retries the whole attempt (via [`run_with_port_retry`]) if it loses
1682    /// the ephemeral-port race. Asserts no settings file was written and
1683    /// returns the resulting error for the caller to inspect.
1684    async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
1685        std::fs::create_dir_all("tmp").ok();
1686        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1687        let settings_path = temp_dir.path().join("settings.json");
1688
1689        let result = run_with_port_retry(|port| {
1690            let settings_path = settings_path.clone();
1691            async move {
1692                let browser = BrowserConfig {
1693                    launch: BrowserLaunch::Manual,
1694                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1695                    callback_port: port,
1696                };
1697                let connector = tokio::spawn(connect_and_send(port, request_line));
1698
1699                let result = login_to(
1700                    &settings_path,
1701                    None,
1702                    "client-id",
1703                    &Secret::new("client-secret"),
1704                    GmailScope::ReadOnly,
1705                    &browser,
1706                    "http://127.0.0.1:1/token", // never reached — fails before exchange
1707                )
1708                .await;
1709
1710                finish_connector(connector, &result).await;
1711                result
1712            }
1713        })
1714        .await;
1715
1716        let err = result.unwrap_err();
1717        assert!(!settings_path.exists());
1718        err
1719    }
1720
1721    #[tokio::test]
1722    async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
1723        let attempts = AtomicU32::new(0);
1724
1725        let status = run_with_port_retry(|_port| {
1726            let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
1727            async move {
1728                if attempt_no == 0 {
1729                    Err(anyhow::anyhow!(
1730                        "Failed to start the local OAuth callback listener: address in use"
1731                    ))
1732                } else {
1733                    Ok(GmailAuthStatus {
1734                        has_client_id: true,
1735                        has_client_secret: true,
1736                        has_refresh_token: true,
1737                        scope: None,
1738                    })
1739                }
1740            }
1741        })
1742        .await
1743        .unwrap();
1744
1745        assert_eq!(attempts.load(Ordering::SeqCst), 2);
1746        assert!(status.has_client_id);
1747    }
1748
1749    #[tokio::test]
1750    async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
1751        let attempts = AtomicU32::new(0);
1752
1753        let result = run_with_port_retry(|_port| {
1754            attempts.fetch_add(1, Ordering::SeqCst);
1755            async move { Err(anyhow::anyhow!("some other failure")) }
1756        })
1757        .await;
1758
1759        assert_eq!(attempts.load(Ordering::SeqCst), 1);
1760        assert!(result.is_err());
1761    }
1762
1763    #[tokio::test]
1764    async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
1765        let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
1766        let connector = tokio::spawn(async move {
1767            tokio::time::sleep(Duration::from_secs(3600)).await;
1768            let _ = tx.send(());
1769        });
1770        let result: Result<GmailAuthStatus> = Err(anyhow::anyhow!(
1771            "Failed to start the local OAuth callback listener: address in use"
1772        ));
1773
1774        tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
1775            .await
1776            .expect("finish_connector must not wait for an aborted connector");
1777
1778        assert!(
1779            rx.try_recv().is_err(),
1780            "connector must have been aborted, not run to completion"
1781        );
1782    }
1783
1784    #[tokio::test]
1785    async fn finish_connector_awaits_connector_when_login_to_succeeds() {
1786        let ran = Arc::new(AtomicBool::new(false));
1787        let ran_clone = ran.clone();
1788        let connector = tokio::spawn(async move {
1789            ran_clone.store(true, Ordering::SeqCst);
1790        });
1791        let result = Ok(GmailAuthStatus {
1792            has_client_id: true,
1793            has_client_secret: true,
1794            has_refresh_token: true,
1795            scope: None,
1796        });
1797
1798        finish_connector(connector, &result).await;
1799
1800        assert!(ran.load(Ordering::SeqCst));
1801    }
1802
1803    #[tokio::test]
1804    async fn wait_for_captured_url_polls_until_content_is_written() {
1805        std::fs::create_dir_all("tmp").ok();
1806        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1807        let path = temp_dir.path().join("captured-url.txt");
1808
1809        let write_path = path.clone();
1810        tokio::spawn(async move {
1811            tokio::time::sleep(Duration::from_millis(10)).await;
1812            std::fs::write(&write_path, "").unwrap();
1813            tokio::time::sleep(Duration::from_millis(10)).await;
1814            std::fs::write(&write_path, "https://example.com/authorize").unwrap();
1815        });
1816
1817        let contents = wait_for_captured_url(&path).await;
1818        assert_eq!(contents, "https://example.com/authorize");
1819    }
1820
1821    #[tokio::test]
1822    async fn login_to_rejects_a_callback_with_mismatched_state() {
1823        let err =
1824            run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
1825
1826        assert!(matches!(
1827            err.downcast_ref::<GmailError>(),
1828            Some(GmailError::StateMismatch)
1829        ));
1830    }
1831
1832    #[tokio::test]
1833    async fn login_to_surfaces_access_denied_from_the_callback() {
1834        let err = run_login_to_expect_err(
1835            b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
1836        )
1837        .await;
1838
1839        match err.downcast_ref::<GmailError>() {
1840            Some(GmailError::AuthorizationDenied(message)) => {
1841                assert!(message.contains("access_denied"));
1842                assert!(message.contains("user declined"));
1843            }
1844            other => panic!("expected AuthorizationDenied, got {other:?}"),
1845        }
1846    }
1847
1848    #[tokio::test]
1849    async fn login_to_rejects_a_callback_missing_code_and_state() {
1850        let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
1851
1852        assert!(matches!(
1853            err.downcast_ref::<GmailError>(),
1854            Some(GmailError::MalformedCallback)
1855        ));
1856    }
1857
1858    #[tokio::test]
1859    async fn login_to_completes_full_success_flow_and_persists_credentials() {
1860        // Captures the real authorization URL `login_to` generates (with its
1861        // randomly-generated CSRF `state`) by pointing the browser launch at
1862        // a shell command instead of an actual browser: `open_browser`
1863        // substitutes `{url}` into the command's args and spawns it, so a
1864        // tiny `/bin/sh` one-liner writes the URL to a file we can read back
1865        // — letting this test drive the full success path (state echoed
1866        // correctly, token exchange, credential persistence) without ever
1867        // opening a real browser or needing to predict the CSRF nonce.
1868        std::fs::create_dir_all("tmp").ok();
1869        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1870        let capture_path = temp_dir.path().join("captured-url.txt");
1871        let settings_path = temp_dir.path().join("settings.json");
1872
1873        let server = wiremock::MockServer::start().await;
1874        wiremock::Mock::given(wiremock::matchers::method("POST"))
1875            .and(wiremock::matchers::path("/token"))
1876            .respond_with(
1877                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1878                    "access_token": "at-1",
1879                    "refresh_token": "rt-1",
1880                    "expires_in": 3600,
1881                    "scope": SCOPE_READONLY,
1882                })),
1883            )
1884            .expect(1)
1885            .mount(&server)
1886            .await;
1887
1888        let status = run_with_port_retry(|port| {
1889            let capture_path = capture_path.clone();
1890            let settings_path = settings_path.clone();
1891            let token_endpoint = format!("{}/token", server.uri());
1892            async move {
1893                let browser = BrowserConfig {
1894                    launch: BrowserLaunch::Command(vec![
1895                        "/bin/sh".to_string(),
1896                        "-c".to_string(),
1897                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
1898                        "{url}".to_string(),
1899                    ]),
1900                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1901                    callback_port: port,
1902                };
1903
1904                let connector = tokio::spawn(async move {
1905                    let auth_url = wait_for_captured_url(&capture_path).await;
1906                    let parsed = Url::parse(&auth_url).unwrap();
1907                    let state = parsed
1908                        .query_pairs()
1909                        .find(|(k, _)| k == "state")
1910                        .map(|(_, v)| v.into_owned())
1911                        .expect("authorization URL must carry a state param");
1912                    connect_and_send(
1913                        port,
1914                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
1915                    )
1916                    .await;
1917                });
1918
1919                let result = login_to(
1920                    &settings_path,
1921                    None,
1922                    "client-id",
1923                    &Secret::new("client-secret"),
1924                    GmailScope::ReadOnly,
1925                    &browser,
1926                    &token_endpoint,
1927                )
1928                .await;
1929
1930                finish_connector(connector, &result).await;
1931                result
1932            }
1933        })
1934        .await
1935        .unwrap();
1936
1937        assert!(status.has_client_id);
1938        assert!(status.has_client_secret);
1939        assert!(status.has_refresh_token);
1940        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
1941
1942        let saved = std::fs::read_to_string(&settings_path).unwrap();
1943        assert!(saved.contains("rt-1"));
1944        assert!(saved.contains("client-id"));
1945    }
1946
1947    /// Full mocked login round trip (real state nonce echoed back via the
1948    /// captured-authorization-URL trick, like
1949    /// `login_to_completes_full_success_flow_and_persists_credentials`
1950    /// above), with an injectable token-response body — the seam the
1951    /// scope-validation tests below use to simulate Google granting no
1952    /// Gmail scope.
1953    async fn run_login_to_with_token_response(
1954        token_response_body: serde_json::Value,
1955    ) -> (Result<GmailAuthStatus>, std::path::PathBuf) {
1956        std::fs::create_dir_all("tmp").ok();
1957        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1958        let capture_path = temp_dir.path().join("captured-url.txt");
1959        let settings_path = temp_dir.path().join("settings.json");
1960
1961        let server = wiremock::MockServer::start().await;
1962        wiremock::Mock::given(wiremock::matchers::method("POST"))
1963            .and(wiremock::matchers::path("/token"))
1964            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
1965            .expect(1)
1966            .mount(&server)
1967            .await;
1968
1969        let result = run_with_port_retry(|port| {
1970            let capture_path = capture_path.clone();
1971            let settings_path = settings_path.clone();
1972            let token_endpoint = format!("{}/token", server.uri());
1973            async move {
1974                let browser = BrowserConfig {
1975                    launch: BrowserLaunch::Command(vec![
1976                        "/bin/sh".to_string(),
1977                        "-c".to_string(),
1978                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
1979                        "{url}".to_string(),
1980                    ]),
1981                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1982                    callback_port: port,
1983                };
1984
1985                let connector = tokio::spawn(async move {
1986                    let auth_url = wait_for_captured_url(&capture_path).await;
1987                    let parsed = Url::parse(&auth_url).unwrap();
1988                    let state = parsed
1989                        .query_pairs()
1990                        .find(|(k, _)| k == "state")
1991                        .map(|(_, v)| v.into_owned())
1992                        .expect("authorization URL must carry a state param");
1993                    connect_and_send(
1994                        port,
1995                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
1996                    )
1997                    .await;
1998                });
1999
2000                let result = login_to(
2001                    &settings_path,
2002                    None,
2003                    "client-id",
2004                    &Secret::new("client-secret"),
2005                    GmailScope::ReadOnly,
2006                    &browser,
2007                    &token_endpoint,
2008                )
2009                .await;
2010
2011                finish_connector(connector, &result).await;
2012                result
2013            }
2014        })
2015        .await;
2016
2017        (result, settings_path)
2018    }
2019
2020    #[tokio::test]
2021    async fn login_to_rejects_a_grant_with_no_gmail_scope() {
2022        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2023            "access_token": "at-1",
2024            "refresh_token": "rt-1",
2025            "expires_in": 3600,
2026            "scope": "openid email profile",
2027        }))
2028        .await;
2029
2030        let err = result.unwrap_err();
2031        assert!(matches!(
2032            err.downcast_ref::<GmailError>(),
2033            Some(GmailError::NoGmailScopeGranted(received)) if received == "openid, email, profile"
2034        ));
2035        assert!(!settings_path.exists());
2036    }
2037
2038    #[tokio::test]
2039    async fn login_to_rejects_a_grant_with_missing_scope_field() {
2040        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2041            "access_token": "at-1",
2042            "refresh_token": "rt-1",
2043            "expires_in": 3600,
2044        }))
2045        .await;
2046
2047        let err = result.unwrap_err();
2048        assert!(matches!(
2049            err.downcast_ref::<GmailError>(),
2050            Some(GmailError::NoGmailScopeGranted(received)) if received == "none"
2051        ));
2052        assert!(!settings_path.exists());
2053    }
2054
2055    #[tokio::test]
2056    async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2057        let server = wiremock::MockServer::start().await;
2058        wiremock::Mock::given(wiremock::matchers::method("POST"))
2059            .and(wiremock::matchers::body_string_contains(
2060                "grant_type=refresh_token",
2061            ))
2062            .respond_with(
2063                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2064                    "access_token": "at-2",
2065                    "expires_in": 1800,
2066                })),
2067            )
2068            .expect(1)
2069            .mount(&server)
2070            .await;
2071
2072        let http = reqwest::Client::new();
2073        let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2074            .await
2075            .unwrap();
2076        assert_eq!(response.access_token, "at-2");
2077        assert_eq!(response.expires_in, 1800);
2078    }
2079
2080    #[tokio::test]
2081    async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2082        let server = wiremock::MockServer::start().await;
2083        wiremock::Mock::given(wiremock::matchers::method("POST"))
2084            .respond_with(
2085                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2086                    "error": "invalid_grant",
2087                })),
2088            )
2089            .mount(&server)
2090            .await;
2091
2092        let http = reqwest::Client::new();
2093        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2094            .await
2095            .unwrap_err();
2096        let msg = err.to_string();
2097        assert!(msg.contains("7 days"));
2098        assert!(msg.contains("Testing"));
2099    }
2100
2101    #[tokio::test]
2102    async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2103        let server = wiremock::MockServer::start().await;
2104        wiremock::Mock::given(wiremock::matchers::method("POST"))
2105            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2106            .mount(&server)
2107            .await;
2108
2109        let http = reqwest::Client::new();
2110        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2111            .await
2112            .unwrap_err();
2113        let msg = err.to_string();
2114        assert!(msg.contains("unparsable error body"));
2115        assert!(msg.contains("not json"));
2116    }
2117
2118    #[tokio::test]
2119    async fn token_request_propagates_network_errors() {
2120        let http = reqwest::Client::new();
2121        let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2122            .await
2123            .unwrap_err();
2124        assert!(err.to_string().contains("Failed to send token request"));
2125    }
2126
2127    #[tokio::test]
2128    async fn token_request_errors_on_unparsable_response_body() {
2129        let server = wiremock::MockServer::start().await;
2130        wiremock::Mock::given(wiremock::matchers::method("POST"))
2131            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2132            .mount(&server)
2133            .await;
2134
2135        let http = reqwest::Client::new();
2136        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2137            .await
2138            .unwrap_err();
2139        assert!(err.to_string().contains("Failed to parse"));
2140    }
2141
2142    // ── GmailSession ─────────────────────────────────────────────────
2143
2144    fn test_credentials() -> GmailCredentials {
2145        GmailCredentials {
2146            client_id: "client-1".to_string(),
2147            client_secret: "secret-1".into(),
2148            refresh_token: "refresh-1".into(),
2149            scope: GmailScope::ReadOnly,
2150        }
2151    }
2152
2153    #[test]
2154    fn named_account_vars_maps_credentials_to_json_string_values() {
2155        assert_eq!(
2156            named_account_vars(&test_credentials()),
2157            [
2158                (
2159                    "client_id",
2160                    serde_json::Value::String("client-1".to_string())
2161                ),
2162                (
2163                    "client_secret",
2164                    serde_json::Value::String("secret-1".to_string())
2165                ),
2166                (
2167                    "refresh_token",
2168                    serde_json::Value::String("refresh-1".to_string())
2169                ),
2170                (
2171                    "scope",
2172                    serde_json::Value::String(SCOPE_READONLY.to_string())
2173                ),
2174            ]
2175        );
2176    }
2177
2178    #[tokio::test]
2179    async fn access_token_refreshes_on_first_call() {
2180        let server = wiremock::MockServer::start().await;
2181        wiremock::Mock::given(wiremock::matchers::method("POST"))
2182            .respond_with(
2183                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2184                    "access_token": "at-first",
2185                    "expires_in": 3600,
2186                })),
2187            )
2188            .expect(1)
2189            .mount(&server)
2190            .await;
2191
2192        let session = GmailSession::new_with_token_endpoint(
2193            reqwest::Client::new(),
2194            &test_credentials(),
2195            &server.uri(),
2196        );
2197        let token = session.access_token().await.unwrap();
2198        assert_eq!(token.expose_secret(), "at-first");
2199    }
2200
2201    #[tokio::test]
2202    async fn access_token_reuses_cached_token_within_skew_window() {
2203        let server = wiremock::MockServer::start().await;
2204        wiremock::Mock::given(wiremock::matchers::method("POST"))
2205            .respond_with(
2206                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2207                    "access_token": "at-cached",
2208                    "expires_in": 3600,
2209                })),
2210            )
2211            .expect(1)
2212            .mount(&server)
2213            .await;
2214
2215        let session = GmailSession::new_with_token_endpoint(
2216            reqwest::Client::new(),
2217            &test_credentials(),
2218            &server.uri(),
2219        );
2220        let first = session.access_token().await.unwrap();
2221        let second = session.access_token().await.unwrap();
2222        assert_eq!(first, second);
2223    }
2224
2225    #[tokio::test]
2226    async fn access_token_proactively_refreshes_when_within_skew_window() {
2227        let server = wiremock::MockServer::start().await;
2228        wiremock::Mock::given(wiremock::matchers::method("POST"))
2229            .respond_with(
2230                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2231                    "access_token": "at-short",
2232                    // Less than REFRESH_SKEW (60s), so the second call also refreshes.
2233                    "expires_in": 30,
2234                })),
2235            )
2236            .expect(2)
2237            .mount(&server)
2238            .await;
2239
2240        let session = GmailSession::new_with_token_endpoint(
2241            reqwest::Client::new(),
2242            &test_credentials(),
2243            &server.uri(),
2244        );
2245        session.access_token().await.unwrap();
2246        session.access_token().await.unwrap();
2247    }
2248
2249    #[tokio::test]
2250    async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2251        let server = wiremock::MockServer::start().await;
2252        wiremock::Mock::given(wiremock::matchers::method("POST"))
2253            .respond_with(
2254                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2255                    "access_token": "at-overflow",
2256                    // Unvalidated, this overflows both TimeDelta::seconds and
2257                    // the subsequent DateTime<Utc> addition (#1531).
2258                    "expires_in": i64::MAX,
2259                })),
2260            )
2261            .expect(1)
2262            .mount(&server)
2263            .await;
2264
2265        let session = GmailSession::new_with_token_endpoint(
2266            reqwest::Client::new(),
2267            &test_credentials(),
2268            &server.uri(),
2269        );
2270        let token = session.access_token().await.unwrap();
2271        assert_eq!(token.expose_secret(), "at-overflow");
2272    }
2273
2274    #[tokio::test]
2275    async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2276        let server = wiremock::MockServer::start().await;
2277        wiremock::Mock::given(wiremock::matchers::method("POST"))
2278            .respond_with(
2279                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2280                    "access_token": "at-negative",
2281                    "expires_in": -3600,
2282                })),
2283            )
2284            // Negative expires_in clamps to 0, so the token is already
2285            // stale and the second call refreshes again.
2286            .expect(2)
2287            .mount(&server)
2288            .await;
2289
2290        let session = GmailSession::new_with_token_endpoint(
2291            reqwest::Client::new(),
2292            &test_credentials(),
2293            &server.uri(),
2294        );
2295        session.access_token().await.unwrap();
2296        session.access_token().await.unwrap();
2297    }
2298
2299    #[tokio::test]
2300    async fn force_refresh_concurrent_callers_do_not_stampede() {
2301        let server = wiremock::MockServer::start().await;
2302        wiremock::Mock::given(wiremock::matchers::method("POST"))
2303            .respond_with(
2304                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2305                    "access_token": "at-bootstrap",
2306                    "expires_in": 3600,
2307                })),
2308            )
2309            .up_to_n_times(1)
2310            .with_priority(1)
2311            .mount(&server)
2312            .await;
2313        wiremock::Mock::given(wiremock::matchers::method("POST"))
2314            .respond_with(
2315                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2316                    "access_token": "at-refreshed",
2317                    "expires_in": 3600,
2318                })),
2319            )
2320            .expect(1)
2321            .with_priority(2)
2322            .mount(&server)
2323            .await;
2324
2325        let session = GmailSession::new_with_token_endpoint(
2326            reqwest::Client::new(),
2327            &test_credentials(),
2328            &server.uri(),
2329        );
2330        let bootstrapped = session.access_token().await.unwrap();
2331        assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2332
2333        let (a, b) = tokio::join!(
2334            session.force_refresh(&bootstrapped),
2335            session.force_refresh(&bootstrapped)
2336        );
2337        let a = a.unwrap();
2338        let b = b.unwrap();
2339        assert_eq!(a, b);
2340        assert_eq!(a.expose_secret(), "at-refreshed");
2341    }
2342
2343    #[tokio::test]
2344    async fn force_refresh_skips_network_call_when_token_already_rotated() {
2345        let server = wiremock::MockServer::start().await;
2346        wiremock::Mock::given(wiremock::matchers::method("POST"))
2347            .respond_with(
2348                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2349                    "access_token": "at-a",
2350                    "expires_in": 3600,
2351                })),
2352            )
2353            .up_to_n_times(1)
2354            .with_priority(1)
2355            .mount(&server)
2356            .await;
2357        wiremock::Mock::given(wiremock::matchers::method("POST"))
2358            .respond_with(
2359                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2360                    "access_token": "at-b",
2361                    "expires_in": 3600,
2362                })),
2363            )
2364            .expect(1)
2365            .with_priority(2)
2366            .mount(&server)
2367            .await;
2368
2369        let session = GmailSession::new_with_token_endpoint(
2370            reqwest::Client::new(),
2371            &test_credentials(),
2372            &server.uri(),
2373        );
2374        let stale = Secret::new("at-never-issued");
2375        let bootstrapped = session.access_token().await.unwrap();
2376        assert_eq!(bootstrapped.expose_secret(), "at-a");
2377
2378        // `stale` was never the live token, so this should reuse the current
2379        // one without an extra network call.
2380        let result = session.force_refresh(&stale).await.unwrap();
2381        assert_eq!(result, bootstrapped);
2382
2383        // A real force_refresh against the actual current token does POST.
2384        let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2385        assert_eq!(refreshed.expose_secret(), "at-b");
2386    }
2387
2388    // ── Secret non-leakage ───────────────────────────────────────────
2389
2390    #[test]
2391    fn gmail_credentials_debug_redacts_client_secret_and_refresh_token() {
2392        let creds = GmailCredentials {
2393            client_id: "client-visible".to_string(),
2394            client_secret: "sekret-client-secret".into(),
2395            refresh_token: "sekret-refresh-token".into(),
2396            scope: GmailScope::ReadOnly,
2397        };
2398        let debug = format!("{creds:?}");
2399        assert!(debug.contains("GmailCredentials"));
2400        assert!(debug.contains("client-visible"));
2401        assert!(!debug.contains("sekret-client-secret"));
2402        assert!(!debug.contains("sekret-refresh-token"));
2403        assert!(debug.contains("client_secret: <redacted>"));
2404        assert!(debug.contains("refresh_token: <redacted>"));
2405    }
2406
2407    #[test]
2408    fn gmail_auth_status_yaml_serialization_contains_no_secret_values() {
2409        let env = crate::test_support::env::MapEnv::new()
2410            .with(GMAIL_CLIENT_ID, "client-id-value")
2411            .with(GMAIL_CLIENT_SECRET, "sekret-do-not-leak")
2412            .with(GMAIL_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2413            .with(GMAIL_SCOPE, SCOPE_READONLY);
2414        let status = status_with(&env);
2415        let yaml = serde_yaml::to_string(&status).unwrap();
2416        assert!(!yaml.contains("sekret-do-not-leak"));
2417        assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2418    }
2419
2420    // ── Env-DI boundary tests ────────────────────────────────────────
2421
2422    use crate::test_support::env::MapEnv;
2423
2424    #[test]
2425    fn status_reports_all_false_when_nothing_configured() {
2426        let status = status_with(&MapEnv::new());
2427        assert!(!status.has_client_id);
2428        assert!(!status.has_client_secret);
2429        assert!(!status.has_refresh_token);
2430        assert_eq!(status.scope, None);
2431    }
2432
2433    #[test]
2434    fn status_reports_scope_when_present() {
2435        let env = MapEnv::new().with(GMAIL_SCOPE, SCOPE_MODIFY);
2436        let status = status_with(&env);
2437        assert_eq!(status.scope.as_deref(), Some(SCOPE_MODIFY));
2438    }
2439
2440    #[test]
2441    fn load_credentials_errors_when_client_id_missing() {
2442        let env = MapEnv::new()
2443            .with(GMAIL_CLIENT_SECRET, "s")
2444            .with(GMAIL_REFRESH_TOKEN, "r");
2445        let err = load_credentials_with(&env).unwrap_err();
2446        assert!(err.to_string().contains("not configured"));
2447    }
2448
2449    #[test]
2450    fn load_credentials_errors_when_client_secret_missing() {
2451        let env = MapEnv::new()
2452            .with(GMAIL_CLIENT_ID, "c")
2453            .with(GMAIL_REFRESH_TOKEN, "r");
2454        assert!(load_credentials_with(&env).is_err());
2455    }
2456
2457    #[test]
2458    fn load_credentials_errors_when_refresh_token_missing() {
2459        let env = MapEnv::new()
2460            .with(GMAIL_CLIENT_ID, "c")
2461            .with(GMAIL_CLIENT_SECRET, "s");
2462        assert!(load_credentials_with(&env).is_err());
2463    }
2464
2465    #[test]
2466    fn load_credentials_succeeds_with_all_three_present() {
2467        let env = MapEnv::new()
2468            .with(GMAIL_CLIENT_ID, "c")
2469            .with(GMAIL_CLIENT_SECRET, "s")
2470            .with(GMAIL_REFRESH_TOKEN, "r");
2471        let creds = load_credentials_with(&env).unwrap();
2472        assert_eq!(creds.client_id, "c");
2473        assert_eq!(creds.scope, GmailScope::ReadOnly);
2474    }
2475
2476    /// Save + remove round-trip against injected settings-file paths — no
2477    /// `HOME` mutation, so the test needs no lock.
2478    #[test]
2479    fn save_then_remove_round_trip() {
2480        // ── Part 1: creates file from scratch ──────────────────────
2481        {
2482            let temp_dir = {
2483                std::fs::create_dir_all("tmp").ok();
2484                tempfile::TempDir::new_in("tmp").unwrap()
2485            };
2486            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2487
2488            let creds = GmailCredentials {
2489                client_id: "client-1".to_string(),
2490                client_secret: "secret-1".into(),
2491                refresh_token: "refresh-1".into(),
2492                scope: GmailScope::ReadOnly,
2493            };
2494            save_credentials_to(&settings_path, None, &creds).unwrap();
2495
2496            assert!(settings_path.exists());
2497            let val: serde_json::Value =
2498                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2499            assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "client-1");
2500            assert_eq!(val["env"]["GMAIL_CLIENT_SECRET"], "secret-1");
2501            assert_eq!(val["env"]["GMAIL_REFRESH_TOKEN"], "refresh-1");
2502            assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_READONLY);
2503
2504            #[cfg(unix)]
2505            {
2506                use std::os::unix::fs::PermissionsExt;
2507                let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2508                assert_eq!(mode & 0o777, 0o600);
2509            }
2510        }
2511
2512        // ── Part 2: merges into existing settings ──────────────────
2513        {
2514            let temp_dir = {
2515                std::fs::create_dir_all("tmp").ok();
2516                tempfile::TempDir::new_in("tmp").unwrap()
2517            };
2518            let omni_dir = temp_dir.path().join(".omni-dev");
2519            fs::create_dir_all(&omni_dir).unwrap();
2520            let settings_path = omni_dir.join("settings.json");
2521            fs::write(
2522                &settings_path,
2523                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2524            )
2525            .unwrap();
2526
2527            let creds = GmailCredentials {
2528                client_id: "client-2".to_string(),
2529                client_secret: "secret-2".into(),
2530                refresh_token: "refresh-2".into(),
2531                scope: GmailScope::Modify,
2532            };
2533            save_credentials_to(&settings_path, None, &creds).unwrap();
2534
2535            let val: serde_json::Value =
2536                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2537            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2538            assert_eq!(val["extra"], true);
2539            assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_MODIFY);
2540        }
2541
2542        // ── Part 3: remove clears the four keys, preserves others ──
2543        {
2544            let temp_dir = {
2545                std::fs::create_dir_all("tmp").ok();
2546                tempfile::TempDir::new_in("tmp").unwrap()
2547            };
2548            let omni_dir = temp_dir.path().join(".omni-dev");
2549            fs::create_dir_all(&omni_dir).unwrap();
2550            let settings_path = omni_dir.join("settings.json");
2551            fs::write(
2552                &settings_path,
2553                r#"{"env": {
2554                    "GMAIL_CLIENT_ID": "a",
2555                    "GMAIL_CLIENT_SECRET": "b",
2556                    "GMAIL_REFRESH_TOKEN": "c",
2557                    "GMAIL_SCOPE": "d",
2558                    "OTHER_KEY": "keep"
2559                }}"#,
2560            )
2561            .unwrap();
2562
2563            let removed = remove_credentials_at(&settings_path, None).unwrap();
2564            assert!(removed);
2565
2566            let val: serde_json::Value =
2567                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2568            assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2569            assert!(val["env"].get("GMAIL_CLIENT_SECRET").is_none());
2570            assert!(val["env"].get("GMAIL_REFRESH_TOKEN").is_none());
2571            assert!(val["env"].get("GMAIL_SCOPE").is_none());
2572            assert_eq!(val["env"]["OTHER_KEY"], "keep");
2573        }
2574
2575        // ── Part 4: remove returns false when nothing to remove ────
2576        {
2577            let temp_dir = {
2578                std::fs::create_dir_all("tmp").ok();
2579                tempfile::TempDir::new_in("tmp").unwrap()
2580            };
2581            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2582            let removed = remove_credentials_at(&settings_path, None).unwrap();
2583            assert!(!removed);
2584        }
2585    }
2586
2587    /// Save + remove round-trip against a profile-targeted env map.
2588    #[test]
2589    fn save_then_remove_round_trip_in_profile() {
2590        let temp_dir = {
2591            std::fs::create_dir_all("tmp").ok();
2592            tempfile::TempDir::new_in("tmp").unwrap()
2593        };
2594        let omni_dir = temp_dir.path().join(".omni-dev");
2595        fs::create_dir_all(&omni_dir).unwrap();
2596        let settings_path = omni_dir.join("settings.json");
2597        fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
2598
2599        let creds = GmailCredentials {
2600            client_id: "client-p".to_string(),
2601            client_secret: "secret-p".into(),
2602            refresh_token: "refresh-p".into(),
2603            scope: GmailScope::ReadOnly,
2604        };
2605        save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
2606
2607        let val: serde_json::Value =
2608            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2609        assert_eq!(
2610            val["profiles"]["work"]["env"]["GMAIL_CLIENT_ID"],
2611            "client-p"
2612        );
2613        assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2614        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2615
2616        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2617        assert!(removed);
2618        let val: serde_json::Value =
2619            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2620        assert!(val["profiles"]["work"]["env"]
2621            .get("GMAIL_CLIENT_ID")
2622            .is_none());
2623
2624        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2625        assert!(!removed);
2626    }
2627
2628    /// The production wrappers resolve `~/.omni-dev/settings.json` from
2629    /// `HOME` and the active profile from `OMNI_DEV_PROFILE`, so this one
2630    /// test must redirect both via [`crate::gmail::test_support::EnvGuard`].
2631    #[test]
2632    fn save_and_remove_credentials_resolve_default_settings_path() {
2633        let guard = crate::gmail::test_support::EnvGuard::take();
2634        let dir = guard.clear_credentials();
2635
2636        let creds = GmailCredentials {
2637            client_id: "wrapper-client".to_string(),
2638            client_secret: "wrapper-secret".into(),
2639            refresh_token: "wrapper-refresh".into(),
2640            scope: GmailScope::ReadOnly,
2641        };
2642        save_credentials(&creds).unwrap();
2643
2644        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2645        let val: serde_json::Value =
2646            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2647        assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "wrapper-client");
2648
2649        assert!(remove_credentials().unwrap());
2650        let val: serde_json::Value =
2651            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2652        assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2653    }
2654
2655    // ── named-account dispatch (issue #1500) ───────────────────────────
2656    //
2657    // These exercise the production `*_for` wrappers, so — like
2658    // `save_and_remove_credentials_resolve_default_settings_path` above —
2659    // they must redirect `HOME` via `EnvGuard`.
2660
2661    #[test]
2662    fn load_credentials_for_named_reads_from_gmail_accounts() {
2663        let guard = crate::gmail::test_support::EnvGuard::take();
2664        let dir = guard.clear_credentials();
2665        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2666        Settings::upsert_gmail_account(
2667            &settings_path,
2668            "work",
2669            &[
2670                (
2671                    "client_id",
2672                    serde_json::Value::String("work-id".to_string()),
2673                ),
2674                (
2675                    "client_secret",
2676                    serde_json::Value::String("work-secret".to_string()),
2677                ),
2678                (
2679                    "refresh_token",
2680                    serde_json::Value::String("work-refresh".to_string()),
2681                ),
2682                ("scope", serde_json::Value::String(SCOPE_MODIFY.to_string())),
2683            ],
2684        )
2685        .unwrap();
2686
2687        let creds = load_credentials_for(Some("work")).unwrap();
2688        assert_eq!(creds.client_id, "work-id");
2689        assert_eq!(creds.client_secret.expose_secret(), "work-secret");
2690        assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
2691        assert_eq!(creds.scope, GmailScope::Modify);
2692    }
2693
2694    #[test]
2695    fn load_credentials_for_unknown_named_account_errors() {
2696        let guard = crate::gmail::test_support::EnvGuard::take();
2697        let dir = guard.clear_credentials();
2698        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2699        Settings::upsert_gmail_account(
2700            &settings_path,
2701            "work",
2702            &[(
2703                "client_id",
2704                serde_json::Value::String("work-id".to_string()),
2705            )],
2706        )
2707        .unwrap();
2708
2709        let err = load_credentials_for(Some("bogus")).unwrap_err();
2710        assert!(err.to_string().contains("unknown Gmail account 'bogus'"));
2711    }
2712
2713    #[test]
2714    fn load_credentials_for_falls_back_to_legacy_when_accounts_empty() {
2715        let guard = crate::gmail::test_support::EnvGuard::take();
2716        let _dir = guard.clear_credentials();
2717        std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2718        std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
2719        std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
2720
2721        let creds = load_credentials_for(None).unwrap();
2722        assert_eq!(creds.client_id, "legacy-id");
2723    }
2724
2725    #[test]
2726    fn load_credentials_for_none_honors_ambient_account_env_var() {
2727        let guard = crate::gmail::test_support::EnvGuard::take();
2728        let dir = guard.clear_credentials();
2729        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2730        Settings::upsert_gmail_account(
2731            &settings_path,
2732            "work",
2733            &[
2734                (
2735                    "client_id",
2736                    serde_json::Value::String("work-id".to_string()),
2737                ),
2738                (
2739                    "client_secret",
2740                    serde_json::Value::String("work-secret".to_string()),
2741                ),
2742                (
2743                    "refresh_token",
2744                    serde_json::Value::String("work-refresh".to_string()),
2745                ),
2746            ],
2747        )
2748        .unwrap();
2749        std::env::set_var(account::GMAIL_ACCOUNT_ENV, "work");
2750
2751        let creds = load_credentials_for(None).unwrap();
2752        assert_eq!(creds.client_id, "work-id");
2753    }
2754
2755    #[test]
2756    fn remove_credentials_for_named_removes_whole_account() {
2757        let guard = crate::gmail::test_support::EnvGuard::take();
2758        let dir = guard.clear_credentials();
2759        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2760        Settings::upsert_gmail_account(
2761            &settings_path,
2762            "work",
2763            &[("client_id", serde_json::Value::String("id".to_string()))],
2764        )
2765        .unwrap();
2766
2767        assert!(remove_credentials_for(Some("work")).unwrap());
2768        let val: serde_json::Value =
2769            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2770        assert!(val["gmail"]["accounts"].get("work").is_none());
2771    }
2772
2773    #[cfg(feature = "mcp")]
2774    #[test]
2775    fn status_for_named_reports_presence_from_account() {
2776        let guard = crate::gmail::test_support::EnvGuard::take();
2777        let dir = guard.clear_credentials();
2778        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2779        Settings::upsert_gmail_account(
2780            &settings_path,
2781            "work",
2782            &[
2783                ("client_id", serde_json::Value::String("id".to_string())),
2784                (
2785                    "scope",
2786                    serde_json::Value::String(SCOPE_READONLY.to_string()),
2787                ),
2788            ],
2789        )
2790        .unwrap();
2791
2792        let status = status_for(Some("work")).unwrap();
2793        assert!(status.has_client_id);
2794        assert!(!status.has_client_secret);
2795        assert!(!status.has_refresh_token);
2796        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2797    }
2798
2799    #[cfg(feature = "mcp")]
2800    #[test]
2801    fn status_for_legacy_matches_status_with_when_accounts_empty() {
2802        let guard = crate::gmail::test_support::EnvGuard::take();
2803        let _dir = guard.clear_credentials();
2804        std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2805
2806        let status = status_for(None).unwrap();
2807        assert!(status.has_client_id);
2808        assert!(!status.has_refresh_token);
2809    }
2810
2811    #[test]
2812    fn load_credentials_legacy_ignores_active_named_account() {
2813        let guard = crate::gmail::test_support::EnvGuard::take();
2814        let dir = guard.clear_credentials();
2815        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2816
2817        // A named default account is configured...
2818        Settings::set_gmail_default_account(&settings_path, Some("work")).unwrap();
2819        Settings::upsert_gmail_account(
2820            &settings_path,
2821            "work",
2822            &[
2823                (
2824                    "client_id",
2825                    serde_json::Value::String("named-id".to_string()),
2826                ),
2827                (
2828                    "client_secret",
2829                    serde_json::Value::String("named-secret".to_string()),
2830                ),
2831                (
2832                    "refresh_token",
2833                    serde_json::Value::String("named-refresh".to_string()),
2834                ),
2835            ],
2836        )
2837        .unwrap();
2838        // ...and legacy credentials also exist in the base `env` map (not
2839        // process env, so rule 1's literal-env bypass does not apply).
2840        Settings::upsert_env_vars(
2841            &settings_path,
2842            &[
2843                (GMAIL_CLIENT_ID, "legacy-id"),
2844                (GMAIL_CLIENT_SECRET, "legacy-secret"),
2845                (GMAIL_REFRESH_TOKEN, "legacy-refresh"),
2846            ],
2847        )
2848        .unwrap();
2849
2850        // The account-aware wrapper resolves to the named default...
2851        let via_resolution = load_credentials_for(None).unwrap();
2852        assert_eq!(via_resolution.client_id, "named-id");
2853
2854        // ...but load_credentials_legacy forces the legacy path regardless.
2855        let forced_legacy = load_credentials_legacy().unwrap();
2856        assert_eq!(forced_legacy.client_id, "legacy-id");
2857    }
2858
2859    #[test]
2860    fn record_account_email_writes_email_address_only() {
2861        let guard = crate::gmail::test_support::EnvGuard::take();
2862        let dir = guard.clear_credentials();
2863        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2864        Settings::upsert_gmail_account(
2865            &settings_path,
2866            "work",
2867            &[("client_id", serde_json::Value::String("id".to_string()))],
2868        )
2869        .unwrap();
2870
2871        record_account_email("work", "alice@work.com").unwrap();
2872
2873        let val: serde_json::Value =
2874            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2875        assert_eq!(
2876            val["gmail"]["accounts"]["work"]["email_address"],
2877            "alice@work.com"
2878        );
2879        assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
2880    }
2881
2882    #[test]
2883    fn record_account_email_does_not_overwrite_an_existing_value() {
2884        let guard = crate::gmail::test_support::EnvGuard::take();
2885        let dir = guard.clear_credentials();
2886        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2887        Settings::upsert_gmail_account(
2888            &settings_path,
2889            "work",
2890            &[(
2891                "email_address",
2892                serde_json::Value::String("manually-set@work.com".to_string()),
2893            )],
2894        )
2895        .unwrap();
2896
2897        record_account_email("work", "alice@work.com").unwrap();
2898
2899        let val: serde_json::Value =
2900            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2901        assert_eq!(
2902            val["gmail"]["accounts"]["work"]["email_address"],
2903            "manually-set@work.com"
2904        );
2905    }
2906
2907    /// The zero-migration guarantee (issue #1500): with `gmail.accounts`
2908    /// empty, the account-aware `_for(None, ...)` wrappers must behave
2909    /// byte-identically to the pre-#1500 wrappers they sit beside. Both
2910    /// sandboxes are seeded via the same unchanged [`save_credentials`] (no
2911    /// account-aware save wrapper exists — `login_for`/
2912    /// `import_client_credentials_for` persist directly, since a
2913    /// resolve-then-save round trip would reject the very name being
2914    /// created), so this isolates `load`/`remove`.
2915    #[test]
2916    fn zero_migration_load_remove_byte_identical_when_accounts_empty() {
2917        let guard = crate::gmail::test_support::EnvGuard::take();
2918        let creds = GmailCredentials {
2919            client_id: "id".to_string(),
2920            client_secret: "secret".into(),
2921            refresh_token: "refresh".into(),
2922            scope: GmailScope::Modify,
2923        };
2924
2925        let dir_legacy = guard.clear_credentials();
2926        save_credentials(&creds).unwrap();
2927        let legacy_written =
2928            fs::read_to_string(dir_legacy.path().join(".omni-dev").join("settings.json")).unwrap();
2929        let legacy_loaded = load_credentials().unwrap();
2930        let legacy_removed = remove_credentials().unwrap();
2931
2932        let dir_for = guard.clear_credentials();
2933        save_credentials(&creds).unwrap();
2934        let for_written =
2935            fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
2936        let for_loaded = load_credentials_for(None).unwrap();
2937        let for_removed = remove_credentials_for(None).unwrap();
2938
2939        assert_eq!(legacy_written, for_written);
2940        assert_eq!(legacy_loaded.client_id, for_loaded.client_id);
2941        assert_eq!(
2942            legacy_loaded.client_secret.expose_secret(),
2943            for_loaded.client_secret.expose_secret()
2944        );
2945        assert_eq!(legacy_loaded.scope, for_loaded.scope);
2946        assert_eq!(legacy_removed, for_removed);
2947    }
2948}