use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::lease::check::{
conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
LeasedWrite,
};
use crate::drive::sheets::a1;
use crate::drive::sheets::api::{
applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::date_value::{
reject_blank, reject_blank_date, reject_empty, reject_invalid_date_between, reject_nan,
reject_reversed_range, DateValue,
};
use crate::drive::sheets::grid_range;
use crate::drive::sheets::target_gate;
use crate::drive::sheets::types::{
BatchUpdateRequestItem, BooleanCondition, ConditionValue, DataValidationRule, GridRange,
SetDataValidationRequest,
};
use crate::drive::types::SheetTargetRefusal;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
#[derive(Debug, Clone, PartialEq)]
pub enum Condition {
OneOfList(Vec<String>),
OneOfRange(String),
NumberBetween(f64, f64),
NumberNotBetween(f64, f64),
NumberGreater(f64),
NumberGreaterEq(f64),
NumberLess(f64),
NumberLessEq(f64),
NumberEq(f64),
NumberNotEq(f64),
TextContains(String),
TextNotContains(String),
TextStartsWith(String),
TextEndsWith(String),
TextEq(String),
DateAfter(DateValue),
DateBefore(DateValue),
DateOn(DateValue),
DateBetween(String, String),
Blank,
NotBlank,
Checkbox,
CustomFormula(String),
}
impl Condition {
const fn condition_type_str(&self) -> &'static str {
match self {
Self::OneOfList(_) => "ONE_OF_LIST",
Self::OneOfRange(_) => "ONE_OF_RANGE",
Self::NumberBetween(..) => "NUMBER_BETWEEN",
Self::NumberNotBetween(..) => "NUMBER_NOT_BETWEEN",
Self::NumberGreater(_) => "NUMBER_GREATER",
Self::NumberGreaterEq(_) => "NUMBER_GREATER_THAN_EQ",
Self::NumberLess(_) => "NUMBER_LESS",
Self::NumberLessEq(_) => "NUMBER_LESS_THAN_EQ",
Self::NumberEq(_) => "NUMBER_EQ",
Self::NumberNotEq(_) => "NUMBER_NOT_EQ",
Self::TextContains(_) => "TEXT_CONTAINS",
Self::TextNotContains(_) => "TEXT_NOT_CONTAINS",
Self::TextStartsWith(_) => "TEXT_STARTS_WITH",
Self::TextEndsWith(_) => "TEXT_ENDS_WITH",
Self::TextEq(_) => "TEXT_EQ",
Self::DateAfter(_) => "DATE_AFTER",
Self::DateBefore(_) => "DATE_BEFORE",
Self::DateOn(_) => "DATE_EQ",
Self::DateBetween(..) => "DATE_BETWEEN",
Self::Blank => "BLANK",
Self::NotBlank => "NOT_BLANK",
Self::Checkbox => "BOOLEAN",
Self::CustomFormula(_) => "CUSTOM_FORMULA",
}
}
fn into_boolean_condition(self) -> BooleanCondition {
let value = |s: String| ConditionValue {
user_entered_value: Some(s),
relative_date: None,
};
let condition_type = self.condition_type_str().to_string();
let values = match self {
Self::OneOfList(items) => items.into_iter().map(value).collect(),
Self::OneOfRange(range) => vec![value(range)],
Self::NumberBetween(min, max) | Self::NumberNotBetween(min, max) => {
vec![value(min.to_string()), value(max.to_string())]
}
Self::NumberGreater(n)
| Self::NumberGreaterEq(n)
| Self::NumberLess(n)
| Self::NumberLessEq(n)
| Self::NumberEq(n)
| Self::NumberNotEq(n) => vec![value(n.to_string())],
Self::TextContains(text)
| Self::TextNotContains(text)
| Self::TextStartsWith(text)
| Self::TextEndsWith(text)
| Self::TextEq(text) => vec![value(text)],
Self::DateAfter(date) | Self::DateBefore(date) | Self::DateOn(date) => {
vec![date.into_condition_value()]
}
Self::DateBetween(start, end) => vec![value(start), value(end)],
Self::Blank | Self::NotBlank | Self::Checkbox => Vec::new(),
Self::CustomFormula(formula) => vec![value(formula)],
};
BooleanCondition {
condition_type,
values,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationVerb {
SetDataValidation {
sheet: Option<String>,
range: Option<String>,
condition: Condition,
input_message: Option<String>,
show_warning: bool,
},
ClearDataValidation {
sheet: Option<String>,
range: Option<String>,
},
}
impl ValidationVerb {
const fn log_operation(&self) -> &'static str {
match self {
Self::SetDataValidation { .. } => "sheets-set-data-validation",
Self::ClearDataValidation { .. } => "sheets-clear-data-validation",
}
}
const fn label(&self) -> &'static str {
match self {
Self::SetDataValidation { .. } => "set-data-validation",
Self::ClearDataValidation { .. } => "clear-data-validation",
}
}
fn sheet_and_range(&self) -> (Option<&str>, Option<&str>) {
match self {
Self::SetDataValidation { sheet, range, .. }
| Self::ClearDataValidation { sheet, range } => (sheet.as_deref(), range.as_deref()),
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationOptions {
pub spreadsheet_id: String,
pub verb: ValidationVerb,
pub dry_run: bool,
pub lease_token: Option<String>,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum ValidationResult {
WouldChange {
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
clamped_to: Option<String>,
},
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedInvalidRange {
detail: String,
},
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Changed {
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
clamped_to: Option<String>,
},
AppliedReplyUnreadable {
summary: String,
detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
clamped_to: Option<String>,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for ValidationResult {
fn from_no_lease() -> Self {
Self::RefusedNoLease
}
fn from_lease_expired() -> Self {
Self::RefusedLeaseExpired
}
fn from_lease_wrong_file() -> Self {
Self::RefusedLeaseWrongFile
}
fn from_lease_stale() -> Self {
Self::RefusedLeaseStale
}
fn from_lease_failed(detail: String) -> Self {
Self::Failed { detail }
}
}
impl ValidationResult {
fn log_status(&self) -> &'static str {
match self {
Self::WouldChange { .. } => "would-change",
Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
Self::RefusedShortcut => "refused-shortcut",
Self::RefusedNoVisibleParents => "refused-no-visible-parents",
Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
Self::RefusedInvalidRange { .. } => "refused-invalid-range",
Self::Blocked { .. } => "blocked",
Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
Self::Changed { .. } => "changed",
Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ValidationOutcome {
pub spreadsheet_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_folder_id: Option<String>,
#[serde(skip)]
pub verb: ValidationVerb,
pub result: ValidationResult,
}
impl JsonlSerialize for ValidationOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub async fn validation(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &ValidationOptions,
rules: &[FolderPermissionRule],
) -> ValidationOutcome {
let started = Instant::now();
let outcome = validation_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, opts, started.elapsed());
}
outcome
}
async fn validation_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &ValidationOptions,
rules: &[FolderPermissionRule],
) -> ValidationOutcome {
let bare = |result| ValidationOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
};
let (sheet, range) = opts.verb.sheet_and_range();
let composed_range = match a1::compose(sheet, range) {
Ok(composed) => composed,
Err(err) => {
return bare(ValidationResult::RefusedInvalidRange {
detail: err.to_string(),
})
}
};
if let ValidationVerb::SetDataValidation {
input_message: _,
condition,
..
} = &opts.verb
{
if let Err(detail) = validate_condition(condition) {
return bare(ValidationResult::RefusedInvalidRange { detail });
}
}
let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
drive,
&opts.spreadsheet_id,
DriveOperation::SheetsStructure,
rules,
)
.await
{
target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
return bare(ValidationResult::Failed { detail })
}
target_gate::TargetGateOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => ValidationResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
ValidationResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => ValidationResult::RefusedNoVisibleParents,
};
return ValidationOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
};
}
target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
return ValidationOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
verb: opts.verb.clone(),
result: ValidationResult::Failed { detail },
};
}
target_gate::TargetGateOutcome::Gated {
target,
decision,
resolved_folder_id,
requires_lease,
} => (target, decision, resolved_folder_id, requires_lease),
};
let gated = |result| ValidationOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
verb: opts.verb.clone(),
result,
};
if decision.verdict == write_gate::Verdict::Deny {
return gated(ValidationResult::Blocked {
decided_by: decision.decided_by,
});
}
let api = SheetsApi::new(sheets);
let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
Ok(workbook) => workbook,
Err(err) => {
return gated(ValidationResult::Failed {
detail: format!("{err:#}"),
})
}
};
let (sheet_title, grid) = match grid_range::resolve_grid_range(
&workbook,
&composed_range,
|detail| ValidationResult::RefusedInvalidRange { detail },
|title, available| ValidationResult::RefusedSheetNotFound { title, available },
) {
Ok(resolved) => resolved,
Err(result) => return gated(result),
};
let clamp = grid_range::clamp_to_sheet(&workbook, &grid)
.filter(|_| !grid_range::starts_past_grid(&workbook, &grid));
let clamped_to = match clamp {
None => {
return gated(ValidationResult::RefusedInvalidRange {
detail: format!(
"{composed_range} lies wholly past the sheet's current grid, so there is \
nothing for {} to apply to",
opts.verb.label()
),
})
}
Some(clamped) if clamped != grid => Some(
grid_range::bounded_range_to_a1(&sheet_title, &clamped)
.unwrap_or_else(|| grid_range::render_grid_range(&clamped)),
),
Some(_) => None,
};
let summary = describe_effect(&opts.verb);
if opts.dry_run {
return gated(ValidationResult::WouldChange {
summary,
clamped_to,
});
}
let request = build_request(&opts.verb, grid);
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets validation",
operation: opts.verb.log_operation(),
ledger_path: &opts.ledger_path,
file_id: &opts.spreadsheet_id,
};
let lease_grant = match gate_optional_leased_write(
leased,
&files_api,
requires_lease,
opts.lease_token.as_deref(),
)
.await
{
Ok(grant) => grant,
Err(err) => return gated(err.into_result()),
};
let result = match conclude_native_leased_write(
leased,
&lease_grant,
&files_api,
api.batch_update(&opts.spreadsheet_id, vec![request]).await,
|err| format!("{err:#}"),
)
.await
{
Ok(BatchUpdateOutcome::Applied(_)) => ValidationResult::Changed {
summary,
clamped_to,
},
Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
ValidationResult::AppliedReplyUnreadable {
summary,
detail,
clamped_to,
}
}
Err(err) => ValidationResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
fn validate_condition(condition: &Condition) -> Result<(), String> {
match condition {
Condition::OneOfList(items) => reject_empty_list(items, "--one-of-list"),
Condition::OneOfRange(range) => {
a1::validate_range(range).map_err(|err| format!("--one-of-range: {err:#}"))
}
Condition::NumberBetween(min, max) => reject_reversed_range(*min, *max, "--number-between"),
Condition::NumberNotBetween(min, max) => {
reject_reversed_range(*min, *max, "--number-not-between")
}
Condition::NumberGreater(n) => reject_nan(*n, "--number-greater"),
Condition::NumberGreaterEq(n) => reject_nan(*n, "--number-greater-eq"),
Condition::NumberLess(n) => reject_nan(*n, "--number-less"),
Condition::NumberLessEq(n) => reject_nan(*n, "--number-less-eq"),
Condition::NumberEq(n) => reject_nan(*n, "--number-eq"),
Condition::NumberNotEq(n) => reject_nan(*n, "--number-not-eq"),
Condition::TextContains(text) => reject_empty(text, "--text-contains"),
Condition::TextNotContains(text) => reject_empty(text, "--text-not-contains"),
Condition::TextStartsWith(text) => reject_empty(text, "--text-starts-with"),
Condition::TextEndsWith(text) => reject_empty(text, "--text-ends-with"),
Condition::TextEq(text) => reject_empty(text, "--text-eq"),
Condition::DateAfter(date) => validate_single_date(date, "--date-after"),
Condition::DateBefore(date) => validate_single_date(date, "--date-before"),
Condition::DateOn(date) => validate_single_date(date, "--date-on"),
Condition::DateBetween(start, end) => reject_invalid_date_between(start, end),
Condition::Blank | Condition::NotBlank | Condition::Checkbox => Ok(()),
Condition::CustomFormula(formula) => {
reject_blank(formula, "--custom-formula")?;
if !formula.trim_start().starts_with('=') {
return Err(format!(
"--custom-formula {formula:?} should start with '=', e.g. '=A1>0'; Sheets \
rejects a custom formula with no leading '='"
));
}
Ok(())
}
}
}
fn validate_single_date(date: &DateValue, flag: &str) -> Result<(), String> {
reject_blank_date(date, flag)?;
if matches!(date, DateValue::Relative(_)) {
return Err(format!(
"{flag} only accepts an absolute date, not a relative keyword like 'today' — \
Sheets rejects relative dates in data validation (they work only in \
add-conditional-format/update-conditional-format)"
));
}
Ok(())
}
fn reject_empty_list(items: &[String], flag: &str) -> Result<(), String> {
if items.is_empty() {
Err(format!("{flag} needs at least one value"))
} else {
Ok(())
}
}
fn describe_effect(verb: &ValidationVerb) -> String {
match verb {
ValidationVerb::SetDataValidation {
condition,
show_warning,
..
} => {
let strictness = if *show_warning {
"warn only"
} else {
"reject invalid entries"
};
format!(
"set data validation ({}, {strictness})",
condition
.condition_type_str()
.to_ascii_lowercase()
.replace('_', " ")
)
}
ValidationVerb::ClearDataValidation { .. } => "clear data validation".to_string(),
}
}
fn build_request(verb: &ValidationVerb, grid: GridRange) -> BatchUpdateRequestItem {
let rule = match verb {
ValidationVerb::SetDataValidation {
condition,
input_message,
show_warning,
..
} => Some(DataValidationRule {
condition: condition.clone().into_boolean_condition(),
input_message: input_message.clone(),
strict: Some(!show_warning),
}),
ValidationVerb::ClearDataValidation { .. } => None,
};
BatchUpdateRequestItem::SetDataValidation(SetDataValidationRequest { range: grid, rule })
}
fn record_attempt(outcome: &ValidationOutcome, opts: &ValidationOptions, duration: Duration) {
request_log::record_drive_mutation(mutation_outcome(outcome, opts, duration));
}
fn mutation_outcome(
outcome: &ValidationOutcome,
opts: &ValidationOptions,
duration: Duration,
) -> DriveMutationOutcome {
let error = match &outcome.result {
ValidationResult::Failed { detail }
| ValidationResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
_ => None,
};
let fields_changed = match &outcome.result {
ValidationResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
_ => None,
};
let decided_by = match &outcome.result {
ValidationResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let validation_type = match &opts.verb {
ValidationVerb::SetDataValidation { condition, .. } => {
Some(condition.condition_type_str().to_string())
}
ValidationVerb::ClearDataValidation { .. } => Some("cleared".to_string()),
};
DriveMutationOutcome {
operation: opts.verb.log_operation(),
file_id: outcome.spreadsheet_id.clone(),
file_name: outcome.file_name.clone().unwrap_or_default(),
status: outcome.result.log_status().to_string(),
resolved_folder_id: outcome.resolved_folder_id.clone(),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
validation_type,
fields_changed,
error,
duration,
..Default::default()
}
}
fn clamped_applied_line(applied: &str) -> String {
format!(
" the range ran past the sheet's current grid; Sheets clamped it, so this applied to \
{applied} only"
)
}
#[must_use]
pub fn describe(outcome: &ValidationOutcome) -> String {
describe_lines(outcome).join("\n")
}
#[must_use]
pub fn describe_lines(outcome: &ValidationOutcome) -> Vec<String> {
let verb = &outcome.verb;
let book = outcome.file_name.as_deref().map_or_else(
|| format!("'{}'", outcome.spreadsheet_id),
|n| format!("'{n}'"),
);
match &outcome.result {
ValidationResult::WouldChange {
summary,
clamped_to,
} => {
let mut lines = vec![format!("Would {summary} in {book}")];
lines.extend(clamped_to.as_ref().map(|applied| {
format!(
" the range runs past the sheet's current grid; Sheets clamps it, so this \
would apply to {applied} only"
)
}));
lines
}
ValidationResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
"Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
`drive sheets {}` only works on spreadsheets",
verb.label()
)],
ValidationResult::RefusedShortcut => vec![format!(
"Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
verb.label()
)],
ValidationResult::RefusedNoVisibleParents => vec![format!(
"Refused: {book} has no parent folder visible to this account, so no folder \
rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
)],
ValidationResult::RefusedSheetNotFound { title, available } => {
vec![grid_range::no_sheet_refusal(&book, title, available)]
}
ValidationResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
ValidationResult::Blocked { decided_by } => vec![match decided_by {
Some(rule) => format!(
"Blocked: {} on {book} refused by rule on {} {}{}",
verb.label(),
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: {} on {book} refused by default policy (no matching rule for \
sheets-structure)",
verb.label()
),
}],
ValidationResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
ValidationResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
ValidationResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
ValidationResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
ValidationResult::Changed {
summary,
clamped_to,
} => {
let mut lines = vec![format!("Applied: {summary} in {book}")];
lines.extend(clamped_to.as_deref().map(clamped_applied_line));
lines
}
ValidationResult::AppliedReplyUnreadable {
summary,
detail,
clamped_to,
} => {
let mut lines = vec![applied_reply_unreadable_line(
summary,
&book,
RetryHint::Idempotent,
detail,
)];
lines.extend(clamped_to.as_deref().map(clamped_applied_line));
lines
}
ValidationResult::Failed { detail } => vec![format!("Failed: {detail}")],
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::drive::test_support::seed_lease;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use std::collections::HashSet;
#[test]
fn one_of_list_builds_a_value_per_item() {
let condition = Condition::OneOfList(vec!["a".to_string(), "b".to_string()]);
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, "ONE_OF_LIST");
assert_eq!(built.values.len(), 2);
assert_eq!(built.values[0].user_entered_value, Some("a".to_string()));
}
#[test]
fn one_of_range_builds_a_single_value() {
let condition = Condition::OneOfRange("Sheet2!A1:A10".to_string());
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, "ONE_OF_RANGE");
assert_eq!(
built.values[0].user_entered_value,
Some("Sheet2!A1:A10".to_string())
);
}
#[test]
fn number_between_builds_two_values() {
let condition = Condition::NumberBetween(1.0, 10.0);
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, "NUMBER_BETWEEN");
assert_eq!(built.values[0].user_entered_value, Some("1".to_string()));
assert_eq!(built.values[1].user_entered_value, Some("10".to_string()));
}
#[test]
fn number_not_between_builds_two_values() {
let condition = Condition::NumberNotBetween(1.0, 10.0);
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, "NUMBER_NOT_BETWEEN");
assert_eq!(built.values.len(), 2);
}
#[test]
fn numeric_comparators_build_a_single_value_each() {
let cases = [
(Condition::NumberGreater(1.0), "NUMBER_GREATER"),
(Condition::NumberGreaterEq(1.0), "NUMBER_GREATER_THAN_EQ"),
(Condition::NumberLess(1.0), "NUMBER_LESS"),
(Condition::NumberLessEq(1.0), "NUMBER_LESS_THAN_EQ"),
(Condition::NumberEq(1.0), "NUMBER_EQ"),
(Condition::NumberNotEq(1.0), "NUMBER_NOT_EQ"),
];
for (condition, expected_type) in cases {
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, expected_type);
assert_eq!(
built.values,
vec![ConditionValue {
user_entered_value: Some("1".to_string()),
relative_date: None,
}]
);
}
}
#[test]
fn text_conditions_build_a_single_value_each() {
let cases = [
(Condition::TextContains("x".to_string()), "TEXT_CONTAINS"),
(
Condition::TextNotContains("x".to_string()),
"TEXT_NOT_CONTAINS",
),
(
Condition::TextStartsWith("x".to_string()),
"TEXT_STARTS_WITH",
),
(Condition::TextEndsWith("x".to_string()), "TEXT_ENDS_WITH"),
(Condition::TextEq("x".to_string()), "TEXT_EQ"),
];
for (condition, expected_type) in cases {
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, expected_type);
assert_eq!(built.values[0].user_entered_value, Some("x".to_string()));
}
}
#[test]
fn date_conditions_build_an_absolute_value() {
let cases = [
(
Condition::DateAfter(DateValue::Absolute("2024-01-01".to_string())),
"DATE_AFTER",
),
(
Condition::DateBefore(DateValue::Absolute("2024-01-01".to_string())),
"DATE_BEFORE",
),
(
Condition::DateOn(DateValue::Absolute("2024-01-01".to_string())),
"DATE_EQ",
),
];
for (condition, expected_type) in cases {
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, expected_type);
assert_eq!(
built.values[0].user_entered_value,
Some("2024-01-01".to_string())
);
assert_eq!(built.values[0].relative_date, None);
}
}
#[test]
fn date_between_builds_two_absolute_values() {
let condition = Condition::DateBetween("2024-01-01".to_string(), "2024-12-31".to_string());
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, "DATE_BETWEEN");
assert_eq!(built.values.len(), 2);
}
#[test]
fn blank_and_not_blank_have_no_values() {
assert_eq!(
Condition::Blank.into_boolean_condition().condition_type,
"BLANK"
);
assert!(Condition::Blank.into_boolean_condition().values.is_empty());
assert_eq!(
Condition::NotBlank.into_boolean_condition().condition_type,
"NOT_BLANK"
);
assert!(Condition::NotBlank
.into_boolean_condition()
.values
.is_empty());
}
#[test]
fn checkbox_has_no_values() {
let built = Condition::Checkbox.into_boolean_condition();
assert_eq!(built.condition_type, "BOOLEAN");
assert!(built.values.is_empty());
}
#[test]
fn custom_formula_builds_a_single_value() {
let condition = Condition::CustomFormula("=A1>0".to_string());
let built = condition.into_boolean_condition();
assert_eq!(built.condition_type, "CUSTOM_FORMULA");
assert_eq!(built.values.len(), 1);
assert_eq!(
built.values[0].user_entered_value,
Some("=A1>0".to_string())
);
}
#[test]
fn condition_type_str_covers_every_condition() {
let types = [
Condition::OneOfList(vec!["a".to_string()]).condition_type_str(),
Condition::OneOfRange("A1:A10".to_string()).condition_type_str(),
Condition::NumberBetween(1.0, 2.0).condition_type_str(),
Condition::NumberNotBetween(1.0, 2.0).condition_type_str(),
Condition::NumberGreater(1.0).condition_type_str(),
Condition::NumberGreaterEq(1.0).condition_type_str(),
Condition::NumberLess(1.0).condition_type_str(),
Condition::NumberLessEq(1.0).condition_type_str(),
Condition::NumberEq(1.0).condition_type_str(),
Condition::NumberNotEq(1.0).condition_type_str(),
Condition::TextContains("x".to_string()).condition_type_str(),
Condition::TextNotContains("x".to_string()).condition_type_str(),
Condition::TextStartsWith("x".to_string()).condition_type_str(),
Condition::TextEndsWith("x".to_string()).condition_type_str(),
Condition::TextEq("x".to_string()).condition_type_str(),
Condition::DateAfter(DateValue::Absolute("d".to_string())).condition_type_str(),
Condition::DateBefore(DateValue::Absolute("d".to_string())).condition_type_str(),
Condition::DateOn(DateValue::Absolute("d".to_string())).condition_type_str(),
Condition::DateBetween("a".to_string(), "b".to_string()).condition_type_str(),
Condition::Blank.condition_type_str(),
Condition::NotBlank.condition_type_str(),
Condition::Checkbox.condition_type_str(),
Condition::CustomFormula("=TRUE".to_string()).condition_type_str(),
];
let unique: HashSet<&&str> = types.iter().collect();
assert_eq!(unique.len(), types.len());
}
fn set_verb(condition: Condition, show_warning: bool) -> ValidationVerb {
ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
condition,
input_message: None,
show_warning,
}
}
fn clear_verb() -> ValidationVerb {
ValidationVerb::ClearDataValidation {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
}
}
#[test]
fn log_operation_and_label_cover_every_verb() {
let set = set_verb(Condition::Checkbox, false);
let clear = clear_verb();
assert_eq!(set.log_operation(), "sheets-set-data-validation");
assert_eq!(clear.log_operation(), "sheets-clear-data-validation");
assert_eq!(set.label(), "set-data-validation");
assert_eq!(clear.label(), "clear-data-validation");
}
#[test]
fn describe_effect_covers_every_condition_and_strictness() {
let reject = describe_effect(&set_verb(
Condition::OneOfList(vec!["a".to_string()]),
false,
));
assert!(reject.contains("reject invalid entries"), "{reject}");
assert!(reject.contains("one of list"), "{reject}");
let warn = describe_effect(&set_verb(Condition::NumberBetween(1.0, 10.0), true));
assert!(warn.contains("warn only"), "{warn}");
assert!(warn.contains("number between"), "{warn}");
let checkbox = describe_effect(&set_verb(Condition::Checkbox, false));
assert!(checkbox.contains("boolean"), "{checkbox}");
let formula = describe_effect(&set_verb(
Condition::CustomFormula("=A1>0".to_string()),
false,
));
assert!(formula.contains("custom formula"), "{formula}");
assert_eq!(describe_effect(&clear_verb()), "clear data validation");
}
#[test]
fn validate_condition_accepts_a_valid_number_between() {
validate_condition(&Condition::NumberBetween(1.0, 10.0)).unwrap();
}
#[test]
fn validate_condition_accepts_a_non_empty_custom_formula() {
validate_condition(&Condition::CustomFormula("=A1>0".to_string())).unwrap();
}
#[test]
fn validate_condition_rejects_an_empty_one_of_list() {
let err = validate_condition(&Condition::OneOfList(Vec::new())).unwrap_err();
assert!(err.contains("at least one value"), "{err}");
}
#[test]
fn validate_condition_rejects_a_reversed_number_between() {
let err = validate_condition(&Condition::NumberBetween(10.0, 1.0)).unwrap_err();
assert!(err.contains("must not exceed"), "{err}");
}
#[test]
fn validate_condition_rejects_a_nan_bound() {
let err = validate_condition(&Condition::NumberBetween(f64::NAN, 5.0)).unwrap_err();
assert!(err.contains("must not exceed"), "{err}");
let err = validate_condition(&Condition::NumberBetween(1.0, f64::NAN)).unwrap_err();
assert!(err.contains("must not exceed"), "{err}");
}
#[test]
fn validate_condition_rejects_a_blank_custom_formula() {
let err = validate_condition(&Condition::CustomFormula(" ".to_string())).unwrap_err();
assert!(err.contains("must not be empty"), "{err}");
}
#[test]
fn validate_condition_rejects_a_custom_formula_missing_its_leading_equals() {
let err = validate_condition(&Condition::CustomFormula("F2>0".to_string())).unwrap_err();
assert!(err.contains("should start with '='"), "{err}");
}
#[test]
fn validate_condition_accepts_a_valid_one_of_range() {
validate_condition(&Condition::OneOfRange("Sheet2!A1:A10".to_string())).unwrap();
}
#[test]
fn validate_condition_rejects_a_blank_one_of_range() {
let err = validate_condition(&Condition::OneOfRange(String::new())).unwrap_err();
assert!(err.contains("must not be empty"), "{err}");
assert!(!err.contains("--sheet"), "{err}");
}
#[test]
fn validate_condition_accepts_a_valid_number_not_between() {
validate_condition(&Condition::NumberNotBetween(1.0, 10.0)).unwrap();
}
#[test]
fn validate_condition_rejects_a_reversed_number_not_between() {
let err = validate_condition(&Condition::NumberNotBetween(10.0, 1.0)).unwrap_err();
assert!(err.contains("must not exceed"), "{err}");
}
#[test]
fn validate_condition_rejects_a_nan_comparator() {
let err = validate_condition(&Condition::NumberGreater(f64::NAN)).unwrap_err();
assert!(err.contains("must not be NaN"), "{err}");
}
#[test]
fn validate_condition_accepts_a_numeric_comparator() {
validate_condition(&Condition::NumberEq(1.0)).unwrap();
}
#[test]
fn validate_condition_rejects_empty_text() {
let err = validate_condition(&Condition::TextContains(String::new())).unwrap_err();
assert!(err.contains("must not be empty"), "{err}");
}
#[test]
fn validate_condition_accepts_non_blank_text() {
validate_condition(&Condition::TextEq("x".to_string())).unwrap();
}
#[test]
fn validate_condition_accepts_whitespace_only_text() {
validate_condition(&Condition::TextContains(" ".to_string())).unwrap();
}
#[test]
fn validate_condition_rejects_a_blank_absolute_date() {
let err = validate_condition(&Condition::DateAfter(DateValue::Absolute(" ".to_string())))
.unwrap_err();
assert!(err.contains("must not be empty"), "{err}");
}
#[test]
fn validate_condition_rejects_a_relative_date() {
for (condition, flag) in [
(
Condition::DateAfter(DateValue::parse("today".to_string())),
"--date-after",
),
(
Condition::DateBefore(DateValue::parse("past-week".to_string())),
"--date-before",
),
(
Condition::DateOn(DateValue::parse("yesterday".to_string())),
"--date-on",
),
] {
let err = validate_condition(&condition).unwrap_err();
assert!(err.contains(flag), "{err}");
assert!(err.contains("relative"), "{err}");
}
}
#[test]
fn validate_condition_accepts_an_absolute_date() {
validate_condition(&Condition::DateAfter(DateValue::parse(
"2024-01-01".to_string(),
)))
.unwrap();
}
#[test]
fn validate_condition_accepts_a_valid_date_between() {
validate_condition(&Condition::DateBetween(
"2024-01-01".to_string(),
"2024-12-31".to_string(),
))
.unwrap();
}
#[test]
fn validate_condition_rejects_a_blank_date_between_bound() {
let err = validate_condition(&Condition::DateBetween(
"2024-01-01".to_string(),
" ".to_string(),
))
.unwrap_err();
assert!(err.contains("must not be empty"), "{err}");
}
#[test]
fn validate_condition_rejects_a_relative_keyword_in_date_between() {
let err = validate_condition(&Condition::DateBetween(
"today".to_string(),
"2024-12-31".to_string(),
))
.unwrap_err();
assert!(err.contains("absolute dates"), "{err}");
assert!(!err.contains("--date-after"), "{err}");
let err = validate_condition(&Condition::DateBetween(
"2024-01-01".to_string(),
"tomorrow".to_string(),
))
.unwrap_err();
assert!(err.contains("absolute dates"), "{err}");
}
#[test]
fn validate_condition_rejects_a_reversed_date_between() {
let err = validate_condition(&Condition::DateBetween(
"2024-12-31".to_string(),
"2024-01-01".to_string(),
))
.unwrap_err();
assert!(err.contains("must not be after"), "{err}");
}
#[test]
fn validate_condition_trusts_a_non_iso_date_between_through_untouched() {
validate_condition(&Condition::DateBetween(
"12/31/2024".to_string(),
"01/01/2024".to_string(),
))
.unwrap();
}
#[test]
fn validate_condition_accepts_blank_and_not_blank() {
validate_condition(&Condition::Blank).unwrap();
validate_condition(&Condition::NotBlank).unwrap();
}
#[test]
fn clear_sets_no_rule() {
let grid = GridRange {
sheet_id: 1,
..Default::default()
};
let request = build_request(
&ValidationVerb::ClearDataValidation {
sheet: None,
range: Some("A1".to_string()),
},
grid,
);
match request {
BatchUpdateRequestItem::SetDataValidation(req) => assert!(req.rule.is_none()),
other => panic!("expected SetDataValidation, got {other:?}"),
}
}
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token", "expires_in": 3600,
})),
)
.mount(server)
.await;
let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut drive,
&test_credentials(),
&format!("{}/token", server.uri()),
);
let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
(drive, sheets)
}
fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
let parents: Vec<&str> = parents.to_vec();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": mime_type, "parents": parents,
"version": "1",
})),
)
}
fn mount_folder(id: &str) -> wiremock::Mock {
mount_file(id, "application/vnd.google-apps.folder", &[])
}
fn allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some(folder.to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
deny: HashSet::default(),
require_lease: true,
}
}
fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, spreadsheet_id, "1");
(Some(token), ledger_path)
}
fn mount_workbook() -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
],
})),
)
}
#[tokio::test]
async fn a_denied_gate_blocks_before_any_batch_update_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
condition: Condition::Checkbox,
input_message: None,
show_warning: false,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Blocked { .. }));
}
#[tokio::test]
async fn set_data_validation_sends_a_boolean_condition() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
condition: Condition::OneOfList(vec!["yes".to_string(), "no".to_string()]),
input_message: Some("pick one".to_string()),
show_warning: false,
},
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let batch = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
let rule = &body["requests"][0]["setDataValidation"]["rule"];
assert_eq!(rule["condition"]["type"], "ONE_OF_LIST");
assert_eq!(rule["strict"], true);
}
#[tokio::test]
async fn set_data_validation_sends_a_tranche_2_comparator_and_relative_date() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
condition: Condition::NumberGreater(0.0),
input_message: None,
show_warning: false,
},
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let batch = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
let rule = &body["requests"][0]["setDataValidation"]["rule"];
assert_eq!(rule["condition"]["type"], "NUMBER_GREATER");
assert_eq!(rule["condition"]["values"][0]["userEnteredValue"], "0");
assert!(rule["condition"]["values"][0]["relativeDate"].is_null());
}
#[tokio::test]
async fn set_data_validation_refuses_a_relative_date_condition() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
condition: Condition::DateAfter(DateValue::parse("today".to_string())),
input_message: None,
show_warning: false,
},
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
match outcome.result {
ValidationResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--date-after"), "{detail}");
assert!(detail.contains("relative"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn clear_data_validation_full_apply_flow() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: clear_verb(),
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let batch = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
assert!(body["requests"][0]["setDataValidation"]["rule"].is_null());
}
#[tokio::test]
async fn dry_run_reports_would_change_without_calling_batch_update() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: true,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
ValidationResult::WouldChange { .. }
));
let requests = server.received_requests().await.unwrap();
assert!(!requests
.iter()
.any(|r| r.url.path().ends_with(":batchUpdate")));
}
async fn dry_run_on_a_sized_grid(range: &str) -> ValidationOutcome {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [{"properties": {"sheetId": 0, "title": "Q1", "index": 0,
"gridProperties": {"rowCount": 1000, "columnCount": 26}}}],
})),
)
.mount(&server)
.await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some(range.to_string()),
condition: Condition::Checkbox,
input_message: None,
show_warning: false,
},
dry_run: true,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
validation(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await
}
#[tokio::test]
async fn a_range_past_the_grid_names_the_clamped_range() {
let outcome = dry_run_on_a_sized_grid("Z1:AA2000").await;
assert_eq!(
outcome.result,
ValidationResult::WouldChange {
summary: describe_effect(&set_verb(Condition::Checkbox, false)),
clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
}
);
let text = describe(&outcome);
assert!(text.contains("would apply to 'Q1'!Z1:Z1000 only"), "{text}");
let inside = dry_run_on_a_sized_grid("A1:B10").await;
assert!(
matches!(
inside.result,
ValidationResult::WouldChange {
clamped_to: None,
..
}
),
"{:?}",
inside.result
);
}
#[tokio::test]
async fn a_range_wholly_past_the_grid_is_refused() {
for range in ["AB1:AC5", "A2000:A"] {
let outcome = dry_run_on_a_sized_grid(range).await;
let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("wholly past the sheet's current grid"),
"{detail}"
);
}
let inside = dry_run_on_a_sized_grid("A5:A").await;
assert!(
matches!(inside.result, ValidationResult::WouldChange { .. }),
"{:?}",
inside.result
);
}
#[tokio::test]
async fn compose_error_returns_refused_invalid_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: None,
range: None,
condition: Condition::Checkbox,
input_message: None,
show_warning: false,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &[]).await;
let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("a range is required"), "{detail}");
assert!(outcome.file_name.is_none());
}
#[tokio::test]
async fn invalid_condition_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::NumberBetween(10.0, 1.0), false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &[]).await;
let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("must not exceed"), "{detail}");
let requests = server.received_requests().await.unwrap();
assert!(!requests
.iter()
.any(|r| r.url.path().starts_with("/drive/v3/files")));
}
#[tokio::test]
async fn metadata_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &[]).await;
assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
assert!(outcome.file_name.is_none());
}
#[tokio::test]
async fn shortcut_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["folder-1"],
)
.mount(&server)
.await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &[]).await;
assert!(matches!(outcome.result, ValidationResult::RefusedShortcut));
assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
}
#[tokio::test]
async fn not_a_spreadsheet_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", "application/pdf", &["folder-1"])
.mount(&server)
.await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &[]).await;
assert!(matches!(
outcome.result,
ValidationResult::RefusedNotASpreadsheet { ref mime_type } if mime_type == "application/pdf"
));
}
#[tokio::test]
async fn no_visible_parents_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &[]).await;
assert!(matches!(
outcome.result,
ValidationResult::RefusedNoVisibleParents
));
}
#[tokio::test]
async fn gate_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &[]).await;
assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
}
#[tokio::test]
async fn workbook_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
}
#[tokio::test]
async fn sheet_not_found_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Nope".to_string()),
range: Some("A1:A10".to_string()),
condition: Condition::Checkbox,
input_message: None,
show_warning: false,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
let ValidationResult::RefusedSheetNotFound { title, available } = &outcome.result else {
panic!("expected RefusedSheetNotFound, got {:?}", outcome.result);
};
assert_eq!(title, "Nope");
assert_eq!(available, &["Q1".to_string()]);
}
#[tokio::test]
async fn invalid_grid_range_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some("??".to_string()),
condition: Condition::Checkbox,
input_message: None,
show_warning: false,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
ValidationResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn batch_update_failure_is_reported_as_failed_not_changed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": {
"code": 403,
"message": "The caller does not have permission",
"status": "PERMISSION_DENIED",
}
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
}
#[tokio::test]
async fn set_with_an_unreadable_reply_reports_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.expect(1)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert_eq!(
outcome.result.log_status(),
"applied-reply-unreadable",
"{:?}",
outcome.result
);
let text = describe(&outcome);
assert!(
text.starts_with("Applied, but the reply could not be read"),
"{text}"
);
}
#[test]
fn describe_lines_renders_applied_reply_unreadable() {
let outcome = ValidationOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: clear_verb(),
result: ValidationResult::AppliedReplyUnreadable {
summary: "clear data validation".to_string(),
detail: "bad reply".to_string(),
clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
},
};
assert_eq!(
describe_lines(&outcome),
vec![
"Applied, but the reply could not be read: clear data validation in 'Budget' — \
check the spreadsheet to confirm (bad reply)"
.to_string(),
" the range ran past the sheet's current grid; Sheets clamped it, so this \
applied to 'Q1'!Z1:Z1000 only"
.to_string(),
]
);
}
#[test]
fn the_unreadable_reply_logs_everything_changed_logs() {
let outcome = ValidationOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: set_verb(Condition::Checkbox, false),
result: ValidationResult::AppliedReplyUnreadable {
summary: "set data validation".to_string(),
detail: "bad reply".to_string(),
clamped_to: None,
},
};
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let logged = mutation_outcome(&outcome, &opts, Duration::ZERO);
assert_eq!(logged.status, "applied-reply-unreadable");
assert_eq!(
logged.validation_type.as_deref(),
Some(Condition::Checkbox.condition_type_str())
);
assert_eq!(
logged.fields_changed.as_deref(),
Some("set data validation")
);
assert_eq!(logged.error.as_deref(), Some("bad reply"));
}
fn every_validation_result() -> Vec<ValidationResult> {
let all = vec![
ValidationResult::WouldChange {
summary: "set data validation".to_string(),
clamped_to: None,
},
ValidationResult::WouldChange {
summary: "set data validation".to_string(),
clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
},
ValidationResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
},
ValidationResult::RefusedShortcut,
ValidationResult::RefusedNoVisibleParents,
ValidationResult::RefusedSheetNotFound {
title: "Nope".to_string(),
available: vec!["Q1".to_string(), "Q2".to_string()],
},
ValidationResult::RefusedSheetNotFound {
title: "Nope".to_string(),
available: Vec::new(),
},
ValidationResult::RefusedInvalidRange {
detail: "bad range".to_string(),
},
ValidationResult::Blocked { decided_by: None },
ValidationResult::Blocked {
decided_by: Some(DecidingRule::Folder {
folder_id: "folder-1".to_string(),
depth: 2,
}),
},
ValidationResult::RefusedNoLease,
ValidationResult::RefusedLeaseExpired,
ValidationResult::RefusedLeaseWrongFile,
ValidationResult::RefusedLeaseStale,
ValidationResult::Changed {
summary: "set data validation".to_string(),
clamped_to: None,
},
ValidationResult::Changed {
summary: "set data validation".to_string(),
clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
},
ValidationResult::AppliedReplyUnreadable {
summary: "set data validation".to_string(),
detail: "bad reply".to_string(),
clamped_to: None,
},
ValidationResult::AppliedReplyUnreadable {
summary: "set data validation".to_string(),
detail: "bad reply".to_string(),
clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
},
ValidationResult::Failed {
detail: "boom".to_string(),
},
];
for result in &all {
match result {
ValidationResult::WouldChange { .. }
| ValidationResult::RefusedNotASpreadsheet { .. }
| ValidationResult::RefusedShortcut
| ValidationResult::RefusedNoVisibleParents
| ValidationResult::RefusedSheetNotFound { .. }
| ValidationResult::RefusedInvalidRange { .. }
| ValidationResult::Blocked { .. }
| ValidationResult::RefusedNoLease
| ValidationResult::RefusedLeaseExpired
| ValidationResult::RefusedLeaseWrongFile
| ValidationResult::RefusedLeaseStale
| ValidationResult::Changed { .. }
| ValidationResult::AppliedReplyUnreadable { .. }
| ValidationResult::Failed { .. } => {}
}
}
all
}
#[test]
fn describe_lines_covers_every_result_for_every_verb() {
for verb in [clear_verb(), set_verb(Condition::Checkbox, false)] {
for result in every_validation_result() {
let outcome = ValidationOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: verb.clone(),
result,
};
let lines = describe_lines(&outcome);
let clamped = matches!(
&outcome.result,
ValidationResult::WouldChange {
clamped_to: Some(_),
..
} | ValidationResult::Changed {
clamped_to: Some(_),
..
} | ValidationResult::AppliedReplyUnreadable {
clamped_to: Some(_),
..
}
);
assert_eq!(
lines.len(),
1 + usize::from(clamped),
"{:?}",
outcome.result
);
assert_eq!(describe(&outcome), lines.join("\n"));
for rendered in &lines {
assert!(!rendered.chars().any(char::is_control), "{rendered:?}");
}
}
}
}
#[test]
fn log_status_covers_every_variant() {
let statuses: HashSet<&'static str> = every_validation_result()
.iter()
.map(ValidationResult::log_status)
.collect();
assert_eq!(statuses.len(), 14);
assert!(statuses.iter().all(|s| !s.is_empty()));
}
#[test]
fn write_jsonl_emits_one_line_of_json() {
let outcome = ValidationOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: clear_verb(),
result: ValidationResult::Changed {
summary: "clear data validation".to_string(),
clamped_to: None,
},
};
let mut buf = Vec::new();
outcome.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text.matches('\n').count(), 1);
let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(parsed["result"]["status"], "changed");
}
#[tokio::test]
async fn refuses_without_a_lease_when_the_rule_requires_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::RefusedNoLease));
assert_eq!(outcome.result.log_status(), "refused-no-lease");
}
#[tokio::test]
async fn reports_a_lock_acquisition_failure_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let mut lock_path = ledger_path.clone().into_os_string();
lock_path.push(".lock");
std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
}
#[tokio::test]
async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
}
#[tokio::test]
async fn refuses_an_unknown_lease_token() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: Some("bogus-token".to_string()),
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
ValidationResult::RefusedLeaseExpired
));
}
#[tokio::test]
async fn refuses_a_lease_bound_to_a_different_file() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "some-other-sheet", "1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: Some(token),
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
ValidationResult::RefusedLeaseWrongFile
));
}
#[tokio::test]
async fn refuses_a_stale_lease_when_the_file_has_moved() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: Some(token),
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
ValidationResult::RefusedLeaseStale
));
}
#[tokio::test]
async fn require_lease_false_writes_without_a_lease() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let rule = FolderPermissionRule {
folder_id: Some("folder-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
deny: HashSet::default(),
require_lease: false,
};
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
};
let outcome = validation(&drive, &sheets, &opts, &[rule]).await;
assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
}
#[tokio::test]
async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rule = FolderPermissionRule {
folder_id: Some("folder-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
deny: HashSet::default(),
require_lease: false,
};
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: set_verb(Condition::Checkbox, false),
dry_run: false,
lease_token: Some(token),
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &[rule]).await;
assert!(matches!(
outcome.result,
ValidationResult::RefusedLeaseStale
));
}
#[tokio::test]
async fn a_leased_validation_change_concludes_its_audit_pair_with_allowed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = ValidationOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: ValidationVerb::SetDataValidation {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
condition: Condition::OneOfList(vec!["yes".to_string(), "no".to_string()]),
input_message: None,
show_warning: false,
},
dry_run: false,
lease_token,
ledger_path,
};
let outcome = validation(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
assert_eq!(records[0].command, ["drive", "sheets-set-data-validation"]);
}
}