1use std::path::PathBuf;
23use std::time::{Duration, Instant};
24
25use serde::Serialize;
26
27use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
28use crate::drive::client::DriveClient;
29use crate::drive::files_api::FilesApi;
30use crate::drive::lease::check::{
31 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
32 LeasedWrite,
33};
34use crate::drive::sheets::a1;
35use crate::drive::sheets::api::{SheetsApi, ValueInputOption};
36use crate::drive::sheets::client::SheetsClient;
37use crate::drive::sheets::grid_range;
38use crate::drive::sheets::target_gate;
39use crate::drive::sheets::types::UpdateValuesResponse;
40use crate::drive::types::SheetTargetRefusal;
41use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
42use crate::request_log::{self, DriveMutationOutcome};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum WriteVerb {
47 Write,
49 Append,
51 Clear,
53}
54
55impl WriteVerb {
56 const fn log_operation(self) -> &'static str {
62 match self {
63 Self::Write => "sheets-write",
64 Self::Append => "sheets-append",
65 Self::Clear => "sheets-clear",
66 }
67 }
68
69 const fn label(self) -> &'static str {
71 match self {
72 Self::Write => "write",
73 Self::Append => "append",
74 Self::Clear => "clear",
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct WriteOptions {
82 pub spreadsheet_id: String,
84 pub verb: WriteVerb,
86 pub range: Option<String>,
88 pub sheet: Option<String>,
90 pub values: Vec<Vec<String>>,
92 pub input: ValueInputOption,
94 pub dry_run: bool,
96 pub lease_token: Option<String>,
101 pub ledger_path: PathBuf,
105}
106
107#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
109#[serde(tag = "status", rename_all = "kebab-case")]
110pub enum WriteResult {
111 WouldWrite {
113 rows: usize,
116 columns: usize,
118 },
119 WouldClear,
123 RefusedNotASpreadsheet {
126 mime_type: String,
128 },
129 RefusedShortcut,
134 RefusedNoVisibleParents,
145 RefusedSheetNotFound {
151 title: String,
153 available: Vec<String>,
155 },
156 RefusedEmptyValues,
159 Blocked {
161 decided_by: Option<DecidingRule>,
164 },
165 RefusedNoLease,
168 RefusedLeaseExpired,
171 RefusedLeaseWrongFile,
173 RefusedLeaseStale,
176 Written {
180 #[serde(skip_serializing_if = "Option::is_none")]
182 updated_range: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 updated_rows: Option<i64>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 updated_columns: Option<i64>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 updated_cells: Option<i64>,
192 },
193 Appended {
197 #[serde(skip_serializing_if = "Option::is_none")]
199 updated_range: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 updated_rows: Option<i64>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 updated_columns: Option<i64>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 updated_cells: Option<i64>,
209 },
210 Cleared {
213 #[serde(skip_serializing_if = "Option::is_none")]
215 updated_range: Option<String>,
216 },
217 Failed {
219 detail: String,
221 },
222}
223
224impl FromLeaseRefusal for WriteResult {
225 fn from_no_lease() -> Self {
226 Self::RefusedNoLease
227 }
228 fn from_lease_expired() -> Self {
229 Self::RefusedLeaseExpired
230 }
231 fn from_lease_wrong_file() -> Self {
232 Self::RefusedLeaseWrongFile
233 }
234 fn from_lease_stale() -> Self {
235 Self::RefusedLeaseStale
236 }
237 fn from_lease_failed(detail: String) -> Self {
238 Self::Failed { detail }
239 }
240}
241
242impl WriteResult {
243 fn log_status(&self) -> &'static str {
249 match self {
250 Self::WouldWrite { .. } => "would-write",
251 Self::WouldClear => "would-clear",
252 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
253 Self::RefusedShortcut => "refused-shortcut",
254 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
255 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
256 Self::RefusedEmptyValues => "refused-empty-values",
257 Self::Blocked { .. } => "blocked",
258 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
259 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
260 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
261 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
262 Self::Written { .. } => "written",
263 Self::Appended { .. } => "appended",
264 Self::Cleared { .. } => "cleared",
265 Self::Failed { .. } => "failed",
266 }
267 }
268}
269
270#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
272pub struct WriteOutcome {
273 pub spreadsheet_id: String,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub file_name: Option<String>,
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub range: Option<String>,
281 #[serde(skip_serializing_if = "Option::is_none")]
284 pub resolved_folder_id: Option<String>,
285 #[serde(skip)]
292 pub verb: WriteVerb,
293 pub result: WriteResult,
295}
296
297impl JsonlSerialize for WriteOutcome {
298 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
299 write_scalar_jsonl(self, out)
300 }
301}
302
303pub async fn write(
309 drive: &DriveClient,
310 sheets: &SheetsClient,
311 opts: &WriteOptions,
312 rules: &[FolderPermissionRule],
313) -> WriteOutcome {
314 let started = Instant::now();
315 let outcome = write_inner(drive, sheets, opts, rules).await;
316 if !opts.dry_run {
319 record_attempt(&outcome, opts, started.elapsed());
320 }
321 outcome
322}
323
324async fn write_inner(
325 drive: &DriveClient,
326 sheets: &SheetsClient,
327 opts: &WriteOptions,
328 rules: &[FolderPermissionRule],
329) -> WriteOutcome {
330 let bare = |result| WriteOutcome {
331 spreadsheet_id: opts.spreadsheet_id.clone(),
332 file_name: None,
333 range: None,
334 resolved_folder_id: None,
335 verb: opts.verb,
336 result,
337 };
338
339 if matches!(opts.verb, WriteVerb::Write | WriteVerb::Append) && opts.values.is_empty() {
345 return bare(WriteResult::RefusedEmptyValues);
346 }
347
348 let range = match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
351 Ok(range) => range,
352 Err(err) => {
353 return bare(WriteResult::Failed {
354 detail: err.to_string(),
355 })
356 }
357 };
358
359 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
365 drive,
366 &opts.spreadsheet_id,
367 DriveOperation::SheetsWrite,
368 rules,
369 )
370 .await
371 {
372 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
373 return bare(WriteResult::Failed { detail })
374 }
375 target_gate::TargetGateOutcome::Refused { target, refusal } => {
376 let result = match refusal {
377 SheetTargetRefusal::Shortcut => WriteResult::RefusedShortcut,
378 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
379 WriteResult::RefusedNotASpreadsheet { mime_type }
380 }
381 SheetTargetRefusal::NoVisibleParents => WriteResult::RefusedNoVisibleParents,
382 };
383 return WriteOutcome {
384 spreadsheet_id: opts.spreadsheet_id.clone(),
385 file_name: Some(target.name),
386 range: Some(range),
387 resolved_folder_id: None,
388 verb: opts.verb,
389 result,
390 };
391 }
392 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
395 return WriteOutcome {
396 spreadsheet_id: opts.spreadsheet_id.clone(),
397 file_name: Some(target.name),
398 range: Some(range),
399 resolved_folder_id: None,
400 verb: opts.verb,
401 result: WriteResult::Failed { detail },
402 };
403 }
404 target_gate::TargetGateOutcome::Gated {
405 target,
406 decision,
407 resolved_folder_id,
408 requires_lease,
409 } => (target, decision, resolved_folder_id, requires_lease),
410 };
411
412 let gated = |result| WriteOutcome {
413 spreadsheet_id: opts.spreadsheet_id.clone(),
414 file_name: Some(target.name.clone()),
415 range: Some(range.clone()),
416 resolved_folder_id: resolved_folder_id.clone(),
417 verb: opts.verb,
418 result,
419 };
420
421 if decision.verdict == write_gate::Verdict::Deny {
422 return gated(WriteResult::Blocked {
423 decided_by: decision.decided_by,
424 });
425 }
426
427 if let Some(title) = opts.sheet.clone().or_else(|| a1::sheet_title_of(&range)) {
437 let api = SheetsApi::new(sheets);
438 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
439 Ok(workbook) => workbook,
440 Err(err) => {
441 return gated(WriteResult::Failed {
442 detail: format!("{err:#}"),
443 })
444 }
445 };
446 if let Err(result) = grid_range::find_sheet_id(&workbook, &title, |title, available| {
447 WriteResult::RefusedSheetNotFound { title, available }
448 }) {
449 return gated(result);
450 }
451 }
452
453 if opts.dry_run {
454 return gated(match opts.verb {
455 WriteVerb::Clear => WriteResult::WouldClear,
456 WriteVerb::Write | WriteVerb::Append => WriteResult::WouldWrite {
457 rows: opts.values.len(),
458 columns: opts.values.iter().map(Vec::len).max().unwrap_or(0),
459 },
460 });
461 }
462
463 let files_api = FilesApi::new(drive);
480 let leased = LeasedWrite {
481 log_prefix: "drive sheets write",
482 operation: opts.verb.log_operation(),
483 ledger_path: &opts.ledger_path,
484 file_id: &opts.spreadsheet_id,
485 };
486 let lease_grant = match gate_optional_leased_write(
487 leased,
488 &files_api,
489 requires_lease,
490 opts.lease_token.as_deref(),
491 )
492 .await
493 {
494 Ok(grant) => grant,
495 Err(err) => return gated(err.into_result()),
496 };
497
498 let api = SheetsApi::new(sheets);
500 let result = match opts.verb {
501 WriteVerb::Write => api
502 .values_update(&opts.spreadsheet_id, &range, &opts.values, opts.input)
503 .await
504 .map(into_written),
505 WriteVerb::Append => api
506 .values_append(&opts.spreadsheet_id, &range, &opts.values, opts.input)
507 .await
508 .map(|response| into_appended(response.updates.unwrap_or_default())),
509 WriteVerb::Clear => api
510 .values_clear(&opts.spreadsheet_id, &range)
511 .await
512 .map(|response| WriteResult::Cleared {
513 updated_range: response.cleared_range,
514 }),
515 };
516
517 let result =
518 match conclude_native_leased_write(leased, &lease_grant, &files_api, result, |err| {
519 format!("{err:#}")
520 })
521 .await
522 {
523 Ok(written) => written,
524 Err(err) => WriteResult::Failed {
525 detail: format!("{err:#}"),
526 },
527 };
528 drop(lease_grant);
529
530 gated(result)
531}
532
533fn into_written(response: UpdateValuesResponse) -> WriteResult {
538 WriteResult::Written {
539 updated_range: response.updated_range,
540 updated_rows: response.updated_rows,
541 updated_columns: response.updated_columns,
542 updated_cells: response.updated_cells,
543 }
544}
545
546fn into_appended(response: UpdateValuesResponse) -> WriteResult {
548 WriteResult::Appended {
549 updated_range: response.updated_range,
550 updated_rows: response.updated_rows,
551 updated_columns: response.updated_columns,
552 updated_cells: response.updated_cells,
553 }
554}
555
556fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
562 let error = match &outcome.result {
563 WriteResult::Failed { detail } => Some(detail.clone()),
564 _ => None,
565 };
566 let decided_by = match &outcome.result {
567 WriteResult::Blocked { decided_by } => decided_by.as_ref(),
568 _ => None,
569 };
570 let decided_by = write_gate::decided_by_log_fields(decided_by);
571 let (updated_range, updated_rows, updated_columns, updated_cells) = match &outcome.result {
572 WriteResult::Written {
573 updated_range,
574 updated_rows,
575 updated_columns,
576 updated_cells,
577 }
578 | WriteResult::Appended {
579 updated_range,
580 updated_rows,
581 updated_columns,
582 updated_cells,
583 } => (
584 updated_range.clone(),
585 *updated_rows,
586 *updated_columns,
587 *updated_cells,
588 ),
589 WriteResult::Cleared { updated_range } => (updated_range.clone(), None, None, None),
590 _ => (None, None, None, None),
591 };
592
593 request_log::record_drive_mutation(DriveMutationOutcome {
594 operation: opts.verb.log_operation(),
595 file_id: outcome.spreadsheet_id.clone(),
596 file_name: outcome.file_name.clone().unwrap_or_default(),
597 status: outcome.result.log_status().to_string(),
598 resolved_folder_id: outcome.resolved_folder_id.clone(),
599 decided_by_folder_id: decided_by.folder_id,
600 decided_by_depth: decided_by.depth,
601 decided_by_file_id: decided_by.file_id,
602 range: outcome.range.clone(),
603 updated_range,
604 updated_rows,
605 updated_columns,
606 updated_cells,
607 error,
608 duration,
609 ..Default::default()
610 });
611}
612
613#[must_use]
618pub fn describe(outcome: &WriteOutcome) -> String {
619 let verb = outcome.verb;
620 let name = outcome
621 .file_name
622 .as_deref()
623 .unwrap_or(&outcome.spreadsheet_id);
624 let range = outcome.range.as_deref().unwrap_or("(unresolved range)");
625 match &outcome.result {
626 WriteResult::WouldClear => format!("Would clear: {range} of '{name}'"),
627 WriteResult::WouldWrite { rows, columns } => format!(
628 "Would {}: {rows} row(s) x {columns} column(s) into {range} of '{name}'",
629 verb.label()
630 ),
631 WriteResult::RefusedNotASpreadsheet { mime_type } => format!(
632 "Refused: '{name}' is not a Google Sheet (mimeType: {mime_type}); \
633 `drive sheets {}` only works on spreadsheets",
634 verb.label()
635 ),
636 WriteResult::RefusedShortcut => format!(
637 "Refused: '{name}' is a shortcut; `drive sheets {}` doesn't follow shortcuts — \
638 resolve the target spreadsheet's id and use that instead",
639 verb.label()
640 ),
641 WriteResult::RefusedNoVisibleParents => format!(
642 "Refused: '{name}' has no parent folder visible to this account, so no folder \
643 rule can apply to it. This is normal for a Sheet shared by link or email. \
644 Grant it by id instead: add {{\"file_id\": \"<spreadsheet id>\", \"allow\": \
645 [\"sheets-write\"]}} to write_permissions.rules. (Adding it to a folder in your \
646 own Drive and granting that folder `sheets-write` also works.)"
647 ),
648 WriteResult::RefusedSheetNotFound { title, available } => {
649 grid_range::no_sheet_refusal(&format!("'{name}'"), title, available)
650 }
651 WriteResult::RefusedEmptyValues => format!(
652 "Refused: `drive sheets {}` was given no values to write (--values is empty)",
653 verb.label()
654 ),
655 WriteResult::Blocked { decided_by } => match decided_by {
656 Some(rule) => format!(
657 "Blocked: {range} of '{name}' — refused by rule on {} {}{}",
658 rule.kind_label(),
659 rule.id(),
660 rule.depth_suffix()
661 ),
662 None => format!(
663 "Blocked: {range} of '{name}' — refused by default policy (no matching rule)"
664 ),
665 },
666 WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease
667 .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
668 .unwrap_or_default(),
669 WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
670 .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
671 .unwrap_or_default(),
672 WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
673 .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
674 .unwrap_or_default(),
675 WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
676 .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
677 .unwrap_or_default(),
678 WriteResult::Cleared { updated_range } => format!(
684 "Cleared {} of '{name}'",
685 updated_range.as_deref().unwrap_or(range)
686 ),
687 WriteResult::Appended {
688 updated_range,
689 updated_cells,
690 ..
691 } => {
692 let where_ = updated_range.as_deref().unwrap_or(range);
693 match updated_cells {
694 Some(cells) => format!("Appended {cells} cell(s) to {where_} of '{name}'"),
695 None => format!("Appended to {where_} of '{name}'"),
696 }
697 }
698 WriteResult::Written {
699 updated_range,
700 updated_cells,
701 ..
702 } => {
703 let where_ = updated_range.as_deref().unwrap_or(range);
704 match updated_cells {
705 Some(cells) => format!("Wrote {cells} cell(s) to {where_} of '{name}'"),
706 None => format!("Wrote to {where_} of '{name}'"),
707 }
708 }
709 WriteResult::Failed { detail } => {
710 format!("Failed: {range} of '{name}': {detail}")
711 }
712 }
713}
714
715#[cfg(test)]
716#[allow(clippy::unwrap_used, clippy::expect_used)]
717mod tests {
718 use super::*;
719 use crate::drive::test_support::seed_lease;
720
721 #[test]
735 fn every_describe_arm_renders_a_single_line() {
736 for verb in [WriteVerb::Write, WriteVerb::Append, WriteVerb::Clear] {
737 for result in every_write_result() {
738 let outcome = WriteOutcome {
739 spreadsheet_id: "sheet-1".to_string(),
740 file_name: Some("Quarterly Plan".to_string()),
741 range: Some("Sheet1!A1:B2".to_string()),
742 resolved_folder_id: None,
743 verb,
744 result,
745 };
746 let rendered = describe(&outcome);
747 assert_eq!(
748 rendered.lines().count(),
749 1,
750 "describe emitted {} lines for {:?}/{verb:?}: {rendered:?}",
751 rendered.lines().count(),
752 outcome.result
753 );
754 assert!(
755 !rendered.chars().any(char::is_control),
756 "describe emitted a control character for {:?}/{verb:?}: {rendered:?}",
757 outcome.result
758 );
759 }
760 }
761 }
762
763 fn every_write_result() -> Vec<WriteResult> {
769 let all = vec![
770 WriteResult::WouldWrite {
771 rows: 2,
772 columns: 3,
773 },
774 WriteResult::WouldClear,
775 WriteResult::RefusedNotASpreadsheet {
776 mime_type: "application/pdf".to_string(),
777 },
778 WriteResult::RefusedShortcut,
779 WriteResult::RefusedNoVisibleParents,
780 WriteResult::RefusedSheetNotFound {
781 title: "Nope".to_string(),
782 available: vec!["Sheet1".to_string()],
783 },
784 WriteResult::RefusedEmptyValues,
785 WriteResult::Blocked { decided_by: None },
786 WriteResult::Blocked {
787 decided_by: Some(DecidingRule::Folder {
788 folder_id: "folder-1".to_string(),
789 depth: 2,
790 }),
791 },
792 WriteResult::Blocked {
793 decided_by: Some(DecidingRule::File {
794 file_id: "sheet-1".to_string(),
795 }),
796 },
797 WriteResult::RefusedNoLease,
798 WriteResult::RefusedLeaseExpired,
799 WriteResult::RefusedLeaseWrongFile,
800 WriteResult::RefusedLeaseStale,
801 WriteResult::Written {
802 updated_range: Some("Sheet1!A1:B2".to_string()),
803 updated_rows: Some(2),
804 updated_columns: Some(2),
805 updated_cells: Some(4),
806 },
807 WriteResult::Written {
808 updated_range: None,
809 updated_rows: None,
810 updated_columns: None,
811 updated_cells: None,
812 },
813 WriteResult::Appended {
814 updated_range: Some("Sheet1!A3:B3".to_string()),
815 updated_rows: Some(1),
816 updated_columns: Some(2),
817 updated_cells: Some(2),
818 },
819 WriteResult::Cleared {
820 updated_range: Some("Sheet1!A1:B2".to_string()),
821 },
822 WriteResult::Failed {
823 detail: "the API said no".to_string(),
824 },
825 ];
826 for result in &all {
827 match result {
828 WriteResult::WouldWrite { .. }
829 | WriteResult::WouldClear
830 | WriteResult::RefusedNotASpreadsheet { .. }
831 | WriteResult::RefusedShortcut
832 | WriteResult::RefusedNoVisibleParents
833 | WriteResult::RefusedSheetNotFound { .. }
834 | WriteResult::RefusedEmptyValues
835 | WriteResult::Blocked { .. }
836 | WriteResult::RefusedNoLease
837 | WriteResult::RefusedLeaseExpired
838 | WriteResult::RefusedLeaseWrongFile
839 | WriteResult::RefusedLeaseStale
840 | WriteResult::Written { .. }
841 | WriteResult::Appended { .. }
842 | WriteResult::Cleared { .. }
843 | WriteResult::Failed { .. } => (),
844 }
845 }
846 all
847 }
848
849 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
850 use crate::drive::sheets::client::SHEETS_API_URL;
851 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
852 use crate::drive::write_gate::Verdict;
853 use crate::test_support::env::MapEnv;
854 use crate::utils::secret::Secret;
855 use std::collections::HashSet;
856
857 fn test_credentials() -> DriveCredentials {
858 DriveCredentials {
859 client_id: "client-1".to_string(),
860 client_secret: Secret::new("secret-1"),
861 refresh_token: Secret::new("refresh-1"),
862 scope: DriveGrantedScopes::READONLY,
863 }
864 }
865
866 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
872 wiremock::Mock::given(wiremock::matchers::method("POST"))
873 .and(wiremock::matchers::path("/token"))
874 .respond_with(
875 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
876 "access_token": "test-token", "expires_in": 3600,
877 })),
878 )
879 .mount(server)
880 .await;
881 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
882 crate::drive::client::test_support::replace_session(
883 &mut drive,
884 &test_credentials(),
885 &format!("{}/token", server.uri()),
886 );
887 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
888 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
889 (drive, sheets)
890 }
891
892 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
896 let parents: Vec<&str> = parents.to_vec();
897 wiremock::Mock::given(wiremock::matchers::method("GET"))
898 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
899 .respond_with(
900 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
901 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
902 "version": "1",
903 })),
904 )
905 }
906
907 fn mount_folder(id: &str) -> wiremock::Mock {
908 mount_file(id, "application/vnd.google-apps.folder", &[])
909 }
910
911 fn allow_rule(folder: &str) -> FolderPermissionRule {
912 FolderPermissionRule {
913 folder_id: Some(folder.to_string()),
914 file_id: None,
915 recursive: true,
916 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
917 deny: HashSet::default(),
918 require_lease: true,
919 }
920 }
921
922 fn opts(verb: WriteVerb, dry_run: bool) -> WriteOptions {
930 let ledger_path = tempfile::tempdir()
931 .unwrap()
932 .keep()
933 .join("lease-ledger.jsonl");
934 let token = seed_lease(&ledger_path, "sheet-1", "1");
935 WriteOptions {
936 spreadsheet_id: "sheet-1".to_string(),
937 verb,
938 range: Some("A1:B2".to_string()),
939 sheet: None,
940 values: vec![vec!["a".to_string(), "b".to_string()]],
941 input: ValueInputOption::UserEntered,
942 dry_run,
943 lease_token: Some(token),
944 ledger_path,
945 }
946 }
947
948 #[tokio::test]
951 async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
952 let server = wiremock::MockServer::start().await;
953 let (drive, sheets) = clients(&server).await;
954 mount_file("sheet-1", "application/pdf", &["parent-1"])
955 .mount(&server)
956 .await;
957 let outcome = write(
963 &drive,
964 &sheets,
965 &opts(WriteVerb::Append, false),
966 &[allow_rule("parent-1")],
967 )
968 .await;
969 assert!(matches!(
970 outcome.result,
971 WriteResult::RefusedNotASpreadsheet { .. }
972 ));
973 let text = describe(&outcome);
974 assert!(text.contains("is not a Google Sheet"), "{text}");
975 assert!(text.contains("drive sheets append"), "{text}");
976 }
977
978 #[tokio::test]
979 async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
980 let server = wiremock::MockServer::start().await;
981 let (drive, sheets) = clients(&server).await;
982 mount_file(
983 "sheet-1",
984 "application/vnd.google-apps.shortcut",
985 &["parent-1"],
986 )
987 .mount(&server)
988 .await;
989 let outcome = write(
990 &drive,
991 &sheets,
992 &opts(WriteVerb::Write, false),
993 &[allow_rule("parent-1")],
994 )
995 .await;
996 assert!(matches!(outcome.result, WriteResult::RefusedShortcut));
997 let text = describe(&outcome);
998 assert!(text.contains("is a shortcut"), "{text}");
999 assert!(!text.contains("is not a Google Sheet"), "{text}");
1000 }
1001
1002 #[tokio::test]
1003 async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
1004 let server = wiremock::MockServer::start().await;
1005 let (drive, sheets) = clients(&server).await;
1006 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
1008 .mount(&server)
1009 .await;
1010 let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1011 assert!(matches!(
1012 outcome.result,
1013 WriteResult::RefusedNoVisibleParents
1014 ));
1015 let text = describe(&outcome);
1018 assert!(text.contains("no parent folder visible"), "{text}");
1019 assert!(text.contains("file_id"), "{text}");
1020 assert!(text.contains("Adding it to a folder"), "{text}");
1021 }
1022
1023 #[tokio::test]
1026 async fn denied_target_makes_zero_sheets_calls() {
1027 let server = wiremock::MockServer::start().await;
1028 let (drive, sheets) = clients(&server).await;
1029 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1030 .mount(&server)
1031 .await;
1032 mount_folder("parent-1").mount(&server).await;
1033 let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1035 assert!(
1036 matches!(outcome.result, WriteResult::Blocked { decided_by: None }),
1037 "{:?}",
1038 outcome.result
1039 );
1040 let text = describe(&outcome);
1041 assert!(
1042 text.contains("refused by default policy (no matching rule)"),
1043 "{text}"
1044 );
1045 }
1046
1047 #[tokio::test]
1048 async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
1049 let server = wiremock::MockServer::start().await;
1050 let (drive, sheets) = clients(&server).await;
1051 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1052 .mount(&server)
1053 .await;
1054 mount_folder("parent-1").mount(&server).await;
1055 let deny_rule = FolderPermissionRule {
1056 folder_id: Some("parent-1".to_string()),
1057 file_id: None,
1058 recursive: true,
1059 allow: HashSet::default(),
1060 deny: std::iter::once(DriveOperation::SheetsWrite).collect(),
1061 require_lease: true,
1062 };
1063 let outcome = write(
1064 &drive,
1065 &sheets,
1066 &opts(WriteVerb::Write, false),
1067 &[deny_rule],
1068 )
1069 .await;
1070 let text = describe(&outcome);
1071 assert!(
1072 text.contains("refused by rule on folder parent-1"),
1073 "{text}"
1074 );
1075 }
1076
1077 #[tokio::test]
1078 async fn an_edit_rule_alone_does_not_permit_a_cell_write() {
1079 let server = wiremock::MockServer::start().await;
1082 let (drive, sheets) = clients(&server).await;
1083 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1084 .mount(&server)
1085 .await;
1086 mount_folder("parent-1").mount(&server).await;
1087 let edit_only = FolderPermissionRule {
1088 folder_id: Some("parent-1".to_string()),
1089 file_id: None,
1090 recursive: true,
1091 allow: std::iter::once(DriveOperation::Edit).collect(),
1092 deny: HashSet::default(),
1093 require_lease: true,
1094 };
1095 let outcome = write(
1096 &drive,
1097 &sheets,
1098 &opts(WriteVerb::Write, false),
1099 &[edit_only],
1100 )
1101 .await;
1102 assert!(matches!(outcome.result, WriteResult::Blocked { .. }));
1103 }
1104
1105 #[tokio::test]
1106 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
1107 let server = wiremock::MockServer::start().await;
1110 let (drive, sheets) = clients(&server).await;
1111 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1112 .mount(&server)
1113 .await;
1114 wiremock::Mock::given(wiremock::matchers::method("GET"))
1115 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1116 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1117 .mount(&server)
1118 .await;
1119 let outcome = write(
1120 &drive,
1121 &sheets,
1122 &opts(WriteVerb::Write, false),
1123 &[allow_rule("parent-1")],
1124 )
1125 .await;
1126 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1127 }
1128
1129 #[tokio::test]
1132 async fn dry_run_reports_dimensions_and_calls_no_values_endpoint() {
1133 let server = wiremock::MockServer::start().await;
1134 let (drive, sheets) = clients(&server).await;
1135 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1136 .mount(&server)
1137 .await;
1138 mount_folder("parent-1").mount(&server).await;
1139 let mut o = opts(WriteVerb::Write, true);
1141 o.values = vec![
1142 vec!["a".to_string(), "b".to_string(), "c".to_string()],
1143 vec!["d".to_string()],
1144 ];
1145 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1146 assert_eq!(
1147 outcome.result,
1148 WriteResult::WouldWrite {
1149 rows: 2,
1150 columns: 3
1151 }
1152 );
1153 let text = describe(&outcome);
1154 assert!(text.contains("2 row(s) x 3 column(s)"), "{text}");
1155 }
1156
1157 #[tokio::test]
1158 async fn write_refuses_an_unknown_sheet_identically_in_dry_run_and_a_real_run() {
1159 for dry_run in [true, false] {
1164 let server = wiremock::MockServer::start().await;
1165 let (drive, sheets) = clients(&server).await;
1166 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1167 .mount(&server)
1168 .await;
1169 mount_folder("parent-1").mount(&server).await;
1170 wiremock::Mock::given(wiremock::matchers::method("GET"))
1171 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1172 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1173 serde_json::json!({
1174 "spreadsheetId": "sheet-1",
1175 "sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}]
1176 }),
1177 ))
1178 .mount(&server)
1179 .await;
1180 let mut o = opts(WriteVerb::Write, dry_run);
1184 o.sheet = Some("Nope".to_string());
1185 o.range = None;
1186 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1187 assert_eq!(
1188 outcome.result,
1189 WriteResult::RefusedSheetNotFound {
1190 title: "Nope".to_string(),
1191 available: vec!["Sheet1".to_string()],
1192 },
1193 "dry_run={dry_run}"
1194 );
1195 let text = describe(&outcome);
1196 assert!(text.contains("no sheet titled 'Nope'"), "{text}");
1197 assert!(text.contains("Available: 'Sheet1'"), "{text}");
1198 }
1199 }
1200
1201 #[tokio::test]
1202 async fn sheet_title_check_metadata_fetch_failure_produces_failed() {
1203 let server = wiremock::MockServer::start().await;
1207 let (drive, sheets) = clients(&server).await;
1208 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1209 .mount(&server)
1210 .await;
1211 mount_folder("parent-1").mount(&server).await;
1212 wiremock::Mock::given(wiremock::matchers::method("GET"))
1213 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1214 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1215 .mount(&server)
1216 .await;
1217 let mut o = opts(WriteVerb::Write, false);
1218 o.sheet = Some("Sheet1".to_string());
1219 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1220 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1221 }
1222
1223 #[tokio::test]
1224 async fn write_and_append_refuse_empty_values_before_any_request() {
1225 let server = wiremock::MockServer::start().await;
1226 let (drive, sheets) = clients(&server).await;
1227 for verb in [WriteVerb::Write, WriteVerb::Append] {
1230 let mut o = opts(verb, true);
1231 o.values = vec![];
1232 let outcome = write(&drive, &sheets, &o, &[]).await;
1233 assert_eq!(outcome.result, WriteResult::RefusedEmptyValues, "{verb:?}");
1234 let text = describe(&outcome);
1235 assert!(text.contains("no values to write"), "{text}");
1236 }
1237 }
1238
1239 #[tokio::test]
1240 async fn clear_is_unaffected_by_empty_values() {
1241 let server = wiremock::MockServer::start().await;
1242 let (drive, sheets) = clients(&server).await;
1243 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1244 .mount(&server)
1245 .await;
1246 mount_folder("parent-1").mount(&server).await;
1247 let mut o = opts(WriteVerb::Clear, true);
1248 o.values = vec![];
1249 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1250 assert_eq!(outcome.result, WriteResult::WouldClear);
1251 }
1252
1253 #[tokio::test]
1254 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
1255 let server = wiremock::MockServer::start().await;
1256 let (drive, sheets) = clients(&server).await;
1257 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1258 .expect(2)
1259 .mount(&server)
1260 .await;
1261 mount_folder("parent-1").expect(2).mount(&server).await;
1262
1263 let dry = write(&drive, &sheets, &opts(WriteVerb::Write, true), &[]).await;
1264 let real = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1265 assert_eq!(dry.result, real.result);
1266 }
1267
1268 #[tokio::test]
1271 async fn allowed_write_calls_values_update_once_with_the_input_option() {
1272 let server = wiremock::MockServer::start().await;
1273 let (drive, sheets) = clients(&server).await;
1274 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1275 .mount(&server)
1276 .await;
1277 mount_folder("parent-1").mount(&server).await;
1278 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1279 .and(wiremock::matchers::path(
1280 "/v4/spreadsheets/sheet-1/values/A1:B2",
1281 ))
1282 .and(wiremock::matchers::query_param(
1283 "valueInputOption",
1284 "USER_ENTERED",
1285 ))
1286 .respond_with(
1287 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1288 "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
1289 })),
1290 )
1291 .expect(1)
1292 .mount(&server)
1293 .await;
1294
1295 let outcome = write(
1296 &drive,
1297 &sheets,
1298 &opts(WriteVerb::Write, false),
1299 &[allow_rule("parent-1")],
1300 )
1301 .await;
1302 assert_eq!(
1303 outcome.result,
1304 WriteResult::Written {
1305 updated_range: Some("'Q1'!A1:B2".to_string()),
1306 updated_rows: Some(1),
1307 updated_columns: None,
1308 updated_cells: Some(2),
1309 }
1310 );
1311 assert!(describe(&outcome).starts_with("Wrote 2 cell(s) "));
1312 }
1313
1314 #[tokio::test]
1315 async fn raw_input_option_is_sent_when_requested() {
1316 let server = wiremock::MockServer::start().await;
1317 let (drive, sheets) = clients(&server).await;
1318 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1319 .mount(&server)
1320 .await;
1321 mount_folder("parent-1").mount(&server).await;
1322 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1323 .and(wiremock::matchers::query_param("valueInputOption", "RAW"))
1324 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1325 .expect(1)
1326 .mount(&server)
1327 .await;
1328
1329 let mut o = opts(WriteVerb::Write, false);
1330 o.input = ValueInputOption::Raw;
1331 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1332 assert!(matches!(outcome.result, WriteResult::Written { .. }));
1333 }
1334
1335 fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
1338 FolderPermissionRule {
1339 require_lease: false,
1340 ..allow_rule(folder)
1341 }
1342 }
1343
1344 #[tokio::test]
1345 async fn refuses_without_a_lease_when_the_rule_requires_one() {
1346 let server = wiremock::MockServer::start().await;
1347 let (drive, sheets) = clients(&server).await;
1348 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1349 .mount(&server)
1350 .await;
1351 mount_folder("parent-1").mount(&server).await;
1352 let mut o = opts(WriteVerb::Write, false);
1355 o.lease_token = None;
1356 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1357 assert_eq!(outcome.result, WriteResult::RefusedNoLease);
1358 }
1359
1360 #[tokio::test]
1361 async fn refuses_an_unknown_lease_token() {
1362 let server = wiremock::MockServer::start().await;
1363 let (drive, sheets) = clients(&server).await;
1364 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1365 .mount(&server)
1366 .await;
1367 mount_folder("parent-1").mount(&server).await;
1368
1369 let mut o = opts(WriteVerb::Write, false);
1370 o.lease_token = Some("bogus-token".to_string());
1371 o.ledger_path = tempfile::tempdir()
1373 .unwrap()
1374 .keep()
1375 .join("lease-ledger.jsonl");
1376 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1377 assert_eq!(outcome.result, WriteResult::RefusedLeaseExpired);
1378 }
1379
1380 #[tokio::test]
1381 async fn refuses_a_lease_bound_to_a_different_file() {
1382 let server = wiremock::MockServer::start().await;
1383 let (drive, sheets) = clients(&server).await;
1384 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1385 .mount(&server)
1386 .await;
1387 mount_folder("parent-1").mount(&server).await;
1388
1389 let ledger_path = tempfile::tempdir()
1390 .unwrap()
1391 .keep()
1392 .join("lease-ledger.jsonl");
1393 let token = seed_lease(&ledger_path, "other-sheet", "1");
1394 let mut o = opts(WriteVerb::Write, false);
1395 o.lease_token = Some(token);
1396 o.ledger_path = ledger_path;
1397 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1398 assert_eq!(outcome.result, WriteResult::RefusedLeaseWrongFile);
1399 }
1400
1401 #[tokio::test]
1402 async fn refuses_a_stale_lease() {
1403 let server = wiremock::MockServer::start().await;
1404 let (drive, sheets) = clients(&server).await;
1405 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1408 .mount(&server)
1409 .await;
1410 mount_folder("parent-1").mount(&server).await;
1411
1412 let ledger_path = tempfile::tempdir()
1413 .unwrap()
1414 .keep()
1415 .join("lease-ledger.jsonl");
1416 let token = seed_lease(&ledger_path, "sheet-1", "0");
1417 let mut o = opts(WriteVerb::Write, false);
1418 o.lease_token = Some(token);
1419 o.ledger_path = ledger_path;
1420 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1421 assert_eq!(outcome.result, WriteResult::RefusedLeaseStale);
1422 }
1423
1424 #[tokio::test]
1425 async fn reports_a_lock_acquisition_failure_as_failed() {
1426 let server = wiremock::MockServer::start().await;
1430 let (drive, sheets) = clients(&server).await;
1431 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1432 .mount(&server)
1433 .await;
1434 mount_folder("parent-1").mount(&server).await;
1435
1436 let o = opts(WriteVerb::Write, false);
1437 let mut lock_path = o.ledger_path.clone().into_os_string();
1443 lock_path.push(".lock");
1444 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
1445
1446 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1447 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1448 }
1449
1450 #[tokio::test]
1451 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_values_call() {
1452 let server = wiremock::MockServer::start().await;
1457 let (drive, sheets) = clients(&server).await;
1458 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1459 .up_to_n_times(1)
1460 .with_priority(1)
1461 .mount(&server)
1462 .await;
1463 wiremock::Mock::given(wiremock::matchers::method("GET"))
1464 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1465 .respond_with(wiremock::ResponseTemplate::new(500))
1466 .with_priority(2)
1467 .mount(&server)
1468 .await;
1469 mount_folder("parent-1").mount(&server).await;
1470 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1471 .respond_with(wiremock::ResponseTemplate::new(200))
1472 .expect(0)
1473 .mount(&server)
1474 .await;
1475
1476 let outcome = write(
1477 &drive,
1478 &sheets,
1479 &opts(WriteVerb::Write, false),
1480 &[allow_rule("parent-1")],
1481 )
1482 .await;
1483 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1484 }
1485
1486 #[tokio::test]
1487 async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
1488 let server = wiremock::MockServer::start().await;
1492 let (drive, sheets) = clients(&server).await;
1493 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1494 .mount(&server)
1495 .await;
1496 mount_folder("parent-1").mount(&server).await;
1497 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1498 .respond_with(
1499 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1500 "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
1501 })),
1502 )
1503 .expect(1)
1504 .mount(&server)
1505 .await;
1506
1507 let mut o = opts(WriteVerb::Write, false);
1508 o.lease_token = None;
1509 let outcome = write(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
1510 assert!(matches!(outcome.result, WriteResult::Written { .. }));
1511 }
1512
1513 #[tokio::test]
1514 async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
1515 let server = wiremock::MockServer::start().await;
1519 let (drive, sheets) = clients(&server).await;
1520 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1523 .mount(&server)
1524 .await;
1525 mount_folder("parent-1").mount(&server).await;
1526 let ledger_path = tempfile::tempdir()
1529 .unwrap()
1530 .keep()
1531 .join("lease-ledger.jsonl");
1532 let token = seed_lease(&ledger_path, "sheet-1", "0");
1533 let mut o = opts(WriteVerb::Write, false);
1534 o.lease_token = Some(token);
1535 o.ledger_path = ledger_path;
1536 let outcome = write(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
1537 assert_eq!(outcome.result, WriteResult::RefusedLeaseStale);
1538 }
1539
1540 #[tokio::test]
1543 async fn a_write_whose_response_omits_the_counts_is_not_described_as_a_clear() {
1544 let server = wiremock::MockServer::start().await;
1548 let (drive, sheets) = clients(&server).await;
1549 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1550 .mount(&server)
1551 .await;
1552 mount_folder("parent-1").mount(&server).await;
1553 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1554 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1555 .mount(&server)
1556 .await;
1557
1558 let outcome = write(
1559 &drive,
1560 &sheets,
1561 &opts(WriteVerb::Write, false),
1562 &[allow_rule("parent-1")],
1563 )
1564 .await;
1565 let text = describe(&outcome);
1566 assert!(!text.contains("Cleared"), "{text}");
1567 assert!(text.starts_with("Wrote to "), "{text}");
1568 }
1569
1570 #[tokio::test]
1571 async fn a_clear_dry_run_omits_the_always_zero_dimensions() {
1572 let server = wiremock::MockServer::start().await;
1573 let (drive, sheets) = clients(&server).await;
1574 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1575 .mount(&server)
1576 .await;
1577 mount_folder("parent-1").mount(&server).await;
1578
1579 let outcome = write(
1580 &drive,
1581 &sheets,
1582 &opts(WriteVerb::Clear, true),
1583 &[allow_rule("parent-1")],
1584 )
1585 .await;
1586 let text = describe(&outcome);
1587 assert!(!text.contains("row(s)"), "{text}");
1588 assert_eq!(text, "Would clear: A1:B2 of 'sheet-1'");
1589 }
1590
1591 #[tokio::test]
1592 async fn append_reads_its_counts_from_the_nested_updates_object() {
1593 let server = wiremock::MockServer::start().await;
1594 let (drive, sheets) = clients(&server).await;
1595 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1596 .mount(&server)
1597 .await;
1598 mount_folder("parent-1").mount(&server).await;
1599 wiremock::Mock::given(wiremock::matchers::method("POST"))
1600 .and(wiremock::matchers::path(
1601 "/v4/spreadsheets/sheet-1/values/A1:B2:append",
1602 ))
1603 .and(wiremock::matchers::query_param(
1604 "insertDataOption",
1605 "INSERT_ROWS",
1606 ))
1607 .respond_with(
1608 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1609 "tableRange": "'Q1'!A1:B3",
1610 "updates": {"updatedRange": "'Q1'!A4:B4", "updatedCells": 2},
1611 })),
1612 )
1613 .expect(1)
1614 .mount(&server)
1615 .await;
1616
1617 let outcome = write(
1618 &drive,
1619 &sheets,
1620 &opts(WriteVerb::Append, false),
1621 &[allow_rule("parent-1")],
1622 )
1623 .await;
1624 assert_eq!(
1625 outcome.result,
1626 WriteResult::Appended {
1627 updated_range: Some("'Q1'!A4:B4".to_string()),
1628 updated_rows: None,
1629 updated_columns: None,
1630 updated_cells: Some(2),
1631 }
1632 );
1633 assert!(describe(&outcome).starts_with("Appended 2 cell(s) "));
1635 }
1636
1637 #[tokio::test]
1638 async fn clear_calls_the_clear_endpoint_and_reports_the_cleared_range() {
1639 let server = wiremock::MockServer::start().await;
1640 let (drive, sheets) = clients(&server).await;
1641 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1642 .mount(&server)
1643 .await;
1644 mount_folder("parent-1").mount(&server).await;
1645 wiremock::Mock::given(wiremock::matchers::method("POST"))
1646 .and(wiremock::matchers::path(
1647 "/v4/spreadsheets/sheet-1/values/A1:B2:clear",
1648 ))
1649 .respond_with(
1650 wiremock::ResponseTemplate::new(200)
1651 .set_body_json(serde_json::json!({"clearedRange": "'Q1'!A1:B2"})),
1652 )
1653 .expect(1)
1654 .mount(&server)
1655 .await;
1656
1657 let outcome = write(
1658 &drive,
1659 &sheets,
1660 &opts(WriteVerb::Clear, false),
1661 &[allow_rule("parent-1")],
1662 )
1663 .await;
1664 assert_eq!(
1665 outcome.result,
1666 WriteResult::Cleared {
1667 updated_range: Some("'Q1'!A1:B2".to_string()),
1668 }
1669 );
1670 assert!(describe(&outcome).starts_with("Cleared "));
1671 }
1672
1673 #[tokio::test]
1674 async fn a_403_surfaces_the_write_scope_hint() {
1675 let server = wiremock::MockServer::start().await;
1676 let (drive, sheets) = clients(&server).await;
1677 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1678 .mount(&server)
1679 .await;
1680 mount_folder("parent-1").mount(&server).await;
1681 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1684 .respond_with(
1685 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1686 "error": {"code": 403, "message": "The caller does not have permission",
1687 "status": "PERMISSION_DENIED"},
1688 })),
1689 )
1690 .mount(&server)
1691 .await;
1692
1693 let outcome = write(
1694 &drive,
1695 &sheets,
1696 &opts(WriteVerb::Write, false),
1697 &[allow_rule("parent-1")],
1698 )
1699 .await;
1700 let WriteResult::Failed { detail } = &outcome.result else {
1701 panic!("expected Failed, got {:?}", outcome.result);
1702 };
1703 assert!(detail.contains("--write-file"), "{detail}");
1704 assert!(detail.contains("--write-full"), "{detail}");
1705 }
1706
1707 #[tokio::test]
1710 async fn a_conflicting_sheet_and_range_fails_before_any_request() {
1711 let server = wiremock::MockServer::start().await;
1712 let (drive, sheets) = clients(&server).await;
1713 let mut o = opts(WriteVerb::Write, false);
1715 o.range = Some("Other!A1".to_string());
1716 o.sheet = Some("Mine".to_string());
1717 let outcome = write(&drive, &sheets, &o, &[]).await;
1718 let WriteResult::Failed { detail } = &outcome.result else {
1719 panic!("expected Failed, got {:?}", outcome.result);
1720 };
1721 assert!(detail.contains("already names a sheet"), "{detail}");
1722 }
1723
1724 #[tokio::test]
1725 async fn a_sheet_title_with_a_space_is_encoded_on_the_wire() {
1726 let server = wiremock::MockServer::start().await;
1727 let (drive, sheets) = clients(&server).await;
1728 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1729 .mount(&server)
1730 .await;
1731 mount_folder("parent-1").mount(&server).await;
1732 wiremock::Mock::given(wiremock::matchers::method("GET"))
1733 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1734 .respond_with(
1735 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1736 "spreadsheetId": "sheet-1",
1737 "sheets": [{"properties": {"sheetId": 0, "title": "My Sheet"}}]
1738 })),
1739 )
1740 .mount(&server)
1741 .await;
1742 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1743 .and(wiremock::matchers::path(
1744 "/v4/spreadsheets/sheet-1/values/'My%20Sheet'!A1:B2",
1745 ))
1746 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1747 .expect(1)
1748 .mount(&server)
1749 .await;
1750
1751 let mut o = opts(WriteVerb::Write, false);
1752 o.sheet = Some("My Sheet".to_string());
1753 let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1754 assert!(matches!(outcome.result, WriteResult::Written { .. }));
1755 }
1756
1757 #[test]
1760 fn log_operations_are_distinct_and_kebab_cased() {
1761 assert_eq!(WriteVerb::Write.log_operation(), "sheets-write");
1762 assert_eq!(WriteVerb::Append.log_operation(), "sheets-append");
1763 assert_eq!(WriteVerb::Clear.log_operation(), "sheets-clear");
1764 }
1765
1766 #[test]
1767 fn labels_are_distinct_present_tense_verbs() {
1768 assert_eq!(WriteVerb::Write.label(), "write");
1769 assert_eq!(WriteVerb::Append.label(), "append");
1770 assert_eq!(WriteVerb::Clear.label(), "clear");
1771 }
1772
1773 #[test]
1777 fn json_statuses_name_the_verb() {
1778 let json = |result: WriteResult| serde_json::to_value(&result).unwrap();
1779 assert_eq!(
1780 json(WriteResult::WouldClear),
1781 serde_json::json!({"status": "would-clear"})
1782 );
1783 assert_eq!(
1784 json(WriteResult::Cleared {
1785 updated_range: Some("Q1!A1:B2".to_string())
1786 }),
1787 serde_json::json!({"status": "cleared", "updated_range": "Q1!A1:B2"})
1788 );
1789 assert_eq!(
1790 json(WriteResult::Appended {
1791 updated_range: None,
1792 updated_rows: Some(1),
1793 updated_columns: Some(2),
1794 updated_cells: Some(2),
1795 })["status"],
1796 "appended"
1797 );
1798 }
1799
1800 #[test]
1801 fn log_status_covers_every_variant() {
1802 assert_eq!(
1803 WriteResult::WouldWrite {
1804 rows: 0,
1805 columns: 0
1806 }
1807 .log_status(),
1808 "would-write"
1809 );
1810 assert_eq!(
1811 WriteResult::RefusedNotASpreadsheet {
1812 mime_type: String::new()
1813 }
1814 .log_status(),
1815 "refused-not-a-spreadsheet"
1816 );
1817 assert_eq!(
1818 WriteResult::RefusedShortcut.log_status(),
1819 "refused-shortcut"
1820 );
1821 assert_eq!(
1822 WriteResult::RefusedNoVisibleParents.log_status(),
1823 "refused-no-visible-parents"
1824 );
1825 assert_eq!(
1826 WriteResult::Blocked { decided_by: None }.log_status(),
1827 "blocked"
1828 );
1829 assert_eq!(
1830 WriteResult::Written {
1831 updated_range: None,
1832 updated_rows: None,
1833 updated_columns: None,
1834 updated_cells: None,
1835 }
1836 .log_status(),
1837 "written"
1838 );
1839 assert_eq!(WriteResult::WouldClear.log_status(), "would-clear");
1840 assert_eq!(
1841 WriteResult::Appended {
1842 updated_range: None,
1843 updated_rows: None,
1844 updated_columns: None,
1845 updated_cells: None,
1846 }
1847 .log_status(),
1848 "appended"
1849 );
1850 assert_eq!(
1851 WriteResult::Cleared {
1852 updated_range: None
1853 }
1854 .log_status(),
1855 "cleared"
1856 );
1857 assert_eq!(
1858 WriteResult::Failed {
1859 detail: String::new()
1860 }
1861 .log_status(),
1862 "failed"
1863 );
1864 assert_eq!(
1865 WriteResult::RefusedEmptyValues.log_status(),
1866 "refused-empty-values"
1867 );
1868 assert_eq!(WriteResult::RefusedNoLease.log_status(), "refused-no-lease");
1869 assert_eq!(
1870 WriteResult::RefusedLeaseExpired.log_status(),
1871 "refused-lease-expired"
1872 );
1873 assert_eq!(
1874 WriteResult::RefusedLeaseWrongFile.log_status(),
1875 "refused-lease-wrong-file"
1876 );
1877 assert_eq!(
1878 WriteResult::RefusedLeaseStale.log_status(),
1879 "refused-lease-stale"
1880 );
1881 }
1882
1883 #[test]
1884 fn write_jsonl_emits_one_line_of_json() {
1885 let outcome = WriteOutcome {
1886 spreadsheet_id: "sheet-1".to_string(),
1887 file_name: Some("Budget".to_string()),
1888 range: Some("A1:B2".to_string()),
1889 resolved_folder_id: Some("parent-1".to_string()),
1890 verb: WriteVerb::Write,
1891 result: WriteResult::Written {
1892 updated_range: Some("A1:B2".to_string()),
1893 updated_rows: Some(1),
1894 updated_columns: Some(2),
1895 updated_cells: Some(2),
1896 },
1897 };
1898 let mut buf = Vec::new();
1899 outcome.write_jsonl(&mut buf).unwrap();
1900 let text = String::from_utf8(buf).unwrap();
1901 assert_eq!(text.matches('\n').count(), 1);
1902 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1903 assert_eq!(parsed["result"]["status"], "written");
1904 }
1905
1906 #[test]
1907 fn gate_denies_sheets_write_by_default() {
1908 let decision =
1909 write_gate::resolve(&["folder".to_string()], DriveOperation::SheetsWrite, &[]);
1910 assert_eq!(decision.verdict, Verdict::Deny);
1911 }
1912
1913 #[tokio::test]
1916 async fn a_file_rule_grants_a_sheet_with_no_visible_parents() {
1917 let server = wiremock::MockServer::start().await;
1920 let (drive, sheets) = clients(&server).await;
1921 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
1922 .mount(&server)
1923 .await;
1924 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1925 .and(wiremock::matchers::path(
1926 "/v4/spreadsheets/sheet-1/values/A1:B2",
1927 ))
1928 .respond_with(
1929 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1930 "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
1931 })),
1932 )
1933 .expect(1)
1934 .mount(&server)
1935 .await;
1936
1937 let outcome = write(
1938 &drive,
1939 &sheets,
1940 &opts(WriteVerb::Write, false),
1941 &[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsWrite])],
1942 )
1943 .await;
1944
1945 assert!(
1946 matches!(outcome.result, WriteResult::Written { .. }),
1947 "{:?}",
1948 outcome.result
1949 );
1950 }
1951
1952 #[tokio::test]
1953 async fn a_file_rule_denies_even_when_a_parent_folder_would_allow() {
1954 let server = wiremock::MockServer::start().await;
1956 let (drive, sheets) = clients(&server).await;
1957 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1958 .mount(&server)
1959 .await;
1960 let outcome = write(
1964 &drive,
1965 &sheets,
1966 &opts(WriteVerb::Write, false),
1967 &[
1968 allow_rule("parent-1"),
1969 FolderPermissionRule::file("sheet-1").denying([DriveOperation::SheetsWrite]),
1970 ],
1971 )
1972 .await;
1973
1974 match &outcome.result {
1975 WriteResult::Blocked { decided_by } => {
1976 let rule = decided_by.as_ref().expect("a file rule decided this");
1977 assert_eq!(rule.kind_label(), "file");
1978 assert_eq!(rule.id(), "sheet-1");
1979 }
1980 other => panic!("expected Blocked, got {other:?}"),
1981 }
1982 let text = describe(&outcome);
1983 assert!(text.contains("refused by rule on file sheet-1"), "{text}");
1984 assert!(!text.contains("depth"), "a file rule has no depth: {text}");
1985 }
1986
1987 #[tokio::test]
1988 async fn the_no_visible_parents_message_names_a_file_rule_as_the_fix() {
1989 let server = wiremock::MockServer::start().await;
1990 let (drive, sheets) = clients(&server).await;
1991 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
1992 .mount(&server)
1993 .await;
1994 let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1995 let text = describe(&outcome);
1996 assert!(text.contains("file_id"), "{text}");
1997 assert!(text.contains("sheets-write"), "{text}");
1998 }
1999
2000 #[tokio::test]
2003 async fn a_leased_write_concludes_its_audit_pair_with_allowed() {
2004 let server = wiremock::MockServer::start().await;
2005 let (drive, sheets) = clients(&server).await;
2006 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
2007 .mount(&server)
2008 .await;
2009 mount_folder("parent-1").mount(&server).await;
2010 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2011 .and(wiremock::matchers::path(
2012 "/v4/spreadsheets/sheet-1/values/A1:B2",
2013 ))
2014 .respond_with(
2015 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2016 "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
2017 })),
2018 )
2019 .mount(&server)
2020 .await;
2021 let dir = tempfile::tempdir().unwrap();
2022 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2023
2024 let outcome = write(
2025 &drive,
2026 &sheets,
2027 &opts(WriteVerb::Write, false),
2028 &[allow_rule("parent-1")],
2029 )
2030 .await;
2031 assert!(matches!(outcome.result, WriteResult::Written { .. }));
2032
2033 let records = audit.records();
2034 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
2035 assert_eq!(records[0].command, ["drive", "sheets-write"]);
2039 assert_eq!(
2042 records[1].context.get("version_after").map(String::as_str),
2043 Some("1")
2044 );
2045 }
2046
2047 #[tokio::test]
2048 async fn a_leased_write_that_fails_concludes_its_audit_pair_with_the_error() {
2049 let server = wiremock::MockServer::start().await;
2050 let (drive, sheets) = clients(&server).await;
2051 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
2052 .mount(&server)
2053 .await;
2054 mount_folder("parent-1").mount(&server).await;
2055 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2056 .and(wiremock::matchers::path(
2057 "/v4/spreadsheets/sheet-1/values/A1:B2",
2058 ))
2059 .respond_with(wiremock::ResponseTemplate::new(500))
2060 .mount(&server)
2061 .await;
2062 let dir = tempfile::tempdir().unwrap();
2063 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2064
2065 let outcome = write(
2066 &drive,
2067 &sheets,
2068 &opts(WriteVerb::Write, false),
2069 &[allow_rule("parent-1")],
2070 )
2071 .await;
2072 let WriteResult::Failed { detail } = &outcome.result else {
2073 panic!("expected Failed, got {:?}", outcome.result);
2074 };
2075
2076 let records = audit.records();
2077 assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
2078 assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
2079 }
2080}