1use anyhow::{Context, Result};
10use url::Url;
11
12use crate::drive::client::DriveClient;
13use crate::drive::error::DriveError;
14use crate::drive::types::{DriveFile, FileListResponse};
15use crate::utils::multipart;
16
17pub const MAX_PAGE_LIMIT: usize = 1000;
23
24pub const HARD_CAP: usize = 10_000;
30
31pub const DEFAULT_SEARCH_LIMIT: usize = 50;
34
35const MAX_DOWNLOAD_BYTES: u64 = 500 * 1024 * 1024;
40
41pub(crate) const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024;
49
50const LIST_FIELDS: &str = "nextPageToken,incompleteSearch,files(id,name,mimeType,size,\
53 md5Checksum,sha1Checksum,sha256Checksum,modifiedTime,parents,webViewLink,\
54 owners(displayName,emailAddress),driveId)";
55
56const GET_FIELDS: &str = "id,name,mimeType,size,md5Checksum,sha1Checksum,sha256Checksum,\
64 modifiedTime,parents,webViewLink,owners(displayName,emailAddress),driveId,exportLinks,\
65 version";
66
67#[derive(Debug)]
69pub struct FilesApi<'a> {
70 client: &'a DriveClient,
71}
72
73impl<'a> FilesApi<'a> {
74 #[must_use]
76 pub fn new(client: &'a DriveClient) -> Self {
77 Self { client }
78 }
79
80 pub async fn search(
85 &self,
86 query: Option<&str>,
87 limit: usize,
88 page_token: Option<&str>,
89 ) -> Result<FileListResponse> {
90 if limit > MAX_PAGE_LIMIT {
91 return Err(anyhow::anyhow!(
92 "`limit` must be <= {MAX_PAGE_LIMIT} (Drive files.list per-page cap; use \
93 `search_all` to auto-paginate)"
94 ));
95 }
96 let url = build_files_list_url(self.client.base_url(), query, limit, page_token)?;
97 self.client
98 .get_parsed(url.as_str(), "Failed to parse files.list response")
99 .await
100 }
101
102 pub async fn search_all(&self, query: Option<&str>, limit: usize) -> Result<FileListResponse> {
107 self.paginate(query, effective_cap(limit)).await
108 }
109
110 async fn paginate(&self, query: Option<&str>, cap: usize) -> Result<FileListResponse> {
121 let mut acc: Option<FileListResponse> = None;
122 let mut page_token: Option<String> = None;
123 loop {
124 let collected = acc.as_ref().map_or(0, |r| r.files.len());
125 let page_size = (cap - collected).min(MAX_PAGE_LIMIT);
126 let page = self.search(query, page_size, page_token.as_deref()).await?;
127 let next_token = page.next_page_token.clone();
128 match acc.as_mut() {
129 Some(existing) => {
130 existing.files.extend(page.files);
131 existing.next_page_token = page.next_page_token;
132 existing.incomplete_search = page.incomplete_search;
133 }
134 None => acc = Some(page),
135 }
136 let collected = acc.as_ref().map_or(0, |r| r.files.len());
137 if collected >= cap || next_token.is_none() {
138 break;
139 }
140 page_token = next_token;
141 }
142 let mut result = acc.unwrap_or_default();
143 if result.files.len() > cap {
144 result.files.truncate(cap);
145 result.next_page_token = None;
146 result.incomplete_search = None;
147 }
148 Ok(result)
149 }
150
151 pub async fn get_metadata(&self, file_id: &str) -> Result<DriveFile> {
154 let url = build_file_get_url(self.client.base_url(), file_id)?;
155 self.client
156 .get_parsed(url.as_str(), "Failed to parse files.get response")
157 .await
158 }
159
160 pub async fn export(&self, file_id: &str, export_mime_type: &str) -> Result<Vec<u8>> {
165 let url = build_export_url(self.client.base_url(), file_id, export_mime_type)?;
166 self.fetch_bytes(&url).await
167 }
168
169 pub async fn download(&self, file_id: &str) -> Result<Vec<u8>> {
172 let url = build_download_url(self.client.base_url(), file_id)?;
173 self.fetch_bytes(&url).await
174 }
175
176 pub async fn rename(&self, file_id: &str, new_name: &str) -> Result<DriveFile> {
180 let url = build_file_update_url(self.client.base_url(), file_id, None, None)?;
181 let response = self
182 .client
183 .patch_json(url.as_str(), &serde_json::json!({ "name": new_name }))
184 .await?;
185 self.client
186 .parse_response(response, "Failed to parse files.update response")
187 .await
188 .map_err(|err| append_write_scope_hint(err, WriteCapability::Metadata))
189 }
190
191 pub async fn move_to(
196 &self,
197 file_id: &str,
198 add_parents: &str,
199 remove_parents: &str,
200 ) -> Result<DriveFile> {
201 let url = build_file_update_url(
202 self.client.base_url(),
203 file_id,
204 Some(add_parents),
205 Some(remove_parents),
206 )?;
207 let response = self
208 .client
209 .patch_json(url.as_str(), &serde_json::json!({}))
210 .await?;
211 self.client
212 .parse_response(response, "Failed to parse files.update response")
213 .await
214 .map_err(|err| append_write_scope_hint(err, WriteCapability::Metadata))
215 }
216
217 pub(in crate::drive) async fn trash(&self, file_id: &str) -> Result<DriveFile> {
235 let url = build_file_update_url(self.client.base_url(), file_id, None, None)?;
236 let response = self
237 .client
238 .patch_json(url.as_str(), &serde_json::json!({ "trashed": true }))
239 .await?;
240 self.client
241 .parse_response(response, "Failed to parse files.update (trash) response")
242 .await
243 .map_err(|err| append_write_scope_hint(err, WriteCapability::Metadata))
244 }
245
246 pub(in crate::drive) async fn create(
259 &self,
260 name: &str,
261 parent_folder_id: &str,
262 mime_type: &str,
263 ) -> Result<DriveFile> {
264 let url = build_file_create_url(self.client.base_url())?;
265 let response = self
266 .client
267 .post_json(
268 url.as_str(),
269 &serde_json::json!({
270 "name": name,
271 "mimeType": mime_type,
272 "parents": [parent_folder_id],
273 }),
274 )
275 .await?;
276 self.client
277 .parse_response(response, "Failed to parse files.create response")
278 .await
279 .map_err(|err| append_write_scope_hint(err, WriteCapability::CreateOrUpload))
280 }
281
282 pub(in crate::drive) async fn copy(
296 &self,
297 file_id: &str,
298 parent_folder_id: &str,
299 name: &str,
300 ) -> Result<DriveFile> {
301 let url = build_file_copy_url(self.client.base_url(), file_id)?;
302 let response = self
303 .client
304 .post_json(
305 url.as_str(),
306 &serde_json::json!({
307 "name": name,
308 "parents": [parent_folder_id],
309 }),
310 )
311 .await?;
312 self.client
313 .parse_response(response, "Failed to parse files.copy response")
314 .await
315 .map_err(|err| append_write_scope_hint(err, WriteCapability::CopyForBackup))
316 }
317
318 pub(in crate::drive) async fn upload(
329 &self,
330 name: &str,
331 parent_folder_id: &str,
332 content: &[u8],
333 content_type: &str,
334 ) -> Result<DriveFile> {
335 check_upload_size(content.len() as u64)?;
336 check_content_type(content_type)?;
337 let boundary = multipart::generate_boundary();
338 let metadata = serde_json::json!({
339 "name": name,
340 "parents": [parent_folder_id],
341 });
342 let body = multipart::build_related_body(&metadata, content, content_type, &boundary);
343 let url = build_file_upload_url(self.client.base_url())?;
344 let response = self
345 .client
346 .post_bytes(
347 url.as_str(),
348 &body,
349 &format!("multipart/related; boundary={boundary}"),
350 )
351 .await?;
352 self.client
353 .parse_response(
354 response,
355 "Failed to parse files.create (multipart) response",
356 )
357 .await
358 .map_err(|err| append_write_scope_hint(err, WriteCapability::CreateOrUpload))
359 }
360
361 pub(in crate::drive) async fn edit_content(
372 &self,
373 file_id: &str,
374 content: &[u8],
375 content_type: &str,
376 ) -> Result<DriveFile> {
377 check_upload_size(content.len() as u64)?;
378 check_content_type(content_type)?;
379 let url = build_file_edit_content_url(self.client.base_url(), file_id)?;
380 let response = self
381 .client
382 .patch_bytes(url.as_str(), content, content_type)
383 .await?;
384 self.client
385 .parse_response(response, "Failed to parse files.update (media) response")
386 .await
387 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
388 }
389
390 async fn fetch_bytes(&self, url: &Url) -> Result<Vec<u8>> {
394 let response = self.client.get_bytes(url.as_str()).await?;
395 if !response.status().is_success() {
396 return Err(DriveClient::response_to_error(response).await.into());
397 }
398 check_download_size(response.content_length())?;
399 let bytes = response
400 .bytes()
401 .await
402 .context("Failed to read response body")?;
403 Ok(bytes.to_vec())
404 }
405}
406
407fn check_download_size(content_length: Option<u64>) -> Result<()> {
412 if let Some(len) = content_length {
413 anyhow::ensure!(
414 len <= MAX_DOWNLOAD_BYTES,
415 "refusing to load {len} bytes into memory (limit: {MAX_DOWNLOAD_BYTES} bytes); \
416 this file is too large for `drive read --content`"
417 );
418 }
419 Ok(())
420}
421
422pub(crate) fn check_upload_size(len: u64) -> Result<()> {
428 anyhow::ensure!(
429 len <= MAX_UPLOAD_BYTES,
430 "refusing to upload {len} bytes (limit: {MAX_UPLOAD_BYTES} bytes); Drive's simple \
431 upload endpoint caps requests at 5 MB — larger content needs resumable upload, not \
432 supported by `drive upload`/`drive edit` yet"
433 );
434 Ok(())
435}
436
437fn check_content_type(content_type: &str) -> Result<()> {
449 anyhow::ensure!(
450 !content_type.contains(['\r', '\n']),
451 "refusing content type {content_type:?}: must not contain a CR or LF byte"
452 );
453 Ok(())
454}
455
456fn build_files_list_url(
457 base_url: &str,
458 query: Option<&str>,
459 limit: usize,
460 page_token: Option<&str>,
461) -> Result<Url> {
462 let mut url = DriveClient::api_url(base_url, "/drive/v3/files")?;
463 {
464 let mut pairs = url.query_pairs_mut();
465 pairs.append_pair("supportsAllDrives", "true");
468 pairs.append_pair("includeItemsFromAllDrives", "true");
469 pairs.append_pair("fields", LIST_FIELDS);
470 if let Some(q) = query.filter(|q| !q.is_empty()) {
471 pairs.append_pair("q", q);
472 }
473 if limit > 0 {
474 pairs.append_pair("pageSize", &limit.to_string());
475 }
476 if let Some(token) = page_token {
477 pairs.append_pair("pageToken", token);
478 }
479 }
480 Ok(url)
481}
482
483fn build_file_get_url(base_url: &str, file_id: &str) -> Result<Url> {
484 let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
485 {
486 let mut pairs = url.query_pairs_mut();
487 pairs.append_pair("fields", GET_FIELDS);
488 pairs.append_pair("supportsAllDrives", "true");
489 }
490 Ok(url)
491}
492
493fn build_file_create_url(base_url: &str) -> Result<Url> {
496 let mut url = DriveClient::api_url(base_url, "/drive/v3/files")?;
497 {
498 let mut pairs = url.query_pairs_mut();
499 pairs.append_pair("fields", GET_FIELDS);
500 pairs.append_pair("supportsAllDrives", "true");
501 }
502 Ok(url)
503}
504
505fn build_file_copy_url(base_url: &str, file_id: &str) -> Result<Url> {
509 let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}/copy"))?;
510 {
511 let mut pairs = url.query_pairs_mut();
512 pairs.append_pair("fields", GET_FIELDS);
513 pairs.append_pair("supportsAllDrives", "true");
514 }
515 Ok(url)
516}
517
518fn build_file_upload_url(base_url: &str) -> Result<Url> {
522 let mut url = DriveClient::api_url(base_url, "/upload/drive/v3/files")?;
523 {
524 let mut pairs = url.query_pairs_mut();
525 pairs.append_pair("uploadType", "multipart");
526 pairs.append_pair("fields", GET_FIELDS);
527 pairs.append_pair("supportsAllDrives", "true");
528 }
529 Ok(url)
530}
531
532fn build_file_edit_content_url(base_url: &str, file_id: &str) -> Result<Url> {
535 let mut url = DriveClient::api_url(base_url, &format!("/upload/drive/v3/files/{file_id}"))?;
536 {
537 let mut pairs = url.query_pairs_mut();
538 pairs.append_pair("uploadType", "media");
539 pairs.append_pair("fields", GET_FIELDS);
540 pairs.append_pair("supportsAllDrives", "true");
541 }
542 Ok(url)
543}
544
545fn build_export_url(base_url: &str, file_id: &str, mime_type: &str) -> Result<Url> {
546 let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}/export"))?;
550 url.query_pairs_mut().append_pair("mimeType", mime_type);
551 Ok(url)
552}
553
554fn build_download_url(base_url: &str, file_id: &str) -> Result<Url> {
555 let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
556 {
557 let mut pairs = url.query_pairs_mut();
558 pairs.append_pair("alt", "media");
559 pairs.append_pair("supportsAllDrives", "true");
560 }
561 Ok(url)
562}
563
564fn build_file_update_url(
569 base_url: &str,
570 file_id: &str,
571 add_parents: Option<&str>,
572 remove_parents: Option<&str>,
573) -> Result<Url> {
574 let mut url = DriveClient::api_url(base_url, &format!("/drive/v3/files/{file_id}"))?;
575 {
576 let mut pairs = url.query_pairs_mut();
577 pairs.append_pair("fields", GET_FIELDS);
578 pairs.append_pair("supportsAllDrives", "true");
579 if let Some(add) = add_parents {
580 pairs.append_pair("addParents", add);
581 }
582 if let Some(remove) = remove_parents {
583 pairs.append_pair("removeParents", remove);
584 }
585 }
586 Ok(url)
587}
588
589pub(crate) enum WriteCapability {
594 Metadata,
599 CreateOrUpload,
602 EditContent,
607 CopyForBackup,
613}
614
615pub(in crate::drive) fn append_write_scope_hint(
628 err: anyhow::Error,
629 capability: WriteCapability,
630) -> anyhow::Error {
631 let is_insufficient_permissions = matches!(
632 err.downcast_ref::<DriveError>(),
633 Some(DriveError::ApiRequestFailed {
634 reason: Some(reason),
635 ..
636 }) if reason == "insufficientPermissions" || reason == "PERMISSION_DENIED"
637 );
638 if !is_insufficient_permissions {
639 return err;
640 }
641 let hint = match capability {
642 WriteCapability::Metadata => {
643 "Run `omni-dev drive auth login --write` to grant the drive.metadata scope needed \
644 for rename/move/trash"
645 }
646 WriteCapability::CreateOrUpload => {
647 "Run `omni-dev drive auth login --write-file` (or `--write-full`) to grant the \
648 scope needed to create files/folders and upload content"
649 }
650 WriteCapability::EditContent => {
651 "Run `omni-dev drive auth login --write-file` if this file was created by \
652 omni-dev, or `--write-full` to edit any pre-existing file's content, then retry"
653 }
654 WriteCapability::CopyForBackup => {
655 "Run `omni-dev drive auth login --write-full` to grant the unrestricted scope \
656 needed to back up a native document before a leased write, then retry"
657 }
658 };
659 err.context(hint)
660}
661
662fn effective_cap(limit: usize) -> usize {
665 if limit == 0 {
666 HARD_CAP
667 } else {
668 limit.min(HARD_CAP)
669 }
670}
671
672#[cfg(test)]
673#[allow(clippy::unwrap_used, clippy::expect_used)]
674mod tests {
675 use super::*;
676 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
677 use crate::drive::types::Owner;
678 use crate::utils::secret::Secret;
679
680 fn test_credentials() -> DriveCredentials {
681 DriveCredentials {
682 client_id: "client-1".to_string(),
683 client_secret: Secret::new("secret-1"),
684 refresh_token: Secret::new("refresh-1"),
685 scope: DriveGrantedScopes::READONLY,
686 }
687 }
688
689 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
690 wiremock::Mock::given(wiremock::matchers::method("POST"))
691 .and(wiremock::matchers::path("/token"))
692 .respond_with(
693 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
694 "access_token": "test-token",
695 "expires_in": 3600,
696 })),
697 )
698 .mount(server)
699 .await;
700
701 let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
702 crate::drive::client::test_support::replace_session(
703 &mut client,
704 &test_credentials(),
705 &format!("{}/token", server.uri()),
706 );
707 client
708 }
709
710 fn fully_populated_drive_file() -> DriveFile {
719 DriveFile {
720 id: "f1".to_string(),
721 name: "n".to_string(),
722 mime_type: "application/pdf".to_string(),
723 size: Some("1".to_string()),
724 md5_checksum: Some("5d41402abc4b2a76b9719d911017c592".to_string()),
725 sha1_checksum: Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d".to_string()),
726 sha256_checksum: Some(
727 "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".to_string(),
728 ),
729 modified_time: Some("2026-01-01T00:00:00Z".to_string()),
730 parents: vec!["p1".to_string()],
731 web_view_link: Some("https://example.com/view".to_string()),
732 owners: vec![Owner {
733 display_name: Some("Alice".to_string()),
734 email_address: Some("alice@example.com".to_string()),
735 }],
736 drive_id: Some("d1".to_string()),
737 export_links: Some(std::collections::HashMap::from([(
738 "text/markdown".to_string(),
739 "https://export.example.com/md".to_string(),
740 )])),
741 version: Some("1".to_string()),
742 }
743 }
744
745 #[test]
746 fn get_fields_requests_every_drive_file_field() {
747 let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
748 for key in json.as_object().unwrap().keys() {
749 assert!(
750 GET_FIELDS.contains(key.as_str()),
751 "GET_FIELDS is missing `{key}` — add it, or update this test if that's \
752 deliberate"
753 );
754 }
755 }
756
757 #[test]
758 fn list_fields_requests_every_drive_file_field_except_export_links() {
759 let json = serde_json::to_value(fully_populated_drive_file()).unwrap();
760 for key in json.as_object().unwrap().keys() {
761 if key == "exportLinks" || key == "version" {
762 continue;
767 }
768 assert!(
769 LIST_FIELDS.contains(key.as_str()),
770 "LIST_FIELDS is missing `{key}` — add it, or update this test if that's \
771 deliberate"
772 );
773 }
774 }
775
776 #[test]
777 fn fields_selectors_request_every_owner_field() {
778 let owner = Owner {
779 display_name: Some("Alice".to_string()),
780 email_address: Some("alice@example.com".to_string()),
781 };
782 let json = serde_json::to_value(owner).unwrap();
783 for key in json.as_object().unwrap().keys() {
784 assert!(
785 GET_FIELDS.contains(key.as_str()),
786 "GET_FIELDS' owners() selector is missing `{key}`"
787 );
788 assert!(
789 LIST_FIELDS.contains(key.as_str()),
790 "LIST_FIELDS' owners() selector is missing `{key}`"
791 );
792 }
793 }
794
795 #[test]
798 fn build_files_list_url_with_only_provided_filters() {
799 let url = build_files_list_url("https://www.googleapis.com", None, 0, None).unwrap();
800 assert!(url.as_str().contains("supportsAllDrives=true"));
801 assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
802 assert!(url.as_str().contains("fields="));
803 assert!(!url.as_str().contains("q="));
804 assert!(!url.as_str().contains("pageSize="));
805 assert!(!url.as_str().contains("pageToken="));
806 }
807
808 #[test]
809 fn build_files_list_url_with_full_filter_set() {
810 let url = build_files_list_url(
811 "https://www.googleapis.com",
812 Some("name contains 'x'"),
813 10,
814 Some("token1"),
815 )
816 .unwrap();
817 assert!(url.as_str().contains("supportsAllDrives=true"));
818 assert!(url.as_str().contains("includeItemsFromAllDrives=true"));
819 assert!(url.as_str().contains("q=name"));
820 assert!(url.as_str().contains("pageSize=10"));
821 assert!(url.as_str().contains("pageToken=token1"));
822 }
823
824 #[test]
825 fn build_files_list_url_rejects_invalid_base_url() {
826 let err = build_files_list_url("not a url", None, 0, None).unwrap_err();
827 assert!(err.to_string().contains("Invalid Drive base URL"));
828 }
829
830 #[test]
831 fn build_file_get_url_includes_fields_and_supports_all_drives() {
832 let url = build_file_get_url("https://www.googleapis.com", "f1").unwrap();
833 assert!(url.as_str().contains("/drive/v3/files/f1"));
834 assert!(url.as_str().contains("fields="));
835 assert!(url.as_str().contains("supportsAllDrives=true"));
836 }
837
838 #[test]
839 fn build_file_create_url_includes_fields_and_supports_all_drives() {
840 let url = build_file_create_url("https://www.googleapis.com").unwrap();
841 assert!(url.path().ends_with("/drive/v3/files"));
842 assert!(url.as_str().contains("fields="));
843 assert!(url.as_str().contains("supportsAllDrives=true"));
844 }
845
846 #[test]
847 fn build_export_url_includes_mime_type_and_omits_supports_all_drives() {
848 let url = build_export_url("https://www.googleapis.com", "f1", "text/markdown").unwrap();
849 assert!(url.as_str().contains("/drive/v3/files/f1/export"));
850 assert!(url.as_str().contains("mimeType=text%2Fmarkdown"));
851 assert!(!url.as_str().contains("supportsAllDrives"));
852 }
853
854 #[test]
855 fn build_download_url_includes_alt_media_and_supports_all_drives() {
856 let url = build_download_url("https://www.googleapis.com", "f1").unwrap();
857 assert!(url.as_str().contains("/drive/v3/files/f1"));
858 assert!(url.as_str().contains("alt=media"));
859 assert!(url.as_str().contains("supportsAllDrives=true"));
860 }
861
862 #[tokio::test]
865 async fn search_rejects_limit_above_max_page_limit_client_side() {
866 let server = wiremock::MockServer::start().await;
867 let client = client_with_bootstrapped_token(&server).await;
868 let err = FilesApi::new(&client)
869 .search(None, MAX_PAGE_LIMIT + 1, None)
870 .await
871 .unwrap_err();
872 assert!(err.to_string().contains("must be <="));
873 }
874
875 #[tokio::test]
876 async fn search_propagates_api_errors() {
877 let server = wiremock::MockServer::start().await;
878 let client = client_with_bootstrapped_token(&server).await;
879 wiremock::Mock::given(wiremock::matchers::method("GET"))
880 .and(wiremock::matchers::path("/drive/v3/files"))
881 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
882 .mount(&server)
883 .await;
884
885 let err = FilesApi::new(&client)
886 .search(None, 10, None)
887 .await
888 .unwrap_err();
889 assert!(err.to_string().contains("500"));
890 }
891
892 #[tokio::test]
893 async fn search_errors_on_malformed_response() {
894 let server = wiremock::MockServer::start().await;
895 let client = client_with_bootstrapped_token(&server).await;
896 wiremock::Mock::given(wiremock::matchers::method("GET"))
897 .and(wiremock::matchers::path("/drive/v3/files"))
898 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
899 .mount(&server)
900 .await;
901
902 let err = FilesApi::new(&client)
903 .search(None, 10, None)
904 .await
905 .unwrap_err();
906 assert!(err.to_string().contains("Failed to parse"));
907 }
908
909 #[tokio::test]
912 async fn search_all_single_page_when_no_next_token() {
913 let server = wiremock::MockServer::start().await;
914 let client = client_with_bootstrapped_token(&server).await;
915 wiremock::Mock::given(wiremock::matchers::method("GET"))
916 .and(wiremock::matchers::path("/drive/v3/files"))
917 .respond_with(
918 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
919 "files": [{"id": "f1", "name": "a"}],
920 })),
921 )
922 .expect(1)
923 .mount(&server)
924 .await;
925
926 let result = FilesApi::new(&client).search_all(None, 10).await.unwrap();
927 assert_eq!(result.files.len(), 1);
928 }
929
930 #[tokio::test]
931 async fn search_all_follows_next_page_token_to_exhaustion() {
932 let server = wiremock::MockServer::start().await;
933 let client = client_with_bootstrapped_token(&server).await;
934 wiremock::Mock::given(wiremock::matchers::method("GET"))
935 .and(wiremock::matchers::path("/drive/v3/files"))
936 .and(wiremock::matchers::query_param_is_missing("pageToken"))
937 .respond_with(
938 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
939 "files": [{"id": "f1", "name": "a"}],
940 "nextPageToken": "page2",
941 })),
942 )
943 .mount(&server)
944 .await;
945 wiremock::Mock::given(wiremock::matchers::method("GET"))
946 .and(wiremock::matchers::path("/drive/v3/files"))
947 .and(wiremock::matchers::query_param("pageToken", "page2"))
948 .respond_with(
949 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
950 "files": [{"id": "f2", "name": "b"}],
951 })),
952 )
953 .mount(&server)
954 .await;
955
956 let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
957 assert_eq!(result.files.len(), 2);
958 assert_eq!(result.files[0].id, "f1");
959 assert_eq!(result.files[1].id, "f2");
960 }
961
962 #[tokio::test]
963 async fn search_all_stops_at_explicit_limit() {
964 let server = wiremock::MockServer::start().await;
965 let client = client_with_bootstrapped_token(&server).await;
966 wiremock::Mock::given(wiremock::matchers::method("GET"))
967 .and(wiremock::matchers::path("/drive/v3/files"))
968 .respond_with(
969 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
970 "files": [
971 {"id": "f1", "name": "a"},
972 {"id": "f2", "name": "b"},
973 {"id": "f3", "name": "c"},
974 ],
975 "nextPageToken": "page2",
976 "incompleteSearch": true,
977 })),
978 )
979 .mount(&server)
980 .await;
981
982 let result = FilesApi::new(&client).search_all(None, 2).await.unwrap();
983 assert_eq!(result.files.len(), 2);
984 assert_eq!(result.next_page_token, None);
987 assert_eq!(result.incomplete_search, None);
988 }
989
990 #[tokio::test]
991 async fn search_all_preserves_next_page_token_at_exact_cap() {
992 let server = wiremock::MockServer::start().await;
993 let client = client_with_bootstrapped_token(&server).await;
994 wiremock::Mock::given(wiremock::matchers::method("GET"))
995 .and(wiremock::matchers::path("/drive/v3/files"))
996 .respond_with(
997 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
998 "files": [
999 {"id": "f1", "name": "a"},
1000 {"id": "f2", "name": "b"},
1001 {"id": "f3", "name": "c"},
1002 ],
1003 "nextPageToken": "page2",
1004 "incompleteSearch": true,
1005 })),
1006 )
1007 .mount(&server)
1008 .await;
1009
1010 let result = FilesApi::new(&client).search_all(None, 3).await.unwrap();
1011 assert_eq!(result.files.len(), 3);
1012 assert_eq!(result.next_page_token.as_deref(), Some("page2"));
1015 assert_eq!(result.incomplete_search, Some(true));
1016 }
1017
1018 #[tokio::test]
1019 async fn search_all_continues_past_empty_page_with_a_valid_next_page_token() {
1020 let server = wiremock::MockServer::start().await;
1021 let client = client_with_bootstrapped_token(&server).await;
1022 wiremock::Mock::given(wiremock::matchers::method("GET"))
1023 .and(wiremock::matchers::path("/drive/v3/files"))
1024 .and(wiremock::matchers::query_param_is_missing("pageToken"))
1025 .respond_with(
1026 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1027 "files": [],
1028 "nextPageToken": "page2",
1029 })),
1030 )
1031 .mount(&server)
1032 .await;
1033 wiremock::Mock::given(wiremock::matchers::method("GET"))
1034 .and(wiremock::matchers::path("/drive/v3/files"))
1035 .and(wiremock::matchers::query_param("pageToken", "page2"))
1036 .respond_with(
1037 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1038 "files": [{"id": "f1", "name": "a"}],
1039 })),
1040 )
1041 .mount(&server)
1042 .await;
1043
1044 let result = FilesApi::new(&client).search_all(None, 0).await.unwrap();
1045 assert_eq!(result.files.len(), 1);
1046 }
1047
1048 #[tokio::test]
1049 async fn search_all_truncates_to_hard_cap() {
1050 assert_eq!(effective_cap(0), HARD_CAP);
1051 }
1052
1053 #[tokio::test]
1056 async fn get_metadata_sends_fields_query_param() {
1057 let server = wiremock::MockServer::start().await;
1058 let client = client_with_bootstrapped_token(&server).await;
1059 wiremock::Mock::given(wiremock::matchers::method("GET"))
1060 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1061 .and(wiremock::matchers::query_param("fields", GET_FIELDS))
1062 .respond_with(
1063 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1064 "id": "f1", "name": "a",
1065 })),
1066 )
1067 .expect(1)
1068 .mount(&server)
1069 .await;
1070
1071 let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
1072 assert_eq!(file.id, "f1");
1073 }
1074
1075 #[tokio::test]
1076 async fn get_metadata_parses_export_links() {
1077 let server = wiremock::MockServer::start().await;
1078 let client = client_with_bootstrapped_token(&server).await;
1079 wiremock::Mock::given(wiremock::matchers::method("GET"))
1080 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1081 .respond_with(
1082 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1083 "id": "f1",
1084 "name": "doc",
1085 "mimeType": "application/vnd.google-apps.document",
1086 "exportLinks": {"text/markdown": "https://export.example/md"},
1087 })),
1088 )
1089 .mount(&server)
1090 .await;
1091
1092 let file = FilesApi::new(&client).get_metadata("f1").await.unwrap();
1093 assert!(file.export_links.unwrap().contains_key("text/markdown"));
1094 }
1095
1096 #[tokio::test]
1097 async fn get_metadata_propagates_api_errors() {
1098 let server = wiremock::MockServer::start().await;
1099 let client = client_with_bootstrapped_token(&server).await;
1100 wiremock::Mock::given(wiremock::matchers::method("GET"))
1101 .and(wiremock::matchers::path("/drive/v3/files/missing"))
1102 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1103 .mount(&server)
1104 .await;
1105
1106 let err = FilesApi::new(&client)
1107 .get_metadata("missing")
1108 .await
1109 .unwrap_err();
1110 assert!(err.to_string().contains("404"));
1111 }
1112
1113 #[tokio::test]
1116 async fn export_sends_mime_type_query_param_and_returns_bytes() {
1117 let server = wiremock::MockServer::start().await;
1118 let client = client_with_bootstrapped_token(&server).await;
1119 wiremock::Mock::given(wiremock::matchers::method("GET"))
1120 .and(wiremock::matchers::path("/drive/v3/files/f1/export"))
1121 .and(wiremock::matchers::query_param("mimeType", "text/markdown"))
1122 .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"# Title".to_vec()))
1123 .expect(1)
1124 .mount(&server)
1125 .await;
1126
1127 let bytes = FilesApi::new(&client)
1128 .export("f1", "text/markdown")
1129 .await
1130 .unwrap();
1131 assert_eq!(bytes, b"# Title");
1132 }
1133
1134 #[tokio::test]
1135 async fn export_propagates_api_errors() {
1136 let server = wiremock::MockServer::start().await;
1137 let client = client_with_bootstrapped_token(&server).await;
1138 wiremock::Mock::given(wiremock::matchers::method("GET"))
1139 .and(wiremock::matchers::path("/drive/v3/files/f1/export"))
1140 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1141 .mount(&server)
1142 .await;
1143
1144 let err = FilesApi::new(&client)
1145 .export("f1", "text/markdown")
1146 .await
1147 .unwrap_err();
1148 assert!(err.to_string().contains("404"));
1149 }
1150
1151 #[tokio::test]
1152 async fn download_sends_alt_media_and_returns_bytes() {
1153 let server = wiremock::MockServer::start().await;
1154 let client = client_with_bootstrapped_token(&server).await;
1155 wiremock::Mock::given(wiremock::matchers::method("GET"))
1156 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1157 .and(wiremock::matchers::query_param("alt", "media"))
1158 .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"binary".to_vec()))
1159 .expect(1)
1160 .mount(&server)
1161 .await;
1162
1163 let bytes = FilesApi::new(&client).download("f1").await.unwrap();
1164 assert_eq!(bytes, b"binary");
1165 }
1166
1167 #[tokio::test]
1168 async fn download_propagates_api_errors() {
1169 let server = wiremock::MockServer::start().await;
1170 let client = client_with_bootstrapped_token(&server).await;
1171 wiremock::Mock::given(wiremock::matchers::method("GET"))
1172 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1173 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1174 .mount(&server)
1175 .await;
1176
1177 let err = FilesApi::new(&client).download("f1").await.unwrap_err();
1178 assert!(err.to_string().contains("500"));
1179 }
1180
1181 #[tokio::test]
1184 async fn rename_sends_name_body_and_no_parents_params() {
1185 let server = wiremock::MockServer::start().await;
1186 let client = client_with_bootstrapped_token(&server).await;
1187 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1188 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1189 .and(wiremock::matchers::query_param("fields", GET_FIELDS))
1190 .and(wiremock::matchers::body_json(
1191 serde_json::json!({"name": "New Name"}),
1192 ))
1193 .respond_with(
1194 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1195 "id": "f1", "name": "New Name",
1196 })),
1197 )
1198 .expect(1)
1199 .mount(&server)
1200 .await;
1201
1202 let file = FilesApi::new(&client)
1203 .rename("f1", "New Name")
1204 .await
1205 .unwrap();
1206 assert_eq!(file.name, "New Name");
1207
1208 let requests = server.received_requests().await.unwrap();
1209 let req = requests
1210 .iter()
1211 .find(|r| r.method.as_str() == "PATCH")
1212 .unwrap();
1213 assert!(req.url.query_pairs().all(|(k, _)| k != "addParents"));
1214 assert!(req.url.query_pairs().all(|(k, _)| k != "removeParents"));
1215 }
1216
1217 #[tokio::test]
1218 async fn rename_propagates_api_errors() {
1219 let server = wiremock::MockServer::start().await;
1220 let client = client_with_bootstrapped_token(&server).await;
1221 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1222 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1223 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1224 .mount(&server)
1225 .await;
1226
1227 let err = FilesApi::new(&client)
1228 .rename("f1", "New Name")
1229 .await
1230 .unwrap_err();
1231 assert!(err.to_string().contains("404"));
1232 }
1233
1234 #[tokio::test]
1235 async fn rename_appends_write_scope_hint_on_insufficient_permissions() {
1236 let server = wiremock::MockServer::start().await;
1237 let client = client_with_bootstrapped_token(&server).await;
1238 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1239 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1240 .respond_with(
1241 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1242 "error": {
1243 "message": "Insufficient Permission",
1244 "errors": [{"reason": "insufficientPermissions"}],
1245 }
1246 })),
1247 )
1248 .mount(&server)
1249 .await;
1250
1251 let err = FilesApi::new(&client)
1252 .rename("f1", "New Name")
1253 .await
1254 .unwrap_err();
1255 assert!(
1256 err.to_string().contains("drive auth login --write"),
1257 "{err}"
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn move_to_sends_add_and_remove_parents_query_params() {
1263 let server = wiremock::MockServer::start().await;
1264 let client = client_with_bootstrapped_token(&server).await;
1265 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1266 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1267 .and(wiremock::matchers::query_param("addParents", "dest"))
1268 .and(wiremock::matchers::query_param("removeParents", "src"))
1269 .and(wiremock::matchers::body_json(serde_json::json!({})))
1270 .respond_with(
1271 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1272 "id": "f1", "name": "a", "parents": ["dest"],
1273 })),
1274 )
1275 .expect(1)
1276 .mount(&server)
1277 .await;
1278
1279 let file = FilesApi::new(&client)
1280 .move_to("f1", "dest", "src")
1281 .await
1282 .unwrap();
1283 assert_eq!(file.parents, vec!["dest".to_string()]);
1284 }
1285
1286 #[tokio::test]
1287 async fn move_to_propagates_api_errors() {
1288 let server = wiremock::MockServer::start().await;
1289 let client = client_with_bootstrapped_token(&server).await;
1290 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1291 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1292 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1293 .mount(&server)
1294 .await;
1295
1296 let err = FilesApi::new(&client)
1297 .move_to("f1", "dest", "src")
1298 .await
1299 .unwrap_err();
1300 assert!(err.to_string().contains("500"));
1301 }
1302
1303 #[tokio::test]
1304 async fn move_to_appends_write_scope_hint_on_insufficient_permissions() {
1305 let server = wiremock::MockServer::start().await;
1306 let client = client_with_bootstrapped_token(&server).await;
1307 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1308 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1309 .respond_with(
1310 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1311 "error": {
1312 "message": "Insufficient Permission",
1313 "errors": [{"reason": "insufficientPermissions"}],
1314 }
1315 })),
1316 )
1317 .mount(&server)
1318 .await;
1319
1320 let err = FilesApi::new(&client)
1321 .move_to("f1", "dest", "src")
1322 .await
1323 .unwrap_err();
1324 assert!(
1325 err.to_string().contains("drive auth login --write"),
1326 "{err}"
1327 );
1328 }
1329
1330 #[tokio::test]
1333 async fn trash_sends_a_trashed_true_body() {
1334 let server = wiremock::MockServer::start().await;
1335 let client = client_with_bootstrapped_token(&server).await;
1336 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1337 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1338 .and(wiremock::matchers::body_json(
1339 serde_json::json!({"trashed": true}),
1340 ))
1341 .respond_with(
1342 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1343 "id": "f1", "name": "backup", "trashed": true,
1344 })),
1345 )
1346 .expect(1)
1347 .mount(&server)
1348 .await;
1349
1350 let file = FilesApi::new(&client).trash("f1").await.unwrap();
1351 assert_eq!(file.id, "f1");
1352 }
1353
1354 #[tokio::test]
1355 async fn trash_propagates_api_errors() {
1356 let server = wiremock::MockServer::start().await;
1357 let client = client_with_bootstrapped_token(&server).await;
1358 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1359 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1360 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1361 .mount(&server)
1362 .await;
1363
1364 let err = FilesApi::new(&client).trash("f1").await.unwrap_err();
1365 assert!(err.to_string().contains("404"));
1366 }
1367
1368 #[tokio::test]
1369 async fn trash_appends_write_scope_hint_on_insufficient_permissions() {
1370 let server = wiremock::MockServer::start().await;
1371 let client = client_with_bootstrapped_token(&server).await;
1372 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1373 .and(wiremock::matchers::path("/drive/v3/files/f1"))
1374 .respond_with(
1375 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1376 "error": {
1377 "message": "Insufficient Permission",
1378 "errors": [{"reason": "insufficientPermissions"}],
1379 }
1380 })),
1381 )
1382 .mount(&server)
1383 .await;
1384
1385 let err = FilesApi::new(&client).trash("f1").await.unwrap_err();
1386 assert!(
1387 err.to_string().contains("drive auth login --write"),
1388 "{err}"
1389 );
1390 }
1391
1392 #[tokio::test]
1395 async fn create_sends_name_mime_type_and_parents() {
1396 let server = wiremock::MockServer::start().await;
1397 let client = client_with_bootstrapped_token(&server).await;
1398 wiremock::Mock::given(wiremock::matchers::method("POST"))
1399 .and(wiremock::matchers::path("/drive/v3/files"))
1400 .and(wiremock::matchers::query_param("fields", GET_FIELDS))
1401 .and(wiremock::matchers::body_json(serde_json::json!({
1402 "name": "New File",
1403 "mimeType": "text/plain",
1404 "parents": ["parent-1"],
1405 })))
1406 .respond_with(
1407 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1408 "id": "f1", "name": "New File", "mimeType": "text/plain",
1409 })),
1410 )
1411 .expect(1)
1412 .mount(&server)
1413 .await;
1414
1415 let file = FilesApi::new(&client)
1416 .create("New File", "parent-1", "text/plain")
1417 .await
1418 .unwrap();
1419 assert_eq!(file.id, "f1");
1420 assert_eq!(file.name, "New File");
1421 }
1422
1423 #[tokio::test]
1424 async fn create_propagates_api_errors() {
1425 let server = wiremock::MockServer::start().await;
1426 let client = client_with_bootstrapped_token(&server).await;
1427 wiremock::Mock::given(wiremock::matchers::method("POST"))
1428 .and(wiremock::matchers::path("/drive/v3/files"))
1429 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1430 .mount(&server)
1431 .await;
1432
1433 let err = FilesApi::new(&client)
1434 .create("New File", "parent-1", "text/plain")
1435 .await
1436 .unwrap_err();
1437 assert!(err.to_string().contains("404"));
1438 }
1439
1440 #[tokio::test]
1441 async fn create_appends_write_scope_hint_on_insufficient_permissions() {
1442 let server = wiremock::MockServer::start().await;
1443 let client = client_with_bootstrapped_token(&server).await;
1444 wiremock::Mock::given(wiremock::matchers::method("POST"))
1445 .and(wiremock::matchers::path("/drive/v3/files"))
1446 .respond_with(
1447 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1448 "error": {
1449 "message": "Insufficient Permission",
1450 "errors": [{"reason": "insufficientPermissions"}],
1451 }
1452 })),
1453 )
1454 .mount(&server)
1455 .await;
1456
1457 let err = FilesApi::new(&client)
1458 .create("New File", "parent-1", "text/plain")
1459 .await
1460 .unwrap_err();
1461 assert!(err.to_string().contains("--write-file"), "{err}");
1462 assert!(err.to_string().contains("--write-full"), "{err}");
1463 }
1464
1465 #[tokio::test]
1468 async fn upload_sends_multipart_related_content_type_and_returns_file() {
1469 let server = wiremock::MockServer::start().await;
1470 let client = client_with_bootstrapped_token(&server).await;
1471 wiremock::Mock::given(wiremock::matchers::method("POST"))
1472 .and(wiremock::matchers::path("/upload/drive/v3/files"))
1473 .and(wiremock::matchers::query_param("uploadType", "multipart"))
1474 .and(wiremock::matchers::header_regex(
1475 "content-type",
1476 "^multipart/related; boundary=omnidev-",
1477 ))
1478 .respond_with(
1479 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1480 "id": "new-upload-1", "name": "photo.jpg",
1481 })),
1482 )
1483 .expect(1)
1484 .mount(&server)
1485 .await;
1486
1487 let file = FilesApi::new(&client)
1488 .upload("photo.jpg", "parent-1", b"JPEGDATA", "image/jpeg")
1489 .await
1490 .unwrap();
1491 assert_eq!(file.id, "new-upload-1");
1492 }
1493
1494 #[tokio::test]
1495 async fn upload_refuses_oversized_content_before_any_network_call() {
1496 let server = wiremock::MockServer::start().await;
1497 let client = client_with_bootstrapped_token(&server).await;
1498 let oversized = vec![0u8; (MAX_UPLOAD_BYTES + 1) as usize];
1501
1502 let err = FilesApi::new(&client)
1503 .upload(
1504 "big.bin",
1505 "parent-1",
1506 &oversized,
1507 "application/octet-stream",
1508 )
1509 .await
1510 .unwrap_err();
1511 assert!(err.to_string().contains("refusing to upload"), "{err}");
1512 }
1513
1514 #[tokio::test]
1515 async fn upload_refuses_content_type_containing_crlf_before_any_network_call() {
1516 let server = wiremock::MockServer::start().await;
1517 let client = client_with_bootstrapped_token(&server).await;
1518 let err = FilesApi::new(&client)
1522 .upload(
1523 "f.txt",
1524 "parent-1",
1525 b"content",
1526 "text/plain\r\n--boundary\r\nX-Injected: yes",
1527 )
1528 .await
1529 .unwrap_err();
1530 assert!(err.to_string().contains("refusing content type"), "{err}");
1531 }
1532
1533 #[tokio::test]
1534 async fn upload_propagates_api_errors() {
1535 let server = wiremock::MockServer::start().await;
1536 let client = client_with_bootstrapped_token(&server).await;
1537 wiremock::Mock::given(wiremock::matchers::method("POST"))
1538 .and(wiremock::matchers::path("/upload/drive/v3/files"))
1539 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1540 .mount(&server)
1541 .await;
1542
1543 let err = FilesApi::new(&client)
1544 .upload("f.txt", "parent-1", b"content", "text/plain")
1545 .await
1546 .unwrap_err();
1547 assert!(err.to_string().contains("404"));
1548 }
1549
1550 #[tokio::test]
1551 async fn upload_appends_write_scope_hint_on_insufficient_permissions() {
1552 let server = wiremock::MockServer::start().await;
1553 let client = client_with_bootstrapped_token(&server).await;
1554 wiremock::Mock::given(wiremock::matchers::method("POST"))
1555 .and(wiremock::matchers::path("/upload/drive/v3/files"))
1556 .respond_with(
1557 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1558 "error": {
1559 "message": "Insufficient Permission",
1560 "errors": [{"reason": "insufficientPermissions"}],
1561 }
1562 })),
1563 )
1564 .mount(&server)
1565 .await;
1566
1567 let err = FilesApi::new(&client)
1568 .upload("f.txt", "parent-1", b"content", "text/plain")
1569 .await
1570 .unwrap_err();
1571 assert!(err.to_string().contains("--write-file"), "{err}");
1572 assert!(err.to_string().contains("--write-full"), "{err}");
1573 }
1574
1575 #[tokio::test]
1578 async fn edit_content_sends_media_upload_type_and_returns_file() {
1579 let server = wiremock::MockServer::start().await;
1580 let client = client_with_bootstrapped_token(&server).await;
1581 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1582 .and(wiremock::matchers::path("/upload/drive/v3/files/f1"))
1583 .and(wiremock::matchers::query_param("uploadType", "media"))
1584 .and(wiremock::matchers::header("content-type", "text/plain"))
1585 .and(wiremock::matchers::body_bytes(b"new content".to_vec()))
1586 .respond_with(
1587 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1588 "id": "f1", "name": "existing.txt",
1589 })),
1590 )
1591 .expect(1)
1592 .mount(&server)
1593 .await;
1594
1595 let file = FilesApi::new(&client)
1596 .edit_content("f1", b"new content", "text/plain")
1597 .await
1598 .unwrap();
1599 assert_eq!(file.id, "f1");
1600 }
1601
1602 #[tokio::test]
1603 async fn edit_content_refuses_oversized_content_before_any_network_call() {
1604 let server = wiremock::MockServer::start().await;
1605 let client = client_with_bootstrapped_token(&server).await;
1606 let oversized = vec![0u8; (MAX_UPLOAD_BYTES + 1) as usize];
1607
1608 let err = FilesApi::new(&client)
1609 .edit_content("f1", &oversized, "application/octet-stream")
1610 .await
1611 .unwrap_err();
1612 assert!(err.to_string().contains("refusing to upload"), "{err}");
1613 }
1614
1615 #[tokio::test]
1616 async fn edit_content_refuses_content_type_containing_crlf_before_any_network_call() {
1617 let server = wiremock::MockServer::start().await;
1618 let client = client_with_bootstrapped_token(&server).await;
1619 let err = FilesApi::new(&client)
1622 .edit_content("f1", b"content", "text/plain\r\nX-Injected: yes")
1623 .await
1624 .unwrap_err();
1625 assert!(err.to_string().contains("refusing content type"), "{err}");
1626 }
1627
1628 #[tokio::test]
1629 async fn edit_content_propagates_api_errors() {
1630 let server = wiremock::MockServer::start().await;
1631 let client = client_with_bootstrapped_token(&server).await;
1632 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1633 .and(wiremock::matchers::path("/upload/drive/v3/files/f1"))
1634 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1635 .mount(&server)
1636 .await;
1637
1638 let err = FilesApi::new(&client)
1639 .edit_content("f1", b"content", "text/plain")
1640 .await
1641 .unwrap_err();
1642 assert!(err.to_string().contains("404"));
1643 }
1644
1645 #[tokio::test]
1646 async fn edit_content_appends_write_scope_hint_on_insufficient_permissions() {
1647 let server = wiremock::MockServer::start().await;
1648 let client = client_with_bootstrapped_token(&server).await;
1649 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1650 .and(wiremock::matchers::path("/upload/drive/v3/files/f1"))
1651 .respond_with(
1652 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1653 "error": {
1654 "message": "Insufficient Permission",
1655 "errors": [{"reason": "insufficientPermissions"}],
1656 }
1657 })),
1658 )
1659 .mount(&server)
1660 .await;
1661
1662 let err = FilesApi::new(&client)
1663 .edit_content("f1", b"content", "text/plain")
1664 .await
1665 .unwrap_err();
1666 assert!(err.to_string().contains("--write-file"), "{err}");
1667 assert!(err.to_string().contains("--write-full"), "{err}");
1668 }
1669
1670 #[test]
1671 fn build_file_edit_content_url_includes_upload_type_fields_and_supports_all_drives() {
1672 let url = build_file_edit_content_url("https://www.googleapis.com", "f1").unwrap();
1673 assert!(url.path().ends_with("/upload/drive/v3/files/f1"));
1674 assert!(url.as_str().contains("uploadType=media"));
1675 assert!(url.as_str().contains("fields="));
1676 assert!(url.as_str().contains("supportsAllDrives=true"));
1677 }
1678
1679 #[test]
1680 fn append_write_scope_hint_leaves_other_errors_unchanged() {
1681 let err = anyhow::anyhow!("some other failure");
1682 let msg = append_write_scope_hint(err, WriteCapability::Metadata).to_string();
1683 assert_eq!(msg, "some other failure");
1684 }
1685
1686 fn insufficient_permissions_error() -> anyhow::Error {
1687 DriveError::ApiRequestFailed {
1688 api: "Drive",
1689 status: 403,
1690 body: String::new(),
1691 reason: Some("insufficientPermissions".to_string()),
1692 }
1693 .into()
1694 }
1695
1696 fn permission_denied_error() -> anyhow::Error {
1699 DriveError::ApiRequestFailed {
1700 api: "Drive",
1701 status: 403,
1702 body: String::new(),
1703 reason: Some("PERMISSION_DENIED".to_string()),
1704 }
1705 .into()
1706 }
1707
1708 #[test]
1709 fn append_write_scope_hint_also_matches_the_google_rpc_permission_denied_spelling() {
1710 let msg = append_write_scope_hint(permission_denied_error(), WriteCapability::EditContent)
1715 .to_string();
1716 assert!(msg.contains("--write-file"), "{msg}");
1717 assert!(msg.contains("--write-full"), "{msg}");
1718 }
1719
1720 #[test]
1721 fn append_write_scope_hint_ignores_an_unrelated_reason_code() {
1722 let err: anyhow::Error = DriveError::ApiRequestFailed {
1723 api: "Drive",
1724 status: 404,
1725 body: "gone".to_string(),
1726 reason: Some("notFound".to_string()),
1727 }
1728 .into();
1729 let msg = append_write_scope_hint(err, WriteCapability::EditContent).to_string();
1730 assert!(!msg.contains("--write-file"), "{msg}");
1731 }
1732
1733 #[test]
1734 fn append_write_scope_hint_metadata_names_write_flag() {
1735 let msg =
1736 append_write_scope_hint(insufficient_permissions_error(), WriteCapability::Metadata)
1737 .to_string();
1738 assert!(msg.contains("--write"), "{msg}");
1739 assert!(msg.contains("rename/move/trash"), "{msg}");
1740 }
1741
1742 #[test]
1743 fn append_write_scope_hint_create_or_upload_names_write_file_flag() {
1744 let msg = append_write_scope_hint(
1745 insufficient_permissions_error(),
1746 WriteCapability::CreateOrUpload,
1747 )
1748 .to_string();
1749 assert!(msg.contains("--write-file"), "{msg}");
1750 assert!(msg.contains("--write-full"), "{msg}");
1751 }
1752
1753 #[test]
1754 fn append_write_scope_hint_edit_content_names_both_flags() {
1755 let msg = append_write_scope_hint(
1756 insufficient_permissions_error(),
1757 WriteCapability::EditContent,
1758 )
1759 .to_string();
1760 assert!(msg.contains("--write-file"), "{msg}");
1761 assert!(msg.contains("--write-full"), "{msg}");
1762 }
1763
1764 #[test]
1765 fn append_write_scope_hint_copy_for_backup_names_write_full_only() {
1766 let msg = append_write_scope_hint(
1767 insufficient_permissions_error(),
1768 WriteCapability::CopyForBackup,
1769 )
1770 .to_string();
1771 assert!(msg.contains("--write-full"), "{msg}");
1772 assert!(msg.contains("back up a native document"), "{msg}");
1773 assert!(!msg.contains("--write-file"), "{msg}");
1774 }
1775
1776 #[test]
1779 fn check_download_size_rejects_a_length_over_the_cap() {
1780 let err = check_download_size(Some(MAX_DOWNLOAD_BYTES + 1)).unwrap_err();
1781 assert!(err.to_string().contains("refusing to load"), "{err}");
1782 }
1783
1784 #[test]
1785 fn check_download_size_allows_a_length_at_the_cap() {
1786 assert!(check_download_size(Some(MAX_DOWNLOAD_BYTES)).is_ok());
1787 }
1788
1789 #[test]
1790 fn check_download_size_allows_a_missing_length() {
1791 assert!(check_download_size(None).is_ok());
1792 }
1793
1794 #[test]
1797 fn check_upload_size_rejects_a_length_over_the_cap() {
1798 let err = check_upload_size(MAX_UPLOAD_BYTES + 1).unwrap_err();
1799 assert!(err.to_string().contains("refusing to upload"), "{err}");
1800 }
1801
1802 #[test]
1803 fn check_upload_size_allows_a_length_at_the_cap() {
1804 assert!(check_upload_size(MAX_UPLOAD_BYTES).is_ok());
1805 }
1806
1807 #[test]
1814 fn build_file_upload_url_includes_upload_type_fields_and_supports_all_drives() {
1815 let url = build_file_upload_url("https://www.googleapis.com").unwrap();
1816 assert!(url.path().ends_with("/upload/drive/v3/files"));
1817 assert!(url.as_str().contains("uploadType=multipart"));
1818 assert!(url.as_str().contains("fields="));
1819 assert!(url.as_str().contains("supportsAllDrives=true"));
1820 }
1821
1822 #[test]
1825 fn effective_cap_zero_is_hard_cap() {
1826 assert_eq!(effective_cap(0), HARD_CAP);
1827 }
1828
1829 #[test]
1830 fn effective_cap_clamps_above_hard_cap() {
1831 assert_eq!(effective_cap(HARD_CAP + 1000), HARD_CAP);
1832 }
1833
1834 #[test]
1835 fn effective_cap_passes_through_small_limits() {
1836 assert_eq!(effective_cap(5), 5);
1837 }
1838}