use std::path::{Path, PathBuf};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::cli::drive::format::JsonlSerialize;
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::folder_ancestry;
use crate::drive::lease::acquire::{self, AcquireOptions, AcquireResult};
use crate::drive::lease::authenticate::{AuthPolicy, Authenticator};
use crate::drive::lease::check::{
finish_leased_native_write, finish_leased_write, gate_leased_write, record_failed_leased_write,
LeaseGateRefusal, LeasedWrite,
};
use crate::drive::lease::ledger::{LeaseBackup, LeaseLedger, LedgerLock};
use crate::drive::sheets::api::{BatchUpdateOutcome, SheetsApi};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::types::{
BatchUpdateRequestItem, SheetPropertiesUpdate, UpdateSheetPropertiesRequest,
};
use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::AuditOutcome;
#[derive(Debug, Clone)]
pub struct RestoreOptions {
pub token: String,
pub backup_dir: std::path::PathBuf,
pub native_backup_folder_id: Option<String>,
pub expiry: ChronoDuration,
pub auth_policy: AuthPolicy,
pub ledger_path: std::path::PathBuf,
pub allow_headless: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum RestoreResult {
Restored {
new_token: String,
expires_at: DateTime<Utc>,
backup: LeaseBackup,
headless_waiver: bool,
},
RestoredSheet {
new_token: String,
expires_at: DateTime<Utc>,
backup: LeaseBackup,
spreadsheet_id: String,
sheet_id: i64,
sheet_title: String,
headless_waiver: bool,
},
SheetAlreadyRestored {
spreadsheet_id: String,
sheet_id: i64,
sheet_title: String,
#[serde(skip_serializing_if = "Option::is_none")]
restored_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
live_lease: Option<LiveLease>,
},
NoSuchBackupToken,
NoTypedRestorePath {
backup_location: String,
},
BackupTooLargeForSimpleUpload {
size: u64,
},
RefusedNoVisibleParents,
Blocked {
decided_by: Option<DecidingRule>,
},
AlreadyLeased {
token: String,
expires_at: DateTime<Utc>,
},
RefusedNativeDocument,
RefusedConcurrentChange {
detail: String,
},
Denied {
detail: String,
},
Unavailable {
detail: String,
},
Failed {
detail: String,
},
FreshLeaseButWriteFailed {
token: String,
expires_at: DateTime<Utc>,
detail: String,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct LiveLease {
pub token: String,
pub expires_at: DateTime<Utc>,
}
impl JsonlSerialize for RestoreResult {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
crate::cli::drive::format::write_scalar_jsonl(self, out)
}
}
impl RestoreResult {
fn verdict(&self) -> &'static str {
match self {
Self::Restored {
headless_waiver: true,
..
} => "restored-headless-waiver",
Self::Restored { .. } => "restored",
Self::RestoredSheet {
headless_waiver: true,
..
} => "restored-sheet-headless-waiver",
Self::RestoredSheet { .. } => "restored-sheet",
Self::SheetAlreadyRestored { .. } => "sheet-already-restored",
Self::NoSuchBackupToken => "no-such-backup-token",
Self::NoTypedRestorePath { .. } => "no-typed-restore-path",
Self::BackupTooLargeForSimpleUpload { .. } => "backup-too-large-for-simple-upload",
Self::RefusedNoVisibleParents => "refused-no-visible-parents",
Self::Blocked { .. } => "blocked",
Self::AlreadyLeased { .. } => "already-leased",
Self::RefusedNativeDocument => "refused-native-document",
Self::RefusedConcurrentChange { .. } => "refused-concurrent-change",
Self::Denied { .. } => "denied",
Self::Unavailable { .. } => "unavailable",
Self::Failed { .. } => "failed",
Self::FreshLeaseButWriteFailed { .. } => "fresh-lease-but-write-failed",
}
}
pub fn exit_code(&self) -> i32 {
match self {
Self::Restored { .. } | Self::RestoredSheet { .. } => 0,
Self::SheetAlreadyRestored { .. }
| Self::NoSuchBackupToken
| Self::NoTypedRestorePath { .. }
| Self::BackupTooLargeForSimpleUpload { .. }
| Self::RefusedNoVisibleParents
| Self::Blocked { .. }
| Self::AlreadyLeased { .. }
| Self::RefusedNativeDocument
| Self::RefusedConcurrentChange { .. }
| Self::Denied { .. }
| Self::Unavailable { .. }
| Self::Failed { .. }
| Self::FreshLeaseButWriteFailed { .. } => 1,
}
}
}
pub async fn restore(
client: &DriveClient,
sheets: &SheetsClient,
opts: &RestoreOptions,
authenticator: &dyn Authenticator,
rules: &[FolderPermissionRule],
) -> RestoreResult {
let result = restore_inner(client, sheets, opts, authenticator, rules).await;
record_attempt(opts, &result);
result
}
async fn restore_inner(
client: &DriveClient,
sheets: &SheetsClient,
opts: &RestoreOptions,
authenticator: &dyn Authenticator,
rules: &[FolderPermissionRule],
) -> RestoreResult {
let backup_record = match LeaseLedger::load(&opts.ledger_path) {
Ok(ledger) => ledger.get(&opts.token).cloned(),
Err(err) => {
return RestoreResult::Failed {
detail: err.to_string(),
}
}
};
let Some(backup_record) = backup_record else {
return RestoreResult::NoSuchBackupToken;
};
let file_id = backup_record.file_id.clone();
if let LeaseBackup::Bytes { size, .. } = &backup_record.backup {
if crate::drive::files_api::check_upload_size(*size).is_err() {
return RestoreResult::BackupTooLargeForSimpleUpload { size: *size };
}
}
let files_api = FilesApi::new(client);
match check_write_permission_gate(&files_api, &file_id, rules).await {
GateCheck::Ok(_) => {}
GateCheck::Failed(detail) => return RestoreResult::Failed { detail },
GateCheck::NoVisibleParents => return RestoreResult::RefusedNoVisibleParents,
GateCheck::Denied(decided_by) => return RestoreResult::Blocked { decided_by },
}
let sheets_api = SheetsApi::new(sheets);
let plan = match &backup_record.backup {
LeaseBackup::DriveCopy { file_id: copy_id } => {
let previously_restored_sheet_id = backup_record.restored_sheet_id;
match detect_sheet_restore(&sheets_api, copy_id, &file_id, previously_restored_sheet_id)
.await
{
SheetDetection::Deleted {
sheet_id,
sheet_title,
} => RestorePlan::Sheet(SheetRestorePlan {
backup_spreadsheet_id: copy_id.clone(),
sheet_id,
sheet_title,
previously_restored_sheet_id,
}),
SheetDetection::AlreadyRestored {
sheet_id,
sheet_title,
} => {
return RestoreResult::SheetAlreadyRestored {
spreadsheet_id: file_id.clone(),
sheet_id,
sheet_title,
restored_at: backup_record.restored_at,
live_lease: live_lease_for(&opts.ledger_path, &file_id),
}
}
SheetDetection::None => {
return RestoreResult::NoTypedRestorePath {
backup_location: copy_id.clone(),
}
}
}
}
LeaseBackup::Bytes { path, sha256, .. } => RestorePlan::Bytes {
path: path.clone(),
sha256: sha256.clone(),
},
};
let prepared = match plan {
RestorePlan::Bytes { path, sha256 } => {
match tokio::task::block_in_place(|| verify_and_read_backup(&path, &sha256)) {
Ok(bytes) => PreparedRestore::Bytes(bytes),
Err(detail) => return RestoreResult::Failed { detail },
}
}
RestorePlan::Sheet(sheet_plan) => PreparedRestore::Sheet(sheet_plan),
};
let acquire_opts = AcquireOptions {
file_id: file_id.clone(),
backup_dir: opts.backup_dir.clone(),
native_backup_folder_id: opts.native_backup_folder_id.clone(),
expiry: opts.expiry,
auth_policy: opts.auth_policy,
ledger_path: opts.ledger_path.clone(),
allow_headless: opts.allow_headless,
supersedes: Some(opts.token.clone()),
};
let (new_token, expires_at, fresh_backup, headless_waiver) =
match acquire::acquire(client, &acquire_opts, authenticator).await {
AcquireResult::Acquired {
token,
expires_at,
backup,
headless_waiver,
superseded_lease_id: _,
} => (token, expires_at, backup, headless_waiver),
AcquireResult::AlreadyLeased { token, expires_at } => {
return RestoreResult::AlreadyLeased { token, expires_at }
}
AcquireResult::RefusedNativeDocument => return RestoreResult::RefusedNativeDocument,
AcquireResult::RefusedConcurrentChange { detail } => {
return RestoreResult::RefusedConcurrentChange { detail }
}
AcquireResult::Denied { detail } => return RestoreResult::Denied { detail },
AcquireResult::Unavailable { detail } => return RestoreResult::Unavailable { detail },
AcquireResult::Failed { detail } => return RestoreResult::Failed { detail },
};
let fresh_lease_but = |detail: String| RestoreResult::FreshLeaseButWriteFailed {
token: new_token.clone(),
expires_at,
detail,
};
let target = match check_write_permission_gate(&files_api, &file_id, rules).await {
GateCheck::Ok(target) => target,
GateCheck::Failed(detail) => return fresh_lease_but(detail),
GateCheck::NoVisibleParents | GateCheck::Denied(_) => {
return fresh_lease_but(
"the write-permission gate no longer allows this write, re-checked after the \
fresh lease's authentication prompt"
.to_string(),
)
}
};
match &prepared {
PreparedRestore::Bytes(_) if target.is_google_native() => {
return fresh_lease_but(
"the target has become a Google-native document since this backup was taken; \
a byte backup cannot be restored into a native document"
.to_string(),
);
}
PreparedRestore::Sheet(_) if target.mime_type != GOOGLE_SHEET_MIME_TYPE => {
return fresh_lease_but(
"the target is no longer a spreadsheet since this backup was taken; a deleted \
sheet cannot be copied back into it"
.to_string(),
);
}
PreparedRestore::Bytes(_) | PreparedRestore::Sheet(_) => {}
}
let leased = LeasedWrite {
log_prefix: "drive lease restore",
operation: "lease-restore",
ledger_path: &opts.ledger_path,
file_id: &file_id,
};
let grant = match gate_leased_write(leased, &files_api, Some(&new_token)).await {
Ok(grant) => grant,
Err(refusal) => return fresh_lease_but(leased_write_refusal_detail(refusal)),
};
let record_failure = |detail: String| -> RestoreResult {
record_failed_leased_write(leased, &new_token, &detail);
fresh_lease_but(detail)
};
let result = match &prepared {
PreparedRestore::Bytes(backup_bytes) => {
match files_api
.edit_content(&file_id, backup_bytes, &target.mime_type)
.await
{
Ok(updated) => {
finish_leased_write(
leased,
&grant.lock,
&new_token,
updated.version,
updated.modified_time,
);
RestoreResult::Restored {
new_token: new_token.clone(),
expires_at,
backup: fresh_backup,
headless_waiver,
}
}
Err(err) => record_failure(err.to_string()),
}
}
PreparedRestore::Sheet(SheetRestorePlan {
backup_spreadsheet_id,
sheet_id,
sheet_title,
previously_restored_sheet_id,
}) => {
match sheets_api.get_spreadsheet(&file_id).await {
Ok(live) => {
let live_ids = live.sheet_ids();
if live_ids.contains(sheet_id) {
return record_failure(
"a sheet with the same id already exists again in the live \
spreadsheet; nothing to restore"
.to_string(),
);
}
if previously_restored_sheet_id.is_some_and(|id| live_ids.contains(&id)) {
return record_failure(
"this backup's deleted sheet was already restored into the live \
spreadsheet; nothing to restore"
.to_string(),
);
}
}
Err(err) => return record_failure(err.to_string()),
}
let copied = match sheets_api
.copy_to(backup_spreadsheet_id, *sheet_id, &file_id)
.await
{
Ok(copied) => copied,
Err(err) => return record_failure(err.to_string()),
};
let Some(new_sheet_id) = copied.sheet_id else {
return record_failure(
"Sheets copyTo response carried no sheetId for the new sheet".to_string(),
);
};
let final_title = rename_back_if_free(
&sheets_api,
&file_id,
new_sheet_id,
sheet_title,
&copied.title,
)
.await;
finish_leased_native_write(leased, &grant.lock, &new_token, &files_api).await;
RestoreResult::RestoredSheet {
new_token: new_token.clone(),
expires_at,
backup: fresh_backup,
spreadsheet_id: file_id.clone(),
sheet_id: new_sheet_id,
sheet_title: final_title,
headless_waiver,
}
}
};
let restored_sheet_id = match &result {
RestoreResult::RestoredSheet { sheet_id, .. } => Some(*sheet_id),
_ => None,
};
if matches!(
result,
RestoreResult::Restored { .. } | RestoreResult::RestoredSheet { .. }
) {
stamp_backup_restored(
&grant.lock,
&opts.ledger_path,
&opts.token,
restored_sheet_id,
);
}
drop(grant);
result
}
struct SheetRestorePlan {
backup_spreadsheet_id: String,
sheet_id: i64,
sheet_title: String,
previously_restored_sheet_id: Option<i64>,
}
enum RestorePlan {
Bytes { path: PathBuf, sha256: String },
Sheet(SheetRestorePlan),
}
enum PreparedRestore {
Bytes(Vec<u8>),
Sheet(SheetRestorePlan),
}
enum SheetDetection {
Deleted {
sheet_id: i64,
sheet_title: String,
},
AlreadyRestored {
sheet_id: i64,
sheet_title: String,
},
None,
}
async fn detect_sheet_restore(
sheets_api: &SheetsApi<'_>,
backup_spreadsheet_id: &str,
live_spreadsheet_id: &str,
previously_restored_sheet_id: Option<i64>,
) -> SheetDetection {
let Ok(backup) = sheets_api.get_spreadsheet(backup_spreadsheet_id).await else {
return SheetDetection::None;
};
let Ok(live) = sheets_api.get_spreadsheet(live_spreadsheet_id).await else {
return SheetDetection::None;
};
let live_ids = live.sheet_ids();
let mut missing = backup.sheets.iter().filter_map(|sheet| {
let props = sheet.properties.as_ref()?;
let id = props.sheet_id?;
(!live_ids.contains(&id)).then(|| (id, props.title.clone()))
});
let Some((sheet_id, sheet_title)) = missing.next() else {
return SheetDetection::None;
};
if missing.next().is_some() {
return SheetDetection::None;
}
if let Some(restored_id) = previously_restored_sheet_id {
if let Some(sheet) = live
.sheets
.iter()
.find(|sheet| sheet.sheet_id() == Some(restored_id))
{
return SheetDetection::AlreadyRestored {
sheet_id: restored_id,
sheet_title: sheet.title().to_string(),
};
}
}
SheetDetection::Deleted {
sheet_id,
sheet_title,
}
}
fn live_lease_for(ledger_path: &Path, file_id: &str) -> Option<LiveLease> {
let ledger = LeaseLedger::load(ledger_path).ok()?;
let record = ledger.live_lease_for_file(file_id, Utc::now(), None)?;
Some(LiveLease {
token: record.token.clone(),
expires_at: record.expires_at,
})
}
async fn rename_back_if_free(
sheets_api: &SheetsApi<'_>,
spreadsheet_id: &str,
new_sheet_id: i64,
original_title: &str,
current_title: &str,
) -> String {
if original_title == current_title {
return current_title.to_string();
}
match sheets_api.get_spreadsheet(spreadsheet_id).await {
Ok(live) => {
if live.has_sheet_titled(original_title) {
return current_title.to_string();
}
}
Err(err) => {
tracing::debug!(
"drive lease restore: failed to check whether '{original_title}' is free \
before renaming the restored sheet back to it: {err}"
);
return current_title.to_string();
}
}
let rename = sheets_api
.batch_update(
spreadsheet_id,
vec![BatchUpdateRequestItem::UpdateSheetProperties(
UpdateSheetPropertiesRequest {
properties: SheetPropertiesUpdate {
sheet_id: new_sheet_id,
title: Some(original_title.to_string()),
..Default::default()
},
fields: "title".to_string(),
},
)],
)
.await;
match rename {
Ok(outcome) => {
if let BatchUpdateOutcome::AppliedReplyUnreadable { detail } = &outcome {
tracing::debug!(
"drive lease restore: renamed the restored sheet back to \
'{original_title}', but the reply could not be read: {detail}"
);
}
original_title.to_string()
}
Err(err) => {
tracing::debug!(
"drive lease restore: failed to rename the restored sheet back to \
'{original_title}': {err}"
);
current_title.to_string()
}
}
}
enum GateCheck {
Ok(Box<crate::drive::types::DriveFile>),
Failed(String),
NoVisibleParents,
Denied(Option<DecidingRule>),
}
async fn check_write_permission_gate(
files_api: &FilesApi<'_>,
file_id: &str,
rules: &[FolderPermissionRule],
) -> GateCheck {
let target = match files_api.get_metadata(file_id).await {
Ok(target) => target,
Err(err) => return GateCheck::Failed(err.to_string()),
};
let evaluated = match folder_ancestry::resolve_decision_for_file_target(
files_api,
&target,
DriveOperation::Edit,
rules,
)
.await
{
Ok(evaluated) => evaluated,
Err(err) => return GateCheck::Failed(err.to_string()),
};
if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
return GateCheck::NoVisibleParents;
}
if evaluated.decision.verdict == write_gate::Verdict::Deny {
return GateCheck::Denied(evaluated.decision.decided_by);
}
GateCheck::Ok(Box::new(target))
}
fn verify_and_read_backup(path: &Path, expected_sha256: &str) -> Result<Vec<u8>, String> {
let bytes = std::fs::read(path)
.map_err(|err| format!("Failed to read backup at {}: {err}", path.display()))?;
let actual = crate::cli::drive::read::to_hex_string(&Sha256::digest(&bytes));
if !actual.eq_ignore_ascii_case(expected_sha256) {
return Err(format!(
"backup at {} no longer matches its recorded SHA-256 (expected {expected_sha256}, \
got {actual}) — refusing to restore from what may be corrupted or tampered-with \
content",
path.display()
));
}
Ok(bytes)
}
fn stamp_backup_restored(
lock: &LedgerLock,
ledger_path: &Path,
token: &str,
restored_sheet_id: Option<i64>,
) {
let result = LeaseLedger::mutate(lock, ledger_path, |ledger| {
ledger.mark_restored(token, Utc::now(), restored_sheet_id);
});
if let Err(err) = result {
tracing::warn!(
"drive lease restore: failed to mark the backup lease as restored-from: {err}"
);
}
}
fn leased_write_refusal_detail(refusal: LeaseGateRefusal) -> String {
match refusal {
LeaseGateRefusal::NoLease => {
"the freshly minted lease was not presented to its own write check".to_string()
}
LeaseGateRefusal::Expired => {
"the freshly minted lease was already expired or released by the time of the \
restore write"
.to_string()
}
LeaseGateRefusal::WrongFile => {
"the freshly minted lease was bound to a different file than the restore write \
targeted"
.to_string()
}
LeaseGateRefusal::Stale => {
"the file changed again between minting the fresh lease and the restore write"
.to_string()
}
LeaseGateRefusal::Failed(detail) => detail,
}
}
fn record_attempt(opts: &RestoreOptions, result: &RestoreResult) {
let (lease_id, error) = match result {
RestoreResult::Restored { new_token, .. }
| RestoreResult::RestoredSheet { new_token, .. } => (Some(new_token.clone()), None),
RestoreResult::AlreadyLeased { token, .. } => (Some(token.clone()), None),
RestoreResult::FreshLeaseButWriteFailed { token, detail, .. } => {
(Some(token.clone()), Some(detail.clone()))
}
RestoreResult::Denied { detail }
| RestoreResult::Unavailable { detail }
| RestoreResult::Failed { detail } => (None, Some(detail.clone())),
RestoreResult::NoSuchBackupToken
| RestoreResult::SheetAlreadyRestored { .. }
| RestoreResult::NoTypedRestorePath { .. }
| RestoreResult::BackupTooLargeForSimpleUpload { .. }
| RestoreResult::RefusedNoVisibleParents
| RestoreResult::Blocked { .. }
| RestoreResult::RefusedNativeDocument
| RestoreResult::RefusedConcurrentChange { .. } => (None, None),
};
let file_id = LeaseLedger::load(&opts.ledger_path)
.ok()
.and_then(|ledger| ledger.get(&opts.token).map(|record| record.file_id.clone()))
.unwrap_or_default();
let outcome = AuditOutcome {
command: vec!["drive".to_string(), "lease-restore".to_string()],
integration: "drive",
file_id,
lease_id,
verdict: result.verdict().to_string(),
restored_from_lease_id: Some(opts.token.clone()),
error,
..Default::default()
};
if let Err(err) = crate::request_log::record_audit_event(outcome) {
tracing::warn!("drive lease restore: failed to write audit record: {err}");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::lease::authenticate::{AuthOutcome, Unsupported};
use crate::drive::lease::ledger::{LeaseRecord, LedgerLock};
use crate::drive::lease::release::{release, ReleaseOptions, ReleaseResult};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::drive::test_support::FakeAuthenticator;
use crate::drive::types::{
GOOGLE_DOC_MIME_TYPE, GOOGLE_FOLDER_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE,
};
use crate::test_support::env::MapEnv;
use crate::test_support::AuditLogGuard;
use crate::utils::secret::Secret;
use std::path::PathBuf;
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 client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
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 client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
fn sheets_client_for(server: &wiremock::MockServer, client: &DriveClient) -> SheetsClient {
let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
SheetsClient::from_drive_client_with(&env, client).unwrap()
}
struct PanicsIfCalled;
impl Authenticator for PanicsIfCalled {
fn authenticate(&self, _reason: &str, _policy: AuthPolicy) -> AuthOutcome {
panic!("must not authenticate: refused before minting a fresh lease")
}
}
fn mount_file(server_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/{server_id}"
)))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": server_id, "name": server_id, "mimeType": mime_type,
"parents": parents, "version": "1",
})),
)
}
fn mount_folder(id: &str) -> wiremock::Mock {
mount_file(id, GOOGLE_FOLDER_MIME_TYPE, &[])
}
fn mount_download(file_id: &str, bytes: &'static [u8]) -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/drive/v3/files/{file_id}"
)))
.and(wiremock::matchers::query_param("alt", "media"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(bytes))
}
fn allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule::folder(folder).allowing([DriveOperation::Edit])
}
fn mount_spreadsheet(spreadsheet_id: &str, sheets: &[(i64, &str)]) -> wiremock::Mock {
let sheets: Vec<serde_json::Value> = sheets
.iter()
.map(|(id, title)| serde_json::json!({"properties": {"sheetId": id, "title": title}}))
.collect();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/v4/spreadsheets/{spreadsheet_id}"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": spreadsheet_id,
"sheets": sheets,
})),
)
}
fn mount_copy_to(
source_spreadsheet_id: &str,
sheet_id: i64,
new_sheet_id: i64,
new_title: &str,
) -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(format!(
"/v4/spreadsheets/{source_spreadsheet_id}/sheets/{sheet_id}:copyTo"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sheetId": new_sheet_id,
"title": new_title,
})),
)
}
fn mount_batch_update(spreadsheet_id: &str) -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(format!(
"/v4/spreadsheets/{spreadsheet_id}:batchUpdate"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": spreadsheet_id,
"replies": [{}],
})),
)
}
fn write_backup_file(dir: &Path, bytes: &[u8]) -> LeaseBackup {
let path = dir.join("backup.bin");
std::fs::write(&path, bytes).unwrap();
let sha256 = crate::cli::drive::read::to_hex_string(&Sha256::digest(bytes));
LeaseBackup::Bytes {
path,
sha256,
size: bytes.len() as u64,
}
}
fn seed_backup_lease(ledger_path: &Path, file_id: &str, backup: LeaseBackup) -> String {
seed_backup_lease_expiring(
ledger_path,
file_id,
backup,
Utc::now() - ChronoDuration::hours(1),
)
}
fn seed_backup_lease_expiring(
ledger_path: &Path,
file_id: &str,
backup: LeaseBackup,
expires_at: DateTime<Utc>,
) -> String {
crate::drive::test_support::seed_lease_at(
ledger_path,
"backup-token",
file_id,
"1",
Utc::now() - ChronoDuration::hours(2),
expires_at,
backup,
)
}
fn opts(dir: &Path, token: &str) -> RestoreOptions {
RestoreOptions {
token: token.to_string(),
backup_dir: dir.join("backups"),
native_backup_folder_id: None,
expiry: ChronoDuration::minutes(30),
auth_policy: AuthPolicy::DeviceOwner,
ledger_path: dir.join("lease-ledger.jsonl"),
allow_headless: false,
}
}
#[tokio::test]
async fn no_such_backup_token_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let result = restore(
&client,
&sheets,
&opts(dir.path(), "no-such-token"),
&PanicsIfCalled,
&[],
)
.await;
assert!(matches!(result, RestoreResult::NoSuchBackupToken));
}
#[tokio::test]
async fn a_native_backup_token_with_identical_sheets_reports_no_typed_restore_path_and_makes_no_mutating_call(
) {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&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"))
.respond_with(wiremock::ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
result,
RestoreResult::NoTypedRestorePath { backup_location } if backup_location == "copy-1"
));
}
#[tokio::test]
async fn a_native_backup_token_whose_backup_copy_is_not_a_spreadsheet_reports_no_typed_restore_path(
) {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(
&test_opts.ledger_path,
"doc-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
result,
RestoreResult::NoTypedRestorePath { backup_location } if backup_location == "copy-1"
));
}
#[tokio::test]
async fn a_native_backup_token_with_more_than_one_missing_sheet_reports_no_typed_restore_path()
{
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet(
"copy-1",
&[(1, "Sheet1"), (2, "Deleted A"), (3, "Deleted B")],
)
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&server)
.await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
result,
RestoreResult::NoTypedRestorePath { backup_location } if backup_location == "copy-1"
));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_native_backup_token_with_exactly_one_deleted_sheet_is_restored_via_copy_to() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.expect(1)
.mount(&server)
.await;
mount_copy_to("copy-1", 2, 999, "Copy of Deleted")
.expect(1)
.mount(&server)
.await;
mount_batch_update("sheet-1").expect(1).mount(&server).await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::RestoredSheet {
new_token,
spreadsheet_id,
sheet_id,
sheet_title,
..
} = result
else {
panic!("expected RestoredSheet, got {result:?}");
};
assert_ne!(new_token, old_token);
assert_eq!(spreadsheet_id, "sheet-1");
assert_eq!(sheet_id, 999);
assert_eq!(
sheet_title, "Deleted",
"must rename back since the title was free"
);
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let old_record = ledger.get(&old_token).expect("old row must be kept");
assert!(
old_record.restored_at.is_some(),
"the backup lease's row must be marked restored-from"
);
let new_record = ledger
.get(&new_token)
.expect("fresh lease must be recorded");
assert!(new_record.is_live(Utc::now()));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(update["properties"]["sheetId"], 999);
assert_eq!(update["properties"]["title"], "Deleted");
assert_eq!(update["fields"], "title");
assert_eq!(body["requests"].as_array().unwrap().len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn restoring_the_same_sheet_backup_twice_refuses_instead_of_duplicating_it() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
mount_copy_to("copy-1", 2, 999, "Copy of Deleted")
.mount(&server)
.await;
mount_batch_update("sheet-1").mount(&server).await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let first = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::RestoredSheet {
sheet_id: first_sheet_id,
new_token: fresh_token,
..
} = first
else {
panic!("expected RestoredSheet on the first run, got {first:?}");
};
assert_eq!(first_sheet_id, 999);
assert_eq!(
LeaseLedger::load(&test_opts.ledger_path)
.unwrap()
.get(&old_token)
.unwrap()
.restored_sheet_id,
Some(999),
"the first restore must record the live id it created"
);
server.reset().await;
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1"), (999, "Deleted")])
.mount(&server)
.await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
for path in [
"/drive/v3/files/sheet-1/copy",
"/v4/spreadsheets/copy-1/sheets/2:copyTo",
"/v4/spreadsheets/sheet-1:batchUpdate",
] {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(path))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
}
let second = restore(
&client,
&sheets,
&restore_opts,
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::SheetAlreadyRestored {
spreadsheet_id,
sheet_id,
sheet_title,
restored_at,
live_lease,
} = second
else {
panic!("expected SheetAlreadyRestored on the second run, got {second:?}");
};
assert_eq!(spreadsheet_id, "sheet-1");
assert_eq!(sheet_id, 999);
assert_eq!(sheet_title, "Deleted");
assert!(restored_at.is_some());
let live_lease = live_lease.expect("the first run's lease is still live");
assert_eq!(
live_lease.token, fresh_token,
"the refusal must name the still-live lease the first restore minted, since \
there is no other way to recover its token"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_restored_sheet_deleted_again_is_restored_again() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
LeaseLedger::mutate_locked(&test_opts.ledger_path, |ledger| {
ledger.mark_restored(&old_token, Utc::now(), Some(999));
})
.unwrap();
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
mount_copy_to("copy-1", 2, 1001, "Copy of Deleted")
.expect(1)
.mount(&server)
.await;
mount_batch_update("sheet-1").expect(1).mount(&server).await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::RestoredSheet { sheet_id, .. } = result else {
panic!("expected RestoredSheet, got {result:?}");
};
assert_eq!(sheet_id, 1001);
assert_eq!(
LeaseLedger::load(&test_opts.ledger_path)
.unwrap()
.get(&old_token)
.unwrap()
.restored_sheet_id,
Some(1001),
"the row must now point at the newest restore, not the stale 999"
);
}
#[tokio::test]
async fn a_different_sheet_deleted_after_an_earlier_restore_reports_no_typed_restore_path() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
LeaseLedger::mutate_locked(&test_opts.ledger_path, |ledger| {
ledger.mark_restored(&old_token, Utc::now(), Some(999));
})
.unwrap();
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted"), (3, "Other")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1"), (999, "Deleted")])
.mount(&server)
.await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
result,
RestoreResult::NoTypedRestorePath { backup_location } if backup_location == "copy-1"
));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_concurrent_restore_landing_during_the_prompt_is_caught_at_the_recheck() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
LeaseLedger::mutate_locked(&test_opts.ledger_path, |ledger| {
ledger.mark_restored(&old_token, Utc::now(), Some(999));
})
.unwrap();
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1"), (999, "Deleted")])
.with_priority(2)
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/copy-1/sheets/2:copyTo",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { detail, .. } = &result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert!(
detail.contains("was already restored"),
"must name the duplicate, not some other refusal: {detail}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_live_spreadsheet_fetch_failure_at_the_sheet_recheck_reports_fresh_lease_but_write_failed(
) {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.up_to_n_times(1)
.with_priority(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))
.with_priority(2)
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/copy-1/sheets/2:copyTo",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_copy_to_response_with_no_sheet_id_reports_fresh_lease_but_write_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/copy-1/sheets/2:copyTo",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"title": "Copy of Deleted"})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, detail, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
assert!(detail.contains("no sheetId"), "{detail}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_restored_sheet_keeps_the_copy_default_title_when_the_original_is_taken() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1"), (5, "Deleted")])
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
mount_copy_to("copy-1", 2, 999, "Copy of Deleted")
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::RestoredSheet { sheet_title, .. } = result else {
panic!("expected RestoredSheet, got {result:?}");
};
assert_eq!(sheet_title, "Copy of Deleted");
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_back_if_free_skips_the_network_when_the_title_already_matches() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let title = rename_back_if_free(
&SheetsApi::new(&sheets),
"sheet-1",
999,
"Deleted",
"Deleted",
)
.await;
assert_eq!(title, "Deleted");
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_back_if_free_swallows_a_spreadsheet_fetch_failure_and_keeps_the_copy_title() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let title = rename_back_if_free(
&SheetsApi::new(&sheets),
"sheet-1",
999,
"Deleted",
"Copy of Deleted",
)
.await;
assert_eq!(title, "Copy of Deleted");
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_back_if_free_swallows_a_rename_failure_and_keeps_the_copy_title() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let title = rename_back_if_free(
&SheetsApi::new(&sheets),
"sheet-1",
999,
"Deleted",
"Copy of Deleted",
)
.await;
assert_eq!(title, "Copy of Deleted");
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_back_treats_an_unreadable_2xx_reply_as_renamed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.expect(1)
.mount(&server)
.await;
let title = rename_back_if_free(
&SheetsApi::new(&sheets),
"sheet-1",
999,
"Deleted",
"Copy of Deleted",
)
.await;
assert_eq!(title, "Deleted");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_sheet_that_reappeared_by_write_time_refuses_the_restore() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1"), (2, "Deleted")])
.with_priority(2)
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/copy-1/sheets/2:copyTo",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, detail, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
assert!(detail.contains("already exists again"), "{detail}");
let restore_verdicts: Vec<String> = audit
.records()
.into_iter()
.filter(|record| record.command == ["drive", "lease-restore"])
.filter(|record| record.context.get("lease_id").map(String::as_str) == Some(&*token))
.filter_map(|record| record.context.get("verdict").cloned())
.collect();
assert_eq!(
restore_verdicts,
["pending", "failed", "fresh-lease-but-write-failed"],
"the fresh lease's audit pair must be concluded before the attempt summary"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_target_that_stopped_being_a_spreadsheet_is_refused_at_the_recheck() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&server)
.await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.up_to_n_times(4)
.with_priority(1)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
mount_file("sheet-1", "text/plain", &["parent-1"])
.with_priority(2)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/copy-1/sheets/2:copyTo",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, detail, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
assert!(detail.contains("no longer a spreadsheet"), "{detail}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_corrupted_backup_is_refused_before_minting_a_fresh_lease() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let mut backup = write_backup_file(dir.path(), b"original content");
if let LeaseBackup::Bytes { path, .. } = &backup {
std::fs::write(path, b"tampered content").unwrap();
}
if let LeaseBackup::Bytes { size, .. } = &mut backup {
*size = b"tampered content".len() as u64;
}
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::Failed { detail } = result else {
panic!("expected Failed, got {result:?}");
};
assert!(detail.contains("SHA-256"), "{detail}");
}
#[tokio::test]
async fn a_denied_target_is_blocked_with_zero_mutating_calls() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[],
)
.await;
assert!(matches!(result, RestoreResult::Blocked { .. }));
}
#[tokio::test]
async fn an_orphan_target_is_refused_as_having_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &[]).mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[],
)
.await;
assert!(matches!(result, RestoreResult::RefusedNoVisibleParents));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_successful_restore_re_uploads_the_backup_and_mints_a_fresh_lease() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
})),
)
.expect(1)
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::Restored { new_token, .. } = result else {
panic!("expected Restored, got {result:?}");
};
assert_ne!(new_token, old_token);
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let old_record = ledger.get(&old_token).expect("old row must be kept");
assert!(
old_record.restored_at.is_some(),
"the backup lease's row must be marked restored-from"
);
let new_record = ledger
.get(&new_token)
.expect("fresh lease must be recorded");
assert!(new_record.is_live(Utc::now()));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_still_live_backup_token_is_superseded_rather_than_refusing_the_restore() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease_expiring(
&test_opts.ledger_path,
"file-1",
backup,
Utc::now() + ChronoDuration::minutes(29),
);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the wrongly-written content")
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
})),
)
.expect(1)
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::Restored { new_token, .. } = result else {
panic!("expected Restored, got {result:?}");
};
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let old_record = ledger.get(&old_token).expect("old row must be kept");
assert!(
old_record.released_at.is_some(),
"the superseded backup lease must be released"
);
assert!(
!old_record.is_live(Utc::now()),
"a superseded lease must stop authorising writes even though its expiry is still \
in the future"
);
assert!(
old_record.restored_at.is_some(),
"releasing it must not skip marking it restored-from"
);
assert_eq!(
ledger
.live_lease_for_file("file-1", Utc::now(), None)
.map(|r| r.token.clone()),
Some(new_token),
);
let acquire_record = audit
.records()
.into_iter()
.find(|r| r.command == vec!["drive".to_string(), "lease-acquire".to_string()])
.expect("the internal acquire writes its own audit record");
assert_eq!(
acquire_record
.context
.get("superseded_lease_id")
.map(String::as_str),
Some(old_token.as_str())
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_denied_prompt_leaves_a_still_live_backup_token_untouched() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease_expiring(
&test_opts.ledger_path,
"file-1",
backup,
Utc::now() + ChronoDuration::minutes(29),
);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Denied("nope".to_string())),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(result, RestoreResult::Denied { .. }));
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let old_record = ledger.get(&old_token).expect("old row must be kept");
assert!(
old_record.released_at.is_none(),
"nothing replaced it, so the backup lease must still be live"
);
assert!(old_record.is_live(Utc::now()));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_live_backup_token_that_then_fails_to_write_reports_fresh_lease_but_write_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease_expiring(
&test_opts.ledger_path,
"file-1",
backup,
Utc::now() + ChronoDuration::minutes(29),
);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed {
token: new_token, ..
} = result
else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(new_token, old_token);
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let old_record = ledger.get(&old_token).expect("old row must be kept");
assert!(
old_record.released_at.is_some(),
"T must be released even though the write that superseded it then failed"
);
assert!(
!old_record.is_live(Utc::now()),
"a superseded lease must stop authorising writes even though its expiry is still \
in the future"
);
let new_record = ledger
.get(&new_token)
.expect("the fresh lease must still be recorded and live");
assert!(new_record.is_live(Utc::now()));
}
#[tokio::test(flavor = "multi_thread")]
async fn releasing_the_fresh_lease_and_retrying_restores_the_original_after_a_write_failure() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease_expiring(
&test_opts.ledger_path,
"file-1",
backup,
Utc::now() + ChronoDuration::minutes(29),
);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "version": "2",
})),
)
.with_priority(2)
.mount(&server)
.await;
let first = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed {
token: fresh_token, ..
} = first
else {
panic!("expected FreshLeaseButWriteFailed, got {first:?}");
};
let release_result = release(&ReleaseOptions {
token: fresh_token,
ledger_path: test_opts.ledger_path.clone(),
})
.await;
assert!(
matches!(release_result, ReleaseResult::Released { .. }),
"{release_result:?}"
);
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let second = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(second, RestoreResult::Restored { .. }),
"the documented recovery must actually succeed on retry: {second:?}"
);
}
#[test]
fn stamp_backup_restored_succeeds_even_though_a_ledger_lock_is_held_elsewhere() {
let dir = tempfile::tempdir().unwrap();
let ledger_path = dir.path().join("lease-ledger.jsonl");
seed_backup_lease(&ledger_path, "file-1", write_backup_file(dir.path(), b"x"));
let held = LedgerLock::acquire(&ledger_path).unwrap();
stamp_backup_restored(&held, &ledger_path, "backup-token", Some(999));
let ledger = LeaseLedger::load(&ledger_path).unwrap();
let record = ledger.get("backup-token").unwrap();
assert!(
record.restored_at.is_some(),
"must land despite the unrelated lock being held"
);
assert_eq!(record.restored_sheet_id, Some(999));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_permission_revoked_during_the_prompt_reports_the_fresh_lease_but_refuses_the_write()
{
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_file("file-1", "text/plain", &["parent-1"])
.up_to_n_times(3)
.with_priority(2)
.mount(&server)
.await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
mount_file("file-1", "text/plain", &["now-unlisted-parent"])
.with_priority(3)
.mount(&server)
.await;
mount_folder("now-unlisted-parent").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, detail, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(
token, old_token,
"the surfaced token must be the fresh one, not the backup token"
);
assert!(
detail.contains("write-permission gate no longer allows this write"),
"expected the permission re-check's own refusal detail, got: {detail}"
);
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert!(ledger.get(&token).unwrap().is_live(Utc::now()));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_restore_write_still_surfaces_the_fresh_lease_token() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert!(
ledger.get(&old_token).unwrap().restored_at.is_none(),
"a failed write must not mark the backup lease as restored-from"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_copy_to_call_still_surfaces_the_fresh_lease_token() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(
&test_opts.ledger_path,
"sheet-1",
LeaseBackup::DriveCopy {
file_id: "copy-1".to_string(),
},
);
mount_spreadsheet("copy-1", &[(1, "Sheet1"), (2, "Deleted")])
.mount(&server)
.await;
mount_spreadsheet("sheet-1", &[(1, "Sheet1")])
.mount(&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("/drive/v3/files/sheet-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-2", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/copy-1/sheets/2:copyTo",
))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert!(
ledger.get(&old_token).unwrap().restored_at.is_none(),
"a failed write must not mark the backup lease as restored-from"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_already_leased_target_is_reported_via_the_fresh_acquire_step() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
let mut ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
ledger.insert(LeaseRecord {
token: "already-live".to_string(),
file_id: "file-1".to_string(),
version: "1".to_string(),
modified_time: None,
backup: LeaseBackup::Bytes {
path: std::path::PathBuf::from("/tmp/other-backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
acquired_at: Utc::now(),
expires_at: Utc::now() + ChronoDuration::minutes(30),
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
});
ledger.save(&test_opts.ledger_path).unwrap();
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
result,
RestoreResult::AlreadyLeased { token, .. } if token == "already-live"
));
}
#[tokio::test(flavor = "multi_thread")]
async fn unavailable_authentication_is_reported_and_takes_no_backup() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&Unsupported,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(result, RestoreResult::Unavailable { .. }));
assert!(dir
.path()
.join("backups")
.read_dir()
.unwrap()
.next()
.is_none());
}
#[test]
fn leased_write_refusal_detail_covers_every_variant() {
assert_eq!(
leased_write_refusal_detail(LeaseGateRefusal::NoLease),
"the freshly minted lease was not presented to its own write check"
);
assert_eq!(
leased_write_refusal_detail(LeaseGateRefusal::Expired),
"the freshly minted lease was already expired or released by the time of the \
restore write"
);
assert_eq!(
leased_write_refusal_detail(LeaseGateRefusal::WrongFile),
"the freshly minted lease was bound to a different file than the restore write \
targeted"
);
assert_eq!(
leased_write_refusal_detail(LeaseGateRefusal::Stale),
"the file changed again between minting the fresh lease and the restore write"
);
assert_eq!(
leased_write_refusal_detail(LeaseGateRefusal::Failed("boom".to_string())),
"boom"
);
}
#[tokio::test]
async fn a_corrupt_ledger_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "backup-token");
std::fs::write(&test_opts.ledger_path, "not json\n").unwrap();
let result = restore(&client, &sheets, &test_opts, &PanicsIfCalled, &[]).await;
assert!(matches!(result, RestoreResult::Failed { .. }));
}
#[tokio::test]
async fn a_target_metadata_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[],
)
.await;
assert!(matches!(result, RestoreResult::Failed { .. }));
}
#[tokio::test]
async fn a_parent_lookup_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[],
)
.await;
assert!(matches!(result, RestoreResult::Failed { .. }));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_native_document_target_is_refused_via_the_fresh_acquire_step() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(result, RestoreResult::RefusedNativeDocument));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_target_that_became_native_is_refused_at_the_recheck_when_a_backup_folder_is_configured(
) {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-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("/drive/v3/files/file-1/copy"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "copy-1", "name": "backup", "mimeType": GOOGLE_SHEET_MIME_TYPE
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let mut restore_opts = opts(dir.path(), &old_token);
restore_opts.native_backup_folder_id = Some("backup-folder".to_string());
let result = restore(
&client,
&sheets,
&restore_opts,
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, detail, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
assert!(
detail.contains("Google-native document"),
"expected the mime-type re-check's own refusal detail, got: {detail}"
);
}
#[tokio::test]
async fn a_backup_over_the_upload_cap_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "");
let oversized = crate::drive::files_api::MAX_UPLOAD_BYTES + 1;
let token = seed_backup_lease(
&test_opts.ledger_path,
"file-1",
LeaseBackup::Bytes {
path: PathBuf::from("/does/not/need/to/exist"),
sha256: "deadbeef".to_string(),
size: oversized,
},
);
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&PanicsIfCalled,
&[],
)
.await;
assert!(matches!(
result,
RestoreResult::BackupTooLargeForSimpleUpload { size } if size == oversized
));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_denied_fresh_authentication_is_reported() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &token),
&FakeAuthenticator(AuthOutcome::Denied("no".to_string())),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(result, RestoreResult::Denied { .. }));
}
#[tokio::test(flavor = "multi_thread")]
async fn an_invalid_fresh_expiry_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"original content");
let test_opts = opts(dir.path(), "");
let token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let mut restore_opts = opts(dir.path(), &token);
restore_opts.expiry = ChronoDuration::minutes(0);
let result = restore(
&client,
&sheets,
&restore_opts,
&PanicsIfCalled,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(result, RestoreResult::Failed { .. }));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_metadata_fetch_failure_during_the_recheck_reports_fresh_lease_but_write_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.up_to_n_times(4)
.with_priority(1)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/file-1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(wiremock::ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_parent_lookup_failure_during_the_recheck_reports_fresh_lease_but_write_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.up_to_n_times(3)
.with_priority(1)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
mount_file("file-1", "text/plain", &["parent-2"])
.with_priority(2)
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_stale_file_at_write_time_reports_fresh_lease_but_write_failed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir_all(dir.path().join("backups")).unwrap();
let backup = write_backup_file(dir.path(), b"the original content");
let test_opts = opts(dir.path(), "");
let old_token = seed_backup_lease(&test_opts.ledger_path, "file-1", backup);
mount_file("file-1", "text/plain", &["parent-1"])
.up_to_n_times(5)
.with_priority(1)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_download("file-1", b"the current, about-to-be-overwritten content")
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/file-1"))
.and(wiremock::matchers::query_param_is_missing("alt"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "mimeType": "text/plain",
"parents": ["parent-1"], "version": "2",
})),
)
.with_priority(2)
.mount(&server)
.await;
let result = restore(
&client,
&sheets,
&opts(dir.path(), &old_token),
&FakeAuthenticator(AuthOutcome::Authorized),
&[allow_rule("parent-1")],
)
.await;
let RestoreResult::FreshLeaseButWriteFailed { token, detail, .. } = result else {
panic!("expected FreshLeaseButWriteFailed, got {result:?}");
};
assert_ne!(token, old_token);
assert!(
detail.contains("file changed again"),
"expected the staleness refusal's own detail, got: {detail}"
);
}
#[tokio::test]
async fn a_best_effort_audit_write_failure_is_warned_and_swallowed() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let sheets = sheets_client_for(&server, &client);
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
std::fs::create_dir(dir.path().join("audit.jsonl")).unwrap();
let result = restore(
&client,
&sheets,
&opts(dir.path(), "no-such-token"),
&PanicsIfCalled,
&[],
)
.await;
assert!(matches!(result, RestoreResult::NoSuchBackupToken));
}
#[test]
fn restore_result_serializes_to_jsonl() {
let mut buf = Vec::new();
RestoreResult::Restored {
new_token: "tok-2".to_string(),
expires_at: Utc::now(),
backup: LeaseBackup::Bytes {
path: PathBuf::from("/tmp/backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
headless_waiver: false,
}
.write_jsonl(&mut buf)
.unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("\"status\":\"restored\""), "{text}");
assert!(text.contains("tok-2"), "{text}");
}
#[test]
fn a_sheet_already_restored_refusal_serializes_to_jsonl() {
let mut buf = Vec::new();
RestoreResult::SheetAlreadyRestored {
spreadsheet_id: "sheet-1".to_string(),
sheet_id: 999,
sheet_title: "Deleted".to_string(),
restored_at: Some(Utc::now()),
live_lease: Some(LiveLease {
token: "tok-6".to_string(),
expires_at: Utc::now(),
}),
}
.write_jsonl(&mut buf)
.unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(
text.contains("\"status\":\"sheet-already-restored\""),
"{text}"
);
assert!(text.contains("tok-6"), "{text}");
}
#[test]
fn verdict_names_the_already_restored_refusal() {
assert_eq!(
RestoreResult::SheetAlreadyRestored {
spreadsheet_id: "sheet-1".to_string(),
sheet_id: 999,
sheet_title: "Deleted".to_string(),
restored_at: None,
live_lease: None,
}
.verdict(),
"sheet-already-restored"
);
}
#[test]
fn verdict_distinguishes_a_headless_waived_restore() {
let restored = |headless_waiver| RestoreResult::Restored {
new_token: "tok".to_string(),
expires_at: Utc::now(),
backup: LeaseBackup::Bytes {
path: PathBuf::from("/tmp/backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
headless_waiver,
};
assert_eq!(restored(false).verdict(), "restored");
assert_eq!(restored(true).verdict(), "restored-headless-waiver");
}
#[test]
fn verdict_distinguishes_a_headless_waived_sheet_restore() {
let restored_sheet = |headless_waiver| RestoreResult::RestoredSheet {
new_token: "tok".to_string(),
expires_at: Utc::now(),
backup: LeaseBackup::Bytes {
path: PathBuf::from("/tmp/backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
spreadsheet_id: "sheet-1".to_string(),
sheet_id: 1,
sheet_title: "Sheet1".to_string(),
headless_waiver,
};
assert_eq!(restored_sheet(false).verdict(), "restored-sheet");
assert_eq!(
restored_sheet(true).verdict(),
"restored-sheet-headless-waiver"
);
}
#[test]
fn exit_code_matches_the_documented_classification() {
let backup = || LeaseBackup::Bytes {
path: PathBuf::from("/tmp/backup"),
sha256: "deadbeef".to_string(),
size: 0,
};
let cases: Vec<(RestoreResult, i32)> = vec![
(
RestoreResult::Restored {
new_token: "tok".to_string(),
expires_at: Utc::now(),
backup: backup(),
headless_waiver: false,
},
0,
),
(
RestoreResult::RestoredSheet {
new_token: "tok".to_string(),
expires_at: Utc::now(),
backup: backup(),
spreadsheet_id: "sheet-1".to_string(),
sheet_id: 1,
sheet_title: "Sheet1".to_string(),
headless_waiver: false,
},
0,
),
(
RestoreResult::SheetAlreadyRestored {
spreadsheet_id: "sheet-1".to_string(),
sheet_id: 1,
sheet_title: "Sheet1".to_string(),
restored_at: None,
live_lease: None,
},
1,
),
(RestoreResult::NoSuchBackupToken, 1),
(
RestoreResult::NoTypedRestorePath {
backup_location: "copy-1".to_string(),
},
1,
),
(RestoreResult::BackupTooLargeForSimpleUpload { size: 1 }, 1),
(RestoreResult::RefusedNoVisibleParents, 1),
(RestoreResult::Blocked { decided_by: None }, 1),
(
RestoreResult::AlreadyLeased {
token: "other-tok".to_string(),
expires_at: Utc::now(),
},
1,
),
(RestoreResult::RefusedNativeDocument, 1),
(
RestoreResult::RefusedConcurrentChange {
detail: "moved".to_string(),
},
1,
),
(
RestoreResult::Denied {
detail: "no".to_string(),
},
1,
),
(
RestoreResult::Unavailable {
detail: "no authenticator".to_string(),
},
1,
),
(
RestoreResult::Failed {
detail: "boom".to_string(),
},
1,
),
(
RestoreResult::FreshLeaseButWriteFailed {
token: "tok".to_string(),
expires_at: Utc::now(),
detail: "gate refused".to_string(),
},
1,
),
];
for (result, expected) in cases {
assert_eq!(result.exit_code(), expected, "{result:?}");
}
}
}