refactor: replace manual arg parsing in auth commands with clap (#594)
* refactor: replace manual arg parsing in auth commands with clap - Add ScopeMode enum (Default, Readonly, Full, Custom) for type-safe scope selection - Build auth_command() with clap subcommands: login, setup, status, export, logout - Replace manual --help check and match dispatch in handle_auth_command with clap subcommand routing - Replace two-pass manual parsing in handle_login (services extraction) and resolve_scopes (scopes/readonly/full flags) with single clap parse - Add --unmasked flag to export subcommand via clap - Preserve run_login(&[]) public API for setup.rs compatibility - Update all 63 tests to use ScopeMode-based API * refactor: extract parse_login_args helper to deduplicate scope/services parsing * fix: make --readonly, --full, --scopes mutually exclusive via clap conflicts * fix: filter empty strings from custom scopes parsing * refactor: extract build_login_subcommand to eliminate fragile .expect() lookup --------- Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@googleworkspace/cli": minor
|
||||
---
|
||||
|
||||
Refactor all `gws auth` subcommands to use clap for argument parsing
|
||||
|
||||
Replace manual argument parsing in `handle_auth_command`, `handle_login`, `resolve_scopes`, and `handle_export` with structured `clap::Command` definitions. Introduces `ScopeMode` enum for type-safe scope selection and adds proper `--help` support for all auth subcommands.
|
||||
+233
-142
@@ -130,51 +130,196 @@ fn token_cache_path() -> PathBuf {
|
||||
config_dir().join("token_cache.json")
|
||||
}
|
||||
|
||||
/// Which scope set to use for login.
|
||||
enum ScopeMode {
|
||||
/// Use the default scopes (MINIMAL_SCOPES).
|
||||
Default,
|
||||
/// Use readonly scopes.
|
||||
Readonly,
|
||||
/// Use full scopes (incl. pubsub + cloud-platform).
|
||||
Full,
|
||||
/// Use explicitly provided custom scopes.
|
||||
Custom(Vec<String>),
|
||||
}
|
||||
|
||||
/// Build the clap Command for the `login` subcommand.
|
||||
/// Used by both `auth_command()` and `login_command()` as single source of truth.
|
||||
fn build_login_subcommand() -> clap::Command {
|
||||
clap::Command::new("login")
|
||||
.about("Authenticate via OAuth2 (opens browser)")
|
||||
.arg(
|
||||
clap::Arg::new("readonly")
|
||||
.long("readonly")
|
||||
.help("Request read-only scopes")
|
||||
.action(clap::ArgAction::SetTrue)
|
||||
.conflicts_with_all(["full", "scopes"]),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("full")
|
||||
.long("full")
|
||||
.help("Request all scopes incl. pubsub + cloud-platform")
|
||||
.action(clap::ArgAction::SetTrue)
|
||||
.conflicts_with_all(["readonly", "scopes"]),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("scopes")
|
||||
.long("scopes")
|
||||
.help("Comma-separated custom scopes")
|
||||
.value_name("scopes")
|
||||
.conflicts_with_all(["readonly", "full"]),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("services")
|
||||
.short('s')
|
||||
.long("services")
|
||||
.help(
|
||||
"Comma-separated service names to limit scope picker (e.g. drive,gmail,sheets)",
|
||||
)
|
||||
.value_name("services"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the clap Command for `gws auth`.
|
||||
fn auth_command() -> clap::Command {
|
||||
clap::Command::new("auth")
|
||||
.about("Manage authentication for Google Workspace APIs")
|
||||
.subcommand_required(false)
|
||||
.subcommand(build_login_subcommand())
|
||||
.subcommand(
|
||||
clap::Command::new("setup")
|
||||
.about("Configure GCP project + OAuth client (requires gcloud)")
|
||||
.disable_help_flag(true)
|
||||
// setup has its own clap-based arg parsing in setup.rs,
|
||||
// so we pass remaining args through.
|
||||
.arg(
|
||||
clap::Arg::new("args")
|
||||
.trailing_var_arg(true)
|
||||
.allow_hyphen_values(true)
|
||||
.num_args(0..)
|
||||
.value_name("ARGS"),
|
||||
),
|
||||
)
|
||||
.subcommand(clap::Command::new("status").about("Show current authentication state"))
|
||||
.subcommand(
|
||||
clap::Command::new("export")
|
||||
.about("Print decrypted credentials to stdout")
|
||||
.arg(
|
||||
clap::Arg::new("unmasked")
|
||||
.long("unmasked")
|
||||
.help("Show secrets without masking")
|
||||
.action(clap::ArgAction::SetTrue),
|
||||
),
|
||||
)
|
||||
.subcommand(clap::Command::new("logout").about("Clear saved credentials and token cache"))
|
||||
}
|
||||
|
||||
/// Handle `gws auth <subcommand>`.
|
||||
pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
|
||||
const USAGE: &str = concat!(
|
||||
"Usage: gws auth <login|setup|status|export|logout> [options]\n\n",
|
||||
" login Authenticate via OAuth2 (opens browser)\n",
|
||||
" --readonly Request read-only scopes\n",
|
||||
" --full Request all scopes incl. pubsub + cloud-platform\n",
|
||||
" (may trigger restricted_client for unverified apps)\n",
|
||||
" --scopes Comma-separated custom scopes\n",
|
||||
" -s, --services Comma-separated service names to limit scope picker\n",
|
||||
" (e.g. -s drive,gmail,sheets)\n",
|
||||
" setup Configure GCP project + OAuth client (requires gcloud)\n",
|
||||
" --project Use a specific GCP project\n",
|
||||
" --login Run `gws auth login` after successful setup\n",
|
||||
" status Show current authentication state\n",
|
||||
" export Print decrypted credentials to stdout\n",
|
||||
" logout Clear saved credentials and token cache",
|
||||
);
|
||||
let matches = match auth_command()
|
||||
.try_get_matches_from(std::iter::once("auth".to_string()).chain(args.iter().cloned()))
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e)
|
||||
if e.kind() == clap::error::ErrorKind::DisplayHelp
|
||||
|| e.kind() == clap::error::ErrorKind::DisplayVersion =>
|
||||
{
|
||||
e.print().map_err(|io_err| {
|
||||
GwsError::Validation(format!("Failed to print help: {io_err}"))
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(GwsError::Validation(e.to_string())),
|
||||
};
|
||||
|
||||
// Honour --help / -h before treating the first arg as a subcommand.
|
||||
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
|
||||
println!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
match matches.subcommand() {
|
||||
Some(("login", sub_m)) => {
|
||||
let (scope_mode, services_filter) = parse_login_args(sub_m);
|
||||
|
||||
match args[0].as_str() {
|
||||
"login" => run_login(&args[1..]).await,
|
||||
"setup" => crate::setup::run_setup(&args[1..]).await,
|
||||
"status" => handle_status().await,
|
||||
"export" => {
|
||||
let unmasked = args.len() > 1 && args[1] == "--unmasked";
|
||||
handle_login_inner(scope_mode, services_filter).await
|
||||
}
|
||||
Some(("setup", sub_m)) => {
|
||||
// Collect remaining args and delegate to setup's own clap parser.
|
||||
let setup_args: Vec<String> = sub_m
|
||||
.get_many::<String>("args")
|
||||
.map(|vals| vals.cloned().collect())
|
||||
.unwrap_or_default();
|
||||
crate::setup::run_setup(&setup_args).await
|
||||
}
|
||||
Some(("status", _)) => handle_status().await,
|
||||
Some(("export", sub_m)) => {
|
||||
let unmasked = sub_m.get_flag("unmasked");
|
||||
handle_export(unmasked).await
|
||||
}
|
||||
"logout" => handle_logout(),
|
||||
other => Err(GwsError::Validation(format!(
|
||||
"Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout"
|
||||
))),
|
||||
Some(("logout", _)) => handle_logout(),
|
||||
_ => {
|
||||
// No subcommand → print help
|
||||
auth_command()
|
||||
.print_help()
|
||||
.map_err(|e| GwsError::Validation(format!("Failed to print help: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the clap Command for `gws auth login` (used by `run_login` for
|
||||
/// standalone parsing when called from setup.rs).
|
||||
fn login_command() -> clap::Command {
|
||||
build_login_subcommand()
|
||||
}
|
||||
|
||||
/// Extract `ScopeMode` and optional services filter from parsed login args.
|
||||
fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option<HashSet<String>>) {
|
||||
let scope_mode = if let Some(scopes_str) = matches.get_one::<String>("scopes") {
|
||||
ScopeMode::Custom(
|
||||
scopes_str
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
)
|
||||
} else if matches.get_flag("readonly") {
|
||||
ScopeMode::Readonly
|
||||
} else if matches.get_flag("full") {
|
||||
ScopeMode::Full
|
||||
} else {
|
||||
ScopeMode::Default
|
||||
};
|
||||
|
||||
let services_filter: Option<HashSet<String>> = matches.get_one::<String>("services").map(|v| {
|
||||
v.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
});
|
||||
|
||||
(scope_mode, services_filter)
|
||||
}
|
||||
|
||||
/// Run the `auth login` flow.
|
||||
///
|
||||
/// Exposed for internal orchestration (e.g. `auth setup --login`).
|
||||
/// Accepts raw args for backward compat with setup.rs calling `run_login(&[])`.
|
||||
pub async fn run_login(args: &[String]) -> Result<(), GwsError> {
|
||||
handle_login(args).await
|
||||
let matches = match login_command()
|
||||
.try_get_matches_from(std::iter::once("login".to_string()).chain(args.iter().cloned()))
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e)
|
||||
if e.kind() == clap::error::ErrorKind::DisplayHelp
|
||||
|| e.kind() == clap::error::ErrorKind::DisplayVersion =>
|
||||
{
|
||||
e.print().map_err(|io_err| {
|
||||
GwsError::Validation(format!("Failed to print help: {io_err}"))
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(GwsError::Validation(e.to_string())),
|
||||
};
|
||||
|
||||
let (scope_mode, services_filter) = parse_login_args(&matches);
|
||||
|
||||
handle_login_inner(scope_mode, services_filter).await
|
||||
}
|
||||
/// Custom delegate that prints the OAuth URL on its own line for easy copying.
|
||||
/// Optionally includes `login_hint` in the URL for account pre-selection.
|
||||
@@ -212,36 +357,11 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
// Extract -s/--services from args
|
||||
let mut services_filter: Option<HashSet<String>> = None;
|
||||
let mut filtered_args: Vec<String> = Vec::new();
|
||||
let mut skip_next = false;
|
||||
for i in 0..args.len() {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
let services_str = if (args[i] == "-s" || args[i] == "--services") && i + 1 < args.len() {
|
||||
skip_next = true;
|
||||
Some(args[i + 1].as_str())
|
||||
} else {
|
||||
args[i].strip_prefix("--services=")
|
||||
};
|
||||
|
||||
if let Some(value) = services_str {
|
||||
services_filter = Some(
|
||||
value
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
filtered_args.push(args[i].clone());
|
||||
}
|
||||
|
||||
/// Inner login implementation that takes already-parsed options.
|
||||
async fn handle_login_inner(
|
||||
scope_mode: ScopeMode,
|
||||
services_filter: Option<HashSet<String>>,
|
||||
) -> Result<(), GwsError> {
|
||||
// Resolve client_id and client_secret:
|
||||
// 1. Env vars (highest priority)
|
||||
// 2. Saved client_secret.json from `gws auth setup` or manual download
|
||||
@@ -258,12 +378,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
}
|
||||
|
||||
// Determine scopes: explicit flags > interactive TUI > defaults
|
||||
let scopes = resolve_scopes(
|
||||
&filtered_args,
|
||||
project_id.as_deref(),
|
||||
services_filter.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let scopes = resolve_scopes(scope_mode, project_id.as_deref(), services_filter.as_ref()).await;
|
||||
|
||||
// Remove restrictive scopes when broader alternatives are present.
|
||||
let mut scopes = filter_redundant_restrictive_scopes(scopes);
|
||||
@@ -479,32 +594,25 @@ fn resolve_client_credentials() -> Result<(String, String, Option<String>), GwsE
|
||||
/// When `services_filter` is `Some`, only scopes belonging to the specified
|
||||
/// services are shown in the picker (or returned in non-interactive mode).
|
||||
async fn resolve_scopes(
|
||||
args: &[String],
|
||||
scope_mode: ScopeMode,
|
||||
project_id: Option<&str>,
|
||||
services_filter: Option<&HashSet<String>>,
|
||||
) -> Vec<String> {
|
||||
// Explicit --scopes flag takes priority (bypasses services filter)
|
||||
for i in 0..args.len() {
|
||||
if args[i] == "--scopes" && i + 1 < args.len() {
|
||||
return args[i + 1]
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect();
|
||||
match scope_mode {
|
||||
ScopeMode::Custom(scopes) => return scopes,
|
||||
ScopeMode::Readonly => {
|
||||
let scopes: Vec<String> = READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let mut result = filter_scopes_by_services(scopes, services_filter);
|
||||
augment_with_dynamic_scopes(&mut result, services_filter, true).await;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
let readonly_only = args.iter().any(|a| a == "--readonly");
|
||||
|
||||
if readonly_only {
|
||||
let scopes: Vec<String> = READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let mut result = filter_scopes_by_services(scopes, services_filter);
|
||||
augment_with_dynamic_scopes(&mut result, services_filter, true).await;
|
||||
return result;
|
||||
}
|
||||
if args.iter().any(|a| a == "--full") {
|
||||
let scopes: Vec<String> = FULL_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let mut result = filter_scopes_by_services(scopes, services_filter);
|
||||
augment_with_dynamic_scopes(&mut result, services_filter, false).await;
|
||||
return result;
|
||||
ScopeMode::Full => {
|
||||
let scopes: Vec<String> = FULL_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let mut result = filter_scopes_by_services(scopes, services_filter);
|
||||
augment_with_dynamic_scopes(&mut result, services_filter, false).await;
|
||||
return result;
|
||||
}
|
||||
ScopeMode::Default => {} // fall through to interactive picker / defaults
|
||||
}
|
||||
|
||||
// Interactive scope picker when running in a TTY
|
||||
@@ -1453,66 +1561,58 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper to run resolve_scopes in tests (async).
|
||||
fn run_resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String> {
|
||||
fn run_resolve_scopes(scope_mode: ScopeMode, project_id: Option<&str>) -> Vec<String> {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(resolve_scopes(args, project_id, None))
|
||||
rt.block_on(resolve_scopes(scope_mode, project_id, None))
|
||||
}
|
||||
|
||||
/// Helper to run resolve_scopes with a services filter.
|
||||
fn run_resolve_scopes_with_services(
|
||||
args: &[String],
|
||||
scope_mode: ScopeMode,
|
||||
project_id: Option<&str>,
|
||||
services: &[&str],
|
||||
) -> Vec<String> {
|
||||
let filter: HashSet<String> = services.iter().map(|s| s.to_string()).collect();
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(resolve_scopes(args, project_id, Some(&filter)))
|
||||
rt.block_on(resolve_scopes(scope_mode, project_id, Some(&filter)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_returns_defaults_when_no_flag() {
|
||||
let args: Vec<String> = vec![];
|
||||
let scopes = run_resolve_scopes(&args, None);
|
||||
let scopes = run_resolve_scopes(ScopeMode::Default, None);
|
||||
assert_eq!(scopes.len(), DEFAULT_SCOPES.len());
|
||||
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_returns_custom_scopes() {
|
||||
let args: Vec<String> = vec![
|
||||
"--scopes".to_string(),
|
||||
"https://www.googleapis.com/auth/drive.readonly".to_string(),
|
||||
];
|
||||
let scopes = run_resolve_scopes(&args, None);
|
||||
let scopes = run_resolve_scopes(
|
||||
ScopeMode::Custom(vec![
|
||||
"https://www.googleapis.com/auth/drive.readonly".to_string()
|
||||
]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(scopes.len(), 1);
|
||||
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive.readonly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_handles_multiple_comma_separated() {
|
||||
let args: Vec<String> = vec![
|
||||
"--scopes".to_string(),
|
||||
"https://www.googleapis.com/auth/drive, https://www.googleapis.com/auth/gmail.readonly"
|
||||
.to_string(),
|
||||
];
|
||||
let scopes = run_resolve_scopes(&args, None);
|
||||
fn resolve_scopes_handles_multiple_custom() {
|
||||
let scopes = run_resolve_scopes(
|
||||
ScopeMode::Custom(vec![
|
||||
"https://www.googleapis.com/auth/drive".to_string(),
|
||||
"https://www.googleapis.com/auth/gmail.readonly".to_string(),
|
||||
]),
|
||||
None,
|
||||
);
|
||||
assert_eq!(scopes.len(), 2);
|
||||
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
|
||||
assert_eq!(scopes[1], "https://www.googleapis.com/auth/gmail.readonly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_ignores_trailing_flag() {
|
||||
// --scopes with no value should use defaults
|
||||
let args: Vec<String> = vec!["--scopes".to_string()];
|
||||
let scopes = run_resolve_scopes(&args, None);
|
||||
assert_eq!(scopes.len(), DEFAULT_SCOPES.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_readonly_returns_readonly_scopes() {
|
||||
let args = vec!["--readonly".to_string()];
|
||||
let scopes = run_resolve_scopes(&args, None);
|
||||
let scopes = run_resolve_scopes(ScopeMode::Readonly, None);
|
||||
assert_eq!(scopes.len(), READONLY_SCOPES.len());
|
||||
for scope in &scopes {
|
||||
assert!(
|
||||
@@ -1523,16 +1623,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_custom_overrides_readonly() {
|
||||
// --scopes takes priority over --readonly
|
||||
let args = vec![
|
||||
"--scopes".to_string(),
|
||||
"https://www.googleapis.com/auth/drive".to_string(),
|
||||
"--readonly".to_string(),
|
||||
];
|
||||
let scopes = run_resolve_scopes(&args, None);
|
||||
assert_eq!(scopes.len(), 1);
|
||||
assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
|
||||
fn resolve_scopes_full_returns_full_scopes() {
|
||||
let scopes = run_resolve_scopes(ScopeMode::Full, None);
|
||||
assert_eq!(scopes.len(), FULL_SCOPES.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1984,8 +2077,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_with_services_filter() {
|
||||
let args: Vec<String> = vec![];
|
||||
let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "gmail"]);
|
||||
let scopes =
|
||||
run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "gmail"]);
|
||||
assert!(!scopes.is_empty());
|
||||
for scope in &scopes {
|
||||
let short = scope
|
||||
@@ -2002,8 +2095,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_services_filter_unknown_service_ignored() {
|
||||
let args: Vec<String> = vec![];
|
||||
let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "nonexistent"]);
|
||||
let scopes =
|
||||
run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "nonexistent"]);
|
||||
assert!(!scopes.is_empty());
|
||||
// Should contain drive scope but not be affected by nonexistent
|
||||
assert!(scopes.iter().any(|s| s.contains("/auth/drive")));
|
||||
@@ -2011,8 +2104,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_services_takes_priority_with_readonly() {
|
||||
let args = vec!["--readonly".to_string()];
|
||||
let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]);
|
||||
let scopes = run_resolve_scopes_with_services(ScopeMode::Readonly, None, &["drive"]);
|
||||
assert!(!scopes.is_empty());
|
||||
for scope in &scopes {
|
||||
let short = scope
|
||||
@@ -2027,8 +2119,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_services_takes_priority_with_full() {
|
||||
let args = vec!["--full".to_string()];
|
||||
let scopes = run_resolve_scopes_with_services(&args, None, &["gmail"]);
|
||||
let scopes = run_resolve_scopes_with_services(ScopeMode::Full, None, &["gmail"]);
|
||||
assert!(!scopes.is_empty());
|
||||
for scope in &scopes {
|
||||
let short = scope
|
||||
@@ -2043,12 +2134,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_scopes_explicit_scopes_bypass_services_filter() {
|
||||
// --scopes should take priority over -s
|
||||
let args = vec![
|
||||
"--scopes".to_string(),
|
||||
"https://www.googleapis.com/auth/calendar".to_string(),
|
||||
];
|
||||
let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]);
|
||||
// Custom scopes take priority over services filter
|
||||
let scopes = run_resolve_scopes_with_services(
|
||||
ScopeMode::Custom(vec!["https://www.googleapis.com/auth/calendar".to_string()]),
|
||||
None,
|
||||
&["drive"],
|
||||
);
|
||||
assert_eq!(scopes.len(), 1);
|
||||
assert_eq!(scopes[0], "https://www.googleapis.com/auth/calendar");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user