1use std::path::PathBuf;
33use std::time::{Duration, Instant};
34
35use serde::Serialize;
36
37use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
38use crate::drive::client::DriveClient;
39use crate::drive::files_api::FilesApi;
40use crate::drive::lease::check::{
41 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
42 LeasedWrite,
43};
44use crate::drive::sheets::a1;
45use crate::drive::sheets::api::{
46 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
47};
48use crate::drive::sheets::client::SheetsClient;
49use crate::drive::sheets::date_value::{
50 reject_blank, reject_blank_date, reject_empty, reject_invalid_date_between, reject_nan,
51 reject_reversed_range, DateValue,
52};
53use crate::drive::sheets::grid_range;
54use crate::drive::sheets::target_gate;
55use crate::drive::sheets::types::{
56 BatchUpdateRequestItem, BooleanCondition, ConditionValue, DataValidationRule, GridRange,
57 SetDataValidationRequest,
58};
59use crate::drive::types::SheetTargetRefusal;
60use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
61use crate::request_log::{self, DriveMutationOutcome};
62
63#[derive(Debug, Clone, PartialEq)]
69pub enum Condition {
70 OneOfList(Vec<String>),
72 OneOfRange(String),
74 NumberBetween(f64, f64),
76 NumberNotBetween(f64, f64),
78 NumberGreater(f64),
80 NumberGreaterEq(f64),
82 NumberLess(f64),
84 NumberLessEq(f64),
86 NumberEq(f64),
88 NumberNotEq(f64),
90 TextContains(String),
92 TextNotContains(String),
94 TextStartsWith(String),
96 TextEndsWith(String),
98 TextEq(String),
100 DateAfter(DateValue),
102 DateBefore(DateValue),
104 DateOn(DateValue),
106 DateBetween(String, String),
108 Blank,
110 NotBlank,
112 Checkbox,
114 CustomFormula(String),
116}
117
118impl Condition {
119 const fn condition_type_str(&self) -> &'static str {
125 match self {
126 Self::OneOfList(_) => "ONE_OF_LIST",
127 Self::OneOfRange(_) => "ONE_OF_RANGE",
128 Self::NumberBetween(..) => "NUMBER_BETWEEN",
129 Self::NumberNotBetween(..) => "NUMBER_NOT_BETWEEN",
130 Self::NumberGreater(_) => "NUMBER_GREATER",
131 Self::NumberGreaterEq(_) => "NUMBER_GREATER_THAN_EQ",
132 Self::NumberLess(_) => "NUMBER_LESS",
133 Self::NumberLessEq(_) => "NUMBER_LESS_THAN_EQ",
134 Self::NumberEq(_) => "NUMBER_EQ",
135 Self::NumberNotEq(_) => "NUMBER_NOT_EQ",
136 Self::TextContains(_) => "TEXT_CONTAINS",
137 Self::TextNotContains(_) => "TEXT_NOT_CONTAINS",
138 Self::TextStartsWith(_) => "TEXT_STARTS_WITH",
139 Self::TextEndsWith(_) => "TEXT_ENDS_WITH",
140 Self::TextEq(_) => "TEXT_EQ",
141 Self::DateAfter(_) => "DATE_AFTER",
142 Self::DateBefore(_) => "DATE_BEFORE",
143 Self::DateOn(_) => "DATE_EQ",
144 Self::DateBetween(..) => "DATE_BETWEEN",
145 Self::Blank => "BLANK",
146 Self::NotBlank => "NOT_BLANK",
147 Self::Checkbox => "BOOLEAN",
148 Self::CustomFormula(_) => "CUSTOM_FORMULA",
149 }
150 }
151
152 fn into_boolean_condition(self) -> BooleanCondition {
153 let value = |s: String| ConditionValue {
154 user_entered_value: Some(s),
155 relative_date: None,
156 };
157 let condition_type = self.condition_type_str().to_string();
158 let values = match self {
159 Self::OneOfList(items) => items.into_iter().map(value).collect(),
160 Self::OneOfRange(range) => vec![value(range)],
161 Self::NumberBetween(min, max) | Self::NumberNotBetween(min, max) => {
162 vec![value(min.to_string()), value(max.to_string())]
163 }
164 Self::NumberGreater(n)
165 | Self::NumberGreaterEq(n)
166 | Self::NumberLess(n)
167 | Self::NumberLessEq(n)
168 | Self::NumberEq(n)
169 | Self::NumberNotEq(n) => vec![value(n.to_string())],
170 Self::TextContains(text)
171 | Self::TextNotContains(text)
172 | Self::TextStartsWith(text)
173 | Self::TextEndsWith(text)
174 | Self::TextEq(text) => vec![value(text)],
175 Self::DateAfter(date) | Self::DateBefore(date) | Self::DateOn(date) => {
176 vec![date.into_condition_value()]
177 }
178 Self::DateBetween(start, end) => vec![value(start), value(end)],
179 Self::Blank | Self::NotBlank | Self::Checkbox => Vec::new(),
180 Self::CustomFormula(formula) => vec![value(formula)],
181 };
182 BooleanCondition {
183 condition_type,
184 values,
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq)]
191pub enum ValidationVerb {
192 SetDataValidation {
194 sheet: Option<String>,
196 range: Option<String>,
198 condition: Condition,
200 input_message: Option<String>,
202 show_warning: bool,
205 },
206 ClearDataValidation {
208 sheet: Option<String>,
210 range: Option<String>,
212 },
213}
214
215impl ValidationVerb {
216 const fn log_operation(&self) -> &'static str {
217 match self {
218 Self::SetDataValidation { .. } => "sheets-set-data-validation",
219 Self::ClearDataValidation { .. } => "sheets-clear-data-validation",
220 }
221 }
222
223 const fn label(&self) -> &'static str {
224 match self {
225 Self::SetDataValidation { .. } => "set-data-validation",
226 Self::ClearDataValidation { .. } => "clear-data-validation",
227 }
228 }
229
230 fn sheet_and_range(&self) -> (Option<&str>, Option<&str>) {
231 match self {
232 Self::SetDataValidation { sheet, range, .. }
233 | Self::ClearDataValidation { sheet, range } => (sheet.as_deref(), range.as_deref()),
234 }
235 }
236}
237
238#[derive(Debug, Clone)]
240pub struct ValidationOptions {
241 pub spreadsheet_id: String,
243 pub verb: ValidationVerb,
245 pub dry_run: bool,
247 pub lease_token: Option<String>,
252 pub ledger_path: PathBuf,
256}
257
258#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
260#[serde(tag = "status", rename_all = "kebab-case")]
261pub enum ValidationResult {
262 WouldChange {
264 summary: String,
266 #[serde(skip_serializing_if = "Option::is_none")]
271 clamped_to: Option<String>,
272 },
273 RefusedNotASpreadsheet {
275 mime_type: String,
277 },
278 RefusedShortcut,
280 RefusedNoVisibleParents,
282 RefusedSheetNotFound {
284 title: String,
286 available: Vec<String>,
288 },
289 RefusedInvalidRange {
292 detail: String,
294 },
295 Blocked {
297 decided_by: Option<DecidingRule>,
299 },
300 RefusedNoLease,
303 RefusedLeaseExpired,
306 RefusedLeaseWrongFile,
308 RefusedLeaseStale,
311 Changed {
313 summary: String,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 clamped_to: Option<String>,
318 },
319 AppliedReplyUnreadable {
322 summary: String,
324 detail: String,
326 #[serde(skip_serializing_if = "Option::is_none")]
328 clamped_to: Option<String>,
329 },
330 Failed {
332 detail: String,
334 },
335}
336
337impl FromLeaseRefusal for ValidationResult {
338 fn from_no_lease() -> Self {
339 Self::RefusedNoLease
340 }
341 fn from_lease_expired() -> Self {
342 Self::RefusedLeaseExpired
343 }
344 fn from_lease_wrong_file() -> Self {
345 Self::RefusedLeaseWrongFile
346 }
347 fn from_lease_stale() -> Self {
348 Self::RefusedLeaseStale
349 }
350 fn from_lease_failed(detail: String) -> Self {
351 Self::Failed { detail }
352 }
353}
354
355impl ValidationResult {
356 fn log_status(&self) -> &'static str {
357 match self {
358 Self::WouldChange { .. } => "would-change",
359 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
360 Self::RefusedShortcut => "refused-shortcut",
361 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
362 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
363 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
364 Self::Blocked { .. } => "blocked",
365 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
366 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
367 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
368 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
369 Self::Changed { .. } => "changed",
370 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
371 Self::Failed { .. } => "failed",
372 }
373 }
374}
375
376#[derive(Debug, Clone, Serialize, PartialEq)]
378pub struct ValidationOutcome {
379 pub spreadsheet_id: String,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub file_name: Option<String>,
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub resolved_folder_id: Option<String>,
387 #[serde(skip)]
389 pub verb: ValidationVerb,
390 pub result: ValidationResult,
392}
393
394impl JsonlSerialize for ValidationOutcome {
395 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
396 write_scalar_jsonl(self, out)
397 }
398}
399
400pub async fn validation(
403 drive: &DriveClient,
404 sheets: &SheetsClient,
405 opts: &ValidationOptions,
406 rules: &[FolderPermissionRule],
407) -> ValidationOutcome {
408 let started = Instant::now();
409 let outcome = validation_inner(drive, sheets, opts, rules).await;
410 if !opts.dry_run {
411 record_attempt(&outcome, opts, started.elapsed());
412 }
413 outcome
414}
415
416async fn validation_inner(
417 drive: &DriveClient,
418 sheets: &SheetsClient,
419 opts: &ValidationOptions,
420 rules: &[FolderPermissionRule],
421) -> ValidationOutcome {
422 let bare = |result| ValidationOutcome {
423 spreadsheet_id: opts.spreadsheet_id.clone(),
424 file_name: None,
425 resolved_folder_id: None,
426 verb: opts.verb.clone(),
427 result,
428 };
429
430 let (sheet, range) = opts.verb.sheet_and_range();
431 let composed_range = match a1::compose(sheet, range) {
432 Ok(composed) => composed,
433 Err(err) => {
434 return bare(ValidationResult::RefusedInvalidRange {
435 detail: err.to_string(),
436 })
437 }
438 };
439
440 if let ValidationVerb::SetDataValidation {
441 input_message: _,
442 condition,
443 ..
444 } = &opts.verb
445 {
446 if let Err(detail) = validate_condition(condition) {
447 return bare(ValidationResult::RefusedInvalidRange { detail });
448 }
449 }
450
451 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
452 drive,
453 &opts.spreadsheet_id,
454 DriveOperation::SheetsStructure,
455 rules,
456 )
457 .await
458 {
459 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
460 return bare(ValidationResult::Failed { detail })
461 }
462 target_gate::TargetGateOutcome::Refused { target, refusal } => {
463 let result = match refusal {
464 SheetTargetRefusal::Shortcut => ValidationResult::RefusedShortcut,
465 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
466 ValidationResult::RefusedNotASpreadsheet { mime_type }
467 }
468 SheetTargetRefusal::NoVisibleParents => ValidationResult::RefusedNoVisibleParents,
469 };
470 return ValidationOutcome {
471 spreadsheet_id: opts.spreadsheet_id.clone(),
472 file_name: Some(target.name),
473 resolved_folder_id: None,
474 verb: opts.verb.clone(),
475 result,
476 };
477 }
478 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
479 return ValidationOutcome {
480 spreadsheet_id: opts.spreadsheet_id.clone(),
481 file_name: Some(target.name),
482 resolved_folder_id: None,
483 verb: opts.verb.clone(),
484 result: ValidationResult::Failed { detail },
485 };
486 }
487 target_gate::TargetGateOutcome::Gated {
488 target,
489 decision,
490 resolved_folder_id,
491 requires_lease,
492 } => (target, decision, resolved_folder_id, requires_lease),
493 };
494
495 let gated = |result| ValidationOutcome {
496 spreadsheet_id: opts.spreadsheet_id.clone(),
497 file_name: Some(target.name.clone()),
498 resolved_folder_id: resolved_folder_id.clone(),
499 verb: opts.verb.clone(),
500 result,
501 };
502
503 if decision.verdict == write_gate::Verdict::Deny {
504 return gated(ValidationResult::Blocked {
505 decided_by: decision.decided_by,
506 });
507 }
508
509 let api = SheetsApi::new(sheets);
510 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
511 Ok(workbook) => workbook,
512 Err(err) => {
513 return gated(ValidationResult::Failed {
514 detail: format!("{err:#}"),
515 })
516 }
517 };
518
519 let (sheet_title, grid) = match grid_range::resolve_grid_range(
520 &workbook,
521 &composed_range,
522 |detail| ValidationResult::RefusedInvalidRange { detail },
523 |title, available| ValidationResult::RefusedSheetNotFound { title, available },
524 ) {
525 Ok(resolved) => resolved,
526 Err(result) => return gated(result),
527 };
528
529 let clamp = grid_range::clamp_to_sheet(&workbook, &grid)
534 .filter(|_| !grid_range::starts_past_grid(&workbook, &grid));
535 let clamped_to = match clamp {
536 None => {
537 return gated(ValidationResult::RefusedInvalidRange {
538 detail: format!(
539 "{composed_range} lies wholly past the sheet's current grid, so there is \
540 nothing for {} to apply to",
541 opts.verb.label()
542 ),
543 })
544 }
545 Some(clamped) if clamped != grid => Some(
546 grid_range::bounded_range_to_a1(&sheet_title, &clamped)
547 .unwrap_or_else(|| grid_range::render_grid_range(&clamped)),
548 ),
549 Some(_) => None,
550 };
551
552 let summary = describe_effect(&opts.verb);
553
554 if opts.dry_run {
555 return gated(ValidationResult::WouldChange {
556 summary,
557 clamped_to,
558 });
559 }
560
561 let request = build_request(&opts.verb, grid);
569
570 let files_api = FilesApi::new(drive);
578 let leased = LeasedWrite {
579 log_prefix: "drive sheets validation",
580 operation: opts.verb.log_operation(),
581 ledger_path: &opts.ledger_path,
582 file_id: &opts.spreadsheet_id,
583 };
584 let lease_grant = match gate_optional_leased_write(
585 leased,
586 &files_api,
587 requires_lease,
588 opts.lease_token.as_deref(),
589 )
590 .await
591 {
592 Ok(grant) => grant,
593 Err(err) => return gated(err.into_result()),
594 };
595
596 let result = match conclude_native_leased_write(
597 leased,
598 &lease_grant,
599 &files_api,
600 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
601 |err| format!("{err:#}"),
602 )
603 .await
604 {
605 Ok(BatchUpdateOutcome::Applied(_)) => ValidationResult::Changed {
606 summary,
607 clamped_to,
608 },
609 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
612 ValidationResult::AppliedReplyUnreadable {
613 summary,
614 detail,
615 clamped_to,
616 }
617 }
618 Err(err) => ValidationResult::Failed {
619 detail: format!("{err:#}"),
620 },
621 };
622 drop(lease_grant);
623 gated(result)
624}
625
626fn validate_condition(condition: &Condition) -> Result<(), String> {
627 match condition {
630 Condition::OneOfList(items) => reject_empty_list(items, "--one-of-list"),
631 Condition::OneOfRange(range) => {
632 a1::validate_range(range).map_err(|err| format!("--one-of-range: {err:#}"))
633 }
634 Condition::NumberBetween(min, max) => reject_reversed_range(*min, *max, "--number-between"),
635 Condition::NumberNotBetween(min, max) => {
636 reject_reversed_range(*min, *max, "--number-not-between")
637 }
638 Condition::NumberGreater(n) => reject_nan(*n, "--number-greater"),
639 Condition::NumberGreaterEq(n) => reject_nan(*n, "--number-greater-eq"),
640 Condition::NumberLess(n) => reject_nan(*n, "--number-less"),
641 Condition::NumberLessEq(n) => reject_nan(*n, "--number-less-eq"),
642 Condition::NumberEq(n) => reject_nan(*n, "--number-eq"),
643 Condition::NumberNotEq(n) => reject_nan(*n, "--number-not-eq"),
644 Condition::TextContains(text) => reject_empty(text, "--text-contains"),
648 Condition::TextNotContains(text) => reject_empty(text, "--text-not-contains"),
649 Condition::TextStartsWith(text) => reject_empty(text, "--text-starts-with"),
650 Condition::TextEndsWith(text) => reject_empty(text, "--text-ends-with"),
651 Condition::TextEq(text) => reject_empty(text, "--text-eq"),
652 Condition::DateAfter(date) => validate_single_date(date, "--date-after"),
653 Condition::DateBefore(date) => validate_single_date(date, "--date-before"),
654 Condition::DateOn(date) => validate_single_date(date, "--date-on"),
655 Condition::DateBetween(start, end) => reject_invalid_date_between(start, end),
656 Condition::Blank | Condition::NotBlank | Condition::Checkbox => Ok(()),
657 Condition::CustomFormula(formula) => {
658 reject_blank(formula, "--custom-formula")?;
659 if !formula.trim_start().starts_with('=') {
663 return Err(format!(
664 "--custom-formula {formula:?} should start with '=', e.g. '=A1>0'; Sheets \
665 rejects a custom formula with no leading '='"
666 ));
667 }
668 Ok(())
669 }
670 }
671}
672
673fn validate_single_date(date: &DateValue, flag: &str) -> Result<(), String> {
684 reject_blank_date(date, flag)?;
685 if matches!(date, DateValue::Relative(_)) {
686 return Err(format!(
687 "{flag} only accepts an absolute date, not a relative keyword like 'today' — \
688 Sheets rejects relative dates in data validation (they work only in \
689 add-conditional-format/update-conditional-format)"
690 ));
691 }
692 Ok(())
693}
694
695fn reject_empty_list(items: &[String], flag: &str) -> Result<(), String> {
696 if items.is_empty() {
697 Err(format!("{flag} needs at least one value"))
698 } else {
699 Ok(())
700 }
701}
702
703fn describe_effect(verb: &ValidationVerb) -> String {
704 match verb {
705 ValidationVerb::SetDataValidation {
706 condition,
707 show_warning,
708 ..
709 } => {
710 let strictness = if *show_warning {
711 "warn only"
712 } else {
713 "reject invalid entries"
714 };
715 format!(
716 "set data validation ({}, {strictness})",
717 condition
718 .condition_type_str()
719 .to_ascii_lowercase()
720 .replace('_', " ")
721 )
722 }
723 ValidationVerb::ClearDataValidation { .. } => "clear data validation".to_string(),
724 }
725}
726
727fn build_request(verb: &ValidationVerb, grid: GridRange) -> BatchUpdateRequestItem {
728 let rule = match verb {
729 ValidationVerb::SetDataValidation {
730 condition,
731 input_message,
732 show_warning,
733 ..
734 } => Some(DataValidationRule {
735 condition: condition.clone().into_boolean_condition(),
736 input_message: input_message.clone(),
737 strict: Some(!show_warning),
738 }),
739 ValidationVerb::ClearDataValidation { .. } => None,
740 };
741 BatchUpdateRequestItem::SetDataValidation(SetDataValidationRequest { range: grid, rule })
742}
743
744fn record_attempt(outcome: &ValidationOutcome, opts: &ValidationOptions, duration: Duration) {
745 request_log::record_drive_mutation(mutation_outcome(outcome, opts, duration));
746}
747
748fn mutation_outcome(
751 outcome: &ValidationOutcome,
752 opts: &ValidationOptions,
753 duration: Duration,
754) -> DriveMutationOutcome {
755 let error = match &outcome.result {
756 ValidationResult::Failed { detail }
757 | ValidationResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
758 _ => None,
759 };
760 let fields_changed = match &outcome.result {
761 ValidationResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
762 _ => None,
763 };
764 let decided_by = match &outcome.result {
765 ValidationResult::Blocked { decided_by } => decided_by.as_ref(),
766 _ => None,
767 };
768 let decided_by = write_gate::decided_by_log_fields(decided_by);
769 let validation_type = match &opts.verb {
770 ValidationVerb::SetDataValidation { condition, .. } => {
771 Some(condition.condition_type_str().to_string())
772 }
773 ValidationVerb::ClearDataValidation { .. } => Some("cleared".to_string()),
774 };
775
776 DriveMutationOutcome {
777 operation: opts.verb.log_operation(),
778 file_id: outcome.spreadsheet_id.clone(),
779 file_name: outcome.file_name.clone().unwrap_or_default(),
780 status: outcome.result.log_status().to_string(),
781 resolved_folder_id: outcome.resolved_folder_id.clone(),
782 decided_by_folder_id: decided_by.folder_id,
783 decided_by_depth: decided_by.depth,
784 decided_by_file_id: decided_by.file_id,
785 validation_type,
786 fields_changed,
787 error,
788 duration,
789 ..Default::default()
790 }
791}
792
793fn clamped_applied_line(applied: &str) -> String {
797 format!(
798 " the range ran past the sheet's current grid; Sheets clamped it, so this applied to \
799 {applied} only"
800 )
801}
802
803#[must_use]
805pub fn describe(outcome: &ValidationOutcome) -> String {
806 describe_lines(outcome).join("\n")
807}
808
809#[must_use]
812pub fn describe_lines(outcome: &ValidationOutcome) -> Vec<String> {
813 let verb = &outcome.verb;
814 let book = outcome.file_name.as_deref().map_or_else(
815 || format!("'{}'", outcome.spreadsheet_id),
816 |n| format!("'{n}'"),
817 );
818 match &outcome.result {
819 ValidationResult::WouldChange {
820 summary,
821 clamped_to,
822 } => {
823 let mut lines = vec![format!("Would {summary} in {book}")];
824 lines.extend(clamped_to.as_ref().map(|applied| {
825 format!(
826 " the range runs past the sheet's current grid; Sheets clamps it, so this \
827 would apply to {applied} only"
828 )
829 }));
830 lines
831 }
832 ValidationResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
833 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
834 `drive sheets {}` only works on spreadsheets",
835 verb.label()
836 )],
837 ValidationResult::RefusedShortcut => vec![format!(
838 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
839 verb.label()
840 )],
841 ValidationResult::RefusedNoVisibleParents => vec![format!(
842 "Refused: {book} has no parent folder visible to this account, so no folder \
843 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
844 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
845 )],
846 ValidationResult::RefusedSheetNotFound { title, available } => {
847 vec![grid_range::no_sheet_refusal(&book, title, available)]
848 }
849 ValidationResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
850 ValidationResult::Blocked { decided_by } => vec![match decided_by {
851 Some(rule) => format!(
852 "Blocked: {} on {book} refused by rule on {} {}{}",
853 verb.label(),
854 rule.kind_label(),
855 rule.id(),
856 rule.depth_suffix()
857 ),
858 None => format!(
859 "Blocked: {} on {book} refused by default policy (no matching rule for \
860 sheets-structure)",
861 verb.label()
862 ),
863 }],
864 ValidationResult::RefusedNoLease => LeaseGateRefusal::NoLease
865 .describe_line(&outcome.spreadsheet_id, &book)
866 .into_iter()
867 .collect(),
868 ValidationResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
869 .describe_line(&outcome.spreadsheet_id, &book)
870 .into_iter()
871 .collect(),
872 ValidationResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
873 .describe_line(&outcome.spreadsheet_id, &book)
874 .into_iter()
875 .collect(),
876 ValidationResult::RefusedLeaseStale => LeaseGateRefusal::Stale
877 .describe_line(&outcome.spreadsheet_id, &book)
878 .into_iter()
879 .collect(),
880 ValidationResult::Changed {
881 summary,
882 clamped_to,
883 } => {
884 let mut lines = vec![format!("Applied: {summary} in {book}")];
885 lines.extend(clamped_to.as_deref().map(clamped_applied_line));
886 lines
887 }
888 ValidationResult::AppliedReplyUnreadable {
889 summary,
890 detail,
891 clamped_to,
892 } => {
893 let mut lines = vec![applied_reply_unreadable_line(
894 summary,
895 &book,
896 RetryHint::Idempotent,
897 detail,
898 )];
899 lines.extend(clamped_to.as_deref().map(clamped_applied_line));
900 lines
901 }
902 ValidationResult::Failed { detail } => vec![format!("Failed: {detail}")],
903 }
904}
905
906#[cfg(test)]
907#[allow(clippy::unwrap_used, clippy::expect_used)]
908mod tests {
909 use super::*;
910 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
911 use crate::drive::sheets::client::SHEETS_API_URL;
912 use crate::drive::test_support::seed_lease;
913 use crate::test_support::env::MapEnv;
914 use crate::utils::secret::Secret;
915 use std::collections::HashSet;
916
917 #[test]
918 fn one_of_list_builds_a_value_per_item() {
919 let condition = Condition::OneOfList(vec!["a".to_string(), "b".to_string()]);
920 let built = condition.into_boolean_condition();
921 assert_eq!(built.condition_type, "ONE_OF_LIST");
922 assert_eq!(built.values.len(), 2);
923 assert_eq!(built.values[0].user_entered_value, Some("a".to_string()));
924 }
925
926 #[test]
927 fn one_of_range_builds_a_single_value() {
928 let condition = Condition::OneOfRange("Sheet2!A1:A10".to_string());
929 let built = condition.into_boolean_condition();
930 assert_eq!(built.condition_type, "ONE_OF_RANGE");
931 assert_eq!(
932 built.values[0].user_entered_value,
933 Some("Sheet2!A1:A10".to_string())
934 );
935 }
936
937 #[test]
938 fn number_between_builds_two_values() {
939 let condition = Condition::NumberBetween(1.0, 10.0);
940 let built = condition.into_boolean_condition();
941 assert_eq!(built.condition_type, "NUMBER_BETWEEN");
942 assert_eq!(built.values[0].user_entered_value, Some("1".to_string()));
943 assert_eq!(built.values[1].user_entered_value, Some("10".to_string()));
944 }
945
946 #[test]
947 fn number_not_between_builds_two_values() {
948 let condition = Condition::NumberNotBetween(1.0, 10.0);
949 let built = condition.into_boolean_condition();
950 assert_eq!(built.condition_type, "NUMBER_NOT_BETWEEN");
951 assert_eq!(built.values.len(), 2);
952 }
953
954 #[test]
955 fn numeric_comparators_build_a_single_value_each() {
956 let cases = [
957 (Condition::NumberGreater(1.0), "NUMBER_GREATER"),
958 (Condition::NumberGreaterEq(1.0), "NUMBER_GREATER_THAN_EQ"),
959 (Condition::NumberLess(1.0), "NUMBER_LESS"),
960 (Condition::NumberLessEq(1.0), "NUMBER_LESS_THAN_EQ"),
961 (Condition::NumberEq(1.0), "NUMBER_EQ"),
962 (Condition::NumberNotEq(1.0), "NUMBER_NOT_EQ"),
963 ];
964 for (condition, expected_type) in cases {
965 let built = condition.into_boolean_condition();
966 assert_eq!(built.condition_type, expected_type);
967 assert_eq!(
968 built.values,
969 vec![ConditionValue {
970 user_entered_value: Some("1".to_string()),
971 relative_date: None,
972 }]
973 );
974 }
975 }
976
977 #[test]
978 fn text_conditions_build_a_single_value_each() {
979 let cases = [
980 (Condition::TextContains("x".to_string()), "TEXT_CONTAINS"),
981 (
982 Condition::TextNotContains("x".to_string()),
983 "TEXT_NOT_CONTAINS",
984 ),
985 (
986 Condition::TextStartsWith("x".to_string()),
987 "TEXT_STARTS_WITH",
988 ),
989 (Condition::TextEndsWith("x".to_string()), "TEXT_ENDS_WITH"),
990 (Condition::TextEq("x".to_string()), "TEXT_EQ"),
991 ];
992 for (condition, expected_type) in cases {
993 let built = condition.into_boolean_condition();
994 assert_eq!(built.condition_type, expected_type);
995 assert_eq!(built.values[0].user_entered_value, Some("x".to_string()));
996 }
997 }
998
999 #[test]
1000 fn date_conditions_build_an_absolute_value() {
1001 let cases = [
1002 (
1003 Condition::DateAfter(DateValue::Absolute("2024-01-01".to_string())),
1004 "DATE_AFTER",
1005 ),
1006 (
1007 Condition::DateBefore(DateValue::Absolute("2024-01-01".to_string())),
1008 "DATE_BEFORE",
1009 ),
1010 (
1011 Condition::DateOn(DateValue::Absolute("2024-01-01".to_string())),
1012 "DATE_EQ",
1013 ),
1014 ];
1015 for (condition, expected_type) in cases {
1016 let built = condition.into_boolean_condition();
1017 assert_eq!(built.condition_type, expected_type);
1018 assert_eq!(
1019 built.values[0].user_entered_value,
1020 Some("2024-01-01".to_string())
1021 );
1022 assert_eq!(built.values[0].relative_date, None);
1023 }
1024 }
1025
1026 #[test]
1032 fn date_between_builds_two_absolute_values() {
1033 let condition = Condition::DateBetween("2024-01-01".to_string(), "2024-12-31".to_string());
1034 let built = condition.into_boolean_condition();
1035 assert_eq!(built.condition_type, "DATE_BETWEEN");
1036 assert_eq!(built.values.len(), 2);
1037 }
1038
1039 #[test]
1040 fn blank_and_not_blank_have_no_values() {
1041 assert_eq!(
1042 Condition::Blank.into_boolean_condition().condition_type,
1043 "BLANK"
1044 );
1045 assert!(Condition::Blank.into_boolean_condition().values.is_empty());
1046 assert_eq!(
1047 Condition::NotBlank.into_boolean_condition().condition_type,
1048 "NOT_BLANK"
1049 );
1050 assert!(Condition::NotBlank
1051 .into_boolean_condition()
1052 .values
1053 .is_empty());
1054 }
1055
1056 #[test]
1057 fn checkbox_has_no_values() {
1058 let built = Condition::Checkbox.into_boolean_condition();
1059 assert_eq!(built.condition_type, "BOOLEAN");
1060 assert!(built.values.is_empty());
1061 }
1062
1063 #[test]
1064 fn custom_formula_builds_a_single_value() {
1065 let condition = Condition::CustomFormula("=A1>0".to_string());
1066 let built = condition.into_boolean_condition();
1067 assert_eq!(built.condition_type, "CUSTOM_FORMULA");
1068 assert_eq!(built.values.len(), 1);
1069 assert_eq!(
1070 built.values[0].user_entered_value,
1071 Some("=A1>0".to_string())
1072 );
1073 }
1074
1075 #[test]
1076 fn condition_type_str_covers_every_condition() {
1077 let types = [
1078 Condition::OneOfList(vec!["a".to_string()]).condition_type_str(),
1079 Condition::OneOfRange("A1:A10".to_string()).condition_type_str(),
1080 Condition::NumberBetween(1.0, 2.0).condition_type_str(),
1081 Condition::NumberNotBetween(1.0, 2.0).condition_type_str(),
1082 Condition::NumberGreater(1.0).condition_type_str(),
1083 Condition::NumberGreaterEq(1.0).condition_type_str(),
1084 Condition::NumberLess(1.0).condition_type_str(),
1085 Condition::NumberLessEq(1.0).condition_type_str(),
1086 Condition::NumberEq(1.0).condition_type_str(),
1087 Condition::NumberNotEq(1.0).condition_type_str(),
1088 Condition::TextContains("x".to_string()).condition_type_str(),
1089 Condition::TextNotContains("x".to_string()).condition_type_str(),
1090 Condition::TextStartsWith("x".to_string()).condition_type_str(),
1091 Condition::TextEndsWith("x".to_string()).condition_type_str(),
1092 Condition::TextEq("x".to_string()).condition_type_str(),
1093 Condition::DateAfter(DateValue::Absolute("d".to_string())).condition_type_str(),
1094 Condition::DateBefore(DateValue::Absolute("d".to_string())).condition_type_str(),
1095 Condition::DateOn(DateValue::Absolute("d".to_string())).condition_type_str(),
1096 Condition::DateBetween("a".to_string(), "b".to_string()).condition_type_str(),
1097 Condition::Blank.condition_type_str(),
1098 Condition::NotBlank.condition_type_str(),
1099 Condition::Checkbox.condition_type_str(),
1100 Condition::CustomFormula("=TRUE".to_string()).condition_type_str(),
1101 ];
1102 let unique: HashSet<&&str> = types.iter().collect();
1103 assert_eq!(unique.len(), types.len());
1104 }
1105
1106 fn set_verb(condition: Condition, show_warning: bool) -> ValidationVerb {
1107 ValidationVerb::SetDataValidation {
1108 sheet: Some("Q1".to_string()),
1109 range: Some("A1:A10".to_string()),
1110 condition,
1111 input_message: None,
1112 show_warning,
1113 }
1114 }
1115
1116 fn clear_verb() -> ValidationVerb {
1117 ValidationVerb::ClearDataValidation {
1118 sheet: Some("Q1".to_string()),
1119 range: Some("A1:A10".to_string()),
1120 }
1121 }
1122
1123 #[test]
1124 fn log_operation_and_label_cover_every_verb() {
1125 let set = set_verb(Condition::Checkbox, false);
1126 let clear = clear_verb();
1127 assert_eq!(set.log_operation(), "sheets-set-data-validation");
1128 assert_eq!(clear.log_operation(), "sheets-clear-data-validation");
1129 assert_eq!(set.label(), "set-data-validation");
1130 assert_eq!(clear.label(), "clear-data-validation");
1131 }
1132
1133 #[test]
1134 fn describe_effect_covers_every_condition_and_strictness() {
1135 let reject = describe_effect(&set_verb(
1136 Condition::OneOfList(vec!["a".to_string()]),
1137 false,
1138 ));
1139 assert!(reject.contains("reject invalid entries"), "{reject}");
1140 assert!(reject.contains("one of list"), "{reject}");
1141
1142 let warn = describe_effect(&set_verb(Condition::NumberBetween(1.0, 10.0), true));
1143 assert!(warn.contains("warn only"), "{warn}");
1144 assert!(warn.contains("number between"), "{warn}");
1145
1146 let checkbox = describe_effect(&set_verb(Condition::Checkbox, false));
1147 assert!(checkbox.contains("boolean"), "{checkbox}");
1148
1149 let formula = describe_effect(&set_verb(
1150 Condition::CustomFormula("=A1>0".to_string()),
1151 false,
1152 ));
1153 assert!(formula.contains("custom formula"), "{formula}");
1154
1155 assert_eq!(describe_effect(&clear_verb()), "clear data validation");
1156 }
1157
1158 #[test]
1159 fn validate_condition_accepts_a_valid_number_between() {
1160 validate_condition(&Condition::NumberBetween(1.0, 10.0)).unwrap();
1161 }
1162
1163 #[test]
1164 fn validate_condition_accepts_a_non_empty_custom_formula() {
1165 validate_condition(&Condition::CustomFormula("=A1>0".to_string())).unwrap();
1166 }
1167
1168 #[test]
1169 fn validate_condition_rejects_an_empty_one_of_list() {
1170 let err = validate_condition(&Condition::OneOfList(Vec::new())).unwrap_err();
1171 assert!(err.contains("at least one value"), "{err}");
1172 }
1173
1174 #[test]
1175 fn validate_condition_rejects_a_reversed_number_between() {
1176 let err = validate_condition(&Condition::NumberBetween(10.0, 1.0)).unwrap_err();
1177 assert!(err.contains("must not exceed"), "{err}");
1178 }
1179
1180 #[test]
1181 fn validate_condition_rejects_a_nan_bound() {
1182 let err = validate_condition(&Condition::NumberBetween(f64::NAN, 5.0)).unwrap_err();
1185 assert!(err.contains("must not exceed"), "{err}");
1186 let err = validate_condition(&Condition::NumberBetween(1.0, f64::NAN)).unwrap_err();
1187 assert!(err.contains("must not exceed"), "{err}");
1188 }
1189
1190 #[test]
1191 fn validate_condition_rejects_a_blank_custom_formula() {
1192 let err = validate_condition(&Condition::CustomFormula(" ".to_string())).unwrap_err();
1193 assert!(err.contains("must not be empty"), "{err}");
1194 }
1195
1196 #[test]
1197 fn validate_condition_rejects_a_custom_formula_missing_its_leading_equals() {
1198 let err = validate_condition(&Condition::CustomFormula("F2>0".to_string())).unwrap_err();
1201 assert!(err.contains("should start with '='"), "{err}");
1202 }
1203
1204 #[test]
1205 fn validate_condition_accepts_a_valid_one_of_range() {
1206 validate_condition(&Condition::OneOfRange("Sheet2!A1:A10".to_string())).unwrap();
1207 }
1208
1209 #[test]
1210 fn validate_condition_rejects_a_blank_one_of_range() {
1211 let err = validate_condition(&Condition::OneOfRange(String::new())).unwrap_err();
1212 assert!(err.contains("must not be empty"), "{err}");
1215 assert!(!err.contains("--sheet"), "{err}");
1216 }
1217
1218 #[test]
1219 fn validate_condition_accepts_a_valid_number_not_between() {
1220 validate_condition(&Condition::NumberNotBetween(1.0, 10.0)).unwrap();
1221 }
1222
1223 #[test]
1224 fn validate_condition_rejects_a_reversed_number_not_between() {
1225 let err = validate_condition(&Condition::NumberNotBetween(10.0, 1.0)).unwrap_err();
1226 assert!(err.contains("must not exceed"), "{err}");
1227 }
1228
1229 #[test]
1230 fn validate_condition_rejects_a_nan_comparator() {
1231 let err = validate_condition(&Condition::NumberGreater(f64::NAN)).unwrap_err();
1232 assert!(err.contains("must not be NaN"), "{err}");
1233 }
1234
1235 #[test]
1236 fn validate_condition_accepts_a_numeric_comparator() {
1237 validate_condition(&Condition::NumberEq(1.0)).unwrap();
1238 }
1239
1240 #[test]
1241 fn validate_condition_rejects_empty_text() {
1242 let err = validate_condition(&Condition::TextContains(String::new())).unwrap_err();
1243 assert!(err.contains("must not be empty"), "{err}");
1244 }
1245
1246 #[test]
1247 fn validate_condition_accepts_non_blank_text() {
1248 validate_condition(&Condition::TextEq("x".to_string())).unwrap();
1249 }
1250
1251 #[test]
1252 fn validate_condition_accepts_whitespace_only_text() {
1253 validate_condition(&Condition::TextContains(" ".to_string())).unwrap();
1256 }
1257
1258 #[test]
1259 fn validate_condition_rejects_a_blank_absolute_date() {
1260 let err = validate_condition(&Condition::DateAfter(DateValue::Absolute(" ".to_string())))
1261 .unwrap_err();
1262 assert!(err.contains("must not be empty"), "{err}");
1263 }
1264
1265 #[test]
1266 fn validate_condition_rejects_a_relative_date() {
1267 for (condition, flag) in [
1271 (
1272 Condition::DateAfter(DateValue::parse("today".to_string())),
1273 "--date-after",
1274 ),
1275 (
1276 Condition::DateBefore(DateValue::parse("past-week".to_string())),
1277 "--date-before",
1278 ),
1279 (
1280 Condition::DateOn(DateValue::parse("yesterday".to_string())),
1281 "--date-on",
1282 ),
1283 ] {
1284 let err = validate_condition(&condition).unwrap_err();
1285 assert!(err.contains(flag), "{err}");
1286 assert!(err.contains("relative"), "{err}");
1287 }
1288 }
1289
1290 #[test]
1291 fn validate_condition_accepts_an_absolute_date() {
1292 validate_condition(&Condition::DateAfter(DateValue::parse(
1293 "2024-01-01".to_string(),
1294 )))
1295 .unwrap();
1296 }
1297
1298 #[test]
1299 fn validate_condition_accepts_a_valid_date_between() {
1300 validate_condition(&Condition::DateBetween(
1301 "2024-01-01".to_string(),
1302 "2024-12-31".to_string(),
1303 ))
1304 .unwrap();
1305 }
1306
1307 #[test]
1308 fn validate_condition_rejects_a_blank_date_between_bound() {
1309 let err = validate_condition(&Condition::DateBetween(
1310 "2024-01-01".to_string(),
1311 " ".to_string(),
1312 ))
1313 .unwrap_err();
1314 assert!(err.contains("must not be empty"), "{err}");
1315 }
1316
1317 #[test]
1318 fn validate_condition_rejects_a_relative_keyword_in_date_between() {
1319 let err = validate_condition(&Condition::DateBetween(
1320 "today".to_string(),
1321 "2024-12-31".to_string(),
1322 ))
1323 .unwrap_err();
1324 assert!(err.contains("absolute dates"), "{err}");
1325 assert!(!err.contains("--date-after"), "{err}");
1328
1329 let err = validate_condition(&Condition::DateBetween(
1330 "2024-01-01".to_string(),
1331 "tomorrow".to_string(),
1332 ))
1333 .unwrap_err();
1334 assert!(err.contains("absolute dates"), "{err}");
1335 }
1336
1337 #[test]
1338 fn validate_condition_rejects_a_reversed_date_between() {
1339 let err = validate_condition(&Condition::DateBetween(
1340 "2024-12-31".to_string(),
1341 "2024-01-01".to_string(),
1342 ))
1343 .unwrap_err();
1344 assert!(err.contains("must not be after"), "{err}");
1345 }
1346
1347 #[test]
1348 fn validate_condition_trusts_a_non_iso_date_between_through_untouched() {
1349 validate_condition(&Condition::DateBetween(
1353 "12/31/2024".to_string(),
1354 "01/01/2024".to_string(),
1355 ))
1356 .unwrap();
1357 }
1358
1359 #[test]
1360 fn validate_condition_accepts_blank_and_not_blank() {
1361 validate_condition(&Condition::Blank).unwrap();
1362 validate_condition(&Condition::NotBlank).unwrap();
1363 }
1364
1365 #[test]
1366 fn clear_sets_no_rule() {
1367 let grid = GridRange {
1368 sheet_id: 1,
1369 ..Default::default()
1370 };
1371 let request = build_request(
1372 &ValidationVerb::ClearDataValidation {
1373 sheet: None,
1374 range: Some("A1".to_string()),
1375 },
1376 grid,
1377 );
1378 match request {
1379 BatchUpdateRequestItem::SetDataValidation(req) => assert!(req.rule.is_none()),
1380 other => panic!("expected SetDataValidation, got {other:?}"),
1381 }
1382 }
1383
1384 fn test_credentials() -> DriveCredentials {
1385 DriveCredentials {
1386 client_id: "client-1".to_string(),
1387 client_secret: Secret::new("secret-1"),
1388 refresh_token: Secret::new("refresh-1"),
1389 scope: DriveGrantedScopes::READONLY,
1390 }
1391 }
1392
1393 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1394 wiremock::Mock::given(wiremock::matchers::method("POST"))
1395 .and(wiremock::matchers::path("/token"))
1396 .respond_with(
1397 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1398 "access_token": "test-token", "expires_in": 3600,
1399 })),
1400 )
1401 .mount(server)
1402 .await;
1403 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1404 crate::drive::client::test_support::replace_session(
1405 &mut drive,
1406 &test_credentials(),
1407 &format!("{}/token", server.uri()),
1408 );
1409 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1410 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1411 (drive, sheets)
1412 }
1413
1414 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1418 let parents: Vec<&str> = parents.to_vec();
1419 wiremock::Mock::given(wiremock::matchers::method("GET"))
1420 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1421 .respond_with(
1422 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1423 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1424 "version": "1",
1425 })),
1426 )
1427 }
1428
1429 fn mount_folder(id: &str) -> wiremock::Mock {
1430 mount_file(id, "application/vnd.google-apps.folder", &[])
1431 }
1432
1433 fn allow_rule(folder: &str) -> FolderPermissionRule {
1434 FolderPermissionRule {
1435 folder_id: Some(folder.to_string()),
1436 file_id: None,
1437 recursive: true,
1438 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1439 deny: HashSet::default(),
1440 require_lease: true,
1441 }
1442 }
1443
1444 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1447 let ledger_path = tempfile::tempdir()
1448 .unwrap()
1449 .keep()
1450 .join("lease-ledger.jsonl");
1451 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1452 (Some(token), ledger_path)
1453 }
1454
1455 fn mount_workbook() -> wiremock::Mock {
1456 wiremock::Mock::given(wiremock::matchers::method("GET"))
1457 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1458 .respond_with(
1459 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1460 "spreadsheetId": "sheet-1",
1461 "properties": {"title": "Budget"},
1462 "sheets": [
1463 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1464 ],
1465 })),
1466 )
1467 }
1468
1469 #[tokio::test]
1470 async fn a_denied_gate_blocks_before_any_batch_update_call() {
1471 let server = wiremock::MockServer::start().await;
1472 let (drive, sheets) = clients(&server).await;
1473 mount_file(
1474 "sheet-1",
1475 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1476 &["folder-1"],
1477 )
1478 .mount(&server)
1479 .await;
1480 mount_folder("folder-1").mount(&server).await;
1481 let rules: Vec<FolderPermissionRule> = Vec::new();
1482 let opts = ValidationOptions {
1483 spreadsheet_id: "sheet-1".to_string(),
1484 verb: ValidationVerb::SetDataValidation {
1485 sheet: Some("Q1".to_string()),
1486 range: Some("A1:A10".to_string()),
1487 condition: Condition::Checkbox,
1488 input_message: None,
1489 show_warning: false,
1490 },
1491 dry_run: false,
1492 lease_token: None,
1493 ledger_path: std::path::PathBuf::new(),
1494 };
1495 let outcome = validation(&drive, &sheets, &opts, &rules).await;
1496 assert!(matches!(outcome.result, ValidationResult::Blocked { .. }));
1497 }
1498
1499 #[tokio::test]
1500 async fn set_data_validation_sends_a_boolean_condition() {
1501 let server = wiremock::MockServer::start().await;
1502 let (drive, sheets) = clients(&server).await;
1503 mount_file(
1504 "sheet-1",
1505 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1506 &["folder-1"],
1507 )
1508 .mount(&server)
1509 .await;
1510 mount_folder("folder-1").mount(&server).await;
1511 mount_workbook().mount(&server).await;
1512 wiremock::Mock::given(wiremock::matchers::method("POST"))
1513 .and(wiremock::matchers::path(
1514 "/v4/spreadsheets/sheet-1:batchUpdate",
1515 ))
1516 .respond_with(
1517 wiremock::ResponseTemplate::new(200)
1518 .set_body_json(serde_json::json!({"replies": [{}]})),
1519 )
1520 .mount(&server)
1521 .await;
1522 let rules = vec![allow_rule("folder-1")];
1523 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1524 let opts = ValidationOptions {
1525 spreadsheet_id: "sheet-1".to_string(),
1526 verb: ValidationVerb::SetDataValidation {
1527 sheet: Some("Q1".to_string()),
1528 range: Some("A1:A10".to_string()),
1529 condition: Condition::OneOfList(vec!["yes".to_string(), "no".to_string()]),
1530 input_message: Some("pick one".to_string()),
1531 show_warning: false,
1532 },
1533 dry_run: false,
1534 lease_token,
1535 ledger_path,
1536 };
1537 let outcome = validation(&drive, &sheets, &opts, &rules).await;
1538 assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
1539
1540 let requests = server.received_requests().await.unwrap();
1541 let batch = requests
1542 .iter()
1543 .find(|r| r.url.path().ends_with(":batchUpdate"))
1544 .unwrap();
1545 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1546 let rule = &body["requests"][0]["setDataValidation"]["rule"];
1547 assert_eq!(rule["condition"]["type"], "ONE_OF_LIST");
1548 assert_eq!(rule["strict"], true);
1549 }
1550
1551 #[tokio::test]
1552 async fn set_data_validation_sends_a_tranche_2_comparator_and_relative_date() {
1553 let server = wiremock::MockServer::start().await;
1554 let (drive, sheets) = clients(&server).await;
1555 mount_file(
1556 "sheet-1",
1557 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1558 &["folder-1"],
1559 )
1560 .mount(&server)
1561 .await;
1562 mount_folder("folder-1").mount(&server).await;
1563 mount_workbook().mount(&server).await;
1564 wiremock::Mock::given(wiremock::matchers::method("POST"))
1565 .and(wiremock::matchers::path(
1566 "/v4/spreadsheets/sheet-1:batchUpdate",
1567 ))
1568 .respond_with(
1569 wiremock::ResponseTemplate::new(200)
1570 .set_body_json(serde_json::json!({"replies": [{}]})),
1571 )
1572 .mount(&server)
1573 .await;
1574 let rules = vec![allow_rule("folder-1")];
1575 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1576 let opts = ValidationOptions {
1577 spreadsheet_id: "sheet-1".to_string(),
1578 verb: ValidationVerb::SetDataValidation {
1579 sheet: Some("Q1".to_string()),
1580 range: Some("A1:A10".to_string()),
1581 condition: Condition::NumberGreater(0.0),
1582 input_message: None,
1583 show_warning: false,
1584 },
1585 dry_run: false,
1586 lease_token,
1587 ledger_path,
1588 };
1589 let outcome = validation(&drive, &sheets, &opts, &rules).await;
1590 assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
1591
1592 let requests = server.received_requests().await.unwrap();
1593 let batch = requests
1594 .iter()
1595 .find(|r| r.url.path().ends_with(":batchUpdate"))
1596 .unwrap();
1597 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1598 let rule = &body["requests"][0]["setDataValidation"]["rule"];
1599 assert_eq!(rule["condition"]["type"], "NUMBER_GREATER");
1600 assert_eq!(rule["condition"]["values"][0]["userEnteredValue"], "0");
1601 assert!(rule["condition"]["values"][0]["relativeDate"].is_null());
1602 }
1603
1604 #[tokio::test]
1605 async fn set_data_validation_refuses_a_relative_date_condition() {
1606 let server = wiremock::MockServer::start().await;
1612 let (drive, sheets) = clients(&server).await;
1613 let rules = vec![allow_rule("folder-1")];
1614 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1615 let opts = ValidationOptions {
1616 spreadsheet_id: "sheet-1".to_string(),
1617 verb: ValidationVerb::SetDataValidation {
1618 sheet: Some("Q1".to_string()),
1619 range: Some("A1:A10".to_string()),
1620 condition: Condition::DateAfter(DateValue::parse("today".to_string())),
1621 input_message: None,
1622 show_warning: false,
1623 },
1624 dry_run: false,
1625 lease_token,
1626 ledger_path,
1627 };
1628 let outcome = validation(&drive, &sheets, &opts, &rules).await;
1629 match outcome.result {
1630 ValidationResult::RefusedInvalidRange { detail } => {
1631 assert!(detail.contains("--date-after"), "{detail}");
1632 assert!(detail.contains("relative"), "{detail}");
1633 }
1634 other => panic!("expected RefusedInvalidRange, got {other:?}"),
1635 }
1636 }
1637
1638 #[tokio::test]
1639 async fn clear_data_validation_full_apply_flow() {
1640 let server = wiremock::MockServer::start().await;
1641 let (drive, sheets) = clients(&server).await;
1642 mount_file(
1643 "sheet-1",
1644 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1645 &["folder-1"],
1646 )
1647 .mount(&server)
1648 .await;
1649 mount_folder("folder-1").mount(&server).await;
1650 mount_workbook().mount(&server).await;
1651 wiremock::Mock::given(wiremock::matchers::method("POST"))
1652 .and(wiremock::matchers::path(
1653 "/v4/spreadsheets/sheet-1:batchUpdate",
1654 ))
1655 .respond_with(
1656 wiremock::ResponseTemplate::new(200)
1657 .set_body_json(serde_json::json!({"replies": [{}]})),
1658 )
1659 .mount(&server)
1660 .await;
1661 let rules = vec![allow_rule("folder-1")];
1662 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1663 let opts = ValidationOptions {
1664 spreadsheet_id: "sheet-1".to_string(),
1665 verb: clear_verb(),
1666 dry_run: false,
1667 lease_token,
1668 ledger_path,
1669 };
1670 let outcome = validation(&drive, &sheets, &opts, &rules).await;
1671 assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
1672
1673 let requests = server.received_requests().await.unwrap();
1674 let batch = requests
1675 .iter()
1676 .find(|r| r.url.path().ends_with(":batchUpdate"))
1677 .unwrap();
1678 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1679 assert!(body["requests"][0]["setDataValidation"]["rule"].is_null());
1680 }
1681
1682 #[tokio::test]
1683 async fn dry_run_reports_would_change_without_calling_batch_update() {
1684 let server = wiremock::MockServer::start().await;
1685 let (drive, sheets) = clients(&server).await;
1686 mount_file(
1687 "sheet-1",
1688 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1689 &["folder-1"],
1690 )
1691 .mount(&server)
1692 .await;
1693 mount_folder("folder-1").mount(&server).await;
1694 mount_workbook().mount(&server).await;
1695 let rules = vec![allow_rule("folder-1")];
1696 let opts = ValidationOptions {
1697 spreadsheet_id: "sheet-1".to_string(),
1698 verb: set_verb(Condition::Checkbox, false),
1699 dry_run: true,
1700 lease_token: None,
1701 ledger_path: std::path::PathBuf::new(),
1702 };
1703 let outcome = validation(&drive, &sheets, &opts, &rules).await;
1704 assert!(matches!(
1705 outcome.result,
1706 ValidationResult::WouldChange { .. }
1707 ));
1708 let requests = server.received_requests().await.unwrap();
1709 assert!(!requests
1710 .iter()
1711 .any(|r| r.url.path().ends_with(":batchUpdate")));
1712 }
1713
1714 async fn dry_run_on_a_sized_grid(range: &str) -> ValidationOutcome {
1717 let server = wiremock::MockServer::start().await;
1718 let (drive, sheets) = clients(&server).await;
1719 mount_file(
1720 "sheet-1",
1721 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1722 &["folder-1"],
1723 )
1724 .mount(&server)
1725 .await;
1726 mount_folder("folder-1").mount(&server).await;
1727 wiremock::Mock::given(wiremock::matchers::method("GET"))
1728 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1729 .respond_with(
1730 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1731 "spreadsheetId": "sheet-1",
1732 "properties": {"title": "Budget"},
1733 "sheets": [{"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1734 "gridProperties": {"rowCount": 1000, "columnCount": 26}}}],
1735 })),
1736 )
1737 .mount(&server)
1738 .await;
1739 let opts = ValidationOptions {
1740 spreadsheet_id: "sheet-1".to_string(),
1741 verb: ValidationVerb::SetDataValidation {
1742 sheet: Some("Q1".to_string()),
1743 range: Some(range.to_string()),
1744 condition: Condition::Checkbox,
1745 input_message: None,
1746 show_warning: false,
1747 },
1748 dry_run: true,
1749 lease_token: None,
1750 ledger_path: std::path::PathBuf::new(),
1751 };
1752 validation(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await
1753 }
1754
1755 #[tokio::test]
1759 async fn a_range_past_the_grid_names_the_clamped_range() {
1760 let outcome = dry_run_on_a_sized_grid("Z1:AA2000").await;
1761 assert_eq!(
1762 outcome.result,
1763 ValidationResult::WouldChange {
1764 summary: describe_effect(&set_verb(Condition::Checkbox, false)),
1765 clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
1766 }
1767 );
1768 let text = describe(&outcome);
1769 assert!(text.contains("would apply to 'Q1'!Z1:Z1000 only"), "{text}");
1770
1771 let inside = dry_run_on_a_sized_grid("A1:B10").await;
1772 assert!(
1773 matches!(
1774 inside.result,
1775 ValidationResult::WouldChange {
1776 clamped_to: None,
1777 ..
1778 }
1779 ),
1780 "{:?}",
1781 inside.result
1782 );
1783 }
1784
1785 #[tokio::test]
1786 async fn a_range_wholly_past_the_grid_is_refused() {
1787 for range in ["AB1:AC5", "A2000:A"] {
1790 let outcome = dry_run_on_a_sized_grid(range).await;
1791 let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
1792 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
1793 };
1795 assert!(
1796 detail.contains("wholly past the sheet's current grid"),
1797 "{detail}"
1798 );
1799 }
1800 let inside = dry_run_on_a_sized_grid("A5:A").await;
1802 assert!(
1803 matches!(inside.result, ValidationResult::WouldChange { .. }),
1804 "{:?}",
1805 inside.result
1806 );
1807 }
1808
1809 #[tokio::test]
1810 async fn compose_error_returns_refused_invalid_range() {
1811 let server = wiremock::MockServer::start().await;
1812 let (drive, sheets) = clients(&server).await;
1813 let opts = ValidationOptions {
1814 spreadsheet_id: "sheet-1".to_string(),
1815 verb: ValidationVerb::SetDataValidation {
1816 sheet: None,
1817 range: None,
1818 condition: Condition::Checkbox,
1819 input_message: None,
1820 show_warning: false,
1821 },
1822 dry_run: false,
1823 lease_token: None,
1824 ledger_path: std::path::PathBuf::new(),
1825 };
1826 let outcome = validation(&drive, &sheets, &opts, &[]).await;
1827 let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
1828 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
1829 };
1830 assert!(detail.contains("a range is required"), "{detail}");
1831 assert!(outcome.file_name.is_none());
1832 }
1833
1834 #[tokio::test]
1835 async fn invalid_condition_is_refused_before_any_network_call() {
1836 let server = wiremock::MockServer::start().await;
1837 let (drive, sheets) = clients(&server).await;
1838 let opts = ValidationOptions {
1839 spreadsheet_id: "sheet-1".to_string(),
1840 verb: set_verb(Condition::NumberBetween(10.0, 1.0), false),
1841 dry_run: false,
1842 lease_token: None,
1843 ledger_path: std::path::PathBuf::new(),
1844 };
1845 let outcome = validation(&drive, &sheets, &opts, &[]).await;
1846 let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
1847 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
1848 };
1849 assert!(detail.contains("must not exceed"), "{detail}");
1850 let requests = server.received_requests().await.unwrap();
1851 assert!(!requests
1852 .iter()
1853 .any(|r| r.url.path().starts_with("/drive/v3/files")));
1854 }
1855
1856 #[tokio::test]
1857 async fn metadata_fetch_failure_is_reported_as_failed() {
1858 let server = wiremock::MockServer::start().await;
1859 let (drive, sheets) = clients(&server).await;
1860 wiremock::Mock::given(wiremock::matchers::method("GET"))
1861 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1862 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1863 .mount(&server)
1864 .await;
1865 let opts = ValidationOptions {
1866 spreadsheet_id: "sheet-1".to_string(),
1867 verb: set_verb(Condition::Checkbox, false),
1868 dry_run: false,
1869 lease_token: None,
1870 ledger_path: std::path::PathBuf::new(),
1871 };
1872 let outcome = validation(&drive, &sheets, &opts, &[]).await;
1873 assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
1874 assert!(outcome.file_name.is_none());
1875 }
1876
1877 #[tokio::test]
1878 async fn shortcut_is_refused() {
1879 let server = wiremock::MockServer::start().await;
1880 let (drive, sheets) = clients(&server).await;
1881 mount_file(
1882 "sheet-1",
1883 "application/vnd.google-apps.shortcut",
1884 &["folder-1"],
1885 )
1886 .mount(&server)
1887 .await;
1888 let opts = ValidationOptions {
1889 spreadsheet_id: "sheet-1".to_string(),
1890 verb: set_verb(Condition::Checkbox, false),
1891 dry_run: false,
1892 lease_token: None,
1893 ledger_path: std::path::PathBuf::new(),
1894 };
1895 let outcome = validation(&drive, &sheets, &opts, &[]).await;
1896 assert!(matches!(outcome.result, ValidationResult::RefusedShortcut));
1897 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
1898 }
1899
1900 #[tokio::test]
1901 async fn not_a_spreadsheet_is_refused() {
1902 let server = wiremock::MockServer::start().await;
1903 let (drive, sheets) = clients(&server).await;
1904 mount_file("sheet-1", "application/pdf", &["folder-1"])
1905 .mount(&server)
1906 .await;
1907 let opts = ValidationOptions {
1908 spreadsheet_id: "sheet-1".to_string(),
1909 verb: set_verb(Condition::Checkbox, false),
1910 dry_run: false,
1911 lease_token: None,
1912 ledger_path: std::path::PathBuf::new(),
1913 };
1914 let outcome = validation(&drive, &sheets, &opts, &[]).await;
1915 assert!(matches!(
1916 outcome.result,
1917 ValidationResult::RefusedNotASpreadsheet { ref mime_type } if mime_type == "application/pdf"
1918 ));
1919 }
1920
1921 #[tokio::test]
1922 async fn no_visible_parents_is_refused() {
1923 let server = wiremock::MockServer::start().await;
1924 let (drive, sheets) = clients(&server).await;
1925 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
1926 .mount(&server)
1927 .await;
1928 let opts = ValidationOptions {
1929 spreadsheet_id: "sheet-1".to_string(),
1930 verb: set_verb(Condition::Checkbox, false),
1931 dry_run: false,
1932 lease_token: None,
1933 ledger_path: std::path::PathBuf::new(),
1934 };
1935 let outcome = validation(&drive, &sheets, &opts, &[]).await;
1936 assert!(matches!(
1937 outcome.result,
1938 ValidationResult::RefusedNoVisibleParents
1939 ));
1940 }
1941
1942 #[tokio::test]
1943 async fn gate_fetch_failure_is_reported_as_failed() {
1944 let server = wiremock::MockServer::start().await;
1945 let (drive, sheets) = clients(&server).await;
1946 mount_file(
1947 "sheet-1",
1948 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1949 &["folder-1"],
1950 )
1951 .mount(&server)
1952 .await;
1953 wiremock::Mock::given(wiremock::matchers::method("GET"))
1954 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
1955 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1956 .mount(&server)
1957 .await;
1958 let opts = ValidationOptions {
1959 spreadsheet_id: "sheet-1".to_string(),
1960 verb: set_verb(Condition::Checkbox, false),
1961 dry_run: false,
1962 lease_token: None,
1963 ledger_path: std::path::PathBuf::new(),
1964 };
1965 let outcome = validation(&drive, &sheets, &opts, &[]).await;
1966 assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
1967 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
1968 }
1969
1970 #[tokio::test]
1971 async fn workbook_fetch_failure_is_reported_as_failed() {
1972 let server = wiremock::MockServer::start().await;
1973 let (drive, sheets) = clients(&server).await;
1974 mount_file(
1975 "sheet-1",
1976 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1977 &["folder-1"],
1978 )
1979 .mount(&server)
1980 .await;
1981 mount_folder("folder-1").mount(&server).await;
1982 wiremock::Mock::given(wiremock::matchers::method("GET"))
1983 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1984 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1985 .mount(&server)
1986 .await;
1987 let rules = vec![allow_rule("folder-1")];
1988 let opts = ValidationOptions {
1989 spreadsheet_id: "sheet-1".to_string(),
1990 verb: set_verb(Condition::Checkbox, false),
1991 dry_run: false,
1992 lease_token: None,
1993 ledger_path: std::path::PathBuf::new(),
1994 };
1995 let outcome = validation(&drive, &sheets, &opts, &rules).await;
1996 assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
1997 }
1998
1999 #[tokio::test]
2000 async fn sheet_not_found_is_refused() {
2001 let server = wiremock::MockServer::start().await;
2002 let (drive, sheets) = clients(&server).await;
2003 mount_file(
2004 "sheet-1",
2005 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2006 &["folder-1"],
2007 )
2008 .mount(&server)
2009 .await;
2010 mount_folder("folder-1").mount(&server).await;
2011 mount_workbook().mount(&server).await;
2012 let rules = vec![allow_rule("folder-1")];
2013 let opts = ValidationOptions {
2014 spreadsheet_id: "sheet-1".to_string(),
2015 verb: ValidationVerb::SetDataValidation {
2016 sheet: Some("Nope".to_string()),
2017 range: Some("A1:A10".to_string()),
2018 condition: Condition::Checkbox,
2019 input_message: None,
2020 show_warning: false,
2021 },
2022 dry_run: false,
2023 lease_token: None,
2024 ledger_path: std::path::PathBuf::new(),
2025 };
2026 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2027 let ValidationResult::RefusedSheetNotFound { title, available } = &outcome.result else {
2028 panic!("expected RefusedSheetNotFound, got {:?}", outcome.result);
2029 };
2030 assert_eq!(title, "Nope");
2031 assert_eq!(available, &["Q1".to_string()]);
2032 }
2033
2034 #[tokio::test]
2035 async fn invalid_grid_range_is_refused() {
2036 let server = wiremock::MockServer::start().await;
2037 let (drive, sheets) = clients(&server).await;
2038 mount_file(
2039 "sheet-1",
2040 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2041 &["folder-1"],
2042 )
2043 .mount(&server)
2044 .await;
2045 mount_folder("folder-1").mount(&server).await;
2046 mount_workbook().mount(&server).await;
2047 let rules = vec![allow_rule("folder-1")];
2048 let opts = ValidationOptions {
2049 spreadsheet_id: "sheet-1".to_string(),
2050 verb: ValidationVerb::SetDataValidation {
2051 sheet: Some("Q1".to_string()),
2052 range: Some("??".to_string()),
2053 condition: Condition::Checkbox,
2054 input_message: None,
2055 show_warning: false,
2056 },
2057 dry_run: false,
2058 lease_token: None,
2059 ledger_path: std::path::PathBuf::new(),
2060 };
2061 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2062 assert!(matches!(
2063 outcome.result,
2064 ValidationResult::RefusedInvalidRange { .. }
2065 ));
2066 }
2067
2068 #[tokio::test]
2069 async fn batch_update_failure_is_reported_as_failed_not_changed() {
2070 let server = wiremock::MockServer::start().await;
2071 let (drive, sheets) = clients(&server).await;
2072 mount_file(
2073 "sheet-1",
2074 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2075 &["folder-1"],
2076 )
2077 .mount(&server)
2078 .await;
2079 mount_folder("folder-1").mount(&server).await;
2080 mount_workbook().mount(&server).await;
2081 wiremock::Mock::given(wiremock::matchers::method("POST"))
2082 .and(wiremock::matchers::path(
2083 "/v4/spreadsheets/sheet-1:batchUpdate",
2084 ))
2085 .respond_with(
2086 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
2087 "error": {
2088 "code": 403,
2089 "message": "The caller does not have permission",
2090 "status": "PERMISSION_DENIED",
2091 }
2092 })),
2093 )
2094 .mount(&server)
2095 .await;
2096 let rules = vec![allow_rule("folder-1")];
2097 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2098 let opts = ValidationOptions {
2099 spreadsheet_id: "sheet-1".to_string(),
2100 verb: set_verb(Condition::Checkbox, false),
2101 dry_run: false,
2102 lease_token,
2103 ledger_path,
2104 };
2105 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2106 assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
2107 }
2108
2109 #[tokio::test]
2110 async fn set_with_an_unreadable_reply_reports_applied_reply_unreadable() {
2111 let server = wiremock::MockServer::start().await;
2112 let (drive, sheets) = clients(&server).await;
2113 mount_file(
2114 "sheet-1",
2115 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2116 &["folder-1"],
2117 )
2118 .mount(&server)
2119 .await;
2120 mount_folder("folder-1").mount(&server).await;
2121 mount_workbook().mount(&server).await;
2122 wiremock::Mock::given(wiremock::matchers::method("POST"))
2124 .and(wiremock::matchers::path(
2125 "/v4/spreadsheets/sheet-1:batchUpdate",
2126 ))
2127 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2128 .expect(1)
2129 .mount(&server)
2130 .await;
2131 let rules = vec![allow_rule("folder-1")];
2132 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2133 let opts = ValidationOptions {
2134 spreadsheet_id: "sheet-1".to_string(),
2135 verb: set_verb(Condition::Checkbox, false),
2136 dry_run: false,
2137 lease_token,
2138 ledger_path,
2139 };
2140 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2141 assert_eq!(
2142 outcome.result.log_status(),
2143 "applied-reply-unreadable",
2144 "{:?}",
2145 outcome.result
2146 );
2147 let text = describe(&outcome);
2148 assert!(
2149 text.starts_with("Applied, but the reply could not be read"),
2150 "{text}"
2151 );
2152 }
2153
2154 #[test]
2155 fn describe_lines_renders_applied_reply_unreadable() {
2156 let outcome = ValidationOutcome {
2157 spreadsheet_id: "sheet-1".to_string(),
2158 file_name: Some("Budget".to_string()),
2159 resolved_folder_id: None,
2160 verb: clear_verb(),
2161 result: ValidationResult::AppliedReplyUnreadable {
2162 summary: "clear data validation".to_string(),
2163 detail: "bad reply".to_string(),
2164 clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2165 },
2166 };
2167 assert_eq!(
2170 describe_lines(&outcome),
2171 vec![
2172 "Applied, but the reply could not be read: clear data validation in 'Budget' — \
2173 check the spreadsheet to confirm (bad reply)"
2174 .to_string(),
2175 " the range ran past the sheet's current grid; Sheets clamped it, so this \
2176 applied to 'Q1'!Z1:Z1000 only"
2177 .to_string(),
2178 ]
2179 );
2180 }
2181
2182 #[test]
2183 fn the_unreadable_reply_logs_everything_changed_logs() {
2184 let outcome = ValidationOutcome {
2185 spreadsheet_id: "sheet-1".to_string(),
2186 file_name: Some("Budget".to_string()),
2187 resolved_folder_id: None,
2188 verb: set_verb(Condition::Checkbox, false),
2189 result: ValidationResult::AppliedReplyUnreadable {
2190 summary: "set data validation".to_string(),
2191 detail: "bad reply".to_string(),
2192 clamped_to: None,
2193 },
2194 };
2195 let opts = ValidationOptions {
2196 spreadsheet_id: "sheet-1".to_string(),
2197 verb: set_verb(Condition::Checkbox, false),
2198 dry_run: false,
2199 lease_token: None,
2200 ledger_path: std::path::PathBuf::new(),
2201 };
2202 let logged = mutation_outcome(&outcome, &opts, Duration::ZERO);
2203 assert_eq!(logged.status, "applied-reply-unreadable");
2204 assert_eq!(
2205 logged.validation_type.as_deref(),
2206 Some(Condition::Checkbox.condition_type_str())
2207 );
2208 assert_eq!(
2209 logged.fields_changed.as_deref(),
2210 Some("set data validation")
2211 );
2212 assert_eq!(logged.error.as_deref(), Some("bad reply"));
2213 }
2214
2215 fn every_validation_result() -> Vec<ValidationResult> {
2216 let all = vec![
2217 ValidationResult::WouldChange {
2218 summary: "set data validation".to_string(),
2219 clamped_to: None,
2220 },
2221 ValidationResult::WouldChange {
2222 summary: "set data validation".to_string(),
2223 clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2224 },
2225 ValidationResult::RefusedNotASpreadsheet {
2226 mime_type: "application/pdf".to_string(),
2227 },
2228 ValidationResult::RefusedShortcut,
2229 ValidationResult::RefusedNoVisibleParents,
2230 ValidationResult::RefusedSheetNotFound {
2231 title: "Nope".to_string(),
2232 available: vec!["Q1".to_string(), "Q2".to_string()],
2233 },
2234 ValidationResult::RefusedSheetNotFound {
2235 title: "Nope".to_string(),
2236 available: Vec::new(),
2237 },
2238 ValidationResult::RefusedInvalidRange {
2239 detail: "bad range".to_string(),
2240 },
2241 ValidationResult::Blocked { decided_by: None },
2242 ValidationResult::Blocked {
2243 decided_by: Some(DecidingRule::Folder {
2244 folder_id: "folder-1".to_string(),
2245 depth: 2,
2246 }),
2247 },
2248 ValidationResult::RefusedNoLease,
2249 ValidationResult::RefusedLeaseExpired,
2250 ValidationResult::RefusedLeaseWrongFile,
2251 ValidationResult::RefusedLeaseStale,
2252 ValidationResult::Changed {
2253 summary: "set data validation".to_string(),
2254 clamped_to: None,
2255 },
2256 ValidationResult::Changed {
2257 summary: "set data validation".to_string(),
2258 clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2259 },
2260 ValidationResult::AppliedReplyUnreadable {
2261 summary: "set data validation".to_string(),
2262 detail: "bad reply".to_string(),
2263 clamped_to: None,
2264 },
2265 ValidationResult::AppliedReplyUnreadable {
2266 summary: "set data validation".to_string(),
2267 detail: "bad reply".to_string(),
2268 clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2269 },
2270 ValidationResult::Failed {
2271 detail: "boom".to_string(),
2272 },
2273 ];
2274 for result in &all {
2276 match result {
2277 ValidationResult::WouldChange { .. }
2278 | ValidationResult::RefusedNotASpreadsheet { .. }
2279 | ValidationResult::RefusedShortcut
2280 | ValidationResult::RefusedNoVisibleParents
2281 | ValidationResult::RefusedSheetNotFound { .. }
2282 | ValidationResult::RefusedInvalidRange { .. }
2283 | ValidationResult::Blocked { .. }
2284 | ValidationResult::RefusedNoLease
2285 | ValidationResult::RefusedLeaseExpired
2286 | ValidationResult::RefusedLeaseWrongFile
2287 | ValidationResult::RefusedLeaseStale
2288 | ValidationResult::Changed { .. }
2289 | ValidationResult::AppliedReplyUnreadable { .. }
2290 | ValidationResult::Failed { .. } => {}
2291 }
2292 }
2293 all
2294 }
2295
2296 #[test]
2297 fn describe_lines_covers_every_result_for_every_verb() {
2298 for verb in [clear_verb(), set_verb(Condition::Checkbox, false)] {
2299 for result in every_validation_result() {
2300 let outcome = ValidationOutcome {
2301 spreadsheet_id: "sheet-1".to_string(),
2302 file_name: Some("Budget".to_string()),
2303 resolved_folder_id: None,
2304 verb: verb.clone(),
2305 result,
2306 };
2307 let lines = describe_lines(&outcome);
2308 let clamped = matches!(
2310 &outcome.result,
2311 ValidationResult::WouldChange {
2312 clamped_to: Some(_),
2313 ..
2314 } | ValidationResult::Changed {
2315 clamped_to: Some(_),
2316 ..
2317 } | ValidationResult::AppliedReplyUnreadable {
2318 clamped_to: Some(_),
2319 ..
2320 }
2321 );
2322 assert_eq!(
2323 lines.len(),
2324 1 + usize::from(clamped),
2325 "{:?}",
2326 outcome.result
2327 );
2328 assert_eq!(describe(&outcome), lines.join("\n"));
2329 for rendered in &lines {
2330 assert!(!rendered.chars().any(char::is_control), "{rendered:?}");
2331 }
2332 }
2333 }
2334 }
2335
2336 #[test]
2337 fn log_status_covers_every_variant() {
2338 let statuses: HashSet<&'static str> = every_validation_result()
2339 .iter()
2340 .map(ValidationResult::log_status)
2341 .collect();
2342 assert_eq!(statuses.len(), 14);
2348 assert!(statuses.iter().all(|s| !s.is_empty()));
2349 }
2350
2351 #[test]
2352 fn write_jsonl_emits_one_line_of_json() {
2353 let outcome = ValidationOutcome {
2354 spreadsheet_id: "sheet-1".to_string(),
2355 file_name: Some("Budget".to_string()),
2356 resolved_folder_id: None,
2357 verb: clear_verb(),
2358 result: ValidationResult::Changed {
2359 summary: "clear data validation".to_string(),
2360 clamped_to: None,
2361 },
2362 };
2363 let mut buf = Vec::new();
2364 outcome.write_jsonl(&mut buf).unwrap();
2365 let text = String::from_utf8(buf).unwrap();
2366 assert_eq!(text.matches('\n').count(), 1);
2367 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
2368 assert_eq!(parsed["result"]["status"], "changed");
2369 }
2370
2371 #[tokio::test]
2374 async fn refuses_without_a_lease_when_the_rule_requires_one() {
2375 let server = wiremock::MockServer::start().await;
2376 let (drive, sheets) = clients(&server).await;
2377 mount_file(
2378 "sheet-1",
2379 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2380 &["folder-1"],
2381 )
2382 .mount(&server)
2383 .await;
2384 mount_folder("folder-1").mount(&server).await;
2385 mount_workbook().mount(&server).await;
2386 let rules = vec![allow_rule("folder-1")];
2389
2390 let opts = ValidationOptions {
2391 spreadsheet_id: "sheet-1".to_string(),
2392 verb: set_verb(Condition::Checkbox, false),
2393 dry_run: false,
2394 lease_token: None,
2395 ledger_path: std::path::PathBuf::new(),
2396 };
2397 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2398 assert!(matches!(outcome.result, ValidationResult::RefusedNoLease));
2399 assert_eq!(outcome.result.log_status(), "refused-no-lease");
2400 }
2401
2402 #[tokio::test]
2403 async fn reports_a_lock_acquisition_failure_as_failed() {
2404 let server = wiremock::MockServer::start().await;
2408 let (drive, sheets) = clients(&server).await;
2409 mount_file(
2410 "sheet-1",
2411 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2412 &["folder-1"],
2413 )
2414 .mount(&server)
2415 .await;
2416 mount_folder("folder-1").mount(&server).await;
2417 mount_workbook().mount(&server).await;
2418 let rules = vec![allow_rule("folder-1")];
2419 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2420 let mut lock_path = ledger_path.clone().into_os_string();
2426 lock_path.push(".lock");
2427 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
2428
2429 let opts = ValidationOptions {
2430 spreadsheet_id: "sheet-1".to_string(),
2431 verb: set_verb(Condition::Checkbox, false),
2432 dry_run: false,
2433 lease_token,
2434 ledger_path,
2435 };
2436 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2437 assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
2438 }
2439
2440 #[tokio::test]
2441 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
2442 let server = wiremock::MockServer::start().await;
2447 let (drive, sheets) = clients(&server).await;
2448 mount_file(
2449 "sheet-1",
2450 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2451 &["folder-1"],
2452 )
2453 .up_to_n_times(1)
2454 .with_priority(1)
2455 .mount(&server)
2456 .await;
2457 wiremock::Mock::given(wiremock::matchers::method("GET"))
2458 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2459 .respond_with(wiremock::ResponseTemplate::new(500))
2460 .with_priority(2)
2461 .mount(&server)
2462 .await;
2463 mount_folder("folder-1").mount(&server).await;
2464 mount_workbook().mount(&server).await;
2465 wiremock::Mock::given(wiremock::matchers::method("POST"))
2466 .and(wiremock::matchers::path(
2467 "/v4/spreadsheets/sheet-1:batchUpdate",
2468 ))
2469 .respond_with(wiremock::ResponseTemplate::new(200))
2470 .expect(0)
2471 .mount(&server)
2472 .await;
2473 let rules = vec![allow_rule("folder-1")];
2474 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2475
2476 let opts = ValidationOptions {
2477 spreadsheet_id: "sheet-1".to_string(),
2478 verb: set_verb(Condition::Checkbox, false),
2479 dry_run: false,
2480 lease_token,
2481 ledger_path,
2482 };
2483 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2484 assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
2485 }
2486
2487 #[tokio::test]
2488 async fn refuses_an_unknown_lease_token() {
2489 let server = wiremock::MockServer::start().await;
2490 let (drive, sheets) = clients(&server).await;
2491 mount_file(
2492 "sheet-1",
2493 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2494 &["folder-1"],
2495 )
2496 .mount(&server)
2497 .await;
2498 mount_folder("folder-1").mount(&server).await;
2499 mount_workbook().mount(&server).await;
2500 let rules = vec![allow_rule("folder-1")];
2501 let ledger_path = tempfile::tempdir()
2502 .unwrap()
2503 .keep()
2504 .join("lease-ledger.jsonl");
2505 let opts = ValidationOptions {
2508 spreadsheet_id: "sheet-1".to_string(),
2509 verb: set_verb(Condition::Checkbox, false),
2510 dry_run: false,
2511 lease_token: Some("bogus-token".to_string()),
2512 ledger_path,
2513 };
2514 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2515 assert!(matches!(
2516 outcome.result,
2517 ValidationResult::RefusedLeaseExpired
2518 ));
2519 }
2520
2521 #[tokio::test]
2522 async fn refuses_a_lease_bound_to_a_different_file() {
2523 let server = wiremock::MockServer::start().await;
2524 let (drive, sheets) = clients(&server).await;
2525 mount_file(
2526 "sheet-1",
2527 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2528 &["folder-1"],
2529 )
2530 .mount(&server)
2531 .await;
2532 mount_folder("folder-1").mount(&server).await;
2533 mount_workbook().mount(&server).await;
2534 let rules = vec![allow_rule("folder-1")];
2535 let ledger_path = tempfile::tempdir()
2536 .unwrap()
2537 .keep()
2538 .join("lease-ledger.jsonl");
2539 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2541
2542 let opts = ValidationOptions {
2543 spreadsheet_id: "sheet-1".to_string(),
2544 verb: set_verb(Condition::Checkbox, false),
2545 dry_run: false,
2546 lease_token: Some(token),
2547 ledger_path,
2548 };
2549 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2550 assert!(matches!(
2551 outcome.result,
2552 ValidationResult::RefusedLeaseWrongFile
2553 ));
2554 }
2555
2556 #[tokio::test]
2557 async fn refuses_a_stale_lease_when_the_file_has_moved() {
2558 let server = wiremock::MockServer::start().await;
2559 let (drive, sheets) = clients(&server).await;
2560 mount_file(
2563 "sheet-1",
2564 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2565 &["folder-1"],
2566 )
2567 .mount(&server)
2568 .await;
2569 mount_folder("folder-1").mount(&server).await;
2570 mount_workbook().mount(&server).await;
2571 let rules = vec![allow_rule("folder-1")];
2572 let ledger_path = tempfile::tempdir()
2573 .unwrap()
2574 .keep()
2575 .join("lease-ledger.jsonl");
2576 let token = seed_lease(&ledger_path, "sheet-1", "0");
2577
2578 let opts = ValidationOptions {
2579 spreadsheet_id: "sheet-1".to_string(),
2580 verb: set_verb(Condition::Checkbox, false),
2581 dry_run: false,
2582 lease_token: Some(token),
2583 ledger_path,
2584 };
2585 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2586 assert!(matches!(
2587 outcome.result,
2588 ValidationResult::RefusedLeaseStale
2589 ));
2590 }
2591
2592 #[tokio::test]
2593 async fn require_lease_false_writes_without_a_lease() {
2594 let server = wiremock::MockServer::start().await;
2595 let (drive, sheets) = clients(&server).await;
2596 mount_file(
2597 "sheet-1",
2598 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2599 &["folder-1"],
2600 )
2601 .mount(&server)
2602 .await;
2603 mount_folder("folder-1").mount(&server).await;
2604 mount_workbook().mount(&server).await;
2605 wiremock::Mock::given(wiremock::matchers::method("POST"))
2606 .and(wiremock::matchers::path(
2607 "/v4/spreadsheets/sheet-1:batchUpdate",
2608 ))
2609 .respond_with(
2610 wiremock::ResponseTemplate::new(200)
2611 .set_body_json(serde_json::json!({"replies": [{}]})),
2612 )
2613 .mount(&server)
2614 .await;
2615 let rule = FolderPermissionRule {
2616 folder_id: Some("folder-1".to_string()),
2617 file_id: None,
2618 recursive: true,
2619 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
2620 deny: HashSet::default(),
2621 require_lease: false,
2622 };
2623
2624 let opts = ValidationOptions {
2626 spreadsheet_id: "sheet-1".to_string(),
2627 verb: set_verb(Condition::Checkbox, false),
2628 dry_run: false,
2629 lease_token: None,
2630 ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
2631 };
2632 let outcome = validation(&drive, &sheets, &opts, &[rule]).await;
2633 assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
2634 }
2635
2636 #[tokio::test]
2637 async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
2638 let server = wiremock::MockServer::start().await;
2642 let (drive, sheets) = clients(&server).await;
2643 mount_file(
2646 "sheet-1",
2647 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2648 &["folder-1"],
2649 )
2650 .mount(&server)
2651 .await;
2652 mount_folder("folder-1").mount(&server).await;
2653 mount_workbook().mount(&server).await;
2654 let rule = FolderPermissionRule {
2657 folder_id: Some("folder-1".to_string()),
2658 file_id: None,
2659 recursive: true,
2660 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
2661 deny: HashSet::default(),
2662 require_lease: false,
2663 };
2664 let ledger_path = tempfile::tempdir()
2665 .unwrap()
2666 .keep()
2667 .join("lease-ledger.jsonl");
2668 let token = seed_lease(&ledger_path, "sheet-1", "0");
2669
2670 let opts = ValidationOptions {
2671 spreadsheet_id: "sheet-1".to_string(),
2672 verb: set_verb(Condition::Checkbox, false),
2673 dry_run: false,
2674 lease_token: Some(token),
2675 ledger_path,
2676 };
2677 let outcome = validation(&drive, &sheets, &opts, &[rule]).await;
2678 assert!(matches!(
2679 outcome.result,
2680 ValidationResult::RefusedLeaseStale
2681 ));
2682 }
2683
2684 #[tokio::test]
2687 async fn a_leased_validation_change_concludes_its_audit_pair_with_allowed() {
2688 let server = wiremock::MockServer::start().await;
2689 let (drive, sheets) = clients(&server).await;
2690 mount_file(
2691 "sheet-1",
2692 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2693 &["folder-1"],
2694 )
2695 .mount(&server)
2696 .await;
2697 mount_folder("folder-1").mount(&server).await;
2698 mount_workbook().mount(&server).await;
2699 wiremock::Mock::given(wiremock::matchers::method("POST"))
2700 .and(wiremock::matchers::path(
2701 "/v4/spreadsheets/sheet-1:batchUpdate",
2702 ))
2703 .respond_with(
2704 wiremock::ResponseTemplate::new(200)
2705 .set_body_json(serde_json::json!({"replies": [{}]})),
2706 )
2707 .mount(&server)
2708 .await;
2709 let dir = tempfile::tempdir().unwrap();
2710 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2711 let rules = vec![allow_rule("folder-1")];
2712 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2713 let opts = ValidationOptions {
2714 spreadsheet_id: "sheet-1".to_string(),
2715 verb: ValidationVerb::SetDataValidation {
2716 sheet: Some("Q1".to_string()),
2717 range: Some("A1:A10".to_string()),
2718 condition: Condition::OneOfList(vec!["yes".to_string(), "no".to_string()]),
2719 input_message: None,
2720 show_warning: false,
2721 },
2722 dry_run: false,
2723 lease_token,
2724 ledger_path,
2725 };
2726
2727 let outcome = validation(&drive, &sheets, &opts, &rules).await;
2728 assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
2729
2730 let records = audit.records();
2731 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
2732 assert_eq!(records[0].command, ["drive", "sheets-set-data-validation"]);
2736 }
2737}