1use std::collections::BTreeMap;
28use std::sync::{Mutex, MutexGuard};
29
30use crate::commands::safety::SafetyProfile;
31use crate::core::auth::GoogleAuth;
32use crate::core::http::HttpCore;
33use crate::discovery::{self, Method, Service};
34use crate::output::{OutputFormat, print_output};
35use anyhow::{Context, Result, bail};
36use clap::{Args, Subcommand};
37use serde_json::{Value, json};
38
39#[derive(Subcommand, Debug)]
40pub enum ApiCommands {
41 List(ApiListArgs),
43 Describe(ApiDescribeArgs),
45 Call(ApiCallArgs),
47 Refresh(ApiRefreshArgs),
50}
51
52#[derive(Args, Debug)]
53pub struct ApiRefreshArgs {
54 #[arg(long)]
58 pub service: Option<String>,
59
60 #[arg(short, long, value_enum, default_value = "json")]
62 pub format: OutputFormat,
63}
64
65#[derive(Args, Debug)]
66pub struct ApiListArgs {
67 #[arg(long)]
71 pub service: Option<String>,
72
73 #[arg(long)]
75 pub filter: Option<String>,
76
77 #[arg(long)]
79 pub grouped: bool,
80
81 #[arg(short, long, value_enum, default_value = "json")]
83 pub format: OutputFormat,
84}
85
86#[derive(Args, Debug)]
87pub struct ApiDescribeArgs {
88 pub method: String,
90
91 #[arg(short, long, value_enum, default_value = "json")]
93 pub format: OutputFormat,
94}
95
96#[derive(Args, Debug)]
97pub struct ApiCallArgs {
98 pub method: String,
100
101 #[arg(long, value_name = "JSON")]
105 pub params: Option<String>,
106
107 #[arg(long = "param", value_name = "KEY=VALUE")]
110 pub param: Vec<String>,
111
112 #[arg(long, value_name = "PATH")]
115 pub body_file: Option<String>,
116
117 #[arg(long, value_name = "VERB")]
119 pub method_override: Option<String>,
120
121 #[arg(long = "query", value_name = "KEY=VALUE")]
124 pub query: Vec<String>,
125
126 #[arg(long)]
128 pub dry_run: bool,
129
130 #[arg(short, long, value_enum, default_value = "json")]
137 pub format: OutputFormat,
138}
139
140pub async fn handle_api_cmd(
141 auth: Option<&GoogleAuth>,
142 cmd: ApiCommands,
143 profile: &SafetyProfile,
144) -> Result<()> {
145 match cmd {
146 ApiCommands::List(args) => handle_list(args)?,
148 ApiCommands::Describe(args) => handle_describe(args)?,
149 ApiCommands::Call(args) => {
150 handle_call(auth, args, profile).await?
155 }
156 ApiCommands::Refresh(args) => handle_refresh(args).await?,
158 }
159 Ok(())
160}
161
162async fn handle_refresh(args: ApiRefreshArgs) -> Result<()> {
163 let reports = crate::discovery::refresh(args.service.as_deref())
164 .await
165 .map_err(anyhow::Error::msg)?;
166
167 let refreshed = reports.iter().filter(|r| r.wrote_cache).count();
168 let skipped = reports.iter().filter(|r| r.skipped_older).count();
169 eprintln!("{refreshed} refreshed, {skipped} already current");
171 print_output(
172 &serde_json::json!({
173 "refreshed": refreshed,
174 "alreadyCurrent": skipped,
175 "services": reports.iter().map(|r| serde_json::json!({
176 "service": r.service,
177 "from": r.from,
178 "to": r.to,
179 "methods": r.methods,
180 "wroteCache": r.wrote_cache,
181 "skippedOlder": r.skipped_older,
182 })).collect::<Vec<_>>(),
183 "nextStep": "restart grr (each invocation is a fresh process) to use the refreshed index",
184 }),
185 args.format,
186 )?;
187 Ok(())
188}
189
190fn matches_filter(method: &Method, needle: &str) -> bool {
191 let needle = needle.to_ascii_lowercase();
192 method.id.to_ascii_lowercase().contains(&needle)
193 || method.description.to_ascii_lowercase().contains(&needle)
194}
195
196fn handle_list(args: ApiListArgs) -> Result<()> {
197 let all = discovery::services();
198 let selected: Vec<(&&str, &Service)> = match &args.service {
199 Some(name) => {
200 let service = all.get_key_value(name.as_str()).ok_or_else(|| {
201 let known: Vec<&str> = all.keys().copied().collect();
202 anyhow::anyhow!("unknown service `{name}`; known: {}", known.join(", "))
203 })?;
204 vec![service]
205 }
206 None => all.iter().collect(),
207 };
208
209 let filtered: Vec<(&&str, &Service, Vec<&Method>)> = selected
210 .into_iter()
211 .map(|(name, service)| {
212 let methods: Vec<&Method> = match &args.filter {
213 Some(needle) => service
214 .methods
215 .iter()
216 .filter(|m| matches_filter(m, needle))
217 .collect(),
218 None => service.methods.iter().collect(),
219 };
220 (name, service, methods)
221 })
222 .filter(|(_, _, methods)| !methods.is_empty())
223 .collect();
224
225 if args.grouped {
226 let payload: Vec<Value> = filtered
227 .iter()
228 .map(|(_, service, methods)| {
229 json!({
230 "service": service.name,
231 "version": service.version,
232 "revision": service.revision,
233 "count": methods.len(),
234 "methods": methods.iter().map(|m| json!({
235 "id": format!("{}.{}", service.name, m.id),
236 "http": m.http_method,
237 })).collect::<Vec<_>>(),
238 })
239 })
240 .collect();
241 print_output(&Value::Array(payload), args.format)?;
242 return Ok(());
243 }
244
245 let total: usize = filtered.iter().map(|(_, _, m)| m.len()).sum();
246 let payload: Vec<Value> = filtered
247 .iter()
248 .flat_map(|(_, service, methods)| {
249 methods.iter().map(move |m| {
250 json!({
251 "id": format!("{}.{}", service.name, m.id),
252 "http": m.http_method,
253 "summary": first_sentence(&m.description),
254 })
255 })
256 })
257 .collect();
258
259 eprintln!("{total} methods");
261 print_output(&Value::Array(payload), args.format)?;
262 Ok(())
263}
264
265fn first_sentence(text: &str) -> String {
266 match text.find(". ") {
267 Some(idx) => text[..idx + 1].to_owned(),
268 None => text.chars().take(120).collect(),
269 }
270}
271
272fn handle_describe(args: ApiDescribeArgs) -> Result<()> {
273 let (service, method) = discovery::resolve(&args.method).map_err(anyhow::Error::msg)?;
274 print_output(
275 &json!({
276 "id": format!("{}.{}", service.name, method.id),
277 "service": service.name,
278 "apiVersion": service.version,
279 "discoveryRevision": service.revision,
280 "httpMethod": method.http_method,
281 "path": method.path,
282 "urlTemplate": format!(
285 "{}{}{}",
286 service.root_url.as_deref().unwrap_or_default(),
287 service.base(),
288 method.path
289 ),
290 "description": method.description,
291 "scopes": method.scopes,
292 "leastPrivilegeScope": method.least_privilege_scope(),
293 "parameters": method.parameters.iter().map(|p| json!({
294 "name": p.name,
295 "type": p.kind,
296 "required": p.required,
297 "repeated": p.repeated,
298 "description": p.description,
299 "enum": p.enum_values,
300 })).collect::<Vec<_>>(),
301 "hint": format!(
302 "grr api call {}.{} --params '{{\"{}\": \"...\"}}'",
303 service.name, method.id,
304 method.parameters.first().map(|p| p.name.as_str()).unwrap_or("userId")
305 ),
306 }),
307 args.format,
308 )?;
309 Ok(())
310}
311
312async fn handle_call(
313 auth: Option<&GoogleAuth>,
314 args: ApiCallArgs,
315 profile: &SafetyProfile,
316) -> Result<()> {
317 let (service, method) = discovery::resolve(&args.method).map_err(|message| {
318 anyhow::anyhow!(
319 "{message}\n\nRun `grr api list {}` to see what is available.",
320 service_hint(&args.method)
321 )
322 })?;
323
324 if let Err(message) = profile.check(&service.name, method) {
329 bail!("{message}");
330 }
331
332 let params = parse_params(args.params.as_deref(), &args.param)?;
333 let payload = call_method(
334 auth,
335 service,
336 method,
337 params,
338 CallOptions {
339 body_file: args.body_file,
340 verb_override: args.method_override,
341 query_extras: args.query,
342 dry_run: args.dry_run,
343 },
344 )
345 .await?;
346 print_output(&payload, args.format)?;
347 Ok(())
348}
349
350#[derive(Debug, Default)]
354pub(crate) struct CallOptions {
355 pub body_file: Option<String>,
358 pub verb_override: Option<String>,
361 pub query_extras: Vec<String>,
363 pub dry_run: bool,
365}
366
367#[derive(Debug)]
371pub(crate) struct RequestPlan {
372 pub verb: String,
373 pub url: String,
374 pub body: Option<Value>,
375}
376
377pub(crate) fn plan_request(
385 service: &Service,
386 method: &Method,
387 params: &serde_json::Map<String, Value>,
388 body_raw: Option<&str>,
389 verb_override: Option<&str>,
390 query_extras: &[String],
391) -> Result<RequestPlan> {
392 for required in &method.required {
394 if !params.contains_key(required) {
395 let known: Vec<String> = method
396 .parameters
397 .iter()
398 .filter(|p| p.required)
399 .map(|p| format!("{} ({})", p.name, p.kind))
400 .collect();
401 bail!(
402 "missing required parameter `{required}` for {}.{}\nRequired: {}",
403 service.name,
404 method.id,
405 if known.is_empty() {
406 "none".to_owned()
407 } else {
408 known.join(", ")
409 }
410 );
411 }
412 }
413
414 let verb = verb_override
415 .unwrap_or(&method.http_method)
416 .to_ascii_uppercase();
417 let path = discovery::build_path(service, method, params).map_err(anyhow::Error::msg)?;
418 let mut query = discovery::build_query(method, params);
419 for extra in query_extras {
420 let Some((k, v)) = extra.split_once('=') else {
421 bail!("--query expects KEY=VALUE, got `{extra}`");
422 };
423 if !query.is_empty() {
424 query.push('&');
425 }
426 query.push_str(&urlencoding::encode(k));
427 query.push('=');
428 query.push_str(&urlencoding::encode(v));
429 }
430 let url = format!(
431 "{}{}{}",
432 service.root_url.as_deref().unwrap_or_default(),
433 path,
434 query
435 );
436
437 let body: Option<Value> = match body_raw {
442 Some(raw) => {
443 Some(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_owned())))
444 }
445 None => {
446 let placeholders = method.path_placeholders();
447 let is_write = matches!(verb.as_str(), "POST" | "PATCH" | "PUT");
448 if !is_write {
449 None
450 } else {
451 let mut rest = serde_json::Map::new();
452 for (key, value) in params {
453 if placeholders.contains(&key.as_str()) {
454 continue;
455 }
456 if let Some(param) = method.parameter(key)
457 && param.location.as_deref() == Some("path")
458 {
459 continue;
460 }
461 rest.insert(key.clone(), value.clone());
462 }
463 (!rest.is_empty()).then_some(Value::Object(rest))
464 }
465 }
466 };
467
468 Ok(RequestPlan { verb, url, body })
469}
470
471pub(crate) fn dry_run_payload(method: &Method, plan: &RequestPlan) -> Value {
474 json!({
475 "method": plan.verb,
476 "url": plan.url,
477 "body": plan.body,
478 "scopesRequested": method.scopes,
479 "leastPrivilegeScope": method.least_privilege_scope(),
480 "dryRun": true,
481 })
482}
483
484async fn read_body_source(source: &str) -> Result<String> {
493 if source == "-" {
494 let mut buf = String::new();
495 tokio::io::AsyncReadExt::read_to_string(&mut tokio::io::stdin(), &mut buf)
496 .await
497 .with_context(|| "reading body from stdin")?;
498 Ok(buf)
499 } else {
500 tokio::fs::read_to_string(source)
501 .await
502 .with_context(|| format!("reading body from {source}"))
503 }
504}
505
506static CORES: Mutex<BTreeMap<String, HttpCore>> = Mutex::new(BTreeMap::new());
519
520const MAX_ENGINES: usize = 32;
524
525const PROBE_PATH: &str = "";
529
530async fn engine_for(auth: &GoogleAuth, base: &url::Url) -> Result<HttpCore> {
538 let key = base.as_str().to_owned();
539 if let Some(engine) = cores().get(&key) {
540 return Ok(engine.clone());
541 }
542 match HttpCore::connect(auth.clone(), base, PROBE_PATH).await {
543 Ok(engine) => {
544 let mut cores = cores();
545 while cores.len() >= MAX_ENGINES {
546 cores.pop_first();
547 }
548 cores.insert(key, engine.clone());
549 Ok(engine)
550 }
551 Err(error) => {
552 cores().remove(&key);
553 Err(error.into())
554 }
555 }
556}
557
558fn cores() -> MutexGuard<'static, BTreeMap<String, HttpCore>> {
561 CORES
562 .lock()
563 .unwrap_or_else(|poisoned| poisoned.into_inner())
564}
565
566pub(crate) async fn call_method(
572 auth: Option<&GoogleAuth>,
573 service: &Service,
574 method: &Method,
575 params: serde_json::Map<String, Value>,
576 options: CallOptions,
577) -> Result<Value> {
578 let body_raw = match options.body_file.as_deref() {
579 Some(source) => Some(read_body_source(source).await?),
580 None => None,
581 };
582 let plan = plan_request(
583 service,
584 method,
585 ¶ms,
586 body_raw.as_deref(),
587 options.verb_override.as_deref(),
588 &options.query_extras,
589 )?;
590
591 if options.dry_run {
592 return Ok(dry_run_payload(method, &plan));
596 }
597
598 let auth = auth.ok_or_else(|| anyhow::anyhow!("{}", crate::core::config::NO_CLIENT_HELP))?;
600
601 let parsed_url = url::Url::parse(&plan.url).with_context(|| {
602 format!(
603 "`{}` is not a valid URL (Discovery path + parameters)",
604 plan.url
605 )
606 })?;
607
608 let engine = engine_for(auth, &parsed_url.join("/")?).await?;
614
615 let mut request = engine.http_client().request(
616 reqwest::Method::from_bytes(plan.verb.as_bytes()).unwrap_or(reqwest::Method::GET),
617 parsed_url,
618 );
619 if let Some(payload) = &plan.body {
620 request = request.json(payload);
621 }
622 let response = engine.execute(request).await?;
623
624 let status = response.status();
625 let text = response.text().await.unwrap_or_default();
626 let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()));
627
628 if !status.is_success() {
629 bail!(
630 "{}",
631 api_failure_message(&plan, status.as_u16(), &parsed, &text, method)
632 );
633 }
634
635 Ok(parsed)
636}
637
638pub(crate) fn api_failure_message(
648 plan: &RequestPlan,
649 status: u16,
650 parsed: &Value,
651 text: &str,
652 method: &Method,
653) -> String {
654 if status == 403
655 && let Some(scope) = method.least_privilege_scope()
656 {
657 let detail = parsed["error"]["message"].as_str().unwrap_or("");
658 let google_said = if detail.is_empty() {
659 String::new()
660 } else {
661 format!("\nGoogle said: {detail}")
662 };
663 return format!(
664 "{} {} -> HTTP 403: this method needs the `{scope}` scope.\n\
665 This build requests every scope the embedded index names, so the grant is what \
666 to check: a token that predates the current set (re-run `grr auth login`), a \
667 scope the project's consent screen never declared (Google Auth Platform → Data \
668 access — undeclared scopes are dropped silently at consent), or an API that is \
669 disabled on the Cloud project.{google_said}",
670 plan.verb, plan.url
671 );
672 }
673 format!(
674 "{} {} -> HTTP {}\n{}",
675 plan.verb,
676 plan.url,
677 status,
678 serde_json::to_string_pretty(parsed).unwrap_or_else(|_| text.to_owned())
679 )
680}
681
682fn service_hint(method_id: &str) -> &str {
683 method_id.split('.').next().unwrap_or("gmail")
684}
685
686pub(crate) fn parse_params(
691 json_blob: Option<&str>,
692 pairs: &[String],
693) -> Result<serde_json::Map<String, Value>> {
694 let mut map = serde_json::Map::new();
695
696 if let Some(blob) = json_blob {
697 let parsed: Value = serde_json::from_str(blob)
698 .with_context(|| "--params must be a JSON object, e.g. '{\"userId\":\"me\"}'")?;
699 let Value::Object(object) = parsed else {
700 bail!("--params must be a JSON object, got {}", type_name(&parsed));
701 };
702 map.extend(object);
703 }
704
705 for pair in pairs {
706 let Some((key, value)) = pair.split_once('=') else {
707 bail!("--param expects KEY=VALUE, got `{pair}`");
708 };
709 let coerced = serde_json::from_str::<Value>(value)
713 .unwrap_or_else(|_| Value::String(value.to_owned()));
714 map.insert(key.to_owned(), coerced);
715 }
716
717 Ok(map)
718}
719
720fn type_name(value: &Value) -> &'static str {
721 match value {
722 Value::Null => "null",
723 Value::Bool(_) => "a boolean",
724 Value::Number(_) => "a number",
725 Value::String(_) => "a string",
726 Value::Array(_) => "an array",
727 Value::Object(_) => "an object",
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 #[test]
736 fn json_params_are_merged() {
737 let params = parse_params(Some(r#"{"userId":"me","q":"is:unread"}"#), &[]).unwrap();
738 assert_eq!(params.get("userId").unwrap(), "me");
739 assert_eq!(params.get("q").unwrap(), "is:unread");
740 }
741
742 #[test]
743 fn param_flags_win_over_json_params() {
744 let params =
747 parse_params(Some(r#"{"userId":"me"}"#), &["userId=someone-else".into()]).unwrap();
748 assert_eq!(params.get("userId").unwrap(), "someone-else");
749 }
750
751 #[test]
752 fn param_values_are_coerced_to_json_types() {
753 let params =
754 parse_params(None, &["a=10".into(), "b=true".into(), "c=text".into()]).unwrap();
755 assert_eq!(params.get("a").unwrap(), 10);
756 assert_eq!(params.get("b").unwrap(), true);
757 assert_eq!(params.get("c").unwrap(), "text");
758 }
759
760 #[test]
761 fn values_containing_equals_are_preserved() {
762 let params = parse_params(None, &["q=name=a.pdf".into()]).unwrap();
764 assert_eq!(params.get("q").unwrap(), "name=a.pdf");
765 }
766
767 #[test]
768 fn malformed_input_is_rejected_with_guidance() {
769 assert!(parse_params(Some("not json"), &[]).is_err());
770 assert!(parse_params(Some("[1,2]"), &[]).is_err());
771 assert!(parse_params(None, &["novalue".into()]).is_err());
772 }
773
774 fn plan(id: &str, params: &[(&str, Value)]) -> (super::RequestPlan, &'static Method) {
775 let (service, method) = crate::discovery::resolve(id).unwrap();
776 let map: serde_json::Map<String, Value> = params
777 .iter()
778 .map(|(k, v)| ((*k).to_owned(), v.clone()))
779 .collect();
780 (
781 super::plan_request(service, method, &map, None, None, &[]).unwrap(),
782 method,
783 )
784 }
785
786 #[test]
787 fn plan_builds_the_url_from_discovery() {
788 let (plan, method) = plan(
789 "gmail.users.messages.list",
790 &[("userId", json!("me")), ("q", json!("is:unread"))],
791 );
792 assert_eq!(plan.verb, "GET");
793 assert_eq!(
794 plan.url,
795 "https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread"
796 );
797 assert_eq!(plan.body, None);
798 assert_eq!(method.id, "users.messages.list");
799 }
800
801 #[test]
802 fn plan_rejects_a_missing_required_parameter_by_name() {
803 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
804 let err = super::plan_request(service, method, &serde_json::Map::new(), None, None, &[])
805 .unwrap_err()
806 .to_string();
807 assert!(err.contains("missing required parameter `userId`"), "{err}");
808 }
809
810 #[test]
811 fn plan_appends_query_extras_after_discovery_pairs() {
812 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
813 let mut params = serde_json::Map::new();
814 params.insert("userId".into(), json!("me"));
815 params.insert("q".into(), json!("is:unread"));
816 let plan = super::plan_request(
817 service,
818 method,
819 ¶ms,
820 None,
821 None,
822 &["alt=json".to_owned()],
823 )
824 .unwrap();
825 assert!(plan.url.ends_with("q=is%3Aunread&alt=json"), "{}", plan.url);
826 }
827
828 #[test]
829 fn plan_rejects_malformed_query_extras() {
830 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
831 let mut params = serde_json::Map::new();
832 params.insert("userId".into(), json!("me"));
833 let err = super::plan_request(
834 service,
835 method,
836 ¶ms,
837 None,
838 None,
839 &["no-equals-sign".to_owned()],
840 )
841 .unwrap_err()
842 .to_string();
843 assert!(err.contains("KEY=VALUE"), "{err}");
844 }
845
846 #[test]
847 fn plan_uppercases_a_verb_override() {
848 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
849 let mut params = serde_json::Map::new();
850 params.insert("userId".into(), json!("me"));
851 let plan = super::plan_request(service, method, ¶ms, None, Some("post"), &[]).unwrap();
852 assert_eq!(plan.verb, "POST");
853 }
854
855 #[test]
856 fn plan_synthesizes_a_body_for_write_verbs_only() {
857 let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
860 let mut params = serde_json::Map::new();
861 params.insert("name".into(), json!("quarterly.pdf"));
862 let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
863 assert_eq!(plan.body, Some(json!({"name": "quarterly.pdf"})));
864
865 let (service, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
868 let mut params = serde_json::Map::new();
869 params.insert("userId".into(), json!("me"));
870 params.insert("q".into(), json!("is:unread"));
871 let plan = super::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
872 assert_eq!(plan.body, None);
873 assert!(plan.url.contains("q=is%3Aunread"));
874 }
875
876 #[test]
877 fn plan_parses_json_bodies_and_wraps_the_rest() {
878 let (service, method) = crate::discovery::resolve("drive.files.create").unwrap();
882 let plan = super::plan_request(
883 service,
884 method,
885 &serde_json::Map::new(),
886 Some(r#"{"name":"uploaded.pdf"}"#),
887 None,
888 &[],
889 )
890 .unwrap();
891 assert_eq!(plan.body, Some(json!({"name": "uploaded.pdf"})));
892
893 let plan = super::plan_request(
894 service,
895 method,
896 &serde_json::Map::new(),
897 Some("not json at all"),
898 None,
899 &[],
900 )
901 .unwrap();
902 assert_eq!(plan.body, Some(json!("not json at all")));
903 }
904
905 #[tokio::test]
906 async fn read_body_source_reads_a_file() {
907 let dir = tempfile::tempdir().unwrap();
908 let path = dir.path().join("body.json");
909 tokio::fs::write(&path, r#"{"a":1}"#).await.unwrap();
910 let raw = super::read_body_source(path.to_str().unwrap())
911 .await
912 .unwrap();
913 assert_eq!(raw, r#"{"a":1}"#);
914 }
915
916 #[test]
917 fn dry_run_payload_is_a_pure_function_of_the_plan() {
918 let (plan, method) = plan("gmail.users.messages.list", &[("userId", json!("me"))]);
919 let payload = super::dry_run_payload(method, &plan);
920 assert_eq!(payload["method"], "GET");
921 assert_eq!(
922 payload["url"],
923 "https://gmail.googleapis.com/gmail/v1/users/me/messages"
924 );
925 assert_eq!(payload["dryRun"], true);
926 assert!(
927 payload["leastPrivilegeScope"]
928 .as_str()
929 .unwrap()
930 .ends_with("gmail.readonly")
931 );
932 }
933
934 fn a_get_plan() -> super::RequestPlan {
935 super::RequestPlan {
936 verb: "GET".into(),
937 url: "https://gmail.googleapis.com/gmail/v1/users/me/messages".into(),
938 body: None,
939 }
940 }
941
942 #[test]
943 fn a_403_names_the_missing_scope_and_keeps_googles_message() {
944 let (_, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
948 let parsed = json!({
949 "error": {"code": 403, "message": "Request had insufficient authentication scopes."}
950 });
951 let message = super::api_failure_message(&a_get_plan(), 403, &parsed, "", method);
952 assert!(message.contains("HTTP 403"), "{message}");
953 assert!(
954 message.contains("gmail.readonly"),
955 "the method's least-privilege scope must be named: {message}"
956 );
957 assert!(
958 message.contains("re-run `grr auth login`"),
959 "the remediation must be named: {message}"
960 );
961 assert!(
962 message.contains("insufficient authentication scopes"),
963 "Google's own message must be quoted, not replaced: {message}"
964 );
965 }
966
967 #[test]
968 fn other_statuses_keep_the_verb_url_body_shape() {
969 let (_, method) = crate::discovery::resolve("gmail.users.messages.list").unwrap();
974 let parsed = json!({"error": {"code": 404, "message": "Requested entity was not found."}});
975 let message = super::api_failure_message(&a_get_plan(), 404, &parsed, "", method);
976 assert!(
977 message.contains(
978 "GET https://gmail.googleapis.com/gmail/v1/users/me/messages -> HTTP 404"
979 ),
980 "{message}"
981 );
982 assert!(
983 message.contains("Requested entity was not found."),
984 "{message}"
985 );
986 }
987
988 async fn test_auth() -> GoogleAuth {
989 let config = crate::core::config::OAuthConfig {
990 client_id: "test-id.apps.googleusercontent.com".into(),
991 client_secret: None,
992 ..Default::default()
993 };
994 let storage = crate::core::auth::TokenStorage {
995 access_token: "test-token".into(),
996 refresh_token: None,
997 expires_at: u64::MAX,
998 token_type: "Bearer".into(),
999 scope: String::new(),
1000 };
1001 GoogleAuth::with_token(config, storage).await.unwrap()
1002 }
1003
1004 #[tokio::test]
1005 async fn handle_call_refuses_a_write_under_readonly() {
1006 let auth = test_auth().await;
1009 let args = ApiCallArgs {
1010 method: "gmail.users.messages.send".into(),
1011 params: None,
1012 param: vec![],
1013 body_file: None,
1014 method_override: None,
1015 query: vec![],
1016 dry_run: true,
1017 format: OutputFormat::Json,
1018 };
1019 let err = handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
1020 .await
1021 .unwrap_err()
1022 .to_string();
1023 assert!(err.contains("POST"), "{err}");
1024 assert!(err.contains("--readonly"), "{err}");
1025 assert!(err.contains("Rerun without --readonly"), "{err}");
1026 }
1027
1028 #[tokio::test]
1029 async fn handle_call_refuses_a_denied_service_and_a_denied_verb() {
1030 let auth = test_auth().await;
1031 let args = ApiCallArgs {
1032 method: "gmail.users.messages.list".into(),
1033 params: None,
1034 param: vec![],
1035 body_file: None,
1036 method_override: None,
1037 query: vec![],
1038 dry_run: true,
1039 format: OutputFormat::Json,
1040 };
1041 let profile = SafetyProfile::new(false, vec!["gmail".to_owned()], vec![]);
1042 let err = handle_call(Some(&auth), args, &profile)
1043 .await
1044 .unwrap_err()
1045 .to_string();
1046 assert!(err.contains("--deny-service gmail"), "{err}");
1047
1048 let args = ApiCallArgs {
1049 method: "gmail.users.messages.list".into(),
1050 params: None,
1051 param: vec![],
1052 body_file: None,
1053 method_override: None,
1054 query: vec![],
1055 dry_run: true,
1056 format: OutputFormat::Json,
1057 };
1058 let profile = SafetyProfile::new(false, vec![], vec!["get".to_owned()]);
1059 let err = handle_call(Some(&auth), args, &profile)
1060 .await
1061 .unwrap_err()
1062 .to_string();
1063 assert!(err.contains("--deny-verb GET"), "{err}");
1064 }
1065
1066 #[tokio::test]
1067 async fn handle_call_runs_a_read_under_readonly() {
1068 let auth = test_auth().await;
1069 let args = ApiCallArgs {
1070 method: "gmail.users.messages.list".into(),
1071 params: None,
1072 param: vec!["userId=me".into()],
1073 body_file: None,
1074 method_override: None,
1075 query: vec![],
1076 dry_run: true,
1077 format: OutputFormat::Json,
1078 };
1079 assert!(
1081 handle_call(Some(&auth), args, &SafetyProfile::new(true, [], []))
1082 .await
1083 .is_ok()
1084 );
1085 }
1086}