1use super::traits::{Tool, ToolResult};
2use crate::config::GoogleWorkspaceAllowedOperation;
3use crate::security::SecurityPolicy;
4use async_trait::async_trait;
5use serde_json::json;
6use std::sync::Arc;
7use std::time::Duration;
8
9const DEFAULT_GWS_TIMEOUT_SECS: u64 = 30;
11const MAX_OUTPUT_BYTES: usize = 1_048_576;
13
14use crate::config::DEFAULT_GWS_SERVICES;
15
16pub struct GoogleWorkspaceTool {
22 security: Arc<SecurityPolicy>,
23 allowed_services: Vec<String>,
24 allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
25 credentials_path: Option<String>,
26 default_account: Option<String>,
27 rate_limit_per_minute: u32,
28 timeout_secs: u64,
29 audit_log: bool,
30}
31
32impl GoogleWorkspaceTool {
33 pub fn new(
37 security: Arc<SecurityPolicy>,
38 allowed_services: Vec<String>,
39 allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
40 credentials_path: Option<String>,
41 default_account: Option<String>,
42 rate_limit_per_minute: u32,
43 timeout_secs: u64,
44 audit_log: bool,
45 ) -> Self {
46 let services = if allowed_services.is_empty() {
47 DEFAULT_GWS_SERVICES
48 .iter()
49 .map(|s| (*s).to_string())
50 .collect()
51 } else {
52 allowed_services
53 .into_iter()
54 .map(|s| s.trim().to_string())
55 .collect()
56 };
57 let operations = allowed_operations
60 .into_iter()
61 .map(|op| GoogleWorkspaceAllowedOperation {
62 service: op.service.trim().to_string(),
63 resource: op.resource.trim().to_string(),
64 sub_resource: op.sub_resource.as_deref().map(|s| s.trim().to_string()),
65 methods: op.methods.iter().map(|m| m.trim().to_string()).collect(),
66 })
67 .collect();
68 Self {
69 security,
70 allowed_services: services,
71 allowed_operations: operations,
72 credentials_path,
73 default_account,
74 rate_limit_per_minute,
75 timeout_secs,
76 audit_log,
77 }
78 }
79
80 fn positional_cmd_args(
82 service: &str,
83 resource: &str,
84 sub_resource: Option<&str>,
85 method: &str,
86 ) -> Vec<String> {
87 let mut args = vec![service.to_string(), resource.to_string()];
88 if let Some(sub) = sub_resource {
89 args.push(sub.to_string());
90 }
91 args.push(method.to_string());
92 args
93 }
94
95 fn build_pagination_args(page_all: bool, page_limit: Option<u64>) -> Vec<String> {
99 let mut args = Vec::new();
100 if page_all {
101 args.push("--page-all".into());
102 }
103 if page_all || page_limit.is_some() {
104 args.push("--page-limit".into());
105 args.push(page_limit.unwrap_or(10).to_string());
106 }
107 args
108 }
109
110 fn is_operation_allowed(
111 &self,
112 service: &str,
113 resource: &str,
114 sub_resource: Option<&str>,
115 method: &str,
116 ) -> bool {
117 if self.allowed_operations.is_empty() {
118 return true;
119 }
120 self.allowed_operations.iter().any(|operation| {
121 operation.service == service
122 && operation.resource == resource
123 && operation.sub_resource.as_deref() == sub_resource
124 && operation.methods.iter().any(|allowed| allowed == method)
125 })
126 }
127}
128
129#[async_trait]
130impl Tool for GoogleWorkspaceTool {
131 fn name(&self) -> &str {
132 "google_workspace"
133 }
134
135 fn description(&self) -> &str {
136 "Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) \
137 via the gws CLI. Requires gws to be installed and authenticated."
138 }
139
140 fn parameters_schema(&self) -> serde_json::Value {
141 json!({
142 "type": "object",
143 "properties": {
144 "service": {
145 "type": "string",
146 "description": "Google Workspace service (e.g. drive, gmail, calendar, sheets, docs, slides, tasks, people, chat, classroom, forms, keep, meet, events)"
147 },
148 "resource": {
149 "type": "string",
150 "description": "Service resource (e.g. files, messages, events, spreadsheets)"
151 },
152 "method": {
153 "type": "string",
154 "description": "Method to call on the resource (e.g. list, get, create, update, delete)"
155 },
156 "sub_resource": {
157 "type": "string",
158 "description": "Optional sub-resource for nested operations"
159 },
160 "params": {
161 "type": "object",
162 "description": "URL/query parameters as key-value pairs (passed as --params JSON)"
163 },
164 "body": {
165 "type": "object",
166 "description": "Request body for POST/PATCH/PUT operations (passed as --json JSON)"
167 },
168 "format": {
169 "type": "string",
170 "enum": ["json", "table", "yaml", "csv"],
171 "description": "Output format (default: json)"
172 },
173 "page_all": {
174 "type": "boolean",
175 "description": "Auto-paginate through all results"
176 },
177 "page_limit": {
178 "type": "integer",
179 "description": "Max pages to fetch when using page_all (default: 10)"
180 }
181 },
182 "required": ["service", "resource", "method"]
183 })
184 }
185
186 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
188 let service = args
189 .get("service")
190 .and_then(|v| v.as_str())
191 .ok_or_else(|| anyhow::anyhow!("Missing 'service' parameter"))?;
192 let resource = args
193 .get("resource")
194 .and_then(|v| v.as_str())
195 .ok_or_else(|| anyhow::anyhow!("Missing 'resource' parameter"))?;
196 let method = args
197 .get("method")
198 .and_then(|v| v.as_str())
199 .ok_or_else(|| anyhow::anyhow!("Missing 'method' parameter"))?;
200
201 let sub_resource: Option<&str> = if let Some(sub_resource_value) = args.get("sub_resource")
203 {
204 let s = match sub_resource_value.as_str() {
205 Some(s) => s,
206 None => {
207 return Ok(ToolResult {
208 success: false,
209 output: String::new(),
210 error: Some("'sub_resource' must be a string".into()),
211 });
212 }
213 };
214 if !s
215 .chars()
216 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
217 {
218 return Ok(ToolResult {
219 success: false,
220 output: String::new(),
221 error: Some(
222 "Invalid characters in 'sub_resource': only lowercase alphanumeric, underscore, and hyphen are allowed"
223 .into(),
224 ),
225 });
226 }
227 Some(s)
228 } else {
229 None
230 };
231
232 if self.security.is_rate_limited() {
234 return Ok(ToolResult {
235 success: false,
236 output: String::new(),
237 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
238 });
239 }
240
241 if !self.allowed_services.iter().any(|s| s == service) {
243 return Ok(ToolResult {
244 success: false,
245 output: String::new(),
246 error: Some(format!(
247 "Service '{service}' is not in the allowed services list. \
248 Allowed: {}",
249 self.allowed_services.join(", ")
250 )),
251 });
252 }
253
254 if !self.is_operation_allowed(service, resource, sub_resource, method) {
255 let op_path = match sub_resource {
256 Some(sub) => format!("{service}/{resource}/{sub}/{method}"),
257 None => format!("{service}/{resource}/{method}"),
258 };
259 return Ok(ToolResult {
260 success: false,
261 output: String::new(),
262 error: Some(format!(
263 "Operation '{op_path}' is not in the allowed operations list"
264 )),
265 });
266 }
267
268 for (label, value) in [
270 ("service", service),
271 ("resource", resource),
272 ("method", method),
273 ] {
274 if !value
275 .chars()
276 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
277 {
278 return Ok(ToolResult {
279 success: false,
280 output: String::new(),
281 error: Some(format!(
282 "Invalid characters in '{label}': only lowercase alphanumeric, underscore, and hyphen are allowed"
283 )),
284 });
285 }
286 }
287
288 let mut cmd_args = Self::positional_cmd_args(service, resource, sub_resource, method);
290
291 if let Some(params) = args.get("params") {
292 if !params.is_object() {
293 return Ok(ToolResult {
294 success: false,
295 output: String::new(),
296 error: Some("'params' must be an object".into()),
297 });
298 }
299 cmd_args.push("--params".into());
300 cmd_args.push(params.to_string());
301 }
302
303 if let Some(body) = args.get("body") {
304 if !body.is_object() {
305 return Ok(ToolResult {
306 success: false,
307 output: String::new(),
308 error: Some("'body' must be an object".into()),
309 });
310 }
311 cmd_args.push("--json".into());
312 cmd_args.push(body.to_string());
313 }
314
315 if let Some(format_value) = args.get("format") {
316 let format = match format_value.as_str() {
317 Some(s) => s,
318 None => {
319 return Ok(ToolResult {
320 success: false,
321 output: String::new(),
322 error: Some("'format' must be a string".into()),
323 });
324 }
325 };
326 match format {
327 "json" | "table" | "yaml" | "csv" => {
328 cmd_args.push("--format".into());
329 cmd_args.push(format.to_string());
330 }
331 _ => {
332 return Ok(ToolResult {
333 success: false,
334 output: String::new(),
335 error: Some(format!(
336 "Invalid format '{format}': must be json, table, yaml, or csv"
337 )),
338 });
339 }
340 }
341 }
342
343 let page_all = match args.get("page_all") {
344 Some(v) => match v.as_bool() {
345 Some(b) => b,
346 None => {
347 return Ok(ToolResult {
348 success: false,
349 output: String::new(),
350 error: Some("'page_all' must be a boolean".into()),
351 });
352 }
353 },
354 None => false,
355 };
356 let page_limit = match args.get("page_limit") {
357 Some(v) => match v.as_u64() {
358 Some(n) => Some(n),
359 None => {
360 return Ok(ToolResult {
361 success: false,
362 output: String::new(),
363 error: Some("'page_limit' must be a non-negative integer".into()),
364 });
365 }
366 },
367 None => None,
368 };
369 cmd_args.extend(Self::build_pagination_args(page_all, page_limit));
370
371 if !self.security.record_action() {
373 return Ok(ToolResult {
374 success: false,
375 output: String::new(),
376 error: Some("Rate limit exceeded: action budget exhausted".into()),
377 });
378 }
379
380 let mut cmd = tokio::process::Command::new("gws");
381 cmd.args(&cmd_args);
382 cmd.env_clear();
383 for key in &["PATH", "HOME", "APPDATA", "USERPROFILE", "LANG", "TERM"] {
385 if let Ok(val) = std::env::var(key) {
386 cmd.env(key, val);
387 }
388 }
389
390 if let Some(ref creds) = self.credentials_path {
392 cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
393 }
394
395 if let Some(ref account) = self.default_account {
397 cmd.args(["--account", account]);
398 }
399
400 if self.audit_log {
401 tracing::info!(
402 tool = "google_workspace",
403 service = service,
404 resource = resource,
405 sub_resource = sub_resource.unwrap_or(""),
406 method = method,
407 "gws audit: executing API call"
408 );
409 }
410
411 let result =
412 tokio::time::timeout(Duration::from_secs(self.timeout_secs), cmd.output()).await;
413
414 match result {
415 Ok(Ok(output)) => {
416 let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
417 let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
418
419 if stdout.len() > MAX_OUTPUT_BYTES {
420 let mut boundary = MAX_OUTPUT_BYTES;
422 while boundary > 0 && !stdout.is_char_boundary(boundary) {
423 boundary -= 1;
424 }
425 stdout.truncate(boundary);
426 stdout.push_str("\n... [output truncated at 1MB]");
427 }
428 if stderr.len() > MAX_OUTPUT_BYTES {
429 let mut boundary = MAX_OUTPUT_BYTES;
430 while boundary > 0 && !stderr.is_char_boundary(boundary) {
431 boundary -= 1;
432 }
433 stderr.truncate(boundary);
434 stderr.push_str("\n... [stderr truncated at 1MB]");
435 }
436
437 Ok(ToolResult {
438 success: output.status.success(),
439 output: stdout,
440 error: if stderr.is_empty() {
441 None
442 } else {
443 Some(stderr)
444 },
445 })
446 }
447 Ok(Err(e)) => Ok(ToolResult {
448 success: false,
449 output: String::new(),
450 error: Some(format!(
451 "Failed to execute gws: {e}. Is gws installed? Run: npm install -g @googleworkspace/cli"
452 )),
453 }),
454 Err(_) => Ok(ToolResult {
455 success: false,
456 output: String::new(),
457 error: Some(format!(
458 "gws command timed out after {}s and was killed",
459 self.timeout_secs
460 )),
461 }),
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::security::{AutonomyLevel, SecurityPolicy};
470
471 fn test_security() -> Arc<SecurityPolicy> {
472 Arc::new(SecurityPolicy {
473 autonomy: AutonomyLevel::Full,
474 workspace_dir: std::env::temp_dir(),
475 ..SecurityPolicy::default()
476 })
477 }
478
479 #[test]
480 fn tool_name() {
481 let tool =
482 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
483 assert_eq!(tool.name(), "google_workspace");
484 }
485
486 #[test]
487 fn tool_description_non_empty() {
488 let tool =
489 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
490 assert!(!tool.description().is_empty());
491 }
492
493 #[test]
494 fn tool_schema_has_required_fields() {
495 let tool =
496 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
497 let schema = tool.parameters_schema();
498 assert!(schema["properties"]["service"].is_object());
499 assert!(schema["properties"]["resource"].is_object());
500 assert!(schema["properties"]["method"].is_object());
501 let required = schema["required"]
502 .as_array()
503 .expect("required should be an array");
504 assert!(required.contains(&json!("service")));
505 assert!(required.contains(&json!("resource")));
506 assert!(required.contains(&json!("method")));
507 }
508
509 #[test]
510 fn default_allowed_services_populated() {
511 let tool =
512 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
513 assert!(!tool.allowed_services.is_empty());
514 assert!(tool.allowed_services.contains(&"drive".to_string()));
515 assert!(tool.allowed_services.contains(&"gmail".to_string()));
516 assert!(tool.allowed_services.contains(&"calendar".to_string()));
517 }
518
519 #[test]
520 fn custom_allowed_services_override_defaults() {
521 let tool = GoogleWorkspaceTool::new(
522 test_security(),
523 vec!["drive".into(), "sheets".into()],
524 vec![],
525 None,
526 None,
527 60,
528 30,
529 false,
530 );
531 assert_eq!(tool.allowed_services.len(), 2);
532 assert!(tool.allowed_services.contains(&"drive".to_string()));
533 assert!(tool.allowed_services.contains(&"sheets".to_string()));
534 assert!(!tool.allowed_services.contains(&"gmail".to_string()));
535 }
536
537 #[tokio::test]
538 async fn rejects_disallowed_service() {
539 let tool = GoogleWorkspaceTool::new(
540 test_security(),
541 vec!["drive".into()],
542 vec![],
543 None,
544 None,
545 60,
546 30,
547 false,
548 );
549 let result = tool
550 .execute(json!({
551 "service": "gmail",
552 "resource": "users",
553 "method": "list"
554 }))
555 .await
556 .expect("disallowed service should return a result");
557 assert!(!result.success);
558 assert!(
559 result
560 .error
561 .as_deref()
562 .unwrap_or("")
563 .contains("not in the allowed")
564 );
565 }
566
567 #[tokio::test]
568 async fn rejects_shell_injection_in_service() {
569 let tool = GoogleWorkspaceTool::new(
570 test_security(),
571 vec!["drive; rm -rf /".into()],
572 vec![],
573 None,
574 None,
575 60,
576 30,
577 false,
578 );
579 let result = tool
580 .execute(json!({
581 "service": "drive; rm -rf /",
582 "resource": "files",
583 "method": "list"
584 }))
585 .await
586 .expect("shell injection should return a result");
587 assert!(!result.success);
588 assert!(
589 result
590 .error
591 .as_deref()
592 .unwrap_or("")
593 .contains("Invalid characters")
594 );
595 }
596
597 #[tokio::test]
598 async fn rejects_shell_injection_in_resource() {
599 let tool =
600 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
601 let result = tool
602 .execute(json!({
603 "service": "drive",
604 "resource": "files$(whoami)",
605 "method": "list"
606 }))
607 .await
608 .expect("shell injection should return a result");
609 assert!(!result.success);
610 assert!(
611 result
612 .error
613 .as_deref()
614 .unwrap_or("")
615 .contains("Invalid characters")
616 );
617 }
618
619 #[tokio::test]
620 async fn rejects_invalid_format() {
621 let tool =
622 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
623 let result = tool
624 .execute(json!({
625 "service": "drive",
626 "resource": "files",
627 "method": "list",
628 "format": "xml"
629 }))
630 .await
631 .expect("invalid format should return a result");
632 assert!(!result.success);
633 assert!(
634 result
635 .error
636 .as_deref()
637 .unwrap_or("")
638 .contains("Invalid format")
639 );
640 }
641
642 #[tokio::test]
643 async fn rejects_wrong_type_params() {
644 let tool =
645 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
646 let result = tool
647 .execute(json!({
648 "service": "drive",
649 "resource": "files",
650 "method": "list",
651 "params": "not_an_object"
652 }))
653 .await
654 .expect("wrong type params should return a result");
655 assert!(!result.success);
656 assert!(
657 result
658 .error
659 .as_deref()
660 .unwrap_or("")
661 .contains("'params' must be an object")
662 );
663 }
664
665 #[tokio::test]
666 async fn rejects_wrong_type_body() {
667 let tool =
668 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
669 let result = tool
670 .execute(json!({
671 "service": "drive",
672 "resource": "files",
673 "method": "create",
674 "body": "not_an_object"
675 }))
676 .await
677 .expect("wrong type body should return a result");
678 assert!(!result.success);
679 assert!(
680 result
681 .error
682 .as_deref()
683 .unwrap_or("")
684 .contains("'body' must be an object")
685 );
686 }
687
688 #[tokio::test]
689 async fn rejects_wrong_type_page_all() {
690 let tool =
691 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
692 let result = tool
693 .execute(json!({
694 "service": "drive",
695 "resource": "files",
696 "method": "list",
697 "page_all": "yes"
698 }))
699 .await
700 .expect("wrong type page_all should return a result");
701 assert!(!result.success);
702 assert!(
703 result
704 .error
705 .as_deref()
706 .unwrap_or("")
707 .contains("'page_all' must be a boolean")
708 );
709 }
710
711 #[tokio::test]
712 async fn rejects_wrong_type_page_limit() {
713 let tool =
714 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
715 let result = tool
716 .execute(json!({
717 "service": "drive",
718 "resource": "files",
719 "method": "list",
720 "page_limit": "ten"
721 }))
722 .await
723 .expect("wrong type page_limit should return a result");
724 assert!(!result.success);
725 assert!(
726 result
727 .error
728 .as_deref()
729 .unwrap_or("")
730 .contains("'page_limit' must be a non-negative integer")
731 );
732 }
733
734 #[tokio::test]
735 async fn rejects_wrong_type_sub_resource() {
736 let tool =
737 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
738 let result = tool
739 .execute(json!({
740 "service": "drive",
741 "resource": "files",
742 "method": "list",
743 "sub_resource": 123
744 }))
745 .await
746 .expect("wrong type sub_resource should return a result");
747 assert!(!result.success);
748 assert!(
749 result
750 .error
751 .as_deref()
752 .unwrap_or("")
753 .contains("'sub_resource' must be a string")
754 );
755 }
756
757 #[tokio::test]
758 async fn missing_required_param_returns_error() {
759 let tool =
760 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
761 let result = tool.execute(json!({"service": "drive"})).await;
762 assert!(result.is_err());
763 }
764
765 #[tokio::test]
766 async fn rate_limited_returns_error() {
767 let security = Arc::new(SecurityPolicy {
768 autonomy: AutonomyLevel::Full,
769 max_actions_per_hour: 0,
770 workspace_dir: std::env::temp_dir(),
771 ..SecurityPolicy::default()
772 });
773 let tool = GoogleWorkspaceTool::new(security, vec![], vec![], None, None, 60, 30, false);
774 let result = tool
775 .execute(json!({
776 "service": "drive",
777 "resource": "files",
778 "method": "list"
779 }))
780 .await
781 .expect("rate-limited should return a result");
782 assert!(!result.success);
783 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
784 }
785
786 #[test]
787 fn gws_timeout_is_reasonable() {
788 assert_eq!(DEFAULT_GWS_TIMEOUT_SECS, 30);
789 }
790
791 #[test]
792 fn operation_allowlist_defaults_to_allow_all() {
793 let tool =
794 GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
795 assert!(tool.is_operation_allowed("gmail", "users", Some("messages"), "send"));
797 assert!(tool.is_operation_allowed("drive", "files", None, "list"));
798 }
799
800 #[test]
801 fn operation_allowlist_matches_gmail_sub_resource_shape() {
802 let tool = GoogleWorkspaceTool::new(
803 test_security(),
804 vec!["gmail".into()],
805 vec![GoogleWorkspaceAllowedOperation {
806 service: "gmail".into(),
807 resource: "users".into(),
808 sub_resource: Some("drafts".into()),
809 methods: vec!["create".into(), "update".into()],
810 }],
811 None,
812 None,
813 60,
814 30,
815 false,
816 );
817
818 assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
820 assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "update"));
821 assert!(!tool.is_operation_allowed("gmail", "users", Some("drafts"), "send"));
823 assert!(!tool.is_operation_allowed("gmail", "users", Some("messages"), "list"));
825 assert!(!tool.is_operation_allowed("gmail", "users", None, "create"));
827 }
828
829 #[test]
830 fn operation_allowlist_matches_drive_3_segment_shape() {
831 let tool = GoogleWorkspaceTool::new(
832 test_security(),
833 vec!["drive".into()],
834 vec![GoogleWorkspaceAllowedOperation {
835 service: "drive".into(),
836 resource: "files".into(),
837 sub_resource: None,
838 methods: vec!["list".into(), "get".into()],
839 }],
840 None,
841 None,
842 60,
843 30,
844 false,
845 );
846
847 assert!(tool.is_operation_allowed("drive", "files", None, "list"));
848 assert!(tool.is_operation_allowed("drive", "files", None, "get"));
849 assert!(!tool.is_operation_allowed("drive", "files", None, "delete"));
851 assert!(!tool.is_operation_allowed("drive", "files", Some("permissions"), "list"));
853 }
854
855 #[tokio::test]
856 async fn rejects_disallowed_operation() {
857 let tool = GoogleWorkspaceTool::new(
858 test_security(),
859 vec!["gmail".into()],
860 vec![GoogleWorkspaceAllowedOperation {
861 service: "gmail".into(),
862 resource: "users".into(),
863 sub_resource: Some("drafts".into()),
864 methods: vec!["create".into()],
865 }],
866 None,
867 None,
868 60,
869 30,
870 false,
871 );
872
873 let result = tool
875 .execute(json!({
876 "service": "gmail",
877 "resource": "users",
878 "sub_resource": "drafts",
879 "method": "send"
880 }))
881 .await
882 .expect("disallowed operation should return a result");
883
884 assert!(!result.success);
885 assert!(
886 result
887 .error
888 .as_deref()
889 .unwrap_or("")
890 .contains("allowed operations list")
891 );
892 }
893
894 #[tokio::test]
895 async fn rejects_operation_with_unlisted_sub_resource() {
896 let tool = GoogleWorkspaceTool::new(
897 test_security(),
898 vec!["gmail".into()],
899 vec![GoogleWorkspaceAllowedOperation {
900 service: "gmail".into(),
901 resource: "users".into(),
902 sub_resource: Some("drafts".into()),
903 methods: vec!["create".into()],
904 }],
905 None,
906 None,
907 60,
908 30,
909 false,
910 );
911
912 let result = tool
914 .execute(json!({
915 "service": "gmail",
916 "resource": "users",
917 "sub_resource": "messages",
918 "method": "send"
919 }))
920 .await
921 .expect("unlisted sub_resource should return a result");
922
923 assert!(!result.success);
924 assert!(
925 result
926 .error
927 .as_deref()
928 .unwrap_or("")
929 .contains("allowed operations list")
930 );
931 }
932
933 #[test]
936 fn cmd_args_3_segment_shape_drive() {
937 let args = GoogleWorkspaceTool::positional_cmd_args("drive", "files", None, "list");
939 assert_eq!(args, vec!["drive", "files", "list"]);
940 }
941
942 #[test]
943 fn cmd_args_4_segment_shape_gmail() {
944 let args =
946 GoogleWorkspaceTool::positional_cmd_args("gmail", "users", Some("messages"), "list");
947 assert_eq!(args, vec!["gmail", "users", "messages", "list"]);
948 }
949
950 #[test]
951 fn cmd_args_sub_resource_precedes_method() {
952 let args =
954 GoogleWorkspaceTool::positional_cmd_args("gmail", "users", Some("drafts"), "create");
955 let sub_idx = args.iter().position(|a| a == "drafts").unwrap();
956 let method_idx = args.iter().position(|a| a == "create").unwrap();
957 assert!(sub_idx < method_idx, "sub_resource must precede method");
958 }
959
960 #[tokio::test]
963 async fn denial_error_includes_sub_resource_when_present() {
964 let tool = GoogleWorkspaceTool::new(
965 test_security(),
966 vec!["gmail".into()],
967 vec![GoogleWorkspaceAllowedOperation {
968 service: "gmail".into(),
969 resource: "users".into(),
970 sub_resource: Some("drafts".into()),
971 methods: vec!["create".into()],
972 }],
973 None,
974 None,
975 60,
976 30,
977 false,
978 );
979
980 let result = tool
981 .execute(json!({
982 "service": "gmail",
983 "resource": "users",
984 "sub_resource": "messages",
985 "method": "send"
986 }))
987 .await
988 .expect("denied operation should return a result");
989
990 let error = result.error.as_deref().unwrap_or("");
991 assert!(
994 error.contains("gmail/users/messages/send"),
995 "expected full 4-segment path in error, got: {error}"
996 );
997 }
998
999 #[test]
1002 fn allowed_operations_config_values_trimmed_at_construction() {
1003 let tool = GoogleWorkspaceTool::new(
1004 test_security(),
1005 vec!["gmail".into()],
1006 vec![GoogleWorkspaceAllowedOperation {
1007 service: " gmail ".into(), resource: " users ".into(),
1009 sub_resource: Some(" drafts ".into()),
1010 methods: vec![" create ".into()],
1011 }],
1012 None,
1013 None,
1014 60,
1015 30,
1016 false,
1017 );
1018
1019 assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
1021 assert!(!tool.is_operation_allowed("gmail", "users", Some(" drafts "), "create"));
1022 }
1023
1024 #[test]
1027 fn pagination_page_limit_alone_appends_limit_without_page_all() {
1028 let flags = GoogleWorkspaceTool::build_pagination_args(false, Some(5));
1030 assert!(flags.contains(&"--page-limit".to_string()));
1031 assert!(!flags.contains(&"--page-all".to_string()));
1032 let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
1033 assert_eq!(flags[limit_idx + 1], "5");
1034 }
1035
1036 #[test]
1037 fn pagination_page_all_without_limit_uses_default() {
1038 let flags = GoogleWorkspaceTool::build_pagination_args(true, None);
1039 assert!(flags.contains(&"--page-all".to_string()));
1040 assert!(flags.contains(&"--page-limit".to_string()));
1041 let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
1042 assert_eq!(flags[limit_idx + 1], "10"); }
1044
1045 #[test]
1046 fn pagination_page_all_with_limit_appends_both() {
1047 let flags = GoogleWorkspaceTool::build_pagination_args(true, Some(20));
1048 assert!(flags.contains(&"--page-all".to_string()));
1049 let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
1050 assert_eq!(flags[limit_idx + 1], "20");
1051 }
1052
1053 #[test]
1054 fn pagination_neither_appends_nothing() {
1055 let flags = GoogleWorkspaceTool::build_pagination_args(false, None);
1056 assert!(flags.is_empty());
1057 }
1058}