mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
added file tests + fixed client move_node
This commit is contained in:
1 parent
1ae5555e01
commit
52b594e524
4 files changed
+363
-12
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user