1use 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
36pub const GMAIL_CLIENT_ID: &str = "GMAIL_CLIENT_ID";
39pub const GMAIL_CLIENT_SECRET: &str = "GMAIL_CLIENT_SECRET";
42pub const GMAIL_REFRESH_TOKEN: &str = "GMAIL_REFRESH_TOKEN";
44pub const GMAIL_SCOPE: &str = "GMAIL_SCOPE";
46pub const GMAIL_API_URL: &str = "GMAIL_API_URL";
58
59const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
61const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
63pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/gmail.readonly";
65pub const SCOPE_MODIFY: &str = "https://www.googleapis.com/auth/gmail.modify";
67
68const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
70const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
73const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum GmailScope {
83 #[default]
86 ReadOnly,
87 Modify,
90}
91
92impl GmailScope {
93 #[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 #[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 #[must_use]
124 pub fn allows_modify(self) -> bool {
125 matches!(self, Self::Modify)
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct GmailCredentials {
132 pub client_id: String,
135 pub client_secret: Secret,
137 pub refresh_token: Secret,
139 pub scope: GmailScope,
141}
142
143#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
145pub struct GmailAuthStatus {
146 pub has_client_id: bool,
148 pub has_client_secret: bool,
150 pub has_refresh_token: bool,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub scope: Option<String>,
155}
156
157pub(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
167pub(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
179fn 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
188pub(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
208fn 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
259pub fn load_credentials() -> Result<GmailCredentials> {
263 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
264}
265
266pub(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
286fn 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
329fn 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
351pub(crate) fn load_credentials_legacy() -> Result<GmailCredentials> {
356 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
357}
358
359pub(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 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
393pub fn status() -> GmailAuthStatus {
398 status_with(&crate::utils::settings::SettingsEnv::load())
399}
400
401pub(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#[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#[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
461pub(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
486pub 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
498pub(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
523fn 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
547pub fn remove_credentials() -> Result<bool> {
553 remove_credentials_at(
554 &Settings::get_settings_path()?,
555 active_profile_from(&SystemEnv).as_deref(),
556 )
557}
558
559pub(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
574pub(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#[derive(Clone, Debug, Default)]
600pub enum BrowserLaunch {
601 #[default]
603 Auto,
604 Command(Vec<String>),
608 Manual,
611}
612
613#[derive(Clone, Debug)]
615pub struct BrowserConfig {
616 pub launch: BrowserLaunch,
618 pub callback_addr: IpAddr,
620 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#[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
678fn 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
689struct 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
705fn 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 .append_pair("prompt", "consent");
734 Ok(url)
735}
736
737#[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
749pub(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
766pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
769 wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
770}
771
772pub(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
810fn parse_callback(request: &str) -> Option<CallbackResult> {
813 let first_line = request.lines().next()?;
814 let path = first_line.split_whitespace().nth(1)?; 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#[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, ¶ms, 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, ¶ms, GrantContext::Refresh).await
888}
889
890async 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
927struct TokenState {
931 access_token: Secret,
932 expires_at: DateTime<Utc>,
933}
934
935pub 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 pub(crate) fn new(http: reqwest::Client, credentials: &GmailCredentials) -> Self {
959 Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
960 }
961
962 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 expires_at: DateTime::<Utc>::MIN_UTC,
981 }),
982 }
983 }
984
985 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 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
1024pub 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
1046pub(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 let credentials =
1058 run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1059 save_credentials_to(settings_path, profile, &credentials)?;
1060 Ok(status_from_credentials(&credentials))
1061}
1062
1063pub(crate) async fn login_for(
1071 explicit: Option<&str>,
1072 client_id: &str,
1073 client_secret: &Secret,
1074 scope: GmailScope,
1075 browser: &BrowserConfig,
1076) -> Result<GmailAuthStatus> {
1077 let settings = Settings::load_or_warn_default();
1078 match resolve_for_write(&settings.gmail, explicit)? {
1079 ResolvedAccount::Legacy => {
1080 login_to(
1081 &Settings::get_settings_path()?,
1082 active_profile_from(&SystemEnv).as_deref(),
1083 client_id,
1084 client_secret,
1085 scope,
1086 browser,
1087 TOKEN_ENDPOINT,
1088 )
1089 .await
1090 }
1091 ResolvedAccount::Named(name) => {
1092 let credentials =
1093 run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1094 Settings::upsert_gmail_account(
1095 &Settings::get_settings_path()?,
1096 &name,
1097 &named_account_vars(&credentials),
1098 )?;
1099 Ok(status_from_credentials(&credentials))
1100 }
1101 }
1102}
1103
1104async fn run_login_flow(
1109 client_id: &str,
1110 client_secret: &Secret,
1111 scope: GmailScope,
1112 browser: &BrowserConfig,
1113 token_endpoint: &str,
1114) -> Result<GmailCredentials> {
1115 let (listener, port) = bind_callback_listener(browser).await?;
1116 let redirect_uri = format!("http://127.0.0.1:{port}");
1117
1118 let pending = generate_pending_login();
1119 let challenge = code_challenge(&pending.code_verifier);
1120 let auth_url =
1121 build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1122 open_browser(&browser.launch, auth_url.as_str())?;
1123
1124 let callback = wait_for_callback(listener).await?;
1125 if let Some(error) = callback.error {
1126 return Err(GmailError::authorization_denied(
1127 &error,
1128 callback.error_description.as_deref(),
1129 )
1130 .into());
1131 }
1132 let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1133 return Err(GmailError::MalformedCallback.into());
1134 };
1135 if returned_state != pending.state {
1140 return Err(GmailError::StateMismatch.into());
1141 }
1142
1143 let http = reqwest::Client::builder()
1144 .connect_timeout(crate::utils::http::connect_timeout())
1145 .read_timeout(crate::utils::http::read_timeout())
1146 .build()
1147 .context("Failed to build HTTP client")?;
1148 let tokens = exchange_code_for_tokens(
1149 &http,
1150 token_endpoint,
1151 client_id,
1152 client_secret.expose_secret(),
1153 &code,
1154 &pending.code_verifier,
1155 &redirect_uri,
1156 )
1157 .await?;
1158 let refresh_token = tokens
1159 .refresh_token
1160 .ok_or(GmailError::MalformedTokenResponse("refresh_token"))?;
1161 let granted_raw = tokens.scope.unwrap_or_default();
1162 let granted_scope = GmailScope::from_granted(&granted_raw).ok_or_else(|| {
1163 let received = if granted_raw.trim().is_empty() {
1164 "none".to_string()
1165 } else {
1166 granted_raw
1167 .split_whitespace()
1168 .collect::<Vec<_>>()
1169 .join(", ")
1170 };
1171 GmailError::NoGmailScopeGranted(received)
1172 })?;
1173
1174 Ok(GmailCredentials {
1175 client_id: client_id.to_string(),
1176 client_secret: client_secret.clone(),
1177 refresh_token: refresh_token.into(),
1178 scope: granted_scope,
1179 })
1180}
1181
1182fn status_from_credentials(credentials: &GmailCredentials) -> GmailAuthStatus {
1185 GmailAuthStatus {
1186 has_client_id: true,
1187 has_client_secret: true,
1188 has_refresh_token: true,
1189 scope: Some(credentials.scope.as_str().to_string()),
1190 }
1191}
1192
1193#[cfg(test)]
1194#[allow(clippy::unwrap_used, clippy::expect_used)]
1195mod tests {
1196 use std::fs;
1197 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1198 use std::sync::Arc;
1199
1200 use super::*;
1201
1202 #[test]
1205 fn scope_as_str_matches_google_scope_strings() {
1206 assert_eq!(GmailScope::ReadOnly.as_str(), SCOPE_READONLY);
1207 assert_eq!(GmailScope::Modify.as_str(), SCOPE_MODIFY);
1208 }
1209
1210 #[test]
1211 fn scope_from_granted_detects_modify_anywhere_in_the_list() {
1212 assert_eq!(
1213 GmailScope::from_granted(&format!("{SCOPE_READONLY} {SCOPE_MODIFY}")),
1214 Some(GmailScope::Modify)
1215 );
1216 }
1217
1218 #[test]
1219 fn scope_from_granted_detects_readonly_with_no_modify() {
1220 assert_eq!(
1221 GmailScope::from_granted(SCOPE_READONLY),
1222 Some(GmailScope::ReadOnly)
1223 );
1224 }
1225
1226 #[test]
1227 fn scope_from_granted_is_none_when_no_gmail_scope_present() {
1228 assert_eq!(GmailScope::from_granted("openid email profile"), None);
1229 }
1230
1231 #[test]
1232 fn scope_from_granted_is_none_for_empty_string() {
1233 assert_eq!(GmailScope::from_granted(""), None);
1234 }
1235
1236 #[test]
1237 fn scope_allows_modify() {
1238 assert!(!GmailScope::ReadOnly.allows_modify());
1239 assert!(GmailScope::Modify.allows_modify());
1240 }
1241
1242 #[test]
1243 fn code_challenge_matches_rfc_7636_test_vector() {
1244 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1246 let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1247 assert_eq!(code_challenge(verifier), expected);
1248 }
1249
1250 #[test]
1251 fn code_challenge_output_is_url_safe_no_padding() {
1252 let challenge = code_challenge("some-verifier-value");
1253 assert!(!challenge.contains('+'));
1254 assert!(!challenge.contains('/'));
1255 assert!(!challenge.contains('='));
1256 }
1257
1258 #[test]
1259 fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1260 let pending = generate_pending_login();
1261 assert_ne!(pending.state, pending.code_verifier);
1262 assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1263 assert!(pending
1264 .code_verifier
1265 .chars()
1266 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1267 }
1268
1269 #[test]
1270 fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1271 let url = build_authorization_url(
1272 "client-123",
1273 "http://127.0.0.1:5555",
1274 GmailScope::ReadOnly,
1275 "state-abc",
1276 "challenge-xyz",
1277 )
1278 .unwrap();
1279 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1280 assert_eq!(query.get("client_id").unwrap(), "client-123");
1281 assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1282 assert_eq!(query.get("response_type").unwrap(), "code");
1283 assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1284 assert_eq!(query.get("state").unwrap(), "state-abc");
1285 assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1286 assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1287 assert_eq!(query.get("access_type").unwrap(), "offline");
1288 assert_eq!(query.get("prompt").unwrap(), "consent");
1289 }
1290
1291 #[test]
1292 fn build_authorization_url_uses_modify_scope_when_requested() {
1293 let url = build_authorization_url("c", "http://127.0.0.1:1", GmailScope::Modify, "s", "ch")
1294 .unwrap();
1295 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1296 assert_eq!(query.get("scope").unwrap(), SCOPE_MODIFY);
1297 }
1298
1299 #[test]
1300 fn parse_callback_extracts_code_and_state() {
1301 let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1302 let result = parse_callback(request).unwrap();
1303 assert_eq!(result.code.as_deref(), Some("abc123"));
1304 assert_eq!(result.state.as_deref(), Some("xyz789"));
1305 assert!(result.error.is_none());
1306 }
1307
1308 #[test]
1309 fn parse_callback_extracts_error_and_error_description() {
1310 let request =
1311 "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1312 let result = parse_callback(request).unwrap();
1313 assert_eq!(result.error.as_deref(), Some("access_denied"));
1314 assert_eq!(result.error_description.as_deref(), Some("user declined"));
1315 }
1316
1317 #[test]
1318 fn parse_callback_missing_query_string_is_none() {
1319 assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1320 assert!(parse_callback("garbage").is_none());
1321 }
1322
1323 #[test]
1324 fn parse_callback_ignores_unrecognized_query_keys() {
1325 let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1326 let result = parse_callback(request).unwrap();
1327 assert_eq!(result.code.as_deref(), Some("abc"));
1328 assert_eq!(result.state.as_deref(), Some("xyz"));
1329 }
1330
1331 #[test]
1332 fn open_browser_manual_logs_and_succeeds() {
1333 assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1334 }
1335
1336 #[test]
1337 fn open_browser_command_substitutes_url_placeholder() {
1338 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1339 assert!(open_browser(&launch, "https://example/auth").is_ok());
1340 }
1341
1342 #[test]
1343 fn open_browser_command_appends_url_when_no_placeholder() {
1344 let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1345 assert!(open_browser(&launch, "https://example/auth").is_ok());
1346 }
1347
1348 #[test]
1349 fn open_browser_command_passes_through_args_without_the_placeholder() {
1350 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1354 assert!(open_browser(&launch, "https://example/auth").is_ok());
1355 }
1356
1357 #[test]
1358 fn open_browser_command_rejects_empty_args() {
1359 let launch = BrowserLaunch::Command(vec![]);
1360 let err = open_browser(&launch, "u").unwrap_err();
1361 assert!(err.to_string().contains("empty browser command"));
1362 }
1363
1364 fn assert_is_auto(config: BrowserConfig) {
1367 assert!(matches!(config.launch, BrowserLaunch::Auto));
1368 }
1369
1370 #[test]
1371 fn build_browser_config_defaults_to_auto_with_no_account() {
1372 assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1373 }
1374
1375 #[test]
1376 fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1377 let account = GmailAccountSettings {
1378 email_address: Some("alice@example.com".to_string()),
1379 ..GmailAccountSettings::default()
1380 };
1381 assert_is_auto(
1384 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1385 );
1386 }
1387
1388 #[test]
1389 fn build_browser_config_uses_browser_command_verbatim() {
1390 let account = GmailAccountSettings {
1391 browser_command: Some("chrome --new-window {url}".to_string()),
1392 ..GmailAccountSettings::default()
1393 };
1394 let config =
1395 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1396 assert!(matches!(
1397 config.launch,
1398 BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1399 ));
1400 }
1401
1402 #[test]
1403 fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1404 let account = GmailAccountSettings {
1405 browser_command: Some("chrome {url}".to_string()),
1406 chrome_profile_from_email: true,
1407 email_address: Some("alice@example.com".to_string()),
1408 ..GmailAccountSettings::default()
1409 };
1410 let config =
1411 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1412 assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1413 }
1414
1415 #[test]
1416 fn build_browser_config_rejects_a_malformed_browser_command() {
1417 let account = GmailAccountSettings {
1418 browser_command: Some("chrome \"--flag".to_string()),
1419 ..GmailAccountSettings::default()
1420 };
1421 let err =
1422 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1423 assert!(err.to_string().contains("browser_command"));
1424 }
1425
1426 #[test]
1427 fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1428 let account = GmailAccountSettings {
1429 chrome_profile_from_email: true,
1430 email_address: Some("alice@example.com".to_string()),
1431 ..GmailAccountSettings::default()
1432 };
1433 let config = build_browser_config(Some(&account), |email| {
1434 assert_eq!(email, "alice@example.com");
1435 Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1436 })
1437 .unwrap();
1438 assert!(matches!(
1439 config.launch,
1440 BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1441 ));
1442 }
1443
1444 #[test]
1445 fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1446 let account = GmailAccountSettings {
1447 chrome_profile_from_email: true,
1448 email_address: Some("alice@example.com".to_string()),
1449 ..GmailAccountSettings::default()
1450 };
1451 assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1452 }
1453
1454 #[test]
1455 fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1456 let account = GmailAccountSettings {
1457 chrome_profile_from_email: true,
1458 ..GmailAccountSettings::default()
1459 };
1460 assert_is_auto(
1461 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1462 );
1463 }
1464
1465 #[test]
1468 fn resolve_browser_config_for_legacy_account_defaults_to_auto() {
1469 let guard = crate::gmail::test_support::EnvGuard::take();
1470 let _dir = guard.clear_credentials();
1471 std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
1472 std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
1473 std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
1474
1475 let gmail = GmailSettings::default();
1476 assert_is_auto(resolve_browser_config_for(&gmail, None).unwrap());
1477 }
1478
1479 #[test]
1480 fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1481 let guard = crate::gmail::test_support::EnvGuard::take();
1482 let _dir = guard.clear_credentials();
1483
1484 let mut gmail = GmailSettings::default();
1485 gmail.accounts.insert(
1486 "work".to_string(),
1487 GmailAccountSettings {
1488 email_address: Some("alice@example.com".to_string()),
1489 ..GmailAccountSettings::default()
1490 },
1491 );
1492
1493 assert_is_auto(resolve_browser_config_for(&gmail, Some("work")).unwrap());
1496 }
1497
1498 #[tokio::test]
1501 async fn wait_for_callback_times_out_when_nothing_connects() {
1502 let browser = BrowserConfig::default();
1503 let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1504 let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1505 .await
1506 .unwrap_err();
1507 assert!(matches!(
1508 err.downcast_ref::<GmailError>(),
1509 Some(GmailError::CallbackTimeout(_))
1510 ));
1511 }
1512
1513 #[tokio::test]
1514 async fn wait_for_callback_reads_a_real_connection() {
1515 let browser = BrowserConfig::default();
1516 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1517
1518 let client = tokio::spawn(async move {
1519 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1520 .await
1521 .unwrap();
1522 stream
1523 .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1524 .await
1525 .unwrap();
1526 });
1527
1528 let result = wait_for_callback(listener).await.unwrap();
1529 client.await.unwrap();
1530 assert_eq!(result.code.as_deref(), Some("abc"));
1531 assert_eq!(result.state.as_deref(), Some("xyz"));
1532 }
1533
1534 #[tokio::test]
1535 async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1536 let browser = BrowserConfig::default();
1537 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1538
1539 let client = tokio::spawn(async move {
1540 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1541 .await
1542 .unwrap();
1543 stream.write_all(b"not an http request").await.unwrap();
1544 });
1545
1546 let err = wait_for_callback(listener).await.unwrap_err();
1547 client.await.unwrap();
1548 assert!(matches!(
1549 err.downcast_ref::<GmailError>(),
1550 Some(GmailError::MalformedCallback)
1551 ));
1552 }
1553
1554 #[tokio::test]
1557 async fn exchange_code_for_tokens_posts_expected_form_body() {
1558 let server = wiremock::MockServer::start().await;
1559 wiremock::Mock::given(wiremock::matchers::method("POST"))
1560 .and(wiremock::matchers::path("/token"))
1561 .and(wiremock::matchers::body_string_contains(
1562 "grant_type=authorization_code",
1563 ))
1564 .and(wiremock::matchers::body_string_contains(
1565 "code_verifier=verifier-1",
1566 ))
1567 .and(wiremock::matchers::body_string_contains(
1568 "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1569 ))
1570 .respond_with(
1571 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1572 "access_token": "at-1",
1573 "refresh_token": "rt-1",
1574 "expires_in": 3600,
1575 "scope": SCOPE_READONLY,
1576 })),
1577 )
1578 .expect(1)
1579 .mount(&server)
1580 .await;
1581
1582 let http = reqwest::Client::new();
1583 let token_endpoint = format!("{}/token", server.uri());
1584 let response = exchange_code_for_tokens(
1585 &http,
1586 &token_endpoint,
1587 "client-1",
1588 "secret-1",
1589 "code-1",
1590 "verifier-1",
1591 "http://127.0.0.1:9999",
1592 )
1593 .await
1594 .unwrap();
1595 assert_eq!(response.access_token, "at-1");
1596 assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1597 }
1598
1599 #[tokio::test]
1600 async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1601 let server = wiremock::MockServer::start().await;
1602 wiremock::Mock::given(wiremock::matchers::method("POST"))
1603 .respond_with(
1604 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1605 "error": "invalid_grant",
1606 "error_description": "Bad Request",
1607 })),
1608 )
1609 .mount(&server)
1610 .await;
1611
1612 let http = reqwest::Client::new();
1613 let err = exchange_code_for_tokens(
1614 &http,
1615 &server.uri(),
1616 "c",
1617 "s",
1618 "code",
1619 "verifier",
1620 "http://127.0.0.1:1",
1621 )
1622 .await
1623 .unwrap_err();
1624 assert!(err.to_string().contains("PKCE"));
1625 }
1626
1627 async fn connect_and_send(port: u16, request_line: &[u8]) {
1646 let mut stream = loop {
1647 match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1648 Ok(stream) => break stream,
1649 Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1650 }
1651 };
1652 stream.write_all(request_line).await.unwrap();
1653 }
1654
1655 fn reserve_free_port() -> u16 {
1656 std::net::TcpListener::bind("127.0.0.1:0")
1657 .unwrap()
1658 .local_addr()
1659 .unwrap()
1660 .port()
1661 }
1662
1663 fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1667 err.to_string()
1668 .contains("Failed to start the local OAuth callback listener")
1669 }
1670
1671 const PORT_RETRY_ATTEMPTS: u32 = 5;
1672
1673 async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<GmailAuthStatus>
1678 where
1679 F: FnMut(u16) -> Fut,
1680 Fut: std::future::Future<Output = Result<GmailAuthStatus>>,
1681 {
1682 for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1683 let result = attempt(reserve_free_port()).await;
1684 let is_retryable_conflict =
1685 matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1686 if !is_retryable_conflict {
1687 return result;
1688 }
1689 }
1690 unreachable!("loop always returns on its last iteration")
1691 }
1692
1693 async fn finish_connector(
1698 connector: tokio::task::JoinHandle<()>,
1699 result: &Result<GmailAuthStatus>,
1700 ) {
1701 match result {
1702 Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1703 _ => connector.await.unwrap(),
1704 }
1705 }
1706
1707 async fn wait_for_captured_url(path: &Path) -> String {
1711 loop {
1712 if let Ok(contents) = std::fs::read_to_string(path) {
1713 if !contents.is_empty() {
1714 return contents;
1715 }
1716 }
1717 tokio::time::sleep(Duration::from_millis(2)).await;
1718 }
1719 }
1720
1721 async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
1728 std::fs::create_dir_all("tmp").ok();
1729 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1730 let settings_path = temp_dir.path().join("settings.json");
1731
1732 let result = run_with_port_retry(|port| {
1733 let settings_path = settings_path.clone();
1734 async move {
1735 let browser = BrowserConfig {
1736 launch: BrowserLaunch::Manual,
1737 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1738 callback_port: port,
1739 };
1740 let connector = tokio::spawn(connect_and_send(port, request_line));
1741
1742 let result = login_to(
1743 &settings_path,
1744 None,
1745 "client-id",
1746 &Secret::new("client-secret"),
1747 GmailScope::ReadOnly,
1748 &browser,
1749 "http://127.0.0.1:1/token", )
1751 .await;
1752
1753 finish_connector(connector, &result).await;
1754 result
1755 }
1756 })
1757 .await;
1758
1759 let err = result.unwrap_err();
1760 assert!(!settings_path.exists());
1761 err
1762 }
1763
1764 #[tokio::test]
1765 async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
1766 let attempts = AtomicU32::new(0);
1767
1768 let status = run_with_port_retry(|_port| {
1769 let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
1770 async move {
1771 if attempt_no == 0 {
1772 Err(anyhow::anyhow!(
1773 "Failed to start the local OAuth callback listener: address in use"
1774 ))
1775 } else {
1776 Ok(GmailAuthStatus {
1777 has_client_id: true,
1778 has_client_secret: true,
1779 has_refresh_token: true,
1780 scope: None,
1781 })
1782 }
1783 }
1784 })
1785 .await
1786 .unwrap();
1787
1788 assert_eq!(attempts.load(Ordering::SeqCst), 2);
1789 assert!(status.has_client_id);
1790 }
1791
1792 #[tokio::test]
1793 async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
1794 let attempts = AtomicU32::new(0);
1795
1796 let result = run_with_port_retry(|_port| {
1797 attempts.fetch_add(1, Ordering::SeqCst);
1798 async move { Err(anyhow::anyhow!("some other failure")) }
1799 })
1800 .await;
1801
1802 assert_eq!(attempts.load(Ordering::SeqCst), 1);
1803 assert!(result.is_err());
1804 }
1805
1806 #[tokio::test]
1807 async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
1808 let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
1809 let connector = tokio::spawn(async move {
1810 tokio::time::sleep(Duration::from_secs(3600)).await;
1811 let _ = tx.send(());
1812 });
1813 let result: Result<GmailAuthStatus> = Err(anyhow::anyhow!(
1814 "Failed to start the local OAuth callback listener: address in use"
1815 ));
1816
1817 tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
1818 .await
1819 .expect("finish_connector must not wait for an aborted connector");
1820
1821 assert!(
1822 rx.try_recv().is_err(),
1823 "connector must have been aborted, not run to completion"
1824 );
1825 }
1826
1827 #[tokio::test]
1828 async fn finish_connector_awaits_connector_when_login_to_succeeds() {
1829 let ran = Arc::new(AtomicBool::new(false));
1830 let ran_clone = ran.clone();
1831 let connector = tokio::spawn(async move {
1832 ran_clone.store(true, Ordering::SeqCst);
1833 });
1834 let result = Ok(GmailAuthStatus {
1835 has_client_id: true,
1836 has_client_secret: true,
1837 has_refresh_token: true,
1838 scope: None,
1839 });
1840
1841 finish_connector(connector, &result).await;
1842
1843 assert!(ran.load(Ordering::SeqCst));
1844 }
1845
1846 #[tokio::test]
1847 async fn wait_for_captured_url_polls_until_content_is_written() {
1848 std::fs::create_dir_all("tmp").ok();
1849 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1850 let path = temp_dir.path().join("captured-url.txt");
1851
1852 let write_path = path.clone();
1853 tokio::spawn(async move {
1854 tokio::time::sleep(Duration::from_millis(10)).await;
1855 std::fs::write(&write_path, "").unwrap();
1856 tokio::time::sleep(Duration::from_millis(10)).await;
1857 std::fs::write(&write_path, "https://example.com/authorize").unwrap();
1858 });
1859
1860 let contents = wait_for_captured_url(&path).await;
1861 assert_eq!(contents, "https://example.com/authorize");
1862 }
1863
1864 #[tokio::test]
1865 async fn login_to_rejects_a_callback_with_mismatched_state() {
1866 let err =
1867 run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
1868
1869 assert!(matches!(
1870 err.downcast_ref::<GmailError>(),
1871 Some(GmailError::StateMismatch)
1872 ));
1873 }
1874
1875 #[tokio::test]
1876 async fn login_to_surfaces_access_denied_from_the_callback() {
1877 let err = run_login_to_expect_err(
1878 b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
1879 )
1880 .await;
1881
1882 match err.downcast_ref::<GmailError>() {
1883 Some(GmailError::AuthorizationDenied(message)) => {
1884 assert!(message.contains("access_denied"));
1885 assert!(message.contains("user declined"));
1886 }
1887 other => panic!("expected AuthorizationDenied, got {other:?}"),
1888 }
1889 }
1890
1891 #[tokio::test]
1892 async fn login_to_rejects_a_callback_missing_code_and_state() {
1893 let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
1894
1895 assert!(matches!(
1896 err.downcast_ref::<GmailError>(),
1897 Some(GmailError::MalformedCallback)
1898 ));
1899 }
1900
1901 #[tokio::test]
1902 async fn login_to_completes_full_success_flow_and_persists_credentials() {
1903 std::fs::create_dir_all("tmp").ok();
1912 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1913 let capture_path = temp_dir.path().join("captured-url.txt");
1914 let settings_path = temp_dir.path().join("settings.json");
1915
1916 let server = wiremock::MockServer::start().await;
1917 wiremock::Mock::given(wiremock::matchers::method("POST"))
1918 .and(wiremock::matchers::path("/token"))
1919 .respond_with(
1920 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1921 "access_token": "at-1",
1922 "refresh_token": "rt-1",
1923 "expires_in": 3600,
1924 "scope": SCOPE_READONLY,
1925 })),
1926 )
1927 .expect(1)
1928 .mount(&server)
1929 .await;
1930
1931 let status = run_with_port_retry(|port| {
1932 let capture_path = capture_path.clone();
1933 let settings_path = settings_path.clone();
1934 let token_endpoint = format!("{}/token", server.uri());
1935 async move {
1936 let browser = BrowserConfig {
1937 launch: BrowserLaunch::Command(vec![
1938 "/bin/sh".to_string(),
1939 "-c".to_string(),
1940 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
1941 "{url}".to_string(),
1942 ]),
1943 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1944 callback_port: port,
1945 };
1946
1947 let connector = tokio::spawn(async move {
1948 let auth_url = wait_for_captured_url(&capture_path).await;
1949 let parsed = Url::parse(&auth_url).unwrap();
1950 let state = parsed
1951 .query_pairs()
1952 .find(|(k, _)| k == "state")
1953 .map(|(_, v)| v.into_owned())
1954 .expect("authorization URL must carry a state param");
1955 connect_and_send(
1956 port,
1957 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
1958 )
1959 .await;
1960 });
1961
1962 let result = login_to(
1963 &settings_path,
1964 None,
1965 "client-id",
1966 &Secret::new("client-secret"),
1967 GmailScope::ReadOnly,
1968 &browser,
1969 &token_endpoint,
1970 )
1971 .await;
1972
1973 finish_connector(connector, &result).await;
1974 result
1975 }
1976 })
1977 .await
1978 .unwrap();
1979
1980 assert!(status.has_client_id);
1981 assert!(status.has_client_secret);
1982 assert!(status.has_refresh_token);
1983 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
1984
1985 let saved = std::fs::read_to_string(&settings_path).unwrap();
1986 assert!(saved.contains("rt-1"));
1987 assert!(saved.contains("client-id"));
1988 }
1989
1990 async fn run_login_to_with_token_response(
1997 token_response_body: serde_json::Value,
1998 ) -> (Result<GmailAuthStatus>, std::path::PathBuf) {
1999 std::fs::create_dir_all("tmp").ok();
2000 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2001 let capture_path = temp_dir.path().join("captured-url.txt");
2002 let settings_path = temp_dir.path().join("settings.json");
2003
2004 let server = wiremock::MockServer::start().await;
2005 wiremock::Mock::given(wiremock::matchers::method("POST"))
2006 .and(wiremock::matchers::path("/token"))
2007 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
2008 .expect(1)
2009 .mount(&server)
2010 .await;
2011
2012 let result = run_with_port_retry(|port| {
2013 let capture_path = capture_path.clone();
2014 let settings_path = settings_path.clone();
2015 let token_endpoint = format!("{}/token", server.uri());
2016 async move {
2017 let browser = BrowserConfig {
2018 launch: BrowserLaunch::Command(vec![
2019 "/bin/sh".to_string(),
2020 "-c".to_string(),
2021 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2022 "{url}".to_string(),
2023 ]),
2024 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2025 callback_port: port,
2026 };
2027
2028 let connector = tokio::spawn(async move {
2029 let auth_url = wait_for_captured_url(&capture_path).await;
2030 let parsed = Url::parse(&auth_url).unwrap();
2031 let state = parsed
2032 .query_pairs()
2033 .find(|(k, _)| k == "state")
2034 .map(|(_, v)| v.into_owned())
2035 .expect("authorization URL must carry a state param");
2036 connect_and_send(
2037 port,
2038 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2039 )
2040 .await;
2041 });
2042
2043 let result = login_to(
2044 &settings_path,
2045 None,
2046 "client-id",
2047 &Secret::new("client-secret"),
2048 GmailScope::ReadOnly,
2049 &browser,
2050 &token_endpoint,
2051 )
2052 .await;
2053
2054 finish_connector(connector, &result).await;
2055 result
2056 }
2057 })
2058 .await;
2059
2060 (result, settings_path)
2061 }
2062
2063 #[tokio::test]
2064 async fn login_to_rejects_a_grant_with_no_gmail_scope() {
2065 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2066 "access_token": "at-1",
2067 "refresh_token": "rt-1",
2068 "expires_in": 3600,
2069 "scope": "openid email profile",
2070 }))
2071 .await;
2072
2073 let err = result.unwrap_err();
2074 assert!(matches!(
2075 err.downcast_ref::<GmailError>(),
2076 Some(GmailError::NoGmailScopeGranted(received)) if received == "openid, email, profile"
2077 ));
2078 assert!(!settings_path.exists());
2079 }
2080
2081 #[tokio::test]
2082 async fn login_to_rejects_a_grant_with_missing_scope_field() {
2083 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2084 "access_token": "at-1",
2085 "refresh_token": "rt-1",
2086 "expires_in": 3600,
2087 }))
2088 .await;
2089
2090 let err = result.unwrap_err();
2091 assert!(matches!(
2092 err.downcast_ref::<GmailError>(),
2093 Some(GmailError::NoGmailScopeGranted(received)) if received == "none"
2094 ));
2095 assert!(!settings_path.exists());
2096 }
2097
2098 #[tokio::test]
2099 async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2100 let server = wiremock::MockServer::start().await;
2101 wiremock::Mock::given(wiremock::matchers::method("POST"))
2102 .and(wiremock::matchers::body_string_contains(
2103 "grant_type=refresh_token",
2104 ))
2105 .respond_with(
2106 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2107 "access_token": "at-2",
2108 "expires_in": 1800,
2109 })),
2110 )
2111 .expect(1)
2112 .mount(&server)
2113 .await;
2114
2115 let http = reqwest::Client::new();
2116 let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2117 .await
2118 .unwrap();
2119 assert_eq!(response.access_token, "at-2");
2120 assert_eq!(response.expires_in, 1800);
2121 }
2122
2123 #[tokio::test]
2124 async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2125 let server = wiremock::MockServer::start().await;
2126 wiremock::Mock::given(wiremock::matchers::method("POST"))
2127 .respond_with(
2128 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2129 "error": "invalid_grant",
2130 })),
2131 )
2132 .mount(&server)
2133 .await;
2134
2135 let http = reqwest::Client::new();
2136 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2137 .await
2138 .unwrap_err();
2139 let msg = err.to_string();
2140 assert!(msg.contains("7 days"));
2141 assert!(msg.contains("Testing"));
2142 }
2143
2144 #[tokio::test]
2145 async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2146 let server = wiremock::MockServer::start().await;
2147 wiremock::Mock::given(wiremock::matchers::method("POST"))
2148 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2149 .mount(&server)
2150 .await;
2151
2152 let http = reqwest::Client::new();
2153 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2154 .await
2155 .unwrap_err();
2156 let msg = err.to_string();
2157 assert!(msg.contains("unparsable error body"));
2158 assert!(msg.contains("not json"));
2159 }
2160
2161 #[tokio::test]
2162 async fn token_request_propagates_network_errors() {
2163 let http = reqwest::Client::new();
2164 let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2165 .await
2166 .unwrap_err();
2167 assert!(err.to_string().contains("Failed to send token request"));
2168 }
2169
2170 #[tokio::test]
2171 async fn token_request_errors_on_unparsable_response_body() {
2172 let server = wiremock::MockServer::start().await;
2173 wiremock::Mock::given(wiremock::matchers::method("POST"))
2174 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2175 .mount(&server)
2176 .await;
2177
2178 let http = reqwest::Client::new();
2179 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2180 .await
2181 .unwrap_err();
2182 assert!(err.to_string().contains("Failed to parse"));
2183 }
2184
2185 fn test_credentials() -> GmailCredentials {
2188 GmailCredentials {
2189 client_id: "client-1".to_string(),
2190 client_secret: "secret-1".into(),
2191 refresh_token: "refresh-1".into(),
2192 scope: GmailScope::ReadOnly,
2193 }
2194 }
2195
2196 #[test]
2197 fn named_account_vars_maps_credentials_to_json_string_values() {
2198 assert_eq!(
2199 named_account_vars(&test_credentials()),
2200 [
2201 (
2202 "client_id",
2203 serde_json::Value::String("client-1".to_string())
2204 ),
2205 (
2206 "client_secret",
2207 serde_json::Value::String("secret-1".to_string())
2208 ),
2209 (
2210 "refresh_token",
2211 serde_json::Value::String("refresh-1".to_string())
2212 ),
2213 (
2214 "scope",
2215 serde_json::Value::String(SCOPE_READONLY.to_string())
2216 ),
2217 ]
2218 );
2219 }
2220
2221 #[tokio::test]
2222 async fn access_token_refreshes_on_first_call() {
2223 let server = wiremock::MockServer::start().await;
2224 wiremock::Mock::given(wiremock::matchers::method("POST"))
2225 .respond_with(
2226 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2227 "access_token": "at-first",
2228 "expires_in": 3600,
2229 })),
2230 )
2231 .expect(1)
2232 .mount(&server)
2233 .await;
2234
2235 let session = GmailSession::new_with_token_endpoint(
2236 reqwest::Client::new(),
2237 &test_credentials(),
2238 &server.uri(),
2239 );
2240 let token = session.access_token().await.unwrap();
2241 assert_eq!(token.expose_secret(), "at-first");
2242 }
2243
2244 #[tokio::test]
2245 async fn access_token_reuses_cached_token_within_skew_window() {
2246 let server = wiremock::MockServer::start().await;
2247 wiremock::Mock::given(wiremock::matchers::method("POST"))
2248 .respond_with(
2249 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2250 "access_token": "at-cached",
2251 "expires_in": 3600,
2252 })),
2253 )
2254 .expect(1)
2255 .mount(&server)
2256 .await;
2257
2258 let session = GmailSession::new_with_token_endpoint(
2259 reqwest::Client::new(),
2260 &test_credentials(),
2261 &server.uri(),
2262 );
2263 let first = session.access_token().await.unwrap();
2264 let second = session.access_token().await.unwrap();
2265 assert_eq!(first, second);
2266 }
2267
2268 #[tokio::test]
2269 async fn access_token_proactively_refreshes_when_within_skew_window() {
2270 let server = wiremock::MockServer::start().await;
2271 wiremock::Mock::given(wiremock::matchers::method("POST"))
2272 .respond_with(
2273 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2274 "access_token": "at-short",
2275 "expires_in": 30,
2277 })),
2278 )
2279 .expect(2)
2280 .mount(&server)
2281 .await;
2282
2283 let session = GmailSession::new_with_token_endpoint(
2284 reqwest::Client::new(),
2285 &test_credentials(),
2286 &server.uri(),
2287 );
2288 session.access_token().await.unwrap();
2289 session.access_token().await.unwrap();
2290 }
2291
2292 #[tokio::test]
2293 async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2294 let server = wiremock::MockServer::start().await;
2295 wiremock::Mock::given(wiremock::matchers::method("POST"))
2296 .respond_with(
2297 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2298 "access_token": "at-overflow",
2299 "expires_in": i64::MAX,
2302 })),
2303 )
2304 .expect(1)
2305 .mount(&server)
2306 .await;
2307
2308 let session = GmailSession::new_with_token_endpoint(
2309 reqwest::Client::new(),
2310 &test_credentials(),
2311 &server.uri(),
2312 );
2313 let token = session.access_token().await.unwrap();
2314 assert_eq!(token.expose_secret(), "at-overflow");
2315 }
2316
2317 #[tokio::test]
2318 async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2319 let server = wiremock::MockServer::start().await;
2320 wiremock::Mock::given(wiremock::matchers::method("POST"))
2321 .respond_with(
2322 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2323 "access_token": "at-negative",
2324 "expires_in": -3600,
2325 })),
2326 )
2327 .expect(2)
2330 .mount(&server)
2331 .await;
2332
2333 let session = GmailSession::new_with_token_endpoint(
2334 reqwest::Client::new(),
2335 &test_credentials(),
2336 &server.uri(),
2337 );
2338 session.access_token().await.unwrap();
2339 session.access_token().await.unwrap();
2340 }
2341
2342 #[tokio::test]
2343 async fn force_refresh_concurrent_callers_do_not_stampede() {
2344 let server = wiremock::MockServer::start().await;
2345 wiremock::Mock::given(wiremock::matchers::method("POST"))
2346 .respond_with(
2347 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2348 "access_token": "at-bootstrap",
2349 "expires_in": 3600,
2350 })),
2351 )
2352 .up_to_n_times(1)
2353 .with_priority(1)
2354 .mount(&server)
2355 .await;
2356 wiremock::Mock::given(wiremock::matchers::method("POST"))
2357 .respond_with(
2358 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2359 "access_token": "at-refreshed",
2360 "expires_in": 3600,
2361 })),
2362 )
2363 .expect(1)
2364 .with_priority(2)
2365 .mount(&server)
2366 .await;
2367
2368 let session = GmailSession::new_with_token_endpoint(
2369 reqwest::Client::new(),
2370 &test_credentials(),
2371 &server.uri(),
2372 );
2373 let bootstrapped = session.access_token().await.unwrap();
2374 assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2375
2376 let (a, b) = tokio::join!(
2377 session.force_refresh(&bootstrapped),
2378 session.force_refresh(&bootstrapped)
2379 );
2380 let a = a.unwrap();
2381 let b = b.unwrap();
2382 assert_eq!(a, b);
2383 assert_eq!(a.expose_secret(), "at-refreshed");
2384 }
2385
2386 #[tokio::test]
2387 async fn force_refresh_skips_network_call_when_token_already_rotated() {
2388 let server = wiremock::MockServer::start().await;
2389 wiremock::Mock::given(wiremock::matchers::method("POST"))
2390 .respond_with(
2391 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2392 "access_token": "at-a",
2393 "expires_in": 3600,
2394 })),
2395 )
2396 .up_to_n_times(1)
2397 .with_priority(1)
2398 .mount(&server)
2399 .await;
2400 wiremock::Mock::given(wiremock::matchers::method("POST"))
2401 .respond_with(
2402 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2403 "access_token": "at-b",
2404 "expires_in": 3600,
2405 })),
2406 )
2407 .expect(1)
2408 .with_priority(2)
2409 .mount(&server)
2410 .await;
2411
2412 let session = GmailSession::new_with_token_endpoint(
2413 reqwest::Client::new(),
2414 &test_credentials(),
2415 &server.uri(),
2416 );
2417 let stale = Secret::new("at-never-issued");
2418 let bootstrapped = session.access_token().await.unwrap();
2419 assert_eq!(bootstrapped.expose_secret(), "at-a");
2420
2421 let result = session.force_refresh(&stale).await.unwrap();
2424 assert_eq!(result, bootstrapped);
2425
2426 let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2428 assert_eq!(refreshed.expose_secret(), "at-b");
2429 }
2430
2431 #[test]
2434 fn gmail_credentials_debug_redacts_client_secret_and_refresh_token() {
2435 let creds = GmailCredentials {
2436 client_id: "client-visible".to_string(),
2437 client_secret: "sekret-client-secret".into(),
2438 refresh_token: "sekret-refresh-token".into(),
2439 scope: GmailScope::ReadOnly,
2440 };
2441 let debug = format!("{creds:?}");
2442 assert!(debug.contains("GmailCredentials"));
2443 assert!(debug.contains("client-visible"));
2444 assert!(!debug.contains("sekret-client-secret"));
2445 assert!(!debug.contains("sekret-refresh-token"));
2446 assert!(debug.contains("client_secret: <redacted>"));
2447 assert!(debug.contains("refresh_token: <redacted>"));
2448 }
2449
2450 #[test]
2451 fn gmail_auth_status_yaml_serialization_contains_no_secret_values() {
2452 let env = crate::test_support::env::MapEnv::new()
2453 .with(GMAIL_CLIENT_ID, "client-id-value")
2454 .with(GMAIL_CLIENT_SECRET, "sekret-do-not-leak")
2455 .with(GMAIL_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2456 .with(GMAIL_SCOPE, SCOPE_READONLY);
2457 let status = status_with(&env);
2458 let yaml = serde_yaml::to_string(&status).unwrap();
2459 assert!(!yaml.contains("sekret-do-not-leak"));
2460 assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2461 }
2462
2463 use crate::test_support::env::MapEnv;
2466
2467 #[test]
2468 fn refresh_token_can_come_from_a_file() {
2469 let (_dir, path) = crate::test_support::env::secret_file("rt-from-file\n");
2470 let env = MapEnv::new()
2471 .with(GMAIL_CLIENT_ID, "id")
2472 .with(GMAIL_CLIENT_SECRET, "secret")
2473 .with("GMAIL_REFRESH_TOKEN_FILE", &path);
2474 let creds = load_credentials_with(&env).unwrap();
2475 assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
2476 assert!(status_with(&env).has_refresh_token);
2477 }
2478
2479 #[test]
2480 fn status_reports_all_false_when_nothing_configured() {
2481 let status = status_with(&MapEnv::new());
2482 assert!(!status.has_client_id);
2483 assert!(!status.has_client_secret);
2484 assert!(!status.has_refresh_token);
2485 assert_eq!(status.scope, None);
2486 }
2487
2488 #[test]
2489 fn status_reports_scope_when_present() {
2490 let env = MapEnv::new().with(GMAIL_SCOPE, SCOPE_MODIFY);
2491 let status = status_with(&env);
2492 assert_eq!(status.scope.as_deref(), Some(SCOPE_MODIFY));
2493 }
2494
2495 #[test]
2496 fn load_credentials_errors_when_client_id_missing() {
2497 let env = MapEnv::new()
2498 .with(GMAIL_CLIENT_SECRET, "s")
2499 .with(GMAIL_REFRESH_TOKEN, "r");
2500 let err = load_credentials_with(&env).unwrap_err();
2501 assert!(err.to_string().contains("not configured"));
2502 }
2503
2504 #[test]
2505 fn load_credentials_errors_when_client_secret_missing() {
2506 let env = MapEnv::new()
2507 .with(GMAIL_CLIENT_ID, "c")
2508 .with(GMAIL_REFRESH_TOKEN, "r");
2509 assert!(load_credentials_with(&env).is_err());
2510 }
2511
2512 #[test]
2513 fn load_credentials_errors_when_refresh_token_missing() {
2514 let env = MapEnv::new()
2515 .with(GMAIL_CLIENT_ID, "c")
2516 .with(GMAIL_CLIENT_SECRET, "s");
2517 assert!(load_credentials_with(&env).is_err());
2518 }
2519
2520 #[test]
2521 fn load_credentials_succeeds_with_all_three_present() {
2522 let env = MapEnv::new()
2523 .with(GMAIL_CLIENT_ID, "c")
2524 .with(GMAIL_CLIENT_SECRET, "s")
2525 .with(GMAIL_REFRESH_TOKEN, "r");
2526 let creds = load_credentials_with(&env).unwrap();
2527 assert_eq!(creds.client_id, "c");
2528 assert_eq!(creds.scope, GmailScope::ReadOnly);
2529 }
2530
2531 #[test]
2534 fn save_then_remove_round_trip() {
2535 {
2537 let temp_dir = {
2538 std::fs::create_dir_all("tmp").ok();
2539 tempfile::TempDir::new_in("tmp").unwrap()
2540 };
2541 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2542
2543 let creds = GmailCredentials {
2544 client_id: "client-1".to_string(),
2545 client_secret: "secret-1".into(),
2546 refresh_token: "refresh-1".into(),
2547 scope: GmailScope::ReadOnly,
2548 };
2549 save_credentials_to(&settings_path, None, &creds).unwrap();
2550
2551 assert!(settings_path.exists());
2552 let val: serde_json::Value =
2553 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2554 assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "client-1");
2555 assert_eq!(val["env"]["GMAIL_CLIENT_SECRET"], "secret-1");
2556 assert_eq!(val["env"]["GMAIL_REFRESH_TOKEN"], "refresh-1");
2557 assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_READONLY);
2558
2559 #[cfg(unix)]
2560 {
2561 use std::os::unix::fs::PermissionsExt;
2562 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2563 assert_eq!(mode & 0o777, 0o600);
2564 }
2565 }
2566
2567 {
2569 let temp_dir = {
2570 std::fs::create_dir_all("tmp").ok();
2571 tempfile::TempDir::new_in("tmp").unwrap()
2572 };
2573 let omni_dir = temp_dir.path().join(".omni-dev");
2574 fs::create_dir_all(&omni_dir).unwrap();
2575 let settings_path = omni_dir.join("settings.json");
2576 fs::write(
2577 &settings_path,
2578 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2579 )
2580 .unwrap();
2581
2582 let creds = GmailCredentials {
2583 client_id: "client-2".to_string(),
2584 client_secret: "secret-2".into(),
2585 refresh_token: "refresh-2".into(),
2586 scope: GmailScope::Modify,
2587 };
2588 save_credentials_to(&settings_path, None, &creds).unwrap();
2589
2590 let val: serde_json::Value =
2591 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2592 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2593 assert_eq!(val["extra"], true);
2594 assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_MODIFY);
2595 }
2596
2597 {
2599 let temp_dir = {
2600 std::fs::create_dir_all("tmp").ok();
2601 tempfile::TempDir::new_in("tmp").unwrap()
2602 };
2603 let omni_dir = temp_dir.path().join(".omni-dev");
2604 fs::create_dir_all(&omni_dir).unwrap();
2605 let settings_path = omni_dir.join("settings.json");
2606 fs::write(
2607 &settings_path,
2608 r#"{"env": {
2609 "GMAIL_CLIENT_ID": "a",
2610 "GMAIL_CLIENT_SECRET": "b",
2611 "GMAIL_REFRESH_TOKEN": "c",
2612 "GMAIL_SCOPE": "d",
2613 "OTHER_KEY": "keep"
2614 }}"#,
2615 )
2616 .unwrap();
2617
2618 let removed = remove_credentials_at(&settings_path, None).unwrap();
2619 assert!(removed);
2620
2621 let val: serde_json::Value =
2622 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2623 assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2624 assert!(val["env"].get("GMAIL_CLIENT_SECRET").is_none());
2625 assert!(val["env"].get("GMAIL_REFRESH_TOKEN").is_none());
2626 assert!(val["env"].get("GMAIL_SCOPE").is_none());
2627 assert_eq!(val["env"]["OTHER_KEY"], "keep");
2628 }
2629
2630 {
2632 let temp_dir = {
2633 std::fs::create_dir_all("tmp").ok();
2634 tempfile::TempDir::new_in("tmp").unwrap()
2635 };
2636 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2637 let removed = remove_credentials_at(&settings_path, None).unwrap();
2638 assert!(!removed);
2639 }
2640 }
2641
2642 #[test]
2644 fn save_then_remove_round_trip_in_profile() {
2645 let temp_dir = {
2646 std::fs::create_dir_all("tmp").ok();
2647 tempfile::TempDir::new_in("tmp").unwrap()
2648 };
2649 let omni_dir = temp_dir.path().join(".omni-dev");
2650 fs::create_dir_all(&omni_dir).unwrap();
2651 let settings_path = omni_dir.join("settings.json");
2652 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
2653
2654 let creds = GmailCredentials {
2655 client_id: "client-p".to_string(),
2656 client_secret: "secret-p".into(),
2657 refresh_token: "refresh-p".into(),
2658 scope: GmailScope::ReadOnly,
2659 };
2660 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
2661
2662 let val: serde_json::Value =
2663 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2664 assert_eq!(
2665 val["profiles"]["work"]["env"]["GMAIL_CLIENT_ID"],
2666 "client-p"
2667 );
2668 assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2669 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2670
2671 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2672 assert!(removed);
2673 let val: serde_json::Value =
2674 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2675 assert!(val["profiles"]["work"]["env"]
2676 .get("GMAIL_CLIENT_ID")
2677 .is_none());
2678
2679 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2680 assert!(!removed);
2681 }
2682
2683 #[test]
2687 fn save_and_remove_credentials_resolve_default_settings_path() {
2688 let guard = crate::gmail::test_support::EnvGuard::take();
2689 let dir = guard.clear_credentials();
2690
2691 let creds = GmailCredentials {
2692 client_id: "wrapper-client".to_string(),
2693 client_secret: "wrapper-secret".into(),
2694 refresh_token: "wrapper-refresh".into(),
2695 scope: GmailScope::ReadOnly,
2696 };
2697 save_credentials(&creds).unwrap();
2698
2699 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2700 let val: serde_json::Value =
2701 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2702 assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "wrapper-client");
2703
2704 assert!(remove_credentials().unwrap());
2705 let val: serde_json::Value =
2706 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2707 assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2708 }
2709
2710 fn owner_only_file(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
2721 let path = dir.path().join(name);
2722 std::fs::write(&path, contents).unwrap();
2723 #[cfg(unix)]
2724 {
2725 use std::os::unix::fs::PermissionsExt;
2726 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2727 }
2728 path.to_str().unwrap().to_string()
2729 }
2730
2731 fn named_settings(account: GmailAccountSettings) -> GmailSettings {
2732 GmailSettings {
2733 accounts: std::iter::once(("work".to_string(), account)).collect(),
2734 ..GmailSettings::default()
2735 }
2736 }
2737
2738 #[test]
2739 fn load_named_credentials_reads_secrets_from_their_files() {
2740 let dir = tempfile::tempdir().unwrap();
2741 let account = GmailAccountSettings {
2742 client_id: Some("id".to_string()),
2743 client_secret_file: Some(owner_only_file(&dir, "cs", "cs-from-file\n")),
2744 refresh_token_file: Some(owner_only_file(&dir, "rt", "rt-from-file\n")),
2745 ..GmailAccountSettings::default()
2746 };
2747 let creds = load_named_credentials(&named_settings(account), "work").unwrap();
2748 assert_eq!(creds.client_secret.expose_secret(), "cs-from-file");
2749 assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
2750 }
2751
2752 #[test]
2753 fn load_named_credentials_rejects_a_field_and_its_file_together() {
2754 let dir = tempfile::tempdir().unwrap();
2755 let account = GmailAccountSettings {
2756 client_id: Some("id".to_string()),
2757 client_secret: Some("cs".to_string()),
2758 refresh_token: Some("rt".to_string()),
2759 refresh_token_file: Some(owner_only_file(&dir, "rt", "rt\n")),
2760 ..GmailAccountSettings::default()
2761 };
2762 let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
2763 assert_eq!(
2764 err.to_string(),
2765 "both gmail.accounts.work.refresh_token and gmail.accounts.work.refresh_token_file \
2766 are set; set only one of them"
2767 );
2768 }
2769
2770 #[test]
2771 fn load_named_credentials_rejects_a_relative_secret_file() {
2772 let account = GmailAccountSettings {
2773 client_id: Some("id".to_string()),
2774 client_secret_file: Some("secrets/cs".to_string()),
2775 refresh_token: Some("rt".to_string()),
2776 ..GmailAccountSettings::default()
2777 };
2778 let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
2779 assert!(
2780 err.to_string()
2781 .contains("gmail.accounts.work.client_secret_file must be an absolute path"),
2782 "{err}"
2783 );
2784 }
2785
2786 #[test]
2787 fn load_named_credentials_rejects_a_client_secret_json_file() {
2788 let dir = tempfile::tempdir().unwrap();
2789 let json = owner_only_file(&dir, "client_secret.json", "{\"installed\": {}}\n");
2790 let account = GmailAccountSettings {
2791 client_id: Some("id".to_string()),
2792 client_secret_file: Some(json),
2793 refresh_token: Some("rt".to_string()),
2794 ..GmailAccountSettings::default()
2795 };
2796 let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
2797 let message = err.to_string();
2798 assert!(
2799 message.contains("gmail.accounts.work.client_secret_file"),
2800 "{message}"
2801 );
2802 assert!(message.contains("gmail auth import"), "{message}");
2803 assert!(!message.contains("installed"), "{message}");
2804 }
2805
2806 #[cfg(feature = "mcp")]
2807 #[test]
2808 fn status_from_named_counts_a_secret_file_as_present() {
2809 let account = GmailAccountSettings {
2810 client_id: Some("id".to_string()),
2811 client_secret_file: Some("/never/read".to_string()),
2812 refresh_token_file: Some("/never/read".to_string()),
2813 ..GmailAccountSettings::default()
2814 };
2815 let status = status_from_named(&named_settings(account), "work");
2816 assert!(status.has_client_secret);
2817 assert!(status.has_refresh_token);
2818 }
2819
2820 #[test]
2821 fn load_credentials_for_named_reads_from_gmail_accounts() {
2822 let guard = crate::gmail::test_support::EnvGuard::take();
2823 let dir = guard.clear_credentials();
2824 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2825 Settings::upsert_gmail_account(
2826 &settings_path,
2827 "work",
2828 &[
2829 (
2830 "client_id",
2831 serde_json::Value::String("work-id".to_string()),
2832 ),
2833 (
2834 "client_secret",
2835 serde_json::Value::String("work-secret".to_string()),
2836 ),
2837 (
2838 "refresh_token",
2839 serde_json::Value::String("work-refresh".to_string()),
2840 ),
2841 ("scope", serde_json::Value::String(SCOPE_MODIFY.to_string())),
2842 ],
2843 )
2844 .unwrap();
2845
2846 let creds = load_credentials_for(Some("work")).unwrap();
2847 assert_eq!(creds.client_id, "work-id");
2848 assert_eq!(creds.client_secret.expose_secret(), "work-secret");
2849 assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
2850 assert_eq!(creds.scope, GmailScope::Modify);
2851 }
2852
2853 #[test]
2854 fn load_credentials_for_unknown_named_account_errors() {
2855 let guard = crate::gmail::test_support::EnvGuard::take();
2856 let dir = guard.clear_credentials();
2857 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2858 Settings::upsert_gmail_account(
2859 &settings_path,
2860 "work",
2861 &[(
2862 "client_id",
2863 serde_json::Value::String("work-id".to_string()),
2864 )],
2865 )
2866 .unwrap();
2867
2868 let err = load_credentials_for(Some("bogus")).unwrap_err();
2869 assert!(err.to_string().contains("unknown Gmail account 'bogus'"));
2870 }
2871
2872 #[test]
2873 fn load_credentials_for_falls_back_to_legacy_when_accounts_empty() {
2874 let guard = crate::gmail::test_support::EnvGuard::take();
2875 let _dir = guard.clear_credentials();
2876 std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2877 std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
2878 std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
2879
2880 let creds = load_credentials_for(None).unwrap();
2881 assert_eq!(creds.client_id, "legacy-id");
2882 }
2883
2884 #[test]
2885 fn load_credentials_for_none_honors_ambient_account_env_var() {
2886 let guard = crate::gmail::test_support::EnvGuard::take();
2887 let dir = guard.clear_credentials();
2888 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2889 Settings::upsert_gmail_account(
2890 &settings_path,
2891 "work",
2892 &[
2893 (
2894 "client_id",
2895 serde_json::Value::String("work-id".to_string()),
2896 ),
2897 (
2898 "client_secret",
2899 serde_json::Value::String("work-secret".to_string()),
2900 ),
2901 (
2902 "refresh_token",
2903 serde_json::Value::String("work-refresh".to_string()),
2904 ),
2905 ],
2906 )
2907 .unwrap();
2908 std::env::set_var(account::GMAIL_ACCOUNT_ENV, "work");
2909
2910 let creds = load_credentials_for(None).unwrap();
2911 assert_eq!(creds.client_id, "work-id");
2912 }
2913
2914 #[test]
2915 fn remove_credentials_for_named_removes_whole_account() {
2916 let guard = crate::gmail::test_support::EnvGuard::take();
2917 let dir = guard.clear_credentials();
2918 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2919 Settings::upsert_gmail_account(
2920 &settings_path,
2921 "work",
2922 &[("client_id", serde_json::Value::String("id".to_string()))],
2923 )
2924 .unwrap();
2925
2926 assert!(remove_credentials_for(Some("work")).unwrap());
2927 let val: serde_json::Value =
2928 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2929 assert!(val["gmail"]["accounts"].get("work").is_none());
2930 }
2931
2932 #[cfg(feature = "mcp")]
2933 #[test]
2934 fn status_for_named_reports_presence_from_account() {
2935 let guard = crate::gmail::test_support::EnvGuard::take();
2936 let dir = guard.clear_credentials();
2937 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2938 Settings::upsert_gmail_account(
2939 &settings_path,
2940 "work",
2941 &[
2942 ("client_id", serde_json::Value::String("id".to_string())),
2943 (
2944 "scope",
2945 serde_json::Value::String(SCOPE_READONLY.to_string()),
2946 ),
2947 ],
2948 )
2949 .unwrap();
2950
2951 let status = status_for(Some("work")).unwrap();
2952 assert!(status.has_client_id);
2953 assert!(!status.has_client_secret);
2954 assert!(!status.has_refresh_token);
2955 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2956 }
2957
2958 #[cfg(feature = "mcp")]
2959 #[test]
2960 fn status_for_legacy_matches_status_with_when_accounts_empty() {
2961 let guard = crate::gmail::test_support::EnvGuard::take();
2962 let _dir = guard.clear_credentials();
2963 std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2964
2965 let status = status_for(None).unwrap();
2966 assert!(status.has_client_id);
2967 assert!(!status.has_refresh_token);
2968 }
2969
2970 #[test]
2971 fn load_credentials_legacy_ignores_active_named_account() {
2972 let guard = crate::gmail::test_support::EnvGuard::take();
2973 let dir = guard.clear_credentials();
2974 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2975
2976 Settings::set_gmail_default_account(&settings_path, Some("work")).unwrap();
2978 Settings::upsert_gmail_account(
2979 &settings_path,
2980 "work",
2981 &[
2982 (
2983 "client_id",
2984 serde_json::Value::String("named-id".to_string()),
2985 ),
2986 (
2987 "client_secret",
2988 serde_json::Value::String("named-secret".to_string()),
2989 ),
2990 (
2991 "refresh_token",
2992 serde_json::Value::String("named-refresh".to_string()),
2993 ),
2994 ],
2995 )
2996 .unwrap();
2997 Settings::upsert_env_vars(
3000 &settings_path,
3001 &[
3002 (GMAIL_CLIENT_ID, "legacy-id"),
3003 (GMAIL_CLIENT_SECRET, "legacy-secret"),
3004 (GMAIL_REFRESH_TOKEN, "legacy-refresh"),
3005 ],
3006 )
3007 .unwrap();
3008
3009 let via_resolution = load_credentials_for(None).unwrap();
3011 assert_eq!(via_resolution.client_id, "named-id");
3012
3013 let forced_legacy = load_credentials_legacy().unwrap();
3015 assert_eq!(forced_legacy.client_id, "legacy-id");
3016 }
3017
3018 #[test]
3019 fn record_account_email_writes_email_address_only() {
3020 let guard = crate::gmail::test_support::EnvGuard::take();
3021 let dir = guard.clear_credentials();
3022 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3023 Settings::upsert_gmail_account(
3024 &settings_path,
3025 "work",
3026 &[("client_id", serde_json::Value::String("id".to_string()))],
3027 )
3028 .unwrap();
3029
3030 record_account_email("work", "alice@work.com").unwrap();
3031
3032 let val: serde_json::Value =
3033 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3034 assert_eq!(
3035 val["gmail"]["accounts"]["work"]["email_address"],
3036 "alice@work.com"
3037 );
3038 assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
3039 }
3040
3041 #[test]
3042 fn record_account_email_does_not_overwrite_an_existing_value() {
3043 let guard = crate::gmail::test_support::EnvGuard::take();
3044 let dir = guard.clear_credentials();
3045 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3046 Settings::upsert_gmail_account(
3047 &settings_path,
3048 "work",
3049 &[(
3050 "email_address",
3051 serde_json::Value::String("manually-set@work.com".to_string()),
3052 )],
3053 )
3054 .unwrap();
3055
3056 record_account_email("work", "alice@work.com").unwrap();
3057
3058 let val: serde_json::Value =
3059 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3060 assert_eq!(
3061 val["gmail"]["accounts"]["work"]["email_address"],
3062 "manually-set@work.com"
3063 );
3064 }
3065
3066 #[test]
3075 fn zero_migration_load_remove_byte_identical_when_accounts_empty() {
3076 let guard = crate::gmail::test_support::EnvGuard::take();
3077 let creds = GmailCredentials {
3078 client_id: "id".to_string(),
3079 client_secret: "secret".into(),
3080 refresh_token: "refresh".into(),
3081 scope: GmailScope::Modify,
3082 };
3083
3084 let dir_legacy = guard.clear_credentials();
3085 save_credentials(&creds).unwrap();
3086 let legacy_written =
3087 fs::read_to_string(dir_legacy.path().join(".omni-dev").join("settings.json")).unwrap();
3088 let legacy_loaded = load_credentials().unwrap();
3089 let legacy_removed = remove_credentials().unwrap();
3090
3091 let dir_for = guard.clear_credentials();
3092 save_credentials(&creds).unwrap();
3093 let for_written =
3094 fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
3095 let for_loaded = load_credentials_for(None).unwrap();
3096 let for_removed = remove_credentials_for(None).unwrap();
3097
3098 assert_eq!(legacy_written, for_written);
3099 assert_eq!(legacy_loaded.client_id, for_loaded.client_id);
3100 assert_eq!(
3101 legacy_loaded.client_secret.expose_secret(),
3102 for_loaded.client_secret.expose_secret()
3103 );
3104 assert_eq!(legacy_loaded.scope, for_loaded.scope);
3105 assert_eq!(legacy_removed, for_removed);
3106 }
3107}