added file tests + fixed client move_node

This commit is contained in:
nvrl committed 2026-06-27 01:12:37 +02:00
1 parent 1ae5555e01
commit 52b594e524
4 files changed
+363 -12

No files matched your search

+8 -2
View File
@@ -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,
+46 -7
View File
@@ -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<Option<LiveClient>> = 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<LiveClient> {
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<LiveClient> {
let (username, password) = match read_dotenv() {
Ok(creds) => creds,
Err(e) => {
@@ -61,7 +74,7 @@ pub async fn live_client() -> Option<LiveClient> {
.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<String> {
None
}
/// RFC 6238 TOTP (SHA-1, 6 digits, 30s step) for the current time.
fn totp_now(secret_b32: &str) -> Result<String, Box<dyn std::error::Error>> {
/// 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<u64> = 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<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
let key = base32_decode(secret_b32)?;
let counter = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() / 30;
let mut mac = Hmac::<Sha1>::new_from_slice(&key)?;
mac.update(&counter.to_be_bytes());
@@ -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;
}
@@ -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()