use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use anyhow::{Context, Result};
use base64::Engine as _;
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use url::Url;
use crate::drive::account::{self, ResolvedAccount};
use crate::drive::chrome_profile;
use crate::drive::error::{DriveError, GrantContext};
use crate::request_log;
use crate::utils::browser_command::split_browser_command;
use crate::utils::env::SystemEnv;
use crate::utils::secret::Secret;
use crate::utils::settings::{active_profile_from, DriveAccountSettings, DriveSettings, Settings};
pub const DRIVE_CLIENT_ID: &str = "DRIVE_CLIENT_ID";
pub const DRIVE_CLIENT_SECRET: &str = "DRIVE_CLIENT_SECRET";
pub const DRIVE_REFRESH_TOKEN: &str = "DRIVE_REFRESH_TOKEN";
pub const DRIVE_SCOPE: &str = "DRIVE_SCOPE";
pub const DRIVE_API_URL: &str = "DRIVE_API_URL";
const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/drive.readonly";
pub const SCOPE_METADATA: &str = "https://www.googleapis.com/auth/drive.metadata";
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DriveScope {
#[default]
ReadOnly,
Metadata,
}
impl DriveScope {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => SCOPE_READONLY,
Self::Metadata => SCOPE_METADATA,
}
}
#[must_use]
pub fn from_granted(granted: &str) -> Option<Self> {
let tokens: Vec<&str> = granted.split_whitespace().collect();
if tokens.contains(&SCOPE_METADATA) {
Some(Self::Metadata)
} else if tokens.contains(&SCOPE_READONLY) {
Some(Self::ReadOnly)
} else {
None
}
}
#[must_use]
pub fn allows_write(self) -> bool {
matches!(self, Self::Metadata)
}
}
#[derive(Debug, Clone)]
pub struct DriveCredentials {
pub client_id: String,
pub client_secret: Secret,
pub refresh_token: Secret,
pub scope: DriveScope,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DriveAuthStatus {
pub has_client_id: bool,
pub has_client_secret: bool,
pub has_refresh_token: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
pub(crate) fn resolve(drive: &DriveSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
let explicit = fold_explicit(explicit);
account::resolve_account(&SystemEnv, drive, explicit.as_deref())
}
pub(crate) fn resolve_for_write(
drive: &DriveSettings,
explicit: Option<&str>,
) -> Result<ResolvedAccount> {
let explicit = fold_explicit(explicit);
account::resolve_account_for_write(&SystemEnv, drive, explicit.as_deref())
}
fn fold_explicit(explicit: Option<&str>) -> Option<String> {
explicit
.map(str::to_string)
.or_else(|| account::active_drive_account_from(&SystemEnv))
}
pub(crate) fn resolve_browser_config_for(
drive: &DriveSettings,
explicit: Option<&str>,
) -> Result<BrowserConfig> {
match resolve_for_write(drive, explicit)? {
ResolvedAccount::Unconfigured => Ok(BrowserConfig::default()),
ResolvedAccount::Named(name) => build_browser_config(
drive.accounts.get(&name),
chrome_profile::resolve_launch_command,
),
}
}
fn build_browser_config(
account: Option<&DriveAccountSettings>,
resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
) -> Result<BrowserConfig> {
let Some(account) = account else {
return Ok(BrowserConfig::default());
};
if let Some(command) = account
.browser_command
.as_deref()
.map(str::trim)
.filter(|command| !command.is_empty())
{
return Ok(BrowserConfig {
launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
..BrowserConfig::default()
});
}
if account.chrome_profile_from_email {
if let Some(email) = account.email_address.as_deref() {
if let Some(args) = resolve_chrome_profile(email) {
return Ok(BrowserConfig {
launch: BrowserLaunch::Command(args),
..BrowserConfig::default()
});
}
} else {
tracing::info!(
"chrome_profile_from_email is set but email_address is not; \
falling back to the default browser"
);
}
}
Ok(BrowserConfig::default())
}
pub fn load_credentials() -> Result<DriveCredentials> {
load_credentials_with(&crate::utils::settings::SettingsEnv::load())
}
pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<DriveCredentials> {
let settings = Settings::load().unwrap_or_default();
match resolve(&settings.drive, explicit)? {
ResolvedAccount::Unconfigured => {
let profile = active_profile_from(&SystemEnv);
load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
settings,
profile.as_deref(),
))
}
ResolvedAccount::Named(name) => load_named_credentials(&settings.drive, &name),
}
}
fn load_named_credentials(drive: &DriveSettings, name: &str) -> Result<DriveCredentials> {
let account = drive
.accounts
.get(name)
.ok_or(DriveError::CredentialsNotFound)?;
let client_id = account
.client_id
.clone()
.ok_or(DriveError::CredentialsNotFound)?;
let client_secret = account
.client_secret
.clone()
.ok_or(DriveError::CredentialsNotFound)?;
let refresh_token = account
.refresh_token
.clone()
.ok_or(DriveError::CredentialsNotFound)?;
let scope = account
.scope
.as_deref()
.and_then(DriveScope::from_granted)
.unwrap_or_default();
Ok(DriveCredentials {
client_id,
client_secret: client_secret.into(),
refresh_token: refresh_token.into(),
scope,
})
}
pub(crate) fn load_credentials_with(
env: &impl crate::utils::env::EnvSource,
) -> Result<DriveCredentials> {
let client_id = env
.var(DRIVE_CLIENT_ID)
.ok_or(DriveError::CredentialsNotFound)?;
let client_secret = env
.var(DRIVE_CLIENT_SECRET)
.ok_or(DriveError::CredentialsNotFound)?;
let refresh_token = env
.var(DRIVE_REFRESH_TOKEN)
.ok_or(DriveError::CredentialsNotFound)?;
let scope = env
.var(DRIVE_SCOPE)
.and_then(|s| DriveScope::from_granted(&s))
.unwrap_or_default();
Ok(DriveCredentials {
client_id,
client_secret: client_secret.into(),
refresh_token: refresh_token.into(),
scope,
})
}
pub fn status() -> DriveAuthStatus {
status_with(&crate::utils::settings::SettingsEnv::load())
}
pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> DriveAuthStatus {
DriveAuthStatus {
has_client_id: env.var(DRIVE_CLIENT_ID).is_some(),
has_client_secret: env.var(DRIVE_CLIENT_SECRET).is_some(),
has_refresh_token: env.var(DRIVE_REFRESH_TOKEN).is_some(),
scope: env.var(DRIVE_SCOPE),
}
}
#[cfg(feature = "mcp")]
pub(crate) fn status_for(explicit: Option<&str>) -> Result<DriveAuthStatus> {
let settings = Settings::load().unwrap_or_default();
match resolve(&settings.drive, explicit)? {
ResolvedAccount::Unconfigured => {
let profile = active_profile_from(&SystemEnv);
Ok(status_with(
&crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
))
}
ResolvedAccount::Named(name) => Ok(status_from_named(&settings.drive, &name)),
}
}
#[cfg(feature = "mcp")]
fn status_from_named(drive: &DriveSettings, name: &str) -> DriveAuthStatus {
let account = drive.accounts.get(name);
DriveAuthStatus {
has_client_id: account.is_some_and(|a| a.client_id.is_some()),
has_client_secret: account.is_some_and(|a| a.client_secret.is_some()),
has_refresh_token: account.is_some_and(|a| a.refresh_token.is_some()),
scope: account.and_then(|a| a.scope.clone()),
}
}
pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
let settings = Settings::load().unwrap_or_default();
if settings
.drive
.accounts
.get(name)
.is_some_and(|account| account.email_address.is_some())
{
return Ok(());
}
Settings::upsert_drive_account(
&Settings::get_settings_path()?,
name,
&[(
"email_address",
serde_json::Value::String(email.to_string()),
)],
)
}
pub fn save_credentials(credentials: &DriveCredentials) -> Result<()> {
save_credentials_to(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
credentials,
)
}
pub(crate) fn save_credentials_to(
settings_path: &Path,
profile: Option<&str>,
credentials: &DriveCredentials,
) -> Result<()> {
Settings::upsert_env_vars_in(
settings_path,
profile,
&[
(DRIVE_CLIENT_ID, credentials.client_id.as_str()),
(
DRIVE_CLIENT_SECRET,
credentials.client_secret.expose_secret(),
),
(
DRIVE_REFRESH_TOKEN,
credentials.refresh_token.expose_secret(),
),
(DRIVE_SCOPE, credentials.scope.as_str()),
],
)
}
fn named_account_vars(credentials: &DriveCredentials) -> [(&str, serde_json::Value); 4] {
[
(
"client_id",
serde_json::Value::String(credentials.client_id.clone()),
),
(
"client_secret",
serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
),
(
"refresh_token",
serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
),
(
"scope",
serde_json::Value::String(credentials.scope.as_str().to_string()),
),
]
}
pub fn remove_credentials() -> Result<bool> {
remove_credentials_at(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
)
}
pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
Settings::remove_env_vars_in(
settings_path,
profile,
&[
DRIVE_CLIENT_ID,
DRIVE_CLIENT_SECRET,
DRIVE_REFRESH_TOKEN,
DRIVE_SCOPE,
],
)
}
pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
let settings = Settings::load().unwrap_or_default();
match resolve(&settings.drive, explicit)? {
ResolvedAccount::Unconfigured => remove_credentials_at(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
),
ResolvedAccount::Named(name) => {
Settings::remove_drive_account(&Settings::get_settings_path()?, &name)
}
}
}
#[derive(Clone, Debug, Default)]
pub enum BrowserLaunch {
#[default]
Auto,
Command(Vec<String>),
Manual,
}
#[derive(Clone, Debug)]
pub struct BrowserConfig {
pub launch: BrowserLaunch,
pub callback_addr: IpAddr,
pub callback_port: u16,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
launch: BrowserLaunch::Auto,
callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
callback_port: 0,
}
}
}
#[allow(clippy::literal_string_with_formatting_args)]
fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
match launch {
BrowserLaunch::Manual => {
tracing::info!("Open this URL in a browser to sign in to Drive:\n{url}");
Ok(())
}
BrowserLaunch::Command(args) => {
let mut parts = args.iter();
let program = parts
.next()
.ok_or_else(|| DriveError::InvalidBrowserCommand("empty browser command".into()))?;
let mut command = Command::new(program);
let mut placed = false;
for arg in parts {
if arg.contains("{url}") {
command.arg(arg.replace("{url}", url));
placed = true;
} else {
command.arg(arg);
}
}
if !placed {
command.arg(url);
}
spawn_detached(command)
}
BrowserLaunch::Auto => {
let program = if cfg!(target_os = "macos") {
"open"
} else if cfg!(target_os = "windows") {
"explorer"
} else {
"xdg-open"
};
let mut command = Command::new(program);
command.arg(url);
spawn_detached(command)
}
}
}
fn spawn_detached(mut command: Command) -> Result<()> {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.context("Failed to launch the browser")
}
struct PendingLogin {
state: String,
code_verifier: String,
}
fn generate_pending_login() -> PendingLogin {
PendingLogin {
state: crate::browser::auth::generate_token(),
code_verifier: crate::browser::auth::generate_token(),
}
}
fn code_challenge(code_verifier: &str) -> String {
let digest = Sha256::digest(code_verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}
fn build_authorization_url(
client_id: &str,
redirect_uri: &str,
scope: DriveScope,
state: &str,
code_challenge: &str,
) -> Result<Url> {
let mut url =
Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Drive authorization endpoint")?;
let requested_scope = match scope {
DriveScope::ReadOnly => SCOPE_READONLY.to_string(),
DriveScope::Metadata => format!("{SCOPE_READONLY} {SCOPE_METADATA}"),
};
url.query_pairs_mut()
.append_pair("client_id", client_id)
.append_pair("redirect_uri", redirect_uri)
.append_pair("response_type", "code")
.append_pair("scope", &requested_scope)
.append_pair("state", state)
.append_pair("code_challenge", code_challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("access_type", "offline")
.append_pair("prompt", "consent");
Ok(url)
}
#[derive(Debug)]
pub(crate) struct CallbackResult {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
let listener = TcpListener::bind(SocketAddr::new(
browser.callback_addr,
browser.callback_port,
))
.await
.context("Failed to start the local OAuth callback listener")?;
let port = listener
.local_addr()
.context("Failed to read the callback listener's port")?
.port();
Ok((listener, port))
}
pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
}
pub(crate) async fn wait_for_callback_with_timeout(
listener: TcpListener,
timeout: Duration,
) -> Result<CallbackResult> {
let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
.await
.map_err(|_| DriveError::CallbackTimeout(timeout.as_secs()))?
.context("Failed to accept the browser's callback connection")?;
let mut buf = vec![0u8; 8192];
let n = stream
.read(&mut buf)
.await
.context("Failed to read the callback request")?;
let request = String::from_utf8_lossy(&buf[..n]);
let result = parse_callback(&request).ok_or(DriveError::MalformedCallback)?;
tracing::info!("Drive OAuth callback received");
let body = if result.error.is_some() {
"<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
} else {
"<html><body>Drive sign-in complete. You can close this tab.</body></html>"
};
let response =
format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
Ok(result)
}
fn parse_callback(request: &str) -> Option<CallbackResult> {
let first_line = request.lines().next()?;
let path = first_line.split_whitespace().nth(1)?; let query = path.split_once('?')?.1;
let mut result = CallbackResult {
code: None,
state: None,
error: None,
error_description: None,
};
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"code" => result.code = Some(value.into_owned()),
"state" => result.state = Some(value.into_owned()),
"error" => result.error = Some(value.into_owned()),
"error_description" => result.error_description = Some(value.into_owned()),
_ => {}
}
}
Some(result)
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
expires_in: i64,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TokenErrorResponse {
error: String,
#[serde(default)]
error_description: Option<String>,
}
async fn exchange_code_for_tokens(
http: &reqwest::Client,
token_endpoint: &str,
client_id: &str,
client_secret: &str,
code: &str,
code_verifier: &str,
redirect_uri: &str,
) -> Result<TokenResponse> {
let params = [
("grant_type", "authorization_code"),
("code", code),
("client_id", client_id),
("client_secret", client_secret),
("redirect_uri", redirect_uri),
("code_verifier", code_verifier),
];
post_token_request(http, token_endpoint, ¶ms, GrantContext::CodeExchange).await
}
async fn refresh_access_token(
http: &reqwest::Client,
token_endpoint: &str,
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> Result<TokenResponse> {
let params = [
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", client_id),
("client_secret", client_secret),
];
post_token_request(http, token_endpoint, ¶ms, GrantContext::Refresh).await
}
async fn post_token_request(
http: &reqwest::Client,
token_endpoint: &str,
params: &[(&str, &str)],
context: GrantContext,
) -> Result<TokenResponse> {
let started = std::time::Instant::now();
let result = http.post(token_endpoint).form(params).send().await;
request_log::record_http_result("drive", "POST", token_endpoint, started, &result);
let response = result.context("Failed to send token request to Google")?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
if err.error == "invalid_grant" {
return Err(DriveError::InvalidGrant(context).into());
}
return Err(anyhow::anyhow!(
"Google token endpoint rejected the request: {} ({})",
err.error,
err.error_description.unwrap_or_default()
));
}
return Err(anyhow::anyhow!(
"Google token endpoint returned an unparsable error body: {body}"
));
}
response
.json::<TokenResponse>()
.await
.context("Failed to parse Google's token response")
}
struct TokenState {
access_token: Secret,
expires_at: DateTime<Utc>,
}
pub struct DriveSession {
http: reqwest::Client,
client_id: String,
client_secret: Secret,
refresh_token: Secret,
token_endpoint: String,
state: tokio::sync::Mutex<TokenState>,
}
impl DriveSession {
pub(crate) fn new(http: reqwest::Client, credentials: &DriveCredentials) -> Self {
Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
}
pub(crate) fn new_with_token_endpoint(
http: reqwest::Client,
credentials: &DriveCredentials,
token_endpoint: &str,
) -> Self {
Self {
http,
client_id: credentials.client_id.clone(),
client_secret: credentials.client_secret.clone(),
refresh_token: credentials.refresh_token.clone(),
token_endpoint: token_endpoint.to_string(),
state: tokio::sync::Mutex::new(TokenState {
access_token: Secret::new(""),
expires_at: DateTime::<Utc>::MIN_UTC,
}),
}
}
pub(crate) async fn access_token(&self) -> Result<Secret> {
let mut state = self.state.lock().await;
if Utc::now() + REFRESH_SKEW >= state.expires_at {
self.refresh_locked(&mut state).await?;
}
Ok(state.access_token.clone())
}
pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
let mut state = self.state.lock().await;
if state.access_token != *observed {
return Ok(state.access_token.clone());
}
self.refresh_locked(&mut state).await?;
Ok(state.access_token.clone())
}
async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
let response = refresh_access_token(
&self.http,
&self.token_endpoint,
&self.client_id,
self.client_secret.expose_secret(),
self.refresh_token.expose_secret(),
)
.await?;
state.access_token = response.access_token.into();
let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
Ok(())
}
}
pub async fn login(
client_id: &str,
client_secret: &Secret,
scope: DriveScope,
browser: &BrowserConfig,
) -> Result<DriveAuthStatus> {
login_to(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
client_id,
client_secret,
scope,
browser,
TOKEN_ENDPOINT,
)
.await
}
pub(crate) async fn login_to(
settings_path: &Path,
profile: Option<&str>,
client_id: &str,
client_secret: &Secret,
scope: DriveScope,
browser: &BrowserConfig,
token_endpoint: &str,
) -> Result<DriveAuthStatus> {
let credentials =
run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
save_credentials_to(settings_path, profile, &credentials)?;
Ok(status_from_credentials(&credentials))
}
pub(crate) async fn login_for(
explicit: Option<&str>,
client_id: &str,
client_secret: &Secret,
scope: DriveScope,
browser: &BrowserConfig,
) -> Result<DriveAuthStatus> {
let settings = Settings::load().unwrap_or_default();
match resolve_for_write(&settings.drive, explicit)? {
ResolvedAccount::Unconfigured => {
login_to(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
client_id,
client_secret,
scope,
browser,
TOKEN_ENDPOINT,
)
.await
}
ResolvedAccount::Named(name) => {
let credentials =
run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
Settings::upsert_drive_account(
&Settings::get_settings_path()?,
&name,
&named_account_vars(&credentials),
)?;
Ok(status_from_credentials(&credentials))
}
}
}
async fn run_login_flow(
client_id: &str,
client_secret: &Secret,
scope: DriveScope,
browser: &BrowserConfig,
token_endpoint: &str,
) -> Result<DriveCredentials> {
let (listener, port) = bind_callback_listener(browser).await?;
let redirect_uri = format!("http://127.0.0.1:{port}");
let pending = generate_pending_login();
let challenge = code_challenge(&pending.code_verifier);
let auth_url =
build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
open_browser(&browser.launch, auth_url.as_str())?;
let callback = wait_for_callback(listener).await?;
if let Some(error) = callback.error {
return Err(DriveError::authorization_denied(
&error,
callback.error_description.as_deref(),
)
.into());
}
let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
return Err(DriveError::MalformedCallback.into());
};
if returned_state != pending.state {
return Err(DriveError::StateMismatch.into());
}
let http = reqwest::Client::builder()
.connect_timeout(crate::utils::http::connect_timeout())
.read_timeout(crate::utils::http::read_timeout())
.build()
.context("Failed to build HTTP client")?;
let tokens = exchange_code_for_tokens(
&http,
token_endpoint,
client_id,
client_secret.expose_secret(),
&code,
&pending.code_verifier,
&redirect_uri,
)
.await?;
let refresh_token = tokens
.refresh_token
.ok_or(DriveError::MalformedTokenResponse("refresh_token"))?;
let granted_raw = tokens.scope.unwrap_or_default();
let granted_scope = DriveScope::from_granted(&granted_raw).ok_or_else(|| {
let received = if granted_raw.trim().is_empty() {
"none".to_string()
} else {
granted_raw
.split_whitespace()
.collect::<Vec<_>>()
.join(", ")
};
DriveError::NoScopeGranted(received)
})?;
Ok(DriveCredentials {
client_id: client_id.to_string(),
client_secret: client_secret.clone(),
refresh_token: refresh_token.into(),
scope: granted_scope,
})
}
fn status_from_credentials(credentials: &DriveCredentials) -> DriveAuthStatus {
DriveAuthStatus {
has_client_id: true,
has_client_secret: true,
has_refresh_token: true,
scope: Some(credentials.scope.as_str().to_string()),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::fs;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use super::*;
#[test]
fn code_challenge_matches_rfc_7636_test_vector() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
assert_eq!(code_challenge(verifier), expected);
}
#[test]
fn code_challenge_output_is_url_safe_no_padding() {
let challenge = code_challenge("some-verifier-value");
assert!(!challenge.contains('+'));
assert!(!challenge.contains('/'));
assert!(!challenge.contains('='));
}
#[test]
fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
let pending = generate_pending_login();
assert_ne!(pending.state, pending.code_verifier);
assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
assert!(pending
.code_verifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
#[test]
fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
let url = build_authorization_url(
"client-123",
"http://127.0.0.1:5555",
DriveScope::ReadOnly,
"state-abc",
"challenge-xyz",
)
.unwrap();
let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
assert_eq!(query.get("client_id").unwrap(), "client-123");
assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
assert_eq!(query.get("response_type").unwrap(), "code");
assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
assert_eq!(query.get("state").unwrap(), "state-abc");
assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
assert_eq!(query.get("access_type").unwrap(), "offline");
assert_eq!(query.get("prompt").unwrap(), "consent");
}
#[test]
fn build_authorization_url_uses_additive_scope_when_metadata_requested() {
let url = build_authorization_url(
"client-123",
"http://127.0.0.1:5555",
DriveScope::Metadata,
"state-abc",
"challenge-xyz",
)
.unwrap();
let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
assert_eq!(
query.get("scope").unwrap(),
&format!("{SCOPE_READONLY} {SCOPE_METADATA}")
);
}
#[test]
fn parse_callback_extracts_code_and_state() {
let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
let result = parse_callback(request).unwrap();
assert_eq!(result.code.as_deref(), Some("abc123"));
assert_eq!(result.state.as_deref(), Some("xyz789"));
assert!(result.error.is_none());
}
#[test]
fn parse_callback_extracts_error_and_error_description() {
let request =
"GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
let result = parse_callback(request).unwrap();
assert_eq!(result.error.as_deref(), Some("access_denied"));
assert_eq!(result.error_description.as_deref(), Some("user declined"));
}
#[test]
fn parse_callback_missing_query_string_is_none() {
assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
assert!(parse_callback("garbage").is_none());
}
#[test]
fn parse_callback_ignores_unrecognized_query_keys() {
let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
let result = parse_callback(request).unwrap();
assert_eq!(result.code.as_deref(), Some("abc"));
assert_eq!(result.state.as_deref(), Some("xyz"));
}
#[test]
fn open_browser_manual_logs_and_succeeds() {
assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
}
#[test]
fn open_browser_command_substitutes_url_placeholder() {
let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
assert!(open_browser(&launch, "https://example/auth").is_ok());
}
#[test]
fn open_browser_command_appends_url_when_no_placeholder() {
let launch = BrowserLaunch::Command(vec!["true".to_string()]);
assert!(open_browser(&launch, "https://example/auth").is_ok());
}
#[test]
fn open_browser_command_passes_through_args_without_the_placeholder() {
let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
assert!(open_browser(&launch, "https://example/auth").is_ok());
}
#[test]
fn open_browser_command_rejects_empty_args() {
let launch = BrowserLaunch::Command(vec![]);
let err = open_browser(&launch, "u").unwrap_err();
assert!(err.to_string().contains("empty browser command"));
}
#[test]
fn named_account_vars_maps_credentials_to_json_string_values() {
let credentials = DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveScope::ReadOnly,
};
assert_eq!(
named_account_vars(&credentials),
[
(
"client_id",
serde_json::Value::String("client-1".to_string())
),
(
"client_secret",
serde_json::Value::String("secret-1".to_string())
),
(
"refresh_token",
serde_json::Value::String("refresh-1".to_string())
),
(
"scope",
serde_json::Value::String(SCOPE_READONLY.to_string())
),
]
);
}
fn assert_is_auto(config: BrowserConfig) {
assert!(matches!(config.launch, BrowserLaunch::Auto));
}
#[test]
fn build_browser_config_defaults_to_auto_with_no_account() {
assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
}
#[test]
fn build_browser_config_defaults_to_auto_with_no_opt_in() {
let account = DriveAccountSettings {
email_address: Some("alice@example.com".to_string()),
..DriveAccountSettings::default()
};
assert_is_auto(
build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
);
}
#[test]
fn build_browser_config_uses_browser_command_verbatim() {
let account = DriveAccountSettings {
browser_command: Some("chrome --new-window {url}".to_string()),
..DriveAccountSettings::default()
};
let config =
build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
assert!(matches!(
config.launch,
BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
));
}
#[test]
fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
let account = DriveAccountSettings {
browser_command: Some("chrome {url}".to_string()),
chrome_profile_from_email: true,
email_address: Some("alice@example.com".to_string()),
..DriveAccountSettings::default()
};
let config =
build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
assert!(matches!(config.launch, BrowserLaunch::Command(_)));
}
#[test]
fn build_browser_config_rejects_a_malformed_browser_command() {
let account = DriveAccountSettings {
browser_command: Some("chrome \"--flag".to_string()),
..DriveAccountSettings::default()
};
let err =
build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
assert!(err.to_string().contains("browser_command"));
}
#[test]
fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
let account = DriveAccountSettings {
chrome_profile_from_email: true,
email_address: Some("alice@example.com".to_string()),
..DriveAccountSettings::default()
};
let config = build_browser_config(Some(&account), |email| {
assert_eq!(email, "alice@example.com");
Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
})
.unwrap();
assert!(matches!(
config.launch,
BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
));
}
#[test]
fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
let account = DriveAccountSettings {
chrome_profile_from_email: true,
email_address: Some("alice@example.com".to_string()),
..DriveAccountSettings::default()
};
assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
}
#[test]
fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
let account = DriveAccountSettings {
chrome_profile_from_email: true,
..DriveAccountSettings::default()
};
assert_is_auto(
build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
);
}
#[test]
fn resolve_browser_config_for_unconfigured_account_defaults_to_auto() {
let guard = crate::drive::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
let drive = DriveSettings::default();
assert_is_auto(resolve_browser_config_for(&drive, None).unwrap());
}
#[test]
fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
let guard = crate::drive::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let mut drive = DriveSettings::default();
drive.accounts.insert(
"work".to_string(),
DriveAccountSettings {
email_address: Some("alice@example.com".to_string()),
..DriveAccountSettings::default()
},
);
assert_is_auto(resolve_browser_config_for(&drive, Some("work")).unwrap());
}
#[tokio::test]
async fn wait_for_callback_times_out_when_nothing_connects() {
let browser = BrowserConfig::default();
let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
.await
.unwrap_err();
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::CallbackTimeout(_))
));
}
#[tokio::test]
async fn wait_for_callback_reads_a_real_connection() {
let browser = BrowserConfig::default();
let (listener, port) = bind_callback_listener(&browser).await.unwrap();
let client = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
stream
.write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
.await
.unwrap();
});
let result = wait_for_callback(listener).await.unwrap();
client.await.unwrap();
assert_eq!(result.code.as_deref(), Some("abc"));
assert_eq!(result.state.as_deref(), Some("xyz"));
}
#[tokio::test]
async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
let browser = BrowserConfig::default();
let (listener, port) = bind_callback_listener(&browser).await.unwrap();
let client = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
stream.write_all(b"not an http request").await.unwrap();
});
let err = wait_for_callback(listener).await.unwrap_err();
client.await.unwrap();
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::MalformedCallback)
));
}
#[tokio::test]
async fn exchange_code_for_tokens_posts_expected_form_body() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.and(wiremock::matchers::body_string_contains(
"grant_type=authorization_code",
))
.and(wiremock::matchers::body_string_contains(
"code_verifier=verifier-1",
))
.and(wiremock::matchers::body_string_contains(
"redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-1",
"refresh_token": "rt-1",
"expires_in": 3600,
"scope": SCOPE_READONLY,
})),
)
.expect(1)
.mount(&server)
.await;
let http = reqwest::Client::new();
let token_endpoint = format!("{}/token", server.uri());
let response = exchange_code_for_tokens(
&http,
&token_endpoint,
"client-1",
"secret-1",
"code-1",
"verifier-1",
"http://127.0.0.1:9999",
)
.await
.unwrap();
assert_eq!(response.access_token, "at-1");
assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
}
#[tokio::test]
async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "invalid_grant",
"error_description": "Bad Request",
})),
)
.mount(&server)
.await;
let http = reqwest::Client::new();
let err = exchange_code_for_tokens(
&http,
&server.uri(),
"c",
"s",
"code",
"verifier",
"http://127.0.0.1:1",
)
.await
.unwrap_err();
assert!(err.to_string().contains("PKCE"));
}
async fn connect_and_send(port: u16, request_line: &[u8]) {
let mut stream = loop {
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
Ok(stream) => break stream,
Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
}
};
stream.write_all(request_line).await.unwrap();
}
fn reserve_free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
err.to_string()
.contains("Failed to start the local OAuth callback listener")
}
const PORT_RETRY_ATTEMPTS: u32 = 5;
async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<DriveAuthStatus>
where
F: FnMut(u16) -> Fut,
Fut: std::future::Future<Output = Result<DriveAuthStatus>>,
{
for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
let result = attempt(reserve_free_port()).await;
let is_retryable_conflict =
matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
if !is_retryable_conflict {
return result;
}
}
unreachable!("loop always returns on its last iteration")
}
async fn finish_connector(
connector: tokio::task::JoinHandle<()>,
result: &Result<DriveAuthStatus>,
) {
match result {
Err(err) if is_callback_bind_conflict(err) => connector.abort(),
_ => connector.await.unwrap(),
}
}
async fn wait_for_captured_url(path: &Path) -> String {
loop {
if let Ok(contents) = std::fs::read_to_string(path) {
if !contents.is_empty() {
return contents;
}
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
}
async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
std::fs::create_dir_all("tmp").ok();
let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
let settings_path = temp_dir.path().join("settings.json");
let result = run_with_port_retry(|port| {
let settings_path = settings_path.clone();
async move {
let browser = BrowserConfig {
launch: BrowserLaunch::Manual,
callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
callback_port: port,
};
let connector = tokio::spawn(connect_and_send(port, request_line));
let result = login_to(
&settings_path,
None,
"client-id",
&Secret::new("client-secret"),
DriveScope::ReadOnly,
&browser,
"http://127.0.0.1:1/token", )
.await;
finish_connector(connector, &result).await;
result
}
})
.await;
let err = result.unwrap_err();
assert!(!settings_path.exists());
err
}
#[tokio::test]
async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
let attempts = AtomicU32::new(0);
let status = run_with_port_retry(|_port| {
let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if attempt_no == 0 {
Err(anyhow::anyhow!(
"Failed to start the local OAuth callback listener: address in use"
))
} else {
Ok(DriveAuthStatus {
has_client_id: true,
has_client_secret: true,
has_refresh_token: true,
scope: None,
})
}
}
})
.await
.unwrap();
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert!(status.has_client_id);
}
#[tokio::test]
async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
let attempts = AtomicU32::new(0);
let result = run_with_port_retry(|_port| {
attempts.fetch_add(1, Ordering::SeqCst);
async move { Err(anyhow::anyhow!("some other failure")) }
})
.await;
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert!(result.is_err());
}
#[tokio::test]
async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
let connector = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(3600)).await;
let _ = tx.send(());
});
let result: Result<DriveAuthStatus> = Err(anyhow::anyhow!(
"Failed to start the local OAuth callback listener: address in use"
));
tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
.await
.expect("finish_connector must not wait for an aborted connector");
assert!(
rx.try_recv().is_err(),
"connector must have been aborted, not run to completion"
);
}
#[tokio::test]
async fn finish_connector_awaits_connector_when_login_to_succeeds() {
let ran = Arc::new(AtomicBool::new(false));
let ran_clone = ran.clone();
let connector = tokio::spawn(async move {
ran_clone.store(true, Ordering::SeqCst);
});
let result = Ok(DriveAuthStatus {
has_client_id: true,
has_client_secret: true,
has_refresh_token: true,
scope: None,
});
finish_connector(connector, &result).await;
assert!(ran.load(Ordering::SeqCst));
}
#[tokio::test]
async fn wait_for_captured_url_polls_until_content_is_written() {
std::fs::create_dir_all("tmp").ok();
let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
let path = temp_dir.path().join("captured-url.txt");
let write_path = path.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
std::fs::write(&write_path, "").unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
std::fs::write(&write_path, "https://example.com/authorize").unwrap();
});
let contents = wait_for_captured_url(&path).await;
assert_eq!(contents, "https://example.com/authorize");
}
#[tokio::test]
async fn login_to_rejects_a_callback_with_mismatched_state() {
let err =
run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::StateMismatch)
));
}
#[tokio::test]
async fn login_to_surfaces_access_denied_from_the_callback() {
let err = run_login_to_expect_err(
b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
)
.await;
match err.downcast_ref::<DriveError>() {
Some(DriveError::AuthorizationDenied(message)) => {
assert!(message.contains("access_denied"));
assert!(message.contains("user declined"));
}
other => panic!("expected AuthorizationDenied, got {other:?}"),
}
}
#[tokio::test]
async fn login_to_rejects_a_callback_missing_code_and_state() {
let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::MalformedCallback)
));
}
#[tokio::test]
async fn login_to_completes_full_success_flow_and_persists_credentials() {
std::fs::create_dir_all("tmp").ok();
let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
let capture_path = temp_dir.path().join("captured-url.txt");
let settings_path = temp_dir.path().join("settings.json");
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-1",
"refresh_token": "rt-1",
"expires_in": 3600,
"scope": SCOPE_READONLY,
})),
)
.expect(1)
.mount(&server)
.await;
let status = run_with_port_retry(|port| {
let capture_path = capture_path.clone();
let settings_path = settings_path.clone();
let token_endpoint = format!("{}/token", server.uri());
async move {
let browser = BrowserConfig {
launch: BrowserLaunch::Command(vec![
"/bin/sh".to_string(),
"-c".to_string(),
format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
"{url}".to_string(),
]),
callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
callback_port: port,
};
let connector = tokio::spawn(async move {
let auth_url = wait_for_captured_url(&capture_path).await;
let parsed = Url::parse(&auth_url).unwrap();
let state = parsed
.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.into_owned())
.expect("authorization URL must carry a state param");
connect_and_send(
port,
format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
)
.await;
});
let result = login_to(
&settings_path,
None,
"client-id",
&Secret::new("client-secret"),
DriveScope::ReadOnly,
&browser,
&token_endpoint,
)
.await;
finish_connector(connector, &result).await;
result
}
})
.await
.unwrap();
assert!(status.has_client_id);
assert!(status.has_client_secret);
assert!(status.has_refresh_token);
assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
let saved = std::fs::read_to_string(&settings_path).unwrap();
assert!(saved.contains("rt-1"));
assert!(saved.contains("client-id"));
}
async fn run_login_to_with_token_response(
token_response_body: serde_json::Value,
) -> (Result<DriveAuthStatus>, std::path::PathBuf) {
std::fs::create_dir_all("tmp").ok();
let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
let capture_path = temp_dir.path().join("captured-url.txt");
let settings_path = temp_dir.path().join("settings.json");
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
.expect(1)
.mount(&server)
.await;
let result = run_with_port_retry(|port| {
let capture_path = capture_path.clone();
let settings_path = settings_path.clone();
let token_endpoint = format!("{}/token", server.uri());
async move {
let browser = BrowserConfig {
launch: BrowserLaunch::Command(vec![
"/bin/sh".to_string(),
"-c".to_string(),
format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
"{url}".to_string(),
]),
callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
callback_port: port,
};
let connector = tokio::spawn(async move {
let auth_url = wait_for_captured_url(&capture_path).await;
let parsed = Url::parse(&auth_url).unwrap();
let state = parsed
.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.into_owned())
.expect("authorization URL must carry a state param");
connect_and_send(
port,
format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
)
.await;
});
let result = login_to(
&settings_path,
None,
"client-id",
&Secret::new("client-secret"),
DriveScope::ReadOnly,
&browser,
&token_endpoint,
)
.await;
finish_connector(connector, &result).await;
result
}
})
.await;
(result, settings_path)
}
#[tokio::test]
async fn login_to_rejects_a_grant_with_no_drive_scope() {
let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
"access_token": "at-1",
"refresh_token": "rt-1",
"expires_in": 3600,
"scope": "openid email profile",
}))
.await;
let err = result.unwrap_err();
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::NoScopeGranted(received)) if received == "openid, email, profile"
));
assert!(!settings_path.exists());
}
#[tokio::test]
async fn login_to_rejects_a_grant_with_missing_scope_field() {
let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
"access_token": "at-1",
"refresh_token": "rt-1",
"expires_in": 3600,
}))
.await;
let err = result.unwrap_err();
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::NoScopeGranted(received)) if received == "none"
));
assert!(!settings_path.exists());
}
#[tokio::test]
async fn login_for_unconfigured_account_rejects_a_callback_with_mismatched_state() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
let result = run_with_port_retry(|port| async move {
let browser = BrowserConfig {
launch: BrowserLaunch::Manual,
callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
callback_port: port,
};
let connector = tokio::spawn(connect_and_send(
port,
b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
));
let result = login_for(
None,
"client-id",
&Secret::new("client-secret"),
DriveScope::ReadOnly,
&browser,
)
.await;
finish_connector(connector, &result).await;
result
})
.await;
let err = result.unwrap_err();
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::StateMismatch)
));
assert!(!settings_path.exists());
}
#[tokio::test]
async fn login_for_named_account_rejects_a_callback_with_mismatched_state() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
let result = run_with_port_retry(|port| async move {
let browser = BrowserConfig {
launch: BrowserLaunch::Manual,
callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
callback_port: port,
};
let connector = tokio::spawn(connect_and_send(
port,
b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
));
let result = login_for(
Some("work"),
"client-id",
&Secret::new("client-secret"),
DriveScope::ReadOnly,
&browser,
)
.await;
finish_connector(connector, &result).await;
result
})
.await;
let err = result.unwrap_err();
assert!(matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::StateMismatch)
));
assert!(!settings_path.exists());
}
#[tokio::test]
async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::body_string_contains(
"grant_type=refresh_token",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-2",
"expires_in": 1800,
})),
)
.expect(1)
.mount(&server)
.await;
let http = reqwest::Client::new();
let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
.await
.unwrap();
assert_eq!(response.access_token, "at-2");
assert_eq!(response.expires_in, 1800);
}
#[tokio::test]
async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "invalid_grant",
})),
)
.mount(&server)
.await;
let http = reqwest::Client::new();
let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("7 days"));
assert!(msg.contains("Testing"));
}
#[tokio::test]
async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
.mount(&server)
.await;
let http = reqwest::Client::new();
let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unparsable error body"));
assert!(msg.contains("not json"));
}
#[tokio::test]
async fn token_request_propagates_network_errors() {
let http = reqwest::Client::new();
let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to send token request"));
}
#[tokio::test]
async fn token_request_errors_on_unparsable_response_body() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let http = reqwest::Client::new();
let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to parse"));
}
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: "secret-1".into(),
refresh_token: "refresh-1".into(),
scope: DriveScope::ReadOnly,
}
}
#[tokio::test]
async fn access_token_refreshes_on_first_call() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-first",
"expires_in": 3600,
})),
)
.expect(1)
.mount(&server)
.await;
let session = DriveSession::new_with_token_endpoint(
reqwest::Client::new(),
&test_credentials(),
&server.uri(),
);
let token = session.access_token().await.unwrap();
assert_eq!(token.expose_secret(), "at-first");
}
#[tokio::test]
async fn access_token_reuses_cached_token_within_skew_window() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-cached",
"expires_in": 3600,
})),
)
.expect(1)
.mount(&server)
.await;
let session = DriveSession::new_with_token_endpoint(
reqwest::Client::new(),
&test_credentials(),
&server.uri(),
);
let first = session.access_token().await.unwrap();
let second = session.access_token().await.unwrap();
assert_eq!(first, second);
}
#[tokio::test]
async fn access_token_proactively_refreshes_when_within_skew_window() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-short",
"expires_in": 30,
})),
)
.expect(2)
.mount(&server)
.await;
let session = DriveSession::new_with_token_endpoint(
reqwest::Client::new(),
&test_credentials(),
&server.uri(),
);
session.access_token().await.unwrap();
session.access_token().await.unwrap();
}
#[tokio::test]
async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-overflow",
"expires_in": i64::MAX,
})),
)
.expect(1)
.mount(&server)
.await;
let session = DriveSession::new_with_token_endpoint(
reqwest::Client::new(),
&test_credentials(),
&server.uri(),
);
let token = session.access_token().await.unwrap();
assert_eq!(token.expose_secret(), "at-overflow");
}
#[tokio::test]
async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-negative",
"expires_in": -3600,
})),
)
.expect(2)
.mount(&server)
.await;
let session = DriveSession::new_with_token_endpoint(
reqwest::Client::new(),
&test_credentials(),
&server.uri(),
);
session.access_token().await.unwrap();
session.access_token().await.unwrap();
}
#[tokio::test]
async fn force_refresh_concurrent_callers_do_not_stampede() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-bootstrap",
"expires_in": 3600,
})),
)
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-refreshed",
"expires_in": 3600,
})),
)
.expect(1)
.with_priority(2)
.mount(&server)
.await;
let session = DriveSession::new_with_token_endpoint(
reqwest::Client::new(),
&test_credentials(),
&server.uri(),
);
let bootstrapped = session.access_token().await.unwrap();
assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
let (a, b) = tokio::join!(
session.force_refresh(&bootstrapped),
session.force_refresh(&bootstrapped)
);
let a = a.unwrap();
let b = b.unwrap();
assert_eq!(a, b);
assert_eq!(a.expose_secret(), "at-refreshed");
}
#[tokio::test]
async fn force_refresh_skips_network_call_when_token_already_rotated() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-a",
"expires_in": 3600,
})),
)
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "at-b",
"expires_in": 3600,
})),
)
.expect(1)
.with_priority(2)
.mount(&server)
.await;
let session = DriveSession::new_with_token_endpoint(
reqwest::Client::new(),
&test_credentials(),
&server.uri(),
);
let stale = Secret::new("at-never-issued");
let bootstrapped = session.access_token().await.unwrap();
assert_eq!(bootstrapped.expose_secret(), "at-a");
let result = session.force_refresh(&stale).await.unwrap();
assert_eq!(result, bootstrapped);
let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
assert_eq!(refreshed.expose_secret(), "at-b");
}
#[test]
fn drive_credentials_debug_redacts_client_secret_and_refresh_token() {
let creds = DriveCredentials {
client_id: "client-visible".to_string(),
client_secret: "sekret-client-secret".into(),
refresh_token: "sekret-refresh-token".into(),
scope: DriveScope::ReadOnly,
};
let debug = format!("{creds:?}");
assert!(debug.contains("DriveCredentials"));
assert!(debug.contains("client-visible"));
assert!(!debug.contains("sekret-client-secret"));
assert!(!debug.contains("sekret-refresh-token"));
assert!(debug.contains("client_secret: <redacted>"));
assert!(debug.contains("refresh_token: <redacted>"));
}
#[test]
fn drive_auth_status_yaml_serialization_contains_no_secret_values() {
let env = crate::test_support::env::MapEnv::new()
.with(DRIVE_CLIENT_ID, "client-id-value")
.with(DRIVE_CLIENT_SECRET, "sekret-do-not-leak")
.with(DRIVE_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
.with(DRIVE_SCOPE, SCOPE_READONLY);
let status = status_with(&env);
let yaml = serde_yaml::to_string(&status).unwrap();
assert!(!yaml.contains("sekret-do-not-leak"));
assert!(!yaml.contains("sekret-refresh-do-not-leak"));
}
use crate::test_support::env::MapEnv;
#[test]
fn status_reports_all_false_when_nothing_configured() {
let status = status_with(&MapEnv::new());
assert!(!status.has_client_id);
assert!(!status.has_client_secret);
assert!(!status.has_refresh_token);
assert_eq!(status.scope, None);
}
#[test]
fn status_reports_scope_when_present() {
let env = MapEnv::new().with(DRIVE_SCOPE, SCOPE_READONLY);
let status = status_with(&env);
assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
}
#[test]
fn load_credentials_errors_when_client_id_missing() {
let env = MapEnv::new()
.with(DRIVE_CLIENT_SECRET, "s")
.with(DRIVE_REFRESH_TOKEN, "r");
let err = load_credentials_with(&env).unwrap_err();
assert!(err.to_string().contains("not configured"));
}
#[test]
fn load_credentials_errors_when_client_secret_missing() {
let env = MapEnv::new()
.with(DRIVE_CLIENT_ID, "c")
.with(DRIVE_REFRESH_TOKEN, "r");
assert!(load_credentials_with(&env).is_err());
}
#[test]
fn load_credentials_errors_when_refresh_token_missing() {
let env = MapEnv::new()
.with(DRIVE_CLIENT_ID, "c")
.with(DRIVE_CLIENT_SECRET, "s");
assert!(load_credentials_with(&env).is_err());
}
#[test]
fn load_credentials_succeeds_with_all_three_present() {
let env = MapEnv::new()
.with(DRIVE_CLIENT_ID, "c")
.with(DRIVE_CLIENT_SECRET, "s")
.with(DRIVE_REFRESH_TOKEN, "r");
let creds = load_credentials_with(&env).unwrap();
assert_eq!(creds.client_id, "c");
assert_eq!(creds.scope, DriveScope::ReadOnly);
}
#[test]
fn save_then_remove_round_trip() {
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
let creds = DriveCredentials {
client_id: "client-1".to_string(),
client_secret: "secret-1".into(),
refresh_token: "refresh-1".into(),
scope: DriveScope::ReadOnly,
};
save_credentials_to(&settings_path, None, &creds).unwrap();
assert!(settings_path.exists());
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "client-1");
assert_eq!(val["env"]["DRIVE_CLIENT_SECRET"], "secret-1");
assert_eq!(val["env"]["DRIVE_REFRESH_TOKEN"], "refresh-1");
assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let omni_dir = temp_dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
fs::write(
&settings_path,
r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
)
.unwrap();
let creds = DriveCredentials {
client_id: "client-2".to_string(),
client_secret: "secret-2".into(),
refresh_token: "refresh-2".into(),
scope: DriveScope::ReadOnly,
};
save_credentials_to(&settings_path, None, &creds).unwrap();
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
assert_eq!(val["extra"], true);
assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
}
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let omni_dir = temp_dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
fs::write(
&settings_path,
r#"{"env": {
"DRIVE_CLIENT_ID": "a",
"DRIVE_CLIENT_SECRET": "b",
"DRIVE_REFRESH_TOKEN": "c",
"DRIVE_SCOPE": "d",
"OTHER_KEY": "keep"
}}"#,
)
.unwrap();
let removed = remove_credentials_at(&settings_path, None).unwrap();
assert!(removed);
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
assert!(val["env"].get("DRIVE_CLIENT_SECRET").is_none());
assert!(val["env"].get("DRIVE_REFRESH_TOKEN").is_none());
assert!(val["env"].get("DRIVE_SCOPE").is_none());
assert_eq!(val["env"]["OTHER_KEY"], "keep");
}
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
let removed = remove_credentials_at(&settings_path, None).unwrap();
assert!(!removed);
}
}
#[test]
fn save_then_remove_round_trip_in_profile() {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let omni_dir = temp_dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
let creds = DriveCredentials {
client_id: "client-p".to_string(),
client_secret: "secret-p".into(),
refresh_token: "refresh-p".into(),
scope: DriveScope::ReadOnly,
};
save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
val["profiles"]["work"]["env"]["DRIVE_CLIENT_ID"],
"client-p"
);
assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
assert!(removed);
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["profiles"]["work"]["env"]
.get("DRIVE_CLIENT_ID")
.is_none());
let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
assert!(!removed);
}
#[test]
fn save_and_remove_credentials_resolve_default_settings_path() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let creds = DriveCredentials {
client_id: "wrapper-client".to_string(),
client_secret: "wrapper-secret".into(),
refresh_token: "wrapper-refresh".into(),
scope: DriveScope::ReadOnly,
};
save_credentials(&creds).unwrap();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "wrapper-client");
assert!(remove_credentials().unwrap());
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
}
#[test]
fn load_credentials_for_named_reads_from_drive_accounts() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[
(
"client_id",
serde_json::Value::String("work-id".to_string()),
),
(
"client_secret",
serde_json::Value::String("work-secret".to_string()),
),
(
"refresh_token",
serde_json::Value::String("work-refresh".to_string()),
),
(
"scope",
serde_json::Value::String(SCOPE_READONLY.to_string()),
),
],
)
.unwrap();
let creds = load_credentials_for(Some("work")).unwrap();
assert_eq!(creds.client_id, "work-id");
assert_eq!(creds.client_secret.expose_secret(), "work-secret");
assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
assert_eq!(creds.scope, DriveScope::ReadOnly);
}
#[test]
fn load_credentials_for_unknown_named_account_errors() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[(
"client_id",
serde_json::Value::String("work-id".to_string()),
)],
)
.unwrap();
let err = load_credentials_for(Some("bogus")).unwrap_err();
assert!(err.to_string().contains("unknown Drive account 'bogus'"));
}
#[test]
fn load_credentials_for_falls_back_to_env_when_accounts_empty() {
let guard = crate::drive::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
let creds = load_credentials_for(None).unwrap();
assert_eq!(creds.client_id, "literal-id");
}
#[test]
fn load_credentials_for_none_honors_ambient_account_env_var() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[
(
"client_id",
serde_json::Value::String("work-id".to_string()),
),
(
"client_secret",
serde_json::Value::String("work-secret".to_string()),
),
(
"refresh_token",
serde_json::Value::String("work-refresh".to_string()),
),
],
)
.unwrap();
std::env::set_var(account::DRIVE_ACCOUNT_ENV, "work");
let creds = load_credentials_for(None).unwrap();
assert_eq!(creds.client_id, "work-id");
}
#[test]
fn remove_credentials_for_named_removes_whole_account() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[("client_id", serde_json::Value::String("id".to_string()))],
)
.unwrap();
assert!(remove_credentials_for(Some("work")).unwrap());
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert!(val["drive"]["accounts"].get("work").is_none());
}
#[cfg(feature = "mcp")]
#[test]
fn status_for_named_reports_presence_from_account() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[
("client_id", serde_json::Value::String("id".to_string())),
(
"scope",
serde_json::Value::String(SCOPE_READONLY.to_string()),
),
],
)
.unwrap();
let status = status_for(Some("work")).unwrap();
assert!(status.has_client_id);
assert!(!status.has_client_secret);
assert!(!status.has_refresh_token);
assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
}
#[cfg(feature = "mcp")]
#[test]
fn status_for_unconfigured_matches_status_with_when_accounts_empty() {
let guard = crate::drive::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
let status = status_for(None).unwrap();
assert!(status.has_client_id);
assert!(!status.has_refresh_token);
}
#[test]
fn record_account_email_writes_email_address_only() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[("client_id", serde_json::Value::String("id".to_string()))],
)
.unwrap();
record_account_email("work", "alice@work.com").unwrap();
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
val["drive"]["accounts"]["work"]["email_address"],
"alice@work.com"
);
assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
}
#[test]
fn record_account_email_does_not_overwrite_an_existing_value() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[(
"email_address",
serde_json::Value::String("manually-set@work.com".to_string()),
)],
)
.unwrap();
record_account_email("work", "alice@work.com").unwrap();
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
val["drive"]["accounts"]["work"]["email_address"],
"manually-set@work.com"
);
}
#[test]
fn accounts_empty_load_remove_byte_identical_via_direct_and_for_wrappers() {
let guard = crate::drive::test_support::EnvGuard::take();
let creds = DriveCredentials {
client_id: "id".to_string(),
client_secret: "secret".into(),
refresh_token: "refresh".into(),
scope: DriveScope::ReadOnly,
};
let dir_direct = guard.clear_credentials();
save_credentials(&creds).unwrap();
let direct_written =
fs::read_to_string(dir_direct.path().join(".omni-dev").join("settings.json")).unwrap();
let direct_loaded = load_credentials().unwrap();
let direct_removed = remove_credentials().unwrap();
let dir_for = guard.clear_credentials();
save_credentials(&creds).unwrap();
let for_written =
fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
let for_loaded = load_credentials_for(None).unwrap();
let for_removed = remove_credentials_for(None).unwrap();
assert_eq!(direct_written, for_written);
assert_eq!(direct_loaded.client_id, for_loaded.client_id);
assert_eq!(
direct_loaded.client_secret.expose_secret(),
for_loaded.client_secret.expose_secret()
);
assert_eq!(direct_loaded.scope, for_loaded.scope);
assert_eq!(direct_removed, for_removed);
}
}