Files
proton-sdk-rs/crates/proton-drive-rs/tests/live_drive_ops.rs
T
nvrl 8611989364 feat(drive): report a folder's size from the server
ProtonDriveClient::get_folder_size returns the total size and number of
a folder's descendants without walking the tree, ported from the TS
SDK's getFolderSize (GET volumes/{vid}/folders/{lid}/calculate-
descendents-size). Trashed descendants and folders count, and the size
is encrypted bytes on storage; the live test pins both, which the TS
docs leave open.
2026-10-25 20:33:29 +01:00

573 lines
18 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::crypto::VerificationStatus;
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;
}