1use crate::commands::safety::SafetyProfile;
27use crate::core::auth::GoogleAuth;
28use crate::discovery::{self, Method, Service};
29use crate::output::{OutputFormat, print_output};
30use anyhow::{Context, Result, bail};
31use clap::{Args, Subcommand};
32use serde_json::{Value, json};
33
34#[derive(Subcommand, Debug)]
35pub enum ApiCommands {
36 List(ApiListArgs),
38 Describe(ApiDescribeArgs),
40 Call(ApiCallArgs),
42 Refresh(ApiRefreshArgs),
45}
46
47#[derive(Args, Debug)]
48pub struct ApiRefreshArgs {
49 #[arg(long)]
53 pub service: Option<String>,
54
55 #[arg(short, long, value_enum, default_value = "json")]
57 pub format: OutputFormat,
58}
59
60#[derive(Args, Debug)]
61pub struct ApiListArgs {
62 #[arg(long)]
66 pub service: Option<String>,
67
68 #[arg(long)]
70 pub filter: Option<String>,
71
72 #[arg(long)]
74 pub grouped: bool,
75
76 #[arg(short, long, value_enum, default_value = "json")]
78 pub format: OutputFormat,
79}
80
81#[derive(Args, Debug)]
82pub struct ApiDescribeArgs {
83 pub method: String,
85
86 #[arg(short, long, value_enum, default_value = "json")]
88 pub format: OutputFormat,
89}
90
91#[derive(Args, Debug)]
92pub struct ApiCallArgs {
93 pub method: String,
95
96 #[arg(long, value_name = "JSON")]
100 pub params: Option<String>,
101
102 #[arg(long = "param", value_name = "KEY=VALUE")]
105 pub param: Vec<String>,
106
107 #[arg(long, value_name = "PATH")]
110 pub body_file: Option<String>,
111
112 #[arg(long, value_name = "VERB")]
114 pub method_override: Option<String>,
115
116 #[arg(long = "query", value_name = "KEY=VALUE")]
119 pub query: Vec<String>,
120
121 #[arg(long)]
123 pub dry_run: bool,
124
125 #[arg(short, long, value_enum, default_value = "json")]
132 pub format: OutputFormat,
133}
134
135pub async fn handle_api_cmd(
136 auth: Option<&GoogleAuth>,
137 cmd: ApiCommands,
138 profile: &SafetyProfile,
139) -> Result<()> {
140 match cmd {
141 ApiCommands::List(args) => handle_list(args)?,
143 ApiCommands::Describe(args) => handle_describe(args)?,
144 ApiCommands::Call(args) => {
145 handle_call(auth, args, profile).await?
150 }
151 ApiCommands::Refresh(args) => handle_refresh(args).await?,
153 }
154 Ok(())
155}
156
157async fn handle_refresh(args: ApiRefreshArgs) -> Result<()> {
158 let reports = crate::discovery::refresh(args.service.as_deref())
159 .await
160 .map_err(anyhow::Error::msg)?;
161
162 let refreshed = reports.iter().filter(|r| r.wrote_cache).count();
163 let skipped = reports.iter().filter(|r| r.skipped_older).count();
164 eprintln!("{refreshed} refreshed, {skipped} already current");
166 print_output(
167 &serde_json::json!({
168 "refreshed": refreshed,
169 "alreadyCurrent": skipped,
170 "services": reports.iter().map(|r| serde_json::json!({
171 "service": r.service,
172 "from": r.from,
173 "to": r.to,
174 "methods": r.methods,
175 "wroteCache": r.wrote_cache,
176 "skippedOlder": r.skipped_older,
177 })).collect::<Vec<_>>(),
178 "nextStep": "restart grr (each invocation is a fresh process) to use the refreshed index",
179 }),
180 args.format,
181 )?;
182 Ok(())
183}
184
185fn matches_filter(method: &Method, needle: &str) -> bool {
186 let needle = needle.to_ascii_lowercase();
187 method.id.to_ascii_lowercase().contains(&needle)
188 || method.description.to_ascii_lowercase().contains(&needle)
189}
190
191fn handle_list(args: ApiListArgs) -> Result<()> {
192 let all = discovery::services();
193 let selected: Vec<(&&str, &Service)> = match &args.service {
194 Some(name) => {
195 let service = all.get_key_value(name.as_str()).ok_or_else(|| {
196 let known: Vec<&str> = all.keys().copied().collect();
197 anyhow::anyhow!("unknown service `{name}`; known: {}", known.join(", "))
198 })?;
199 vec![service]
200 }
201 None => all.iter().collect(),
202 };
203
204 let filtered: Vec<(&&str, &Service, Vec<&Method>)> = selected
205 .into_iter()
206 .map(|(name, service)| {
207 let methods: Vec<&Method> = match &args.filter {
208 Some(needle) => service
209 .methods
210 .iter()
211 .filter(|m| matches_filter(m, needle))
212 .collect(),
213 None => service.methods.iter().collect(),
214 };
215 (name, service, methods)
216 })
217 .filter(|(_, _, methods)| !methods.is_empty())
218 .collect();
219
220 if args.grouped {
221 let payload: Vec<Value> = filtered
222 .iter()
223 .map(|(_, service, methods)| {
224 json!({
225 "service": service.name,
226 "version": service.version,
227 "revision": service.revision,
228 "count": methods.len(),
229 "methods": methods.iter().map(|m| json!({
230 "id": format!("{}.{}", service.name, m.id),
231 "http": m.http_method,
232 })).collect::<Vec<_>>(),
233 })
234 })
235 .collect();
236 print_output(&Value::Array(payload), args.format)?;
237 return Ok(());
238 }
239
240 let total: usize = filtered.iter().map(|(_, _, m)| m.len()).sum();
241 let payload: Vec<Value> = filtered
242 .iter()
243 .flat_map(|(_, service, methods)| {
244 methods.iter().map(move |m| {
245 json!({
246 "id": format!("{}.{}", service.name, m.id),
247 "http": m.http_method,
248 "summary": first_sentence(&m.description),
249 })
250 })
251 })
252 .collect();
253
254 eprintln!("{total} methods");
256 print_output(&Value::Array(payload), args.format)?;
257 Ok(())
258}
259
260fn first_sentence(text: &str) -> String {
261 match text.find(". ") {
262 Some(idx) => text[..idx + 1].to_owned(),
263 None => text.chars().take(120).collect(),
264 }
265}
266
267fn handle_describe(args: ApiDescribeArgs) -> Result<()> {
268 let (service, method) = discovery::resolve(&args.method).map_err(anyhow::Error::msg)?;
269 print_output(
270 &json!({
271 "id": format!("{}.{}", service.name, method.id),
272 "service": service.name,
273 "apiVersion": service.version,
274 "discoveryRevision": service.revision,
275 "httpMethod": method.http_method,
276 "path": method.path,
277 "urlTemplate": format!(
280 "{}{}{}",
281 service.root_url.as_deref().unwrap_or_default(),
282 service.base(),
283 method.path
284 ),
285 "description": method.description,
286 "scopes": method.scopes,
287 "leastPrivilegeScope": method.least_privilege_scope(),
288 "parameters": method.parameters.iter().map(|p| json!({
289 "name": p.name,
290 "type": p.kind,
291 "required": p.required,
292 "repeated": p.repeated,
293 "description": p.description,
294 "enum": p.enum_values,
295 })).collect::<Vec<_>>(),
296 "hint": format!(
297 "grr api call {}.{} --params '{{\"{}\": \"...\"}}'",
298 service.name, method.id,
299 method.parameters.first().map(|p| p.name.as_str()).unwrap_or("userId")
300 ),
301 }),
302 args.format,
303 )?;
304 Ok(())
305}
306
307async fn handle_call(
308 auth: Option<&GoogleAuth>,
309 args: ApiCallArgs,
310 profile: &SafetyProfile,
311) -> Result<()> {
312 let (service, method) = discovery::resolve(&args.method).map_err(|message| {
313 anyhow::anyhow!(
314 "{message}\n\nRun `grr api list {}` to see what is available.",
315 service_hint(&args.method)
316 )
317 })?;
318
319 if let Err(message) = profile.check(&service.name, method) {
324 bail!("{message}");
325 }
326
327 let params = parse_params(args.params.as_deref(), &args.param)?;
328 let payload = call_method(
329 auth,
330 service,
331 method,
332 params,
333 CallOptions {
334 body_file: args.body_file,
335 verb_override: args.method_override,
336 query_extras: args.query,
337 dry_run: args.dry_run,
338 },
339 )
340 .await?;
341 print_output(&payload, args.format)?;
342 Ok(())
343}
344
345#[derive(Debug, Default)]
349pub(crate) struct CallOptions {
350 pub body_file: Option<String>,
353 pub verb_override: Option<String>,
356 pub query_extras: Vec<String>,
358 pub dry_run: bool,
360}
361
362#[derive(Debug)]
366pub(crate) struct RequestPlan {
367 pub verb: String,
368 pub url: String,
369 pub body: Option<Value>,
370}
371
372pub(crate) fn plan_request(
380 service: &Service,
381 method: &Method,
382 params: &serde_json::Map<String, Value>,
383 body_raw: Option<&str>,
384 verb_override: Option<&str>,
385 query_extras: &[String],
386) -> Result<RequestPlan> {
387 for required in &method.required {
389 if !params.contains_key(required) {
390 let known: Vec<String> = method
391 .parameters
392 .iter()
393 .filter(|p| p.required)
394 .map(|p| format!("{} ({})", p.name, p.kind))
395 .collect();
396 bail!(
397 "missing required parameter `{required}` for {}.{}\nRequired: {}",
398 service.name,
399 method.id,
400 if known.is_empty() {
401 "none".to_owned()
402 } else {
403 known.join(", ")
404 }
405 );
406 }
407 }
408
409 let verb = verb_override
410 .unwrap_or(&method.http_method)
411 .to_ascii_uppercase();
412 let path = discovery::build_path(service, method, params).map_err(anyhow::Error::msg)?;
413 let mut query = discovery::build_query(method, params);
414 for extra in query_extras {
415 let Some((k, v)) = extra.split_once('=') else {
416 bail!("--query expects KEY=VALUE, got `{extra}`");
417 };
418 if !query.is_empty() {
419 query.push('&');
420 }
421 query.push_str(&urlencoding::encode(k));
422 query.push('=');
423 query.push_str(&urlencoding::encode(v));
424 }
425 let url = format!(
426 "{}{}{}",
427 service.root_url.as_deref().unwrap_or_default(),
428 path,
429 query
430 );
431
432 let body: Option<Value> = match body_raw {
437 Some(raw) => {
438 Some(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_owned())))
439 }
440 None => {
441 let placeholders = method.path_placeholders();
442 let is_write = matches!(verb.as_str(), "POST" | "PATCH" | "PUT");
443 if !is_write {
444 None
445 } else {
446 let mut rest = serde_json::Map::new();
447 for (key, value) in params {
448 if placeholders.contains(&key.as_str()) {
449 continue;
450 }
451 if let Some(param) = method.parameter(key)
452 && param.location.as_deref() == Some("path")
453 {
454 continue;
455 }
456 rest.insert(key.clone(), value.clone());
457 }
458 (!rest.is_empty()).then_some(Value::Object(rest))
459 }
460 }
461 };
462
463 Ok(RequestPlan { verb, url, body })
464}
465
466pub(crate) fn dry_run_payload(method: &Method, plan: &RequestPlan) -> Value {
469 json!({
470 "method": plan.verb,
471 "url": plan.url,
472 "body": plan.body,
473 "scopesRequested": method.scopes,
474 "leastPrivilegeScope": method.least_privilege_scope(),
475 "dryRun": true,
476 })
477}
478
479async fn read_body_source(source: &str) -> Result<String> {
488 if source == "-" {
489 let mut buf = String::new();
490 tokio::io::AsyncReadExt::read_to_string(&mut tokio::io::stdin(), &mut buf)
491 .await
492 .with_context(|| "reading body from stdin")?;
493 Ok(buf)
494 } else {
495 tokio::fs::read_to_string(source)
496 .await
497 .with_context(|| format!("reading body from {source}"))
498 }
499}
500
501pub(crate) async fn call_method(
507 auth: Option<&GoogleAuth>,
508 service: &Service,
509 method: &Method,
510 params: serde_json::Map<String, Value>,
511 options: CallOptions,
512) -> Result<Value> {
513 let body_raw = match options.body_file.as_deref() {
514 Some(source) => Some(read_body_source(source).await?),
515 None => None,
516 };
517 let plan = plan_request(
518 service,
519 method,
520 ¶ms,
521 body_raw.as_deref(),
522 options.verb_override.as_deref(),
523 &options.query_extras,
524 )?;
525
526 if options.dry_run {
527 return Ok(dry_run_payload(method, &plan));
531 }
532
533 let auth = auth.ok_or_else(|| anyhow::anyhow!("{}", crate::core::config::NO_CLIENT_HELP))?;
535
536 let needed = method.least_privilege_scope().unwrap_or_default();
540 if !needed.is_empty() && !crate::core::auth::SCOPES.contains(&needed) {
541 eprintln!(
542 "note: {}.{} wants `{needed}`, which is outside the scopes this build consented to. \
543 Attempting anyway; if Google answers 403, re-run `grr auth login` after extending the scope set.",
544 service.name, method.id
545 );
546 }
547 let token = auth.get_access_token().await?;
548
549 let parsed_url = url::Url::parse(&plan.url).with_context(|| {
550 format!(
551 "`{}` is not a valid URL (Discovery path + parameters)",
552 plan.url
553 )
554 })?;
555
556 let mut request = auth.http().request(
557 reqwest::Method::from_bytes(plan.verb.as_bytes()).unwrap_or(reqwest::Method::GET),
558 parsed_url,
559 );
560 if let Some(payload) = &plan.body {
561 request = request.json(payload);
562 }
563 let response = request
564 .bearer_auth(token)
565 .send()
566 .await
567 .map_err(|e| anyhow::anyhow!("{} {} failed: {e}", plan.verb, plan.url))?;
568
569 let status = response.status();
570 let text = response.text().await.unwrap_or_default();
571 let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()));
572
573 if !status.is_success() {
574 if status.as_u16() == 403
577 && let Some(scope) = method.least_privilege_scope()
578 {
579 bail!(
580 "{} {} -> HTTP 403: this method needs the `{scope}` scope, \
581 which this build was not consented for.\n\
582 Every other scope grr holds is working; re-consent with that scope added \
583 to `SCOPES` in src/core/auth/mod.rs and rebuild.",
584 plan.verb,
585 plan.url
586 );
587 }
588 bail!(
589 "{} {} -> HTTP {}\n{}",
590 plan.verb,
591 plan.url,
592 status.as_u16(),
593 serde_json::to_string_pretty(&parsed).unwrap_or(text)
594 );
595 }
596
597 Ok(parsed)
598}
599
600fn service_hint(method_id: &str) -> &str {
601 method_id.split('.').next().unwrap_or("gmail")
602}
603
604pub(crate) fn parse_params(
609 json_blob: Option<&str>,
610 pairs: &[String],
611) -> Result<serde_json::Map<String, Value>> {
612 let mut map = serde_json::Map::new();
613
614 if let Some(blob) = json_blob {
615 let parsed: Value = serde_json::from_str(blob)
616 .with_context(|| "--params must be a JSON object, e.g. '{\"userId\":\"me\"}'")?;
617 let Value::Object(object) = parsed else {
618 bail!("--params must be a JSON object, got {}", type_name(&parsed));
619 };
620 map.extend(object);
621 }
622
623 for pair in pairs {
624 let Some((key, value)) = pair.split_once('=') else {
625 bail!("--param expects KEY=VALUE, got `{pair}`");
626 };
627 let coerced = serde_json::from_str::<Value>(value)
631 .unwrap_or_else(|_| Value::String(value.to_owned()));
632 map.insert(key.to_owned(), coerced);
633 }
634
635 Ok(map)
636}
637
638fn type_name(value: &Value) -> &'static str {
639 match value {
640 Value::Null => "null",
641 Value::Bool(_) => "a boolean",
642 Value::Number(_) => "a number",
643 Value::String(_) => "a string",
644 Value::Array(_) => "an array",
645 Value::Object(_) => "an object",
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn json_params_are_merged() {
655 let params = parse_params(Some(r#"{"userId":"me","q":"is:unread"}"#), &[]).unwrap();
656 assert_eq!(params.get("userId").unwrap(), "me");
657 assert_eq!(params.get("q").unwrap(), "is:unread");
658 }
659
660 #[test]
661 fn param_flags_win_over_json_params() {
662 let params =
665 parse_params(Some(r#"{"userId":"me"}"#), &["userId=someone-else".into()]).unwrap();
666 assert_eq!(params.get("userId").unwrap(), "someone-else");
667 }
668
669 #[test]
670 fn param_values_are_coerced_to_json_types() {
671 let params =
672 parse_params(None, &["a=10".into(), "b=true".into(), "c=text".into()]).unwrap();
673 assert_eq!(params.get("a").unwrap(), 10);
674 assert_eq!(params.get("b").unwrap(), true);
675 assert_eq!(params.get("c").unwrap(), "text");
676 }
677
678 #[test]
679 fn values_containing_equals_are_preserved() {
680 let params = parse_params(None, &["q=name=a.pdf".into()]).unwrap();
682 assert_eq!(params.get("q").unwrap(), "name=a.pdf");
683 }
684
685 #[test]
686 fn malformed_input_is_rejected_with_guidance() {
687 assert!(parse_params(Some("not json"), &[]).is_err());
688 assert!(parse_params(Some("[1,2]"), &[]).is_err());
689 assert!(parse_params(None, &["novalue".into()]).is_err());
690 }
691
692 fn plan(id: &str, params: &[(&str, Value)]) -> (super::RequestPlan, &'static Method) {
693 let (service, method) = crate::discovery::resolve(id).unwrap();
694 let map: serde_json::Map<String, Value> = params
695 .iter()
696 .map(|(k, v)| ((*k).to_owned(), v.clone()))
697 .collect();
698 (
699 super::plan_request(service, method, &map, None, None, &[]).unwrap(),
700 method,
701 )
702 }
703
704 #[test]
705 fn plan_builds_the_url_from_discovery() {
706 let (plan, method) = plan(
707 "gmail.users.messages.list",
708 &[("userId", json!("me")), ("q", json!("is:unread"))],
709 );
710 assert_eq!(plan.verb, "GET");
711 assert_eq!(
712 plan.url,
713 "https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread"
714 );
715 assert_eq!(plan.body, None);
716 assert_eq!(method.id, "users.messages.list");
717 }
718
719 #[test]
720 fn plan_rejects_a_missing_required_parameter_by_name() {
721 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
722 let err = super::plan_request(service, method, &serde_json::Map::new(), None, None, &[])
723 .unwrap_err()
724 .to_string();
725 assert!(err.contains("missing required parameter `userId`"), "{err}");
726 }
727
728 #[test]
729 fn plan_appends_query_extras_after_discovery_pairs() {
730 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
731 let mut params = serde_json::Map::new();
732 params.insert("userId".into(), json!("me"));
733 params.insert("q".into(), json!("is:unread"));
734 let plan = super::plan_request(
735 service,
736 method,
737 ¶ms,
738 None,
739 None,
740 &["alt=json".to_owned()],
741 )
742 .unwrap();
743 assert!(plan.url.ends_with("q=is%3Aunread&alt=json"), "{}", plan.url);
744 }
745
746 #[test]
747 fn plan_rejects_malformed_query_extras() {
748 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
749 let mut params = serde_json::Map::new();
750 params.insert("userId".into(), json!("me"));
751 let err = super::plan_request(
752 service,
753 method,
754 ¶ms,
755 None,
756 None,
757 &["no-equals-sign".to_owned()],
758 )
759 .unwrap_err()
760 .to_string();
761 assert!(err.contains("KEY=VALUE"), "{err}");
762 }
763
764 #[test]
765 fn plan_uppercases_a_verb_override() {
766 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
767 let mut params = serde_json::Map::new();
768 params.insert("userId".into(), json!("me"));
769 let plan = super::plan_request(service, method, ¶ms, None, Some("post"), &[]).unwrap();
770 assert_eq!(plan.verb, "POST");
771 }
772
773 #[test]
774 fn plan_synthesizes_a_body_for_write_verbs_only() {
775 let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
778 let mut params = serde_json::Map::new();
779 params.insert("name".into(), json!("quarterly.pdf"));
780 let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
781 assert_eq!(plan.body, Some(json!({"name": "quarterly.pdf"})));
782
783 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
786 let mut params = serde_json::Map::new();
787 params.insert("userId".into(), json!("me"));
788 params.insert("q".into(), json!("is:unread"));
789 let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
790 assert_eq!(plan.body, None);
791 assert!(plan.url.contains("q=is%3Aunread"));
792 }
793
794 #[test]
795 fn plan_parses_json_bodies_and_wraps_the_rest() {
796 let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
800 let plan = super::plan_request(
801 service,
802 method,
803 &serde_json::Map::new(),
804 Some(r#"{"name":"uploaded.pdf"}"#),
805 None,
806 &[],
807 )
808 .unwrap();
809 assert_eq!(plan.body, Some(json!({"name": "uploaded.pdf"})));
810
811 let plan = super::plan_request(
812 service,
813 method,
814 &serde_json::Map::new(),
815 Some("not json at all"),
816 None,
817 &[],
818 )
819 .unwrap();
820 assert_eq!(plan.body, Some(json!("not json at all")));
821 }
822
823 #[tokio::test]
824 async fn read_body_source_reads_a_file() {
825 let dir = tempfile::tempdir().unwrap();
826 let path = dir.path().join("body.json");
827 tokio::fs::write(&path, r#"{"a":1}"#).await.unwrap();
828 let raw = super::read_body_source(path.to_str().unwrap())
829 .await
830 .unwrap();
831 assert_eq!(raw, r#"{"a":1}"#);
832 }
833
834 #[test]
835 fn dry_run_payload_is_a_pure_function_of_the_plan() {
836 let (plan, method) = plan("gmail.users.messages.list", &[("userId", json!("me"))]);
837 let payload = super::dry_run_payload(method, &plan);
838 assert_eq!(payload["method"], "GET");
839 assert_eq!(
840 payload["url"],
841 "https://gmail.googleapis.com/gmail/v1/users/me/messages"
842 );
843 assert_eq!(payload["dryRun"], true);
844 assert!(
845 payload["leastPrivilegeScope"]
846 .as_str()
847 .unwrap()
848 .ends_with("gmail.readonly")
849 );
850 }
851
852 async fn test_auth() -> GoogleAuth {
853 let config = crate::core::config::OAuthConfig {
854 client_id: "test-id.apps.googleusercontent.com".into(),
855 client_secret: None,
856 };
857 let storage = crate::core::auth::TokenStorage {
858 access_token: "test-token".into(),
859 refresh_token: None,
860 expires_at: u64::MAX,
861 token_type: "Bearer".into(),
862 scope: String::new(),
863 };
864 GoogleAuth::with_token(config, storage).await.unwrap()
865 }
866
867 #[tokio::test]
868 async fn handle_call_refuses_a_write_under_readonly() {
869 let auth = test_auth().await;
872 let args = ApiCallArgs {
873 method: "gmail.users.messages.send".into(),
874 params: None,
875 param: vec![],
876 body_file: None,
877 method_override: None,
878 query: vec![],
879 dry_run: true,
880 format: OutputFormat::Json,
881 };
882 let err = handle_call(Some(&auth), args, &SafetyProfile::readonly())
883 .await
884 .unwrap_err()
885 .to_string();
886 assert!(err.contains("POST"), "{err}");
887 assert!(err.contains("--readonly"), "{err}");
888 assert!(err.contains("Rerun without --readonly"), "{err}");
889 }
890
891 #[tokio::test]
892 async fn handle_call_refuses_a_denied_service_and_a_denied_verb() {
893 let auth = test_auth().await;
894 let args = ApiCallArgs {
895 method: "gmail.users.messages.list".into(),
896 params: None,
897 param: vec![],
898 body_file: None,
899 method_override: None,
900 query: vec![],
901 dry_run: true,
902 format: OutputFormat::Json,
903 };
904 let profile = SafetyProfile::new(false, vec!["gmail".to_owned()], vec![]);
905 let err = handle_call(Some(&auth), args, &profile)
906 .await
907 .unwrap_err()
908 .to_string();
909 assert!(err.contains("--deny-service gmail"), "{err}");
910
911 let args = ApiCallArgs {
912 method: "gmail.users.messages.list".into(),
913 params: None,
914 param: vec![],
915 body_file: None,
916 method_override: None,
917 query: vec![],
918 dry_run: true,
919 format: OutputFormat::Json,
920 };
921 let profile = SafetyProfile::new(false, vec![], vec!["get".to_owned()]);
922 let err = handle_call(Some(&auth), args, &profile)
923 .await
924 .unwrap_err()
925 .to_string();
926 assert!(err.contains("--deny-verb GET"), "{err}");
927 }
928
929 #[tokio::test]
930 async fn handle_call_runs_a_read_under_readonly() {
931 let auth = test_auth().await;
932 let args = ApiCallArgs {
933 method: "gmail.users.messages.list".into(),
934 params: None,
935 param: vec!["userId=me".into()],
936 body_file: None,
937 method_override: None,
938 query: vec![],
939 dry_run: true,
940 format: OutputFormat::Json,
941 };
942 assert!(
944 handle_call(Some(&auth), args, &SafetyProfile::readonly())
945 .await
946 .is_ok()
947 );
948 }
949}