use donutbrowser_lib::sync::types::*; use reqwest::Client; use serde_json::json; use std::env; use std::fs; use std::path::Path; use tempfile::TempDir; const TEST_TOKEN: &str = "test-sync-token"; fn get_sync_server_url() -> String { env::var("SYNC_SERVER_URL").unwrap_or_else(|_| "http://localhost:12342".to_string()) } /// Check if sync server is available and fail with a clear error message if not. /// This ensures tests fail with helpful information rather than being silently ignored. async fn ensure_sync_server_available() { let client = Client::new(); let health_url = format!("{}/health", get_sync_server_url()); match client .get(&health_url) .timeout(std::time::Duration::from_secs(5)) .send() .await { Ok(response) => { if !response.status().is_success() { panic!( "Sync server is not healthy. Health check returned status: {}\n\ Server URL: {}\n\ Please ensure:\n\ 1. MinIO is running (docker compose up -d in donut-sync/)\n\ 2. donut-sync server is running (cd donut-sync && pnpm start:dev)\n\ 3. SYNC_SERVER_URL environment variable is set correctly", response.status(), get_sync_server_url() ); } } Err(e) => { panic!( "Cannot connect to sync server: {}\n\ Server URL: {}\n\ Please ensure:\n\ 1. MinIO is running (docker compose up -d in donut-sync/)\n\ 2. donut-sync server is running (cd donut-sync && pnpm start:dev)\n\ 3. SYNC_SERVER_URL environment variable is set correctly\n\ 4. Network connectivity is available", e, get_sync_server_url() ); } } } struct TestClient { client: Client, base_url: String, token: String, } impl TestClient { fn new() -> Self { Self { client: Client::new(), base_url: get_sync_server_url(), token: TEST_TOKEN.to_string(), } } fn url(&self, path: &str) -> String { format!("{}/v1/objects/{}", self.base_url, path) } async fn stat(&self, key: &str) -> Result> { let response = self .client .post(self.url("stat")) .header("Authorization", format!("Bearer {}", self.token)) .json(&json!({ "key": key })) .send() .await?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(format!("stat failed with status {status}: {body}").into()); } Ok(response.json().await?) } async fn presign_upload( &self, key: &str, content_type: &str, ) -> Result> { let response = self .client .post(self.url("presign-upload")) .header("Authorization", format!("Bearer {}", self.token)) .json(&json!({ "key": key, "contentType": content_type })) .send() .await?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(format!("presign-upload failed with status {status}: {body}").into()); } Ok(response.json().await?) } async fn presign_download( &self, key: &str, ) -> Result> { let response = self .client .post(self.url("presign-download")) .header("Authorization", format!("Bearer {}", self.token)) .json(&json!({ "key": key })) .send() .await?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(format!("presign-download failed with status {status}: {body}").into()); } Ok(response.json().await?) } async fn delete( &self, key: &str, tombstone_key: Option<&str>, ) -> Result> { let mut body = json!({ "key": key }); if let Some(tk) = tombstone_key { body["tombstoneKey"] = json!(tk); body["deletedAt"] = json!(chrono::Utc::now().to_rfc3339()); } let response = self .client .post(self.url("delete")) .header("Authorization", format!("Bearer {}", self.token)) .json(&body) .send() .await?; let status = response.status(); if !status.is_success() { let body_text = response.text().await.unwrap_or_default(); return Err(format!("delete failed with status {status}: {body_text}").into()); } Ok(response.json().await?) } async fn upload_bytes( &self, url: &str, data: &[u8], content_type: &str, ) -> Result<(), Box> { let response = self .client .put(url) .header("Content-Type", content_type) .body(data.to_vec()) .send() .await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Upload failed with status {status}: {body}").into()); } Ok(()) } async fn download_bytes(&self, url: &str) -> Result, Box> { let response = self.client.get(url).send().await?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(format!("Download failed with status {status}: {body}").into()); } Ok(response.bytes().await?.to_vec()) } } fn create_test_profile_bundle(temp_dir: &Path) -> Vec { use flate2::write::GzEncoder; use flate2::Compression; use tar::Builder; let metadata = json!({ "id": "test-profile-id", "name": "Test Profile", "browser": "chromium", "version": "120.0.0", "release_type": "stable", "sync_enabled": true, "tags": ["test", "e2e"], "note": "Test profile for e2e" }); let profile_dir = temp_dir.join("profile"); fs::create_dir_all(&profile_dir).unwrap(); fs::write(profile_dir.join("test_file.txt"), "test content").unwrap(); let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); { let mut tar = Builder::new(&mut encoder); let metadata_json = serde_json::to_string_pretty(&metadata).unwrap(); let mut header = tar::Header::new_gnu(); header.set_size(metadata_json.len() as u64); header.set_mode(0o644); header.set_cksum(); tar .append_data(&mut header, "metadata.json", metadata_json.as_bytes()) .unwrap(); tar.append_dir_all("profile", &profile_dir).unwrap(); tar.finish().unwrap(); } encoder.finish().unwrap() } fn create_test_profile_bundle_with_bypass_rules(temp_dir: &Path, bypass_rules: &[&str]) -> Vec { use flate2::write::GzEncoder; use flate2::Compression; use tar::Builder; let metadata = json!({ "id": "test-bypass-profile-id", "name": "Bypass Rules Profile", "browser": "camoufox", "version": "120.0.0", "release_type": "stable", "sync_enabled": true, "tags": [], "proxy_bypass_rules": bypass_rules }); let profile_dir = temp_dir.join("bypass_profile"); fs::create_dir_all(&profile_dir).unwrap(); fs::write(profile_dir.join("test_file.txt"), "bypass test content").unwrap(); let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); { let mut tar = Builder::new(&mut encoder); let metadata_json = serde_json::to_string_pretty(&metadata).unwrap(); let mut header = tar::Header::new_gnu(); header.set_size(metadata_json.len() as u64); header.set_mode(0o644); header.set_cksum(); tar .append_data(&mut header, "metadata.json", metadata_json.as_bytes()) .unwrap(); tar.append_dir_all("profile", &profile_dir).unwrap(); tar.finish().unwrap(); } encoder.finish().unwrap() } fn extract_bundle(data: &[u8], target_dir: &Path) -> serde_json::Value { use flate2::read::GzDecoder; use tar::Archive; let decoder = GzDecoder::new(data); let mut archive = Archive::new(decoder); archive.unpack(target_dir).unwrap(); let metadata_path = target_dir.join("metadata.json"); let metadata_content = fs::read_to_string(metadata_path).unwrap(); serde_json::from_str(&metadata_content).unwrap() } #[tokio::test] async fn test_sync_server_health() { ensure_sync_server_available().await; let client = Client::new(); let url = format!("{}/health", get_sync_server_url()); let response = client.get(&url).send().await.unwrap(); assert!(response.status().is_success()); } #[tokio::test] async fn test_stat_nonexistent_key() { ensure_sync_server_available().await; let client = TestClient::new(); let result = client.stat("nonexistent-key").await.unwrap(); assert!(!result.exists); } #[tokio::test] async fn test_upload_download_cycle() { ensure_sync_server_available().await; let client = TestClient::new(); let test_key = format!("test/e2e-rust-{}.txt", uuid::Uuid::new_v4()); let test_content = b"Hello from Rust e2e test!"; let presign = client .presign_upload(&test_key, "text/plain") .await .unwrap(); client .upload_bytes(&presign.url, test_content, "text/plain") .await .unwrap(); let stat = client.stat(&test_key).await.unwrap(); assert!(stat.exists); assert_eq!(stat.size, Some(test_content.len() as u64)); let download_presign = client.presign_download(&test_key).await.unwrap(); let downloaded = client.download_bytes(&download_presign.url).await.unwrap(); assert_eq!(downloaded, test_content); let delete_result = client.delete(&test_key, None).await.unwrap(); assert!(delete_result.deleted); let final_stat = client.stat(&test_key).await.unwrap(); assert!(!final_stat.exists); } #[tokio::test] async fn test_profile_bundle_upload_download() { ensure_sync_server_available().await; let client = TestClient::new(); let temp_dir = TempDir::new().unwrap(); let profile_id = uuid::Uuid::new_v4().to_string(); let test_key = format!("profiles/{}.tar.gz", profile_id); let bundle = create_test_profile_bundle(temp_dir.path()); let presign = client .presign_upload(&test_key, "application/gzip") .await .unwrap(); client .upload_bytes(&presign.url, &bundle, "application/gzip") .await .unwrap(); let stat = client.stat(&test_key).await.unwrap(); assert!(stat.exists); let download_presign = client.presign_download(&test_key).await.unwrap(); let downloaded = client.download_bytes(&download_presign.url).await.unwrap(); assert_eq!(downloaded.len(), bundle.len()); let extract_dir = temp_dir.path().join("extracted"); fs::create_dir_all(&extract_dir).unwrap(); let metadata = extract_bundle(&downloaded, &extract_dir); assert_eq!(metadata["name"], "Test Profile"); assert_eq!(metadata["browser"], "chromium"); assert!(metadata["sync_enabled"].as_bool().unwrap()); let test_file = extract_dir.join("profile").join("test_file.txt"); assert!(test_file.exists()); let content = fs::read_to_string(test_file).unwrap(); assert_eq!(content, "test content"); client.delete(&test_key, None).await.unwrap(); } #[tokio::test] async fn test_tombstone_creation() { ensure_sync_server_available().await; let client = TestClient::new(); let test_key = format!("test/tombstone-test-{}.txt", uuid::Uuid::new_v4()); let tombstone_key = format!("tombstones/{}", test_key.replace("test/", "")); let presign = client .presign_upload(&test_key, "text/plain") .await .unwrap(); client .upload_bytes(&presign.url, b"to be deleted", "text/plain") .await .unwrap(); let delete_result = client .delete(&test_key, Some(&tombstone_key)) .await .unwrap(); assert!(delete_result.deleted); assert!(delete_result.tombstone_created); let tombstone_stat = client.stat(&tombstone_key).await.unwrap(); assert!(tombstone_stat.exists); client.delete(&tombstone_key, None).await.unwrap(); } #[tokio::test] async fn test_device_a_to_device_b_sync() { ensure_sync_server_available().await; let client = TestClient::new(); let temp_dir_a = TempDir::new().unwrap(); let temp_dir_b = TempDir::new().unwrap(); let profile_id = uuid::Uuid::new_v4().to_string(); let test_key = format!("profiles/{}.tar.gz", profile_id); let bundle_a = create_test_profile_bundle(temp_dir_a.path()); let presign = client .presign_upload(&test_key, "application/gzip") .await .unwrap(); client .upload_bytes(&presign.url, &bundle_a, "application/gzip") .await .unwrap(); let download_presign = client.presign_download(&test_key).await.unwrap(); let downloaded_b = client.download_bytes(&download_presign.url).await.unwrap(); let extract_dir_b = temp_dir_b.path().join("extracted"); fs::create_dir_all(&extract_dir_b).unwrap(); let metadata_b = extract_bundle(&downloaded_b, &extract_dir_b); assert_eq!(metadata_b["name"], "Test Profile"); assert_eq!(metadata_b["browser"], "chromium"); let test_file_b = extract_dir_b.join("profile").join("test_file.txt"); assert!(test_file_b.exists()); let content_b = fs::read_to_string(test_file_b).unwrap(); assert_eq!(content_b, "test content"); client.delete(&test_key, None).await.unwrap(); } #[tokio::test] async fn test_proxy_sync() { ensure_sync_server_available().await; let client = TestClient::new(); let proxy_id = uuid::Uuid::new_v4().to_string(); let test_key = format!("proxies/{}.json", proxy_id); let proxy_data = json!({ "id": proxy_id, "name": "Test Proxy", "proxy_settings": { "proxy_type": "http", "host": "proxy.example.com", "port": 8080, "username": "user", "password": "pass" } }); let proxy_json = serde_json::to_string(&proxy_data).unwrap(); let presign = client .presign_upload(&test_key, "application/json") .await .unwrap(); client .upload_bytes(&presign.url, proxy_json.as_bytes(), "application/json") .await .unwrap(); let stat = client.stat(&test_key).await.unwrap(); assert!(stat.exists); let download_presign = client.presign_download(&test_key).await.unwrap(); let downloaded = client.download_bytes(&download_presign.url).await.unwrap(); let downloaded_proxy: serde_json::Value = serde_json::from_slice(&downloaded).unwrap(); assert_eq!(downloaded_proxy["name"], "Test Proxy"); assert_eq!( downloaded_proxy["proxy_settings"]["host"], "proxy.example.com" ); client.delete(&test_key, None).await.unwrap(); } #[tokio::test] async fn test_group_sync() { ensure_sync_server_available().await; let client = TestClient::new(); let group_id = uuid::Uuid::new_v4().to_string(); let test_key = format!("groups/{}.json", group_id); let group_data = json!({ "id": group_id, "name": "Test Group" }); let group_json = serde_json::to_string(&group_data).unwrap(); let presign = client .presign_upload(&test_key, "application/json") .await .unwrap(); client .upload_bytes(&presign.url, group_json.as_bytes(), "application/json") .await .unwrap(); let stat = client.stat(&test_key).await.unwrap(); assert!(stat.exists); let download_presign = client.presign_download(&test_key).await.unwrap(); let downloaded = client.download_bytes(&download_presign.url).await.unwrap(); let downloaded_group: serde_json::Value = serde_json::from_slice(&downloaded).unwrap(); assert_eq!(downloaded_group["name"], "Test Group"); client.delete(&test_key, None).await.unwrap(); } #[tokio::test] async fn test_batch_presign_upload() { ensure_sync_server_available().await; let client = TestClient::new(); let profile_id = uuid::Uuid::new_v4().to_string(); let items = vec![ json!({ "key": format!("profiles/{}/files/file1.txt", profile_id), "contentType": "text/plain" }), json!({ "key": format!("profiles/{}/files/file2.txt", profile_id), "contentType": "text/plain" }), json!({ "key": format!("profiles/{}/files/subdir/file3.txt", profile_id), "contentType": "text/plain" }), ]; let response = client .client .post(client.url("presign-upload-batch")) .header("Authorization", format!("Bearer {}", client.token)) .json(&json!({ "items": items })) .send() .await .unwrap(); assert!(response.status().is_success()); let result: serde_json::Value = response.json().await.unwrap(); let items_result = result["items"].as_array().unwrap(); assert_eq!(items_result.len(), 3); for item in items_result { assert!(item["url"].as_str().is_some()); assert!(item["key"].as_str().is_some()); } } #[tokio::test] async fn test_batch_presign_download() { ensure_sync_server_available().await; let client = TestClient::new(); let profile_id = uuid::Uuid::new_v4().to_string(); // First upload some files let file_keys = vec![ format!("profiles/{}/files/file1.txt", profile_id), format!("profiles/{}/files/file2.txt", profile_id), ]; for key in &file_keys { let presign = client.presign_upload(key, "text/plain").await.unwrap(); client .upload_bytes(&presign.url, b"test content", "text/plain") .await .unwrap(); } // Now test batch download presign let response = client .client .post(client.url("presign-download-batch")) .header("Authorization", format!("Bearer {}", client.token)) .json(&json!({ "keys": file_keys })) .send() .await .unwrap(); assert!(response.status().is_success()); let result: serde_json::Value = response.json().await.unwrap(); let items_result = result["items"].as_array().unwrap(); assert_eq!(items_result.len(), 2); for item in items_result { assert!(item["url"].as_str().is_some()); assert!(item["key"].as_str().is_some()); } // Cleanup for key in &file_keys { client.delete(key, None).await.unwrap(); } } #[tokio::test] async fn test_delete_prefix() { ensure_sync_server_available().await; let client = TestClient::new(); let profile_id = uuid::Uuid::new_v4().to_string(); let prefix = format!("profiles/{}/", profile_id); // Upload multiple files under the profile prefix let file_keys = vec![ format!("profiles/{}/manifest.json", profile_id), format!("profiles/{}/metadata.json", profile_id), format!("profiles/{}/files/file1.txt", profile_id), format!("profiles/{}/files/subdir/file2.txt", profile_id), ]; for key in &file_keys { let content_type = if key.ends_with(".json") { "application/json" } else { "text/plain" }; let presign = client.presign_upload(key, content_type).await.unwrap(); client .upload_bytes(&presign.url, b"test content", content_type) .await .unwrap(); } // Verify all files exist for key in &file_keys { let stat = client.stat(key).await.unwrap(); assert!(stat.exists, "File should exist before delete: {}", key); } // Delete with prefix let tombstone_key = format!("tombstones/profiles/{}.json", profile_id); let response = client .client .post(client.url("delete-prefix")) .header("Authorization", format!("Bearer {}", client.token)) .json(&json!({ "prefix": prefix, "tombstoneKey": tombstone_key })) .send() .await .unwrap(); assert!(response.status().is_success()); let result: serde_json::Value = response.json().await.unwrap(); assert_eq!(result["deletedCount"].as_u64().unwrap(), 4); assert!(result["tombstoneCreated"].as_bool().unwrap()); // Verify all files are deleted for key in &file_keys { let stat = client.stat(key).await.unwrap(); assert!( !stat.exists, "File should be deleted after delete-prefix: {}", key ); } // Verify tombstone exists let tombstone_stat = client.stat(&tombstone_key).await.unwrap(); assert!(tombstone_stat.exists, "Tombstone should exist"); // Cleanup tombstone client.delete(&tombstone_key, None).await.unwrap(); } #[tokio::test] async fn test_delta_sync_only_changed_files() { ensure_sync_server_available().await; let client = TestClient::new(); let profile_id = uuid::Uuid::new_v4().to_string(); // Simulate initial upload of 3 files let file1_key = format!("profiles/{}/files/file1.txt", profile_id); let file2_key = format!("profiles/{}/files/file2.txt", profile_id); let file3_key = format!("profiles/{}/files/file3.txt", profile_id); let presign1 = client .presign_upload(&file1_key, "text/plain") .await .unwrap(); client .upload_bytes(&presign1.url, b"content1", "text/plain") .await .unwrap(); let presign2 = client .presign_upload(&file2_key, "text/plain") .await .unwrap(); client .upload_bytes(&presign2.url, b"content2", "text/plain") .await .unwrap(); let presign3 = client .presign_upload(&file3_key, "text/plain") .await .unwrap(); client .upload_bytes(&presign3.url, b"content3", "text/plain") .await .unwrap(); // Get initial stats let stat1_before = client.stat(&file1_key).await.unwrap(); let stat2_before = client.stat(&file2_key).await.unwrap(); let stat3_before = client.stat(&file3_key).await.unwrap(); // Wait a moment for timestamp differentiation tokio::time::sleep(std::time::Duration::from_secs(1)).await; // Simulate delta sync: only update file2 let presign2_update = client .presign_upload(&file2_key, "text/plain") .await .unwrap(); client .upload_bytes(&presign2_update.url, b"content2-updated", "text/plain") .await .unwrap(); // Check that file2's metadata changed let stat2_after = client.stat(&file2_key).await.unwrap(); assert_ne!( stat2_before.size, stat2_after.size, "File2 size should have changed" ); // Verify file1 and file3 are unchanged (same size) let stat1_after = client.stat(&file1_key).await.unwrap(); let stat3_after = client.stat(&file3_key).await.unwrap(); assert_eq!( stat1_before.size, stat1_after.size, "File1 should be unchanged" ); assert_eq!( stat3_before.size, stat3_after.size, "File3 should be unchanged" ); // Cleanup client.delete(&file1_key, None).await.unwrap(); client.delete(&file2_key, None).await.unwrap(); client.delete(&file3_key, None).await.unwrap(); } #[tokio::test] async fn test_profile_bypass_rules_sync() { ensure_sync_server_available().await; let client = TestClient::new(); let temp_dir = TempDir::new().unwrap(); let profile_id = uuid::Uuid::new_v4().to_string(); let test_key = format!("profiles/{}.tar.gz", profile_id); let bypass_rules = vec!["example.com", "192.168.1.0/24", ".*\\.internal\\.net"]; let bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &bypass_rules); let presign = client .presign_upload(&test_key, "application/gzip") .await .unwrap(); client .upload_bytes(&presign.url, &bundle, "application/gzip") .await .unwrap(); let stat = client.stat(&test_key).await.unwrap(); assert!(stat.exists); // Download and verify bypass rules survive the round-trip let download_presign = client.presign_download(&test_key).await.unwrap(); let downloaded = client.download_bytes(&download_presign.url).await.unwrap(); assert_eq!(downloaded.len(), bundle.len()); let extract_dir = temp_dir.path().join("extracted"); fs::create_dir_all(&extract_dir).unwrap(); let metadata = extract_bundle(&downloaded, &extract_dir); assert_eq!(metadata["name"], "Bypass Rules Profile"); assert_eq!(metadata["browser"], "camoufox"); let synced_rules = metadata["proxy_bypass_rules"] .as_array() .expect("proxy_bypass_rules should be an array"); assert_eq!(synced_rules.len(), 3); assert_eq!(synced_rules[0], "example.com"); assert_eq!(synced_rules[1], "192.168.1.0/24"); assert_eq!(synced_rules[2], ".*\\.internal\\.net"); // Also verify empty bypass rules are handled correctly let empty_bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &[]); let empty_key = format!("profiles/{}.tar.gz", uuid::Uuid::new_v4()); let presign2 = client .presign_upload(&empty_key, "application/gzip") .await .unwrap(); client .upload_bytes(&presign2.url, &empty_bundle, "application/gzip") .await .unwrap(); let download_presign2 = client.presign_download(&empty_key).await.unwrap(); let downloaded2 = client.download_bytes(&download_presign2.url).await.unwrap(); let extract_dir2 = temp_dir.path().join("extracted2"); fs::create_dir_all(&extract_dir2).unwrap(); let metadata2 = extract_bundle(&downloaded2, &extract_dir2); let empty_rules = metadata2["proxy_bypass_rules"] .as_array() .expect("proxy_bypass_rules should be an array"); assert!(empty_rules.is_empty()); // Cleanup client.delete(&test_key, None).await.unwrap(); client.delete(&empty_key, None).await.unwrap(); } #[tokio::test] async fn test_encrypted_profile_sync() { use donutbrowser_lib::sync::encryption::{ decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt, }; ensure_sync_server_available().await; let client = TestClient::new(); let temp_dir = TempDir::new().unwrap(); let profile_id = uuid::Uuid::new_v4().to_string(); let test_key = format!("profiles/{}.tar.gz.enc", profile_id); let bundle = create_test_profile_bundle(temp_dir.path()); let salt = generate_salt(); let password = "test-e2e-encryption-password"; let key = derive_profile_key(password, &salt).unwrap(); let encrypted = encrypt_bytes(&key, &bundle).unwrap(); assert_ne!( encrypted, bundle, "Encrypted data should differ from plaintext" ); assert!( encrypted.len() > bundle.len(), "Encrypted data includes nonce + auth tag overhead" ); let presign = client .presign_upload(&test_key, "application/octet-stream") .await .unwrap(); client .upload_bytes(&presign.url, &encrypted, "application/octet-stream") .await .unwrap(); let stat = client.stat(&test_key).await.unwrap(); assert!(stat.exists); assert_eq!(stat.size, Some(encrypted.len() as u64)); let download_presign = client.presign_download(&test_key).await.unwrap(); let downloaded = client.download_bytes(&download_presign.url).await.unwrap(); assert_eq!(downloaded.len(), encrypted.len()); let decrypted = decrypt_bytes(&key, &downloaded).unwrap(); assert_eq!( decrypted, bundle, "Decrypted content should match original bundle" ); let extract_dir = temp_dir.path().join("extracted"); fs::create_dir_all(&extract_dir).unwrap(); let metadata = extract_bundle(&decrypted, &extract_dir); assert_eq!(metadata["id"], "test-profile-id"); assert_eq!(metadata["name"], "Test Profile"); assert_eq!(metadata["browser"], "chromium"); assert_eq!(metadata["version"], "120.0.0"); assert!(metadata["sync_enabled"].as_bool().unwrap()); let tags = metadata["tags"].as_array().unwrap(); assert_eq!(tags.len(), 2); assert_eq!(tags[0], "test"); assert_eq!(tags[1], "e2e"); let test_file = extract_dir.join("profile").join("test_file.txt"); assert!(test_file.exists()); assert_eq!(fs::read_to_string(test_file).unwrap(), "test content"); let wrong_key = derive_profile_key("wrong-password", &salt).unwrap(); assert!( decrypt_bytes(&wrong_key, &downloaded).is_err(), "Decryption with wrong key should fail" ); let different_salt = generate_salt(); let wrong_salt_key = derive_profile_key(password, &different_salt).unwrap(); assert!( decrypt_bytes(&wrong_salt_key, &downloaded).is_err(), "Decryption with key derived from wrong salt should fail" ); client.delete(&test_key, None).await.unwrap(); let final_stat = client.stat(&test_key).await.unwrap(); assert!(!final_stat.exists); } #[tokio::test] async fn test_encrypted_delta_sync() { use donutbrowser_lib::sync::encryption::{ decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt, }; ensure_sync_server_available().await; let client = TestClient::new(); let profile_id = uuid::Uuid::new_v4().to_string(); let salt = generate_salt(); let password = "delta-sync-test-password"; let key = derive_profile_key(password, &salt).unwrap(); let file1_key = format!("profiles/{}/files/file1.txt.enc", profile_id); let file2_key = format!("profiles/{}/files/file2.txt.enc", profile_id); let file3_key = format!("profiles/{}/files/file3.txt.enc", profile_id); let content1 = b"file one content"; let content2 = b"file two content"; let content3 = b"file three content"; let encrypted1 = encrypt_bytes(&key, content1).unwrap(); let encrypted2 = encrypt_bytes(&key, content2).unwrap(); let encrypted3 = encrypt_bytes(&key, content3).unwrap(); let presign1 = client .presign_upload(&file1_key, "application/octet-stream") .await .unwrap(); client .upload_bytes(&presign1.url, &encrypted1, "application/octet-stream") .await .unwrap(); let presign2 = client .presign_upload(&file2_key, "application/octet-stream") .await .unwrap(); client .upload_bytes(&presign2.url, &encrypted2, "application/octet-stream") .await .unwrap(); let presign3 = client .presign_upload(&file3_key, "application/octet-stream") .await .unwrap(); client .upload_bytes(&presign3.url, &encrypted3, "application/octet-stream") .await .unwrap(); for (file_key, expected_content) in [ (&file1_key, content1.as_slice()), (&file2_key, content2.as_slice()), (&file3_key, content3.as_slice()), ] { let dl_presign = client.presign_download(file_key).await.unwrap(); let downloaded = client.download_bytes(&dl_presign.url).await.unwrap(); let decrypted = decrypt_bytes(&key, &downloaded).unwrap(); assert_eq!( decrypted, expected_content, "Decrypted content mismatch for {file_key}" ); } let stat1_before = client.stat(&file1_key).await.unwrap(); let stat2_before = client.stat(&file2_key).await.unwrap(); let stat3_before = client.stat(&file3_key).await.unwrap(); tokio::time::sleep(std::time::Duration::from_secs(1)).await; let updated_content2 = b"file two content -- updated with new data"; let encrypted2_updated = encrypt_bytes(&key, updated_content2).unwrap(); let presign2_update = client .presign_upload(&file2_key, "application/octet-stream") .await .unwrap(); client .upload_bytes( &presign2_update.url, &encrypted2_updated, "application/octet-stream", ) .await .unwrap(); let stat2_after = client.stat(&file2_key).await.unwrap(); assert_ne!( stat2_before.size, stat2_after.size, "File2 size should have changed after update" ); let stat1_after = client.stat(&file1_key).await.unwrap(); let stat3_after = client.stat(&file3_key).await.unwrap(); assert_eq!( stat1_before.size, stat1_after.size, "File1 should be unchanged" ); assert_eq!( stat3_before.size, stat3_after.size, "File3 should be unchanged" ); let dl_presign2 = client.presign_download(&file2_key).await.unwrap(); let downloaded2 = client.download_bytes(&dl_presign2.url).await.unwrap(); let decrypted2 = decrypt_bytes(&key, &downloaded2).unwrap(); assert_eq!( decrypted2, updated_content2.to_vec(), "Updated file2 should decrypt to new content" ); client.delete(&file1_key, None).await.unwrap(); client.delete(&file2_key, None).await.unwrap(); client.delete(&file3_key, None).await.unwrap(); }