Files
proton-sdk-rs/crates/proton-drive-rs/tests/live_drive_ops.rs
T
nvrl 24a99e6b9a public link consume, revision history, path helpers
Public links (public_link.rs, ported from the TS SDK — the C# SDK has no
equivalent): ProtonDrivePublicLinkClient lets a visitor with no Proton
account open someone else's share link. `info` reads pre-auth metadata,
`open` runs the SRP handshake against drive/urls/{token}/info + /auth and
unlocks the share key (bcrypt SharePasswordSalt -> key password -> the
SKESK'd SharePassphrase -> ShareKey). The read surface then reuses the
ordinary link/children/revision endpoints. Signature verification is
deliberately absent: an anonymous visitor cannot read core/v4/keys/all,
matching the TS SDK. Supporting primitives: http::get_unauthenticated and
crypto::decrypt_armored_with_password.

Revision history: enumerate_revisions / get_revision / restore_revision /
delete_revision, plus open_revision_at / download_revision{,_to} to read a
specific revision. Drafts are filtered on the wire value, since
RevisionState::from_raw maps 0 to Active. Three server behaviors found
live and worked around: ?NoBlockUrls=true still emits Blocks with null
BareURL, so get_revision deserializes into a DTO without that field; the
restore POST needs an empty object body, not null; and restore is async
(HTTP 202, non-1000 envelope) so the new active revision is not live on
return.

Path and hierarchy helpers: get_node_hierarchy, get_node_path,
get_node_by_path, create_folder_path. Names are encrypted and there is no
name-query endpoint, so lookup walks and decrypts one directory level per
segment.

Fix a silent-corruption path in block-size resolution. RevisionReader
accumulates block sizes to derive where each block starts in the
plaintext, so a vector that overstates any block but the last shifts every
block after it and the reader splices at the wrong offsets — full-length
reads of the wrong bytes, no error raised. resolve_block_sizes previously
guessed all-full blocks when both Common.BlockSizes and Common.Size were
unusable. It now errors for multi-block files and only assumes a full
block when there is exactly one (which cannot be misaligned). Regression
test in revision.rs::padded_block_sizes_shift_later_blocks.

Recover from stale upload drafts. An upload interrupted after the draft
node was created but before it was sealed leaves an unsealed draft, and
every later attempt at that name returns 422 AlreadyExists forever. Draft
creation now deletes the conflicting node and retries when the conflict is
an unsealed draft; by default only our own client's, with
upload_file_replacing_draft_from opting into any client's to recover
across a daemon restart (which rotates our client uid). A committed file
of that name is never touched.

Live tests for the public-link round trip, custom-password links, revision
history, and the path helpers.
2026-07-19 13:17:30 +02:00

440 lines
13 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::NodeUid;
/// Trash then permanently delete the given nodes; best-effort, logs on failure.
async fn cleanup(client: &proton_drive_rs::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_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
client
.trash_nodes(std::slice::from_ref(&uid))
.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(std::slice::from_ref(&uid))
.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(std::slice::from_ref(&uid))
.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;
}
// ---------------------------------------------------------------------------
// 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;
}