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