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