//! 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 = (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, ©, "file copy").await; assert_eq!(node.name, "data.bin"); assert_eq!(node.parent_uid.as_ref(), Some(&target)); assert_eq!( client.download_file(©).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 = 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; }