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 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
1071pub(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
1112async 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 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
1190fn 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 #[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 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 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 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 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 #[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 assert_is_auto(resolve_browser_config_for(&gmail, Some("work")).unwrap());
1504 }
1505
1506 #[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 #[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 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 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 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 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 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 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 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", )
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 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 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 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 "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 "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 .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 let result = session.force_refresh(&stale).await.unwrap();
2467 assert_eq!(result, bootstrapped);
2468
2469 let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2471 assert_eq!(refreshed.expose_secret(), "at-b");
2472 }
2473
2474 #[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 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 #[test]
2577 fn save_then_remove_round_trip() {
2578 {
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 {
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 {
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 {
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 #[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 #[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 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 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 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 let via_resolution = load_credentials_for(None).unwrap();
3054 assert_eq!(via_resolution.client_id, "named-id");
3055
3056 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 #[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}