use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::lease::check::{
conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
LeasedWrite,
};
use crate::drive::sheets::a1;
use crate::drive::sheets::api::{SheetsApi, ValueInputOption};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::grid_range;
use crate::drive::sheets::target_gate;
use crate::drive::sheets::types::UpdateValuesResponse;
use crate::drive::types::SheetTargetRefusal;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteVerb {
Write,
Append,
Clear,
}
impl WriteVerb {
const fn log_operation(self) -> &'static str {
match self {
Self::Write => "sheets-write",
Self::Append => "sheets-append",
Self::Clear => "sheets-clear",
}
}
const fn label(self) -> &'static str {
match self {
Self::Write => "write",
Self::Append => "append",
Self::Clear => "clear",
}
}
}
#[derive(Debug, Clone)]
pub struct WriteOptions {
pub spreadsheet_id: String,
pub verb: WriteVerb,
pub range: Option<String>,
pub sheet: Option<String>,
pub values: Vec<Vec<String>>,
pub input: ValueInputOption,
pub dry_run: bool,
pub lease_token: Option<String>,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum WriteResult {
WouldWrite {
rows: usize,
columns: usize,
},
WouldClear,
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedEmptyValues,
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Written {
#[serde(skip_serializing_if = "Option::is_none")]
updated_range: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_rows: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_columns: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_cells: Option<i64>,
},
Appended {
#[serde(skip_serializing_if = "Option::is_none")]
updated_range: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_rows: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_columns: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_cells: Option<i64>,
},
Cleared {
#[serde(skip_serializing_if = "Option::is_none")]
updated_range: Option<String>,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for WriteResult {
fn from_no_lease() -> Self {
Self::RefusedNoLease
}
fn from_lease_expired() -> Self {
Self::RefusedLeaseExpired
}
fn from_lease_wrong_file() -> Self {
Self::RefusedLeaseWrongFile
}
fn from_lease_stale() -> Self {
Self::RefusedLeaseStale
}
fn from_lease_failed(detail: String) -> Self {
Self::Failed { detail }
}
}
impl WriteResult {
fn log_status(&self) -> &'static str {
match self {
Self::WouldWrite { .. } => "would-write",
Self::WouldClear => "would-clear",
Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
Self::RefusedShortcut => "refused-shortcut",
Self::RefusedNoVisibleParents => "refused-no-visible-parents",
Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
Self::RefusedEmptyValues => "refused-empty-values",
Self::Blocked { .. } => "blocked",
Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
Self::Written { .. } => "written",
Self::Appended { .. } => "appended",
Self::Cleared { .. } => "cleared",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WriteOutcome {
pub spreadsheet_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_folder_id: Option<String>,
#[serde(skip)]
pub verb: WriteVerb,
pub result: WriteResult,
}
impl JsonlSerialize for WriteOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub async fn write(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &WriteOptions,
rules: &[FolderPermissionRule],
) -> WriteOutcome {
let started = Instant::now();
let outcome = write_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, opts, started.elapsed());
}
outcome
}
async fn write_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &WriteOptions,
rules: &[FolderPermissionRule],
) -> WriteOutcome {
let bare = |result| WriteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
range: None,
resolved_folder_id: None,
verb: opts.verb,
result,
};
if matches!(opts.verb, WriteVerb::Write | WriteVerb::Append) && opts.values.is_empty() {
return bare(WriteResult::RefusedEmptyValues);
}
let range = match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
Ok(range) => range,
Err(err) => {
return bare(WriteResult::Failed {
detail: err.to_string(),
})
}
};
let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
drive,
&opts.spreadsheet_id,
DriveOperation::SheetsWrite,
rules,
)
.await
{
target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
return bare(WriteResult::Failed { detail })
}
target_gate::TargetGateOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => WriteResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
WriteResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => WriteResult::RefusedNoVisibleParents,
};
return WriteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
range: Some(range),
resolved_folder_id: None,
verb: opts.verb,
result,
};
}
target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
return WriteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
range: Some(range),
resolved_folder_id: None,
verb: opts.verb,
result: WriteResult::Failed { detail },
};
}
target_gate::TargetGateOutcome::Gated {
target,
decision,
resolved_folder_id,
requires_lease,
} => (target, decision, resolved_folder_id, requires_lease),
};
let gated = |result| WriteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
range: Some(range.clone()),
resolved_folder_id: resolved_folder_id.clone(),
verb: opts.verb,
result,
};
if decision.verdict == write_gate::Verdict::Deny {
return gated(WriteResult::Blocked {
decided_by: decision.decided_by,
});
}
if let Some(title) = opts.sheet.clone().or_else(|| a1::sheet_title_of(&range)) {
let api = SheetsApi::new(sheets);
let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
Ok(workbook) => workbook,
Err(err) => {
return gated(WriteResult::Failed {
detail: format!("{err:#}"),
})
}
};
if let Err(result) = grid_range::find_sheet_id(&workbook, &title, |title, available| {
WriteResult::RefusedSheetNotFound { title, available }
}) {
return gated(result);
}
}
if opts.dry_run {
return gated(match opts.verb {
WriteVerb::Clear => WriteResult::WouldClear,
WriteVerb::Write | WriteVerb::Append => WriteResult::WouldWrite {
rows: opts.values.len(),
columns: opts.values.iter().map(Vec::len).max().unwrap_or(0),
},
});
}
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets write",
operation: opts.verb.log_operation(),
ledger_path: &opts.ledger_path,
file_id: &opts.spreadsheet_id,
};
let lease_grant = match gate_optional_leased_write(
leased,
&files_api,
requires_lease,
opts.lease_token.as_deref(),
)
.await
{
Ok(grant) => grant,
Err(err) => return gated(err.into_result()),
};
let api = SheetsApi::new(sheets);
let result = match opts.verb {
WriteVerb::Write => api
.values_update(&opts.spreadsheet_id, &range, &opts.values, opts.input)
.await
.map(into_written),
WriteVerb::Append => api
.values_append(&opts.spreadsheet_id, &range, &opts.values, opts.input)
.await
.map(|response| into_appended(response.updates.unwrap_or_default())),
WriteVerb::Clear => api
.values_clear(&opts.spreadsheet_id, &range)
.await
.map(|response| WriteResult::Cleared {
updated_range: response.cleared_range,
}),
};
let result =
match conclude_native_leased_write(leased, &lease_grant, &files_api, result, |err| {
format!("{err:#}")
})
.await
{
Ok(written) => written,
Err(err) => WriteResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
fn into_written(response: UpdateValuesResponse) -> WriteResult {
WriteResult::Written {
updated_range: response.updated_range,
updated_rows: response.updated_rows,
updated_columns: response.updated_columns,
updated_cells: response.updated_cells,
}
}
fn into_appended(response: UpdateValuesResponse) -> WriteResult {
WriteResult::Appended {
updated_range: response.updated_range,
updated_rows: response.updated_rows,
updated_columns: response.updated_columns,
updated_cells: response.updated_cells,
}
}
fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
let error = match &outcome.result {
WriteResult::Failed { detail } => Some(detail.clone()),
_ => None,
};
let decided_by = match &outcome.result {
WriteResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let (updated_range, updated_rows, updated_columns, updated_cells) = match &outcome.result {
WriteResult::Written {
updated_range,
updated_rows,
updated_columns,
updated_cells,
}
| WriteResult::Appended {
updated_range,
updated_rows,
updated_columns,
updated_cells,
} => (
updated_range.clone(),
*updated_rows,
*updated_columns,
*updated_cells,
),
WriteResult::Cleared { updated_range } => (updated_range.clone(), None, None, None),
_ => (None, None, None, None),
};
request_log::record_drive_mutation(DriveMutationOutcome {
operation: opts.verb.log_operation(),
file_id: outcome.spreadsheet_id.clone(),
file_name: outcome.file_name.clone().unwrap_or_default(),
status: outcome.result.log_status().to_string(),
resolved_folder_id: outcome.resolved_folder_id.clone(),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
range: outcome.range.clone(),
updated_range,
updated_rows,
updated_columns,
updated_cells,
error,
duration,
..Default::default()
});
}
#[must_use]
pub fn describe(outcome: &WriteOutcome) -> String {
let verb = outcome.verb;
let name = outcome
.file_name
.as_deref()
.unwrap_or(&outcome.spreadsheet_id);
let range = outcome.range.as_deref().unwrap_or("(unresolved range)");
match &outcome.result {
WriteResult::WouldClear => format!("Would clear: {range} of '{name}'"),
WriteResult::WouldWrite { rows, columns } => format!(
"Would {}: {rows} row(s) x {columns} column(s) into {range} of '{name}'",
verb.label()
),
WriteResult::RefusedNotASpreadsheet { mime_type } => format!(
"Refused: '{name}' is not a Google Sheet (mimeType: {mime_type}); \
`drive sheets {}` only works on spreadsheets",
verb.label()
),
WriteResult::RefusedShortcut => format!(
"Refused: '{name}' is a shortcut; `drive sheets {}` doesn't follow shortcuts — \
resolve the target spreadsheet's id and use that instead",
verb.label()
),
WriteResult::RefusedNoVisibleParents => format!(
"Refused: '{name}' has no parent folder visible to this account, so no folder \
rule can apply to it. This is normal for a Sheet shared by link or email. \
Grant it by id instead: add {{\"file_id\": \"<spreadsheet id>\", \"allow\": \
[\"sheets-write\"]}} to write_permissions.rules. (Adding it to a folder in your \
own Drive and granting that folder `sheets-write` also works.)"
),
WriteResult::RefusedSheetNotFound { title, available } => {
grid_range::no_sheet_refusal(&format!("'{name}'"), title, available)
}
WriteResult::RefusedEmptyValues => format!(
"Refused: `drive sheets {}` was given no values to write (--values is empty)",
verb.label()
),
WriteResult::Blocked { decided_by } => match decided_by {
Some(rule) => format!(
"Blocked: {range} of '{name}' — refused by rule on {} {}{}",
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: {range} of '{name}' — refused by default policy (no matching rule)"
),
},
WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::Cleared { updated_range } => format!(
"Cleared {} of '{name}'",
updated_range.as_deref().unwrap_or(range)
),
WriteResult::Appended {
updated_range,
updated_cells,
..
} => {
let where_ = updated_range.as_deref().unwrap_or(range);
match updated_cells {
Some(cells) => format!("Appended {cells} cell(s) to {where_} of '{name}'"),
None => format!("Appended to {where_} of '{name}'"),
}
}
WriteResult::Written {
updated_range,
updated_cells,
..
} => {
let where_ = updated_range.as_deref().unwrap_or(range);
match updated_cells {
Some(cells) => format!("Wrote {cells} cell(s) to {where_} of '{name}'"),
None => format!("Wrote to {where_} of '{name}'"),
}
}
WriteResult::Failed { detail } => {
format!("Failed: {range} of '{name}': {detail}")
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::test_support::seed_lease;
#[test]
fn every_describe_arm_renders_a_single_line() {
for verb in [WriteVerb::Write, WriteVerb::Append, WriteVerb::Clear] {
for result in every_write_result() {
let outcome = WriteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Quarterly Plan".to_string()),
range: Some("Sheet1!A1:B2".to_string()),
resolved_folder_id: None,
verb,
result,
};
let rendered = describe(&outcome);
assert_eq!(
rendered.lines().count(),
1,
"describe emitted {} lines for {:?}/{verb:?}: {rendered:?}",
rendered.lines().count(),
outcome.result
);
assert!(
!rendered.chars().any(char::is_control),
"describe emitted a control character for {:?}/{verb:?}: {rendered:?}",
outcome.result
);
}
}
}
fn every_write_result() -> Vec<WriteResult> {
let all = vec![
WriteResult::WouldWrite {
rows: 2,
columns: 3,
},
WriteResult::WouldClear,
WriteResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
},
WriteResult::RefusedShortcut,
WriteResult::RefusedNoVisibleParents,
WriteResult::RefusedSheetNotFound {
title: "Nope".to_string(),
available: vec!["Sheet1".to_string()],
},
WriteResult::RefusedEmptyValues,
WriteResult::Blocked { decided_by: None },
WriteResult::Blocked {
decided_by: Some(DecidingRule::Folder {
folder_id: "folder-1".to_string(),
depth: 2,
}),
},
WriteResult::Blocked {
decided_by: Some(DecidingRule::File {
file_id: "sheet-1".to_string(),
}),
},
WriteResult::RefusedNoLease,
WriteResult::RefusedLeaseExpired,
WriteResult::RefusedLeaseWrongFile,
WriteResult::RefusedLeaseStale,
WriteResult::Written {
updated_range: Some("Sheet1!A1:B2".to_string()),
updated_rows: Some(2),
updated_columns: Some(2),
updated_cells: Some(4),
},
WriteResult::Written {
updated_range: None,
updated_rows: None,
updated_columns: None,
updated_cells: None,
},
WriteResult::Appended {
updated_range: Some("Sheet1!A3:B3".to_string()),
updated_rows: Some(1),
updated_columns: Some(2),
updated_cells: Some(2),
},
WriteResult::Cleared {
updated_range: Some("Sheet1!A1:B2".to_string()),
},
WriteResult::Failed {
detail: "the API said no".to_string(),
},
];
for result in &all {
match result {
WriteResult::WouldWrite { .. }
| WriteResult::WouldClear
| WriteResult::RefusedNotASpreadsheet { .. }
| WriteResult::RefusedShortcut
| WriteResult::RefusedNoVisibleParents
| WriteResult::RefusedSheetNotFound { .. }
| WriteResult::RefusedEmptyValues
| WriteResult::Blocked { .. }
| WriteResult::RefusedNoLease
| WriteResult::RefusedLeaseExpired
| WriteResult::RefusedLeaseWrongFile
| WriteResult::RefusedLeaseStale
| WriteResult::Written { .. }
| WriteResult::Appended { .. }
| WriteResult::Cleared { .. }
| WriteResult::Failed { .. } => (),
}
}
all
}
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
use crate::drive::write_gate::Verdict;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use std::collections::HashSet;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token", "expires_in": 3600,
})),
)
.mount(server)
.await;
let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut drive,
&test_credentials(),
&format!("{}/token", server.uri()),
);
let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
(drive, sheets)
}
fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
let parents: Vec<&str> = parents.to_vec();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": mime_type, "parents": parents,
"version": "1",
})),
)
}
fn mount_folder(id: &str) -> wiremock::Mock {
mount_file(id, "application/vnd.google-apps.folder", &[])
}
fn allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some(folder.to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
deny: HashSet::default(),
require_lease: true,
}
}
fn opts(verb: WriteVerb, dry_run: bool) -> WriteOptions {
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "1");
WriteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb,
range: Some("A1:B2".to_string()),
sheet: None,
values: vec![vec!["a".to_string(), "b".to_string()]],
input: ValueInputOption::UserEntered,
dry_run,
lease_token: Some(token),
ledger_path,
}
}
#[tokio::test]
async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", "application/pdf", &["parent-1"])
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Append, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
WriteResult::RefusedNotASpreadsheet { .. }
));
let text = describe(&outcome);
assert!(text.contains("is not a Google Sheet"), "{text}");
assert!(text.contains("drive sheets append"), "{text}");
}
#[tokio::test]
async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["parent-1"],
)
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::RefusedShortcut));
let text = describe(&outcome);
assert!(text.contains("is a shortcut"), "{text}");
assert!(!text.contains("is not a Google Sheet"), "{text}");
}
#[tokio::test]
async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
assert!(matches!(
outcome.result,
WriteResult::RefusedNoVisibleParents
));
let text = describe(&outcome);
assert!(text.contains("no parent folder visible"), "{text}");
assert!(text.contains("file_id"), "{text}");
assert!(text.contains("Adding it to a folder"), "{text}");
}
#[tokio::test]
async fn denied_target_makes_zero_sheets_calls() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
assert!(
matches!(outcome.result, WriteResult::Blocked { decided_by: None }),
"{:?}",
outcome.result
);
let text = describe(&outcome);
assert!(
text.contains("refused by default policy (no matching rule)"),
"{text}"
);
}
#[tokio::test]
async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let deny_rule = FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: true,
allow: HashSet::default(),
deny: std::iter::once(DriveOperation::SheetsWrite).collect(),
require_lease: true,
};
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[deny_rule],
)
.await;
let text = describe(&outcome);
assert!(
text.contains("refused by rule on folder parent-1"),
"{text}"
);
}
#[tokio::test]
async fn an_edit_rule_alone_does_not_permit_a_cell_write() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let edit_only = FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::Edit).collect(),
deny: HashSet::default(),
require_lease: true,
};
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[edit_only],
)
.await;
assert!(matches!(outcome.result, WriteResult::Blocked { .. }));
}
#[tokio::test]
async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::Failed { .. }));
}
#[tokio::test]
async fn dry_run_reports_dimensions_and_calls_no_values_endpoint() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let mut o = opts(WriteVerb::Write, true);
o.values = vec![
vec!["a".to_string(), "b".to_string(), "c".to_string()],
vec!["d".to_string()],
];
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(
outcome.result,
WriteResult::WouldWrite {
rows: 2,
columns: 3
}
);
let text = describe(&outcome);
assert!(text.contains("2 row(s) x 3 column(s)"), "{text}");
}
#[tokio::test]
async fn write_refuses_an_unknown_sheet_identically_in_dry_run_and_a_real_run() {
for dry_run in [true, false] {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"spreadsheetId": "sheet-1",
"sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}]
}),
))
.mount(&server)
.await;
let mut o = opts(WriteVerb::Write, dry_run);
o.sheet = Some("Nope".to_string());
o.range = None;
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(
outcome.result,
WriteResult::RefusedSheetNotFound {
title: "Nope".to_string(),
available: vec!["Sheet1".to_string()],
},
"dry_run={dry_run}"
);
let text = describe(&outcome);
assert!(text.contains("no sheet titled 'Nope'"), "{text}");
assert!(text.contains("Available: 'Sheet1'"), "{text}");
}
}
#[tokio::test]
async fn sheet_title_check_metadata_fetch_failure_produces_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let mut o = opts(WriteVerb::Write, false);
o.sheet = Some("Sheet1".to_string());
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert!(matches!(outcome.result, WriteResult::Failed { .. }));
}
#[tokio::test]
async fn write_and_append_refuse_empty_values_before_any_request() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
for verb in [WriteVerb::Write, WriteVerb::Append] {
let mut o = opts(verb, true);
o.values = vec![];
let outcome = write(&drive, &sheets, &o, &[]).await;
assert_eq!(outcome.result, WriteResult::RefusedEmptyValues, "{verb:?}");
let text = describe(&outcome);
assert!(text.contains("no values to write"), "{text}");
}
}
#[tokio::test]
async fn clear_is_unaffected_by_empty_values() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let mut o = opts(WriteVerb::Clear, true);
o.values = vec![];
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, WriteResult::WouldClear);
}
#[tokio::test]
async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.expect(2)
.mount(&server)
.await;
mount_folder("parent-1").expect(2).mount(&server).await;
let dry = write(&drive, &sheets, &opts(WriteVerb::Write, true), &[]).await;
let real = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
assert_eq!(dry.result, real.result);
}
#[tokio::test]
async fn allowed_write_calls_values_update_once_with_the_input_option() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2",
))
.and(wiremock::matchers::query_param(
"valueInputOption",
"USER_ENTERED",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
})),
)
.expect(1)
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
assert_eq!(
outcome.result,
WriteResult::Written {
updated_range: Some("'Q1'!A1:B2".to_string()),
updated_rows: Some(1),
updated_columns: None,
updated_cells: Some(2),
}
);
assert!(describe(&outcome).starts_with("Wrote 2 cell(s) "));
}
#[tokio::test]
async fn raw_input_option_is_sent_when_requested() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::query_param("valueInputOption", "RAW"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&server)
.await;
let mut o = opts(WriteVerb::Write, false);
o.input = ValueInputOption::Raw;
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert!(matches!(outcome.result, WriteResult::Written { .. }));
}
fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
require_lease: false,
..allow_rule(folder)
}
}
#[tokio::test]
async fn refuses_without_a_lease_when_the_rule_requires_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let mut o = opts(WriteVerb::Write, false);
o.lease_token = None;
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, WriteResult::RefusedNoLease);
}
#[tokio::test]
async fn refuses_an_unknown_lease_token() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let mut o = opts(WriteVerb::Write, false);
o.lease_token = Some("bogus-token".to_string());
o.ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, WriteResult::RefusedLeaseExpired);
}
#[tokio::test]
async fn refuses_a_lease_bound_to_a_different_file() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "other-sheet", "1");
let mut o = opts(WriteVerb::Write, false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, WriteResult::RefusedLeaseWrongFile);
}
#[tokio::test]
async fn refuses_a_stale_lease() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let mut o = opts(WriteVerb::Write, false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, WriteResult::RefusedLeaseStale);
}
#[tokio::test]
async fn reports_a_lock_acquisition_failure_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let o = opts(WriteVerb::Write, false);
let mut lock_path = o.ledger_path.clone().into_os_string();
lock_path.push(".lock");
std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert!(matches!(outcome.result, WriteResult::Failed { .. }));
}
#[tokio::test]
async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_values_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::Failed { .. }));
}
#[tokio::test]
async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
})),
)
.expect(1)
.mount(&server)
.await;
let mut o = opts(WriteVerb::Write, false);
o.lease_token = None;
let outcome = write(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
assert!(matches!(outcome.result, WriteResult::Written { .. }));
}
#[tokio::test]
async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let mut o = opts(WriteVerb::Write, false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = write(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
assert_eq!(outcome.result, WriteResult::RefusedLeaseStale);
}
#[tokio::test]
async fn a_write_whose_response_omits_the_counts_is_not_described_as_a_clear() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(!text.contains("Cleared"), "{text}");
assert!(text.starts_with("Wrote to "), "{text}");
}
#[tokio::test]
async fn a_clear_dry_run_omits_the_always_zero_dimensions() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Clear, true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(!text.contains("row(s)"), "{text}");
assert_eq!(text, "Would clear: A1:B2 of 'sheet-1'");
}
#[tokio::test]
async fn append_reads_its_counts_from_the_nested_updates_object() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2:append",
))
.and(wiremock::matchers::query_param(
"insertDataOption",
"INSERT_ROWS",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tableRange": "'Q1'!A1:B3",
"updates": {"updatedRange": "'Q1'!A4:B4", "updatedCells": 2},
})),
)
.expect(1)
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Append, false),
&[allow_rule("parent-1")],
)
.await;
assert_eq!(
outcome.result,
WriteResult::Appended {
updated_range: Some("'Q1'!A4:B4".to_string()),
updated_rows: None,
updated_columns: None,
updated_cells: Some(2),
}
);
assert!(describe(&outcome).starts_with("Appended 2 cell(s) "));
}
#[tokio::test]
async fn clear_calls_the_clear_endpoint_and_reports_the_cleared_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2:clear",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"clearedRange": "'Q1'!A1:B2"})),
)
.expect(1)
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Clear, false),
&[allow_rule("parent-1")],
)
.await;
assert_eq!(
outcome.result,
WriteResult::Cleared {
updated_range: Some("'Q1'!A1:B2".to_string()),
}
);
assert!(describe(&outcome).starts_with("Cleared "));
}
#[tokio::test]
async fn a_403_surfaces_the_write_scope_hint() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": {"code": 403, "message": "The caller does not have permission",
"status": "PERMISSION_DENIED"},
})),
)
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
let WriteResult::Failed { detail } = &outcome.result else {
panic!("expected Failed, got {:?}", outcome.result);
};
assert!(detail.contains("--write-file"), "{detail}");
assert!(detail.contains("--write-full"), "{detail}");
}
#[tokio::test]
async fn a_conflicting_sheet_and_range_fails_before_any_request() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let mut o = opts(WriteVerb::Write, false);
o.range = Some("Other!A1".to_string());
o.sheet = Some("Mine".to_string());
let outcome = write(&drive, &sheets, &o, &[]).await;
let WriteResult::Failed { detail } = &outcome.result else {
panic!("expected Failed, got {:?}", outcome.result);
};
assert!(detail.contains("already names a sheet"), "{detail}");
}
#[tokio::test]
async fn a_sheet_title_with_a_space_is_encoded_on_the_wire() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"sheets": [{"properties": {"sheetId": 0, "title": "My Sheet"}}]
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'My%20Sheet'!A1:B2",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&server)
.await;
let mut o = opts(WriteVerb::Write, false);
o.sheet = Some("My Sheet".to_string());
let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert!(matches!(outcome.result, WriteResult::Written { .. }));
}
#[test]
fn log_operations_are_distinct_and_kebab_cased() {
assert_eq!(WriteVerb::Write.log_operation(), "sheets-write");
assert_eq!(WriteVerb::Append.log_operation(), "sheets-append");
assert_eq!(WriteVerb::Clear.log_operation(), "sheets-clear");
}
#[test]
fn labels_are_distinct_present_tense_verbs() {
assert_eq!(WriteVerb::Write.label(), "write");
assert_eq!(WriteVerb::Append.label(), "append");
assert_eq!(WriteVerb::Clear.label(), "clear");
}
#[test]
fn json_statuses_name_the_verb() {
let json = |result: WriteResult| serde_json::to_value(&result).unwrap();
assert_eq!(
json(WriteResult::WouldClear),
serde_json::json!({"status": "would-clear"})
);
assert_eq!(
json(WriteResult::Cleared {
updated_range: Some("Q1!A1:B2".to_string())
}),
serde_json::json!({"status": "cleared", "updated_range": "Q1!A1:B2"})
);
assert_eq!(
json(WriteResult::Appended {
updated_range: None,
updated_rows: Some(1),
updated_columns: Some(2),
updated_cells: Some(2),
})["status"],
"appended"
);
}
#[test]
fn log_status_covers_every_variant() {
assert_eq!(
WriteResult::WouldWrite {
rows: 0,
columns: 0
}
.log_status(),
"would-write"
);
assert_eq!(
WriteResult::RefusedNotASpreadsheet {
mime_type: String::new()
}
.log_status(),
"refused-not-a-spreadsheet"
);
assert_eq!(
WriteResult::RefusedShortcut.log_status(),
"refused-shortcut"
);
assert_eq!(
WriteResult::RefusedNoVisibleParents.log_status(),
"refused-no-visible-parents"
);
assert_eq!(
WriteResult::Blocked { decided_by: None }.log_status(),
"blocked"
);
assert_eq!(
WriteResult::Written {
updated_range: None,
updated_rows: None,
updated_columns: None,
updated_cells: None,
}
.log_status(),
"written"
);
assert_eq!(WriteResult::WouldClear.log_status(), "would-clear");
assert_eq!(
WriteResult::Appended {
updated_range: None,
updated_rows: None,
updated_columns: None,
updated_cells: None,
}
.log_status(),
"appended"
);
assert_eq!(
WriteResult::Cleared {
updated_range: None
}
.log_status(),
"cleared"
);
assert_eq!(
WriteResult::Failed {
detail: String::new()
}
.log_status(),
"failed"
);
assert_eq!(
WriteResult::RefusedEmptyValues.log_status(),
"refused-empty-values"
);
assert_eq!(WriteResult::RefusedNoLease.log_status(), "refused-no-lease");
assert_eq!(
WriteResult::RefusedLeaseExpired.log_status(),
"refused-lease-expired"
);
assert_eq!(
WriteResult::RefusedLeaseWrongFile.log_status(),
"refused-lease-wrong-file"
);
assert_eq!(
WriteResult::RefusedLeaseStale.log_status(),
"refused-lease-stale"
);
}
#[test]
fn write_jsonl_emits_one_line_of_json() {
let outcome = WriteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
range: Some("A1:B2".to_string()),
resolved_folder_id: Some("parent-1".to_string()),
verb: WriteVerb::Write,
result: WriteResult::Written {
updated_range: Some("A1:B2".to_string()),
updated_rows: Some(1),
updated_columns: Some(2),
updated_cells: Some(2),
},
};
let mut buf = Vec::new();
outcome.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text.matches('\n').count(), 1);
let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(parsed["result"]["status"], "written");
}
#[test]
fn gate_denies_sheets_write_by_default() {
let decision =
write_gate::resolve(&["folder".to_string()], DriveOperation::SheetsWrite, &[]);
assert_eq!(decision.verdict, Verdict::Deny);
}
#[tokio::test]
async fn a_file_rule_grants_a_sheet_with_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
})),
)
.expect(1)
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsWrite])],
)
.await;
assert!(
matches!(outcome.result, WriteResult::Written { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn a_file_rule_denies_even_when_a_parent_folder_would_allow() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[
allow_rule("parent-1"),
FolderPermissionRule::file("sheet-1").denying([DriveOperation::SheetsWrite]),
],
)
.await;
match &outcome.result {
WriteResult::Blocked { decided_by } => {
let rule = decided_by.as_ref().expect("a file rule decided this");
assert_eq!(rule.kind_label(), "file");
assert_eq!(rule.id(), "sheet-1");
}
other => panic!("expected Blocked, got {other:?}"),
}
let text = describe(&outcome);
assert!(text.contains("refused by rule on file sheet-1"), "{text}");
assert!(!text.contains("depth"), "a file rule has no depth: {text}");
}
#[tokio::test]
async fn the_no_visible_parents_message_names_a_file_rule_as_the_fix() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
let text = describe(&outcome);
assert!(text.contains("file_id"), "{text}");
assert!(text.contains("sheets-write"), "{text}");
}
#[tokio::test]
async fn a_leased_write_concludes_its_audit_pair_with_allowed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
})),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::Written { .. }));
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
assert_eq!(records[0].command, ["drive", "sheets-write"]);
assert_eq!(
records[1].context.get("version_after").map(String::as_str),
Some("1")
);
}
#[tokio::test]
async fn a_leased_write_that_fails_concludes_its_audit_pair_with_the_error() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2",
))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let outcome = write(
&drive,
&sheets,
&opts(WriteVerb::Write, false),
&[allow_rule("parent-1")],
)
.await;
let WriteResult::Failed { detail } = &outcome.result else {
panic!("expected Failed, got {:?}", outcome.result);
};
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
}
}