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