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