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