1use std::path::PathBuf;
31use std::time::{Duration, Instant};
32
33use serde::Serialize;
34
35use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
36use crate::drive::client::DriveClient;
37use crate::drive::files_api::FilesApi;
38use crate::drive::lease::check::{
39 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
40 LeasedWrite,
41};
42use crate::drive::sheets::a1;
43use crate::drive::sheets::api::{
44 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
45};
46use crate::drive::sheets::client::SheetsClient;
47use crate::drive::sheets::grid_range;
48use crate::drive::sheets::target_gate;
49use crate::drive::sheets::types::{
50 AddProtectedRangeRequest, BatchUpdateRequestItem, DeleteProtectedRangeRequest, GridRange,
51 ProtectedRange, ProtectedRangeEditors, ProtectedRangeUpdate, Spreadsheet,
52 UpdateProtectedRangeRequest,
53};
54use crate::drive::types::SheetTargetRefusal;
55use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
56use crate::request_log::{self, DriveMutationOutcome};
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ProtectionVerb {
61 ProtectRange {
63 sheet: Option<String>,
66 range: Option<String>,
69 whole_sheet: bool,
72 description: Option<String>,
74 warning_only: bool,
76 editors: Vec<String>,
78 },
79 UpdateProtection {
82 sheet: Option<String>,
85 range: Option<String>,
88 whole_sheet: bool,
93 description: Option<String>,
95 warning_only: Option<bool>,
97 add_editors: Vec<String>,
99 remove_editors: Vec<String>,
101 },
102 UnprotectRange {
104 sheet: Option<String>,
107 range: Option<String>,
110 whole_sheet: bool,
113 },
114}
115
116impl ProtectionVerb {
117 const fn log_operation(&self) -> &'static str {
118 match self {
119 Self::ProtectRange { .. } => "sheets-protect-range",
120 Self::UpdateProtection { .. } => "sheets-update-protection",
121 Self::UnprotectRange { .. } => "sheets-unprotect-range",
122 }
123 }
124
125 const fn label(&self) -> &'static str {
126 match self {
127 Self::ProtectRange { .. } => "protect-range",
128 Self::UpdateProtection { .. } => "update-protection",
129 Self::UnprotectRange { .. } => "unprotect-range",
130 }
131 }
132
133 fn sheet_range_whole(&self) -> (Option<&str>, Option<&str>, bool) {
137 match self {
138 Self::ProtectRange {
139 sheet,
140 range,
141 whole_sheet,
142 ..
143 }
144 | Self::UpdateProtection {
145 sheet,
146 range,
147 whole_sheet,
148 ..
149 }
150 | Self::UnprotectRange {
151 sheet,
152 range,
153 whole_sheet,
154 } => (sheet.as_deref(), range.as_deref(), *whole_sheet),
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct ProtectionOptions {
162 pub spreadsheet_id: String,
164 pub verb: ProtectionVerb,
166 pub dry_run: bool,
168 pub lease_token: Option<String>,
173 pub ledger_path: PathBuf,
177}
178
179#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
181#[serde(tag = "status", rename_all = "kebab-case")]
182pub enum ProtectionResult {
183 WouldChange {
185 summary: String,
187 },
188 RefusedNotASpreadsheet {
190 mime_type: String,
192 },
193 RefusedShortcut,
195 RefusedNoVisibleParents,
197 RefusedSheetNotFound {
199 title: String,
201 available: Vec<String>,
203 },
204 RefusedInvalidRange {
206 detail: String,
208 },
209 RefusedProtectionNotFound {
212 detail: String,
214 },
215 RefusedAmbiguousProtection {
218 candidates: Vec<i64>,
221 },
222 Blocked {
224 decided_by: Option<DecidingRule>,
226 },
227 RefusedNoLease,
230 RefusedLeaseExpired,
233 RefusedLeaseWrongFile,
235 RefusedLeaseStale,
238 Changed {
240 summary: String,
242 protected_range_id: Option<i64>,
245 },
246 AppliedReplyUnreadable {
252 summary: String,
254 protected_range_id: Option<i64>,
259 detail: String,
261 },
262 Failed {
264 detail: String,
266 },
267}
268
269impl FromLeaseRefusal for ProtectionResult {
270 fn from_no_lease() -> Self {
271 Self::RefusedNoLease
272 }
273 fn from_lease_expired() -> Self {
274 Self::RefusedLeaseExpired
275 }
276 fn from_lease_wrong_file() -> Self {
277 Self::RefusedLeaseWrongFile
278 }
279 fn from_lease_stale() -> Self {
280 Self::RefusedLeaseStale
281 }
282 fn from_lease_failed(detail: String) -> Self {
283 Self::Failed { detail }
284 }
285}
286
287impl ProtectionResult {
288 fn log_status(&self) -> &'static str {
289 match self {
290 Self::WouldChange { .. } => "would-change",
291 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
292 Self::RefusedShortcut => "refused-shortcut",
293 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
294 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
295 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
296 Self::RefusedProtectionNotFound { .. } => "refused-protection-not-found",
297 Self::RefusedAmbiguousProtection { .. } => "refused-ambiguous-protection",
298 Self::Blocked { .. } => "blocked",
299 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
300 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
301 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
302 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
303 Self::Changed { .. } => "changed",
304 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
305 Self::Failed { .. } => "failed",
306 }
307 }
308}
309
310#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
312pub struct ProtectionOutcome {
313 pub spreadsheet_id: String,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub file_name: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub resolved_folder_id: Option<String>,
321 #[serde(skip)]
323 pub verb: ProtectionVerb,
324 pub result: ProtectionResult,
326}
327
328impl JsonlSerialize for ProtectionOutcome {
329 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
330 write_scalar_jsonl(self, out)
331 }
332}
333
334pub async fn protection(
337 drive: &DriveClient,
338 sheets: &SheetsClient,
339 opts: &ProtectionOptions,
340 rules: &[FolderPermissionRule],
341) -> ProtectionOutcome {
342 let started = Instant::now();
343 let outcome = protection_inner(drive, sheets, opts, rules).await;
344 if !opts.dry_run {
345 record_attempt(&outcome, opts, started.elapsed());
346 }
347 outcome
348}
349
350async fn protection_inner(
351 drive: &DriveClient,
352 sheets: &SheetsClient,
353 opts: &ProtectionOptions,
354 rules: &[FolderPermissionRule],
355) -> ProtectionOutcome {
356 let bare = |result| ProtectionOutcome {
357 spreadsheet_id: opts.spreadsheet_id.clone(),
358 file_name: None,
359 resolved_folder_id: None,
360 verb: opts.verb.clone(),
361 result,
362 };
363
364 let composed_range = match compose_target(&opts.verb) {
365 Ok(composed) => composed,
366 Err(detail) => return bare(ProtectionResult::RefusedInvalidRange { detail }),
367 };
368
369 if let Err(detail) = validate_verb(&opts.verb) {
370 return bare(ProtectionResult::RefusedInvalidRange { detail });
371 }
372
373 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
374 drive,
375 &opts.spreadsheet_id,
376 DriveOperation::SheetsProtection,
377 rules,
378 )
379 .await
380 {
381 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
382 return bare(ProtectionResult::Failed { detail })
383 }
384 target_gate::TargetGateOutcome::Refused { target, refusal } => {
385 let result = match refusal {
386 SheetTargetRefusal::Shortcut => ProtectionResult::RefusedShortcut,
387 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
388 ProtectionResult::RefusedNotASpreadsheet { mime_type }
389 }
390 SheetTargetRefusal::NoVisibleParents => ProtectionResult::RefusedNoVisibleParents,
391 };
392 return ProtectionOutcome {
393 spreadsheet_id: opts.spreadsheet_id.clone(),
394 file_name: Some(target.name),
395 resolved_folder_id: None,
396 verb: opts.verb.clone(),
397 result,
398 };
399 }
400 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
401 return ProtectionOutcome {
402 spreadsheet_id: opts.spreadsheet_id.clone(),
403 file_name: Some(target.name),
404 resolved_folder_id: None,
405 verb: opts.verb.clone(),
406 result: ProtectionResult::Failed { detail },
407 };
408 }
409 target_gate::TargetGateOutcome::Gated {
410 target,
411 decision,
412 resolved_folder_id,
413 requires_lease,
414 } => (target, decision, resolved_folder_id, requires_lease),
415 };
416
417 let gated = |result| ProtectionOutcome {
418 spreadsheet_id: opts.spreadsheet_id.clone(),
419 file_name: Some(target.name.clone()),
420 resolved_folder_id: resolved_folder_id.clone(),
421 verb: opts.verb.clone(),
422 result,
423 };
424
425 if decision.verdict == write_gate::Verdict::Deny {
426 return gated(ProtectionResult::Blocked {
427 decided_by: decision.decided_by,
428 });
429 }
430
431 let api = SheetsApi::new(sheets);
432 let workbook = match api
433 .get_spreadsheet_with_protections(&opts.spreadsheet_id)
434 .await
435 {
436 Ok(workbook) => workbook,
437 Err(err) => {
438 return gated(ProtectionResult::Failed {
439 detail: format!("{err:#}"),
440 })
441 }
442 };
443
444 let grid = match resolve_grid(&workbook, &opts.verb, composed_range.as_deref()) {
445 Ok(grid) => grid,
446 Err(result) => return gated(result),
447 };
448
449 let existing = match &opts.verb {
454 ProtectionVerb::ProtectRange { .. } => None,
455 ProtectionVerb::UpdateProtection { .. } | ProtectionVerb::UnprotectRange { .. } => {
456 match find_existing_protection(&workbook, grid) {
457 Ok(existing) => Some(existing),
458 Err(result) => return gated(result),
459 }
460 }
461 };
462
463 let built = match &opts.verb {
474 ProtectionVerb::ProtectRange {
475 description,
476 warning_only,
477 editors,
478 ..
479 } => Ok((
480 BatchUpdateRequestItem::AddProtectedRange(AddProtectedRangeRequest {
481 protected_range: ProtectedRange {
482 protected_range_id: None,
483 range: Some(grid),
484 description: description.clone(),
485 warning_only: Some(*warning_only),
486 editors: (!editors.is_empty()).then(|| ProtectedRangeEditors {
487 users: editors.clone(),
488 }),
489 },
490 }),
491 None,
492 )),
493 ProtectionVerb::UpdateProtection {
494 description,
495 warning_only,
496 add_editors,
497 remove_editors,
498 ..
499 } => {
500 let Some(existing) = existing else {
501 unreachable!("existing is resolved for UpdateProtection above")
502 };
503 build_update(
504 existing,
505 description,
506 *warning_only,
507 add_editors,
508 remove_editors,
509 )
510 .map(|update| {
511 (
512 BatchUpdateRequestItem::UpdateProtectedRange(update),
513 existing.protected_range_id,
514 )
515 })
516 }
517 ProtectionVerb::UnprotectRange { .. } => {
518 let Some(existing) = existing else {
519 unreachable!("existing is resolved for UnprotectRange above")
520 };
521 let id = existing.protected_range_id;
522 Ok((
523 BatchUpdateRequestItem::DeleteProtectedRange(DeleteProtectedRangeRequest {
524 protected_range_id: id.unwrap_or_default(),
525 }),
526 id,
527 ))
528 }
529 };
530 let (request, existing_id) = match built {
531 Ok(built) => built,
532 Err(detail) => return gated(ProtectionResult::RefusedInvalidRange { detail }),
533 };
534
535 let summary = describe_effect(&opts.verb);
536
537 if opts.dry_run {
538 return gated(ProtectionResult::WouldChange { summary });
539 }
540
541 let files_api = FilesApi::new(drive);
549 let leased = LeasedWrite {
550 log_prefix: "drive sheets protection",
551 operation: opts.verb.log_operation(),
552 ledger_path: &opts.ledger_path,
553 file_id: &opts.spreadsheet_id,
554 };
555 let lease_grant = match gate_optional_leased_write(
556 leased,
557 &files_api,
558 requires_lease,
559 opts.lease_token.as_deref(),
560 )
561 .await
562 {
563 Ok(grant) => grant,
564 Err(err) => return gated(err.into_result()),
565 };
566
567 let result = match conclude_native_leased_write(
568 leased,
569 &lease_grant,
570 &files_api,
571 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
572 |err| format!("{err:#}"),
573 )
574 .await
575 {
576 Ok(BatchUpdateOutcome::Applied(response)) => {
577 let protected_range_id = added_protected_range_id(&response).or(existing_id);
578 ProtectionResult::Changed {
579 summary,
580 protected_range_id,
581 }
582 }
583 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
586 ProtectionResult::AppliedReplyUnreadable {
587 summary,
588 protected_range_id: existing_id,
589 detail,
590 }
591 }
592 Err(err) => ProtectionResult::Failed {
593 detail: format!("{err:#}"),
594 },
595 };
596 drop(lease_grant);
597 gated(result)
598}
599
600fn compose_target(verb: &ProtectionVerb) -> Result<Option<String>, String> {
610 let (sheet, range, whole_sheet) = verb.sheet_range_whole();
611 if whole_sheet {
612 if range.is_some() {
613 return Err("--whole-sheet and --range are mutually exclusive".to_string());
614 }
615 if sheet.is_none() {
616 return Err("--whole-sheet needs --sheet to name the sheet to protect".to_string());
617 }
618 return Ok(None);
619 }
620 a1::compose(sheet, range)
621 .map(Some)
622 .map_err(|err| err.to_string())
623}
624
625fn validate_verb(verb: &ProtectionVerb) -> Result<(), String> {
633 if let ProtectionVerb::ProtectRange {
634 warning_only: true,
635 editors,
636 ..
637 } = verb
638 {
639 if !editors.is_empty() {
640 return Err(
641 "--warning-only and --editor are mutually exclusive: a warning-only \
642 protection never blocks an edit, so there is no one to exempt from it"
643 .to_string(),
644 );
645 }
646 }
647 Ok(())
648}
649
650fn resolve_grid(
651 workbook: &Spreadsheet,
652 verb: &ProtectionVerb,
653 composed: Option<&str>,
654) -> Result<GridRange, ProtectionResult> {
655 let (sheet, _range, whole_sheet) = verb.sheet_range_whole();
659 if whole_sheet {
660 let Some(sheet) = sheet else {
661 return Err(ProtectionResult::RefusedInvalidRange {
662 detail: "--whole-sheet needs --sheet to name the sheet to protect".to_string(),
663 });
664 };
665 let sheet_id = find_sheet_id(workbook, sheet)?;
666 return Ok(GridRange {
667 sheet_id,
668 ..Default::default()
669 });
670 }
671 let composed = composed.unwrap_or_default();
672 let (_, grid) = grid_range::resolve_grid_range(
673 workbook,
674 composed,
675 |detail| ProtectionResult::RefusedInvalidRange { detail },
676 |title, available| ProtectionResult::RefusedSheetNotFound { title, available },
677 )?;
678 Ok(grid)
679}
680
681fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, ProtectionResult> {
682 grid_range::find_sheet_id(workbook, title, |title, available| {
683 ProtectionResult::RefusedSheetNotFound { title, available }
684 })
685}
686
687fn find_existing_protection(
691 workbook: &Spreadsheet,
692 grid: GridRange,
693) -> Result<&ProtectedRange, ProtectionResult> {
694 let matches: Vec<&ProtectedRange> = workbook
695 .sheets
696 .iter()
697 .flat_map(|sheet| sheet.protected_ranges.iter())
698 .filter(|p| p.range.as_ref() == Some(&grid))
699 .collect();
700 match matches.as_slice() {
701 [one] => Ok(one),
702 [] => Err(ProtectionResult::RefusedProtectionNotFound {
703 detail: "no protected range exactly matches this target; run \
704 `drive sheets list-protections` to see what exists"
705 .to_string(),
706 }),
707 many => Err(ProtectionResult::RefusedAmbiguousProtection {
708 candidates: many.iter().filter_map(|p| p.protected_range_id).collect(),
709 }),
710 }
711}
712
713fn build_update(
718 existing: &ProtectedRange,
719 description: &Option<String>,
720 warning_only: Option<bool>,
721 add_editors: &[String],
722 remove_editors: &[String],
723) -> Result<UpdateProtectedRangeRequest, String> {
724 let mut fields = Vec::new();
725 let mut update = ProtectedRangeUpdate {
726 protected_range_id: existing.protected_range_id.unwrap_or_default(),
727 ..Default::default()
728 };
729 if let Some(description) = description {
730 update.description = Some(description.clone());
731 fields.push("description");
732 }
733 if let Some(warning_only) = warning_only {
734 update.warning_only = Some(warning_only);
735 fields.push("warningOnly");
736 }
737 let mut resulting_editors: Vec<String> = existing
738 .editors
739 .as_ref()
740 .map(|e| e.users.clone())
741 .unwrap_or_default();
742 if !add_editors.is_empty() || !remove_editors.is_empty() {
743 for editor in add_editors {
744 if !resulting_editors.contains(editor) {
745 resulting_editors.push(editor.clone());
746 }
747 }
748 resulting_editors.retain(|e| !remove_editors.contains(e));
749 update.editors = Some(ProtectedRangeEditors {
750 users: resulting_editors.clone(),
751 });
752 fields.push("editors");
753 }
754 let resulting_warning_only =
762 warning_only.unwrap_or_else(|| existing.warning_only.unwrap_or(false));
763 if resulting_warning_only && !resulting_editors.is_empty() {
764 return Err(
765 "this would leave the protection warning-only with editors set, which Sheets \
766 rejects: a warning-only protection never blocks an edit, so there is no one to \
767 exempt from it — drop --warning-only or remove every editor first"
768 .to_string(),
769 );
770 }
771 Ok(UpdateProtectedRangeRequest {
772 protected_range: update,
773 fields: fields.join(","),
774 })
775}
776
777fn added_protected_range_id(
778 response: &crate::drive::sheets::types::BatchUpdateResponse,
779) -> Option<i64> {
780 response
781 .replies
782 .iter()
783 .find_map(|reply| reply.add_protected_range.as_ref())
784 .and_then(|added| added.protected_range.as_ref())
785 .and_then(|range| range.protected_range_id)
786}
787
788fn describe_effect(verb: &ProtectionVerb) -> String {
789 match verb {
790 ProtectionVerb::ProtectRange {
791 warning_only,
792 editors,
793 ..
794 } => {
795 let strictness = if *warning_only {
796 "warn only"
797 } else {
798 "block edits"
799 };
800 let editors = if editors.is_empty() {
801 String::new()
802 } else {
803 format!(", editors exempted: {}", editors.join(", "))
804 };
805 format!("protect ({strictness}{editors})")
806 }
807 ProtectionVerb::UpdateProtection {
808 description,
809 warning_only,
810 add_editors,
811 remove_editors,
812 ..
813 } => {
814 let mut parts = Vec::new();
819 if let Some(description) = description {
820 parts.push(format!("description: '{description}'"));
821 }
822 if let Some(warning_only) = warning_only {
823 parts.push(format!("warning-only: {warning_only}"));
824 }
825 if !add_editors.is_empty() {
826 parts.push(format!("+{}", add_editors.join(",")));
827 }
828 if !remove_editors.is_empty() {
829 parts.push(format!("-{}", remove_editors.join(",")));
830 }
831 if parts.is_empty() {
832 "update protection".to_string()
833 } else {
834 format!("update protection ({})", parts.join(" "))
835 }
836 }
837 ProtectionVerb::UnprotectRange { .. } => "remove protection".to_string(),
838 }
839}
840
841fn record_attempt(outcome: &ProtectionOutcome, opts: &ProtectionOptions, duration: Duration) {
842 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
843}
844
845fn mutation_record(
848 outcome: &ProtectionOutcome,
849 opts: &ProtectionOptions,
850 duration: Duration,
851) -> DriveMutationOutcome {
852 let error = match &outcome.result {
853 ProtectionResult::Failed { detail }
854 | ProtectionResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
855 _ => None,
856 };
857 let decided_by = match &outcome.result {
858 ProtectionResult::Blocked { decided_by } => decided_by.as_ref(),
859 _ => None,
860 };
861 let decided_by = write_gate::decided_by_log_fields(decided_by);
862 let (protected_range_id, editors_added, editors_removed) = match &outcome.result {
869 ProtectionResult::Changed {
870 protected_range_id, ..
871 }
872 | ProtectionResult::AppliedReplyUnreadable {
873 protected_range_id, ..
874 } => {
875 let (added, removed) = match &opts.verb {
876 ProtectionVerb::ProtectRange { editors, .. } => (editors.clone(), Vec::new()),
877 ProtectionVerb::UpdateProtection {
878 add_editors,
879 remove_editors,
880 ..
881 } => (add_editors.clone(), remove_editors.clone()),
882 ProtectionVerb::UnprotectRange { .. } => (Vec::new(), Vec::new()),
883 };
884 (*protected_range_id, added, removed)
885 }
886 _ => (None, Vec::new(), Vec::new()),
887 };
888 let fields_changed = match &outcome.result {
889 ProtectionResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
890 _ => None,
891 };
892
893 DriveMutationOutcome {
894 operation: opts.verb.log_operation(),
895 file_id: outcome.spreadsheet_id.clone(),
896 file_name: outcome.file_name.clone().unwrap_or_default(),
897 status: outcome.result.log_status().to_string(),
898 resolved_folder_id: outcome.resolved_folder_id.clone(),
899 decided_by_folder_id: decided_by.folder_id,
900 decided_by_depth: decided_by.depth,
901 decided_by_file_id: decided_by.file_id,
902 protected_range_id,
903 protection_editors_added: editors_added,
904 protection_editors_removed: editors_removed,
905 fields_changed,
906 error,
907 duration,
908 ..Default::default()
909 }
910}
911
912#[must_use]
914pub fn describe(outcome: &ProtectionOutcome) -> String {
915 describe_lines(outcome).join("\n")
916}
917
918#[must_use]
921pub fn describe_lines(outcome: &ProtectionOutcome) -> Vec<String> {
922 let verb = &outcome.verb;
923 let book = outcome.file_name.as_deref().map_or_else(
924 || format!("'{}'", outcome.spreadsheet_id),
925 |n| format!("'{n}'"),
926 );
927 match &outcome.result {
928 ProtectionResult::WouldChange { summary } => vec![format!("Would {summary} in {book}")],
929 ProtectionResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
930 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
931 `drive sheets {}` only works on spreadsheets",
932 verb.label()
933 )],
934 ProtectionResult::RefusedShortcut => vec![format!(
935 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
936 verb.label()
937 )],
938 ProtectionResult::RefusedNoVisibleParents => vec![format!(
939 "Refused: {book} has no parent folder visible to this account, so no folder \
940 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
941 id>\", \"allow\": [\"sheets-protection\"]}} to write_permissions.rules."
942 )],
943 ProtectionResult::RefusedSheetNotFound { title, available } => {
944 vec![grid_range::no_sheet_refusal(&book, title, available)]
945 }
946 ProtectionResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
947 ProtectionResult::RefusedProtectionNotFound { detail } => {
948 vec![format!("Refused: {detail}")]
949 }
950 ProtectionResult::RefusedAmbiguousProtection { candidates } => vec![format!(
951 "Refused: more than one protected range matches this target (ids: {}); \
952 this is ambiguous and nothing was changed",
953 candidates
954 .iter()
955 .map(ToString::to_string)
956 .collect::<Vec<_>>()
957 .join(", ")
958 )],
959 ProtectionResult::Blocked { decided_by } => vec![match decided_by {
960 Some(rule) => format!(
961 "Blocked: {} on {book} refused by rule on {} {}{}",
962 verb.label(),
963 rule.kind_label(),
964 rule.id(),
965 rule.depth_suffix()
966 ),
967 None => format!(
968 "Blocked: {} on {book} refused by default policy (no matching rule for \
969 sheets-protection)",
970 verb.label()
971 ),
972 }],
973 ProtectionResult::RefusedNoLease => LeaseGateRefusal::NoLease
974 .describe_line(&outcome.spreadsheet_id, &book)
975 .into_iter()
976 .collect(),
977 ProtectionResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
978 .describe_line(&outcome.spreadsheet_id, &book)
979 .into_iter()
980 .collect(),
981 ProtectionResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
982 .describe_line(&outcome.spreadsheet_id, &book)
983 .into_iter()
984 .collect(),
985 ProtectionResult::RefusedLeaseStale => LeaseGateRefusal::Stale
986 .describe_line(&outcome.spreadsheet_id, &book)
987 .into_iter()
988 .collect(),
989 ProtectionResult::Changed {
990 summary,
991 protected_range_id,
992 } => {
993 let id = protected_range_id.map_or_else(String::new, |id| format!(" (id {id})"));
994 vec![format!("Applied: {summary}{id} in {book}")]
995 }
996 ProtectionResult::AppliedReplyUnreadable {
997 summary,
998 protected_range_id,
999 detail,
1000 } => {
1001 let id = protected_range_id.map_or_else(String::new, |id| format!(" (id {id})"));
1002 let mut lines = vec![applied_reply_unreadable_line(
1003 &format!("{summary}{id}"),
1004 &book,
1005 RetryHint::NotIdempotent,
1006 detail,
1007 )];
1008 if matches!(verb, ProtectionVerb::ProtectRange { .. }) {
1009 lines.push(
1010 " the new protection's id was in the unreadable reply; run \
1011 `list-protections` to find it"
1012 .to_string(),
1013 );
1014 }
1015 lines
1016 }
1017 ProtectionResult::Failed { detail } => vec![format!("Failed: {detail}")],
1018 }
1019}
1020
1021#[cfg(test)]
1022#[allow(clippy::unwrap_used, clippy::expect_used)]
1023mod tests {
1024 use super::*;
1025 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1026 use crate::drive::sheets::client::SHEETS_API_URL;
1027 use crate::drive::sheets::types::{Sheet, SheetProperties};
1028 use crate::drive::test_support::seed_lease;
1029 use crate::test_support::env::MapEnv;
1030 use crate::utils::secret::Secret;
1031 use std::collections::HashSet;
1032
1033 fn grid(sheet_id: i64) -> GridRange {
1034 GridRange {
1035 sheet_id,
1036 start_row_index: Some(0),
1037 end_row_index: Some(5),
1038 start_column_index: Some(0),
1039 end_column_index: Some(1),
1040 }
1041 }
1042
1043 fn protected(id: i64, range: GridRange) -> ProtectedRange {
1044 ProtectedRange {
1045 protected_range_id: Some(id),
1046 range: Some(range),
1047 ..Default::default()
1048 }
1049 }
1050
1051 fn workbook_with(protections: Vec<ProtectedRange>) -> Spreadsheet {
1052 Spreadsheet {
1053 sheets: vec![Sheet {
1054 properties: None,
1055 protected_ranges: protections,
1056 ..Default::default()
1057 }],
1058 ..Default::default()
1059 }
1060 }
1061
1062 #[test]
1063 fn find_existing_protection_matches_by_exact_range() {
1064 let workbook = workbook_with(vec![protected(10, grid(1))]);
1065 let found = find_existing_protection(&workbook, grid(1)).unwrap();
1066 assert_eq!(found.protected_range_id, Some(10));
1067 }
1068
1069 #[test]
1070 fn find_existing_protection_refuses_when_none_matches() {
1071 let workbook = workbook_with(vec![protected(10, grid(1))]);
1072 let mut other = grid(1);
1073 other.end_row_index = Some(6);
1074 let err = find_existing_protection(&workbook, other).unwrap_err();
1075 assert!(matches!(
1076 err,
1077 ProtectionResult::RefusedProtectionNotFound { .. }
1078 ));
1079 }
1080
1081 #[test]
1082 fn find_existing_protection_refuses_when_ambiguous() {
1083 let workbook = workbook_with(vec![protected(10, grid(1)), protected(11, grid(1))]);
1084 let err = find_existing_protection(&workbook, grid(1)).unwrap_err();
1085 match err {
1086 ProtectionResult::RefusedAmbiguousProtection { mut candidates } => {
1087 candidates.sort_unstable();
1088 assert_eq!(candidates, vec![10, 11]);
1089 }
1090 other => panic!("expected RefusedAmbiguousProtection, got {other:?}"),
1091 }
1092 }
1093
1094 #[test]
1095 fn build_update_adds_and_removes_editors_from_the_current_set() {
1096 let existing = ProtectedRange {
1097 protected_range_id: Some(1),
1098 editors: Some(ProtectedRangeEditors {
1099 users: vec!["a@example.com".to_string(), "b@example.com".to_string()],
1100 }),
1101 ..Default::default()
1102 };
1103 let request = build_update(
1104 &existing,
1105 &None,
1106 None,
1107 &["c@example.com".to_string()],
1108 &["a@example.com".to_string()],
1109 )
1110 .unwrap();
1111 let mut users = request.protected_range.editors.unwrap().users;
1112 users.sort();
1113 assert_eq!(users, vec!["b@example.com", "c@example.com"]);
1114 assert_eq!(request.fields, "editors");
1115 }
1116
1117 #[test]
1118 fn build_update_removing_the_last_editor_still_sends_an_editors_object() {
1119 let existing = ProtectedRange {
1124 protected_range_id: Some(1),
1125 editors: Some(ProtectedRangeEditors {
1126 users: vec!["a@example.com".to_string()],
1127 }),
1128 ..Default::default()
1129 };
1130 let request =
1131 build_update(&existing, &None, None, &[], &["a@example.com".to_string()]).unwrap();
1132 assert_eq!(
1133 serde_json::to_value(&request).unwrap(),
1134 serde_json::json!({
1135 "protectedRange": {"protectedRangeId": 1, "editors": {}},
1136 "fields": "editors",
1137 })
1138 );
1139 }
1140
1141 #[test]
1142 fn build_update_leaves_editors_untouched_when_neither_add_nor_remove_given() {
1143 let existing = ProtectedRange {
1144 protected_range_id: Some(1),
1145 editors: Some(ProtectedRangeEditors {
1146 users: vec!["a@example.com".to_string()],
1147 }),
1148 ..Default::default()
1149 };
1150 let request = build_update(&existing, &Some("note".to_string()), None, &[], &[]).unwrap();
1151 assert!(request.protected_range.editors.is_none());
1152 assert_eq!(request.fields, "description");
1153 }
1154
1155 #[test]
1156 fn build_update_refuses_warning_only_with_editors_from_either_side() {
1157 let existing = ProtectedRange {
1161 protected_range_id: Some(1),
1162 editors: Some(ProtectedRangeEditors {
1163 users: vec!["a@example.com".to_string()],
1164 }),
1165 ..Default::default()
1166 };
1167 let err = build_update(&existing, &None, Some(true), &[], &[]).unwrap_err();
1168 assert!(err.contains("warning-only"), "{err}");
1169
1170 let existing_warning_only = ProtectedRange {
1174 protected_range_id: Some(1),
1175 warning_only: Some(true),
1176 ..Default::default()
1177 };
1178 let err = build_update(
1179 &existing_warning_only,
1180 &None,
1181 None,
1182 &["a@example.com".to_string()],
1183 &[],
1184 )
1185 .unwrap_err();
1186 assert!(err.contains("warning-only"), "{err}");
1187 }
1188
1189 fn test_credentials() -> DriveCredentials {
1190 DriveCredentials {
1191 client_id: "client-1".to_string(),
1192 client_secret: Secret::new("secret-1"),
1193 refresh_token: Secret::new("refresh-1"),
1194 scope: DriveGrantedScopes::READONLY,
1195 }
1196 }
1197
1198 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1199 wiremock::Mock::given(wiremock::matchers::method("POST"))
1200 .and(wiremock::matchers::path("/token"))
1201 .respond_with(
1202 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1203 "access_token": "test-token", "expires_in": 3600,
1204 })),
1205 )
1206 .mount(server)
1207 .await;
1208 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1209 crate::drive::client::test_support::replace_session(
1210 &mut drive,
1211 &test_credentials(),
1212 &format!("{}/token", server.uri()),
1213 );
1214 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1215 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1216 (drive, sheets)
1217 }
1218
1219 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1223 let parents: Vec<&str> = parents.to_vec();
1224 wiremock::Mock::given(wiremock::matchers::method("GET"))
1225 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1226 .respond_with(
1227 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1228 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1229 "version": "1",
1230 })),
1231 )
1232 }
1233
1234 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1238 let ledger_path = tempfile::tempdir()
1239 .unwrap()
1240 .keep()
1241 .join("lease-ledger.jsonl");
1242 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1243 (Some(token), ledger_path)
1244 }
1245
1246 fn mount_folder(id: &str) -> wiremock::Mock {
1247 mount_file(id, "application/vnd.google-apps.folder", &[])
1248 }
1249
1250 fn allow_rule(folder: &str) -> FolderPermissionRule {
1251 FolderPermissionRule {
1252 folder_id: Some(folder.to_string()),
1253 file_id: None,
1254 recursive: true,
1255 allow: std::iter::once(DriveOperation::SheetsProtection).collect(),
1256 deny: HashSet::default(),
1257 require_lease: true,
1258 }
1259 }
1260
1261 fn mount_workbook(protected_ranges: serde_json::Value) -> wiremock::Mock {
1262 wiremock::Mock::given(wiremock::matchers::method("GET"))
1263 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1264 .respond_with(
1265 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1266 "spreadsheetId": "sheet-1",
1267 "properties": {"title": "Budget"},
1268 "sheets": [
1269 {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
1270 "protectedRanges": protected_ranges},
1271 ],
1272 })),
1273 )
1274 }
1275
1276 #[tokio::test]
1277 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
1278 let server = wiremock::MockServer::start().await;
1279 let (drive, sheets) = clients(&server).await;
1280 mount_file(
1281 "sheet-1",
1282 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1283 &["folder-1"],
1284 )
1285 .mount(&server)
1286 .await;
1287 mount_folder("folder-1").mount(&server).await;
1288 let rules: Vec<FolderPermissionRule> = Vec::new();
1289 let opts = ProtectionOptions {
1290 spreadsheet_id: "sheet-1".to_string(),
1291 verb: ProtectionVerb::ProtectRange {
1292 sheet: Some("Q1".to_string()),
1293 range: Some("A1:A5".to_string()),
1294 whole_sheet: false,
1295 description: None,
1296 warning_only: false,
1297 editors: Vec::new(),
1298 },
1299 dry_run: false,
1300 lease_token: None,
1301 ledger_path: std::path::PathBuf::new(),
1302 };
1303 let outcome = protection(&drive, &sheets, &opts, &rules).await;
1304 assert!(matches!(outcome.result, ProtectionResult::Blocked { .. }));
1305 }
1306
1307 #[tokio::test]
1308 async fn protect_range_sends_an_add_protected_range_request() {
1309 let server = wiremock::MockServer::start().await;
1310 let (drive, sheets) = clients(&server).await;
1311 mount_file(
1312 "sheet-1",
1313 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1314 &["folder-1"],
1315 )
1316 .mount(&server)
1317 .await;
1318 mount_folder("folder-1").mount(&server).await;
1319 mount_workbook(serde_json::json!([])).mount(&server).await;
1320 wiremock::Mock::given(wiremock::matchers::method("POST"))
1321 .and(wiremock::matchers::path(
1322 "/v4/spreadsheets/sheet-1:batchUpdate",
1323 ))
1324 .respond_with(
1325 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1326 "replies": [{"addProtectedRange": {"protectedRange": {"protectedRangeId": 99}}}]
1327 })),
1328 )
1329 .mount(&server)
1330 .await;
1331 let rules = vec![allow_rule("folder-1")];
1332 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1333 let opts = ProtectionOptions {
1334 spreadsheet_id: "sheet-1".to_string(),
1335 verb: ProtectionVerb::ProtectRange {
1336 sheet: Some("Q1".to_string()),
1337 range: Some("A1:A5".to_string()),
1338 whole_sheet: false,
1339 description: Some("locked".to_string()),
1340 warning_only: false,
1341 editors: Vec::new(),
1342 },
1343 dry_run: false,
1344 lease_token,
1345 ledger_path,
1346 };
1347 let outcome = protection(&drive, &sheets, &opts, &rules).await;
1348 match outcome.result {
1349 ProtectionResult::Changed {
1350 protected_range_id, ..
1351 } => assert_eq!(protected_range_id, Some(99)),
1352 other => panic!("expected Changed, got {other:?}"),
1353 }
1354 }
1355
1356 #[tokio::test]
1357 async fn protect_range_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1358 let server = wiremock::MockServer::start().await;
1359 let (drive, sheets) = clients(&server).await;
1360 mount_file(
1361 "sheet-1",
1362 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1363 &["folder-1"],
1364 )
1365 .mount(&server)
1366 .await;
1367 mount_folder("folder-1").mount(&server).await;
1368 mount_workbook(serde_json::json!([])).mount(&server).await;
1369 wiremock::Mock::given(wiremock::matchers::method("POST"))
1371 .and(wiremock::matchers::path(
1372 "/v4/spreadsheets/sheet-1:batchUpdate",
1373 ))
1374 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1375 .expect(1)
1376 .mount(&server)
1377 .await;
1378 let rules = vec![allow_rule("folder-1")];
1379 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1380 let opts = ProtectionOptions {
1381 spreadsheet_id: "sheet-1".to_string(),
1382 verb: ProtectionVerb::ProtectRange {
1383 sheet: Some("Q1".to_string()),
1384 range: Some("A1:A5".to_string()),
1385 whole_sheet: false,
1386 description: Some("locked".to_string()),
1387 warning_only: false,
1388 editors: Vec::new(),
1389 },
1390 dry_run: false,
1391 lease_token,
1392 ledger_path,
1393 };
1394 let outcome = protection(&drive, &sheets, &opts, &rules).await;
1395 assert_eq!(
1396 outcome.result.log_status(),
1397 "applied-reply-unreadable",
1398 "{:?}",
1399 outcome.result
1400 );
1401 assert!(
1402 describe(&outcome).starts_with("Applied, but the reply could not be read"),
1403 "{}",
1404 describe(&outcome)
1405 );
1406 }
1407
1408 #[tokio::test]
1409 async fn unprotect_range_refuses_when_no_protection_matches() {
1410 let server = wiremock::MockServer::start().await;
1411 let (drive, sheets) = clients(&server).await;
1412 mount_file(
1413 "sheet-1",
1414 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1415 &["folder-1"],
1416 )
1417 .mount(&server)
1418 .await;
1419 mount_folder("folder-1").mount(&server).await;
1420 mount_workbook(serde_json::json!([])).mount(&server).await;
1421 let rules = vec![allow_rule("folder-1")];
1422 let opts = ProtectionOptions {
1423 spreadsheet_id: "sheet-1".to_string(),
1424 verb: ProtectionVerb::UnprotectRange {
1425 sheet: Some("Q1".to_string()),
1426 range: Some("A1:A5".to_string()),
1427 whole_sheet: false,
1428 },
1429 dry_run: false,
1430 lease_token: None,
1431 ledger_path: std::path::PathBuf::new(),
1432 };
1433 let outcome = protection(&drive, &sheets, &opts, &rules).await;
1434 assert!(matches!(
1435 outcome.result,
1436 ProtectionResult::RefusedProtectionNotFound { .. }
1437 ));
1438 }
1439
1440 #[tokio::test]
1441 async fn unprotect_range_whole_sheet_finds_a_whole_sheet_protection() {
1442 let server = wiremock::MockServer::start().await;
1446 let (drive, sheets) = clients(&server).await;
1447 mount_file(
1448 "sheet-1",
1449 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1450 &["folder-1"],
1451 )
1452 .mount(&server)
1453 .await;
1454 mount_folder("folder-1").mount(&server).await;
1455 mount_workbook(serde_json::json!([{
1456 "protectedRangeId": 42,
1457 "range": {"sheetId": 0},
1458 }]))
1459 .mount(&server)
1460 .await;
1461 wiremock::Mock::given(wiremock::matchers::method("POST"))
1462 .and(wiremock::matchers::path(
1463 "/v4/spreadsheets/sheet-1:batchUpdate",
1464 ))
1465 .respond_with(
1466 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1467 "replies": [{}]
1468 })),
1469 )
1470 .mount(&server)
1471 .await;
1472 let rules = vec![allow_rule("folder-1")];
1473 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1474 let opts = ProtectionOptions {
1475 spreadsheet_id: "sheet-1".to_string(),
1476 verb: ProtectionVerb::UnprotectRange {
1477 sheet: Some("Q1".to_string()),
1478 range: None,
1479 whole_sheet: true,
1480 },
1481 dry_run: false,
1482 lease_token,
1483 ledger_path,
1484 };
1485 let outcome = protection(&drive, &sheets, &opts, &rules).await;
1486 match outcome.result {
1487 ProtectionResult::Changed {
1488 protected_range_id, ..
1489 } => assert_eq!(protected_range_id, Some(42)),
1490 other => panic!("expected Changed, got {other:?}"),
1491 }
1492 }
1493
1494 fn protect_verb() -> ProtectionVerb {
1495 ProtectionVerb::ProtectRange {
1496 sheet: None,
1497 range: None,
1498 whole_sheet: false,
1499 description: None,
1500 warning_only: false,
1501 editors: Vec::new(),
1502 }
1503 }
1504
1505 fn update_verb() -> ProtectionVerb {
1506 ProtectionVerb::UpdateProtection {
1507 sheet: None,
1508 range: None,
1509 whole_sheet: false,
1510 description: None,
1511 warning_only: None,
1512 add_editors: Vec::new(),
1513 remove_editors: Vec::new(),
1514 }
1515 }
1516
1517 fn unprotect_verb() -> ProtectionVerb {
1518 ProtectionVerb::UnprotectRange {
1519 sheet: None,
1520 range: None,
1521 whole_sheet: false,
1522 }
1523 }
1524
1525 #[test]
1526 fn protection_verb_label_and_log_operation_name_every_variant() {
1527 assert_eq!(protect_verb().label(), "protect-range");
1528 assert_eq!(update_verb().label(), "update-protection");
1529 assert_eq!(unprotect_verb().label(), "unprotect-range");
1530 assert_eq!(protect_verb().log_operation(), "sheets-protect-range");
1531 assert_eq!(update_verb().log_operation(), "sheets-update-protection");
1532 assert_eq!(unprotect_verb().log_operation(), "sheets-unprotect-range");
1533 }
1534
1535 #[test]
1536 fn sheet_range_whole_reads_update_protections_fields() {
1537 let verb = ProtectionVerb::UpdateProtection {
1538 sheet: Some("Q1".to_string()),
1539 range: Some("A1:A5".to_string()),
1540 whole_sheet: false,
1541 description: None,
1542 warning_only: None,
1543 add_editors: Vec::new(),
1544 remove_editors: Vec::new(),
1545 };
1546 assert_eq!(verb.sheet_range_whole(), (Some("Q1"), Some("A1:A5"), false));
1547 }
1548
1549 #[test]
1550 fn protection_result_log_status_names_every_variant() {
1551 assert_eq!(
1552 ProtectionResult::WouldChange {
1553 summary: String::new()
1554 }
1555 .log_status(),
1556 "would-change"
1557 );
1558 assert_eq!(
1559 ProtectionResult::RefusedNotASpreadsheet {
1560 mime_type: String::new()
1561 }
1562 .log_status(),
1563 "refused-not-a-spreadsheet"
1564 );
1565 assert_eq!(
1566 ProtectionResult::RefusedShortcut.log_status(),
1567 "refused-shortcut"
1568 );
1569 assert_eq!(
1570 ProtectionResult::RefusedNoVisibleParents.log_status(),
1571 "refused-no-visible-parents"
1572 );
1573 assert_eq!(
1574 ProtectionResult::RefusedSheetNotFound {
1575 title: String::new(),
1576 available: Vec::new()
1577 }
1578 .log_status(),
1579 "refused-sheet-not-found"
1580 );
1581 assert_eq!(
1582 ProtectionResult::RefusedInvalidRange {
1583 detail: String::new()
1584 }
1585 .log_status(),
1586 "refused-invalid-range"
1587 );
1588 assert_eq!(
1589 ProtectionResult::RefusedAmbiguousProtection {
1590 candidates: Vec::new()
1591 }
1592 .log_status(),
1593 "refused-ambiguous-protection"
1594 );
1595 assert_eq!(
1596 ProtectionResult::AppliedReplyUnreadable {
1597 summary: String::new(),
1598 protected_range_id: None,
1599 detail: String::new(),
1600 }
1601 .log_status(),
1602 "applied-reply-unreadable"
1603 );
1604 assert_eq!(
1605 ProtectionResult::Failed {
1606 detail: String::new()
1607 }
1608 .log_status(),
1609 "failed"
1610 );
1611 }
1612
1613 #[test]
1614 fn write_jsonl_emits_one_line_of_json() {
1615 let outcome = ProtectionOutcome {
1616 spreadsheet_id: "sheet-1".to_string(),
1617 file_name: Some("Budget".to_string()),
1618 resolved_folder_id: None,
1619 verb: unprotect_verb(),
1620 result: ProtectionResult::Changed {
1621 summary: "remove protection".to_string(),
1622 protected_range_id: Some(1),
1623 },
1624 };
1625 let mut buf = Vec::new();
1626 outcome.write_jsonl(&mut buf).unwrap();
1627 let text = String::from_utf8(buf).unwrap();
1628 assert_eq!(text.matches('\n').count(), 1);
1629 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1630 assert_eq!(parsed["result"]["status"], "changed");
1631 }
1632
1633 #[test]
1634 fn compose_target_rejects_whole_sheet_with_range() {
1635 let verb = ProtectionVerb::ProtectRange {
1636 sheet: Some("Q1".to_string()),
1637 range: Some("A1:A5".to_string()),
1638 whole_sheet: true,
1639 description: None,
1640 warning_only: false,
1641 editors: Vec::new(),
1642 };
1643 let err = compose_target(&verb).unwrap_err();
1644 assert!(err.contains("mutually exclusive"), "{err}");
1645 }
1646
1647 #[test]
1648 fn compose_target_rejects_whole_sheet_without_a_sheet() {
1649 let verb = ProtectionVerb::ProtectRange {
1650 sheet: None,
1651 range: None,
1652 whole_sheet: true,
1653 description: None,
1654 warning_only: false,
1655 editors: Vec::new(),
1656 };
1657 let err = compose_target(&verb).unwrap_err();
1658 assert!(err.contains("needs --sheet"), "{err}");
1659 }
1660
1661 #[test]
1662 fn validate_verb_rejects_warning_only_protect_range_with_editors() {
1663 let verb = ProtectionVerb::ProtectRange {
1664 sheet: Some("Q1".to_string()),
1665 range: Some("A1:A5".to_string()),
1666 whole_sheet: false,
1667 description: None,
1668 warning_only: true,
1669 editors: vec!["a@example.com".to_string()],
1670 };
1671 let err = validate_verb(&verb).unwrap_err();
1672 assert!(err.contains("mutually exclusive"), "{err}");
1673 }
1674
1675 #[test]
1676 fn resolve_grid_whole_sheet_without_a_sheet_name_is_refused() {
1677 let workbook = workbook_with(Vec::new());
1678 let verb = ProtectionVerb::UnprotectRange {
1679 sheet: None,
1680 range: None,
1681 whole_sheet: true,
1682 };
1683 let err = resolve_grid(&workbook, &verb, None).unwrap_err();
1684 assert!(matches!(err, ProtectionResult::RefusedInvalidRange { .. }));
1685 }
1686
1687 #[test]
1688 fn resolve_grid_reports_a_sheet_not_found() {
1689 let workbook = workbook_with(Vec::new());
1690 let err = resolve_grid(&workbook, &protect_verb(), Some("Missing!A1")).unwrap_err();
1691 assert!(matches!(err, ProtectionResult::RefusedSheetNotFound { .. }));
1692 }
1693
1694 #[test]
1695 fn resolve_grid_reports_an_invalid_range() {
1696 let workbook = Spreadsheet {
1697 sheets: vec![Sheet {
1698 properties: Some(SheetProperties {
1699 sheet_id: Some(0),
1700 title: "Q1".to_string(),
1701 ..Default::default()
1702 }),
1703 protected_ranges: Vec::new(),
1704 ..Default::default()
1705 }],
1706 ..Default::default()
1707 };
1708 let err = resolve_grid(&workbook, &protect_verb(), Some("Q1!!!!")).unwrap_err();
1709 assert!(matches!(err, ProtectionResult::RefusedInvalidRange { .. }));
1710 }
1711
1712 #[test]
1713 fn describe_effect_protect_range_reports_strictness_and_editors() {
1714 let blocking = ProtectionVerb::ProtectRange {
1715 sheet: None,
1716 range: None,
1717 whole_sheet: false,
1718 description: None,
1719 warning_only: false,
1720 editors: vec!["a@example.com".to_string()],
1721 };
1722 assert_eq!(
1723 describe_effect(&blocking),
1724 "protect (block edits, editors exempted: a@example.com)"
1725 );
1726
1727 let warn_only = ProtectionVerb::ProtectRange {
1728 sheet: None,
1729 range: None,
1730 whole_sheet: false,
1731 description: None,
1732 warning_only: true,
1733 editors: Vec::new(),
1734 };
1735 assert_eq!(describe_effect(&warn_only), "protect (warn only)");
1736 }
1737
1738 #[test]
1739 fn describe_effect_update_protection_reports_editor_deltas() {
1740 assert_eq!(describe_effect(&update_verb()), "update protection");
1741
1742 let both = ProtectionVerb::UpdateProtection {
1743 sheet: None,
1744 range: None,
1745 whole_sheet: false,
1746 description: None,
1747 warning_only: None,
1748 add_editors: vec!["a@example.com".to_string()],
1749 remove_editors: vec!["b@example.com".to_string()],
1750 };
1751 assert_eq!(
1752 describe_effect(&both),
1753 "update protection (+a@example.com -b@example.com)"
1754 );
1755 }
1756
1757 #[test]
1758 fn describe_effect_update_protection_reports_description_and_warning_only() {
1759 let description_only = ProtectionVerb::UpdateProtection {
1762 sheet: None,
1763 range: None,
1764 whole_sheet: false,
1765 description: Some("note".to_string()),
1766 warning_only: None,
1767 add_editors: Vec::new(),
1768 remove_editors: Vec::new(),
1769 };
1770 assert_eq!(
1771 describe_effect(&description_only),
1772 "update protection (description: 'note')"
1773 );
1774
1775 let warning_only_change = ProtectionVerb::UpdateProtection {
1776 sheet: None,
1777 range: None,
1778 whole_sheet: false,
1779 description: None,
1780 warning_only: Some(true),
1781 add_editors: Vec::new(),
1782 remove_editors: Vec::new(),
1783 };
1784 assert_eq!(
1785 describe_effect(&warning_only_change),
1786 "update protection (warning-only: true)"
1787 );
1788
1789 let everything = ProtectionVerb::UpdateProtection {
1790 sheet: None,
1791 range: None,
1792 whole_sheet: false,
1793 description: Some("note".to_string()),
1794 warning_only: Some(false),
1795 add_editors: vec!["a@example.com".to_string()],
1796 remove_editors: vec!["b@example.com".to_string()],
1797 };
1798 assert_eq!(
1799 describe_effect(&everything),
1800 "update protection (description: 'note' warning-only: false \
1801 +a@example.com -b@example.com)"
1802 );
1803 }
1804
1805 fn outcome_with(
1806 verb: ProtectionVerb,
1807 file_name: Option<&str>,
1808 result: ProtectionResult,
1809 ) -> ProtectionOutcome {
1810 ProtectionOutcome {
1811 spreadsheet_id: "sheet-1".to_string(),
1812 file_name: file_name.map(str::to_string),
1813 resolved_folder_id: None,
1814 verb,
1815 result,
1816 }
1817 }
1818
1819 #[test]
1820 fn describe_lines_renders_would_change() {
1821 let out = outcome_with(
1822 protect_verb(),
1823 Some("Budget"),
1824 ProtectionResult::WouldChange {
1825 summary: "protect (block edits)".to_string(),
1826 },
1827 );
1828 assert_eq!(describe(&out), "Would protect (block edits) in 'Budget'");
1829 }
1830
1831 #[test]
1832 fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
1833 let out = outcome_with(
1834 protect_verb(),
1835 None,
1836 ProtectionResult::RefusedNotASpreadsheet {
1837 mime_type: "text/plain".to_string(),
1838 },
1839 );
1840 let text = describe(&out);
1841 assert!(text.contains("'sheet-1'"), "{text}");
1842 assert!(text.contains("protect-range"), "{text}");
1843 assert!(text.contains("text/plain"), "{text}");
1844 }
1845
1846 #[test]
1847 fn describe_lines_renders_shortcut() {
1848 let out = outcome_with(
1849 unprotect_verb(),
1850 Some("Budget"),
1851 ProtectionResult::RefusedShortcut,
1852 );
1853 let text = describe(&out);
1854 assert!(text.contains("shortcut"), "{text}");
1855 assert!(text.contains("unprotect-range"), "{text}");
1856 }
1857
1858 #[test]
1859 fn describe_lines_renders_no_visible_parents() {
1860 let out = outcome_with(
1861 protect_verb(),
1862 Some("Budget"),
1863 ProtectionResult::RefusedNoVisibleParents,
1864 );
1865 assert!(describe(&out).contains("sheets-protection"));
1866 }
1867
1868 #[test]
1869 fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
1870 let none = outcome_with(
1871 protect_verb(),
1872 Some("Budget"),
1873 ProtectionResult::RefusedSheetNotFound {
1874 title: "Q2".to_string(),
1875 available: Vec::new(),
1876 },
1877 );
1878 assert!(describe(&none).contains("Available: none"));
1879
1880 let some = outcome_with(
1881 protect_verb(),
1882 Some("Budget"),
1883 ProtectionResult::RefusedSheetNotFound {
1884 title: "Q2".to_string(),
1885 available: vec!["Q1".to_string(), "Q3".to_string()],
1886 },
1887 );
1888 let text = describe(&some);
1889 assert!(text.contains("'Q1', 'Q3'"), "{text}");
1890 }
1891
1892 #[test]
1893 fn describe_lines_renders_invalid_range_and_protection_not_found() {
1894 let invalid = outcome_with(
1895 protect_verb(),
1896 Some("Budget"),
1897 ProtectionResult::RefusedInvalidRange {
1898 detail: "bad range".to_string(),
1899 },
1900 );
1901 assert_eq!(describe(&invalid), "Refused: bad range");
1902
1903 let not_found = outcome_with(
1904 update_verb(),
1905 Some("Budget"),
1906 ProtectionResult::RefusedProtectionNotFound {
1907 detail: "no match".to_string(),
1908 },
1909 );
1910 assert_eq!(describe(¬_found), "Refused: no match");
1911 }
1912
1913 #[test]
1914 fn describe_lines_renders_ambiguous_protection() {
1915 let out = outcome_with(
1916 update_verb(),
1917 Some("Budget"),
1918 ProtectionResult::RefusedAmbiguousProtection {
1919 candidates: vec![10, 11],
1920 },
1921 );
1922 assert!(describe(&out).contains("ids: 10, 11"));
1923 }
1924
1925 #[test]
1926 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
1927 let folder_rule = outcome_with(
1928 update_verb(),
1929 Some("Budget"),
1930 ProtectionResult::Blocked {
1931 decided_by: Some(DecidingRule::Folder {
1932 folder_id: "folder-1".to_string(),
1933 depth: 2,
1934 }),
1935 },
1936 );
1937 let text = describe(&folder_rule);
1938 assert!(text.contains("update-protection"), "{text}");
1939 assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
1940
1941 let default_policy = outcome_with(
1942 unprotect_verb(),
1943 Some("Budget"),
1944 ProtectionResult::Blocked { decided_by: None },
1945 );
1946 assert!(describe(&default_policy).contains("default policy"));
1947 }
1948
1949 #[test]
1950 fn describe_lines_renders_changed_with_and_without_an_id() {
1951 let with_id = outcome_with(
1952 protect_verb(),
1953 Some("Budget"),
1954 ProtectionResult::Changed {
1955 summary: "protect (block edits)".to_string(),
1956 protected_range_id: Some(7),
1957 },
1958 );
1959 assert!(describe(&with_id).contains("(id 7)"));
1960
1961 let without_id = outcome_with(
1962 unprotect_verb(),
1963 Some("Budget"),
1964 ProtectionResult::Changed {
1965 summary: "remove protection".to_string(),
1966 protected_range_id: None,
1967 },
1968 );
1969 assert!(!describe(&without_id).contains("(id"));
1970 }
1971
1972 #[test]
1973 fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_protects() {
1974 let protected = outcome_with(
1975 protect_verb(),
1976 Some("Budget"),
1977 ProtectionResult::AppliedReplyUnreadable {
1978 summary: "protect (block edits)".to_string(),
1979 protected_range_id: None,
1980 detail: "bad reply".to_string(),
1981 },
1982 );
1983 assert_eq!(
1984 describe_lines(&protected),
1985 vec![
1986 "Applied, but the reply could not be read: protect (block edits) in 'Budget' — \
1987 do not retry; check the spreadsheet first (bad reply)"
1988 .to_string(),
1989 " the new protection's id was in the unreadable reply; run \
1990 `list-protections` to find it"
1991 .to_string(),
1992 ]
1993 );
1994
1995 let removed = outcome_with(
1996 unprotect_verb(),
1997 Some("Budget"),
1998 ProtectionResult::AppliedReplyUnreadable {
1999 summary: "remove protection".to_string(),
2000 protected_range_id: Some(7),
2001 detail: "bad reply".to_string(),
2002 },
2003 );
2004 assert_eq!(
2006 describe_lines(&removed),
2007 vec![
2008 "Applied, but the reply could not be read: remove protection (id 7) in \
2009 'Budget' — do not retry; check the spreadsheet first (bad reply)"
2010 .to_string()
2011 ]
2012 );
2013 }
2014
2015 #[test]
2018 fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
2019 let verb = ProtectionVerb::UpdateProtection {
2020 sheet: None,
2021 range: None,
2022 whole_sheet: false,
2023 description: None,
2024 warning_only: None,
2025 add_editors: vec!["a@example.com".to_string()],
2026 remove_editors: vec!["b@example.com".to_string()],
2027 };
2028 let opts = ProtectionOptions {
2029 spreadsheet_id: "sheet-1".to_string(),
2030 verb: verb.clone(),
2031 dry_run: false,
2032 lease_token: None,
2033 ledger_path: std::path::PathBuf::new(),
2034 };
2035 let record = |result| {
2036 let out = outcome_with(verb.clone(), Some("Budget"), result);
2037 mutation_record(&out, &opts, Duration::ZERO)
2038 };
2039 let changed = record(ProtectionResult::Changed {
2040 summary: "update protection".to_string(),
2041 protected_range_id: Some(7),
2042 });
2043 let unreadable = record(ProtectionResult::AppliedReplyUnreadable {
2044 summary: "update protection".to_string(),
2045 protected_range_id: Some(7),
2046 detail: "bad reply".to_string(),
2047 });
2048 assert_eq!(unreadable.protected_range_id, changed.protected_range_id);
2049 assert_eq!(
2050 unreadable.protection_editors_added,
2051 vec!["a@example.com".to_string()]
2052 );
2053 assert_eq!(
2054 unreadable.protection_editors_removed,
2055 vec!["b@example.com".to_string()]
2056 );
2057 assert_eq!(unreadable.status, "applied-reply-unreadable");
2058 assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
2059 assert_eq!(
2060 unreadable.fields_changed.as_deref(),
2061 Some("update protection")
2062 );
2063 }
2064
2065 #[test]
2066 fn describe_lines_renders_failed() {
2067 let out = outcome_with(
2068 protect_verb(),
2069 Some("Budget"),
2070 ProtectionResult::Failed {
2071 detail: "boom".to_string(),
2072 },
2073 );
2074 assert_eq!(describe(&out), "Failed: boom");
2075 }
2076
2077 #[test]
2078 fn describe_lines_renders_every_lease_refusal_with_the_lease_acquire_hint() {
2079 let no_lease = outcome_with(
2080 protect_verb(),
2081 Some("Budget"),
2082 ProtectionResult::RefusedNoLease,
2083 );
2084 let text = describe(&no_lease);
2085 assert!(text.contains("requires a Drive write lease"), "{text}");
2086 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
2087
2088 let expired = outcome_with(
2089 protect_verb(),
2090 Some("Budget"),
2091 ProtectionResult::RefusedLeaseExpired,
2092 );
2093 let text = describe(&expired);
2094 assert!(text.contains("expired, released, or unknown"), "{text}");
2095 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
2096
2097 let wrong_file = outcome_with(
2098 protect_verb(),
2099 Some("Budget"),
2100 ProtectionResult::RefusedLeaseWrongFile,
2101 );
2102 let text = describe(&wrong_file);
2103 assert!(text.contains("acquired for a different file"), "{text}");
2104 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
2105
2106 let stale = outcome_with(
2107 protect_verb(),
2108 Some("Budget"),
2109 ProtectionResult::RefusedLeaseStale,
2110 );
2111 let text = describe(&stale);
2112 assert!(
2113 text.contains("changed since the lease was acquired"),
2114 "{text}"
2115 );
2116 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
2117 }
2118
2119 #[tokio::test]
2120 async fn a_metadata_fetch_failure_surfaces_as_failed() {
2121 let server = wiremock::MockServer::start().await;
2122 let (drive, sheets) = clients(&server).await;
2123 wiremock::Mock::given(wiremock::matchers::method("GET"))
2124 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2125 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2126 .mount(&server)
2127 .await;
2128 let rules: Vec<FolderPermissionRule> = Vec::new();
2129 let opts = ProtectionOptions {
2130 spreadsheet_id: "sheet-1".to_string(),
2131 verb: ProtectionVerb::ProtectRange {
2132 sheet: Some("Q1".to_string()),
2133 range: Some("A1:A5".to_string()),
2134 whole_sheet: false,
2135 description: None,
2136 warning_only: false,
2137 editors: Vec::new(),
2138 },
2139 dry_run: false,
2140 lease_token: None,
2141 ledger_path: std::path::PathBuf::new(),
2142 };
2143 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2144 assert!(matches!(outcome.result, ProtectionResult::Failed { .. }));
2145 }
2146
2147 #[tokio::test]
2148 async fn a_shortcut_target_is_refused() {
2149 let server = wiremock::MockServer::start().await;
2150 let (drive, sheets) = clients(&server).await;
2151 mount_file(
2152 "sheet-1",
2153 "application/vnd.google-apps.shortcut",
2154 &["folder-1"],
2155 )
2156 .mount(&server)
2157 .await;
2158 let rules = vec![allow_rule("folder-1")];
2159 let opts = ProtectionOptions {
2160 spreadsheet_id: "sheet-1".to_string(),
2161 verb: ProtectionVerb::ProtectRange {
2162 sheet: Some("Q1".to_string()),
2163 range: Some("A1:A5".to_string()),
2164 whole_sheet: false,
2165 description: None,
2166 warning_only: false,
2167 editors: Vec::new(),
2168 },
2169 dry_run: false,
2170 lease_token: None,
2171 ledger_path: std::path::PathBuf::new(),
2172 };
2173 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2174 assert!(matches!(outcome.result, ProtectionResult::RefusedShortcut));
2175 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2176 }
2177
2178 #[tokio::test]
2179 async fn a_non_spreadsheet_target_is_refused() {
2180 let server = wiremock::MockServer::start().await;
2181 let (drive, sheets) = clients(&server).await;
2182 mount_file(
2183 "sheet-1",
2184 "application/vnd.google-apps.document",
2185 &["folder-1"],
2186 )
2187 .mount(&server)
2188 .await;
2189 let rules = vec![allow_rule("folder-1")];
2190 let opts = ProtectionOptions {
2191 spreadsheet_id: "sheet-1".to_string(),
2192 verb: ProtectionVerb::ProtectRange {
2193 sheet: Some("Q1".to_string()),
2194 range: Some("A1:A5".to_string()),
2195 whole_sheet: false,
2196 description: None,
2197 warning_only: false,
2198 editors: Vec::new(),
2199 },
2200 dry_run: false,
2201 lease_token: None,
2202 ledger_path: std::path::PathBuf::new(),
2203 };
2204 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2205 assert!(matches!(
2206 outcome.result,
2207 ProtectionResult::RefusedNotASpreadsheet { .. }
2208 ));
2209 }
2210
2211 #[tokio::test]
2212 async fn a_target_with_no_visible_parents_is_refused() {
2213 let server = wiremock::MockServer::start().await;
2214 let (drive, sheets) = clients(&server).await;
2215 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2216 .mount(&server)
2217 .await;
2218 let rules: Vec<FolderPermissionRule> = Vec::new();
2219 let opts = ProtectionOptions {
2220 spreadsheet_id: "sheet-1".to_string(),
2221 verb: ProtectionVerb::ProtectRange {
2222 sheet: Some("Q1".to_string()),
2223 range: Some("A1:A5".to_string()),
2224 whole_sheet: false,
2225 description: None,
2226 warning_only: false,
2227 editors: Vec::new(),
2228 },
2229 dry_run: false,
2230 lease_token: None,
2231 ledger_path: std::path::PathBuf::new(),
2232 };
2233 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2234 assert!(matches!(
2235 outcome.result,
2236 ProtectionResult::RefusedNoVisibleParents
2237 ));
2238 }
2239
2240 #[tokio::test]
2241 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
2242 let server = wiremock::MockServer::start().await;
2243 let (drive, sheets) = clients(&server).await;
2244 mount_file(
2245 "sheet-1",
2246 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2247 &["folder-1"],
2248 )
2249 .mount(&server)
2250 .await;
2251 wiremock::Mock::given(wiremock::matchers::method("GET"))
2252 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2253 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2254 .mount(&server)
2255 .await;
2256 let rules = vec![allow_rule("folder-1")];
2257 let opts = ProtectionOptions {
2258 spreadsheet_id: "sheet-1".to_string(),
2259 verb: ProtectionVerb::ProtectRange {
2260 sheet: Some("Q1".to_string()),
2261 range: Some("A1:A5".to_string()),
2262 whole_sheet: false,
2263 description: None,
2264 warning_only: false,
2265 editors: Vec::new(),
2266 },
2267 dry_run: false,
2268 lease_token: None,
2269 ledger_path: std::path::PathBuf::new(),
2270 };
2271 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2272 assert!(matches!(outcome.result, ProtectionResult::Failed { .. }));
2273 }
2274
2275 #[tokio::test]
2276 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
2277 let server = wiremock::MockServer::start().await;
2278 let (drive, sheets) = clients(&server).await;
2279 mount_file(
2280 "sheet-1",
2281 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2282 &["folder-1"],
2283 )
2284 .mount(&server)
2285 .await;
2286 mount_folder("folder-1").mount(&server).await;
2287 wiremock::Mock::given(wiremock::matchers::method("GET"))
2288 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2289 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2290 .mount(&server)
2291 .await;
2292 let rules = vec![allow_rule("folder-1")];
2293 let opts = ProtectionOptions {
2294 spreadsheet_id: "sheet-1".to_string(),
2295 verb: ProtectionVerb::ProtectRange {
2296 sheet: Some("Q1".to_string()),
2297 range: Some("A1:A5".to_string()),
2298 whole_sheet: false,
2299 description: None,
2300 warning_only: false,
2301 editors: Vec::new(),
2302 },
2303 dry_run: false,
2304 lease_token: None,
2305 ledger_path: std::path::PathBuf::new(),
2306 };
2307 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2308 assert!(matches!(outcome.result, ProtectionResult::Failed { .. }));
2309 }
2310
2311 #[tokio::test]
2312 async fn protect_range_rejects_whole_sheet_and_range_together() {
2313 let server = wiremock::MockServer::start().await;
2314 let (drive, sheets) = clients(&server).await;
2315 let rules: Vec<FolderPermissionRule> = Vec::new();
2316 let opts = ProtectionOptions {
2317 spreadsheet_id: "sheet-1".to_string(),
2318 verb: ProtectionVerb::ProtectRange {
2319 sheet: Some("Q1".to_string()),
2320 range: Some("A1:A5".to_string()),
2321 whole_sheet: true,
2322 description: None,
2323 warning_only: false,
2324 editors: Vec::new(),
2325 },
2326 dry_run: false,
2327 lease_token: None,
2328 ledger_path: std::path::PathBuf::new(),
2329 };
2330 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2331 match outcome.result {
2332 ProtectionResult::RefusedInvalidRange { detail } => {
2333 assert!(detail.contains("mutually exclusive"), "{detail}");
2334 }
2335 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2336 }
2337 }
2338
2339 #[tokio::test]
2340 async fn protect_range_rejects_warning_only_with_editors() {
2341 let server = wiremock::MockServer::start().await;
2342 let (drive, sheets) = clients(&server).await;
2343 let rules: Vec<FolderPermissionRule> = Vec::new();
2344 let opts = ProtectionOptions {
2345 spreadsheet_id: "sheet-1".to_string(),
2346 verb: ProtectionVerb::ProtectRange {
2347 sheet: Some("Q1".to_string()),
2348 range: Some("A1:A5".to_string()),
2349 whole_sheet: false,
2350 description: None,
2351 warning_only: true,
2352 editors: vec!["a@example.com".to_string()],
2353 },
2354 dry_run: false,
2355 lease_token: None,
2356 ledger_path: std::path::PathBuf::new(),
2357 };
2358 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2359 match outcome.result {
2360 ProtectionResult::RefusedInvalidRange { detail } => {
2361 assert!(detail.contains("mutually exclusive"), "{detail}");
2362 }
2363 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2364 }
2365 }
2366
2367 #[tokio::test]
2368 async fn protect_range_refuses_an_unknown_sheet() {
2369 let server = wiremock::MockServer::start().await;
2370 let (drive, sheets) = clients(&server).await;
2371 mount_file(
2372 "sheet-1",
2373 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2374 &["folder-1"],
2375 )
2376 .mount(&server)
2377 .await;
2378 mount_folder("folder-1").mount(&server).await;
2379 mount_workbook(serde_json::json!([])).mount(&server).await;
2380 let rules = vec![allow_rule("folder-1")];
2381 let opts = ProtectionOptions {
2382 spreadsheet_id: "sheet-1".to_string(),
2383 verb: ProtectionVerb::ProtectRange {
2384 sheet: Some("Missing".to_string()),
2385 range: Some("A1:A5".to_string()),
2386 whole_sheet: false,
2387 description: None,
2388 warning_only: false,
2389 editors: Vec::new(),
2390 },
2391 dry_run: false,
2392 lease_token: None,
2393 ledger_path: std::path::PathBuf::new(),
2394 };
2395 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2396 assert!(matches!(
2397 outcome.result,
2398 ProtectionResult::RefusedSheetNotFound { .. }
2399 ));
2400 }
2401
2402 #[tokio::test]
2403 async fn a_dry_run_reports_would_change_without_calling_batch_update() {
2404 let server = wiremock::MockServer::start().await;
2405 let (drive, sheets) = clients(&server).await;
2406 mount_file(
2407 "sheet-1",
2408 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2409 &["folder-1"],
2410 )
2411 .mount(&server)
2412 .await;
2413 mount_folder("folder-1").mount(&server).await;
2414 mount_workbook(serde_json::json!([])).mount(&server).await;
2415 let rules = vec![allow_rule("folder-1")];
2417 let opts = ProtectionOptions {
2418 spreadsheet_id: "sheet-1".to_string(),
2419 verb: ProtectionVerb::ProtectRange {
2420 sheet: Some("Q1".to_string()),
2421 range: Some("A1:A5".to_string()),
2422 whole_sheet: false,
2423 description: None,
2424 warning_only: false,
2425 editors: Vec::new(),
2426 },
2427 dry_run: true,
2428 lease_token: None,
2429 ledger_path: std::path::PathBuf::new(),
2430 };
2431 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2432 assert!(matches!(
2433 outcome.result,
2434 ProtectionResult::WouldChange { .. }
2435 ));
2436 }
2437
2438 #[tokio::test]
2439 async fn protect_range_with_editors_sends_an_editor_list() {
2440 let server = wiremock::MockServer::start().await;
2441 let (drive, sheets) = clients(&server).await;
2442 mount_file(
2443 "sheet-1",
2444 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2445 &["folder-1"],
2446 )
2447 .mount(&server)
2448 .await;
2449 mount_folder("folder-1").mount(&server).await;
2450 mount_workbook(serde_json::json!([])).mount(&server).await;
2451 wiremock::Mock::given(wiremock::matchers::method("POST"))
2452 .and(wiremock::matchers::path(
2453 "/v4/spreadsheets/sheet-1:batchUpdate",
2454 ))
2455 .respond_with(
2456 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2457 "replies": [{"addProtectedRange": {"protectedRange": {"protectedRangeId": 5}}}]
2458 })),
2459 )
2460 .mount(&server)
2461 .await;
2462 let rules = vec![allow_rule("folder-1")];
2463 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2464 let opts = ProtectionOptions {
2465 spreadsheet_id: "sheet-1".to_string(),
2466 verb: ProtectionVerb::ProtectRange {
2467 sheet: Some("Q1".to_string()),
2468 range: Some("A1:A5".to_string()),
2469 whole_sheet: false,
2470 description: None,
2471 warning_only: false,
2472 editors: vec!["a@example.com".to_string()],
2473 },
2474 dry_run: false,
2475 lease_token,
2476 ledger_path,
2477 };
2478 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2479 assert!(matches!(outcome.result, ProtectionResult::Changed { .. }));
2480 }
2481
2482 #[tokio::test]
2483 async fn update_protection_changes_description_and_editors() {
2484 let server = wiremock::MockServer::start().await;
2485 let (drive, sheets) = clients(&server).await;
2486 mount_file(
2487 "sheet-1",
2488 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2489 &["folder-1"],
2490 )
2491 .mount(&server)
2492 .await;
2493 mount_folder("folder-1").mount(&server).await;
2494 mount_workbook(serde_json::json!([{
2495 "protectedRangeId": 20,
2496 "range": {
2497 "sheetId": 0,
2498 "startRowIndex": 0,
2499 "endRowIndex": 5,
2500 "startColumnIndex": 0,
2501 "endColumnIndex": 1,
2502 },
2503 "editors": {"users": ["a@example.com"]},
2504 }]))
2505 .mount(&server)
2506 .await;
2507 wiremock::Mock::given(wiremock::matchers::method("POST"))
2508 .and(wiremock::matchers::path(
2509 "/v4/spreadsheets/sheet-1:batchUpdate",
2510 ))
2511 .respond_with(
2512 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2513 "replies": [{}]
2514 })),
2515 )
2516 .mount(&server)
2517 .await;
2518 let rules = vec![allow_rule("folder-1")];
2519 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2520 let opts = ProtectionOptions {
2521 spreadsheet_id: "sheet-1".to_string(),
2522 verb: ProtectionVerb::UpdateProtection {
2523 sheet: Some("Q1".to_string()),
2524 range: Some("A1:A5".to_string()),
2525 whole_sheet: false,
2526 description: Some("new note".to_string()),
2527 warning_only: None,
2528 add_editors: vec!["b@example.com".to_string()],
2529 remove_editors: vec!["a@example.com".to_string()],
2530 },
2531 dry_run: false,
2532 lease_token,
2533 ledger_path,
2534 };
2535 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2536 match outcome.result {
2537 ProtectionResult::Changed {
2538 protected_range_id, ..
2539 } => assert_eq!(protected_range_id, Some(20)),
2540 other => panic!("expected Changed, got {other:?}"),
2541 }
2542 }
2543
2544 #[tokio::test]
2545 async fn update_protection_refuses_a_result_that_is_warning_only_with_editors() {
2546 let server = wiremock::MockServer::start().await;
2547 let (drive, sheets) = clients(&server).await;
2548 mount_file(
2549 "sheet-1",
2550 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2551 &["folder-1"],
2552 )
2553 .mount(&server)
2554 .await;
2555 mount_folder("folder-1").mount(&server).await;
2556 mount_workbook(serde_json::json!([{
2557 "protectedRangeId": 30,
2558 "range": {
2559 "sheetId": 0,
2560 "startRowIndex": 0,
2561 "endRowIndex": 5,
2562 "startColumnIndex": 0,
2563 "endColumnIndex": 1,
2564 },
2565 "warningOnly": true,
2566 }]))
2567 .mount(&server)
2568 .await;
2569 let rules = vec![allow_rule("folder-1")];
2572 let opts = ProtectionOptions {
2573 spreadsheet_id: "sheet-1".to_string(),
2574 verb: ProtectionVerb::UpdateProtection {
2575 sheet: Some("Q1".to_string()),
2576 range: Some("A1:A5".to_string()),
2577 whole_sheet: false,
2578 description: None,
2579 warning_only: None,
2580 add_editors: vec!["a@example.com".to_string()],
2581 remove_editors: Vec::new(),
2582 },
2583 dry_run: false,
2584 lease_token: None,
2585 ledger_path: std::path::PathBuf::new(),
2586 };
2587 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2588 match outcome.result {
2589 ProtectionResult::RefusedInvalidRange { detail } => {
2590 assert!(detail.contains("warning-only"), "{detail}");
2591 }
2592 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2593 }
2594 }
2595
2596 #[tokio::test]
2597 async fn update_protection_dry_run_refuses_the_same_warning_only_result_a_real_run_would() {
2598 let server = wiremock::MockServer::start().await;
2604 let (drive, sheets) = clients(&server).await;
2605 mount_file(
2606 "sheet-1",
2607 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2608 &["folder-1"],
2609 )
2610 .mount(&server)
2611 .await;
2612 mount_folder("folder-1").mount(&server).await;
2613 mount_workbook(serde_json::json!([{
2614 "protectedRangeId": 30,
2615 "range": {
2616 "sheetId": 0,
2617 "startRowIndex": 0,
2618 "endRowIndex": 5,
2619 "startColumnIndex": 0,
2620 "endColumnIndex": 1,
2621 },
2622 "editors": {"users": ["a@example.com"]},
2623 }]))
2624 .mount(&server)
2625 .await;
2626 let rules = vec![allow_rule("folder-1")];
2627 let opts = ProtectionOptions {
2628 spreadsheet_id: "sheet-1".to_string(),
2629 verb: ProtectionVerb::UpdateProtection {
2630 sheet: Some("Q1".to_string()),
2631 range: Some("A1:A5".to_string()),
2632 whole_sheet: false,
2633 description: None,
2634 warning_only: Some(true),
2635 add_editors: Vec::new(),
2636 remove_editors: Vec::new(),
2637 },
2638 dry_run: true,
2639 lease_token: None,
2640 ledger_path: std::path::PathBuf::new(),
2641 };
2642 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2643 match outcome.result {
2644 ProtectionResult::RefusedInvalidRange { detail } => {
2645 assert!(detail.contains("warning-only"), "{detail}");
2646 }
2647 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2648 }
2649 }
2650
2651 #[tokio::test]
2652 async fn protect_range_dry_run_summary_matches_the_real_run_summary() {
2653 let server = wiremock::MockServer::start().await;
2662 let (drive, sheets) = clients(&server).await;
2663 mount_file(
2664 "sheet-1",
2665 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2666 &["folder-1"],
2667 )
2668 .mount(&server)
2669 .await;
2670 mount_folder("folder-1").mount(&server).await;
2671 mount_workbook(serde_json::json!([])).mount(&server).await;
2672 let rules = vec![allow_rule("folder-1")];
2673 let verb = ProtectionVerb::ProtectRange {
2674 sheet: Some("Q1".to_string()),
2675 range: Some("A1:A5".to_string()),
2676 whole_sheet: false,
2677 description: Some("locked".to_string()),
2678 warning_only: false,
2679 editors: Vec::new(),
2680 };
2681
2682 let dry_run_opts = ProtectionOptions {
2683 spreadsheet_id: "sheet-1".to_string(),
2684 verb: verb.clone(),
2685 dry_run: true,
2686 lease_token: None,
2687 ledger_path: std::path::PathBuf::new(),
2688 };
2689 let dry_run_outcome = protection(&drive, &sheets, &dry_run_opts, &rules).await;
2690 let dry_run_summary = match dry_run_outcome.result {
2691 ProtectionResult::WouldChange { summary } => summary,
2692 other => panic!("expected WouldChange, got {other:?}"),
2693 };
2694
2695 wiremock::Mock::given(wiremock::matchers::method("POST"))
2698 .and(wiremock::matchers::path(
2699 "/v4/spreadsheets/sheet-1:batchUpdate",
2700 ))
2701 .respond_with(
2702 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2703 "replies": [{"addProtectedRange": {"protectedRange": {"protectedRangeId": 7}}}]
2704 })),
2705 )
2706 .mount(&server)
2707 .await;
2708 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2709 let real_run_opts = ProtectionOptions {
2710 spreadsheet_id: "sheet-1".to_string(),
2711 verb,
2712 dry_run: false,
2713 lease_token,
2714 ledger_path,
2715 };
2716 let real_run_outcome = protection(&drive, &sheets, &real_run_opts, &rules).await;
2717 let real_run_summary = match real_run_outcome.result {
2718 ProtectionResult::Changed { summary, .. } => summary,
2719 other => panic!("expected Changed, got {other:?}"),
2720 };
2721
2722 assert_eq!(dry_run_summary, real_run_summary);
2723 }
2724
2725 #[tokio::test]
2726 async fn unprotect_range_dry_run_summary_matches_the_real_run_summary() {
2727 let server = wiremock::MockServer::start().await;
2733 let (drive, sheets) = clients(&server).await;
2734 mount_file(
2735 "sheet-1",
2736 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2737 &["folder-1"],
2738 )
2739 .mount(&server)
2740 .await;
2741 mount_folder("folder-1").mount(&server).await;
2742 mount_workbook(serde_json::json!([{
2743 "protectedRangeId": 30,
2744 "range": {
2745 "sheetId": 0,
2746 "startRowIndex": 0,
2747 "endRowIndex": 5,
2748 "startColumnIndex": 0,
2749 "endColumnIndex": 1,
2750 },
2751 }]))
2752 .mount(&server)
2753 .await;
2754 let rules = vec![allow_rule("folder-1")];
2755 let verb = ProtectionVerb::UnprotectRange {
2756 sheet: Some("Q1".to_string()),
2757 range: Some("A1:A5".to_string()),
2758 whole_sheet: false,
2759 };
2760
2761 let dry_run_opts = ProtectionOptions {
2762 spreadsheet_id: "sheet-1".to_string(),
2763 verb: verb.clone(),
2764 dry_run: true,
2765 lease_token: None,
2766 ledger_path: std::path::PathBuf::new(),
2767 };
2768 let dry_run_outcome = protection(&drive, &sheets, &dry_run_opts, &rules).await;
2769 let dry_run_summary = match dry_run_outcome.result {
2770 ProtectionResult::WouldChange { summary } => summary,
2771 other => panic!("expected WouldChange, got {other:?}"),
2772 };
2773
2774 wiremock::Mock::given(wiremock::matchers::method("POST"))
2777 .and(wiremock::matchers::path(
2778 "/v4/spreadsheets/sheet-1:batchUpdate",
2779 ))
2780 .respond_with(
2781 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2782 "replies": [{}]
2783 })),
2784 )
2785 .mount(&server)
2786 .await;
2787 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2788 let real_run_opts = ProtectionOptions {
2789 spreadsheet_id: "sheet-1".to_string(),
2790 verb,
2791 dry_run: false,
2792 lease_token,
2793 ledger_path,
2794 };
2795 let real_run_outcome = protection(&drive, &sheets, &real_run_opts, &rules).await;
2796 let real_run_summary = match real_run_outcome.result {
2797 ProtectionResult::Changed { summary, .. } => summary,
2798 other => panic!("expected Changed, got {other:?}"),
2799 };
2800
2801 assert_eq!(dry_run_summary, real_run_summary);
2802 }
2803
2804 #[tokio::test]
2805 async fn a_batch_update_failure_surfaces_as_failed() {
2806 let server = wiremock::MockServer::start().await;
2807 let (drive, sheets) = clients(&server).await;
2808 mount_file(
2809 "sheet-1",
2810 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2811 &["folder-1"],
2812 )
2813 .mount(&server)
2814 .await;
2815 mount_folder("folder-1").mount(&server).await;
2816 mount_workbook(serde_json::json!([])).mount(&server).await;
2817 wiremock::Mock::given(wiremock::matchers::method("POST"))
2818 .and(wiremock::matchers::path(
2819 "/v4/spreadsheets/sheet-1:batchUpdate",
2820 ))
2821 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2822 .mount(&server)
2823 .await;
2824 let rules = vec![allow_rule("folder-1")];
2825 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2826 let opts = ProtectionOptions {
2827 spreadsheet_id: "sheet-1".to_string(),
2828 verb: ProtectionVerb::ProtectRange {
2829 sheet: Some("Q1".to_string()),
2830 range: Some("A1:A5".to_string()),
2831 whole_sheet: false,
2832 description: None,
2833 warning_only: false,
2834 editors: Vec::new(),
2835 },
2836 dry_run: false,
2837 lease_token,
2838 ledger_path,
2839 };
2840 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2841 assert!(matches!(outcome.result, ProtectionResult::Failed { .. }));
2842 }
2843
2844 #[tokio::test]
2847 async fn refuses_without_a_lease_when_the_rule_requires_one() {
2848 let server = wiremock::MockServer::start().await;
2849 let (drive, sheets) = clients(&server).await;
2850 mount_file(
2851 "sheet-1",
2852 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2853 &["folder-1"],
2854 )
2855 .mount(&server)
2856 .await;
2857 mount_folder("folder-1").mount(&server).await;
2858 mount_workbook(serde_json::json!([])).mount(&server).await;
2859 let rules = vec![allow_rule("folder-1")];
2862
2863 let opts = ProtectionOptions {
2864 spreadsheet_id: "sheet-1".to_string(),
2865 verb: ProtectionVerb::ProtectRange {
2866 sheet: Some("Q1".to_string()),
2867 range: Some("A1:A5".to_string()),
2868 whole_sheet: false,
2869 description: None,
2870 warning_only: false,
2871 editors: Vec::new(),
2872 },
2873 dry_run: false,
2874 lease_token: None,
2875 ledger_path: std::path::PathBuf::new(),
2876 };
2877 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2878 assert!(matches!(outcome.result, ProtectionResult::RefusedNoLease));
2879 assert_eq!(outcome.result.log_status(), "refused-no-lease");
2880 }
2881
2882 #[tokio::test]
2883 async fn reports_a_lock_acquisition_failure_as_failed() {
2884 let server = wiremock::MockServer::start().await;
2888 let (drive, sheets) = clients(&server).await;
2889 mount_file(
2890 "sheet-1",
2891 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2892 &["folder-1"],
2893 )
2894 .mount(&server)
2895 .await;
2896 mount_folder("folder-1").mount(&server).await;
2897 mount_workbook(serde_json::json!([])).mount(&server).await;
2898 let rules = vec![allow_rule("folder-1")];
2899 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2900 let mut lock_path = ledger_path.clone().into_os_string();
2906 lock_path.push(".lock");
2907 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
2908
2909 let opts = ProtectionOptions {
2910 spreadsheet_id: "sheet-1".to_string(),
2911 verb: ProtectionVerb::ProtectRange {
2912 sheet: Some("Q1".to_string()),
2913 range: Some("A1:A5".to_string()),
2914 whole_sheet: false,
2915 description: None,
2916 warning_only: false,
2917 editors: Vec::new(),
2918 },
2919 dry_run: false,
2920 lease_token,
2921 ledger_path,
2922 };
2923 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2924 assert!(matches!(outcome.result, ProtectionResult::Failed { .. }));
2925 }
2926
2927 #[tokio::test]
2928 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
2929 let server = wiremock::MockServer::start().await;
2934 let (drive, sheets) = clients(&server).await;
2935 mount_file(
2936 "sheet-1",
2937 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2938 &["folder-1"],
2939 )
2940 .up_to_n_times(1)
2941 .with_priority(1)
2942 .mount(&server)
2943 .await;
2944 wiremock::Mock::given(wiremock::matchers::method("GET"))
2945 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2946 .respond_with(wiremock::ResponseTemplate::new(500))
2947 .with_priority(2)
2948 .mount(&server)
2949 .await;
2950 mount_folder("folder-1").mount(&server).await;
2951 mount_workbook(serde_json::json!([])).mount(&server).await;
2952 wiremock::Mock::given(wiremock::matchers::method("POST"))
2953 .and(wiremock::matchers::path(
2954 "/v4/spreadsheets/sheet-1:batchUpdate",
2955 ))
2956 .respond_with(wiremock::ResponseTemplate::new(200))
2957 .expect(0)
2958 .mount(&server)
2959 .await;
2960 let rules = vec![allow_rule("folder-1")];
2961 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2962
2963 let opts = ProtectionOptions {
2964 spreadsheet_id: "sheet-1".to_string(),
2965 verb: ProtectionVerb::ProtectRange {
2966 sheet: Some("Q1".to_string()),
2967 range: Some("A1:A5".to_string()),
2968 whole_sheet: false,
2969 description: None,
2970 warning_only: false,
2971 editors: Vec::new(),
2972 },
2973 dry_run: false,
2974 lease_token,
2975 ledger_path,
2976 };
2977 let outcome = protection(&drive, &sheets, &opts, &rules).await;
2978 assert!(matches!(outcome.result, ProtectionResult::Failed { .. }));
2979 }
2980
2981 #[tokio::test]
2982 async fn refuses_an_unknown_lease_token() {
2983 let server = wiremock::MockServer::start().await;
2984 let (drive, sheets) = clients(&server).await;
2985 mount_file(
2986 "sheet-1",
2987 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2988 &["folder-1"],
2989 )
2990 .mount(&server)
2991 .await;
2992 mount_folder("folder-1").mount(&server).await;
2993 mount_workbook(serde_json::json!([])).mount(&server).await;
2994 let rules = vec![allow_rule("folder-1")];
2995 let ledger_path = tempfile::tempdir()
2996 .unwrap()
2997 .keep()
2998 .join("lease-ledger.jsonl");
2999 let opts = ProtectionOptions {
3002 spreadsheet_id: "sheet-1".to_string(),
3003 verb: ProtectionVerb::ProtectRange {
3004 sheet: Some("Q1".to_string()),
3005 range: Some("A1:A5".to_string()),
3006 whole_sheet: false,
3007 description: None,
3008 warning_only: false,
3009 editors: Vec::new(),
3010 },
3011 dry_run: false,
3012 lease_token: Some("bogus-token".to_string()),
3013 ledger_path,
3014 };
3015 let outcome = protection(&drive, &sheets, &opts, &rules).await;
3016 assert!(matches!(
3017 outcome.result,
3018 ProtectionResult::RefusedLeaseExpired
3019 ));
3020 }
3021
3022 #[tokio::test]
3023 async fn refuses_a_lease_bound_to_a_different_file() {
3024 let server = wiremock::MockServer::start().await;
3025 let (drive, sheets) = clients(&server).await;
3026 mount_file(
3027 "sheet-1",
3028 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3029 &["folder-1"],
3030 )
3031 .mount(&server)
3032 .await;
3033 mount_folder("folder-1").mount(&server).await;
3034 mount_workbook(serde_json::json!([])).mount(&server).await;
3035 let rules = vec![allow_rule("folder-1")];
3036 let ledger_path = tempfile::tempdir()
3037 .unwrap()
3038 .keep()
3039 .join("lease-ledger.jsonl");
3040 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
3042
3043 let opts = ProtectionOptions {
3044 spreadsheet_id: "sheet-1".to_string(),
3045 verb: ProtectionVerb::ProtectRange {
3046 sheet: Some("Q1".to_string()),
3047 range: Some("A1:A5".to_string()),
3048 whole_sheet: false,
3049 description: None,
3050 warning_only: false,
3051 editors: Vec::new(),
3052 },
3053 dry_run: false,
3054 lease_token: Some(token),
3055 ledger_path,
3056 };
3057 let outcome = protection(&drive, &sheets, &opts, &rules).await;
3058 assert!(matches!(
3059 outcome.result,
3060 ProtectionResult::RefusedLeaseWrongFile
3061 ));
3062 }
3063
3064 #[tokio::test]
3065 async fn refuses_a_stale_lease_when_the_file_has_moved() {
3066 let server = wiremock::MockServer::start().await;
3067 let (drive, sheets) = clients(&server).await;
3068 mount_file(
3071 "sheet-1",
3072 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3073 &["folder-1"],
3074 )
3075 .mount(&server)
3076 .await;
3077 mount_folder("folder-1").mount(&server).await;
3078 mount_workbook(serde_json::json!([])).mount(&server).await;
3079 let rules = vec![allow_rule("folder-1")];
3080 let ledger_path = tempfile::tempdir()
3081 .unwrap()
3082 .keep()
3083 .join("lease-ledger.jsonl");
3084 let token = seed_lease(&ledger_path, "sheet-1", "0");
3085
3086 let opts = ProtectionOptions {
3087 spreadsheet_id: "sheet-1".to_string(),
3088 verb: ProtectionVerb::ProtectRange {
3089 sheet: Some("Q1".to_string()),
3090 range: Some("A1:A5".to_string()),
3091 whole_sheet: false,
3092 description: None,
3093 warning_only: false,
3094 editors: Vec::new(),
3095 },
3096 dry_run: false,
3097 lease_token: Some(token),
3098 ledger_path,
3099 };
3100 let outcome = protection(&drive, &sheets, &opts, &rules).await;
3101 assert!(matches!(
3102 outcome.result,
3103 ProtectionResult::RefusedLeaseStale
3104 ));
3105 }
3106
3107 #[tokio::test]
3108 async fn require_lease_false_writes_without_a_lease() {
3109 let server = wiremock::MockServer::start().await;
3110 let (drive, sheets) = clients(&server).await;
3111 mount_file(
3112 "sheet-1",
3113 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3114 &["folder-1"],
3115 )
3116 .mount(&server)
3117 .await;
3118 mount_folder("folder-1").mount(&server).await;
3119 mount_workbook(serde_json::json!([])).mount(&server).await;
3120 wiremock::Mock::given(wiremock::matchers::method("POST"))
3121 .and(wiremock::matchers::path(
3122 "/v4/spreadsheets/sheet-1:batchUpdate",
3123 ))
3124 .respond_with(
3125 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3126 "replies": [{"addProtectedRange": {"protectedRange": {"protectedRangeId": 1}}}]
3127 })),
3128 )
3129 .mount(&server)
3130 .await;
3131 let rule = FolderPermissionRule {
3132 folder_id: Some("folder-1".to_string()),
3133 file_id: None,
3134 recursive: true,
3135 allow: std::iter::once(DriveOperation::SheetsProtection).collect(),
3136 deny: HashSet::default(),
3137 require_lease: false,
3138 };
3139
3140 let opts = ProtectionOptions {
3142 spreadsheet_id: "sheet-1".to_string(),
3143 verb: ProtectionVerb::ProtectRange {
3144 sheet: Some("Q1".to_string()),
3145 range: Some("A1:A5".to_string()),
3146 whole_sheet: false,
3147 description: None,
3148 warning_only: false,
3149 editors: Vec::new(),
3150 },
3151 dry_run: false,
3152 lease_token: None,
3153 ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
3154 };
3155 let outcome = protection(&drive, &sheets, &opts, &[rule]).await;
3156 assert!(matches!(outcome.result, ProtectionResult::Changed { .. }));
3157 }
3158
3159 #[tokio::test]
3160 async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
3161 let server = wiremock::MockServer::start().await;
3165 let (drive, sheets) = clients(&server).await;
3166 mount_file(
3169 "sheet-1",
3170 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3171 &["folder-1"],
3172 )
3173 .mount(&server)
3174 .await;
3175 mount_folder("folder-1").mount(&server).await;
3176 mount_workbook(serde_json::json!([])).mount(&server).await;
3177 let rule = FolderPermissionRule {
3180 folder_id: Some("folder-1".to_string()),
3181 file_id: None,
3182 recursive: true,
3183 allow: std::iter::once(DriveOperation::SheetsProtection).collect(),
3184 deny: HashSet::default(),
3185 require_lease: false,
3186 };
3187 let ledger_path = tempfile::tempdir()
3188 .unwrap()
3189 .keep()
3190 .join("lease-ledger.jsonl");
3191 let token = seed_lease(&ledger_path, "sheet-1", "0");
3192
3193 let opts = ProtectionOptions {
3194 spreadsheet_id: "sheet-1".to_string(),
3195 verb: ProtectionVerb::ProtectRange {
3196 sheet: Some("Q1".to_string()),
3197 range: Some("A1:A5".to_string()),
3198 whole_sheet: false,
3199 description: None,
3200 warning_only: false,
3201 editors: Vec::new(),
3202 },
3203 dry_run: false,
3204 lease_token: Some(token),
3205 ledger_path,
3206 };
3207 let outcome = protection(&drive, &sheets, &opts, &[rule]).await;
3208 assert!(matches!(
3209 outcome.result,
3210 ProtectionResult::RefusedLeaseStale
3211 ));
3212 }
3213
3214 #[tokio::test]
3217 async fn a_leased_protection_change_concludes_its_audit_pair_with_allowed() {
3218 let server = wiremock::MockServer::start().await;
3219 let (drive, sheets) = clients(&server).await;
3220 mount_file(
3221 "sheet-1",
3222 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3223 &["folder-1"],
3224 )
3225 .mount(&server)
3226 .await;
3227 mount_folder("folder-1").mount(&server).await;
3228 mount_workbook(serde_json::json!([])).mount(&server).await;
3229 wiremock::Mock::given(wiremock::matchers::method("POST"))
3230 .and(wiremock::matchers::path(
3231 "/v4/spreadsheets/sheet-1:batchUpdate",
3232 ))
3233 .respond_with(
3234 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3235 "replies": [{"addProtectedRange": {"protectedRange": {"protectedRangeId": 99}}}]
3236 })),
3237 )
3238 .mount(&server)
3239 .await;
3240 let dir = tempfile::tempdir().unwrap();
3241 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
3242 let rules = vec![allow_rule("folder-1")];
3243 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3244 let opts = ProtectionOptions {
3245 spreadsheet_id: "sheet-1".to_string(),
3246 verb: ProtectionVerb::ProtectRange {
3247 sheet: Some("Q1".to_string()),
3248 range: Some("A1:A5".to_string()),
3249 whole_sheet: false,
3250 description: None,
3251 warning_only: false,
3252 editors: Vec::new(),
3253 },
3254 dry_run: false,
3255 lease_token,
3256 ledger_path,
3257 };
3258
3259 let outcome = protection(&drive, &sheets, &opts, &rules).await;
3260 assert!(matches!(outcome.result, ProtectionResult::Changed { .. }));
3261
3262 let records = audit.records();
3263 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
3264 assert_eq!(records[0].command, ["drive", "sheets-protect-range"]);
3268 }
3269
3270 #[tokio::test]
3280 async fn an_invalid_editor_update_fails_before_opening_the_audit_pair() {
3281 let server = wiremock::MockServer::start().await;
3282 let (drive, sheets) = clients(&server).await;
3283 mount_file(
3284 "sheet-1",
3285 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3286 &["folder-1"],
3287 )
3288 .mount(&server)
3289 .await;
3290 mount_folder("folder-1").mount(&server).await;
3291 mount_workbook(serde_json::json!([{
3292 "protectedRangeId": 20,
3293 "range": {
3294 "sheetId": 0,
3295 "startRowIndex": 0,
3296 "endRowIndex": 5,
3297 "startColumnIndex": 0,
3298 "endColumnIndex": 1,
3299 },
3300 "editors": {"users": ["a@example.com"]},
3301 }]))
3302 .mount(&server)
3303 .await;
3304 let dir = tempfile::tempdir().unwrap();
3307 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
3308 let rules = vec![allow_rule("folder-1")];
3309 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3310 let opts = ProtectionOptions {
3311 spreadsheet_id: "sheet-1".to_string(),
3312 verb: ProtectionVerb::UpdateProtection {
3313 sheet: Some("Q1".to_string()),
3314 range: Some("A1:A5".to_string()),
3315 whole_sheet: false,
3316 description: None,
3317 warning_only: Some(true),
3318 add_editors: Vec::new(),
3319 remove_editors: Vec::new(),
3320 },
3321 dry_run: false,
3322 lease_token,
3323 ledger_path,
3324 };
3325
3326 let outcome = protection(&drive, &sheets, &opts, &rules).await;
3327 match outcome.result {
3328 ProtectionResult::RefusedInvalidRange { detail } => {
3329 assert!(detail.contains("warning-only"), "{detail}");
3330 }
3331 other => panic!("expected RefusedInvalidRange, got {other:?}"),
3332 }
3333 assert!(audit.records().is_empty(), "{:?}", audit.records());
3334 }
3335}