Files
proton-sdk-rs/crates/proton-drive-rs/tests/live_drive_ops.rs
T
nvrl c30b107382 feat(drive): copy a node on the server
ProtonDriveClient::copy_node copies a file with its content, or a folder
with its subtree, under a new parent without downloading it, ported from
the TS SDK's copyNode (POST volumes/{vid}/links/{lid}/copy). The crypto
is the move's: the passphrase is rewrapped to the new parent, so the
node and content keys stay the same, and the name is encrypted and
signed to it. An optional name renames the copy.

The new parent may be on another volume, and the returned uid takes the
target's volume, where TS uses the source's. CopyPhotoResponse is now
the shared CopyLinkResponse.

UPSTREAM_SYNC.md logs this, get_folder_size and the read retries.
2026-10-25 20:37:51 +01:00

666 lines
21 KiB
Rust

//! Live integration: folder operations, node moves, and signature
//! verification against a real Proton account.
//!
//! Skipped by default. Run against the test account with:
//! cargo test -p proton-drive-rs --test live_drive_ops -- --ignored --nocapture
//!
//! Each test cleans up after itself (trash + delete-from-trash) so the account
//! stays reusable across runs.
mod common;
use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::{Node, NodeKind};
use proton_sdk::ids::{LinkId, NodeUid};
/// Trash then permanently delete the given nodes; best-effort, logs on failure.
async fn cleanup(client: &proton_drive_rs::ProtonDriveClient, uids: &[NodeUid]) {
match client.trash_nodes(uids).await {
Ok(outcomes) => common::log_outcomes("trash", &outcomes),
Err(e) => {
eprintln!("[cleanup] trash failed: {e}");
return;
}
}
match client.delete_nodes(uids).await {
Ok(outcomes) => common::log_outcomes("delete", &outcomes),
Err(e) => eprintln!("[cleanup] delete failed: {e}"),
}
}
/// Fetch a node that must exist, panicking with context otherwise.
async fn get(client: &proton_drive_rs::ProtonDriveClient, uid: &NodeUid, what: &str) -> Node {
client
.get_node(uid)
.await
.unwrap_or_else(|e| panic!("get_node({what}) errored: {e}"))
.unwrap_or_else(|| panic!("get_node({what}) returned None"))
}
// ---------------------------------------------------------------------------
// Folder operations
// ---------------------------------------------------------------------------
/// create → rename → trash → restore → delete, asserting state at each step.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn folder_create_rename_trash_restore() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
// create
let name = format!("folder-ops-{}", common::unique_suffix());
let uid = client
.create_folder(&root.uid, &name, None)
.await
.expect("create_folder");
let node = get(client, &uid, "after create").await;
assert!(matches!(node.kind, NodeKind::Folder), "must be a folder");
assert_eq!(node.name, name, "name must round-trip");
assert_eq!(
node.parent_uid.as_ref(),
Some(&root.uid),
"parent must be root"
);
// rename
let renamed = format!("{name}-renamed");
client
.rename_node(&uid, &renamed, None)
.await
.expect("rename_node");
let node = get(client, &uid, "after rename").await;
assert_eq!(node.name, renamed, "rename must take effect");
// trash
let outcomes = client
.trash_nodes(std::slice::from_ref(&uid))
.await
.expect("trash");
common::expect_all_ok("trash", &outcomes);
let node = get(client, &uid, "after trash").await;
assert!(node.trashed, "node must report trashed");
let trash = client
.enumerate_trash_node_uids()
.await
.expect("enumerate trash");
assert!(trash.contains(&uid), "trash listing must include the node");
// restore
let outcomes = client
.restore_nodes(std::slice::from_ref(&uid))
.await
.expect("restore");
common::expect_all_ok("restore", &outcomes);
let node = get(client, &uid, "after restore").await;
assert!(!node.trashed, "node must no longer be trashed");
// delete (final)
cleanup(client, &[uid]).await;
}
/// empty_trash removes a trashed node from the trash listing.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn folder_empty_trash() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let name = format!("empty-trash-{}", common::unique_suffix());
let uid = client
.create_folder(&root.uid, &name, None)
.await
.expect("create_folder");
let outcomes = client
.trash_nodes(std::slice::from_ref(&uid))
.await
.expect("trash");
common::expect_all_ok("trash", &outcomes);
assert!(
client
.enumerate_trash_node_uids()
.await
.expect("enumerate trash")
.contains(&uid),
"node must be in trash before empty"
);
client.empty_trash().await.expect("empty_trash");
assert!(
!client
.enumerate_trash_node_uids()
.await
.expect("enumerate trash")
.contains(&uid),
"node must be gone from trash after empty_trash"
);
}
// ---------------------------------------------------------------------------
// Move
// ---------------------------------------------------------------------------
/// Single move: a child folder relocates from parent A to parent B.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn move_node_single() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let suffix = common::unique_suffix();
let a = client
.create_folder(&root.uid, &format!("move-src-{suffix}"), None)
.await
.expect("create A");
let b = client
.create_folder(&root.uid, &format!("move-dst-{suffix}"), None)
.await
.expect("create B");
let child = client
.create_folder(&a, &format!("move-child-{suffix}"), None)
.await
.expect("create child in A");
// Precondition: child parented to A.
assert_eq!(
get(client, &child, "before move").await.parent_uid.as_ref(),
Some(&a),
"child must start under A"
);
client.move_node(&child, &b).await.expect("move_node");
assert_eq!(
get(client, &child, "after move").await.parent_uid.as_ref(),
Some(&b),
"child must be reparented to B"
);
cleanup(client, &[child, a, b]).await;
}
/// Batch move: two children relocate to a destination folder in one call.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn move_nodes_batch() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let suffix = common::unique_suffix();
let dst = client
.create_folder(&root.uid, &format!("batch-dst-{suffix}"), None)
.await
.expect("create dst");
let c1 = client
.create_folder(&root.uid, &format!("batch-c1-{suffix}"), None)
.await
.expect("create c1");
let c2 = client
.create_folder(&root.uid, &format!("batch-c2-{suffix}"), None)
.await
.expect("create c2");
let outcomes = client
.move_nodes(&[c1.clone(), c2.clone()], &dst)
.await
.expect("move_nodes");
assert_eq!(outcomes.len(), 2, "one outcome per requested node");
for (uid, outcome) in &outcomes {
outcome
.as_ref()
.unwrap_or_else(|e| panic!("move of {uid} must succeed: {e}"));
}
assert_eq!(
get(client, &c1, "c1 after move").await.parent_uid.as_ref(),
Some(&dst),
"c1 must be under dst"
);
assert_eq!(
get(client, &c2, "c2 after move").await.parent_uid.as_ref(),
Some(&dst),
"c2 must be under dst"
);
cleanup(client, &[c1, c2, dst]).await;
}
/// One bad node must not sink the batch: `move_nodes` reports per-node outcomes
/// (C# `MoveNodesAsync`'s result map), so a bogus link id fails alone while its
/// neighbour still moves.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn move_nodes_partial_failure() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let suffix = common::unique_suffix();
let dst = client
.create_folder(&root.uid, &format!("partial-dst-{suffix}"), None)
.await
.expect("create dst");
let good = client
.create_folder(&root.uid, &format!("partial-good-{suffix}"), None)
.await
.expect("create good");
let bogus = NodeUid::new(
dst.volume_id.clone(),
LinkId::new(format!("nonexistent-link-{suffix}")),
);
let outcomes = client
.move_nodes(&[good.clone(), bogus.clone()], &dst)
.await
.expect("move_nodes must not fail as a whole");
assert_eq!(outcomes.len(), 2, "one outcome per requested node");
assert_eq!(outcomes[0].0, good, "outcomes keep the input order");
outcomes[0]
.1
.as_ref()
.expect("the real node must still move");
assert_eq!(outcomes[1].0, bogus);
let e = outcomes[1]
.1
.as_ref()
.expect_err("the bogus node must report its own failure");
eprintln!("[info] bogus node outcome: {e}");
assert_eq!(
get(client, &good, "good after move")
.await
.parent_uid
.as_ref(),
Some(&dst),
"the real node must be under dst"
);
cleanup(client, &[good, dst]).await;
}
// ---------------------------------------------------------------------------
// Signature verification
// ---------------------------------------------------------------------------
/// A self-uploaded file must verify cleanly: this exercises the live
/// `core/v4/keys/all` author-key resolution path (the offline tests sign with
/// in-process keys and never prove the remote lookup works).
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn verification_file_fully_verified() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let name = format!("verify-{}.txt", common::unique_suffix());
let payload = b"verification probe payload".to_vec();
let uid = client
.upload_file(&root.uid, &name, "text/plain", &payload)
.await
.expect("upload_file");
let node = get(client, &uid, "uploaded file").await;
let v = node.verification;
// Name is inline-signed to the parent by our address key; an `Ok` here
// proves the author email resolved to a usable verification key remotely.
assert_eq!(
v.name,
VerificationStatus::Ok,
"name signature must verify against the resolved author key"
);
assert_eq!(
v.passphrase,
VerificationStatus::Ok,
"node passphrase signature must verify"
);
assert_eq!(
v.content_key,
Some(VerificationStatus::Ok),
"content-key signature (node-key signer) must verify"
);
assert!(
v.is_fully_verified(),
"no signature may be NoVerifier/Failed: {v:?}"
);
cleanup(client, &[uid]).await;
}
/// A created folder must verify cleanly (name + passphrase signed; no
/// content-key / xattr for a plain folder).
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn verification_folder_fully_verified() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let name = format!("verify-folder-{}", common::unique_suffix());
let uid = client
.create_folder(&root.uid, &name, None)
.await
.expect("create_folder");
let node = get(client, &uid, "created folder").await;
let v = node.verification;
assert_eq!(v.name, VerificationStatus::Ok, "folder name must verify");
assert_eq!(
v.passphrase,
VerificationStatus::Ok,
"folder passphrase must verify"
);
assert!(v.is_fully_verified(), "folder must fully verify: {v:?}");
cleanup(client, &[uid]).await;
}
// ---------------------------------------------------------------------------
// Hierarchy & path helpers
// ---------------------------------------------------------------------------
/// `create_folder_path` → `get_node_by_path` → `get_node_hierarchy` →
/// `get_node_path`, asserting the four agree on the same nested tree.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn folder_path_helpers_round_trip() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let top = format!("path-ops-{}", common::unique_suffix());
let path = format!("{top}/nested/deep");
// mkdir -p
let deep = client
.create_folder_path(&root.uid, &path)
.await
.expect("create_folder_path");
// Idempotent: a second call adopts the existing folders, same uid.
let again = client
.create_folder_path(&root.uid, &path)
.await
.expect("create_folder_path is idempotent");
assert_eq!(again, deep, "re-creating a path must reuse the folders");
// Lookup by path, tolerating separator noise.
for variant in [path.as_str(), &format!("/{path}"), &format!("//{path}//")] {
let found = client
.get_node_by_path(&root.uid, variant)
.await
.unwrap_or_else(|e| panic!("get_node_by_path({variant}) errored: {e}"))
.unwrap_or_else(|| panic!("get_node_by_path({variant}) returned None"));
assert_eq!(found.uid, deep, "path {variant} must resolve to the folder");
}
// An empty path is the root itself.
let at_root = client
.get_node_by_path(&root.uid, "/")
.await
.expect("get_node_by_path(/)")
.expect("root must resolve");
assert_eq!(at_root.uid, root.uid, "empty path is the root");
// A missing segment resolves to None, not an error.
assert!(
client
.get_node_by_path(&root.uid, &format!("{top}/nope"))
.await
.expect("missing path must not error")
.is_none(),
"a missing segment must resolve to None"
);
// Hierarchy is root-first and ends at the queried node.
let hierarchy = client
.get_node_hierarchy(&deep)
.await
.expect("get_node_hierarchy")
.expect("hierarchy for an existing node");
assert_eq!(
hierarchy.first().map(|n| &n.uid),
Some(&root.uid),
"hierarchy must start at the tree root"
);
assert_eq!(
hierarchy.last().map(|n| &n.uid),
Some(&deep),
"hierarchy must end at the queried node"
);
let names: Vec<&str> = hierarchy.iter().skip(1).map(|n| n.name.as_str()).collect();
assert_eq!(names, [top.as_str(), "nested", "deep"]);
// The rendered path round-trips back through the path lookup.
let rendered = client
.get_node_path(&deep)
.await
.expect("get_node_path")
.expect("path for an existing node");
assert_eq!(rendered, format!("/{path}"));
let reresolved = client
.get_node_by_path(&root.uid, &rendered)
.await
.expect("re-resolve rendered path")
.expect("rendered path must resolve");
assert_eq!(reresolved.uid, deep, "rendered path must round-trip");
// Only the top folder needs cleaning — delete cascades to its children.
let top_uid = client
.get_node_by_path(&root.uid, &top)
.await
.expect("locate top folder")
.expect("top folder must exist")
.uid;
cleanup(client, &[top_uid]).await;
}
/// `get_folder_size` counts every descendant, nested folders included, and
/// sums their encrypted sizes.
///
/// The TS docs leave both points open; the first live run answered them with
/// `size: 301112, descendants: 3` — two single-block files, 56 bytes of
/// encryption overhead each.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn folder_size_counts_nested_descendants() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let top_name = format!("size-ops-{}", common::unique_suffix());
let top = client
.create_folder(&root.uid, &top_name, None)
.await
.expect("create top folder");
let nested = client
.create_folder(&top, "nested", None)
.await
.expect("create nested folder");
let small = vec![b'a'; 1000];
let large = vec![b'b'; 300_000];
client
.upload_file(&top, "small.bin", "application/octet-stream", &small)
.await
.expect("upload small file");
client
.upload_file(&nested, "large.bin", "application/octet-stream", &large)
.await
.expect("upload nested file");
let size = client.get_folder_size(&top).await.expect("get_folder_size");
eprintln!("[folder size] {size:?} for 1 folder + 2 files of 301000 plaintext bytes");
assert_eq!(
size.descendants, 3,
"two files and the nested folder: {size:?}"
);
let plaintext = (small.len() + large.len()) as u64;
assert!(
(plaintext..plaintext + 1024).contains(&size.size),
"encrypted size, a little above the plaintext: {size:?}"
);
let empty = client
.get_folder_size(&nested)
.await
.expect("get_folder_size(nested)");
assert_eq!(empty.descendants, 1, "nested holds one file: {empty:?}");
cleanup(client, &[top]).await;
}
/// `copy_node` copies a file with its content and a folder with its subtree,
/// keeps or replaces the name, and refuses a name already taken.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn copy_node_copies_content_and_subtrees() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let top_name = format!("copy-ops-{}", common::unique_suffix());
let top = client
.create_folder(&root.uid, &top_name, None)
.await
.expect("create top folder");
let source = client
.create_folder(&top, "source", None)
.await
.expect("create source folder");
let target = client
.create_folder(&top, "target", None)
.await
.expect("create target folder");
let payload: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let file = client
.upload_file(&source, "data.bin", "application/octet-stream", &payload)
.await
.expect("upload file");
// A file, keeping its name, into another folder.
let copy = client
.copy_node(&file, &target, None)
.await
.expect("copy file");
assert_ne!(copy, file, "a copy is a new node");
let node = get(client, &copy, "file copy").await;
assert_eq!(node.name, "data.bin");
assert_eq!(node.parent_uid.as_ref(), Some(&target));
assert_eq!(
client.download_file(&copy).await.expect("download copy"),
payload,
"the copy carries the content"
);
// A file, renamed, next to itself.
let renamed = client
.copy_node(&file, &source, Some("data (copy).bin"))
.await
.expect("copy file with a new name");
assert_eq!(
get(client, &renamed, "renamed copy").await.name,
"data (copy).bin"
);
assert_eq!(get(client, &file, "source file").await.name, "data.bin");
// The same name twice in one folder is refused.
let taken = client
.copy_node(&file, &source, None)
.await
.expect_err("the name is taken");
match &taken {
ProtonError::Api(e) => assert_eq!(e.code, ResponseCode::AlreadyExists, "{taken:?}"),
other => panic!("expected an api error, got {other:?}"),
}
// A folder, with its subtree.
let folder_copy = client
.copy_node(&source, &target, Some("source copy"))
.await
.expect("copy folder");
let children = client
.enumerate_folder_children_node_uids(&folder_copy)
.await
.expect("list folder copy");
let mut names: Vec<String> = client
.enumerate_nodes(&children)
.await
.expect("read folder copy children")
.into_iter()
.map(|n| n.name)
.collect();
names.sort();
assert_eq!(names, ["data (copy).bin", "data.bin"]);
cleanup(client, &[top]).await;
}