Skip to main content

omni_dev/drive/sheets/
protection.rs

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