diff --git a/crates/proton-drive-sdk/src/client.rs b/crates/proton-drive-sdk/src/client.rs index 70d2d1c..1ef9167 100644 --- a/crates/proton-drive-sdk/src/client.rs +++ b/crates/proton-drive-sdk/src/client.rs @@ -1188,8 +1188,14 @@ impl ProtonDriveClient { let request = MoveLinkRequest { parent_link_id: new_parent.link_id.clone(), passphrase: parts.passphrase, - // The rewrap preserves the plaintext, so the signature is unchanged. - passphrase_signature: link.passphrase_signature, + // The rewrap preserves the plaintext, so the existing detached + // signature stays valid and is not re-sent (C# `MoveSingleAsync` + // sends `PassphraseSignature = null` for non-anonymous nodes; only + // an anonymous move re-signs). Passing the link's own value back is + // wrong: the API returns it as an empty string for these nodes, and + // a serialized empty `NodePassphraseSignature` is rejected 400 + // "should not be empty" — the batch path already hardcodes `None`. + passphrase_signature: None, name: parts.encrypted_name, name_signature_email: email, name_hash: parts.name_hash, diff --git a/crates/proton-drive-sdk/tests/common/mod.rs b/crates/proton-drive-sdk/tests/common/mod.rs index e9f30f4..19135d1 100644 --- a/crates/proton-drive-sdk/tests/common/mod.rs +++ b/crates/proton-drive-sdk/tests/common/mod.rs @@ -31,14 +31,27 @@ pub struct LiveClient { pub _session: ProtonApiSession, } -/// Build an authenticated `ProtonDriveClient`, or `None` when credentials are -/// not configured (skip, don't fail). +/// Process-global authenticated client, built once and shared by every test in +/// this binary. Proton anti-abuse throttles repeated SRP logins (and each login +/// burns a TOTP window), so a fresh login per test trips +/// `IncorrectLoginCredentials` after a handful. One login for the whole suite +/// sidesteps both. `None` = credentials absent (skip cleanly). +static SHARED: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); + +/// The shared authenticated `ProtonDriveClient`, or `None` when credentials are +/// not configured (skip, don't fail). Logs in on first call; reused thereafter. /// /// Call from a test like: /// ```ignore /// let Some(live) = common::live_client().await else { return }; +/// let client = &live.client; /// ``` -pub async fn live_client() -> Option { +pub async fn live_client() -> Option<&'static LiveClient> { + SHARED.get_or_init(build_live_client).await.as_ref() +} + +/// Perform the one-time SRP + 2FA login and build the client. +async fn build_live_client() -> Option { let (username, password) = match read_dotenv() { Ok(creds) => creds, Err(e) => { @@ -61,7 +74,7 @@ pub async fn live_client() -> Option { .expect("SRP login failed"); if session.is_waiting_for_second_factor() { - let code = totp_now(&totp_secret).expect("TOTP compute failed"); + let code = totp_fresh(&totp_secret).expect("TOTP compute failed"); session .apply_second_factor_code(&code) .await @@ -128,10 +141,36 @@ fn read_totp_secret() -> Option { None } -/// RFC 6238 TOTP (SHA-1, 6 digits, 30s step) for the current time. -fn totp_now(secret_b32: &str) -> Result> { +/// Last TOTP window (30s counter) submitted by *any* login in this process. +/// +/// Proton rejects a TOTP code re-submitted within its 30s validity window, so +/// back-to-back logins (one per test) must each land on a distinct window. +static LAST_TOTP_COUNTER: std::sync::Mutex = std::sync::Mutex::new(0); + +/// A TOTP code guaranteed not to reuse a window already consumed by an earlier +/// login in this process. Blocks until the next 30s boundary when the current +/// window was already used (so serial `--ignored` runs don't trip on code +/// reuse). Uses a blocking sleep — fine for the single-threaded test harness. +fn totp_fresh(secret_b32: &str) -> Result> { + loop { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let counter = now / 30; + { + let mut last = LAST_TOTP_COUNTER.lock().unwrap(); + if counter > *last { + *last = counter; + return totp_at(secret_b32, counter); + } + } + let wait = 30 - (now % 30) + 1; + eprintln!("[2fa] TOTP window already used; waiting {wait}s for a fresh code"); + std::thread::sleep(std::time::Duration::from_secs(wait)); + } +} + +/// RFC 6238 TOTP (SHA-1, 6 digits, 30s step) at a specific 30s counter. +fn totp_at(secret_b32: &str, counter: u64) -> Result> { let key = base32_decode(secret_b32)?; - let counter = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() / 30; let mut mac = Hmac::::new_from_slice(&key)?; mac.update(&counter.to_be_bytes()); diff --git a/crates/proton-drive-sdk/tests/live_drive_ops.rs b/crates/proton-drive-sdk/tests/live_drive_ops.rs new file mode 100644 index 0000000..9a2d5ad --- /dev/null +++ b/crates/proton-drive-sdk/tests/live_drive_ops.rs @@ -0,0 +1,306 @@ +//! 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-sdk --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_sdk::proton_sdk::crypto::VerificationStatus; +use proton_drive_sdk::{Node, NodeKind}; +use proton_sdk::ids::NodeUid; + +/// Trash then permanently delete the given nodes; best-effort, logs on failure. +async fn cleanup(client: &proton_drive_sdk::ProtonDriveClient, uids: &[NodeUid]) { + if let Err(e) = client.trash_nodes(uids).await { + eprintln!("[cleanup] trash failed: {e}"); + return; + } + if let Err(e) = client.delete_nodes(uids).await { + eprintln!("[cleanup] delete failed: {e}"); + } +} + +/// Fetch a node that must exist, panicking with context otherwise. +async fn get(client: &proton_drive_sdk::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 + client.trash_nodes(&[uid.clone()]).await.expect("trash"); + 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 + client.restore_nodes(&[uid.clone()]).await.expect("restore"); + 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"); + + client.trash_nodes(&[uid.clone()]).await.expect("trash"); + 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"); + + client + .move_nodes(&[c1.clone(), c2.clone()], &dst) + .await + .expect("move_nodes"); + + 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; +} + +// --------------------------------------------------------------------------- +// 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; +} diff --git a/crates/proton-drive-sdk/tests/live_upload_download.rs b/crates/proton-drive-sdk/tests/live_upload_download.rs index c36aeae..f198e19 100644 --- a/crates/proton-drive-sdk/tests/live_upload_download.rs +++ b/crates/proton-drive-sdk/tests/live_upload_download.rs @@ -28,7 +28,7 @@ async fn upload_download_small_roundtrip() { let Some(live) = common::live_client().await else { return; }; - let client = live.client; + let client = &live.client; let root = client .get_my_files_folder() @@ -57,7 +57,7 @@ async fn upload_download_multiblock_roundtrip() { let Some(live) = common::live_client().await else { return; }; - let client = live.client; + let client = &live.client; let root = client .get_my_files_folder() @@ -103,7 +103,7 @@ async fn new_revision_roundtrip() { let Some(live) = common::live_client().await else { return; }; - let client = live.client; + let client = &live.client; let root = client .get_my_files_folder()